Skip to content

Satellite attitude control

This guide builds an attitude controller for a small satellite from nine models, run together with the plant they fly. It builds on A simple motor and controller, in particular model instances, feedback and probes.

The physics are small-angle and decoupled per axis:

  • J·ω̇ = τ_wheel + τ_dist and θ̇ = ω on each axis.
  • Wheel momentum is h = ∫(τ_wheel − bleed).
  • The craft starts tumbling at (0.02, −0.015, 0.01) rad/s.

A 3-axis signal is one value of type [f64; 3], not three scalars. The per-axis math is written once as an element-wise expression, and the builtins follow:

  • limit(v, -1.0, 1.0) saturates every element.
  • v .* w multiplies element by element.
  • amax(v) returns the largest absolute element.
Model instance Job
disturbances The torque environment: a slow sine, a stuck thruster, a bias
plant Torque to rate to angle, per axis
rate_filter One-pole low-pass on the measured rates
fault_detection Over-rate or over-momentum, latched
mode_logic Detumble 1, point 2, safe 3, with hysteresis
attitude_control PD pointing law or rate-damping detumble law, gated by the fault
wheels Torque authority, slew limit and a momentum integrator
momentum_management Bleeds every wheel once the loudest passes 0.45 N·m·s

The plant runs in the same model, so one deterministic run exercises controller and plant together.

Terminal window
magnet new gnc && cd gnc

Replace the seeded src/gnc.mag and add a file per module as the sections below go (nine files, one model each). Two scheduling facts shape them:

  1. Model calls are black boxes. A loop through an instance needs an explicit delay at the top level. The torque path to the plant, the momentum path to the monitors and the bleed path to the wheels each cross a fby.
  2. Forward references need an annotation. The top level calls self.plant(td, d) before td is defined, which is legal because td’s let is annotated. Tuple bindings cannot be forward referenced, so the calls come first and the delays last.
// --- Disturbances: a slow sinusoidal torque on axis 1 (gravity-gradient
// stand-in), a 70 s stuck-thruster impulse on axis 2, and a constant
// secular torque on axis 3 that slowly loads wheel 3 with momentum.
use crate::spacecraft_plant::SpacecraftPlant;
model Disturbances {
sine_amp: f64,
sine_freq: f64,
event_start: f64,
event_stop: f64,
event_torque: f64,
secular: f64,
}
step Disturbances() -> (d: [f64; 3]) {
let t: f64 = 0.0 fby (t + dt);
let gravity: f64 = self.sine_amp * sin(TAU * self.sine_freq * t);
let thruster: f64 = if t >= self.event_start and t < self.event_stop {
self.event_torque
} else {
0.0
};
d = [gravity, thruster, self.secular];
}

One disturbance per axis, assembled into one [f64; 3]:

  1. A 200 s sine on axis 1 stands in for the gravity gradient.
  2. A 70 s window on axis 2 is a stuck thruster at twice the wheels’ authority. The fault path exists for this one.
  3. A constant on axis 3 slowly fills wheel 3 with momentum.

let t: f64 = 0.0 fby (t + dt); is the block’s own time base.

// --- SpacecraftPlant: torques sum, divide by inertia, and integrate twice —
// torque to rate to angle, element-wise across all three axes at once.
use crate::rate_filter::RateFilter;
model SpacecraftPlant {
inv_inertia: [f64; 3],
rate_ic: [f64; 3],
angle_ic: [f64; 3],
}
step SpacecraftPlant(tau: [f64; 3], d: [f64; 3]) -> (om: [f64; 3], th: [f64; 3]) {
let alpha: [f64; 3] = (tau + d) .* self.inv_inertia;
om = self.rate_ic fby limit(om + dt * alpha, -1.0, 1.0);
th = self.angle_ic fby limit(th + dt * om, -6.283, 6.283);
}

(tau + d) sums wheel and disturbance torque element-wise, and .* self.inv_inertia divides each axis by its own inertia. Each integrator follows the ic fby limit(x + dt·ẋ) pattern.

inv_inertia: [0.1, 0.08, 0.125] is 1/J for inertias of 10, 12.5 and 8 kg·m². The rate integrators’ initial values are the tumble, and the angle integrators’ are the initial pointing error.

// --- RateFilter: a one-pole low-pass on the measured rates,
// wf = α·ω + (1−α)·wf⁻¹. The delay's initial condition matches the plant's
// initial rates — boot it at zero and the mode logic would wake up
// believing the craft is already still. Note the two delay forms: `wf_prev`
// reads *last* tick's filter output, while `wf` itself reads *this* tick's
// `om` — which side of the `fby` a signal sits on is the whole design.
use crate::fault_detection::FaultDetection;
model RateFilter {
alpha: f64,
ic: [f64; 3],
}
step RateFilter(om: [f64; 3]) -> (wf: [f64; 3]) {
let wf_prev: [f64; 3] = self.ic fby wf;
wf = self.alpha * om + (1.0 - self.alpha) * wf_prev;
}

