Causality, delays, and feedback
This guide explains how Magnetite schedules equations and closes feedback loops. It uses the closed loop from A simple motor and controller.
The synchronous model
Section titled “The synchronous model”A program advances in discrete ticks. Within one tick, every equation of a step holds at
once and their textual order carries no meaning. The compiler orders them by data dependency,
breaking ties in source order so the generated code is deterministic.
Dependencies come in two kinds:
- Instantaneous.
let error = reference - omega;needsomegafrom this tick. - Delayed.
init fby nextneedsnextonly from the previous tick. It compiles to a struct field that is read before the tick’s equations and written after them.
A cycle of instantaneous dependencies has no valid order. That is an algebraic loop, and it is
a compile error. A cycle through a fby is fine, because the delay cuts it.
The black-box rule
Section titled “The black-box rule”Each step compiles alone into one Rust module with one Model impl, however often the model is
used. That keeps the generated code readable and the trace from block to code exact.
The price is one rule: at a call site, the scheduler treats a model or fn call as a black box
with full feedthrough, where every input may reach every output in the same tick. A loop through a
model call is therefore rejected even when the callee’s output comes from a fby.
Two remedies exist, and both are explicit in the source.
Remedy 1: an explicit fby
Section titled “Remedy 1: an explicit fby”let prev_omega = self.motor.omega_init fby omega;let error = reference - prev_omega;This adds one tick of delay to the feedback path, chosen by you and visible in the source. In a sampled control loop that usually matches the hardware, because the controller acts on the last measurement it has, so this is the remedy to prefer.
Remedy 2: an inline instance
Section titled “Remedy 2: an inline instance”model ClosedLoop { pid: Pid, inline motor: Motor,}The compiler splices the instance’s body into the caller before scheduling. Its internal fby
becomes a caller equation and cuts the loop with no added delay. Use this when the delay you need
already exists in the callee and another one would change the dynamics.
inline has two costs:
- The instance has no module of its own. Other instances of the same model keep theirs.
- The code trace coarsens. Spliced equations keep their source spans, but one caller function now holds another block’s logic.
What the two remedies generate
Section titled “What the two remedies generate”With the explicit fby, motor stays a field of the state struct and is stepped through its own
module:
pub struct ClosedLoop { prev_omega: f64, pid: Pid, motor: Motor,}
fn step(&mut self, ctx: &Ctx, params: &Self::Parameters, reference: Self::Inputs) -> Self::Outputs { let prev_omega: f64 = self.prev_omega; let error: f64 = reference - prev_omega; let control: f64 = self.pid.step(ctx, ¶ms.pid, error); let omega: f64 = self.motor.step(ctx, ¶ms.motor, control); self.prev_omega = omega; omega}With inline motor: Motor, the Motor field is gone and its register becomes the caller’s
motor_omega:
pub struct ClosedLoop { prev_omega: f64, motor_omega: f64, pid: Pid,}
fn step(&mut self, ctx: &Ctx, params: &Self::Parameters, reference: Self::Inputs) -> Self::Outputs { let prev_omega: f64 = self.prev_omega; let error: f64 = reference - prev_omega; let control: f64 = self.pid.step(ctx, ¶ms.pid, error); let omega: f64 = self.motor_omega; let motor_omega_dot: f64 = (params.motor.k * control - omega) / params.motor.tau; self.prev_omega = omega; self.motor_omega = omega + motor_omega_dot * ctx.dt_secs_f64(); omega}In both, ClosedLoopParameters nests pub motor: MotorParameters and init reads
params.motor.omega_init.
Catalog blocks
Section titled “Catalog blocks”Catalog blocks come in two kinds:
- Stateful blocks such as the integrator, the PID, the filters and the sources are modules like your own models. A loop through one needs the same remedies.
- Primitives are spliced into every caller. These are the stateless blocks (a sign, a switch, a
conversion) and the unit delay, which is
fbywith a parameter.
inline on a primitive is an error, because the compiler already splices it. The unit delay stays
visible to the scheduler, which is why a loop drawn on the canvas closes with a Unit Delay block.
How this compares
Section titled “How this compares”Two established designs handle feedback through a modular boundary:
| Block interface | Feedback through a boundary | Cost | |
|---|---|---|---|
| Atomic step | One step function per block | Not visible. You add a delay or inline the block. | Occasional forced delays or hand-picked inlining |
| Output/update split | Separate output and update calls, plus a feedthrough flag per port | Visible. The scheduler interleaves calls across blocks. | A complex block contract and a weak code trace |
Magnetite takes the atomic step. It keeps:
- every block’s generated code uniform;
- the compiler simple;
- the trace from block to code exact.
The same choice explains a line in every stateful block’s generated code:
let omega: f64 = self.omega;omega the signal means this tick’s value, but by the end of the tick self.omega holds the next
one. The local keeps this tick’s value past the write. Under an output/update split, output()
would return self.omega and update() would write it, so no copy would be needed.