A simple motor and controller
This tutorial writes a PID controller and a first-order DC motor, closes a feedback loop between them, and probes the signals inside it. It builds on the project from the quickstart.
The controller
Section titled “The controller”A parallel PID is u = kp·e + ki·∫e dt + kd·(de/dt). Discretized with a forward-Euler integral
and a backward-difference derivative, each term is one line. Write it as src/pid.mag:
model Pid { kp: f64, ki: f64, kd: f64,}
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;}Three language features carry this model:
fby(“followed by”) is the unit delay and the only stateful construct.init fby nextisiniton the first tick and the previous tick’snextafterwards, soiis an integrator.dtis the sample time, in scope in everystep. It is the value passed to--dt.letbinds a local signal. A type annotation is optional unless the binding refers to itself, asidoes.
Feed it a constant error of 1.0 from error.csv. Columns are matched by header name, and the
tick count defaults to the number of rows:
error1.01.01.01.0$ magnet simulate --dt 0.1 --input error.csv --model Pid --param kp=1.0 --param ki=1.5 --param kd=0.05tick,t,control0,0,1.51,0.1,1.152,0.2,1.33,0.30000000000000004,1.4500000000000002Tick 0 checks out by hand: P is 1, I is still 0, and D is 0.05 · (1 − 0) / 0.1 = 0.5.
Create the plant
Section titled “Create the plant”The motor is first order, τ·ω̇ = k·throttle − ω, discretized with forward Euler. Add
src/motor.mag next to src/pid.mag:
model Motor { k: f64, tau: f64, omega_init: f64,}
step Motor(throttle: f64) -> (omega: f64) { let omega_dot = (self.k * throttle - omega) / self.tau; omega = self.omega_init fby omega + omega_dot * dt;}The first line is the physics and the second is the Euler step. omega_dot reads omega before
it is assigned, which is legal because the assignment goes through a fby. This tick’s omega
is state and is known when the tick starts.
Every declared input needs a column named after it. The PID’s file has an error column and no
throttle, so it is refused before the first tick:
$ cp error.csv throttle.csv$ magnet simulate --dt 0.1 --input throttle.csv --model Motor --param k=2.0 --param tau=0.5 --param omega_init=0.0 Error throttle.csv: no `throttle` columnWrite the file the motor asks for, 50 rows of full throttle:
$ { echo throttle; yes 1.0 | head -50; } > throttle.csv$ magnet simulate --dt 0.1 --input throttle.csv --model Motor --param k=2.0 --param tau=0.5 --param omega_init=0.0tick,t,omega0,0,01,0.1,0.42,0.2,0.72000000000000013,0.30000000000000004,0.9764,0.4,1.18085,0.5,1.34464...49,4.9,1.9999643188076823--model picks which model to run when a package has several. The motor coasts toward its DC
gain of 2.0, and it needs a controller to settle at 1.0.
Because the initial condition is a parameter, a run can start anywhere. Start above the steady state and ω decays to 2.0 on the same 0.5 s time constant:
$ magnet simulate --dt 0.1 --input throttle.csv --model Motor --param k=2.0 --param tau=0.5 --param omega_init=10.0tick,t,omega0,0,101,0.1,8.42,0.2,7.12...49,4.9,2.0001427247692702Close the loop
Section titled “Close the loop”A model field whose type is another model declares a model instance, and
self.<field>(inputs) steps it. Rename the starter src/hello.mag to
src/closed_loop.mag, which renames its model to ClosedLoop, and compose the two:
use crate::motor::Motor;use crate::pid::Pid;
model ClosedLoop { pid: Pid, motor: Motor,}
step ClosedLoop(reference: f64) -> (omega: f64) { let error = reference - omega; let control = self.pid(error); omega = self.motor(control);}The loop reads correctly, but magnet check rejects it:
$ magnet check Checking hello Error error: algebraic loop between error -> control -> omega (break it with a `fby`, or mark the instance `inline` to expose its internal delay) --> ./src/closed_loop.mag:10:5 |10 | let error = reference - omega; | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ --> ./src/closed_loop.mag:11:5 |11 | let control = self.pid(error); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ --> ./src/closed_loop.mag:12:5 |12 | omega = self.motor(control); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^The scheduler analyzes each step alone and treats a model call as a black box, where every input
may reach every output in the same tick. It cannot see that Motor’s output comes from a fby,
so error, control and omega form a cycle.
Break the loop by feeding back the previous tick’s measurement:
step ClosedLoop(reference: f64) -> (omega: f64) { let error = reference - omega; let prev_omega = self.motor.omega_init fby omega; let error = reference - prev_omega; let control = self.pid(error); omega = self.motor(control);}The delay starts where ω starts. self.motor.omega_init reads the motor’s parameter, so the delay
follows the plant whatever value the motor is given, including one routed in from the command
line.
Marking the instance inline motor: Motor is the other remedy. The compiler splices the motor’s
body into the loop, its internal fby becomes visible, and the loop closes without an added delay.
Causality, delays, and feedback weighs the two.
Parameters
Section titled “Parameters”A params item at the end of src/closed_loop.mag is the loop’s constructor. It builds the whole
parameter tree by calling each instance’s own constructor:
params ClosedLoop() { Self { pid: Pid(1.0, 1.5, 0.05), motor: Motor(2.0, 0.5, 0.0), }}Pid and Motor have no params item, so each has the implicit constructor: every field is an
argument, in declaration order, with no default. Motor(2.0, 0.5, 0.0) is k, tau,
omega_init.
The params item also marks the entry, so --model is no longer needed. Simulate against a step
reference:
$ { echo reference; yes 1.0 | head -50; } > main.input.csv$ magnet simulate --dt 0.1 --input main.input.csvtick,t,omega0,0,01,0.1,0.60000000000000012,0.2,0.94000000000000013,0.30000000000000004,0.912...49,4.9,0.9999249437791932The loop tracks the reference and ω converges to 1.0.
--param sets an argument of the entry’s constructor, and ClosedLoop() takes none, so
--param pid.kp=2.0 is refused: a child’s arguments are its parent’s decision. Route a gain
through a root argument, as in
params ClosedLoop(kp: f64 = 1.0) { Self { pid: Pid(kp, 1.5, 0.05), … } }, and --param kp=2.0
overrides it for one run.
Probes
Section titled “Probes”The CSV shows only omega. The error and the control effort are locals, and promoting them to
outputs would add debug ports to the model you ship. Probe them instead:
@probe(error, control)step ClosedLoop(reference: f64) -> (omega: f64) { let prev_omega = self.motor.omega_init fby omega; let error = reference - prev_omega; let control = self.pid(error); omega = self.motor(control);}@probe records any input, local or output of its step. Probes are telemetry, not signals, so no
equation can read one and adding one never changes what the model computes.
$ magnet simulate --dt 0.1 --input main.input.csvtick,t,omega,probe.error,probe.control0,0,0,1,1.51,0.1,0.6000000000000001,1,1.15...49,4.9,0.9999249437791932,0.00008782747556501658,0.49998975732337714The control effort settles at 0.5, the input that holds a gain-2 motor at 1.0. The omega column
is identical to the run without probes.
Probes inside instances
Section titled “Probes inside instances”Probe an instance’s internals on its own step. omega_dot is the motor’s acceleration, which no
caller should need as an output:
@probe(omega_dot)step Motor(throttle: f64) -> (omega: f64) {The caller nests the instance’s telemetry, and the CSV shows it as a dotted column:
$ magnet simulate --dt 0.1 --input main.input.csvtick,t,omega,probe.error,probe.control,probe.motor.omega_dot0,0,0,1,1.5,6...49,4.9,0.9999249437791932,0.00008782747556501658,0.49998975732337714,0.00010914173512222014Nesting works to any depth, so a probe three instances down arrives as probe.a.b.c.
The generated code
Section titled “The generated code”Simulation compiles a real Rust crate. Build it to read what a target would receive:
$ magnet build --profile sim Checking hello Transpiling target/codegen/sim/hello Compiling hello v0.1.0 Compiling sim-hello-closed-loop v0.1.0 Finished `release` profile [optimized] target(s) in 3.08s Ready simulation runnerThe crate is #![no_std], depends only on the magnetite runtime, and has one module per model:
#[repr(C)]pub struct MotorParameters { pub k: f64, pub tau: f64, pub omega_init: f64,}
impl MotorParameters { pub fn new(k: f64, tau: f64, omega_init: f64) -> Self { Self { k, tau, omega_init, } }}
#[derive(Clone, Copy)]pub struct MotorTelemetry { pub omega_dot: f64,}
pub struct Motor { omega: f64,}
impl Model for Motor { type Parameters = MotorParameters; type Inputs = f64; type Outputs = (f64, MotorTelemetry);
fn init(params: &Self::Parameters) -> Self { Self { omega: params.omega_init, } }
fn step(&mut self, ctx: &Ctx, params: &Self::Parameters, throttle: Self::Inputs) -> Self::Outputs { let omega: f64 = self.omega; let omega_dot: f64 = (params.k * throttle - omega) / params.tau; self.omega = omega + omega_dot * ctx.dt_secs_f64(); let telemetry = MotorTelemetry { omega_dot }; (omega, telemetry) }}The profile decides what is compiled:
--profile release, the default, builds the deployable library with no probes.--profile simbuilds the simulation runner thatmagnet simulateexecutes.