"""The simulation: a grid, a list of fields, a list of populations, a clock.
:class:`Simulation` owns the shared :class:`Grid`, a *list* of fields, and a
*list* of populations, and advances them together. Both are lists from the
start so that adding a pheromone field or a predator population later is an
append, not a rewrite. v1 has one resource field and one greedy forager
population; the :meth:`forager` factory builds that world from the LEAFS applet
parameters.
"""
import numpy as np
from pyLEAFS.grid import Grid
from pyLEAFS.fields import ResourceField
from pyLEAFS.population import Population
[docs]
class Simulation:
"""A clocked collection of fields and populations on a shared grid.
Parameters
----------
grid : Grid
Shared spatial grid.
fields : list
Fields to step each timestep (e.g. a :class:`ResourceField`).
populations : list of Population
Populations to step each timestep.
dt : float
Timestep.
rng : numpy.random.Generator, optional
Random source; defaults to a fresh unseeded generator.
Attributes
----------
step_count : int
Number of timesteps advanced.
time : float
Physical time elapsed (``step_count * dt``).
Examples
--------
>>> sim = Simulation.forager(seed=0)
>>> sim.run(50)
>>> sim.populations[0].count >= 0
True
"""
def __init__(self, grid, fields, populations, dt, rng=None):
self.grid = grid
self.fields = list(fields)
self.populations = list(populations)
self.dt = float(dt)
self.rng = rng if rng is not None else np.random.default_rng()
self.step_count = 0
@property
def time(self):
return self.step_count * self.dt
[docs]
def step(self):
"""Advance every field then every population by one timestep."""
for f in self.fields:
f.step(self.rng)
for p in self.populations:
p.step(self.fields, self.rng)
self.step_count += 1
[docs]
def run(self, n_steps, stop_on_extinction=True):
"""Advance ``n_steps`` timesteps.
Parameters
----------
n_steps : int
Number of steps to advance.
stop_on_extinction : bool, optional
Stop early if every population is empty (default True).
Returns
-------
int
Number of steps actually taken.
"""
for k in range(n_steps):
self.step()
if stop_on_extinction and all(p.count == 0 for p in self.populations):
return k + 1
return n_steps
# ----------------------------------------------------------- factory
[docs]
@classmethod
def forager(cls, seed=None, shape=(10, 10), n_agents=5, Xi=0.5):
"""Build the v1 greedy-forager world from ``forager_applet.js``.
The environment homogeneity ``Xi`` is the primary control: the energy
per resource is derived from it as in the applet,
.. math::
\\epsilon = \\Gamma / \\big((\\Xi / r_\\mathrm{col})^2\\,\\gamma\\big),
so that the equilibrium resource density rises as ``Xi`` falls (a more
homogeneous field). Remaining parameters match the applet: ``mu0=0.1``,
``dt=0.02``, ``v=20``, ``R_sense=6``, ``r_collect=1``, ``s_max=1``,
reproduction at ``0.8`` with the daughter budded ``r_collect`` away.
Parameters
----------
seed : int, optional
RNG seed for reproducibility.
shape : tuple of int, optional
Regions per axis; length 2 or 3 selects the dimension
(default ``(10, 10)``, the applet's 100x100 domain at L=10).
n_agents : int, optional
Initial number of foragers (default 5).
Xi : float, optional
Environment homogeneity parameter (default 0.5).
Returns
-------
Simulation
A seeded simulation with one resource field and one forager
population, both populated near their initial conditions.
"""
rng = np.random.default_rng(seed)
L, dt = 10.0, 0.02
Gamma, gamma, r_collect = 0.001, 0.1, 1.0
epsilon = Gamma / ((Xi / r_collect) ** 2 * gamma)
grid = Grid(shape, L=L)
resource = ResourceField(grid, Gamma=Gamma, gamma=gamma, epsilon=epsilon, dt=dt)
resource.seed(rng)
pop = Population(
grid, v=20.0, dt=dt, r_collect=r_collect, R_sense=6.0,
mu0=0.1, s_max=1.0, sigma=0.1, tau_A=1.0, repro_fraction=0.8,
)
pop.seed(n_agents, rng)
return cls(grid, [resource], [pop], dt=dt, rng=rng)