Min-protein skeleton model as mass-conserving reaction-diffusion
The skeleton model of the Min proteins on a periodic line, a mass-conserving reaction-diffusion system.
- Name:
min_protein_skeleton - Field:
biology/systems_biology - Formalism: ordinary differential equations
- References: Brauns and Marchetti 2024; Halatek and Frey 2018; Huang, Meir, and Wingreen 2003
Factory
@register_model(
field='biology/systems_biology',
tags=('field', 'min proteins', 'mass conserving', 'reaction-diffusion', 'traveling waves', 'pde'),
references=('Brauns and Marchetti 2024', 'Halatek and Frey 2018', 'Huang, Meir, and Wingreen 2003'),
defaults={'until': 400.0, 'dt': 4.0, 'solver': ScipyODESolver(method='BDF', jacobian='exact')},
)
def min_protein_skeleton(
n_sites: int = 128,
*,
length: float = 100.0,
total_D: float = 200.0,
total_E: float = 120.0,
random_state: int | np.random.Generator | None = 0,
) -> System:
"""Return the skeleton model of the Min proteins on a periodic line, a mass-conserving reaction-diffusion system.
MinD and MinE cycle between the membrane, where they diffuse slowly, and the cytosol, where they diffuse fast,
with the reactions of `MinSkeletonElement`. The reactions conserve the totals of MinD and MinE, so only diffusion
moves mass, and the local reactive equilibria map the dynamics onto conserved fields with nonreciprocal
cross-diffusion (Brauns and Marchetti, App. F). With the default totals, the uniform state is unstable to an
oscillatory band of wavelengths near 17, and narrow bands of membrane MinD travel along the line and meet head-on.
The fast cytosolic diffusion and the fast recruitment make the system stiff, so the catalog registers the implicit
`BDF` method with the exact Jacobian.
Args:
n_sites:
The number of sites of the periodic line.
length:
The length of the line.
total_D:
The mean total density of MinD.
total_E:
The mean total density of MinE.
random_state:
The seed or generator of the 1% noise that moves MinD between the membrane and the cytosol.
Returns:
system:
The model, with the membrane fields `cell.md` and `cell.mde` and the cytosolic fields `cell.cdt`,
`cell.cdd`, and `cell.ce`.
Examples:
>>> from skcomplex.models.biology.systems_biology import min_protein_skeleton
>>> values = min_protein_skeleton(32).elements[0].values
>>> round(float(values['ce'].mean() + values['mde'].mean()), 6)
120.0
"""
rng = np.random.default_rng(random_state)
uniform = _uniform_state(MinSkeletonElement('site'), total_D, total_E)
values: dict[str, Array] = {name: np.full(n_sites, value) for name, value in uniform.items()}
noise = 0.01 * uniform['md'] * rng.uniform(-1.0, 1.0, n_sites)
values['md'] = values['md'] + noise
values['cdt'] = values['cdt'] - noise
space = LatticeSpace(n_sites, spacing=length / n_sites)
cell = FieldElement('cell', element=MinSkeletonElement, space=space, values=values)
reactions = [
ReactionInteraction(
'attachment',
target='cell',
reactants={'cdt': 1},
products={'md': 1},
propensity=lambda s: (s.target.k_d + s.target.k_dd * s.target.md) * s.target.cdt,
),
ReactionInteraction(
'recruitment',
target='cell',
reactants={'md': 1, 'ce': 1},
products={'mde': 1},
propensity=lambda s: s.target.k_de * s.target.md * s.target.ce,
),
ReactionInteraction(
'release',
target='cell',
reactants={'mde': 1},
products={'cdd': 1, 'ce': 1},
propensity=lambda s: s.target.k_off * s.target.mde,
),
ReactionInteraction(
'exchange',
target='cell',
reactants={'cdd': 1},
products={'cdt': 1},
propensity=lambda s: s.target.lam * s.target.cdd,
),
]
membrane = [
DiffusionInteraction(f'diffusion_{state}', target='cell', state=state, coefficient=lambda s: s.target.Dm)
for state in ('md', 'mde')
]
cytosol = [
DiffusionInteraction(f'diffusion_{state}', target='cell', state=state, coefficient=lambda s: s.target.Dc)
for state in ('cdt', 'cdd', 'ce')
]
return System(elements=[cell], interactions=[*reactions, *membrane, *cytosol])
Components
class MinSkeletonElement(Element):
"""Site of the Min skeleton model, with membrane MinD and MinDE and cytosolic MinD-ATP, MinD-ADP, and MinE.
The reactions are the attachment and self-recruitment of MinD-ATP to the membrane, `(k_d + k_dd md) cdt`, the
recruitment of MinE by membrane MinD, `k_de md ce`, the dissociation of MinDE complexes, `k_off mde`, which releases
MinD-ADP and MinE to the cytosol, and the nucleotide exchange `lam cdd` from MinD-ADP to MinD-ATP. They conserve
the totals of MinD, `md + mde + cdt + cdd`, and of MinE, `mde + ce`.
The element holds the states and parameters and has no laws of its own: the interactions of `min_protein_skeleton`
read its parameters.
Args:
label:
The label of the site or the field.
visual:
How the element is drawn, or `None` for `default_visual_`.
md:
The initial membrane MinD.
mde:
The initial membrane MinDE complexes.
cdt:
The initial cytosolic MinD-ATP.
cdd:
The initial cytosolic MinD-ADP.
ce:
The initial cytosolic MinE.
Dm:
The diffusivity on the membrane.
Dc:
The diffusivity in the cytosol.
k_d:
The attachment rate of MinD-ATP.
k_dd:
The recruitment rate of MinD-ATP by membrane MinD.
k_de:
The recruitment rate of MinE by membrane MinD.
k_off:
The dissociation rate of MinDE complexes.
lam:
The nucleotide exchange rate.
Examples:
>>> from skcomplex.models.biology.systems_biology import MinSkeletonElement
>>> MinSkeletonElement('site').lam
6.0
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = None,
*,
md: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
mde: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
cdt: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
cdd: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
ce: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
Dm: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 0.013,
Dc: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 60.0,
k_d: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.1,
k_dd: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.1,
k_de: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.5,
k_off: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.4,
lam: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 6.0,
) -> None:
super().__init__(label, visual=visual)
self.md = md
self.mde = mde
self.cdt = cdt
self.cdd = cdd
self.ce = ce
self.Dm = Dm
self.Dc = Dc
self.k_d = k_d
self.k_dd = k_dd
self.k_de = k_de
self.k_off = k_off
self.lam = lam
class ReactionInteraction(JumpInteraction):
"""Reaction that moves units from its reactants to its products at a mass-action or a given propensity.
A reactant, a product, or a modifier is a pair of the path of an element, or of a member of a group such as
`cells[0]`, and the name of its state that holds the units, mapped to its stoichiometric coefficient. A coefficient
is a positive number, or a function of the namespace of the reaction that returns an expression of its parameters,
such as `lambda s: 1 / s.target.epsilon`. So `S + I -> 2 I` has the reactants `{('S', 'n'): 1, ('I', 'n'): 1}` and
the products `{('I', 'n'): 2}`, and two states of one element are `('cell', 'u')` and `('cell', 'v')`. A species may
also be the name of a state of the `target` alone, such as `{'u': 1, 'v': 2}`. A target that is a group or a field
reacts in every member, with the species of every member its own states, as the reactions at every site of a
reaction-diffusion field.
A subclass may bind its species under roles of its own names, such as `susceptible` and `infected`: it returns its
entries from `_get_reactants`, `_get_products`, and `_get_modifiers`, pairs of the path a role binds and a state
name, and the reaction reads every pair from the namespace of the role that binds its path.
The propensity is `k` times the product of every reactant and modifier state to the power of its coefficient, or the
expression the `propensity` function returns. The reaction changes every state by its coefficient among the products
minus its coefficient among the reactants, so it conserves the total of the states whose changes sum to zero, and
modifiers enter the propensity but do not change.
In the mode `'rate'` the reaction is a rate equation. In the mode `'jump'` it is the random event of a
`JumpInteraction` at the propensity that moves the units of the coefficients. In the mode `'langevin'` it is the
chemical Langevin equation, the rate equation with one Wiener process of amplitude the square root of the propensity
driving every state the reaction changes.
In the `'agent'` form the target is an agent group and every member reacts on its own states, the species named by
state names alone, in every step from the states at its start. The propensity of a member is `k` times the product
of its reactant and modifier states to the power of their coefficients, or the number `propensity(agent, model)`
returns, and a coefficient that is a function is the number `coefficient(agent, model)` returns. In the mode
`'rate'` the member adds the changes times its propensity, one unit step of the rate equation, and in the mode
`'jump'` it adds the changes with the probability `1 - exp(-propensity)` that an event fires in the step. The
members react at once from the states at the start of the rule under the schedule `'synchronous'`, one after the
other in their order or in a new random order every step under `'sequential'` or `'random-sequential'`, or each in
its own activation under `'member'`.
Args:
label:
The label of the reaction, unique among the parts of its system.
target:
The path of the element, group, or field whose states the species named by a state alone are, or `None`.
reactants:
The coefficients of the reactants, or `None` for a source.
products:
The coefficients of the products, or `None` for a sink.
k:
The rate constant.
propensity:
A function of the namespace of the reaction that returns the propensity, or `None` for mass action. The
namespace holds the tuples `reactants`, `products`, and `modifiers` of the namespaces of the elements their
pairs bind, in the order of their entries, `target`, the namespace of the target or of one of its members,
and the symbol `k`. In the agent form, a function `propensity(agent, model)` that returns a number.
modifiers:
The coefficients of the modifiers, whose states the propensity reads but the reaction does not change, or
`None`.
mode:
The form of the laws, `'rate'`, `'jump'`, or `'langevin'`.
form:
`'symbolic'` for the laws of the states, or `'agent'` for a rule in which every member of an agent group
reacts on its own states.
phase:
The phase of the step the agent form runs in, or `None` for the default phase.
schedule:
The order of the members in the agent form, `'synchronous'`, `'sequential'`, `'random-sequential'`, or
`'member'`.
Raises:
ValidationError:
The mode, the form, or the schedule is unknown, the symbolic form is not synchronous, the reaction has
neither reactants nor products, an entry is neither a pair of a path and a state name nor a state name of a
given target, a coefficient is neither a positive number nor a function, or the agent form has no target,
has an entry that is a pair, or has the mode `'langevin'`.
Examples:
>>> from skcomplex.core.elements import CompartmentElement
>>> from skcomplex.core.interactions import ReactionInteraction
>>> from skcomplex.core.system import System
>>> from skcomplex.engine.runtime import Simulation
>>> infection = ReactionInteraction(
... 'infection', reactants={('S', 'n'): 1, ('I', 'n'): 1}, products={('I', 'n'): 2}, k=0.0005
... )
>>> recovery = ReactionInteraction('recovery', reactants={('I', 'n'): 1}, products={('R', 'n'): 1}, k=0.1)
>>> system = System(
... elements=[CompartmentElement('S', n=990), CompartmentElement('I', n=10), CompartmentElement('R')],
... interactions=[infection, recovery],
... )
>>> states = Simulation(system, dt=1.0).check().prepare().run(until=50).trajectory_.states
>>> round(float(states[-1].sum()), 6)
1000.0
The decay `n -> 0` at every site of a field, at the rate `k n` of the site:
>>> import numpy as np
>>> from skcomplex.core.elements import FieldElement
>>> from skcomplex.core.spaces import LatticeSpace
>>> decay = ReactionInteraction('decay', target='cells', reactants={'n': 1}, k=1.0)
>>> values = {'n': np.arange(3.0)}
>>> cells = FieldElement('cells', element=CompartmentElement, space=LatticeSpace((3,)), values=values)
>>> simulation = Simulation(System(elements=[cells], interactions=[decay]), dt=1.0).check().prepare()
>>> np.round(simulation.run(until=1).trajectory_.lattice('cells.n')[-1], 3).tolist()
[0.0, 0.368, 0.736]
A coefficient that is a parameter, the decay at the rate `x` that removes `1 / epsilon` units per event:
>>> from typing import Annotated
>>> from skcomplex.core.elements import Element
>>> from skcomplex.specs import ParameterSpec, StateSpec
>>> class ScaledElement(Element):
... def __init__(
... self,
... label,
... x: Annotated[float, StateSpec()] = 1.0,
... epsilon: Annotated[float, ParameterSpec()] = 0.5,
... ):
... super().__init__(label)
... self.x = x
... self.epsilon = epsilon
>>> scaled = ReactionInteraction(
... 'decay', target='box', reactants={'x': lambda s: 1 / s.target.epsilon}, propensity=lambda s: s.target.x
... )
>>> simulation = Simulation(System(elements=[ScaledElement('box')], interactions=[scaled]), dt=0.01)
>>> round(float(simulation.check().prepare().run(until=0.5).trajectory_.values('box.x')[-1]), 3)
0.368
Every agent of a group decays on its own states, by `k n` in a step of the rate equation:
>>> from skcomplex.core.elements import GroupElement
>>> from skcomplex.core.spaces import DiscreteTime
>>> class CellElement(Element):
... def __init__(self, label, n: Annotated[float, StateSpec()] = 3.0):
... super().__init__(label)
... self.n = n
>>> cells = GroupElement('cells', element=CellElement, size=2)
>>> decay = ReactionInteraction('decay', target='cells', reactants={'n': 1}, k=0.1, form='agent')
>>> system = System(elements=[cells], interactions=[decay], time=DiscreteTime())
>>> trajectory = Simulation(system).check().prepare().run(until=2).trajectory_
>>> np.round(trajectory.agents('cells', 2).values['n'], 6).tolist()
[2.43, 2.43]
"""
def __init__(
self,
label: str,
*,
target: Annotated[str | None, RoleSpec()] = None,
reactants: Annotated[Stoichiometry | None, RoleSpec(variadic=True)] = None,
products: Annotated[Stoichiometry | None, RoleSpec(variadic=True)] = None,
k: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
propensity: Callable[..., object] | None = None,
modifiers: Annotated[Stoichiometry | None, RoleSpec(variadic=True)] = None,
mode: str = 'rate',
form: str = 'symbolic',
phase: str | None = None,
schedule: str = 'synchronous',
) -> None:
super().__init__(label, target='')
self.target = target
self.reactants = reactants
self.products = products
self.k = k
self.propensity = propensity
self.modifiers = modifiers
self.mode = mode
self.form = form
self.phase = phase
self.schedule = schedule
def _get_reactants(self) -> Stoichiometry | None:
"""Return the coefficients of the reactants, which a subclass with roles of its own names returns."""
return self.reactants
def _get_products(self) -> Stoichiometry | None:
"""Return the coefficients of the products, which a subclass with roles of its own names returns."""
return self.products
def _get_modifiers(self) -> Stoichiometry | None:
"""Return the coefficients of the modifiers, which a subclass with roles of its own names returns."""
return self.modifiers
def _get_mode(self) -> str:
"""Return the form of the laws, which a subclass that sets it by another argument returns."""
return self.mode
def _get_bound(self, namespace: SimpleNamespace) -> dict[str, SimpleNamespace]:
"""Return the namespace of every path the plain roles of the reaction bind, by path."""
roles = [
name
for name, spec in self._get_specs().items()
if isinstance(spec, RoleSpec) and not spec.variadic and not spec.noise
]
return {
getattr(self, name): getattr(namespace, name)
for name in roles
if isinstance(getattr(self, name), str) and hasattr(namespace, name)
}
def _get_terms(
self,
entries: Stoichiometry | None,
namespace: SimpleNamespace,
name: str,
) -> list[tuple[Expression, Expression | float]]:
"""Return the state symbol and the coefficient of every entry of a kind of species.
A state name alone is a state of the target. A pair reads the namespace of the plain role that binds its path,
or else the namespace of its variadic role `name`, in the order of the pairs. A coefficient that is a function
is its expression at the namespace.
"""
entries = entries or {}
pairs = [entry for entry in entries if not isinstance(entry, str)]
sources: dict[object, SimpleNamespace] = {}
if pairs and name in self._get_specs():
sources = dict(zip(pairs, getattr(namespace, name), strict=True))
elif pairs:
bound = self._get_bound(namespace)
sources = {pair: bound[pair[0]] for pair in pairs}
return [
(
getattr(
self._get_member(namespace, 'target') if isinstance(entry, str) else sources[entry],
self._get_state(entry),
),
SR(coefficient(namespace)) if callable(coefficient) else coefficient,
)
for entry, coefficient in entries.items()
]
def _get_state(self, entry: tuple[str, str] | str) -> str:
"""Return the name of the state of an entry, a state name alone or the second item of a pair."""
return entry if isinstance(entry, str) else entry[1]
def _get_changes(self, namespace: SimpleNamespace) -> dict[Expression, float]:
"""Return the net change of every state the reaction changes, products minus reactants."""
changes: dict[Expression, float] = {}
for sign, entries, name in (
(-1, self._get_reactants(), 'reactants'),
(1, self._get_products(), 'products'),
):
for state, coefficient in self._get_terms(entries, namespace, name):
changes[state] = changes.get(state, 0) + sign * coefficient
return {state: change for state, change in changes.items() if change}
def _get_mass_action(self, namespace: SimpleNamespace, constant: Expression) -> Expression:
"""Return the mass action of a rate constant, times every reactant and modifier to its coefficient."""
propensity = SR(constant)
for entries, name in ((self._get_reactants(), 'reactants'), (self._get_modifiers(), 'modifiers')):
for state, coefficient in self._get_terms(entries, namespace, name):
propensity *= state**coefficient
return propensity
def _get_rate(self, namespace: SimpleNamespace) -> Expression:
"""Return the propensity of the reaction, by the user's function or by mass action at the rate constant `k`."""
if self.propensity is not None:
return SR(self.propensity(namespace))
return self._get_mass_action(namespace, self._get_parameter_symbol('k', namespace))
def _get_langevin_laws(self, propensity: Expression, changes: dict[Expression, float]) -> list[BasePart]:
"""Return the drift of the reaction and the noise of its one Wiener process on every state it changes."""
amplitude = sqrt(where(propensity > 0, propensity, 0))
rates: list[BasePart] = [RateLaw(state, change * propensity) for state, change in changes.items()]
noises: list[BasePart] = [
NoiseLaw(state, change * amplitude, process=self.label) for state, change in changes.items()
]
return rates + noises
def _is_entry(self, entry: object) -> bool:
"""Check whether an entry is a pair of a path and a state name, or a state name of a given target."""
if isinstance(entry, str):
return self.target is not None and entry.isidentifier()
return (
isinstance(entry, tuple)
and len(entry) == ENTRY_SIZE
and all(isinstance(item, str) for item in entry)
and entry[1].isidentifier()
)
def _is_coefficient(self, coefficient: object) -> bool:
"""Check whether a coefficient is a positive number or a function."""
return callable(coefficient) or (isinstance(coefficient, int | float) and coefficient > 0)
def _check_entries(self, name: str, entries: Stoichiometry | None) -> None:
"""Raise an error for entries that are neither pairs nor state names of the target, or not coefficients."""
if entries is None:
return
valid = isinstance(entries, Mapping) and all(
self._is_entry(entry) and self._is_coefficient(coefficient) for entry, coefficient in entries.items()
)
if not valid:
error_msg = (
f'The {name} of reaction {self.label!r} are {entries!r}. Map pairs of a path and a state name, or '
'state names of the target, to positive numbers or functions, such as {("S", "n"): 1} or {"n": 1}.'
)
raise ValidationError(error_msg)
def _check_form(self) -> None:
"""Raise an error for an unknown form, or an agent form without a target, with pairs, or of Langevin mode."""
if self.form not in FORMS:
error_msg = f'Reaction {self.label!r} has the form {self.form!r}. Use one of {", ".join(FORMS)}.'
raise ValidationError(error_msg)
self._check_variant('schedule', self.schedule, SCHEDULES)
if self.form != 'agent' and self.schedule != 'synchronous':
error_msg = f'The schedule {self.schedule!r} of reaction {self.label!r} runs agent rules. Use form="agent".'
raise ValidationError(error_msg)
if self.form != 'agent':
return
if self.target is None:
error_msg = f'Reaction {self.label!r} has the agent form but no target. Give the agent group as `target`.'
raise ValidationError(error_msg)
mode = self._get_mode()
if mode not in AGENT_MODES:
error_msg = (
f'Reaction {self.label!r} has the agent form and the mode {mode!r}. Use {" or ".join(AGENT_MODES)}.'
)
raise ValidationError(error_msg)
entries = [*(self._get_reactants() or {}), *(self._get_products() or {}), *(self._get_modifiers() or {})]
if not all(isinstance(entry, str) for entry in entries):
error_msg = (
f'Reaction {self.label!r} has the agent form, whose species are states of every member. Name them by '
'state names alone, such as {"n": 1}.'
)
raise ValidationError(error_msg)
def _check_jump(self) -> None:
"""Raise an error for an unknown mode or form, no reactants and products, or entries the reaction cannot take.
Raises:
ValidationError:
The mode or the form is unknown, the reaction has neither reactants nor products, an entry is neither a
pair of a path and a state name nor a state name of a given target, a coefficient is neither a positive
number nor a function, or the agent form has no target, has an entry that is a pair, or has the mode
`'langevin'`.
"""
mode = self._get_mode()
if mode not in MODES:
error_msg = f'Reaction {self.label!r} has the mode {mode!r}. Use one of {", ".join(MODES)}.'
raise ValidationError(error_msg)
reactants, products = self._get_reactants(), self._get_products()
if not reactants and not products:
error_msg = f'Reaction {self.label!r} has neither reactants nor products. Give at least one of them.'
raise ValidationError(error_msg)
for name, entries in (('reactants', reactants), ('products', products), ('modifiers', self._get_modifiers())):
self._check_entries(name, entries)
self._check_form()
def _get_number(self, value: object, agent: AgentViewProtocol, model: ModelViewProtocol) -> float:
"""Return a number, or the value of a function of an agent and the model as a number."""
found = value(agent, model) if callable(value) else value
if not isinstance(found, SupportsFloat):
error_msg = f'A function of reaction {self.label!r} returns {found!r} for an agent. Return a number.'
raise ValidationError(error_msg)
return float(found)
def _get_agent_changes(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> dict[str, float]:
"""Return the net change of every state of an agent the reaction changes, products minus reactants."""
changes: dict[str, float] = {}
for sign, entries in ((-1, self._get_reactants()), (1, self._get_products())):
for entry, coefficient in (entries or {}).items():
state = self._get_state(entry)
changes[state] = changes.get(state, 0.0) + sign * self._get_number(coefficient, agent, model)
return changes
def _get_agent_propensity(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> float:
"""Return the propensity of an agent, by the user's function or by mass action at the rate constant `k`."""
if self.propensity is not None:
return self._get_number(self.propensity, agent, model)
propensity = self._get_parameter_value('k', model)
for entries in (self._get_reactants(), self._get_modifiers()):
for entry, coefficient in (entries or {}).items():
value = float(getattr(agent, self._get_state(entry)))
propensity *= value ** self._get_number(coefficient, agent, model)
return propensity
def _get_agent_scale(self, propensity: float, model: ModelViewProtocol) -> float:
"""Return the multiple of its changes an agent adds in a step, its propensity or whether an event fires."""
if self._get_mode() == 'rate':
return propensity
return float(model.rng.random() < 1.0 - math.exp(-max(propensity, 0.0)))
def _get_agent_step(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> dict[str, float]:
"""Return the change of every state of an agent in the step, its changes times its propensity or its event."""
changes, propensity = self._get_agent_changes(agent, model), self._get_agent_propensity(agent, model)
scale = self._get_agent_scale(propensity, model)
return {state: change * scale for state, change in changes.items()}
def _apply_agent_step(self, agent: AgentViewProtocol, changes: dict[str, float]) -> None:
"""Add their changes to the states of an agent."""
for state, change in changes.items():
setattr(agent, state, float(getattr(agent, state)) + change)
def _react_member(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""React in one agent on its own states, in its own activation."""
if agent.alive:
self._apply_agent_step(agent, self._get_agent_step(agent, model))
def react(self, model: ModelViewProtocol) -> None:
"""React in every agent of the target on its own states, by the schedule.
Args:
model:
The model view of the interaction.
"""
self._update_agents(model, self.schedule, self._get_agent_step, self._apply_agent_step)
def _get_laws(self, namespace: SimpleNamespace) -> list[BasePart]:
"""Return the laws of the reaction in its mode, or the rule of the agent form.
Args:
namespace:
The namespace of the symbols the laws can use, with the namespaces of the target, the reactants, the
products, and the modifiers.
Returns:
laws:
The rate laws, the jump law, the rate and noise laws, or the rule of the reaction.
"""
if self.form == 'agent' and self.schedule == 'member':
return [RuleLaw(self._react_member, phase=self.phase, members=self._get_role('target'))]
if self.form == 'agent':
return [RuleLaw(self.react, phase=self.phase)]
mode = self._get_mode()
if mode == 'jump':
return [*super()._get_laws(namespace)]
propensity = self._get_rate(namespace)
changes = self._get_changes(namespace)
if mode == 'langevin':
return self._get_langevin_laws(propensity, changes)
return [RateLaw(state, change * propensity) for state, change in changes.items()]
class DiffusionInteraction(ConservationInteraction):
"""Diffusion `du/dt = D laplacian(u)` of a state `u` of a field, the conservation law of the flux `-D gradient(u)`.
The coefficient `D` is uniform in space, a number or a function of the namespace that returns an expression of
parameters and time. The interaction lowers to the Laplacian of the field, which its discretization evaluates, so
the total of the state on a periodic field stays constant and a point mass spreads as the heat kernel.
Args:
label:
The label of the interaction, unique among the parts of its system.
target:
The path of the field whose state diffuses.
state:
The name of the state of the field that diffuses.
coefficient:
A function of the namespace that returns the diffusion coefficient, or a number.
Raises:
ValidationError:
The state is not a name, or the coefficient is neither callable nor a number.
Examples:
>>> from typing import Annotated
>>> import numpy as np
>>> from skcomplex.core.elements import Element, FieldElement
>>> from skcomplex.core.interactions import DiffusionInteraction
>>> from skcomplex.core.spaces import LatticeSpace
>>> from skcomplex.core.system import System
>>> from skcomplex.engine.runtime import Simulation
>>> from skcomplex.specs import StateSpec
>>> class CellElement(Element):
... def __init__(self, label, u: Annotated[float, StateSpec()] = 0.0):
... super().__init__(label)
... self.u = u
>>> space = LatticeSpace((40,), spacing=0.1, boundary='periodic')
>>> field = FieldElement('rod', element=CellElement, space=space, values={'u': np.eye(40)[20]})
>>> heat = DiffusionInteraction('heat', target='rod', state='u', coefficient=0.1)
>>> trajectory = Simulation(System(elements=[field], interactions=[heat]), dt=0.01).check().prepare()
>>> round(float(trajectory.run(until=1.0).trajectory_.values('rod.u')[-1].sum()), 6)
1.0
"""
def __init__(
self,
label: str,
*,
target: Annotated[str, RoleSpec()],
state: str = 'u',
coefficient: Callable[[SimpleNamespace], object] | float = 1.0,
) -> None:
super().__init__(label, target=target, state=state)
self.coefficient = coefficient
def _check(self) -> None:
"""Raise an error for a state that is not a name, or a coefficient that is neither callable nor a number.
Raises:
ValidationError:
The type declares states, the state is not a name, or the coefficient is neither callable nor a number.
"""
super()._check()
if not (callable(self.coefficient) or isinstance(self.coefficient, int | float)):
error_msg = f'The coefficient of {self.label!r} is {self.coefficient!r}. Give a function or a number.'
raise ValidationError(error_msg)
def _get_transport_laws(self, namespace: SimpleNamespace) -> list[BasePart]:
"""Return the rate `D laplacian(u)` of the state, the divergence of the diffusive flux.
Args:
namespace:
The namespace of the symbols the laws can use, with the namespace of the field.
Returns:
laws:
The rate law of the state.
"""
coefficient = self.coefficient(namespace) if callable(self.coefficient) else self.coefficient
state = getattr(self._get_member(namespace, 'target'), self.state)
return [RateLaw(state, coefficient * laplacian(state))]
Equations
cell.md[i]: diff(md_cell(t), t) == -k_de_cell*ce_cell(t)*md_cell(t) + (k_dd_cell*md_cell(t) + k_d_cell)*cdt_cell(t) + Dm_cell*laplacian(md_cell(t))
cell.mde[i]: diff(mde_cell(t), t) == k_de_cell*ce_cell(t)*md_cell(t) + Dm_cell*laplacian(mde_cell(t)) - k_off_cell*mde_cell(t)
cell.cdt[i]: diff(cdt_cell(t), t) == lam_cell*cdd_cell(t) - (k_dd_cell*md_cell(t) + k_d_cell)*cdt_cell(t) + Dc_cell*laplacian(cdt_cell(t))
cell.cdd[i]: diff(cdd_cell(t), t) == -lam_cell*cdd_cell(t) + Dc_cell*laplacian(cdd_cell(t)) + k_off_cell*mde_cell(t)
cell.ce[i]: diff(ce_cell(t), t) == -k_de_cell*ce_cell(t)*md_cell(t) + Dc_cell*laplacian(ce_cell(t)) + k_off_cell*mde_cell(t)