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
Declare the period
Section titled “Declare the period”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.
Run it
Section titled “Run it”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.
$ magnet simulate --dt 0.1 --input main.input.csvtick,t,omega,probe.error,probe.control0,0,0,1,1.11,0.1,0.44000000000000006,1,1.12,0.2,0.7920000000000001,0.5599999999999999,1.13,0.30000000000000004,1.0736,0.20799999999999985,1.14,0.4,1.29888,-0.07360000000000011,1.15,0.5,1.479104,-0.29888000000000003,0.321231999999999966,0.6000000000000001,1.311776,-0.479104,0.321231999999999967,0.7000000000000001,1.1779136000000001,-0.31177600000000005,0.321231999999999968,0.8,1.0708236800000002,-0.17791360000000012,0.321231999999999969,0.9,0.9851517440000002,-0.07082368000000017,0.3212319999999999610,1,0.9166141952000001,0.014848255999999838,0.5720610815999998...49,4.9,1.0000864044824642,-0.00009002115845491154,0.5000359688892506The 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.
Sample on fire, hold between
Section titled “Sample on fire, hold between”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:
- A
fbyinside a rated step advances only when it fires. - Probes are held like outputs. Their columns stay one row per base tick and show the value the fast consumer saw.
- An unrated model inherits its caller’s clock. Catalog blocks carry no rate of their own.
Nesting
Section titled “Nesting”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:
- Harmonic only. A child’s period is a whole multiple of its parent’s, so
10msand100msare valid under1msand1500usis refused. - No child faster than its parent. Make fast models siblings under the faster parent instead.
- 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.
- One clock per distinct period.
10msand10000usshare a clock.
The run’s sample time
Section titled “The run’s sample time”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:
$ magnet simulate --dt 0.3 --input main.input.csv--dt 0.3 does not divide 500ms, the rate `Pid` runs atA rated entry, such as @every(10ms) on ClosedLoop itself, fixes the run’s sample time, and
magnet simulate refuses another:
$ magnet simulate --dt 0.02--dt 0.02 contradicts `ClosedLoop`, which declares @every(10ms); a rated entry fixes the sample time, so drop --dtGenerated code
Section titled “Generated code”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 }, ¶ms.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 }, ¶ms.limiter, i_ref); self.limiter_i_lim = i_lim; } let i_lim: f64 = self.limiter_i_lim; let v: f64 = self.current.step(ctx, ¶ms.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.
Not supported yet
Section titled “Not supported yet”Each of these is a named error today:
- A phase offset. Every instance fires from tick 0.
inlineon a rated instance, because a rate is a module boundary.- A rated instance stepped inside a
matcharm. - A rate on a catalog block.
- A period named by a
const, such as@every(CTRL_PERIOD). - Editing the period from the canvas.