Skip to content

Modes and state machines

Most controllers are modal: idle or running, charging or discharging, nominal or in a fault they never leave. magscript declares the modes with an enum and runs one arm per mode with a match. This guide builds the controller in examples/modes.

An enum is a closed set of names, declared once in the file that owns it:

enum Mode {
Idle,
Running,
Fault,
}

A variant such as Mode::Idle is a value. It can be:

  • an input or an output;
  • a parameter;
  • a probe;
  • a const;
  • the initial value of a fby.

An enum supports only == and !=.

A match lists the targets every arm drives and runs one arm per variant:

model Ctl {
k: f64,
lim: f64,
mode_ic: Mode,
}
@probe(mode)
step Ctl(cmd: bool, e: f64) -> (out: f64) {
let mode: Mode = self.mode_ic fby next;
match mode -> (u: f64, next: Mode) {
Mode::Idle => {
u = 0.0;
next = if cmd { Mode::Running } else { Mode::Idle };
}
Mode::Running => {
let i: f64 = 0.0 fby (i + dt * e);
u = self.k * i;
next = if e > self.lim { Mode::Fault } else if not cmd { Mode::Idle } else { Mode::Running };
}
Mode::Fault => {
u = 0.0;
next = Mode::Fault;
}
}
out = u;
}
params Ctl(k: f64 = 2.0, lim: f64 = 10.0, mode_ic: Mode = Mode::Idle) {
Self { k, lim, mode_ic }
}

The model has three parts:

  1. mode is a memory that starts at mode_ic and takes next on each tick.
  2. The match declares the targets u and next, and each arm assigns both. next is the transition, an ordinary if over the inputs.
  3. out = u after the match drives the output.

An arm is a scope. The fby in Running belongs to that arm and advances only while it runs. Leave Running and return, and i resumes where it stopped instead of restarting at zero.

Terminal window
$ magnet simulate --dt 0.1 --input data/commands.csv
tick,t,out,probe.mode
0,0,0,Idle
1,0.1,0,Idle
2,0.2,0,Running
3,0.30000000000000004,0.2,Running
4,0.4,0.4,Running
5,0.5,0,Idle
6,0.6000000000000001,0,Idle
7,0.7000000000000001,0.6000000000000001,Running
8,0.8,0,Fault
9,0.9,0,Fault

On tick 7 the controller resumes at 0.6, one step past tick 4, because i held through the idle ticks. Tick 8 feeds an error of 20.0, and the fault latches because no transition leaves Fault.

The initial mode is a parameter, so --param mode_ic=Fault starts the run latched.

The checker reports each of these at the line that caused it:

  • The scrutinee is an enum value and every variant has exactly one arm. A missing arm is named, as in `Mode` has no arm for `Mode::Fault`.
  • Every target is assigned exactly once in every arm.
  • An output is never assigned inside an arm. Drive it from a target after the match.
  • An arm’s let is not visible outside the arm.
  • Equations inside an arm are ordered by dependency, and a target read in its own arm outside a fby is an algebraic loop.
  • An instance is stepped at most once on every path through the tick.

A mode another model needs is an ordinary output. examples/bms has:

  • a state machine that outputs its BmsMode;
  • a charger that matches on it;
  • a contactor that closes on mode != BmsMode::Standby.

An enum in another file is reached with use crate::state_machine::BmsMode;. Two files that each declare a Mode declare two types.

The match becomes Rust’s match over an enum, with the targets declared before it:

#[repr(C)]
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Idle,
Running,
Fault,
}
pub struct Ctl {
mode: Mode,
running_i: f64,
}
fn step(&mut self, ctx: &Ctx, params: &Self::Parameters, (cmd, e): Self::Inputs) -> Self::Outputs {
let mode: Mode = self.mode;
let out: f64;
let next: Mode;
match mode {
Mode::Idle => {
out = 0.0;
next = if cmd { Mode::Running } else { Mode::Idle };
}
Mode::Running => {
let i: f64 = self.running_i;
out = params.k * i;
next = if e > params.lim { Mode::Fault } else if !cmd { Mode::Idle } else { Mode::Running };
self.running_i = i + ctx.dt_secs_f64() * e;
}
Mode::Fault => {
out = 0.0;
next = Mode::Fault;
}
}
self.mode = next;
out
}

running_i is the arm’s i, written only at the end of its arm. The simulation harness prints and reads a mode by its variant name, so a CSV says Running, not 1.

Each of these is a named error today:

  • A _ arm. Adding a variant should fail at every match that has not handled it.
  • A match inside an arm.
  • A match as an expression.
  • Variants with payloads.
  • A rated instance stepped inside an arm.
  • An inline instance stepped in more than one arm.
  • restart on a transition.
  • Same-tick transitions.
  • Hierarchical machines.