System dynamics¶
Stocks and flows with feedback, alongside the ordinary block flow chart -- the same model file,
the same run_model.
The grid¶
simulation.sd = {"dt": 0.25, "method": "euler"|"rk4"} turns the level engine into a fixed-step
integrator. Without it nothing changes: a level re-evaluates only at events, exactly as before, so
every older model stays bit-identical. With it, a level whose rate reads LEVEL, FRACTION,
RAMP or TNOW -- a DYNAMIC rate, one that can change between events on the clock alone -- is
re-integrated at every grid point. euler is the ordinary step; rk4 replaces a dynamic rate by
its effective value over the step (fourth-order accurate, still exact at a crossing or a
threshold, since levels stay piecewise-linear inside a step either way).
Auxiliaries, delays, functions of time¶
- Auxiliaries (
AUX("name")) are named expressions, evaluated on read and memoised for one pass -- a computed variable with no stock of its own (a price, a ratio, a policy). - Delays (
DELAY("name")) arematerial(a pipeline, conserves what enters),information(SMOOTH, an exponential lag) orpipeline(an exact, dt-free history read backtauago). ShorthandSMOOTH/SMOOTH3/DELAY1/DELAY3/DELAYN/DELAYPinside a flow rate, an auxiliary or a delay's own input expands into hidden stages automatically; with no initial argument it starts in equilibrium at the input's t=0 value, as in Vensim. STEP(height, t0),PULSE(height, t0, width),RAMP(slope, t0[, t1])and a lazyIF(cond, a, b)are ordinary expression functions; a literal switching time is exact even without a grid.
Function pack¶
Beyond STEP/PULSE/RAMP/IF, an expression anywhere in a level's initial value, a flow rate,
an auxiliary or a delay field may call:
ZIDZ(a, b)--a/b, or 0 whenbis 0.XIDZ(a, b, x)--a/b, orx(default 0) whenbis 0.MODULO(a, b)-- Vensim's remainder, with the sign ofa.TABLE_EXTRAPOLATE("name", ...)-- likeTABLE, but alinearaxis keeps following the slope of its last segment past the ends instead of clamping.PROD(x),VMIN(x),VMAX(x)-- likeSUM/MEAN, the product/minimum/maximum over the[*]elements of a subscripted quantity (PROD(LEVEL("Cost[*]"))).INITIAL(x)-- the value ofxat the start of the run, held for the rest of it.ACTIVE_INITIAL(x, init)--initwhile a level's OWN initial value is being computed,xeverywhere else (a flow rate, an auxiliary, another level or delay's field) -- Vensim's rule for breaking an initialisation-order tie.SAMPLE_IF_TRUE(cond, x, init)-- holds the lastxread whilecondwas true,initbefore the first true reading; updated at every event and, withsimulation.sd, every grid point.TREND(x, avg_time[, init_trend])andFORECAST(x, avg_time, horizon)-- the fractional growth rate ofx(an internal SMOOTH), and a linear projectionx * (1 + trend * horizon).NPV(stream, discount_rate, init, factor)-- the present value ofstreamsince the run started, Vensim's own formula:(cum + stream * dt * df) * factor, wheredfdecays from 1 atrate / (1 + rate * dt)andcumaccumulatesstream * dffrominit(dt: the SD grid step).NOISE_NORMAL(mean, sd, lo, hi)-- a normal draw clipped to[lo, hi], redrawn every re-evaluation from a stream fixed by the call's own position in the model (reproducible across runs of the same seed); refused under"method": "rk4"(it would draw four times per step).
INITIAL/ACTIVE_INITIAL/SAMPLE_IF_TRUE/TREND/FORECAST/NPV/NOISE_NORMAL are expanded
into hidden levels/delays when the model is built, the same way SMOOTH/DELAYn are -- they
cannot be called from a block expression (an ASSIGN, a DECIDE condition, a TRANSFER amount), only
from a level's initial value, a flow rate, an auxiliary or a delay field.
Conveyors¶
A level with a conveyor: {transit_time, capacity?, in_limit?, arrest?, leak_kind?} field is a
Stella conveyor instead of an ordinary stock: whatever enters
keeps ITS OWN transit time (a later edit never reaches back into material already on the belt), it
moves forward one slat per grid step, and it leaves through exactly one primary outflow with no
rate of its own ("rate": "") -- the amount reaching the end, not a rate you write. A leak flow
("leak": {"fraction": "<expr>", "zone"?: [start, end]}, zone a fraction of the belt from the
inflowing side, default the whole thing) drains a fraction of what remains as it travels;
leak_kind is "linear" (a constant fraction lost per step) or "exponential" (a fraction of
what remains, compounding). capacity/in_limit cap what an equation-driven inflow may add per
step; arrest (a boolean-like expression) freezes the belt entirely while true -- nothing moves,
nothing leaks, nothing is admitted. Needs simulation.sd on method "euler" (the slat model moves
one slat per DT; RK4's stages have no meaning for it): E_CONVEYOR_GRID/E_CONVEYOR_METHOD
otherwise, E_CONVEYOR_OUTFLOW for anything but exactly one primary outflow,
E_CONVEYOR_LEAK for a leak on a flow that is not a conveyor's own outflow or a zone outside
[0, 1], W_CONVEYOR_TRANSIT when a literal transit_time is not a multiple of dt.
nagasim/models/sd_training_pipeline.json is a worked example (a hiring pipeline with dropouts).
Subscripts and units¶
subscripts: [{"name": "region", "elements": ["north", "south"]}] declares a dimension; giving a
level, flow, auxiliary or delay "dims": ["region"] flattens it into one record per element
(Pop[north], Pop[south]) when the model is built -- every existing feature and every KPI works
per element, with no second code path. SUM(x)/MEAN(x) unroll over a [*] index.
A subrange names a subset of another dimension's elements:
{"name": "North", "elements": ["n1", "n2"], "parent": "Region"}. A record can run over it
("dims": ["North"]), and inside one, both Pop[North] and Pop[Region] read the owner's
element. SUM(LEVEL("Pop[North*]")) sums a Region-dimensioned Pop over North's elements only
([Region*] is the same as [*]).
A mapping ties one dimension to another element by element:
{"name": "Region", "elements": ["r1", "r2"], "map_to": {"dim": "Zone", "elements": ["z2", "z1"]}}
(without elements, by position, so the sizes must match). Inside a record over Region,
AUX("k[Zone]") reads the Zone element mapped to the owner's (r1 reads k[z2]), and a flow's
"to": "Stock[Zone]" ends on it. When the mapping is one-to-one it also works backwards: inside a
record over Zone, [Region] reads the Region element mapped onto it.
Per-element equations override a subscripted record's shared expression for some elements:
{"name": "k", "dims": ["Region"], "expr": "0.1", "elements": {"r2": "0.3"}} (for two dims, key
by "a,x"). The override replaces a level's initial, a flow's rate, an auxiliary's expr or
a delay's input. Codes: E_SUBSCRIPT_RANGE (a parent that is not a declared superset),
E_SUBSCRIPT_MAP (an unknown target, or a mismatched element list), E_SUBSCRIPT_ELEMENT (an
override key that is not an element of the record's dims).
A declared or inferred unit (kg, kWh, people, ...) is checked wherever simulation.sd is
present: a level's unit against its flows' (flow = level unit / time), an auxiliary's declared
unit against what its expression implies, a table's unit_x/unit_y against a TABLE() call's
key and result, a time series' unit against what TS() returns. simulation.sd.units picks
how strict this is:
"warn"(the default) -- mismatches are warnings (W_UNIT_MISMATCH/W_UNIT_TIME), never errors."off"-- skip the checker entirely."strict"-- the same two conditions are errors (E_UNIT_MISMATCH/E_UNIT_TIME) instead, and a level or flow with no unit at all is flagged (W_UNIT_MISSING, always a warning -- a missing unit is not itself wrong).
ZIDZ/XIDZ divide their arguments' units; MODULO/INITIAL/SAMPLE_IF_TRUE/FORECAST/NPV/
PROD/VMIN/VMAX/NOISE_NORMAL all keep the unit of the argument they read, hold or reduce
over; TREND is a fractional rate of change, 1/time. A literal number is always a wildcard
(never a false positive), so an untyped constant multiplying a canonical rate stays clean.
Loop dominance¶
Loops That Matter (nagasim.loopdominance.analyse, naga loops model.json, or the agent
tool run_loop_dominance) answers which feedback loop is driving the model's behaviour and when
that hand-off happens, from one grid run: every loop gets a relative score at every step and a
dominant set (the fewest loops explaining at least half the change).
Import from XMILE or Vensim¶
naga sdimport model.xmile (or the import_xmile agent tool)
reads a Stella/iThink .xmile/.stmx file into a new model: stocks, flows, auxiliaries,
graphical functions (as lookup tables), dimensions (as subscripts), non-apply-to-all arrays
(<element subscript="...">, as per-element equations), with Pop[north], Pop[*] and
SUM/MEAN/PROD/MIN/MAX over an array, and modules, through a small
expression translator. A <module name="m"> referencing another <model name="m"> in the same
file becomes a submodel instance: the referenced model's own stocks/flows/auxiliaries become
$-scoped records of a submodel definition (the same blockless, continuous shape the SD asset
library uses), an access="input" auxiliary is fed by its <connect to="m.input" from="...">,
and an access="output" variable is read from the outer model as m.output, a dotted name
translated into that instance's own scoped record. Nested modules (a module inside a module) are
refused, along with a stock or flow marked access="input", a graphical function inside a module,
and a subscripted variable inside one (subscripts inside a submodel definition are not supported
yet). A construct this reader cannot translate at all -- a conveyor/queue stock, a dimension
mapping (XMILE 1.0 defines none), an unknown function -- is reported and left out of the model
rather than guessed at; an approximation (a bare DELAY, a non-euler/rk4 method) is reported
too. The file is refused outright, before parsing, if it carries a DOCTYPE/ENTITY declaration
or is over 20 MB -- the same rule every file the engine reads follows.
naga sdimport model.mdl (or the import_mdl agent tool)
reads a Vensim .mdl file the same way, for the common subset: INTEG stocks (a rate that is a
plain flow name or a +/- chain of them), WITH LOOKUP(...) as a lookup table, DELAY1/DELAY3/
DELAY1I/DELAY3I, subscript ranges and subranges (a range of another range's elements),
-> mappings, per-element equations (x[s1] = ..., x[Region] = 1, 2, 3) and
SUM/PROD/VMIN/VMAX over x[Region!]. This is a small stdlib reader, not pysd -- pysd is not
and does not become a dependency. A :MACRO: definition, a GET XLS/DIRECT ... external
data reference or an unknown function/name is reported and left out, the same way. A variable
name with spaces is read with underscores (Stock A becomes Stock_A); a dimension name with a
space is not translated. .mdl is often Windows-1252, not UTF-8; this reader tries UTF-8 first and falls back
to Windows-1252 rather than guessing wrong or refusing a file most Vensim installs would open fine.
Worked example: logistic growth¶
from nagasim.api import run_model
from nagasim.core import sd
layout = sd.add(
{"simulation": {"time_limit": 50, "sd": sd.SDSettings(0.25, "rk4").to_dict()}},
sd.Stock("Population", initial=10, min=0.0),
sd.Flow("growth", 'AUX("r") * LEVEL("Population") * (1 - LEVEL("Population") / AUX("K"))',
target="Population"),
sd.Aux("r", "0.2"), sd.Aux("K", "1000"))
results = run_model(layout, replications=1)
print(results["level_summary"]) # Population settles near its carrying capacity, K=1000
Calibration to data¶
Fit r and K to a measured trajectory with a SeriesTarget, Vensim Optimize's weighted
payoff: sum over data points of w * (model(t) - data(t))^2, summed over targets and averaged
over replications (common random numbers).
from nagasim import calibrate
from nagasim.optimize import Factor
target = calibrate.SeriesTarget("level/Population", [(0, 10), (10, 69.5), (20, 355.5)],
weight="1/sd")
result = calibrate.calibrate(layout, [target],
[Factor("auxiliaries.r.expr", "continuous", 0.05, 0.5),
Factor("auxiliaries.K.expr", "continuous", 500, 2000)],
budget=100)
result.best, result.objective # fitted values, payoff
result.residuals["level/Population"] # Time, Data, Model, Residual, Weight
The series is any key of the timeseries frame (level/<name>, aux/<name>,
agents/<pop>/<state>); data is a DataFrame (time_col, value_col), a list of (t, value)
pairs, or the name of a 1-D model table keyed by time. The model is read from the sampler's grid,
linearly interpolated between samples, so data on that grid (k * time_limit /
timeseries_points) is matched exactly. A numeric weight multiplies the squared error;
"1/sd" measures each error in standard deviations of the data, so series in different units add
up fairly. Series targets mix freely with scalar KPI targets, and the HTML report draws model
against data for each one.
See nagasim.core.sd for the full authoring API (Stock, Flow, Aux, Delay, Subscript),
and Security for how sdimport and every other file-reading surface is
confined.
Reality checks¶
A reality check (Vensim's term) pairs a TEST INPUT -- a condition imposed on the model, such as "workforce = 0" -- with a CONSTRAINT expected to hold given it, such as "production = 0", or "inventory never negative" with no input at all. Each check runs as its own deterministic replication and reports pass/fail with where and when it first failed:
from nagasim import modelcheck
layout["reality_checks"] = [
{"name": "shutdown", "inputs": {"auxiliaries.Workforce.expr": "0"},
"constraint": 'AUX("Production") <= 0'},
{"name": "no shortage", "inputs": {},
"constraint": 'LEVEL("Inventory") >= 0', "when": "always"},
]
table = modelcheck.run_reality_checks(layout)
table[["Check", "Passed", "Violation time", "Snapshot"]]
inputs is {override path: value}, the same dotted-path grammar a scenario override uses (a map
here, since a check names each path once). constraint is a boolean expression over
LEVEL()/AUX()/FLOW() and TNOW -- the model's own safe expression grammar -- checked live at every
SD grid point (with simulation.sd) or every level-engine event (without), and at every
timeseries sample, which also gives the Snapshot (None when the failure fell between samples).
when narrows which instants are checked: "always" (default), "final" (the last sample only),
or "after <t>".
The agent tool run_reality_checks runs a model's stored checks and returns
{"check", "passed", "violation_time", "snapshot"} per check, without exposing the raw frames.