import festim
from festim.h_transport_problem import HTransportProblem
from fenics import *
import numpy as np
import sympy as sp
import warnings
[docs]
class Simulation:
"""
Main festim class representing a festim model
Args:
mesh (festim.Mesh, optional): The mesh of the model. Defaults to
None.
materials (festim.Materials or list or festim.Material, optional):
The model materials. Defaults to None.
sources (list of festim.Source, optional): Volumetric sources
(particle or heat sources). Defaults to [].
boundary_conditions (list of festim.BoundaryCondition, optional):
The model's boundary conditions (temperature of H
concentration). Defaults to None.
traps (festim.Traps or list or festim.Trap, optional): The model's traps. Defaults
to None.
dt (festim.Stepsize, optional): The model's stepsize. Defaults to
None.
settings (festim.Settings, optional): The model's settings.
Defaults to None.
temperature (int, float, sympy.Expr, festim.Temperature, optional): The model's
temperature. Can be an expression or a heat transfer model.
Defaults to None.
initial_conditions (list of festim.InitialCondition, optional):
The model's initial conditions (H or T). Defaults to [].
exports (festim.Exports or list or festim.Export, optional): The model's exports
(derived quantities, XDMF exports, txt exports...). Defaults
to None.
log_level (int, optional): set what kind of FEniCS messsages are
displayed. Defaults to 40.
CRITICAL = 50, errors that may lead to data corruption
ERROR = 40, errors
WARNING = 30, warnings
INFO = 20, information of general interest
PROGRESS = 16, what's happening (broadly)
TRACE = 13, what's happening (in detail)
DBG = 10 sundry
Attributes:
log_level (int): set what kind of FEniCS messsages are
displayed.
CRITICAL = 50, errors that may lead to data corruption
ERROR = 40, errors
WARNING = 30, warnings
INFO = 20, information of general interest
PROGRESS = 16, what's happening (broadly)
TRACE = 13, what's happening (in detail)
DBG = 10 sundry
settings (festim.Settings): The model's settings.
dt (festim.Stepsize): The model's stepsize.
traps (festim.Traps): The model's traps.
materials (festim.Materials): The model materials.
boundary_conditions (list of festim.BoundaryCondition):
The model's boundary conditions (temperature of H
concentration).
initial_conditions (list of festim.InitialCondition):
The model's initial conditions (H or T).
T (festim.Temperature): The model's temperature.
exports (festim.Exports): The model's exports
(derived quantities, XDMF exports, txt exports...).
mesh (festim.Mesh): The mesh of the model.
sources (list of festim.Source): Volumetric sources
(particle or heat sources).
mobile (festim.Mobile): the mobile concentration (c_m or theta)
t (fenics.Constant): the current time of simulation
timer (fenics.timer): the elapsed time of simulation
"""
def __init__(
self,
mesh=None,
materials=None,
sources=[],
boundary_conditions=[],
traps=None,
dt=None,
settings=None,
temperature=None,
initial_conditions=[],
exports=None,
log_level=40,
):
self.log_level = log_level
self.settings = settings
self.dt = dt
self.traps = traps
self.materials = materials
self.boundary_conditions = boundary_conditions
self.initial_conditions = initial_conditions
self.T = temperature
self.exports = exports
self.mesh = mesh
self.sources = sources
# internal attributes
self.h_transport_problem = None
self.t = 0 # Initialising time to 0s
self.timer = None
@property
def traps(self):
return self._traps
@traps.setter
def traps(self, value):
if value is None:
self._traps = festim.Traps([])
elif isinstance(value, festim.Traps):
self._traps = value
elif isinstance(value, list):
self._traps = festim.Traps(value)
elif isinstance(value, festim.Trap):
self._traps = festim.Traps([value])
else:
raise TypeError(
"Accepted types for traps are list, festim.Traps or festim.Trap"
)
@property
def materials(self):
return self._materials
@materials.setter
def materials(self, value):
if isinstance(value, festim.Materials):
self._materials = value
elif isinstance(value, list):
self._materials = festim.Materials(value)
elif isinstance(value, festim.Material):
self._materials = festim.Materials([value])
elif value is None:
self._materials = value
else:
raise TypeError(
"accepted types for materials are list, festim.Material or festim.Materials"
)
@property
def exports(self):
return self._exports
@exports.setter
def exports(self, value):
if value is None:
self._exports = festim.Exports([])
elif isinstance(value, (festim.Export, festim.DerivedQuantities)):
self._exports = festim.Exports([value])
elif isinstance(value, festim.Exports):
self._exports = value
elif isinstance(value, list):
self._exports = festim.Exports(value)
else:
raise TypeError(
"accepted types for exports are list, festim.DerivedQuantities, festim.Export or festim.Exports"
)
@property
def T(self):
return self._T
@T.setter
def T(self, value):
if isinstance(value, festim.Temperature):
self._T = value
elif value is None:
self._T = value
elif isinstance(value, (int, float, sp.Expr)):
self._T = festim.Temperature(value)
else:
raise TypeError(
"accepted types for T attribute are int, float, sympy.Expr or festim.Temperature"
)
@property
def dt(self):
return self._dt
@dt.setter
def dt(self, value):
if value is None:
self._dt = value
elif isinstance(value, (int, float)):
self._dt = festim.Stepsize(value)
elif isinstance(value, festim.Stepsize):
self._dt = value
else:
raise TypeError("dt must be an int, float, or festim.Stepsize")
[docs]
def attribute_source_terms(self):
"""Assigns the source terms (in self.sources) to the correct field
(self.mobile, self.T, or traps)
"""
# reinitialise sources for concentrations and temperature
self.mobile.sources = []
self.T.sources = []
for t in self.traps:
t.sources = []
# make field_to_object dict
field_to_object = {
"solute": self.mobile,
"0": self.mobile,
0: self.mobile,
"mobile": self.mobile,
"T": self.T,
}
for i, trap in enumerate(self.traps, 1):
field_to_object[i] = trap
field_to_object[str(i)] = trap
# set sources
for source in self.sources:
if source.field == "T" and not isinstance(
self.T, festim.HeatTransferProblem
): # check that there is not a source defined in T as the same time as a festim.Temperature
raise TypeError(
"Heat transfer sources can only be used with HeatTransferProblem"
)
if isinstance(source, festim.RadioactiveDecay) and source.field == "all":
# assign source to each of the unique festim.Concentration
# objects in field_to_object
for obj in set(field_to_object.values()):
if isinstance(obj, festim.Concentration):
obj.sources.append(source)
else:
field_to_object[source.field].sources.append(source)
[docs]
def check_boundary_conditions(self):
"""Runs a series of checks on the BCs and raise errors accordingly"""
valid_fields = (
["T", 0, "0"] # temperature and mobile concentration
+ [str(i + 1) for i, _ in enumerate(self.traps)]
+ [i + 1 for i, _ in enumerate(self.traps)]
)
# collect all DirichletBCs and SurfaceKinetics objects
dc_sk_bcs = [
bc
for bc in self.boundary_conditions
if isinstance(bc, (festim.DirichletBC, festim.SurfaceKinetics))
]
for bc in self.boundary_conditions:
if bc.field not in valid_fields:
raise ValueError(f"{bc.field} is not a valid field for BC")
# check SurfaceKinetics in 1D simulations
if (
isinstance(bc, festim.SurfaceKinetics)
and self.mesh.mesh.topology().dim() != 1
):
raise ValueError("SurfaceKinetics can only be used in 1D simulations")
# check that there is not a Temperature defined at the same time as a boundary condition in T
if bc.field == "T" and not isinstance(self.T, festim.HeatTransferProblem):
raise TypeError(
"Heat transfer boundary conditions can only be used with HeatTransferProblem"
)
# checks that DirichletBC or SurfaceKinetics is not set with another bc on the same surface
# iterate through all BCs
for dc_sk_bc in dc_sk_bcs:
if (
bc == dc_sk_bc or bc.field != dc_sk_bc.field
): # skip if the same BC or different fields
continue
# check if BCs share the same surfaces using the set().isdisjoint() method
# that returns True if the first set has no elements in common with other containers
if not set(bc.surfaces).isdisjoint(dc_sk_bc.surfaces):
# convert lists of surfaces to sets and obtain their intersection
intersection = set(bc.surfaces) & set(dc_sk_bc.surfaces)
# check the bc type for the export message
bc_type = (
"DirichletBC"
if isinstance(dc_sk_bc, festim.DirichletBC)
else "SurfaceKinetics"
)
msg = f"{bc_type} is simultaneously set with another boundary condition "
msg += f"on surfaces {intersection} for field {dc_sk_bc.field}"
raise ValueError(msg)
[docs]
def check_mesh_dim_coords(self):
"""Checks if the used coordinates can be applied for geometry with the specified dimensions"""
if self.mesh.type == "spherical" and self.mesh.mesh.topology().dim() != 1:
raise AttributeError(
"spherical coordinates can be used for one-dimensional domains only"
)
if self.mesh.type == "cylindrical" and self.mesh.mesh.topology().dim() > 2:
raise AttributeError(
"cylindrical coordinates cannot be used for 3D domains"
)
[docs]
def attribute_boundary_conditions(self):
"""Assigns boundary_conditions to mobile and T"""
self.T.boundary_conditions = []
self.h_transport_problem.boundary_conditions = []
self.check_boundary_conditions()
for bc in self.boundary_conditions:
if bc.field == "T":
self.T.boundary_conditions.append(bc)
else:
self.h_transport_problem.boundary_conditions.append(bc)
[docs]
def initialise(self):
"""Initialise the model. Defines markers, create the suitable function
spaces, the functions, the variational forms...
"""
set_log_level(self.log_level)
self.t = 0 # reinitialise t to zero
if self.settings.chemical_pot:
self.mobile = festim.Theta()
else:
self.mobile = festim.Mobile()
# check that dt attribute is None if the sim is steady state
if not self.settings.transient and self.dt is not None:
raise AttributeError("dt must be None in steady state simulations")
if self.settings.transient and self.settings.final_time is None:
raise AttributeError(
"final_time argument must be provided to settings in transient simulations"
)
if self.settings.transient and self.dt is None:
raise AttributeError("dt must be provided in transient simulations")
if not self.T:
raise AttributeError("Temperature is not defined")
# initialise dt
if self.settings.transient:
self.dt.initialise_value()
self.h_transport_problem = HTransportProblem(
self.mobile, self.traps, self.T, self.settings, self.initial_conditions
)
self.attribute_source_terms()
self.attribute_boundary_conditions()
self.check_mesh_dim_coords()
if isinstance(self.mesh, festim.Mesh1D):
self.mesh.define_measures(self.materials)
else:
self.mesh.define_measures()
# needed to avoid hanging behaviour in parrallel see #498
self.mesh.mesh.bounding_box_tree()
self.V_DG1 = FunctionSpace(self.mesh.mesh, "DG", 1)
self.exports.V_DG1 = self.V_DG1
# Define temperature
if isinstance(self.T, festim.HeatTransferProblem):
self.T.create_functions(self.materials, self.mesh, self.dt)
elif isinstance(self.T, festim.Temperature):
self.T.create_functions(self.mesh)
# Create functions for properties
self.materials.check_materials(
self.T, derived_quantities=[]
) # FIXME derived quantities shouldn't be []
self.materials.create_properties(self.mesh.volume_markers, self.T.T)
self.materials.create_solubility_law_markers(self.mesh)
# if the temperature is not time-dependent, solubility can be projected
if self.settings.chemical_pot:
# TODO this could be moved to Materials.create_properties()
if self.T.is_steady_state():
# self.materials.S = project(self.materials.S, self.V_DG1)
self.materials.solubility_as_function(self.mesh, self.T.T)
self.h_transport_problem.initialise(self.mesh, self.materials, self.dt)
for export in self.exports:
if isinstance(export, festim.DerivedQuantities):
for q in export:
# raise warning if the derived quantities don't match the type of mesh
# eg. SurfaceFlux is used with cylindrical mesh
if self.mesh.type not in q.allowed_meshes:
warnings.warn(
f"{type(q)} may not work as intended for {self.mesh.type} meshes"
)
if isinstance(q, festim.AdsorbedHydrogen):
# check that festim.AdsorbedHydrogen is defined together with
# festim.SurfaceKinetics on the same surface
surf_kin_present = any(
q.surface in bc.surfaces
for bc in self.boundary_conditions
if isinstance(bc, festim.SurfaceKinetics)
)
if not surf_kin_present:
raise AttributeError(
f"SurfaceKinetics boundary condition must be defined on surface {q.surface} to export data with festim.AdsorbedHydrogen"
)
# set Soret to True for SurfaceFlux quantities
if isinstance(q, festim.SurfaceFlux):
q.soret = self.settings.soret
q.T = self.T.T
if isinstance(export, festim.TXTExport):
# pre-process data depending on the chemical potential flag, trap element type,
# and material borders
project_to_DG = (
self.settings.chemical_pot
or self.settings.traps_element_type == "DG"
)
export.initialise(
self.mesh.mesh,
project_to_DG,
self.materials,
)
# needed to ensure that data is actually exported at TXTExport.times
# see issue 675
if export.times:
if not self.dt.milestones:
self.dt.milestones = []
for time in export.times:
if time not in self.dt.milestones:
msg = "To ensure that TXTExport exports data at the desired times "
msg += "TXTExport.times are added to milestones"
warnings.warn(msg)
self.dt.milestones.append(time)
self.dt.milestones.sort()
self.exports.initialise_derived_quantities(
self.mesh.dx, self.mesh.ds, self.materials
)
[docs]
def run(self, completion_tone=False):
"""Runs the model.
Args:
completion_tone (bool, optional): If True, a native os alert
tone will alert user upon completion of current run. Defaults
to False.
Returns:
dict: output containing solutions, mesh, derived quantities
"""
self.timer = Timer() # start timer
if self.settings.transient:
self.run_transient()
else:
self.run_steady()
self.timer.stop()
# End
if completion_tone:
print("\007")
def run_transient(self):
# add final_time to Exports
self.exports.final_time = self.settings.final_time
# compute Jacobian before iterating if required
if not self.settings.update_jacobian:
self.h_transport_problem.compute_jacobian()
# Time-stepping
festim.festim_print("Time stepping...")
while self.t < self.settings.final_time and not np.isclose(
self.t, self.settings.final_time, atol=0
):
self.iterate()
def run_steady(self):
# Solve steady state
festim.festim_print("Solving steady state problem...")
nb_iterations, converged = self.h_transport_problem.solve_once()
# Post processing
self.run_post_processing()
elapsed_time = round(self.timer.elapsed()[0], 1)
# print final message
if converged:
msg = "Solved problem in {:.2f} s".format(elapsed_time)
festim.festim_print(msg)
else:
msg = "The solver diverged in "
msg += "{:.0f} iteration(s) ({:.2f} s)".format(nb_iterations, elapsed_time)
raise ValueError(msg)
[docs]
def iterate(self):
"""Advance the model by one iteration"""
# Update current time
self.t += float(self.dt.value)
# update temperature
self.T.update(self.t)
# update H problem
self.h_transport_problem.update(self.t, self.dt)
# Display time
self.display_time()
# Post processing
self.run_post_processing()
# avoid t > final_time
next_time = self.t + float(self.dt.value)
if next_time > self.settings.final_time:
self.dt.value.assign(self.settings.final_time - self.t)
[docs]
def display_time(self):
"""Displays the current time"""
simulation_percentage = round(self.t / self.settings.final_time * 100, 2)
elapsed_time = round(self.timer.elapsed()[0], 1)
msg = "{:.1f} % ".format(simulation_percentage)
msg += "{:.1e} s".format(self.t)
msg += " Elapsed time so far: {:.1f} s".format(elapsed_time)
if (
not np.isclose(self.t, self.settings.final_time, atol=0)
and self.log_level == 40
):
festim.festim_print(msg, end="\r")
else:
festim.festim_print(msg)
[docs]
def run_post_processing(self):
"""Create post processing functions and compute/write the exports"""
self.update_post_processing_solutions()
self.exports.t = self.t
self.exports.write(
self.label_to_function,
self.mesh.dx,
)
[docs]
def update_post_processing_solutions(self):
"""Creates the post-processing functions by splitting self.u. Projects
the function on a suitable functionspace if needed.
Returns:
dict: a mapping of the field ("solute", "T", "retention") to its
post_processsing_solution
"""
self.h_transport_problem.update_post_processing_solutions(self.exports)
label_to_function = {
"solute": self.mobile.post_processing_solution,
"0": self.mobile.post_processing_solution,
0: self.mobile.post_processing_solution,
"T": self.T.T,
"retention": sum(
[self.mobile.post_processing_solution]
+ [trap.post_processing_solution for trap in self.traps]
),
# dictionary {"post_processing_solutions": bc.post_processing_solutions, "surfaces": bc.surfaces}
# for each SurfaceKinetics boundary condition
"adsorbed": [
{
"post_processing_solutions": bc.post_processing_solutions,
"surfaces": bc.surfaces,
}
for bc in self.boundary_conditions
if isinstance(bc, festim.SurfaceKinetics)
],
}
for trap in self.traps:
label_to_function[trap.id] = trap.post_processing_solution
label_to_function[str(trap.id)] = trap.post_processing_solution
self.label_to_function = label_to_function