Skip to content

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.

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 next is init on the first tick and the previous tick’s next afterwards, so i is an integrator.
  • dt is the sample time, in scope in every step. It is the value passed to --dt.
  • let binds a local signal. A type annotation is optional unless the binding refers to itself, as i does.

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:

error
1.0
1.0
1.0
1.0
Terminal window
$ magnet simulate --dt 0.1 --input error.csv --model Pid --param kp=1.0 --param ki=1.5 --param kd=0.05
tick,t,control
0,0,1.5
1,0.1,1.15
2,0.2,1.3
3,0.30000000000000004,1.4500000000000002

Tick 0 checks out by hand: P is 1, I is still 0, and D is 0.05 · (1 − 0) / 0.1 = 0.5.

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:

Terminal window
$ 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` column

Write the file the motor asks for, 50 rows of full throttle:

Terminal window
$ { 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.0
tick,t,omega
0,0,0
1,0.1,0.4
2,0.2,0.7200000000000001
3,0.30000000000000004,0.976
4,0.4,1.1808
5,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:

Terminal window
$ magnet simulate --dt 0.1 --input throttle.csv --model Motor --param k=2.0 --param tau=0.5 --param omega_init=10.0
tick,t,omega
0,0,10
1,0.1,8.4
2,0.2,7.12
...
49,4.9,2.0001427247692702

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:

Terminal window
$ 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.

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:

Terminal window
$ { echo reference; yes 1.0 | head -50; } > main.input.csv
$ magnet simulate --dt 0.1 --input main.input.csv
tick,t,omega
0,0,0
1,0.1,0.6000000000000001
2,0.2,0.9400000000000001
3,0.30000000000000004,0.912
...
49,4.9,0.9999249437791932

The 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.

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.

Terminal window
$ magnet simulate --dt 0.1 --input main.input.csv
tick,t,omega,probe.error,probe.control
0,0,0,1,1.5
1,0.1,0.6000000000000001,1,1.15
...
49,4.9,0.9999249437791932,0.00008782747556501658,0.49998975732337714

The 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.

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:

Terminal window
$ magnet simulate --dt 0.1 --input main.input.csv
tick,t,omega,probe.error,probe.control,probe.motor.omega_dot
0,0,0,1,1.5,6
...
49,4.9,0.9999249437791932,0.00008782747556501658,0.49998975732337714,0.00010914173512222014

Nesting works to any depth, so a probe three instances down arrives as probe.a.b.c.

Simulation compiles a real Rust crate. Build it to read what a target would receive:

Terminal window
$ 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 runner

The crate is #![no_std], depends only on the magnetite runtime, and has one module per model:

target/codegen/sim/hello/src/motor.rs
#[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 sim builds the simulation runner that magnet simulate executes.