Skip to content

Latest commit

 

History

History
370 lines (277 loc) · 15.9 KB

generated-model.md

File metadata and controls

370 lines (277 loc) · 15.9 KB

What a generated model does

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).

Contents

  1. The life of a run
  2. How settings are finalized
  3. How initial conditions are finalized
  4. Starting the run
  5. The tick
  6. The end of a run
  7. Where the data lives

1. The life of a run

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();                      // finish

finish() is owed even when init() returns false.

2. How settings are finalized

The layers

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.

Derived values are recomputed, not folded

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.

What is fixed, and when

Settings are finalized inside sim.init(), before any node's init() runs:

  1. The host's edits through settings() are collected and pinned.
  2. resolve_settings() runs, so every derived value is current.
  3. 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.

What can go wrong

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.

3. How initial conditions are finalized

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().

4. Starting the run

sim.init() does the following, in order.

  1. Configures logging and the real-time frame, as the sim file set them.
  2. Finalizes the settings (section 2).
  3. If a configuration failure stands, stops here. No node runs.
  4. Writes every state's initial value (layers 1 and 2 above).
  5. Opens the recording.
  6. 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().

5. The tick

The sequence

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().

The solver stages

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.

What runs, by kind of node

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.

What each call can see

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.

next. and var land at different times

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.

Ordering and data dependence

  • output() order is computed. A wire from one node's output to an input listed in another node's output(...) 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() and derivative() 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.

Latency, worked through

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(); }

6. The end of a run

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:

  1. Calls final(ctx) in reverse init order, only on nodes whose init() completed.
  2. Closes the recording.
  3. Prints the real-time summary, if the run was real time.
  4. Prints the contract summary.
  5. 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.

7. Where the data lives

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.

Further reading

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