A one-pole low-pass, wf = α·ω + (1−α)·wf⁻¹, written once for three axes. The two sides of its fby are the design of the filter:

  • let wf_prev: [f64; 3] = self.ic fby wf; is the previous tick’s output.
  • wf = self.alpha * om + … reads this tick’s om.
// --- FaultDetection: over-rate and over-momentum monitors on the loudest
// axis (`amax` = the largest absolute element), OR-ed and latched through
// a `max` with the latch's own previous value.
use crate::mode_logic::ModeLogic;
model FaultDetection {
max_rate: f64,
max_momentum: f64,
}
step FaultDetection(wf: [f64; 3], h: [f64; 3]) -> (fault: f64) {
let tripped: bool = amax(wf) >= self.max_rate or amax(h) >= self.max_momentum;
let trip: f64 = if tripped { 1.0 } else { 0.0 };
let latched: f64 = 0.0 fby fault;
fault = max(trip, latched);
}

Two monitors, each one comparison:

  • Over-rate: amax(wf) ≥ 0.05 rad/s on the loudest filtered axis.
  • Over-momentum: amax(h) ≥ 0.9 N·m·s on the loudest wheel.

fault = max(now, before), with a fby feeding fault back into itself, is a latch in pure dataflow. In the nominal run the over-rate monitor trips, because desaturation keeps momentum well below its ceiling.

// --- ModeLogic: 1 detumble · 2 point · 3 safe. A hysteretic relay on the
// loudest filtered rate decides detumble vs point without chattering; the
// fault latch overrides everything.
use core::relay::Relay;
use crate::attitude_control::AttitudeControl;
model ModeLogic {
detumble: Relay,
}
step ModeLogic(fault: f64, wf: [f64; 3]) -> (mode: f64, det: f64) {
det = self.detumble(amax(wf));
mode = if fault >= 0.5 { 3.0 } else { 2.0 - det };
}

The mode is fault ? 3 : 2 − det. The Relay is a catalog block declared as an instance field. It turns on above 0.015 rad/s and off below 0.008 rad/s, so detumble and point cannot chatter.

It also re-arms. When the stuck thruster spins the craft past 0.015 again, the mode drops back to detumble before the fault monitor gives up.

// --- AttitudeControl: a PD law tracks the reference in point mode and a
// pure rate damper runs in detumble; the fault latch gates the command to
// zero. Only axis 1 has a reference — axes 2 and 3 hold zero.
//
// The law runs every second over the plant's 100 ms tick: `@every(1s)`
// samples its inputs on the tick it fires and holds `tc` between, so the
// wheels see one command for ten plant ticks — a 1 Hz controller on a
// 10 Hz plant, which is what the flight computer would run.
use crate::reaction_wheels::ReactionWheels;
model AttitudeControl {
kp: f64,
kd: f64,
k_detumble: f64,
}
// The gains the flight computer ships with. `gnc.mag` passes its own; an
// export of the controller on its own — an FMU — starts from these.

The law is four expressions:

  1. reference − th is the attitude error. Only axis 1 has a commanded angle.
  2. self.kp * err + self.kd * wf is the PD command.
  3. self.k_detumble * wf is the rate damper.
  4. An if over whole arrays picks one per mode, and (1.0 − fault) gates all axes to zero once the latch trips.

The PD gains put each axis at ωₙ ≈ 0.07 rad/s, critically damped. The detumble gain saturates the wheels while the craft tumbles and ignores attitude entirely.

@every(1s) runs the law once a second over the plant’s 100 ms tick. It samples its inputs on the tick it fires and holds tc between, so the wheels see one command for ten plant ticks and probe.tc shows runs of ten equal rows. Multi-rate models covers the rules.

// --- ReactionWheels: the commanded torque is clipped to the wheel's
// authority and slew rate, and everything the wheels exert accumulates as
// stored momentum; the desat bleed drains it.
use crate::momentum_management::MomentumManagement;
model ReactionWheels {
max_torque: f64,
slew_rate: f64,
}
step ReactionWheels(tc: [f64; 3], bleed: [f64; 3]) -> (tau: [f64; 3], h: [f64; 3]) {
let clipped: [f64; 3] = limit(tc, -self.max_torque, self.max_torque);
let prev: [f64; 3] = [0.0, 0.0, 0.0] fby tau;
tau = prev + limit(clipped - prev, -(dt * self.slew_rate), dt * self.slew_rate);
h = [0.0, 0.0, 0.0] fby limit(h + dt * (tau - bleed), -1.0, 1.0);
}

