def __init__(self, tsmesh, time_step_module=None, output_step_module=None, forcing_module=None, thermal_properties=None, time_initial=None): self.mesh = tsmesh self.variables = {} self.variables_store = [] self.diagnostic_modules = {} self.diagnostic_update_order = [] self.eq = None self.boundary_condition_collection = None self._time = Variable(value=0) self._time_step_module = time_step_module self._timeref = None # will generally be set by forcing_module; otherwise manually if forcing_module is not None: self.initializeForcing(forcing_module) if time_initial is not None: self.time = time_initial if thermal_properties is not None: self.initializeThermalProperties(thermal_properties) self._output_step_module = output_step_module self._output_module = SlumpOutput() if output_step_module is None: self._output_step_module = OutputStep()
def create_mesh(self): """ Create the :mod:`fipy` mesh for the domain using :attr:`cell_size` and :attr:`total_cells`. The arrays :attr:`depths` and :attr:`distances` are created, which provide the coordinates and distances of the mesh cells. """ self.logger.info( 'Creating UniformGrid1D with {} sediment and {} DBL cells of {}'. format(self.sediment_cells, self.DBL_cells, self.cell_size)) self.mesh = Grid1D( dx=self.cell_size.numericValue, nx=self.total_cells, ) self.logger.debug('Created domain mesh: {}'.format(self.mesh)) #: An array of the scaled cell distances of the mesh self.distances = Variable(value=self.mesh.scaledCellDistances[:-1], unit='m', name='distances') Z = self.mesh.x() Z = Z - Z[self.idx_surface] #: An array of the cell center coordinates, with the 0 set at the sediment surface self.depths = Variable(Z, unit='m', name='depths')
def test_snapshot_due(self): sim = Simulation() model = mock.MagicMock(MicroBenthosModel) clock = mock.MagicMock(ModelClock) clock.return_value = C = PhysicalField(3, 'h') model.clock = clock model.full_eqn = feqn = mock.Mock() sim.model = model sim._prev_snapshot = Variable(C - sim.snapshot_interval / 2.0) assert not sim.snapshot_due() sim._prev_snapshot = Variable(C - sim.snapshot_interval * 1.01) assert sim.snapshot_due()
def __init__(self, values_inp, t_inp=None, variables=None, key_time='time'): if t_inp is None: t_inp_int = values_inp[key_time] else: t_inp_int = t_inp self.t_inp = t_inp_int self._timeref = t_inp_int[0] t_inp_rel = [tj - self._timeref for tj in self.t_inp] try: self.t_inp_rel = np.array([tj.total_seconds() for tj in t_inp_rel]) except: self.t_inp_rel = np.array(t_inp_rel) if variables is None: self.variables = [vj for vj in values_inp if vj != key_time] else: self.variables = variables self.variables = { vj: Variable(value=values_inp[vj][0]) for vj in self.variables } self.values = {vj: values_inp[vj] for vj in self.variables}
def test_setup(self): proc = mock.MagicMock(spec=Process) proc.name = mock.Mock(return_value='Proc') proc.evaluate.return_value = CONDITION = object() model = mock.MagicMock(MicroBenthosModel) CLOCK_VAL = Variable(2.5, 'h') model.clock = mock.Mock(return_value=CLOCK_VAL) model.clock.copy.return_value = CLOCK_VAL expr = mock.MagicMock(Expression) expr.expr.return_value = EXPR = object() pe = ProcessEvent(expr) pe.setup(process=proc, model=model) assert pe.process == proc model.domain.create_var.assert_called_once_with(name=repr(pe), store=False, unit='s', value=model.clock) assert isinstance(pe._prev_clock, Variable) expr.expr.assert_called_once() proc.evaluate.assert_called_once_with(EXPR) assert pe.condition is CONDITION
def __init__(self, values_inp, timeref=datetime.datetime(2012, 1, 1), variables=None): if variables is None: self.variables = [vj for vj in values_inp] else: self.variables = variables self._timeref = timeref self.variables = { vj: Variable(value=values_inp[vj]) for vj in self.variables } self.values = {vj: values_inp[vj] for vj in self.variables}
def run(self, initialConditions, step_n): pinit = np.zeros( self.nx * self.ny * self.nz) #TODO: how to map from the ABM domain to this form if initialConditions == None: for i in range(self.nx * self.ny * self.nz): pinit[i] = float( decimal.Decimal(random.randrange(8, 50)) / 1000000) #ng/mm3 else: pass print("Initial values:") print(pinit) phi = CellVariable(name="Concentration (ng/ml)", mesh=self.mesh, value=pinit) t = Variable(0.) for step in range(step_n): print("Time = {}".format(t.value)) self.eq.solve(var=phi, dt=self.timeStepDuration) t.setValue(t.value + self.timeStepDuration) return phi.value
def test_add_diffusion_term_from(self, model): # input should be path, coeff eqn = ModelEquation(model, 'domain.abc', coeff=5) model.get_object.return_value = rv = mock.Mock(CellVariable) rv.as_term = rvt = mock.Mock() rvt.return_value = v = Variable(1.5) varpath = 'domain.var1' coeff = 1.1 assert eqn.term_diffusion is None assert eqn.diffusion_def is () eqn.add_diffusion_term_from(varpath, coeff) assert eqn.term_diffusion == DiffusionTerm(var=eqn.var, coeff=v * coeff) assert eqn.diffusion_def == (varpath, coeff)
class ThawSlump(object): # 1D # time_initial only works when forcing is provided def __init__(self, tsmesh, time_step_module=None, output_step_module=None, forcing_module=None, thermal_properties=None, time_initial=None): self.mesh = tsmesh self.variables = {} self.variables_store = [] self.diagnostic_modules = {} self.diagnostic_update_order = [] self.eq = None self.boundary_condition_collection = None self._time = Variable(value=0) self._time_step_module = time_step_module self._timeref = None # will generally be set by forcing_module; otherwise manually if forcing_module is not None: self.initializeForcing(forcing_module) if time_initial is not None: self.time = time_initial if thermal_properties is not None: self.initializeThermalProperties(thermal_properties) self._output_step_module = output_step_module self._output_module = SlumpOutput() if output_step_module is None: self._output_step_module = OutputStep() @property def time(self): return float(self._time.value) @time.setter def time(self, t): # can also handle date objects try: self.date = t except: self._time.setValue(t) @property def timeStep(self): return self._time_step_module.calculate(self) @property def date(self): return self._internal_time_to_date(self.time) def _internal_time_to_date(self, internal_time): return self._timeref + datetime.timedelta(seconds=internal_time) @date.setter def date(self, d): dtsec = self._date_to_internal_time(d) self._time.setValue(dtsec) def _date_to_internal_time(self, d): dt = d - self._timeref dtsec = dt.days * 24 * 3600 + dt.seconds + dt.microseconds * 1e-6 return dtsec def initializeTimeReference(self, timeref): # timeref is a datetime object self._timeref = timeref def initializePDE(self, tseq=None): self.eq = tseq def initializeTimeStepModule(self, time_step_module): self._time_step_module = time_step_module def _initializeSourcesZero(self, source_name='S'): self.variables[source_name] = CellVariable(name=source_name, mesh=self.mesh.mesh, value=0.0) def initializeDiagnostic(self, variable, funpointer, default=0.0, face_variable=False, output_variable=True): if not face_variable: self.variables[variable] = CellVariable(name=variable, mesh=self.mesh.mesh, value=default) else: self.variables[variable] = FaceVariable(name=variable, mesh=self.mesh.mesh, value=default) self.diagnostic_modules[variable] = DiagnosticModule(funpointer, self) if output_variable: self.variables_store.append(variable) self.diagnostic_update_order.append(variable) def initializeOutputStepModule(self, output_step_module): self._output_step_module = output_step_module def initializeThermalProperties(self, thermal_properties): self.thermal_properties = thermal_properties self.thermal_properties.initializeVariables(self) self.initializeTright() def initializeForcing(self, forcing_module): self.forcing_module = forcing_module for varj in self.forcing_module.variables: assert varj not in self.variables self.variables[varj] = self.forcing_module.variables[varj] self.initializeTimeReference(self.forcing_module._timeref) def initializeEnthalpyTemperature(self, T_initial, proportion_frozen=None, time=None): # time can be internal time or also a datetime object pf = 0.0 if proportion_frozen is None else proportion_frozen assert pf >= 0.0 and pf <= 1.0 self.variables['T'].setValue(T_initial) self.variables['h'].setValue( self.thermal_properties.enthalpyFromTemperature( self, T=T_initial, proportion_frozen=pf)) self.updateDiagnostics() if time is not None: self.time = time def updateDiagnostic(self, variable): self.variables[variable].setValue( self.diagnostic_modules[variable].evaluate()) def updateDiagnostics(self, variables=None): if variables is not None: variablesorder = variables else: variablesorder = self.diagnostic_update_order for variable in variablesorder: self.updateDiagnostic(variable) def specifyBoundaryConditions(self, boundary_condition_collection): self.boundary_condition_collection = boundary_condition_collection self.updateGeometryBoundaryConditions() self.invokeBoundaryConditions() self.initializePDE() def updateGeometryBoundaryConditions(self): self.boundary_condition_collection.updateGeometry(self) def updateBoundaryConditions(self, bc_data, invoke=True): self.boundary_condition_collection.update(bc_data) if invoke: self.invokeBoundaryConditions() def invokeBoundaryConditions(self): self.boundary_condition_collection.invoke(self) def updateGeometry(self): self.boundary_condition_collection.updateGeometry(self) def nextOutput(self): return self._output_step_module.next(self) def updateOutput(self, datanew={}): for v in self.variables_store: datanew[v] = np.copy(self.variables[v].value) # boundary condition outputs: # separate routine: total source, source components, or for basic b.c. just value) datanew.update(self.boundary_condition_collection.output()) self._output_module.update(self.date, datanew) def exportOutput(self, fn): self._output_module.export(fn) def addStoredVariable(self, varname): # varname can also be list if isinstance(varname, str): if varname not in self.variables_store: self.variables_store.append(varname) else: # tuple/list,etc. for varnamej in varname: self.addStoredVariable(varnamej)
eq = (TransientTerm() == DiffusionTerm(coeff= alpha* D) + ExplicitDiffusionTerm(coeff = (1.0 - alpha)*D)) # dt = 0.00018 should be stable for forward Euler with the default simulation conditions dt = 0.00018 # We might not want to see the output from every single timestep, so we define a stride parameter time_stride = 1 timestep = 0 run_time = 1 # We need to implement the analytical solution. We can do two solutions: 1) Full solution, 2) S.S. solution pi = numerix.pi x = mesh.cellCenters[0] t = Variable(0.0) # This part of the code may be a little too much, dont worry about it, essentially we are recreating the analytical solution # First we define a function that gives me an individual (nth) fourier mode: def one_fourier (n): mode = (-2.0/(n*pi))*numerix.sin(n*pi*x)*numerix.exp(-t*(pi**2)*(n**2)) return mode # Next we define a second function that gives me an truncated version of the analytical solution with k modes def analytical_expression (k): Fourier = 0 for mode in range(1, k+1): Fourier = Fourier + one_fourier(mode) Approx = 1.0 - x + Fourier return Approx
def evolution(self): """ Evolves the model clock through the time steps for the simulation, i.e. by calling :meth:`.run_timestep` and :meth:`.model.clock.increment_time` repeatedly while ``model.clock() <= self.simtime_total``. This is a generator that yields the step number, and the state of the evolution after each time step. If :meth:`snapshot_due` is true, then also the model snapshot is included in the state. Yields: `(step, state)` tuple of step number and simulation state Raises: RuntimeError: if :attr:`.started` is already True """ if self.started: raise RuntimeError( 'Simulation already started. Cannot run parallel evolutions!') self.logger.debug( 'simtime_total={o.simtime_total} simtime_step={o.simtime_step}, ' 'max_sweeps={o.max_sweeps} max_residual={o.max_residual}'.format( o=self)) self.logger.debug('Solving: {}'.format(self.model.full_eqn)) self._create_solver() self._started = True self.logger.info('Simulation evolution starting') self.model.update_vars() self._prev_snapshot = Variable(self.model.clock.copy(), name='prev_snapshot') step = 0 self.simtime_step = self.simtime_lims[0] while self.model.clock() <= self.simtime_total: self.logger.debug('Running step #{} {}'.format( step, self.model.clock)) dt = self.simtime_step tic = time.time() residual, num_sweeps = self.run_timestep() toc = time.time() self.logger.debug( f'For dt={dt} residual={residual:.4g} with sweeps={num_sweeps}' ) # if residual == 0: # raise RuntimeError(f'Residual perfect 0 for dt={dt}. Problem in domain!') self._sweepsQ.appendleft(num_sweeps) self._residualQ.appendleft(residual) if residual >= self.max_residual: self.logger.info( f'Ignoring dt={dt}: res={residual:.4g} > {self.max_residual:.4g}' ) self.update_simtime_step(residual, num_sweeps) self.model.revert_vars() # just go back to while loop start, now that dt has been made smaller continue else: self.model.update_vars() self.model.update_equations(dt) step += 1 self.update_simtime_step(residual, num_sweeps) calc_time = 1000 * (toc - tic) self.logger.debug('Time step {} done in {:.2f} msec'.format( self.simtime_step, calc_time)) if self.snapshot_due(): self.logger.debug('Snapshot in step #{}'.format(step)) state = self.get_state(state=self.model.snapshot(), calc_time=calc_time, residual=residual, num_sweeps=num_sweeps) yield (step, state) # now set the prev_snapshot so that snapshot_due() will # remain true for processing self._prev_snapshot.setValue(self.model.clock.copy()) self.logger.debug('Prev snapshot set: {}'.format( self._prev_snapshot)) else: # create a minimal state # this is the model clock and current residual state = self.get_state(calc_time=calc_time, residual=residual, num_sweeps=num_sweeps) yield (step, state) self.model.clock.increment_time(dt) self.logger.info('Simulation evolution completed') self._started = False
beta = math.radians(30.0) # [bool] Wenn True, wird direkt das Gleichgewicht berechnet steady_state = True # Berechnete Werte ------------------------------------------------------------ # Anzahl der Zeitschritte intervals = int(total_time / dt) # Temperaturleitfaehigkeit in x und y Richtung in Tensor (D) zusammenfassen Dx = material["lambda_x"] / (material["cp"] * material["dichte"]) Dy = material["lambda_y"] / (material["cp"] * material["dichte"]) # D kann einfache Variable sein, da sie Ortsunabhängig ist D = Variable(value=((Dx, 0), (0, Dy))) # FiPy init ------------------------------------------------------------------- geometryTemplate = ''' cellSize = {0}; Point(1) = {{ 0, 0, 0, cellSize}}; Point(2) = {{ {1}, 0, 0, cellSize}}; Point(3) = {{ {2}, {3}, 0, cellSize}}; Point(4) = {{ {4}, {3}, 0, cellSize}}; Point(5) = {{ {4}, {5}, 0, cellSize}}; Line(10) = {{1,2}}; Line(11) = {{2,3}}; Line(12) = {{3,4}};
# coding: utf-8 from fipy import CellVariable, DiffusionTerm, Grid2D, parallelComm, TransientTerm, Variable from fipy.tools import dump, numerix import numpy as np # Numerical parameters nx = ny = 100 # domain size dx = dy = 1.0 # mesh resolution dt = Variable(0.1) # initial timestep # Physical parameters mm = 4. # anisotropic symmetry epsilon_m = 0.025 # degree of anisotropy theta_0 = 0.0 # tilt w.r.t. x-axis tau_0 = 1. # numerical mobility DD = 10. # thermal diffusivity W_0 = 1. # isotropic well height lamda = DD * tau_0 / 0.6267 / W_0**2 delta = 0.05 # undercooling # Mesh and field variables mesh = Grid2D(nx=nx, ny=ny, dx=dx, dy=dy) phase = CellVariable(mesh=mesh, hasOld=True) uu = CellVariable(mesh=mesh, hasOld=True) uu.constrain(-delta, mesh.exteriorFaces) def initialize(): phase[:] = -1.0
#%% Parameter Setup # ALPHA = 0.015 # Alpha CONSTANT C_ani = 0.02 # Component of (D) the anisotropic diffusion tensor in 2D N = 6. # Symmetry THETA = np.pi / 8 # Orientation psi = THETA + np.arctan2(phase.faceGrad[1], phase.faceGrad[0]) PHI = np.tan(N * psi / 2) PHI_SQ = PHI**2 BETA = (1. - PHI_SQ) / (1. + PHI_SQ) D_BETA_D_PSI = -N * 2 * PHI / (1 + PHI_SQ) D_DIAG = (1 + C_ani * BETA) D_OFF = C_ani * D_BETA_D_PSI I0 = Variable(value=((1, 0), (0, 1))) I1 = Variable(value=((0, -1), (1, 0))) DIF_COEF = ALPHA**2 * (1. + C_ani * BETA) * (D_DIAG * I0 + D_OFF * I1) TAU = 0.0003 KAPPA_1 = 0.9 KAPPA_2 = 20. phase_EQ = (TransientTerm(TAU) == DiffusionTerm(DIF_COEF) + ImplicitSourceTerm( (phase - 0.5 - KAPPA_1 / np.pi * np.arctan(KAPPA_2 * D_temp)) * (1 - phase))) #%% Circular Solidified Region in the Center radius = DX * 5.0 C_circ = (NX * DX / 2, NY * DY / 2) X, Y = mesh.cellCenters
def __init__(self, hours_total=24, day_fraction=0.5, channels=None, **kwargs): """ Initialize an irradiance source in the model domain Args: hours_total (int, float, PhysicalField): Number of hours in a diel period day_fraction (float): Fraction (between 0 and 1) of diel period which is illuminated (default: 0.5) channels: See :meth:`.create_channel` **kwargs: passed to superclass """ self.logger = kwargs.get('logger') or logging.getLogger(__name__) self.logger.debug('Init in {}'.format(self.__class__.__name__)) kwargs['logger'] = self.logger super(Irradiance, self).__init__(**kwargs) #: the channels in the irradiance entity self.channels = {} #: the number of hours in a diel period self.hours_total = PhysicalField(hours_total, 'h') if not (1 <= self.hours_total.value <= 48): raise ValueError('Hours total {} should be between (1, 48)'.format( self.hours_total)) # TODO: remove (1, 48) hour constraint on hours_total day_fraction = float(day_fraction) if not (0 < day_fraction < 1): raise ValueError("Day fraction should be between 0 and 1") #: fraction of diel period that is illuminated self.day_fraction = day_fraction #: numer of hours in the illuminated fraction self.hours_day = day_fraction * self.hours_total #: the time within the diel period which is the zenith of radiation self.zenith_time = self.hours_day #: the intensity level at the zenith time self.zenith_level = 100.0 C = 1.0 / numerix.sqrt(2 * numerix.pi) # to scale the cosine distribution from 0 to 1 (at zenith) self._profile = cosine(loc=self.zenith_time, scale=C**2 * self.hours_day) # This profile with loc=zenith means that the day starts at "midnight" and zenith occurs # in the center of the daylength #: a :class:`Variable` for the momentary radiance level at the surface self.surface_irrad = Variable(name='irrad_surface', value=0.0, unit=None) if channels: for chinfo in channels: self.create_channel(**chinfo) self.logger.debug('Created Irradiance: {}'.format(self))
l3 = 31.784 / 1000.0 # [rad] Winkel der aeusseren Kante alpha = math.radians(25.0) # [rad] Winkel der inneren Kante beta = math.radians(30.0) # Berechnete Werte ------------------------------------------------------------ # Temperaturleitfaehigkeit in x und y Richtung in Tensor (D) zusammenfassen Dx = material["lambda_x"] / (material["cp"] * material["dichte"]) Dy = material["lambda_y"] / (material["cp"] * material["dichte"]) Dz = material["lambda_y"] / (material["cp"] * material["dichte"]) # D als Urtsunabhängigen Tensor D = Variable(value=((Dx, 0, 0), (0, Dy, 0), (0, 0, Dz))) # FiPy init ------------------------------------------------------------------- geometryTemplate = ''' cellSize = {0}; Point(1) = {{ 0, 0, 0, cellSize}}; Point(2) = {{ {1}, 0, 0, cellSize}}; Point(3) = {{ {2}, {3}, 0, cellSize}}; Point(4) = {{ {4}, {3}, 0, cellSize}}; Point(5) = {{ {4}, {5}, 0, cellSize}}; Line(10) = {{1,2}}; Line(11) = {{2,3}}; Line(12) = {{3,4}};
class Simulation(CreateMixin): """ This class enables the process of repeatedly solving the model's equations for a (small) time step to a certain numerical accuracy, and then incrementing the model clock. During the evolution of the simulation, the state of the model as well as the simulation is yielded repeatedly. Numerically approximating the solution to a set of partial differential equations requires that the solver system has a reasonable target accuracy ("residual") and enough attempts ("sweeps") to reach both a stable and accurate approximation for a time step. This class attempts to abstract out these optimizations for the user, by performing adaptive time-stepping. The user needs to specify a worst-case residual ( :attr:`.max_residual`), maximum number of sweeps per time-step ( :attr:`.max_sweeps`) and the range of time-step values to explore during evolution (:attr:`.simtime_lims`). During the evolution of the simulation, the time-step is penalized if the max residual is overshot or max sweeps reached. If not, the reward is a bump up in the time-step duration, allowing for faster evolution of the simulation. See Also: The scheme of simulation :meth:`.evolution`. The adaptive scheme to :meth:`.update_time_step`. """ schema_key = 'simulation' FIPY_SOLVERS = ('scipy', 'pyAMG', 'trilinos', 'pysparse') def __init__( self, simtime_total=6, simtime_days=None, simtime_lims=(0.1, 120), snapshot_interval=60, fipy_solver='scipy', max_sweeps=100, max_residual=1e-12, ): """ Args: simtime_total (float, PhysicalField): The number of hours for the simulation to run simtime_days (float): The number of days (in terms of the model's irradiance cycle) the simulation should run for. Note that specifying this will override the given :attr:`.simtime_total` when the :attr:`.model` is supplied. simtime_lims (float, PhysicalField): The minimum and maximum limits for the :attr:`simtime_step` for adaptive time-stepping. This should be supplied as a pair of values, which are assumed to be in seconds and cast into PhysicalField internally. (default: 0.01, 240) max_sweeps (int): Maximum number of sweeps to attempt per timestep (default: 50) max_residual (float): Maximum residual value for the solver at a timestep (default: 1e-14) snapshot_interval (int, float, :class:`PhysicalField`): the duration in seconds of the model clock between yielding snapshots of the model state for exporters (default: 60) fipy_solver (str): Name of the fipy solver to use. One of ``('scipy', 'pyAMG', 'trilinos','pysparse')`` (default: "scipy") """ super(Simulation, self).__init__() # the __init__ call is deliberately empty. will implement # cooeperative inheritance only # when necessary self.logger = logging.getLogger(__name__) self._started = False self._solver = None self._fipy_solver = None self.fipy_solver = fipy_solver self._simtime_lims = None self._simtime_total = None self._simtime_step = None #: Numer of days to simulate in terms of the model's irradiance source self.simtime_days = None if simtime_days: simtime_days = float(simtime_days) if simtime_days <= 0: raise ValueError( 'simtime_days should be >0, not {:.2f}'.format( simtime_days)) self.simtime_days = simtime_days self.simtime_lims = simtime_lims self.simtime_total = simtime_total self.simtime_step = self.simtime_lims[0] self.snapshot_interval = PhysicalField(snapshot_interval, 's') self.max_residual = float(max_residual) if not (0 < self.max_residual < 1e-3): raise ValueError('Max residual should be a small positive number, ' 'not {:.3g}'.format(self.max_residual)) self._residualQ = deque([], maxlen=10) self._max_sweeps = None self.max_sweeps = max_sweeps self._sweepsQ = deque([], maxlen=5) self._model = None @property def started(self): """ Returns: bool: Flag for if the sim evolution has started """ return self._started @property def fipy_solver(self): return self._fipy_solver @fipy_solver.setter def fipy_solver(self, val): if val not in self.FIPY_SOLVERS: raise ValueError('Solver {!r} not in {}'.format( val, self.FIPY_SOLVERS)) if self.started: raise RuntimeError('Fipy solver cannot be changed after started') self._fipy_solver = val @property def simtime_total(self): """ The number of hours of the model clock the simulation should be evolved for. The supplied value must be larger than the time-steps allowed. Also, it may be over-ridden by supplying :attr:`.simtime_days`. Returns: PhysicalField: duration in hours """ return self._simtime_total @simtime_total.setter def simtime_total(self, val): try: val = PhysicalField(val, 'h') except TypeError: raise ValueError( 'simtime_total {!r} not compatible with time units'.format( val)) if val <= 0: raise ValueError('simtime_total should be > 0') if self.simtime_step is not None: if val <= self.simtime_lims[0]: raise ValueError('simtime_total {} should be > step {}'.format( val, self.simtime_lims[0])) self._simtime_total = val @property def simtime_step(self): """ The current time-step duration. While setting, the supplied value will be clipped to within :attr:`simtime_lims`. Returns: PhysicalField: in seconds """ return self._simtime_step @simtime_step.setter def simtime_step(self, val): try: val = PhysicalField(val, 's') except TypeError: raise ValueError( 'simtime_step {!r} not compatible with time units'.format(val)) dtMin, dtMax = self.simtime_lims # val = min(max(val, dtMin), dtMax) val = min(val, dtMax) assert hasattr(val, 'unit') if self.simtime_total is not None: if self.simtime_total <= val: raise ValueError('simtime_total {} should be > step {}'.format( self.simtime_total, val)) self._simtime_step = val @property def simtime_lims(self): """ The limits for the time-step duration allowed during evolution. This parameter determines the degree to which the simulation evolution can be speeded up. In phases of the model evolution where the numerical solution is reached within a few sweeps, the clock would run at the max limit, whereas when a large number of sweeps are required, it would be penalized towards the min limit. A high max value enables faster evolution, but can also lead to numerical inaccuracy ( higher residual) or solution breakdown (numerical error) during :meth:`.run_timestep`. A small enough min value allows recovery, but turning back the clock to the previous time step and restarting with the min timestep and allowing subsequent relaxation. Args: vals (float, PhysicalField): the (min, max) durations in seconds Returns: lims (tuple): The (min, max) limits of :attr:`simtime_step` each as a :class:`.PhysicalField` """ return self._simtime_lims @simtime_lims.setter def simtime_lims(self, vals): if vals is None: lmin = PhysicalField(0.1, 's') # lmax = (self.simtime_total / 25.0).inUnitsOf('s').floor() lmax = PhysicalField(120, 's') else: lmin, lmax = [PhysicalField(float(_), 's') for _ in vals] if not (0 < lmin < lmax): raise ValueError( 'simtime_lims ({}, {}) are not positive and in order'.format( lmin, lmax)) self._simtime_lims = (lmin, lmax) self.logger.debug('simtime_lims set: {}'.format(self._simtime_lims)) @property def max_sweeps(self): """ The maximum number of sweeps allowed for a timestep Args: val (int): should be > 0 Returns: int """ return self._max_sweeps @max_sweeps.setter def max_sweeps(self, val): try: val = int(val) assert val > 0 self._max_sweeps = val except: raise ValueError('max_sweeps {} should be > 0'.format(val)) @property def model(self) -> MicroBenthosModel: """ The model to run the simulation on. This is typically an instance of :class:`~microbenthos.MicroBenthosModel` or its subclasses. The interface it must provide is: * a method :meth:`create_full_equation()` * an attribute :attr:`full_eqn` created by above method, which is a :class:`~fipy.terms.binaryTerm._BinaryTerm` that has a :meth:`sweep()` method. * method :meth:`model.update_vars()` which is called before each timestep * method :meth:`model.clock.increment_time(dt)` which is called after each timestep Additionally, if :attr:`.simtime_days` is set, then setting the model will try to find the ``"env.irradiance:`` object and use its :attr:`.hours_total` attribute to set the :attr:`.simtime_total`. Args: model (:class:`~microbenthos.MicroBenthosModel`): model instance Returns: :class:`~microbenthos.MicroBenthosModel` Raises: RuntimeError: if model has already been set ValueError: if modes interface does not match ValueError: if model :attr:`.model.full_eqn` does not get created even after :meth:`.model.create_full_equation` is called. """ return self._model @model.setter def model(self, m): if self.model: raise RuntimeError('Model already set') full_eqn = getattr(m, 'full_eqn', None) if full_eqn is None: if hasattr(m, 'create_full_equation'): m.create_full_equation() full_eqn = getattr(m, 'full_eqn', None) if full_eqn is None: raise ValueError( 'Model {!r} (type={}) does not have a valid equation'.format( m, type(m))) def recursive_hasattr(obj, path, is_callable=False): parts = path.split('.') S = obj FOUND = False for p in parts: if hasattr(S, p): S = getattr(S, p) FOUND = True else: FOUND = False break if not FOUND: return False else: if is_callable: return callable(S) else: return True expected_attrs = ['clock', 'full_eqn'] expected_callables = [ 'full_eqn.sweep', 'update_vars', 'clock.increment_time' ] failed_attrs = tuple( filter(lambda x: not recursive_hasattr(m, x), expected_attrs)) failed_callables = tuple( filter(lambda x: not recursive_hasattr(m, x, is_callable=True), expected_callables)) if failed_attrs: self.logger.error( 'Model is missing required attributes: {}'.format( failed_attrs)) if failed_callables: self.logger.error('Model is missing required callables: {}'.format( failed_callables)) if failed_callables or failed_attrs: raise ValueError('Model interface is missing: {}'.format( set(failed_attrs + failed_callables))) self._model = m # if simtime_days is given, override the simtime_total with it if self.simtime_days is not None: I = self.model.get_object('env.irradiance') simtime_total = self.simtime_days * I.hours_total self.logger.info( 'Setting simtime_total={} for {} days of simtime'.format( simtime_total, self.simtime_days)) self.simtime_total = simtime_total def _create_solver(self): """ Create the fipy solver to be used """ solver_module = importlib.import_module('fipy.solvers.{}'.format( self.fipy_solver)) Solver = getattr(solver_module, 'DefaultSolver') self._solver = Solver() self.logger.debug('Created fipy {} solver: {}'.format( self.fipy_solver, self._solver)) def run_timestep(self): """ Evolve the model through a single timestep """ if not self.started: raise RuntimeError( 'Simulation timestep cannot be run since started=False') if self.model is None: raise RuntimeError('Simulation model is None, cannot run timestep') dt = self.simtime_step self.logger.info('Running timestep {} + {}'.format( self.model.clock, dt)) num_sweeps = 0 EQN = self.model.full_eqn retry = True res = 100.0 while (res > self.max_residual) and (num_sweeps < self.max_sweeps) \ and retry: try: res = EQN.sweep(solver=self._solver, dt=float(dt.numericValue)) num_sweeps += 1 res = float(res) self.logger.debug('Sweeps: {} residual: {:.2g}'.format( num_sweeps, res)) except (TypeError, RuntimeError): self.logger.warning(f'Error with simulation timestep dt={dt}') res = self.max_residual * 100 break return res, num_sweeps def evolution(self): """ Evolves the model clock through the time steps for the simulation, i.e. by calling :meth:`.run_timestep` and :meth:`.model.clock.increment_time` repeatedly while ``model.clock() <= self.simtime_total``. This is a generator that yields the step number, and the state of the evolution after each time step. If :meth:`snapshot_due` is true, then also the model snapshot is included in the state. Yields: `(step, state)` tuple of step number and simulation state Raises: RuntimeError: if :attr:`.started` is already True """ if self.started: raise RuntimeError( 'Simulation already started. Cannot run parallel evolutions!') self.logger.debug( 'simtime_total={o.simtime_total} simtime_step={o.simtime_step}, ' 'max_sweeps={o.max_sweeps} max_residual={o.max_residual}'.format( o=self)) self.logger.debug('Solving: {}'.format(self.model.full_eqn)) self._create_solver() self._started = True self.logger.info('Simulation evolution starting') self.model.update_vars() self._prev_snapshot = Variable(self.model.clock.copy(), name='prev_snapshot') step = 0 self.simtime_step = self.simtime_lims[0] while self.model.clock() <= self.simtime_total: self.logger.debug('Running step #{} {}'.format( step, self.model.clock)) dt = self.simtime_step tic = time.time() residual, num_sweeps = self.run_timestep() toc = time.time() self.logger.debug( f'For dt={dt} residual={residual:.4g} with sweeps={num_sweeps}' ) # if residual == 0: # raise RuntimeError(f'Residual perfect 0 for dt={dt}. Problem in domain!') self._sweepsQ.appendleft(num_sweeps) self._residualQ.appendleft(residual) if residual >= self.max_residual: self.logger.info( f'Ignoring dt={dt}: res={residual:.4g} > {self.max_residual:.4g}' ) self.update_simtime_step(residual, num_sweeps) self.model.revert_vars() # just go back to while loop start, now that dt has been made smaller continue else: self.model.update_vars() self.model.update_equations(dt) step += 1 self.update_simtime_step(residual, num_sweeps) calc_time = 1000 * (toc - tic) self.logger.debug('Time step {} done in {:.2f} msec'.format( self.simtime_step, calc_time)) if self.snapshot_due(): self.logger.debug('Snapshot in step #{}'.format(step)) state = self.get_state(state=self.model.snapshot(), calc_time=calc_time, residual=residual, num_sweeps=num_sweeps) yield (step, state) # now set the prev_snapshot so that snapshot_due() will # remain true for processing self._prev_snapshot.setValue(self.model.clock.copy()) self.logger.debug('Prev snapshot set: {}'.format( self._prev_snapshot)) else: # create a minimal state # this is the model clock and current residual state = self.get_state(calc_time=calc_time, residual=residual, num_sweeps=num_sweeps) yield (step, state) self.model.clock.increment_time(dt) self.logger.info('Simulation evolution completed') self._started = False def get_state(self, state=None, metrics=None, **kwargs): """ Get the state of the simulation evolution Args: state (None, dict): If state is given (from ``model.snapshot()``), then that is used. If None, then just the time info is created by using :attr:`.model.clock`. metrics (None, dict): a dict to get the simulation metrics from, else from `kwargs` **kwargs: parameters to build metrics dict. Currently the keys `"calc_time"`, `"residual"` and `"num_sweeps"` are used, if available. Returns: dict: the simulation state """ if state is None: state = dict(time=dict(data=snapshot_var(self.model.clock))) if metrics is None: metrics = dict( calc_time=dict(data=(kwargs.get('calc_time', 0.0), dict(unit='ms'))), residual=dict(data=(kwargs.get('residual', 0.0), None)), num_sweeps=dict(data=(kwargs.get('num_sweeps', 0), None)), ) state['metrics'] = metrics return state def snapshot_due(self): """ Returns: bool: If the current model clock time has exceeded :attr:`.snapshot_interval` since the last snapshot time """ return self.model.clock() - self._prev_snapshot() >= \ self.snapshot_interval def update_simtime_step(self, residual, num_sweeps): """ Update the :attr:`.simtime_step` to be adaptive to the current residual and sweeps. A multiplicative factor for the time-step is determined based on the number of sweeps and residual. If the `residual` is more than :attr:`.max_residual`, then the time-step is quartered. If not, it is boosted by up to double, depending on the `num_sweeps` and :attr:`.max_sweeps`. Once a new timestep is determined, it is limited to the time left in the model simulation. Args: residual (float): the residual from the last equation step num_sweeps (int): the number of sweeps from the last equation step """ self.logger.info( 'Updating step {} after {}/{} sweeps and {:.3g}/{:.3g} ' 'residual'.format(self.simtime_step, num_sweeps, self.max_sweeps, residual, self.max_residual)) old_step = self.simtime_step mult = 1.0 if residual >= self.max_residual: mult = 0.5 else: # if the last N simtime steps have produced residuals lesser than max, then boost the # timestep if all([r < self.max_residual for r in list(self._residualQ)]): mult = 1.25 else: mult = 1.0 new_step = self.simtime_step * max(0.01, mult) self.simtime_step = min(new_step, self.simtime_total - self.model.clock()) self.logger.info(f'Residual={residual} max={self.max_residual}') self.logger.info('Time-step update {} x {:.2g} = {}'.format( old_step, mult, self.simtime_step))