Skip to content

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.

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; needs omega from this tick.
  • Delayed. init fby next needs next only 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.

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.

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.

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:

  1. The instance has no module of its own. Other instances of the same model keep theirs.
  2. The code trace coarsens. Spliced equations keep their source spans, but one caller function now holds another block’s logic.

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, &params.pid, error);
let omega: f64 = self.motor.step(ctx, &params.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, &params.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 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 fby with 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.

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.