Three steps per wheel:

  1. The commanded torque is clipped to ±0.02 N·m of authority.
  2. A slew limiter, prev + limit(clipped − prev, ±dt·rate), reaches full torque in 2 s.
  3. Everything the wheel exerts accumulates in its momentum integrator.

A reaction wheel stores momentum rather than removing it, and something else must eventually dump it.

// --- MomentumManagement: once the loudest wheel passes 0.45 N·m·s of
// stored momentum, bleed every wheel toward zero until the loudest is
// back under 0.2 — the relay's hysteresis makes it a sawtooth, not a
// chatter.
use core::relay::Relay;
model MomentumManagement {
k_bleed: f64,
monitor: Relay,
}
step MomentumManagement(h: [f64; 3]) -> (bleed: [f64; 3], desat: f64) {
desat = self.monitor(amax(h));
bleed = (self.k_bleed * desat) * h;
}

Once the loudest wheel passes 0.45 N·m·s, every wheel bleeds proportionally (0.02·h) until the loudest is under 0.2. The relay’s hysteresis turns desaturation into a sawtooth.

// --- The top level: the reference source, the eight model instances, and the
// three `fby` delays that carry every wheel torque, momentum, and bleed
// command across a tick to break the loops.
use core::relay::Relay;
use core::step_function::StepFunction;
use crate::disturbances::Disturbances;
use crate::spacecraft_plant::SpacecraftPlant;
use crate::rate_filter::RateFilter;
use crate::fault_detection::FaultDetection;
use crate::mode_logic::ModeLogic;
use crate::attitude_control::AttitudeControl;
use crate::reaction_wheels::ReactionWheels;
use crate::momentum_management::MomentumManagement;
model Gnc {
reference: StepFunction,
disturbances: Disturbances,
plant: SpacecraftPlant,
rate_filter: RateFilter,
fault_detection: FaultDetection,
mode_logic: ModeLogic,
attitude_control: AttitudeControl,
wheels: ReactionWheels,
momentum_management: MomentumManagement,
}
@probe(th, om, hd, mode, tc)
step Gnc() -> () {
let ref1: f64 = self.reference();
let d: [f64; 3] = self.disturbances();
let (om, th) = self.plant(td, d);
let wf: [f64; 3] = self.rate_filter(om);
let fault: f64 = self.fault_detection(wf, hd);
let (mode, det) = self.mode_logic(fault, wf);
let tc: [f64; 3] = self.attitude_control(ref1, th, wf, det, fault);
let (tau, h) = self.wheels(tc, bld);
let (bleed, desat) = self.momentum_management(hd);
let td: [f64; 3] = [0.0, 0.0, 0.0] fby tau;
let hd: [f64; 3] = [0.0, 0.0, 0.0] fby h;
let bld: [f64; 3] = [0.0, 0.0, 0.0] fby bleed;
}
// The initial body rate, stated once: the plant starts at it and the rate
// filter is seeded with it, and the two have to agree.
const RATE_IC: [f64; 3] = [0.02, -0.015, 0.01];

Three things make up the architecture:

  • the reference
  • eight model calls
  • three fby delays

Follow one loop:

  1. wheels produces tau.
  2. td delays it a tick.
  3. The earlier plant call consumes td.

@probe(th, om, hd, mode, tc) requests telemetry. Array probes flatten to one column per element, as in probe.th[0]. The params Gnc item at the end of the file states the whole parameter tree.

Terminal window
magnet check
magnet simulate --dt 0.1 --ticks 6000 > out.csv

Plot the probe columns of out.csv:

  • probe.mode is 1 for the first 7.6 s, then 2 for most of the run. It dips to 1 at t ≈ 407 s as the thruster spins the craft, then latches at 3 from t ≈ 429 s.
  • probe.om[0] decays from 0.02 to near zero by t ≈ 11 s, shows small transients at the t = 200 s step, and wanders once the wheels stop.
  • probe.th[0] rises from 0.1 to about 0.22 during detumble, because the rate damper ignores attitude. It then converges with a ±0.02 rad ripple, steps to 0.1 at t = 200 s, and drifts to about 0.57 rad after the fault.
  • probe.hd[1] parks near +0.2 after detumble. Wheel 2 stores the axis-2 tumble momentum, J₂ · 0.015 ≈ 0.19 N·m·s, as a wheel must.
  • probe.hd[2] ramps down under the constant torque, reaches −0.45 at t = 185 s, sawtooths back, reaches −0.45 again at t ≈ 373 s, and bleeds to about −0.1 after the fault.

params Gnc takes two arguments, event_torque and secular, and passes them into the Disturbances call. A --param sets an argument of the entry’s constructor, so each experiment is one flag:

  • --param event_torque=0.01 keeps the thruster event inside the wheels’ authority. There is no fault, desaturation absorbs the momentum, and the run ends pointing. The fault threshold separates what the actuators can absorb from what they cannot.
  • --param secular=0.005 loads wheel 3 2.5 times faster. The bleed then balances the torque at about 0.25 N·m·s, above the 0.2 release threshold, so desaturation never releases.

