"""Provides actors that influence scalar fields.
.. autosummary::
:nosignatures:
~LocalReactionsActor
~ScalarPDEActor
~DiffusionActor
~ReactionDiffusionActor
~CollectionPDEActor
.. codeauthor:: David Zwicker <david.zwicker@ds.mpg.de>
"""
from __future__ import annotations
import inspect
from collections.abc import Callable
from typing import Any
import numpy as np
from pde import FieldCollection, ReactionDiffusionPDE, ScalarField
from pde.backends.numba.utils import jit
from pde.pdes.base import PDEBase
from pde.tools.docstrings import get_text_block
from pde.tools.expressions import ScalarExpression
from ... import Parameter
from ...elements import FieldCollectionElement, FieldElementBase, ScalarFieldElement
from ..base import ActorBase
[docs]
class LocalReactionsActor(ActorBase):
"""Actor simulating a local chemical reactions in a field."""
parameters_default = [
Parameter(
"reaction_flux",
"0",
str,
"An expression for the reaction flux in the mean field. The expression may "
"depend on the concentration and time, which are denoted by the variables "
"`c` and `t` respectively.",
),
]
element_classes = (FieldElementBase,)
def __init__(self, parameters: dict[str, Any] | None = None):
"""
Args:
parameters (dict):
Parameters affecting the actor. Call
:meth:`~LocalReactionsActor.show_parameters` for details.
"""
super().__init__(parameters=parameters)
reaction_flux = self.parameters["reaction_flux"]
self._reaction = ScalarExpression(reaction_flux, signature=["c", "t"])
[docs]
def estimate_dt(self, elements: tuple[FieldElementBase]) -> float: # type: ignore
"""Get the optimal time step for the simulation of the actor.
Args:
elements (tuple of :class:`~emulsim.elements.fields.MeanfieldElement`):
The element affected by the actor
"""
s_max = np.abs(self._reaction(np.linspace(0, 1, 32), t=0)).max()
if s_max == 0:
return float("inf")
else:
# The maximum timestep for s(c) = dc/dt is based on the
# analysis of the differential equation based on a linearized
# expression for the flux as dc(t)/dt = k * c(t),
# which has the solution c(t) = c(0) * exp(k * t),
# where k is a growth or reaction rate.
# Using an explicit Euler stepping, we find that the relative
# error ε during a single time step of length Δt is given by
# ε ≈ 0.5 * (k * Δt)**2 to lowest order in Δt. If we want to limit
# ε ≤ 0.01, we thus have to choose Δt ≤ sqrt(2 * ε) / k = 0.1 / k.
return 0.1 / s_max # type: ignore
[docs]
def make_evolver_numba( # type: ignore
self, elements: tuple[FieldElementBase]
) -> Callable[[tuple[np.ndarray], float, float], None]:
"""Return a function evolve the field from time `t` to `t + dt`
Args:
elements (tuple of :class:`~emulsim.elements.fields.MeanfieldElement`):
The element affected by the actor
Returns:
callable: A function with signature (field_data, t: float,
dt: float), which evolves the field_data.
"""
reation_flux = self._reaction.get_function(backend="numba")
@jit
def evolver(fields_data: tuple[np.ndarray], t: float, dt: float) -> None:
"""Evolve the diffusion equation explicitly."""
(field_data,) = fields_data
field_data += dt * reation_flux(field_data, t)
return evolver # type: ignore
[docs]
def evolve(self, elements: tuple[FieldElementBase], t: float, dt: float): # type: ignore
"""Evolve the field from time `t` to `t + dt`
Args:
elements (tuple of :class:`~emulsim.elements.fields.MeanfieldElement`):
The element affected by the actor
t (float):
The current time point
dt (float):
The time step used to evolve the element
"""
(element,) = elements # extract single element
element.data[...] += dt * self._reaction(element.data, t)
[docs]
class ScalarPDEActor(ActorBase):
"""Actor evolving a field according to a PDE."""
element_classes = (ScalarFieldElement,)
def __init__(self, pde: PDEBase, parameters: dict[str, Any] | None = None):
"""Initialize the actor and its PDE.
Args:
pde (:class:`~pde.pdes.base.PDEBase`):
The partial differential equation describing the dynamics of the
scalar field.
parameters (dict):
Parameters affecting the actor. Call
:meth:`~ScalarPDEActor.show_parameters` for details.
"""
super().__init__(parameters=parameters)
if inspect.isclass(pde):
self._logger.warning("Got class `%s` instead of an instance", pde)
self.pde: PDEBase = pde()
else:
self.pde = pde
@property
def info(self) -> dict[str, Any]:
"""dict: information about the actor"""
result = super().info
result["pde"] = {"class": self.pde.__class__.__name__}
return result
[docs]
def copy(self) -> ScalarPDEActor:
"""Returns a copy the actor."""
return self.__class__(self.pde, self.parameters.copy())
[docs]
def make_evolver_numba( # type: ignore
self, elements: tuple[ScalarFieldElement]
) -> Callable[[tuple[np.ndarray], float, float], None]:
"""Return a function evolving the field from time `t` to `t + dt`
Args:
elements (tuple of :class:`~emulsim.elements.fields.ScalarFieldElement`):
The element affected by the actor
Returns:
callable: A function with signature (field_data, t: float,
dt: float), which evolves the field_data.
"""
(element,) = elements # extract single element
pde_rhs = self.pde.make_pde_rhs(element._field, backend="numba")
@jit
def evolver(fields_data: tuple[np.ndarray], t: float, dt: float) -> None:
"""Evolve the PDE explicitly."""
(field_data,) = fields_data
field_data += dt * pde_rhs(field_data, t)
return evolver # type: ignore
[docs]
def evolve(self, elements: tuple[ScalarFieldElement], t: float, dt: float): # type: ignore
"""Evolve the field from time `t` to `t + dt`
Args:
elements (tuple of :class:`~emulsim.elements.fields.ScalarFieldElement`):
The element affected by the actor
t (float):
The current time point
dt (float):
The time step used to evolve the element
"""
(element,) = elements # extract single element
rate = self.pde.evolution_rate(element._field, t)
element._field += dt * rate
[docs]
class DiffusionActor(ScalarPDEActor):
"""Actor evolving a field according to a simple diffusion equation."""
parameters_default = [
Parameter(
"diffusivity",
1,
float,
"Diffusivity in the field. This actor only supports constant "
"diffusivities. Diffusivities depending on local concentration are "
"supported by `ReactionDiffusionActor`.",
),
Parameter(
"boundary_conditions",
"auto_periodic_neumann",
object,
"Defines the boundary conditions on the field."
+ get_text_block("ARG_BOUNDARIES"),
),
]
def __init__(self, parameters: dict[str, Any] | None = None):
"""
Args:
parameters (dict):
Parameters affecting the actor. Call
:meth:`~DiffusionActor.show_parameters` for details.
"""
from pde import DiffusionPDE
# parse parameters
parameters = self._parse_parameters(parameters, include_deprecated=True)
# initialize diffusion equation
eq = DiffusionPDE(
diffusivity=parameters["diffusivity"],
bc=parameters["boundary_conditions"],
)
super().__init__(eq, parameters)
[docs]
def estimate_dt(self, elements: tuple[ScalarFieldElement]) -> float: # type: ignore
"""Get the optimal time step for the simulation of the actor.
Args:
elements (tuple of :class:`~emulsim.elements.fields.ScalarFieldElement`):
The element affected by the actor
Returns:
float: the time step
"""
(element,) = elements # extract single element
dx = float(element.grid.discretization.min())
return 0.1 * dx**2 / float(self.pde.diffusivity) # type: ignore
[docs]
class ReactionDiffusionActor(ScalarPDEActor):
"""Actor evolving a field according to a reaction-diffusion equation."""
parameters_default = [
Parameter(
"diffusivity",
1,
float,
"Diffusivity in the field.",
),
Parameter(
"reaction_flux",
"0",
str,
"An expression for the reaction flux in the field, which can depend on the "
"concentration (denoted by `c`), spatial coordinates (denoted by `x[i]`, "
"where `i` is the dimension), and time `t`.",
),
Parameter(
"boundary_conditions",
"auto_periodic_neumann",
object,
"Defines the boundary conditions on the field."
+ get_text_block("ARG_BOUNDARIES"),
),
Parameter(
"expression_constants",
{},
dict,
"A dictionary defining values of constants that can be used in "
"expressions, e.g., the parameter `reaction_flux`.",
),
]
def __init__(self, parameters: dict[str, Any] | None = None):
"""
Args:
parameters (dict):
Parameters affecting the actor. Call
:meth:`~ReactionDiffusionActor.show_parameters` for details
"""
# parse parameters
parameters = self._parse_parameters(parameters, include_deprecated=True)
# initialize reaction-diffusion equation
eq = ReactionDiffusionPDE(
variables=["c"],
diffusivity=parameters["diffusivity"],
sources=[parameters["reaction_flux"]],
bc=parameters["boundary_conditions"],
consts=parameters["expression_constants"],
)
super().__init__(eq, parameters)
[docs]
def estimate_dt(self, elements: tuple[ScalarFieldElement]) -> float: # type: ignore
"""Get the optimal time step for the simulation of the actor.
Args:
elements (tuple of :class:`~emulsim.elements.fields.ScalarFieldElement`):
The element affected by the actor
Returns:
float: the time step
"""
(element,) = elements # extract single element
grid = element.grid
# estimate the time step based on the chemical reaction
test_field = FieldCollection([ScalarField(grid)])
s_max = 0
for c in np.linspace(0, 1, 32):
test_field.data = c
rates = self.pde.evolution_rate(test_field) # type: ignore
s_max = max(s_max, np.max(np.abs(rates.data)))
diffusivity = self.parameters["diffusivity"] # type: ignore
if s_max == 0:
dt_reaction = float("inf")
else:
dt_reaction = 0.2 / s_max # maximal 2% error during time step
# This estimate is based on an analysis of the differential equation
# dc(t)/dt = k * c(t), which has the solution c(t) = c(0) * exp(k * t), so
# k is a growth or reaction rate. Using an explicit Euler stepping, we find
# that the relative error ε during a single time step of length Δt is given
# by ε ≈ 0.5 * (k * Δt)**2 to lowest order in Δt. If we want to limit
# ε ≤ 0.02, we thus have to choose Δt ≤ sqrt(2 * ε) / k = 0.2 / k. The
# expression is thus a conservative estimate using the maximal reaction
# rate k = max(s).
# estimate the time step required for diffusion
dx = grid.discretization.min()
dt_diffusion = 0.1 * dx**2 / diffusivity
return min(dt_reaction, dt_diffusion) # type: ignore
[docs]
def make_evolver_numba( # type: ignore
self, elements: tuple[ScalarFieldElement]
) -> Callable[[tuple[np.ndarray], float, float], None]:
"""Return a function evolving the field from time `t` to `t + dt`
Args:
elements (tuple of :class:`~emulsim.elements.fields.ScalarFieldElement`):
The element affected by the actor
Returns:
callable: A function with signature (field_data, t: float,
dt: float), which evolves the field_data.
"""
(element,) = elements # extract single element
pde_rhs = self.pde.make_pde_rhs(
FieldCollection([element._field]), backend="numba"
)
@jit
def evolver(fields_data: tuple[np.ndarray], t: float, dt: float) -> None:
"""Evolve the PDE explicitly."""
(field_data,) = fields_data
field_data += dt * pde_rhs(field_data[np.newaxis, ...], t)[0]
return evolver # type: ignore
[docs]
def evolve(self, elements: tuple[ScalarFieldElement], t: float, dt: float): # type: ignore
"""Evolve the field from time `t` to `t + dt`
Args:
elements (tuple of :class:`~emulsim.elements.fields.ScalarFieldElement`):
The element affected by the actor
t (float):
The current time point
dt (float):
The time step used to evolve the element
"""
(element,) = elements # extract single element
rate = self.pde.evolution_rate(FieldCollection([element._field]), t)
element._field += dt * rate[0]
[docs]
class CollectionPDEActor(ActorBase):
"""Actor evolving a field collection according to a PDE."""
element_classes = (FieldCollectionElement,)
def __init__(self, pde: PDEBase, parameters: dict[str, Any] | None = None):
"""Initialize the actor and its PDE.
Args:
pde (:class:`~pde.pdes.base.PDEBase`):
The partial differential equation describing the dynamics of the
scalar field.
parameters (dict):
Parameters affecting the actor. Call
:meth:`~CollectionPDEActor.show_parameters` for details.
"""
super().__init__(parameters=parameters)
if inspect.isclass(pde):
self._logger.warning("Got class `%s` instead of an instance", pde)
self.pde: PDEBase = pde()
else:
self.pde = pde
@property
def info(self) -> dict[str, Any]:
"""dict: information about the actor"""
result = super().info
result["pde"] = {"class": self.pde.__class__.__name__}
return result
[docs]
def copy(self) -> CollectionPDEActor:
"""Returns a copy the actor."""
return self.__class__(self.pde, self.parameters.copy())
[docs]
def make_evolver_numba( # type: ignore
self, elements: tuple[FieldCollectionElement]
) -> Callable[[tuple[np.ndarray], float, float], None]:
"""Return a function evolving the field from time `t` to `t + dt`
Args:
elements (tuple of :class:`~emulsim.elements.fields.ScalarFieldElement`):
The element affected by the actor
Returns:
callable: A function with signature (field_data, t: float,
dt: float), which evolves the field_data.
"""
(element,) = elements # extract single element
pde_rhs = self.pde.make_pde_rhs(element.field, backend="numba")
@jit
def evolver(fields_data: tuple[np.ndarray], t: float, dt: float) -> None:
"""Evolve the PDE explicitly."""
(field_data,) = fields_data
field_data += dt * pde_rhs(field_data, t)
return evolver # type: ignore
[docs]
def evolve(self, elements: tuple[FieldCollectionElement], t: float, dt: float): # type: ignore
"""Evolve the field from time `t` to `t + dt`
Args:
elements (tuple of :class:`~emulsim.elements.fields.ScalarFieldElement`):
The element affected by the actor
t (float):
The current time point
dt (float):
The time step used to evolve the element
"""
(element,) = elements # extract single element
rate = self.pde.evolution_rate(element.field, t)
element.field.data += dt * rate.data