Agent-based modelling¶
Populations of agents, each running its own small statechart, alongside the ordinary block flow
chart and system dynamics -- the same model file, the same run_model. One AgentSystem process runs a whole population (not one SimPy process per
agent), so a 10,000-agent SIR epidemic over 1,000 time units is a few thousand events, under a
second.
Agent types and populations¶
An agent type (agent_types) declares the states, transitions, parameters and attributes every
agent of that kind shares; a population (populations) is N agents of one type, with how
many start in which state:
"agent_types": [{"name": "Person", "parameters": {"beta": 0.3, "gamma": 0.1},
"attributes": [{"name": "susceptibility", "init": "UNIF(0.5, 1.5)"}],
"states": [{"name": "S"}, {"name": "I", "color": "RED"}, {"name": "R"}],
"initial_state": "S",
"transitions": [
{"from": "S", "to": "I", "rate": "self.susceptibility * beta * COUNT(\"People\", \"I\") / COUNT(\"People\")"},
{"from": "I", "to": "R", "timeout": "EXPO(1 / gamma)"}]}],
"populations": [{"name": "People", "type": "Person", "count": 10000,
"initial": [{"state": "I", "count": 10}]}]
parameters are the type's own named constants (an expression scope's locals, like a submodel's
parameters); attributes are per-agent numeric columns, each initialised once per agent from init
(the agent's own random stream, so two runs with the same seed draw the same values). self.<attr>
reads an agent's own row; self.state/self.id are read-only.
A population with attributes also gets an agent_attributes results frame: one row per
(population, attribute, state-or-"All"), with End Mean/P05/P50/P95/Min/Max over the agents alive at
the end of the run and a Time-Avg Mean -- the exact time average of the population-wide mean,
piecewise-constant between agent events (the same treatment agent_summary's own Avg Count gets),
computed for the "All" row only. KPI keys agent/<population>/<attribute>/<state>/<column>; the
"Agent attributes" export sheet.
Populations from data¶
count may name a model table instead of a literal number:
"tables": [{"name": "People", "keys": [0, 1, 2],
"columns": ["age"], "values": [[34], [61], [8]]}],
"agent_types": [{"name": "Person", "attributes": [{"name": "age", "init": {"column": "age"}}],
"states": [{"name": "S"}]}],
"populations": [{"name": "People", "type": "Person", "count": {"table": "People"}}]
{"table": "<name>"} is the table's ROW COUNT: one agent per row, row i for agent i. An
attribute's init may then read {"column": "<col>"} instead of an expression -- row i of the
POPULATION's own table for agent i, straight from the data, no random draw. {"table": "<name>",
"column": "<col>"} is instead the SUM of that column, for a table whose rows are groups (a
headcount by region) rather than individual agents -- a group table like this has no row-per-agent
attribute init, since a row is no longer one agent. max_count (births and deaths, below) defaults
to the resolved count either way. A spawned agent has no table row of its own: its data-column
attributes start at 0, same as any other attribute's default, unless the spawn action's own init
sets them. Python: Population("People", "Person", count={"table": "People"}), Attribute("age",
init={"column": "age"}).
Categorical attributes¶
An attribute is numeric by default; "kind": "category" makes it a closed set of names instead --
stored as that name's position in the list (a code, exact in the same column a numeric attribute
uses), so it costs nothing extra to keep:
{"name": "colour", "kind": "category", "categories": ["red", "blue", "green"], "init": "\"red\""}
init is a quoted category name, {"column": "<col>"} (a table column, holding the CODE --
tables stay numeric-only, the contract TABLE()/schedules already rely on -- not the name), or any
expression that evaluates to one of the declared names (another self.<attr> of the same
categories, IF(...), ...); an unknown name, at compile time for a literal or at run time otherwise,
is E_AGENT_ATTRIBUTE. self.<attr> reads back the NAME, so self.colour == "red",
!= and in ("red", "blue") work exactly as self.state == "<name>" already does -- no new
grammar -- and the ==/!=-to-a-literal form compiles to a fast code comparison over the whole
population (AB-4) the same way a state comparison does. A set action's expr must land on one of
the categories too (another categorical attribute of the same list, most simply); arithmetic on a
category, or an expression that lands on something else, raises the same way. SUM/MEAN refuse a
categorical attribute (E_AGENT_ATTRIBUTE: they average codes, which is not a number worth having);
count by category instead:
COUNT("People", "colour", "red") -- agents whose colour is "red"
COUNT("People", "I", "colour", "red") -- also in state "I"
agent_attributes reports a categorical attribute as one row per (population, attribute,
state-or-"All", category) instead, "End Mean" holding that category's SHARE (fraction) of the
group instead of a percentile -- the other stat columns and Time-Avg Mean stay NaN. KPI keys fold the
category in: agent/<population>/<attribute>=<category>/<state>/End Mean.
States and transitions¶
A transition fires on exactly one trigger:
| Trigger | Meaning |
|---|---|
timeout |
a duration expression, drawn once on entering the source state |
rate |
a hazard rate per agent per time unit (see "Rate forms" below) |
condition |
a boolean, re-checked after every agent event (add "scan": <interval> to also poll on a grid); it cannot draw random numbers -- use a rate |
message |
a name ("" matches any) delivered by a send action, a SEND block, or move_to's own "arrived" |
A transition may add a guard (checked when the trigger fires; false consumes a timeout, redraws a
rate, or just holds a condition) and actions -- Action.set, .send, .enter, .transfer,
.signal, .move_to. On a firing: the source's exit actions, the transition's own actions, the
move, the target's entry actions, then the target's own transitions are armed -- so an entry
action can set something an outgoing rate reads.
Rate forms: shared, own, product¶
A rate expression is classified once, when the model is built:
- shared -- no
self(COUNT("People", "I") / COUNT("People")): the same hazard for every agent in the state, changing only when the shared part changes. - own -- only
self, parameters, constants and a short list of pure functions (min,max,abs,IF, ...): a per-agent weight, constant for that agent's whole time in the state. - product --
own * sharedorown / shared(either order): the common case,self. susceptibility * beta * COUNT(...)above is one.
Anything else -- a rate that mixes self into the shared part some other way, or varies
continuously with time (a bare TNOW, a linear RAMP) -- is refused (E_AGENT_RATE_FORM) with a
hint to write it as own * shared. The reason is exactness: each agent's hazard integrates to a
closed form between the shared rate's change points (Anderson's modified next-reaction method),
so the runtime never redraws every agent's clock when only one of them fires --
O(log N) per event, not O(N).
Messages¶
Action.send(message, to, payload=None) (or a SEND block) queues one message per recipient on a
single FIFO, delivered synchronously as soon as the sender's own firing (or block) finishes, before
the next tied event. Delivery fires the first message transition out of the recipient's CURRENT
state whose name matches ("" matches any) and whose guard holds, with payload readable in that
guard and its actions; otherwise the message is dropped and counted (agent_summary's "Dropped
Messages"). to is an agent id, {"population": p, "where": cond}, or {"population": p, "random":
true}.
Inside a where (of a send, a remove, a link), self is the candidate and sender.<attr> the
agent doing the sending (sender.x, sender.y, sender.id, sender.state, its attributes); it is
refused anywhere else, and a where still may not draw. With "random": true the one candidate is
drawn from the SENDER's stream, which makes a one-to-one claim -- a wolf eating ONE sheep on its own
cell:
{"remove": {"population": "Sheep", "random": true,
"where": "(self.x - sender.x) * (self.x - sender.x) + (self.y - sender.y) * (self.y - sender.y) < 0.25"}}
A removed sheep is gone for the next wolf, so two wolves never eat the same one. A where that is
one comparison (as above) runs in numpy; and/or fall back to a per-candidate loop.
Action.enter(block, e_type=None) spawns a DES entity with entity.agent set to the sending
agent, so a block downstream can read whose agent created it. Action.transfer(level, amount, mode)
moves an SD level (conservation checked exactly as the TRANSFER block's own instant branch).
Action.signal(name) raises a DES signal like the SIGNAL block.
Space, movement, NEAR¶
An agent type's space is {"kind": "continuous", "bounds": [x0, y0, x1, y1]} or {"kind":
"network"} (the model's own network section); speed is an expression evaluated once per
Action.move_to(target) call. Position is stored as one linear motion segment per agent (x0, y0,
x1, y1, t0, t1), so a frame at any instant is exact with no per-tick update; on a network, the
segment follows the real polyline (Network.path); playback draws the token along the straight chord
between departure and arrival. Arrival delivers an "arrived" message -- no new trigger kind, just
the usual message machinery -- before any other event tied at that instant.
self.x and self.y read an agent's current position (read-only, any space, unless the type
declares attributes with those names). NEAR(pop, r[, state]) and NEAREST(pop[, state]) read self's position against a
scipy.spatial.cKDTree of pop's current positions, built at most once per distinct simulation
instant (repeat queries at the same time reuse it).
Neighbour aggregates¶
NEAR_SUM/NEAR_MEAN/NEAR_MIN/NEAR_MAX(pop, attr, r[, state]) reduce an attribute over the same
neighbourhood NEAR counts: alive agents of pop within r of self, inclusive, self excluded
when pop is self's own population, optionally restricted to state. KNN_SUM/KNN_MEAN(pop, attr,
k[, state]) reduce over the k nearest instead, the state filter applied before the k are chosen;
ties at the cutoff distance break by ascending agent index, not tree-traversal order. An empty
neighbourhood is 0 for SUM/MEAN (no agents to add or average) and NaN for MIN/MAX (no identity
element). The attribute must be numeric -- a categorical one is E_AGENT_ATTRIBUTE, the same rule
SUM/MEAN already enforce; use COUNT(pop, attr, value) to count a category instead.
NEAR_MEAN("Cars", "speed", 50) -- average speed within 50m, self excluded
KNN_SUM("Cars", "load", 3, "Loaded") -- total load of the 3 nearest loaded cars
Grid space and patches¶
A grid space divides the plane into w x h square cells, NetLogo style:
"space": {"kind": "grid", "size": [50, 50], "cell": 1, "torus": true,
"neighbourhood": "moore",
"patches": [{"name": "food", "init": "TABLE(\"food0\", cx, cy)",
"diffuse": 0.5, "evaporate": 0.1}],
"patch_tick": 1}
- Cells. Cell
(cx, cy)covers[cx * cell, (cx + 1) * cell)along x, and the same along y. Agents always sit at a cell centre, so several agents can share a cell. There is no per-cell capacity; useCELL_COUNTin a guard if you need one.NEAR,KNN_*and playback work as in a continuous space. A population's placement is{"kind": "random"}(a uniform cell drawn from the agent's own stream) or{"kind": "table", "points": [[cx, cy], ...]}. - Neighbourhood.
"moore"(default) is the 8 surrounding cells and"von_neumann"the 4 sharing an edge. The agent's own cell is never part of it. On a torus the edges wrap; on a bounded grid a cell at the edge simply has fewer neighbours. - Moves are instant (no travel time and no
"arrived"message):{"move_to": [cx, cy]}or{"move_to": "AGENT(\"Pop\", i)"}(that agent's cell);{"step": [dx, dy]}(integers or expressions; a step off a bounded grid leaves the agent where it is);{"move_random": "any"}moves to a neighbourhood cell drawn uniformly from the agent's own stream;{"move_random": "free"}does the same among cells holding no agent of its own population. With no free cell the agent stays and draws nothing (Schelling's move). - Counting.
NEIGHBOURS(pop[, state])counts pop's agents in self's neighbourhood.NEIGHBOUR_SUM/NEIGHBOUR_MEAN(pop, attr[, state])reduce an attribute over the same agents (0if none).CELL_COUNT(pop[, dx, dy])counts one cell, self's own by default and self included, and is0off a bounded grid. pop must be on a grid of the same size. - Patches are per-cell numbers shared by every population of the type.
initis a number or an expression ofcx,cy(TABLE, parameters and draws allowed; noself).PATCH("food"[, dx, dy])reads self's cell or one offset from it (0off a bounded grid).{"target": "patch.food", "expr": "PATCH(\"food\") + 1"}writes self's cell. - Diffusion and evaporation run at every
patch_tick(k * patch_tick, after the agents' own ticks at that instant). Each cell keeps1 - diffuseof its value and handsdiffuse / 8(Moore) ordiffuse / 4(von Neumann) to each neighbour. On a bounded grid, a share with no cell to go to stays home, so the total is conserved. Evaporation then multiplies every cell by1 - evaporate. - Regrowth (
"grow": {"rate": r, "max": m}) runs last: a cell belowmgainsr, clipped atm; one at or above it is left alone. Withoutmax, each cell grows back to its own initial value (Sugarscape's capacity landscape). NetLogo's grass countdown israte 1, max 30, with "green" meaningPATCH("grass") >= 30. - Synchronous ticks read the tick-start patches, like every other read. Positions and cell counts are live, so an agent sees the moves made earlier in the same tick.
- A grid call in a rate is
E_AGENT_RATE_FORM: neighbours change between events. Use a condition or a tick transition instead. Grid features without a grid space areE_AGENT_GRID, and patch mistakes areE_AGENT_PATCH. - Playback draws the agents only, not the patches. After a run, read a patch with
env.ctx.agent_system.patch_values("<type>", "<patch>"), an(h, w)array indexed[cy, cx].
from nagasim.core import agents
walker = agents.AgentType(
"Ant", states=["S"], tick={"dt": 1},
space=agents.Space("grid", size=[50, 50], torus=True,
patches=[{"name": "trail", "diffuse": 0.2, "evaporate": 0.05}],
patch_tick=1),
on_tick=[agents.Action.move_random(),
agents.Action.set("patch.trail", 'PATCH("trail") + 1')])
Links and networks¶
Agents of one population can be linked to each other, with or without a space. A population's
network draws the initial links at run start:
{"name": "People", "type": "Person", "count": 500,
"network": {"kind": "small_world", "k": 4, "beta": 0.1}}
kind |
Keys | Links |
|---|---|---|
random |
p or mean_degree |
Erdos-Renyi G(n, p): each pair linked with probability p (mean_degree = p (n - 1)). |
small_world |
k (even), beta |
Watts-Strogatz: a ring where each agent links to its k nearest, then each link rewired to a random agent with probability beta (beta = 0 is the plain ring). |
scale_free |
m |
Barabasi-Albert: agents 0..m-1 start unlinked, each later agent links to m earlier ones picked in proportion to their degree; m (n - m) links. |
table |
table |
The rows of a model table with columns from and to (agent indices). |
none |
No initial links; link actions add them. |
- Links are undirected unless
"directed": true(randomandtableonly). A directed linki -> jcounts fori:LINKED,to: "links"andIS_LINKEDfollow out-links. - The network has its own random stream, so adding one never moves any agent's draws. The same seed gives the same network.
- Links join agents of one population. A link between two populations is refused at run time.
- Reading links.
LINKED("People"[, "state"])counts self's linked neighbours (in that state).LINK_SUM("attr"[, "state"])andLINK_MEAN("attr"[, "state"])reduce an attribute over them (0if none).IS_LINKED(AGENT("People", i))tests one link. Like the grid calls, these are per-agent: they are allowed in a condition, a guard, an action or a tick transition, not in a rate (E_AGENT_RATE_FORM). - Sending.
"to": "links"sends to every linked neighbour, in index order.{"links": true, "where": "...", "random": true}filters them or picks one, like a population target. - Changing links.
{"link": <target>}and{"unlink": <target>}take any send target ("links"included, so{"unlink": "links"}drops them all). Self is skipped. - Births and deaths. A removed agent loses all its links. A newborn starts unlinked, or linked
to the agent that spawned it with
"link_on_spawn": true. - Synchronous ticks read the tick-start links, like every other read. Sends and link actions use the live links.
- Playback draws a thin line between two linked agents on the floor plan (2D only), for the
agents under the trace cap.
system.links_of("People")returns the live links after a run. - Mistakes in
networkareE_AGENT_NETWORK.
An SIR spreading along a scale-free network: an infected agent recovers after an exponential delay and, as it does, infects every susceptible neighbour (the others drop the message):
person = agents.AgentType(
"Person", states=["S", "I", "R"], initial="S",
transitions=[agents.Transition("S", "I", "message", "infect"),
agents.Transition("I", "R", "timeout", "EXPO(5)",
actions=[agents.Action.send("infect", "links")])])
people = agents.Population("People", "Person", 1000, {"I": 5},
network=agents.Network("scale_free", m=2))
Heading and steering¶
A continuous-space agent type gets a built-in self.heading (degrees, 0 = east,
counter-clockwise). It reads and writes like any other attribute, but is not one -- it needs no
Attribute declaration and cannot be named as one:
"heading" is 0 = east, counter-clockwise around the plane.
NetLogo's own turtles use 0 = north, clockwise -- the OPPOSITE convention. Porting a NetLogo
model: heading_here = (90 - heading_netlogo) mod 360.
Four actions move or turn along it, all needing "space": {"kind": "continuous", ...}:
| Action | Effect |
|---|---|
{"forward": "<distance>"} |
Move <distance> along self.heading. |
{"turn": "<degrees>"} |
Rotate self.heading by <degrees>, left (counter-clockwise) positive. |
{"face": <target>} |
Point self.heading at a literal point [x, y], an expression giving one, or another agent ('AGENT("Pop", i)'). |
{"random_walk": {"step": "<d>", "turn_sd": "<sd>"}} |
Turn by a draw from NORMAL(0, sd) on self's own stream, then move step. |
turn, face and a random_walk's turn never touch position, and run instantly whether inside a
tick or not. forward (and the forward half of random_walk) picks its own timing from where the
action sits:
- Inside
on_tickit is an INSTANT jump (NetLogo style: the tick is already the model's own discrete step), with no travel time and no message. - Anywhere else (a transition, a state's entry/exit) it is a TIMED move at the type's
speed, landing exactly likemove_to-- same segment, same"arrived"message on arrival, so anAction.forwardcan sit next to anAction.move_toin the same transition's actions with no special-casing.
ant = agents.AgentType(
"Ant", states=["S"], tick={"dt": 1},
space=agents.Space("continuous", [0, 0, 100, 100]),
on_tick=[agents.Action.random_walk(step=1, turn_sd=15)])
Edges. A continuous space's bounds take an "edge", which only forward/random_walk obey
(move_to, turn and face never fold a position):
edge |
Behaviour |
|---|---|
"none" (default) |
Unbounded -- the same as before this feature, so an existing model is untouched. |
"clamp" |
Stop at the wall. |
"wrap" |
A torus: reappear on the opposite side, exact modulo arithmetic. |
"bounce" |
Reflect off the wall, mirroring self.heading; exact for any distance (even several widths) with no simulated sub-steps. |
Steering. NEAR_HEADING(pop, r[, state]) is the CIRCULAR mean heading of the same
neighbourhood NEAR counts -- averaging 350 degrees and 10 degrees gives 0, not the 180 a plain
mean would. It composes with the existing NEAR_SUM/NEAR_MEAN for a boids-style flock, with no
separate flocking engine:
# Alignment: turn toward the neighbourhood's average heading.
agents.Action.turn('(NEAR_HEADING("Boids", 10) - self.heading + 540) % 360 - 180')
# Separation: steer away from a neighbour that is too close (NEAR > 0 within 2).
# Cohesion: face the average position -- approximate it with NEAR_MEAN of "x"/"y" attributes,
# or track a nearby flockmate with `face: 'AGENT("Boids", j)'`.
(The alignment turn above takes the shortest way around the circle: (target - self + 540) % 360 -
180 is in (-180, 180], so a flock never spins the long way round to align.)
SIN, COS, TAN and ATAN2(y, x) work in radians, like Python; RAD/DEG convert and PI()
is pi, so a heading's unit vector is (COS(RAD(self.heading)), SIN(RAD(self.heading))). A flock's
polarisation is then SQRT(MEAN("Boids", "ux") ** 2 + MEAN("Boids", "uy") ** 2).
Births and deaths¶
Two actions change a population's size during the run:
{"spawn": "Cells", "count": 1, "state": "Young", "init": {"energy": "self.energy / 2"}}
{"remove": "self"}
spawn adds count agents (default 1) in state (default the type's initial state). A newborn
evaluates its own attribute inits first; then each init expression is evaluated in the SPAWNER's
scope (self is the parent) and overwrites that attribute; then its entry actions run and its
transitions are armed. remove takes "self" or any send target (an index, AGENT("Pop", i),
{"population": "Pop", "where": "..."}); removing the agent that is firing ends its firing there.
Messages still queued to a removed agent are dropped and counted in "Dropped Messages".
A population holds at most max_count live agents (default: its count, so set it whenever a model
spawns); a spawn beyond it stops the run with E_POPULATION_FULL. Python: Action.spawn(pop, count,
state, init), Action.remove(target), Population(..., max_count=500).
- COUNT, SUM/MEAN, NEAR/NEAREST, population sends,
agent_summary(Avg Count, Final) and the playback tokens all see live agents only; a removal counts as leaving its state (Avg Sojourn), a birth is not an Entry. - A removed agent's slot is reused by a later birth, but never its random stream: the s-th birth of a
population draws from stream key
count + s, so a newborn draws exactly as an initial agent with that index would. - A reference kept to a removed agent (
entity.agent, anAGENT()value) raises an error when used, even if its slot now holds a newborn; test it withALIVE(ref)first. - Births and deaths start with the run: a spawn or remove in an entry action run while the populations initialise is an error.
Tick-based models¶
Cellular automata, Schelling, Sugarscape and boids are written as "every step, every agent does X".
Give the type (or one population) a tick, and its agents run on_tick actions and tick
transitions at every step:
{"name": "Cell", "states": [{"name": "S"}], "attributes": [{"name": "v", "init": "self.id"}],
"tick": {"dt": 1, "activation": "synchronous"},
"on_tick": [{"target": "self.v", "expr": "AGENT(\"Ring\", (self.id + 2) % 3).v"}],
"transitions": [{"from": "S", "to": "S", "tick": "self.v > 100"}]}
Tick k runs at phase + k * dt (phase defaults to 0 and must be below dt), for every tick after
the run start, computed from k so a long run never drifts. A population's tick keys override its
type's. At each tick, each agent alive at its start runs on_tick, then takes the first tick
transition out of its current state whose condition holds ("" fires every tick; guard and
actions work as usual). activation decides what an agent sees of the others:
synchronous(default): everyone reads the state as of the tick start --self.x,AGENT().x,self.state, COUNT and SUM/MEAN of the population -- and the writes all land at the end, like a cellular automaton. Every agent'son_tickruns first, then every agent's tick transitions. An agent's own second action also reads the tick-start value, so compute a new value in ONE expression, or once in alet(below).sequential: index order, each agent seeing the writes of those before it.random: a fresh random order every tick, drawn from the population's own tick stream (keyed by the population and k), so replications and scenarios keep common random numbers.
On a 3-agent ring where each agent copies its left neighbour, v = [10, 20, 30] becomes [30, 10,
20] after one synchronous tick (a rotation) but [30, 30, 30] after one sequential tick. Ticks share
the clock with everything else: at an instant, the event-driven transitions settle first, then the
ticks run, then conditions, rates and SD levels see the result at that same instant. A tick agent
may send messages, spawn, remove and move; an agent born during a tick first acts at the next one.
A synchronous on_tick made only of self.x sets in the numpy subset runs column-wise. Python:
AgentType(..., tick=Tick(1, "random"), on_tick=[...]), Population(..., tick=Tick(0.5)),
Transition("S", "T", "tick", "self.v > 100"). A bad tick, or tick behaviour on a population
with no dt, is E_AGENT_TICK.
Tick-local values. "let": {"name": "<expression>", ...} on the type is evaluated in order,
once per agent per tick, before its on_tick; on_tick and the tick transitions read each as a
bare name, and a later let may read an earlier one. A draw in a let is ONE draw from the agent's
own stream, shared by every action of that tick, under any activation:
"let": {"v1": "min(self.v + 1, vmax)", "vn": "IF(UNIF(0, 1) < p and v1 > 0, v1 - 1, v1)"},
"on_tick": [{"step": ["vn", 0]}, {"target": "self.v", "expr": "vn"}]
A let name must be new (not a parameter, variable or reserved name), else E_AGENT_TICK.
Python behaviour¶
When a rule is easier to write as code than as actions, an agent type can carry python: plain
def statements. The action {"python": "<function>"} calls function(agent, api). It works
anywhere an action list does: on_tick, a state's entry or exit actions, or a transition's actions.
{"name": "Walker", "states": [{"name": "S"}], "attributes": [{"name": "x"}, {"name": "v"}],
"parameters": {"n": 50}, "tick": {"dt": 1},
"on_tick": [{"python": "step"}],
"python": "def step(agent, api):\n agent.set('x', agent.get('x') + api.normal(0, 1))\n left = api.agent('Ring', (agent.id + 1) % api.params['n'])\n agent.set('v', left.get('v'))\n"}
Running it needs your consent. A model file cannot switch Python on. Its code runs only when
the process running the model has the environment variable NAGASIM_ALLOW_AGENT_PYTHON=1. That
means your shell, or os.environ in your own
script. Without it the run stops with an error that says so, and validation only parses the code. Set it
only for models whose code you trust. With consent, the code has the same standing as a
custom_logic.py class or a plugin: Python cannot sandbox code running in its own process.
What the code can reach:
agent (this agent) |
|
|---|---|
agent.id, .population, .type, .state, .pos, .alive |
read |
agent.get("attr") |
an attribute, state, id or heading; a synchronous tick reads its start |
agent.set("attr", value) |
this agent only, checked like a set action (categories, numbers) |
api |
|
|---|---|
api.now, api.payload, api.params |
the clock, a message's payload (else None), the type's parameters (read-only) |
api.let(name) |
a let value of this tick, the same value (draw included) the expressions read |
api.random(), .uniform(a, b), .normal(m, sd), .integers(lo, hi) |
draws from the agent's own stream, exactly as RAND/UNIF/NORM do |
api.count(pop[, state]), .sum(pop, attr[, state]), .mean(...) |
as COUNT/SUM/MEAN |
api.agent(pop, i), .near(pop, r[, state]), .links([state]) |
other agents as read-only views, ascending index |
api.send(message, to, payload=None) |
to: an agent or a list of agents |
api.spawn(pop, count=1, state=None, init={attr: value}), api.remove(target=None) |
births and deaths (None: this agent) |
agent and api live for one call: kept across calls (a mutable default, say), a write, draw, send
or remove through them raises.
The code is checked at load. Only def statements may sit at the top level. Imports, global,
class, yield, with, decorators and _-prefixed names are refused. Attributes are limited to
the two views, math and plain list/dict/str methods. Builtins are limited to abs, min, max,
len, range, sum, sorted and similar. A problem is E_AGENT_PYTHON with its line number.
An exception while running stops the run and names the type, the function, the agent, the time
and the line. A Python on_tick draws exactly what the equivalent declarative one does, so common
random numbers and bit-for-bit comparisons hold. Each call adds about 1 microsecond. Scenarios and
data bindings cannot override python, and a library cannot carry it.
Playback¶
In a recorded run, an agent with a space is a small token coloured by its
current state, alongside entity tokens and vehicles (Timeline.state_at()["agents"]); only the
first simulation.agents.trace_agents agents of a population are traced (default 200), the same
budget rule entity tokens use for a crowded queue.
Performance notes¶
- Storage is columnar numpy per population (about 100 B/agent plus 1 KB once an agent's random stream is created, lazily, on first use) -- there is no second, "vectorised" engine, just this storage plus numpy aggregates.
COUNT(pop, "state")against a literal state name is an O(1) counter, not a scan.SUM/MEANvectorise over numpy when the expression is array-safe, else fall back to a plain per-agent loop.- At most
max(10,000, 100 x agents)firings (and queued messages) are allowed per simulation instant -- past that the run stops with a hint pointing at a condition that stays true across its own transition, a zero timeout loop, or two agents messaging each other forever.
Worked example: SIR¶
Python classes:
from nagasim.api import run_model
from nagasim.core import agents as A
person = A.AgentType(
"Person", parameters={"beta": 0.3, "gamma": 0.1}, states=["S", "I", "R"], initial="S",
transitions=[
A.Transition("S", "I", "rate", 'beta * COUNT("People", "I") / COUNT("People")'),
A.Transition("I", "R", "timeout", "EXPO(1 / gamma)")])
layout = A.add({"simulation": {"time_limit": 100}}, person,
A.Population("People", "Person", 10000, {"I": 10}))
results = run_model(layout, replications=1)
print(results["agent_summary"]) # Avg/Min/Max/Final count and Avg Sojourn per (Population, State)
The same model as JSON (the agent tools only ever produce this shape):
{"simulation": {"time_limit": 100},
"agent_types": [{"name": "Person", "parameters": {"beta": 0.3, "gamma": 0.1},
"states": ["S", "I", "R"], "initial_state": "S",
"transitions": [
{"from": "S", "to": "I", "rate": "beta * COUNT(\"People\", \"I\") / COUNT(\"People\")"},
{"from": "I", "to": "R", "timeout": "EXPO(1 / gamma)"}]}],
"populations": [{"name": "People", "type": "Person", "count": 10000, "initial": [{"state": "I", "count": 10}]}]}
For R0 = beta/gamma = 2.5 the attack rate (final R / N) matches the closed-form root of
z = 1 - e^{-R0 z} within its 95% CI over a handful of replications.
Agents in submodels¶
A population and its type are cloned for every submodel instance that uses them, like a level:
inside a definition, write the population as $People (COUNT("$People", "I"),
AGENT("$People", i), {"population": "$People"}, {"spawn": "$People"}, a SEND block's to),
or name it under the definition's scoped.populations. Each instance then gets its own
<instance>/People and <instance>/Person, with its own random streams, and its arguments reach
the clone through scoped overrides ("parameters.beta": {"$arg": "beta"}, "count": {"$arg": "N"}).
See nagasim.core.agents for the full authoring API (AgentType, State, Transition, Action,
Space, Population), System dynamics for the level/flow constructs an
agent's transfer action moves, and Security for how every file/socket surface a
model can reach is confined.