Skip to content

Multi-rate models

A real controller samples slower than the physics it drives changes. This guide runs the PID from A simple motor and controller at its own, slower rate while the motor keeps the simulation’s tick. It then gives the full rules:

  • when a rated model fires
  • how periods nest
  • what the generated Rust does

Add one attribute above the PID’s step:

@every(500ms)
step Pid(error: f64) -> (control: f64) {
let i: f64 = 0.0 fby (i + dt * error);
let prev_error = 0.0 fby error;
let d = (error - prev_error) / dt;
control = self.kp * error + self.ki * i + self.kd * d;
}

@every(500ms) declares the model’s period as an integer and a unit: s, ms, us or ns. Inside the step, dt is exactly that period, whatever the caller’s tick is, so the integral and the difference count in the controller’s own time. The loop still steps self.pid(error) exactly as before.

The motor’s @probe(omega_dot) from the previous guide is left out here. With it, the CSV also has a probe.motor.omega_dot column.

Terminal window
$ magnet simulate --dt 0.1 --input main.input.csv
tick,t,omega,probe.error,probe.control
0,0,0,1,1.1
1,0.1,0.44000000000000006,1,1.1
2,0.2,0.7920000000000001,0.5599999999999999,1.1
3,0.30000000000000004,1.0736,0.20799999999999985,1.1
4,0.4,1.29888,-0.07360000000000011,1.1
5,0.5,1.479104,-0.29888000000000003,0.32123199999999996
6,0.6000000000000001,1.311776,-0.479104,0.32123199999999996
7,0.7000000000000001,1.1779136000000001,-0.31177600000000005,0.32123199999999996
8,0.8,1.0708236800000002,-0.17791360000000012,0.32123199999999996
9,0.9,0.9851517440000002,-0.07082368000000017,0.32123199999999996
10,1,0.9166141952000001,0.014848255999999838,0.5720610815999998
...
49,4.9,1.0000864044824642,-0.00009002115845491154,0.5000359688892506

The PID fires on tick 0 and every fifth tick after it. Between fires, its output is held, so probe.control shows runs of five equal rows. The motor is unrated and integrates every tick under that constant command. That causes the overshoot to 1.48 at t = 0.5 s, and the loop still settles at 1.0 with the same gains.

A parent steps a rated instance like any other. The rate decides when the call runs:

model MotorController {
speed: SpeedLoop,
limiter: RateLimiter,
current: CurrentLoop,
}
@every(100us)
step MotorController(w_ref: f64, w: f64, i: f64) -> (v: f64) {
let i_ref: f64 = self.speed(w_ref, w); // @every(10ms): fires every 100th tick, held between
let i_lim: f64 = self.limiter(i_ref); // also @every(10ms): same clock, same ticks
v = self.current(i_lim, i); // unrated: inherits 100us
}

When its period comes due, an instance fires, reads what its parent computed that tick, and produces fresh outputs. On every other tick the parent reads the outputs from the last fire.

Gating the whole body has three consequences:

  1. A fby inside a rated step advances only when it fires.
  2. Probes are held like outputs. Their columns stay one row per base tick and show the value the fast consumer saw.
  3. An unrated model inherits its caller’s clock. Catalog blocks carry no rate of their own.

Periods nest, and each level knows only its own dt:

model Controller {
inner: CurrentLoop,
outer: SpeedLoop,
avoid: CollisionAvoidance,
}
@every(1ms)
step Controller(w_ref: f64, w: f64, i: f64, obstacles: [f64; 4]) -> (v: f64) {
let w_safe: f64 = self.avoid(w_ref, obstacles); // @every(100ms)
let i_ref: f64 = self.outer(w_safe, w); // @every(10ms)
v = self.inner(i_ref, i); // unrated: inherits 1ms
}

The checker enforces four rules:

  1. Harmonic only. A child’s period is a whole multiple of its parent’s, so 10ms and 100ms are valid under 1ms and 1500us is refused.
  2. No child faster than its parent. Make fast models siblings under the faster parent instead.
  3. Same period, same ticks. All instances at one period in one parent fire on the same ticks, counting from tick 0, ordered by data dependency.
  4. One clock per distinct period. 10ms and 10000us share a clock.

An unrated entry takes --dt, which must divide every period on the base clock. Five ticks of 0.1 s make one 500 ms period; a tick that does not fit is refused with an error that shows the model whose rate it missed:

Terminal window
$ magnet simulate --dt 0.3 --input main.input.csv
--dt 0.3 does not divide 500ms, the rate `Pid` runs at

A rated entry, such as @every(10ms) on ClosedLoop itself, fixes the run’s sample time, and magnet simulate refuses another:

Terminal window
$ magnet simulate --dt 0.02
--dt 0.02 contradicts `ClosedLoop`, which declares @every(10ms); a rated entry fixes the sample time, so drop --dt

A rated model’s own Rust carries no period. The parent owns the clock: one accumulator and one fire flag per distinct child period, with each rated call gated in place. The held output is a named field.

const PERIOD_10MS: Duration = Duration::from_millis(10);
pub struct MotorController {
speed: SpeedLoop,
limiter: RateLimiter,
current: CurrentLoop,
clock_10ms: Duration,
speed_i_ref: f64,
limiter_i_lim: f64,
}
impl Model for MotorController {
fn step(&mut self, ctx: &Ctx, params: &Self::Parameters, (w_ref, w, i): Self::Inputs) -> Self::Outputs {
let fire_10ms: bool = self.clock_10ms >= PERIOD_10MS;
if fire_10ms {
let i_ref: f64 = self.speed.step(&Ctx { dt: PERIOD_10MS }, &params.speed, (w_ref, w));
self.speed_i_ref = i_ref;
}
let i_ref: f64 = self.speed_i_ref;
if fire_10ms {
let i_lim: f64 = self.limiter.step(&Ctx { dt: PERIOD_10MS }, &params.limiter, i_ref);
self.limiter_i_lim = i_lim;
}
let i_lim: f64 = self.limiter_i_lim;
let v: f64 = self.current.step(ctx, &params.current, (i_lim, i));
self.clock_10ms += ctx.dt;
if fire_10ms {
self.clock_10ms -= PERIOD_10MS;
}
v
}
}

The clock is read at the start of the tick and advanced at the end, so tick 0 fires and every interval is exact. The arithmetic is on Duration with no casts, and the Model trait is unchanged. An exported model keeps the same clock: an FMU or a firmware steps it at its tick and every child fires on the ticks its period falls on.

Each of these is a named error today:

  • A phase offset. Every instance fires from tick 0.
  • inline on a rated instance, because a rate is a module boundary.
  • A rated instance stepped inside a match arm.
  • A rate on a catalog block.
  • A period named by a const, such as @every(CTRL_PERIOD).
  • Editing the period from the canvas.