The top-level file is the repository’s known-good example, pinned by the test suite:

src/gnc.mag
// A closed-loop attitude GNC for a small satellite over a 3-axis rigid-body
// plant, written as hand-authored DSL: 3-axis signals are `[f64; 3]` arrays,
// per-axis math is one element-wise expression instead of three copies, and
// every delay is an explicit `fby`. (The same model built block by block on
// the canvas lives, in the exact shape the GUI's splice engine emits, at
// crates/magnetite-testbed/tests/data/ok/gnc/gnc.mag behind
// docs/internal/walkthroughs/gnc.md.)
//
// magnet check
// magnet simulate --dt 0.1 --ticks 6000 > out.csv
//
// Small-angle, per-axis decoupled dynamics: J·ω̇ = τ_wheel + τ_dist and
// θ̇ = ω on each axis. The craft starts tumbling; mode 1 (detumble) damps
// the rates, mode 2 (point) holds a PD attitude loop, and a stuck-thruster
// event at t = 400 s overwhelms the wheels until the over-rate monitor
// latches a fault and mode 3 (safe) gates every wheel torque to zero.
// The attitude controller is rated `@every(1s)`: it fires every tenth
// plant tick and its torque `tc` is held between, which the probe shows
// as ten equal rows per command.
// Reaction-wheel momentum integrates every torque the wheels exert; a
// hysteretic desaturation manager bleeds it down whenever the loudest
// wheel passes 0.45 N·m·s. Every wheel and momentum signal into the GNC
// crosses a `fby` — black-box model calls are full feedthrough, so
// those delays are what break the plant ↔ controller loops.
// --- The top level: the reference source, the eight model instances, and the
// three `fby` delays that carry every wheel torque, momentum, and bleed
// command across a tick to break the loops.
use core::relay::Relay;
use core::step_function::StepFunction;
use crate::disturbances::Disturbances;
use crate::spacecraft_plant::SpacecraftPlant;
use crate::rate_filter::RateFilter;
use crate::fault_detection::FaultDetection;
use crate::mode_logic::ModeLogic;
use crate::attitude_control::AttitudeControl;
use crate::reaction_wheels::ReactionWheels;
use crate::momentum_management::MomentumManagement;
model Gnc {
reference: StepFunction,
disturbances: Disturbances,
plant: SpacecraftPlant,
rate_filter: RateFilter,
fault_detection: FaultDetection,
mode_logic: ModeLogic,
attitude_control: AttitudeControl,
wheels: ReactionWheels,
momentum_management: MomentumManagement,
}
@probe(th, om, hd, mode, tc)
step Gnc() -> () {
let ref1: f64 = self.reference();
let d: [f64; 3] = self.disturbances();
let (om, th) = self.plant(td, d);
let wf: [f64; 3] = self.rate_filter(om);
let fault: f64 = self.fault_detection(wf, hd);
let (mode, det) = self.mode_logic(fault, wf);
let tc: [f64; 3] = self.attitude_control(ref1, th, wf, det, fault);
let (tau, h) = self.wheels(tc, bld);
let (bleed, desat) = self.momentum_management(hd);
let td: [f64; 3] = [0.0, 0.0, 0.0] fby tau;
let hd: [f64; 3] = [0.0, 0.0, 0.0] fby h;
let bld: [f64; 3] = [0.0, 0.0, 0.0] fby bleed;
}
// The initial body rate, stated once: the plant starts at it and the rate
// filter is seeded with it, and the two have to agree.
const RATE_IC: [f64; 3] = [0.02, -0.015, 0.01];
params Gnc(event_torque: f64 = 0.04, secular: f64 = 0.002) {
Self {
reference: StepFunction(200.0, 0.0, 0.1),
disturbances: Disturbances(0.001, 0.005, 400.0, 470.0, event_torque, secular),
plant: SpacecraftPlant([0.1, 0.08, 0.125], RATE_IC, [0.1, -0.08, 0.06]),
rate_filter: RateFilter(0.2, RATE_IC),
fault_detection: FaultDetection(0.05, 0.9),
mode_logic: ModeLogic(),
attitude_control: AttitudeControl(0.05, -1.5, -2.0),
wheels: ReactionWheels(0.02, 0.01),
momentum_management: MomentumManagement(),
}
}
  • Replace the step source with a recorded profile. Delete reference, add an input port named target, and run magnet simulate --dt 0.1 --input pointing_profile.csv.
  • Move the plant to coupled 6-DOF dynamics in a custom Rust block.
  • Read Causality, delays, and feedback for the scheduling rules behind the latches and delays.
  • Export the controller as an FMU and step it from a co-simulation master.