Random-walker epidemics on a grid and on a small world
Random walkers among whom one sick walker starts a deadly epidemic, on a grid or on a network.
- Name:
random_walker_epidemics - Field:
biology/epidemiology - Formalism: agent-based model
- References: Charbonneau 2017
Factory
@register_model(
field='biology/epidemiology',
tags=('agents', 'grid', 'network', 'random walk', 'percolation'),
references=('Charbonneau 2017',),
defaults={'until': 300},
)
def random_walker_epidemics(
width: int = 64,
height: int = 64,
*,
walkers: int = 1000,
lifetime: float = 20.0,
infection: float = 1.0,
travel: float = 1.0,
graph: object = None,
random_state: int | np.random.Generator | None = 0,
) -> System:
"""Return random walkers among whom one sick walker starts a deadly epidemic, on a grid or on a network.
Sick walkers infect the walkers they meet in a cell and die `lifetime` steps later, so the epidemic spreads as a
front like a forest fire and burns out. Above a critical density of walkers it percolates across the grid. On a
network, such as a small world of cities, walkers travel along edges and long links carry the disease far.
Args:
width:
The number of columns of the grid, whose edges walkers cannot cross.
height:
The number of rows of the grid.
walkers:
The number of walkers.
lifetime:
The number of steps a walker lives after its infection.
infection:
The probability that a sick walker infects a healthy walker of its cell at every step.
travel:
The probability of moving at every step.
graph:
A NetworkX graph whose nodes the walkers occupy instead of the grid, or `None` for the grid.
random_state:
The seed or generator of the initial places and the first sick walker.
Returns:
system:
The model, with the group `walkers`.
Examples:
>>> from skcomplex.models.biology.epidemiology import random_walker_epidemics
>>> float(random_walker_epidemics(walkers=50).elements[0].values['health'].sum())
1.0
"""
rng = np.random.default_rng(random_state)
if graph is None:
cells = rng.integers(0, [width, height], size=(walkers, 2))
locations: list[object] = [tuple(int(item) for item in cell) for cell in cells]
space: LatticeSpace | NetworkSpace = LatticeSpace((width, height), boundary='fixed', neighborhood='von_neumann')
else:
nodes = list(nx.nodes(graph))
locations = [nodes[int(pick)] for pick in rng.integers(0, len(nodes), size=walkers)]
space = NetworkSpace(graph)
health = _initial_health(walkers, 1, rng, SICK)
group = GroupElement(
'walkers',
element=WalkerElement,
size=walkers,
values={'health': health, 'infection': infection, 'travel': travel},
location=locations,
)
interactions = [
MovementInteraction(
'walk',
target='walkers',
condition=_is_living_walker,
accept=_get_walker_step,
schedule='member',
),
TransitionInteraction(
'infection',
target='walkers',
state='health',
source=HEALTHY,
destination=SICK,
probability=_get_walker_infection,
over='cell',
schedule='member',
form='agent',
),
TransitionInteraction(
'death',
target='walkers',
state='health',
source=SICK,
destination=DECEASED,
condition=_is_due,
after=math.ceil(lifetime),
schedule='member',
form='agent',
),
]
return System(elements=[group], interactions=interactions, space=space)
Components
class WalkerElement(Element):
"""Random walker that carries a deadly disease, of Charbonneau's epidemic model.
In its own activation, a living walker moves to a neighbouring cell or node with probability `travel` by a
`MovementInteraction`, and a `TransitionInteraction` whose members act on their cell makes a sick walker infect
every healthy walker of its cell with probability `infection`. Another transition kills a sick walker after a
residence of a number of steps. Health is 0 healthy, 1 sick, or 2 dead.
Args:
label:
The label of the agent or group.
visual:
How the element is drawn, or `None` for `default_visual_`.
health:
The health state.
infection:
The probability that a sick walker infects a healthy walker of its cell at every step.
travel:
The probability of moving at every step.
Examples:
>>> from skcomplex.core.elements import GroupElement
>>> from skcomplex.models.biology.epidemiology import WalkerElement
>>> GroupElement('walkers', element=WalkerElement, size=10).size
10
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = VisualSpec('agents', {'color_by': 'health', 'color_range': (0.0, 2.0)}),
*,
health: Annotated[float, StateSpec(domain=NN)] = HEALTHY,
infection: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
travel: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
) -> None:
super().__init__(label, visual=visual)
self.health = health
self.infection = infection
self.travel = travel
@classmethod
def _get_view(cls) -> type[WalkerView]:
"""Return the view of a walker, `WalkerView`."""
return WalkerView
class MovementInteraction(Interaction):
"""Movement of the agents of a group by steps, every step of discrete time.
A step is one of five kinds:
- `'neighbor'` moves an agent to a uniformly drawn cell or node next to its own, on a lattice or a network.
- `'gaussian'` adds to the position of an agent a normal vector whose components have the standard deviation
`scale`, in a continuous space.
- `'levy'` moves an agent in a uniformly drawn direction by a Pareto length, whose least value is `scale` and whose
tail exponent is `exponent`, in a continuous space.
- `'best'` moves an agent to the cell or node next to its own with the highest `score(agent, cell, model)`. With a
`vision`, a number or a function of the agent and the model view, it looks instead along every direction of the
neighbourhood of a lattice up to that many cells, and at the cell of the agent, in the order of the axes, the
positive direction first. Among cells of equal highest score, the ties `'random'` draw one uniformly, `'first'`
take the first in that order, and `'nearest'` take the nearest, the first of them among equal distances. By
default the ties are `'random'` without a vision and `'nearest'` with one.
- A function `step(agent, model)` returns the destination, or `None` to stay.
An agent for which `condition(agent, model)` holds attempts one step every step of time, or a Poisson number of
steps with the mean `rate`, and takes each attempt with the probability `probability` and then with the probability
`accept(agent, destination, model)`. An exclusive movement never moves an agent to a cell another agent of the group
holds. The agents move one after the other, in their order under the schedule `'sequential'`, in a new random order
every step under `'random-sequential'`, or each in its own activation under `'member'`, together with the rules of
its element and the other member interactions, in the order the system lists them. Every step taken charges its
`cost`, a `TransferInteraction` from the agent to itself or a function of the agent and the model view. The
movement is an agent rule, so the system runs in discrete time.
Args:
label:
The label of the interaction, unique among the parts of its system.
target:
The path of the group whose agents move.
step:
The kind of step, `'neighbor'`, `'gaussian'`, `'levy'`, or `'best'`, or a function of the agent and the
model view that returns the destination.
probability:
The probability that an agent takes an attempted step.
rate:
The mean number of attempted steps of an agent in one unit of time, or `None` for one attempt every step.
condition:
A function of the agent and the model view that returns whether the agent moves, or `None` for every agent.
accept:
A function of the agent, its destination, and the model view that returns the probability of taking the
step, or `None` to take every step.
exclusive:
Whether an agent moves only to cells or nodes no other agent of the group holds.
score:
The function of the agent, a neighbouring cell, and the model view that the step `'best'` maximizes.
scale:
The standard deviation of every component of a Gaussian step, or the least length of a Lévy step.
exponent:
The tail exponent of the length of a Lévy step.
cost:
A `TransferInteraction` that the agent pays to itself or to the environment for every step taken, a function
of the agent and the model view run for every step taken, or `None`.
vision:
The number of cells the step `'best'` looks along every direction of a lattice, a number or a function of
the agent and the model view, or `None` for the cells next to the agent.
schedule:
The order of the agents, `'sequential'`, `'random-sequential'`, or `'member'`.
phase:
The phase of the step the movement runs in, or `None` for the default phase.
ties:
The cell the step `'best'` takes among cells of equal highest score, `'random'`, `'first'`, or `'nearest'`,
or `None` for `'random'` without a vision and `'nearest'` with one.
Raises:
ValidationError:
The step, the schedule, or the ties are unknown, the rate is not positive, a function is not callable, or
the step `'best'` has no score, raised by `check`.
Examples:
>>> from skcomplex.core.elements import Element, GroupElement
>>> from skcomplex.core.interactions import MovementInteraction
>>> from skcomplex.core.spaces import LatticeSpace
>>> from skcomplex.core.system import System
>>> from skcomplex.engine.runtime import Simulation
>>> walkers = GroupElement('walkers', element=Element, size=2, location=[(2, 2), (2, 2)])
>>> walk = MovementInteraction('walk', target='walkers', step='neighbor')
>>> space = LatticeSpace((5, 5), neighborhood='von_neumann')
>>> system = System(elements=[walkers], interactions=[walk], space=space)
>>> trajectory = Simulation(system, random_state=0).check().prepare().run(until=1).trajectory_
>>> [bool(abs(cell - 2).sum() == 1) for cell in trajectory.agents('walkers', 1).locations]
[True, True]
"""
def __init__(
self,
label: str,
*,
target: Annotated[str, RoleSpec()],
step: Step = 'neighbor',
probability: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 1.0,
rate: float | None = None,
condition: Callable[[AgentViewProtocol, ModelViewProtocol], bool] | None = None,
accept: Callable[[AgentViewProtocol, object, ModelViewProtocol], float] | None = None,
exclusive: bool = False,
score: Callable[[AgentViewProtocol, object, ModelViewProtocol], float] | None = None,
scale: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 1.0,
exponent: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 1.5,
cost: Cost | None = None,
vision: Vision | None = None,
schedule: str = 'random-sequential',
phase: str | None = None,
ties: str | None = None,
) -> None:
super().__init__(label)
self.target = target
self.step = step
self.probability = probability
self.rate = rate
self.condition = condition
self.accept = accept
self.exclusive = exclusive
self.score = score
self.scale = scale
self.exponent = exponent
self.cost = cost
self.vision = vision
self.schedule = schedule
self.phase = phase
self.ties = ties
def _check_functions(self) -> None:
"""Raise an error for a condition, an acceptance, a score, or a cost that cannot be called."""
for name in ('condition', 'accept', 'score'):
value = getattr(self, name)
if value is not None and not callable(value):
error_msg = f'The {name} of {self.label!r} is {value!r}. Give a function, or None.'
raise ValidationError(error_msg)
if isinstance(self.cost, TransferInteraction):
self.cost.check()
elif self.cost is not None and not callable(self.cost):
error_msg = f'The cost of {self.label!r} is {self.cost!r}. Give a TransferInteraction, a function, or None.'
raise ValidationError(error_msg)
def _check_space(self, space: BaseSpace | None) -> None:
"""Raise an error for a step to a neighbouring cell or node in a continuous space."""
if isinstance(self.step, str) and self.step in GRID_STEPS:
self._check_grid('step', self.step, space, "step='gaussian' or step='levy', or a step function")
def _check(self) -> None:
"""Raise an error for an unknown step, a rate that is not positive, or a function that cannot be called.
Raises:
ValidationError:
The step, the schedule, or the ties are unknown, the rate is not positive, a function is not callable,
or the step `'best'` has no score.
"""
super()._check()
self._check_variant('step', self.step, STEPS, functions=True)
if self.rate is not None and (isinstance(self.rate, bool) or not self.rate > 0):
error_msg = f'The rate of {self.label!r} is {self.rate!r}. Give a positive number, or None.'
raise ValidationError(error_msg)
if self.schedule not in SCHEDULES:
error_msg = f'Unknown schedule {self.schedule!r} of {self.label!r}. Use one of {", ".join(SCHEDULES)}.'
raise ValidationError(error_msg)
if self.ties is not None:
self._check_variant('ties', self.ties, TIES)
if self.vision is not None and not callable(self.vision) and not isinstance(self.vision, int | float):
error_msg = f'The vision of {self.label!r} is {self.vision!r}. Give a number, a function, or None.'
raise ValidationError(error_msg)
if self.step == 'best' and self.score is None:
error_msg = f'The step `best` of {self.label!r} maximizes a score. Pass `score(agent, cell, model)`.'
raise ValidationError(error_msg)
self._check_functions()
def _count_attempts(self, model: ModelViewProtocol) -> int:
"""Return the number of steps an agent attempts in one step of time."""
return 1 if self.rate is None else int(model.rng.poisson(self.rate))
def _get_cells(self, agent: AgentViewProtocol, occupied: set[object]) -> list[object]:
"""Return the cells or nodes around an agent that it may move to."""
cells = agent.neighbor_cells()
if not self.exclusive:
return cells
return [cell for cell in cells if cell not in occupied]
def _get_best(
self,
agent: AgentViewProtocol,
cells: list[object],
model: ModelViewProtocol,
distances: list[int] | None = None,
) -> object:
"""Return the cell of the highest score, chosen among ties by the tie rule."""
if self.score is None or not cells:
return None
scores = [self.score(agent, cell, model) for cell in cells]
ties = self.ties or ('random' if distances is None else 'nearest')
if ties != 'nearest':
return cells[self._pick_best(scores, ties, model)]
values = np.asarray(scores, dtype=float)
best = np.flatnonzero(values == values.max())
near = np.asarray([1 if distances is None else distances[int(index)] for index in best])
return cells[int(best[near == near.min()][0])]
@staticmethod
def _shift(here: tuple[int, ...], cell: tuple[int, ...], model: ModelViewProtocol) -> tuple[int, ...]:
"""Return the unit step from a cell to a neighbouring cell, across the wrapped edge of a torus."""
shift = [b - a for a, b in zip(here, cell, strict=True)]
if model.torus:
sizes = [int(item) for item in model.dimensions or ()]
shift = [d - round(d / n) * n for d, n in zip(shift, sizes, strict=True)]
return tuple(shift)
def _get_sight(
self,
agent: AgentViewProtocol,
occupied: set[object],
model: ModelViewProtocol,
) -> tuple[list[object], list[int]]:
"""Return the cells in sight of an agent along every direction of a lattice, its own first, with distances."""
vision = self.vision(agent, model) if callable(self.vision) else self.vision
reach = int(vision or 0)
here = tuple(int(item) for item in np.atleast_1d(np.asarray(agent.location_key)))
sizes = [int(item) for item in model.dimensions or ()]
cells: list[object] = [agent.location_key]
distances = [0]
shifts = [
self._shift(here, tuple(int(item) for item in np.atleast_1d(np.asarray(neighbor))), model)
for neighbor in agent.neighbor_cells()
]
for shift in sorted(shifts, key=lambda step: tuple((item == 0, -item) for item in step)):
for distance in range(1, reach + 1):
point = [a + distance * d for a, d in zip(here, shift, strict=True)]
if model.torus:
point = [item % size for item, size in zip(point, sizes, strict=True)]
elif any(not 0 <= item < size for item, size in zip(point, sizes, strict=True)):
break
cell = tuple(point)
if not self.exclusive or cell not in occupied:
cells.append(cell)
distances.append(distance)
return cells, distances
def _get_destination(self, agent: AgentViewProtocol, model: ModelViewProtocol, occupied: set[object]) -> object:
"""Return the location a step of an agent leads to, or `None` to stay."""
rng = model.rng
if callable(self.step):
return self.step(agent, model)
if self.step == 'best' and self.vision is not None:
cells, distances = self._get_sight(agent, occupied, model)
return self._get_best(agent, cells, model, distances)
if self.step in ('neighbor', 'best'):
cells = self._get_cells(agent, occupied)
if self.step == 'best':
return self._get_best(agent, cells, model)
return cells[int(rng.integers(len(cells)))] if cells else None
position = np.asarray(agent.location, dtype=float)
if self.step == 'gaussian':
return position + rng.normal(0.0, self._get_parameter_value('scale', model), position.shape)
direction = rng.normal(size=position.shape)
length = self._get_parameter_value('scale', model) * (
1.0 + rng.pareto(self._get_parameter_value('exponent', model))
)
return position + length * direction / np.linalg.norm(direction)
def _charge(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Charge the cost of one step taken to an agent."""
if isinstance(self.cost, TransferInteraction):
self.cost.transfer(agent, agent, model)
elif self.cost is not None:
self.cost(agent, model)
def _take(self, agent: AgentViewProtocol, destination: object, occupied: set[object]) -> bool:
"""Move an agent to a destination unless an exclusive movement finds it held, and return whether it moved."""
if not self.exclusive:
agent.move_to(destination)
return True
if destination == agent.location_key:
return True
if destination in occupied:
return False
occupied.discard(agent.location_key)
agent.move_to(destination)
occupied.add(agent.location_key)
return True
def _move(
self,
agent: AgentViewProtocol,
model: ModelViewProtocol,
probability: float,
occupied: set[object],
) -> None:
"""Take the attempted steps of one agent, each with its probability and acceptance, charging every step."""
for _ in range(self._count_attempts(model)):
if probability < 1.0 and model.rng.random() >= probability:
continue
destination = self._get_destination(agent, model, occupied)
if destination is None:
continue
if self.accept is not None and model.rng.random() >= self.accept(agent, destination, model):
continue
if self._take(agent, destination, occupied):
self._charge(agent, model)
def _move_member(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Move one agent whose condition holds, in its own activation."""
if agent.alive and (self.condition is None or self.condition(agent, model)):
occupied = (
{other.location_key for other in model.agents(self._get_role('target'))} if self.exclusive else set()
)
self._move(agent, model, self._get_parameter_value('probability', model), occupied)
def _move_all(self, model: ModelViewProtocol) -> None:
"""Move every agent of the group whose condition holds, in their order or in random order."""
agents = model.agents(self._get_role('target'))
occupied = {agent.location_key for agent in agents} if self.exclusive else set()
probability = self._get_parameter_value('probability', model)
order = model.rng.permutation(len(agents)) if self.schedule == 'random-sequential' else range(len(agents))
for index in order:
agent = agents[int(index)]
if agent.alive and (self.condition is None or self.condition(agent, model)):
self._move(agent, model, probability, occupied)
def _get_laws(self, namespace: SimpleNamespace) -> list[BaseLaw]:
"""Return the rule of the movement.
Args:
namespace:
The namespace of the symbols the laws can use.
Returns:
laws:
The rule that moves the agents every step.
"""
if self.schedule == 'member':
return [RuleLaw(self._move_member, phase=self.phase, members=self._get_role('target'))]
return [RuleLaw(self._move_all, phase=self.phase)]
class TransitionInteraction(Interaction):
"""Move of a discrete state of the target from one value to another, or between all its values by a matrix.
A member whose state holds `source` moves to `destination` at a `rate`, as a jump in continuous time, with a
`probability` in every step of discrete time, or in the step in which a `condition` holds. With `over='neighbors'`
the rate and the probability are those of one neighbour, written with `namespace.target.other` for the neighbour:
the rates of the neighbours add up, and their probabilities combine as `1 - prod(1 - p)`, the probability that at
least one of them moves the member. With a matrix instead, the state takes the values `0` to `n - 1` and steps by
the rows of a transition matrix in discrete time, the row of its current value. An `emission` matrix then draws the
`observed` state of the target from the row that the next value of the state selects, as a hidden Markov source.
With a residence time `after`, a member moves by a probability or a condition only once it has held the source
value for `after` steps, at the earliest at the end of the step that completes them.
In the `'symbolic'` form, every function reads `namespace.target`, the states and parameters of the target, and the
time `namespace.t`. In the `'agent'` form, the target is an agent group and every function takes the agent and
the model view, `f(agent, model)`, and returns a number, a truth value, or the rows of a matrix, and a rate `r`
moves an agent with probability `1 - exp(-r)` in a step. With `over='cell'`, every member acts on the other members
of its cell: it moves each one that holds the source value with the probability, the rate, or the condition
`f(agent, other, model)` of that member `agent` and the acting member `other`, each by its own draw.
The `schedule` of the agent form orders the members. Under `'synchronous'` every member decides from the states at
the start of the step, under `'sequential'` the members act one after the other in their order, and under
`'random-sequential'` in a new random order every step, each reading the values the members before it set. Under
`'member'` every member acts in its own activation, together with the rules of its element and the other member
interactions, in the order the system lists them, so one rule of a member runs its movement, transfers,
transitions, and life cycle.
Args:
label:
The label of the interaction, unique among the parts of its system.
target:
The path of the element or group whose state moves.
state:
The name of the discrete state of the target.
source:
The value the state moves from, a collection of such values, or a function of the value of the state that
returns whether the state moves from it, such as `lambda value: value >= 0`, a condition in the symbolic
form. A state that moves from several values moves to the destination from each.
destination:
The value the state moves to.
rate:
The function of the rate of the move, or `None`.
probability:
The function of the probability of the move in a step, or `None`.
condition:
The function of the condition under which the state moves in a step, or `None`.
matrix:
The function of the transition matrix, one row and one column per value of the state, or `None`.
emission:
With a matrix, the function of the emission matrix, one row per value of the state and one column per value
of the observed state, or `None`.
observed:
The name of the state of the target that the emission matrix draws, or `None`.
over:
`'member'` for a rate or a probability of the member, `'neighbors'` for that of one neighbour, or `'cell'`
for the agent form whose members act on the members of their cell.
neighborhood:
The neighbourhood of `over='neighbors'`, or `None` for the neighbours of the network or the lattice.
after:
The number of steps a member holds the source value before it can move, or `None`.
schedule:
The order of the members of the agent form, `'synchronous'`, `'sequential'`, `'random-sequential'`, or
`'member'`.
form:
`'symbolic'` for laws of the states of the target, or `'agent'` for a rule over an agent group.
phase:
The phase of the step the agent form runs in, or `None` for the default phase.
Raises:
ValidationError:
The transition has not exactly one kind, the source and the destination are equal, an emission has no
matrix or no observed state, `over` is unknown or goes with a form or a kind it cannot take, the
residence time is negative or goes with a rate, a matrix, or members acting on their cell, the form or the
schedule is unknown, the symbolic form has a schedule, or a function is not callable.
Examples:
>>> from skcomplex.core.interactions import TransitionInteraction
>>> recovery = TransitionInteraction(
... 'recovery', target='hosts', state='state', source=1, destination=0, rate=lambda s: 0.5
... )
>>> recovery.check().over
'member'
>>> agents = TransitionInteraction(
... 'recovery', target='hosts', state='state', source=1, destination=2, probability=lambda a, m: 0.1,
... after=3, form='agent'
... )
>>> type(agents.check().prepare().run(SimpleNamespace()).laws_[0]).__name__
'RuleLaw'
>>> separation = TransitionInteraction(
... 'separation', target='workers', state='employer', source=lambda value: value >= 0, destination=-1,
... probability=lambda a, m: 0.05, form='agent'
... )
>>> separation.check().destination
-1
"""
def __init__(
self,
label: str,
*,
target: Annotated[str, RoleSpec()],
state: str = 'state',
source: SourceValues = 0.0,
destination: float = 1.0,
rate: Callable[..., SupportsFloat] | None = None,
probability: Callable[..., SupportsFloat] | None = None,
condition: Callable[..., object] | None = None,
matrix: Callable[..., Sequence[Sequence[object]]] | None = None,
emission: Callable[..., Sequence[Sequence[object]]] | None = None,
observed: str | None = None,
over: str = 'member',
neighborhood: BaseNeighborhood | None = None,
after: float | None = None,
schedule: str = 'synchronous',
form: str = 'symbolic',
phase: str | None = None,
) -> None:
super().__init__(label)
self.target = target
self.state = state
self.source = source
self.destination = destination
self.rate = rate
self.probability = probability
self.condition = condition
self.matrix = matrix
self.emission = emission
self.observed = observed
self.over = over
self.neighborhood = neighborhood
self.after = after
self.schedule = schedule
self.form = form
self.phase = phase
def _check_kind(self) -> str:
"""Raise an error for a transition without exactly one callable kind, or with an emission it cannot take."""
kinds = [kind for kind in KINDS if getattr(self, kind) is not None]
if len(kinds) != 1 or not callable(getattr(self, kinds[0])):
error_msg = (
f'The transition {self.label!r} has the forms {kinds}. Give exactly one of {", ".join(KINDS)}, as a '
'function of the namespace.'
)
raise ValidationError(error_msg)
unpaired = (self.emission is None) != (self.observed is None)
if unpaired or (self.emission is not None and self.matrix is None):
error_msg = (
f'The transition {self.label!r} has the emission {self.emission!r} of the state {self.observed!r}. '
'Give both with a matrix, or neither.'
)
raise ValidationError(error_msg)
return kinds[0]
def _check_form(self) -> None:
"""Raise an error for a transition without exactly one callable kind, or with arguments its kind ignores."""
kind = self._check_kind()
if self.over not in OVERS:
error_msg = f'The transition {self.label!r} runs over {self.over!r}. Use one of {", ".join(OVERS)}.'
raise ValidationError(error_msg)
if self.form not in FORMS:
error_msg = f'The form of transition {self.label!r} is {self.form!r}. Use one of {", ".join(FORMS)}.'
raise ValidationError(error_msg)
if self.schedule not in SCHEDULES or (self.form == 'symbolic' and self.schedule != 'synchronous'):
error_msg = (
f'The schedule of transition {self.label!r} is {self.schedule!r}. Use one of {", ".join(SCHEDULES)}, '
'and the agent form for another than `synchronous`.'
)
raise ValidationError(error_msg)
if self.over == 'cell' and (self.form != 'agent' or kind == 'matrix' or self.after is not None):
error_msg = (
f'The members of transition {self.label!r} act on their cell, which only the agent form does, by a '
'rate, a probability, or a condition and without a residence time.'
)
raise ValidationError(error_msg)
if self.over == 'neighbors' and (kind not in {'rate', 'probability'} or self.form == 'agent'):
error_msg = (
f'The transition {self.label!r} runs over neighbours, which only a rate or a probability of the '
'symbolic form does. In the agent form, read the neighbours of the agent in the function.'
)
raise ValidationError(error_msg)
single = not (callable(self.source) or isinstance(self.source, Collection))
if self.matrix is None and single and self.source == self.destination:
error_msg = f'The transition {self.label!r} moves from {self.source} to itself. Give distinct values.'
raise ValidationError(error_msg)
if self.after is not None and (kind not in {'probability', 'condition'} or not self.after >= 0):
error_msg = (
f'The transition {self.label!r} has the residence time {self.after!r}. Give a number of steps at least '
'0 with a probability or a condition.'
)
raise ValidationError(error_msg)
def _check_transition(self) -> None:
"""Raise an error for arguments the transition cannot take."""
self._check_form()
def _check(self) -> None:
"""Raise an error for a transition without exactly one kind, or with arguments its kind ignores.
Raises:
ValidationError:
The transition has not exactly one kind, the source and the destination are equal, an emission has
no matrix or no observed state, `over` is unknown or goes with a condition, a matrix, or the agent
form, the residence time is negative or goes with a rate or a matrix, the form is unknown, or a
function is not callable.
"""
super()._check()
self._check_transition()
def _get_moving(self, value: Expression, source: SourceValues) -> Expression:
"""Return 1 for a member whose state holds a source value, and 0 otherwise."""
if callable(source):
return where(SR(source(value)), 1, 0)
if isinstance(source, Collection):
return sum((where(value == item, 1, 0) for item in set(source)), SR(0))
return where(value == source, 1, 0)
def _get_jump(self, value: Expression, source: SourceValues, destination: float, rate: Expression) -> JumpLaw:
"""Return the jump of the state from a source value to the destination at a rate."""
if callable(source) or isinstance(source, Collection):
return JumpLaw(rate * self._get_moving(value, source), {value: destination - value})
return JumpLaw(rate * self._get_moving(value, source), {value: destination - source})
def _get_step(self, value: Expression, source: SourceValues, destination: float, moves: Expression) -> UpdateLaw:
"""Return the update of the state that moves a member from a source value where a condition holds."""
if callable(source) or isinstance(source, Collection):
return UpdateLaw(value, where(self._get_moving(value, source) > 0, where(moves, destination, value), value))
return UpdateLaw(value, where(value == source, where(moves, destination, source), value), source=source)
def _holds_source(self, value: float) -> bool:
"""Return whether the value of the state of an agent is a source value."""
if callable(self.source):
return bool(self.source(value))
if isinstance(self.source, Collection):
return value in set(self.source)
return value == self.source
def _combine_rates(self, rate: Expression) -> Expression:
"""Return the rate of the member, or the sum of the rates of its neighbours."""
return neighbors(rate, over=self.neighborhood) if self.over == 'neighbors' else rate
def _combine_probabilities(self, probability: Expression) -> Expression:
"""Return the probability of the member, or the probability that at least one neighbour moves it."""
if self.over == 'member':
return probability
return 1 - exp(neighbors(log(1 - probability), over=self.neighborhood))
def _get_draw(self) -> Expression:
"""Return the uniform draw of the transition in a step."""
return uniform(re.sub(r'\W', '_', f'transition_{self.label}'))
def _get_chain(self, namespace: SimpleNamespace, value: Expression) -> list[BasePart]:
"""Return the transition law of the state by its matrix, with the emission law of the observed state."""
laws: list[BasePart] = []
if self.matrix is not None:
laws.append(TransitionLaw(value, self.matrix(namespace), label=self.label))
if self.emission is not None and self.observed is not None:
observed = getattr(self._get_member(namespace, 'target'), self.observed)
laws.append(EmissionLaw(observed, value, self.emission(namespace), label=self.label))
return laws
def _get_held(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> int:
"""Return the number of whole steps an agent has held its value, counted in the storage of the model."""
value = float(getattr(agent, self.state))
record = model.storage.get(f'{self.label}.{agent.id}')
held = record[1] + 1 if isinstance(record, tuple) and record[0] == value else 0
model.storage[f'{self.label}.{agent.id}'] = (value, held)
return held
def _draw_row(self, row: Sequence[object], model: ModelViewProtocol) -> float:
"""Return a value drawn by the probabilities of a row of a matrix."""
weights = np.asarray(row, dtype=float)
return float(model.rng.choice(weights.size, p=weights / weights.sum()))
def _evaluate[T](
self,
function: Callable[..., T],
agent: AgentViewProtocol,
other: AgentViewProtocol | None,
model: ModelViewProtocol,
) -> T:
"""Return a function of an agent, or of an agent and the member of its cell that acts on it."""
return function(agent, model) if other is None else function(agent, other, model)
def _moves_agent(
self,
agent: AgentViewProtocol,
model: ModelViewProtocol,
other: AgentViewProtocol | None = None,
) -> bool:
"""Return whether an agent that holds the source value moves in this step, moved by another one or alone."""
if self.condition is not None:
return bool(self._evaluate(self.condition, agent, other, model))
if self.rate is not None:
probability = 1.0 - math.exp(-float(self._evaluate(self.rate, agent, other, model)))
elif self.probability is not None:
probability = float(self._evaluate(self.probability, agent, other, model))
else:
probability = 0.0
return bool(model.rng.random() < probability)
def _get_next(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> float | None:
"""Return the next value of the state of an agent, or `None` when it keeps its value."""
value = float(getattr(agent, self.state))
if self.matrix is not None:
return self._draw_row(self.matrix(agent, model)[int(value)], model)
held = self._get_held(agent, model) if self.after is not None else 0
if not self._holds_source(value) or (self.after is not None and held + 1 < self.after):
return None
return float(self.destination) if self._moves_agent(agent, model) else None
def _get_moves(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> list[tuple[AgentViewProtocol, float]]:
"""Return the members an agent moves in this step, itself or the members of its cell, with their values."""
if self.over != 'cell':
value = self._get_next(agent, model)
return [] if value is None else [(agent, value)]
return [
(other, float(self.destination))
for other in agent.agents_here(agent.group)
if other is not agent
and self._holds_source(float(getattr(other, self.state)))
and self._moves_agent(other, model, agent)
]
def _apply(self, moves: list[tuple[AgentViewProtocol, float]], model: ModelViewProtocol) -> None:
"""Set the moved states, and draw the observed state of an emission."""
for agent, value in moves:
setattr(agent, self.state, value)
if self.emission is not None and self.observed is not None:
setattr(agent, self.observed, self._draw_row(self.emission(agent, model)[int(value)], model))
def _move_member(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Move the state of one member, or of the members of its cell, from the states the members before it set."""
if agent.alive:
self._apply(self._get_moves(agent, model), model)
def move(self, model: ModelViewProtocol) -> None:
"""Move the state of every agent of the target that moves in this step, by the schedule.
Args:
model:
The model view of the interaction.
"""
agents = list(model.agents(self._get_role('target')))
if self.schedule == 'synchronous':
moves = [move for agent in agents for move in self._get_moves(agent, model)]
self._apply(moves, model)
return
order = model.rng.permutation(len(agents)) if self.schedule == 'random-sequential' else range(len(agents))
for index in order:
self._move_member(agents[int(index)], model)
def _get_laws(self, namespace: SimpleNamespace) -> list[BasePart]:
"""Return the jump, the update, the transition or emission law of the state, or the rule of the agent form.
Args:
namespace:
The namespace of the symbols the laws can use, with `namespace.target`.
Returns:
laws:
The law of the move, or the rule that moves the agents.
"""
if self.form == 'agent' and self.schedule == 'member':
return [RuleLaw(self._move_member, phase=self.phase, members=self._get_role('target'))]
if self.form == 'agent':
return [RuleLaw(self.move, phase=self.phase)]
value = getattr(self._get_member(namespace, 'target'), self.state)
if self.matrix is not None:
return self._get_chain(namespace, value)
if self.rate is not None:
rate = self._combine_rates(SR(self.rate(namespace)))
return [self._get_jump(value, self.source, self.destination, rate)]
if self.probability is not None:
probability = self._combine_probabilities(SR(self.probability(namespace)))
moves = self._get_draw() < probability
else:
moves = SR(self.condition(namespace)) if self.condition is not None else SR(0)
if self.after is not None:
moves = both(residence(value) + 1 >= self.after, moves)
return [self._get_step(value, self.source, self.destination, moves)]