This document describes the behaviour of a model after sec --emit has turned
it into C++: how its settings and initial conditions reach their final values,
what is called on every tick and in what order, what each call can see, and how
a run ends.
It is a description, not the definition. SPECIFICATION.md defines the language and argues for each decision. Where the two disagree, the specification is right and this document has a bug.
The examples use examples/drivetrain/, whose generated files are Drivetrain.hpp
(the public interface), Drivetrain.cpp (everything else) and main.cpp (an
example host).
- The life of a run
- How settings are finalized
- How initial conditions are finalized
- Starting the run
- The tick
- The end of a run
- Where the data lives
A host makes four kinds of call, in this order.
| Phase | Host calls | What happens |
|---|---|---|
| Configure | Sim(), settings(), load_settings(), dump_settings() |
Settings and initial-condition overrides are collected. No node code runs. |
| Start | init() |
Settings are finalized, initial conditions are written, and every node's init() runs. |
| Step | inputs(), tick(), outputs(), done() |
One tick() samples the model at the current time and advances it one step. |
| Finish | finish() |
Every node's final() runs, the recording and log close, and the exit code is returned. |
drivetrain::Sim sim;
sim.settings().wheel_inertia = 1.1; // configure
if (!sim.init()) return sim.finish(); // start
while (!sim.done()) { // step
sim.inputs().axle_torque = 250.0;
sim.tick();
double cut = sim.outputs().torque_cut;
}
return sim.finish(); // finishfinish() is owed even when init() returns false.
A setting's value is decided in layers, and the last writer wins. The first four
are seen by sec and decide what the generated code starts with. The last two
happen in the compiled program.
| # | Layer | Where it is written | When it applies |
|---|---|---|---|
| 1 | Definition default | settings { mass: double(kg) = 1500; } |
Compile time |
| 2 | Flow-down from the parent | node whl : Wheel { inertia = param.wheel_inertia; }; |
Compile time, re-evaluated at run time |
| 3 | The sim file's root block |
root: drivetrain.Corner { wheel_inertia = 0.9 (kg*m^2); }; |
Compile time |
| 4 | A .settings file given to sec |
whl.radius = 0.315 (m); |
Compile time |
| 5 | sim.settings() |
sim.settings().wheel_inertia = 1.1; |
Run time, root settings only |
| 6 | sim.load_settings(), or --settings and --set on the generated executable |
front.left.mass = 340 (kg); |
Run time, any depth |
Layers 5 and 6 are a single layer in time: whichever the host does later wins.
An expression that reads param.* is emitted as code and runs in
resolve_settings(), a generated function with one assignment per setting, in
dependency order, each guarded by a pin:
void Sim::Impl::resolve_settings() {
if (!se_pin_[0]) se_cfg_root.total_mass = 1500.0;
if (!se_pin_[1]) front.param.mass = se_cfg_root.total_mass / 2.0;
}So overriding total_mass at run time moves everything derived from it.
A run-time override pins its target. resolve_settings() then leaves that
setting alone, and everything derived from it follows the pinned value. An edit
through settings() pins only if it changes the value.
A deep override detaches from the flow-down. After front.mass = 340 (kg);,
that value no longer follows total_mass. The same is true at compile time: if
layer 4 sets a value that a flow-down would have computed, the generated code holds
a constant there, and overriding the parent at run time no longer reaches it.
sample_rate and time_step are compile-time constants derived from the sim file's
step.
Settings are finalized inside sim.init(), before any node's init() runs:
- The host's edits through
settings()are collected and pinned. resolve_settings()runs, so every derived value is current.settings()is refreshed, so it shows what the model will run with.
After that the settings are constant. load_settings() is refused once init()
has been called. A node's init() reads final values and cannot change them.
load_settings() itself ends with resolve_settings(), so settings() and
dump_settings() are up to date between loads. dump_settings() shows exactly
what init() would run with, in a form load_settings() reads back.
A load is all or nothing: every binding is parsed and checked against its target's
unit and type before any is applied. Values are converted to the target's unit, so
0.34 (t) at a kg setting is 340.
A refused load returns false, error() says which line and why, and the refusal
stands: init() will return false without running any node, so a host that
ignores the return value cannot run a configuration it did not ask for.
In a model with array-shaped states, configuration also computes the array sizes from the settings and then needs the host's state buffers. See §6.2b and §15.5a.
Initial conditions use the same channel as settings, addressed by path:
front.left.velocity = 2 (m/s); // a state's initial value
bw.w1[3] = 0 (-); // one element of an array state
dev.link = CONNECTED; // a finite state, by enumerator
A state's starting value is decided in this order:
| # | Layer | Written by |
|---|---|---|
| 1 | Declared default | states { continuous omega: double(rad/s) = 0.0; } |
| 2 | Override | The sim file, a .settings file, or load_settings() |
| 3 | The node's init() |
state.omega = equilibrium(); |
The consequence of that order: every override is in place before any init()
runs. An init() may do real work, such as opening a socket or solving for an
equilibrium, and it does that work once, against the configuration the run will
actually use. It reads an overridden initial condition as the value it starts
from, and it may refine it.
A state that init() writes regardless of its current value is computed rather
than configured. An override of it is accepted and then replaced. A node that wants
that quantity to be tunable exposes the input to the computation as a setting, as
se.sig.src.Prbs computes reg from seed.
var members are not initial conditions. They have no override channel, start at
zero, and are normally computed in init().
sim.init() does the following, in order.
- Configures logging and the real-time frame, as the sim file set them.
- Finalizes the settings (section 2).
- If a configuration failure stands, stops here. No node runs.
- Writes every state's initial value (layers 1 and 2 above).
- Opens the recording.
- Calls each node's
init()in elaboration order, stopping if one halts the run.
init() returns false if a load was refused or a node halted the run. error()
says why.
No output has been computed yet. The first values appear during the first
tick().
One call to sim.tick() at index k, with t[k] = k · step:
| # | Step | Called | Runs |
|---|---|---|---|
| 1 | Set the time | ||
| 2 | Propagate outputs | output() |
Every node, in topological order |
| 3 | Jump map and per-tick work | next() |
Every node that defines it, in any order, unless the run has already halted |
| 4 | Record | If this is a recording tick | |
| 5 | Exit | If the run has halted, or k = tick_count |
|
| 6 | Commit | dis = nxt, fin = fnx, x = xnx, all at once |
|
| 7 | Flow | output() and derivative() |
Solver stages, below |
| 8 | Pace | If mode: realtime and this tick closes a frame |
The host writes inputs() before calling tick(). outputs() afterwards hold
y[k], the values computed in step 2. Steps 2 to 4 all see the state as the tick
found it, and every state moves below the exit. That gives two guarantees:
- A recorded row is one consistent sample at t[k]. A state that jumps on this tick still shows its old value on this row. The new value appears on the next row, beside the signals it produced.
- A stopped run is left holding a valid checkpoint, because it never reaches the commit.
The tick_count tick samples the final instant and does not advance, so a full run
is tick_count + 1 calls to tick().
Step 7 runs the global solver. A stage sets the time to its own instant, writes its
trial state into x, re-propagates outputs in topological order, then calls every
derivative() in any order.
| Solver | Stages, at |
|---|---|
euler |
t |
rk2 |
t, t + h/2 |
rk4 |
t, t + h/2, t + h/2, t + h |
A node with a discrete state is held. It is left out of the stage output
passes, so its published output stays at y[k] across the whole step. Every other
node is re-evaluated at every stage, so it sees trial states and the stage's time.
| In one tick | Algebraic | Continuous | Discrete | Finite |
|---|---|---|---|---|
output() in step 2 |
yes | yes | yes | yes |
next() in step 3 |
if defined | if defined | yes | yes |
| Committed in step 6 | nothing | x, if next() wrote it |
dis |
fin |
output() at each stage |
yes | yes | no | yes |
derivative() at each stage |
no | yes | no | no |
A node may mix kinds. It is held if it has any discrete state, and derivative()
still runs at every stage if it has any continuous state.
With rk4, one tick calls an unheld node's output() 5 times and a held node's
once. It calls derivative() 4 times and next() once.
state |
Inputs | var |
sim.time() |
|
|---|---|---|---|---|
output(), step 2 |
Entry: x[k], dis[k], fin[k] | y[k] from its producers, which have already run | As the last next() left it |
t[k] |
next(), step 3 |
Entry: x[k], dis[k], fin[k] | y[k] | Current, including its own writes above | t[k] |
output(), stage |
Trial x, dis[k+1], fin[k+1] | This stage's values, or y[k] from a held producer | As this tick's next() left it |
Stage instant |
derivative(), stage |
Trial x, dis[k+1], fin[k+1] | The same | As this tick's next() left it |
Stage instant |
A node sees only the inputs its method lists, as in output(cmd). Reading any
other input is a C++ compile error.
Note the third and fourth rows. Stages run after the commit, so a body running at a stage reads the new discrete and finite values. That is intended for a finite state, which is in force from just after t[k]. It is why a discrete node has to be held: without the hold, it would publish its next value early.
What each call may change:
| Method | May write | May also |
|---|---|---|
init() |
state, var |
Touch natives, log, halt |
output() |
out |
log.trace, sim.check_* |
derivative() |
der |
log.trace, sim.check_* |
next() |
next, var |
Touch natives, log, halt, sim.check_* |
final(ctx) |
Nothing the model can see | Touch natives, log |
output() and derivative() run at trial states the trajectory never visits, which
is why they can have no side effects. next() runs once per tick, on the trajectory.
A next.<state> write goes to a buffer. Every body reads the old value through
state., whatever order the writes are in, and a state the body does not write
keeps its value. All buffers land together at step 6, across the whole model.
A var write lands immediately. A later line of the same body reads the new value.
output()order is computed. A wire from one node's output to an input listed in another node'soutput(...)is an edge in a topological sort. With output groups, each group is its own vertex. A cycle of these edges is a compile error.- A node whose
output()lists no inputs is a sort root. It breaks loops. next()andderivative()lists constrain nothing, because every output they read has already been computed. Their relative order cannot change a result.- A wire is the producer's output storage, not a copy. An output a body does not write keeps its value from the last time it was written.
A registered controller publishes its state and updates it from its input:
states { discrete cmd: double(-) = 0.0; }
next(err) { next.cmd = param.kp * in.err; }
output() { out.cmd = state.cmd; }
| Tick | Step 2 publishes | Step 3 computes | Step 6 commits |
|---|---|---|---|
| k | cmd[k] | K · err[k] | cmd[k+1] = K · err[k] |
| k + 1 | K · err[k] | K · err[k+1] |
That is one tick of latency. An output(err) { out.cmd = param.kp * in.err; } has
none, but it joins the sort and can form an algebraic loop.
Anything slower than the tick is written against the tick counter:
next() { if (sim.tick() % var.poll_ticks == 0) poll(); }
A run ends in one of these ways, and tick() then sets done():
| Cause | ctx.reason |
|---|---|
The tick_count tick |
completed |
sim.stop(...) |
stopped |
sim.abort(...) |
aborted |
A failed sim.require(...), or a configuration failure |
error |
A halt in next() takes effect on the same tick. Every other node's next() still
runs, the row is still recorded, the exit is taken, and nothing commits.
sim.finish() then:
- Calls
final(ctx)in reverse init order, only on nodes whoseinit()completed. - Closes the recording.
- Prints the real-time summary, if the run was real time.
- Prints the contract summary.
- Writes the closing log line.
It returns 0 for a clean run. It returns 1 if the run was aborted or failed, or if a
sim.check_* contract was broken at any point. A broken check does not stop a run.
Behind the public header, the model is a small number of flat blocks.
| Block | Holds | Notes |
|---|---|---|
x |
Continuous states | The solver's vector |
xd |
Their derivatives | Scratch for each stage |
sig |
Every wire value and the root inputs | One slot per producer's output |
dis |
Discrete states | |
fin |
Finite states | |
nxt, fnx, xnx |
Buffers for dis, fin, x |
Seeded before next() runs, copied back at the commit. xnx exists only if some next() sits beside a continuous state. |
Settings live in each node's param member, and each var in its node.
x + sig + dis + fin is a complete checkpoint of the model. The buffers are
not part of it, because they are reseeded on every tick.
Each slot's path, offset, type and unit are listed in generated tables, and
<model>.units.txt carries the same information for tools. See
§15.5 and
§15.6.
| Topic | Specification |
|---|---|
| Configuration | §6.2b |
| The three kinds of state | §6.4 |
| The five methods and their rules | §8.1 |
| The sort | §8.5 |
| Holding | §9.3 |
| The tick | §9.6 |
| Settings layers and initial conditions | §13.3, §13.4 |
| Loading settings into a compiled model | §14.1 |