On this page

Model logic with @=

Manual contents | Tutorial | Dashboard configuration | Platforms | Where NuML runs

The @= operator carries all of a NuML program's model logic: state variables and their dynamics, intermediate terms, input bindings, output routing, and the stop condition. This chapter specifies each statement form and then explains how the compiler turns them into a running simulation. Sources: uberbuilder.js, numlTransformer.js, apiTools.js.

1. Statement anatomy

A NuML program is a sequence of statements, one per line (a trailing ; is optional), parsed with JavaScript syntax extended by the two operators:

LHS @= RHS      // model logic (this chapter)
LHS #= RHS      // configuration (next chapter)

The first line of the program must be the string 'numl' (or "numl") — that header is how the runtime recognizes the text as NuML (the engine contract). // line comments and /* */ block comments are allowed anywhere.

The @= statement forms:

Form Declares
deriv(X) @= expr X is a continuous model variable with derivative expr
next(X) @= expr X is a discrete model variable; expr is its value at the next step
initial(X) @= expr the starting value of model variable X (required for every deriv/next)
name @= expr name is a term — a named intermediate value
name @= Slider1 binds term name to an input component
update(a, b, …) @= Display routes values to a display once per step
quitWhen @= condition stops the run when the condition becomes true
name @= iterator(fn, x0, n, burn) creates an iteration tool (section 8)

Right-hand sides are ordinary JavaScript expressions over numbers, strings, model variables, terms, inputs, primops, and Math.* calls. All multiplication must be explicit — 2 * S, never 2S.

2. Model variables: deriv, next, and initial

A model variable is a function of model time — an SD stock. It is declared by deriv or next and must receive a matching initial:

deriv(S) @= -beta * S * I / N     // continuous: dS/dt = …
initial(S) @= N - I0

next(L) @= R * L * (1 - L)        // discrete: L(t+1) = …
initial(L) @= Init

Always write the deriv/next statement before its initial. Every variable referenced in any equation must be defined somewhere — as a model variable, a term, an input binding, or a numeric constant you assign; there are no phantom variables.

Within one time step, every equation reads the same committed values of all model variables; new values take effect together at the end of the step. Declaration order therefore never changes the result.

3. Terms

A term is a named value recomputed from current state — an SD auxiliary:

lambda @= beta * I / N
NewInf @= binomial(S, lambda)

Semantics that matter in practice:

4. Binding inputs: sliders and switches

N    @= Slider1
vacc @= Switch1

A term whose right-hand side is exactly an input component's name reads that component's current value on every evaluation — live, mid-run. Switches yield 0 or 1. Give every input you bind a configuration statement (Slider1 #= [legend("N"), …]) so the dashboard shows a labeled, ranged control (sliders and switches).

Input names may also appear directly inside expressions (deriv(x) @= Slider2 * x), but binding them to a named term keeps the model readable.

5. Routing output: update

update(S) @= TimeGraph
update(I, R) @= TimeGraph1
update(N, Survivors, Recruits) @= Table01

Once per completed step, each update sends the listed values — model variables or terms — to the named display. Write one update per display, listing all of its series; their order matches the display's legend. Phase graphs consume the values as coordinate tuples (update(x, z) @= PhaseGraph1 plots z against x).

A value can also be recorded without occupying a visible graph using the synthetic target update(TotNewInf) @= TimeSeries("TotNewInf") — used by the stochastic platform (recording without a graph).

6. Stopping early: quitWhen

quitWhen @= (N <= 0)

The condition is checked after every step; when it is true the run halts (before maxTime). The time at which a run stopped is available to the stochastic platform as quitTime (quitWhen and quitTime). Never use quitWhen or quitTime as a sensitivity target.

7. Primops and special operators

Primops are convenience functions usable in any @= expression without the Math. prefix. They are the APIRoot statics documented in the SD manual's utilities table:

Group Primops
Random draws random(x, y?), irandom(x, y?), normal(m, s), flip(p), binomial(n, p), poisson(lambda), choose(…), multinomial(N, …)
Trigonometry cos, sin, tan, acos, asin, atan, atan2, cosh, sinh, tanh, acosh, asinh, atanh
Constants PI, PI2 (2π), Pi2 (π/2), Pi34 (3π/4), degToRad, radToDeg

Everything else from JavaScript's Math object is available with the prefix: Math.exp, Math.max, Math.log, Math.sqrt, and so on.

Special operators expand in place:

Operator Meaning
currentTime the current model time
nextTime currentTime + dt
initial(X) (on a right-hand side) the initial value of model variable X
timeWhen(cond) the current time while cond is true, otherwise null; used to record event times for stochastic histograms

8. Blocks, functions, and the iterator tool

A right-hand side may be a brace-enclosed block; the normalizer wraps it as an immediately invoked function, so it may contain statements and must return its value:

next(r) @= { loop.step(r); return r + rdelta; }

The iterator tool runs an inner iteration each outer step — the engine behind the bifurcation-diagram preset:

loop @= iterator(x => r * x * (1 - x), x0, iterations, burnin)
next(r) @= { loop.step(r); return r + rdelta; }
initial(r) @= r0
update(loop) @= PhaseGraph

iterator(fn, x0, n, burn) iterates fn from x0 n times, discarding the first burn values; loop.step(p) tags the retained values with parameter p, and update(loop) @= PhaseGraph plots the point cloud. Iterator state resets with the run.

9. How NuML becomes a running model

The @= compiler (uberbuilder.js) translates the program onto the SD runtime documented in the SD API User Manual:

NuML Runtime construct
deriv(X) + initial(X) a Stock with flow = () => expr
next(X) + initial(X) a sequence stock (replaced, not accumulated)
term a getter memoized per integration step
input binding a parameter read (getParam) against the hosting app's input codes
update(…) @= D a per-step show(D, …) call
quitWhen the main class's stopWhenTrue predicate

Identifiers in each expression are resolved in this order: special operators, primops, trig/Math shorthands, input codes, model variables, terms, then iterator tools; anything left unresolved is passed through to JavaScript and fails at run time as an undefined variable. The two-phase step cycle — simultaneous reads, synchronized commitment, Euler versus RK4 — is exactly the one specified in the SD simulation cycle.

Because RK4 evaluates every deriv expression four times per dt at provisional states, randomness belongs in next models run under Euler, never inside a deriv under RK4 (integration guidance).

10. Rules and pitfalls

  1. Start with 'numl'. The header string must be the first line.
  2. Explicit multiplication. 0.5 * G2, 0.5 * (1 - r) — never 0.5G2 or 0.5(1 - r).
  3. Every model variable needs initial, and the deriv/next comes first.
  4. No phantom variables. Every right-hand-side identifier must be a model variable, term, input, primop, or Math member. Assign invented constants explicitly (gravity @= 9.81).
  5. Names must match exactly across deriv/next, initial, update, and expressions — Central and centralCompart are different variables.
  6. One update per display, with legend arity to match.
  7. Math. prefix for JavaScript math in model expressions; no prefix inside #= configuration directives.
  8. Match method to statement type: deriv with method("RK4"); next or stochastic draws with method("Euler").
  9. Parse and runtime errors surface in a Numl Error(s) dialog with the offending line; fix the first error reported and re-apply.