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 + τ_distandθ̇ = ω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 .* wmultiplies element by element.amax(v)returns the largest absolute element.
What you will build
Section titled “What you will build”| 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.
Before you start
Section titled “Before you start”magnet new gnc && cd gncReplace 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:
- 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. - Forward references need an annotation. The top level calls
self.plant(td, d)beforetdis defined, which is legal becausetd’sletis annotated. Tuple bindings cannot be forward referenced, so the calls come first and the delays last.
The torque environment
Section titled “The torque environment”// --- 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]:
- A 200 s sine on axis 1 stands in for the gravity gradient.
- A 70 s window on axis 2 is a stuck thruster at twice the wheels’ authority. The fault path exists for this one.
- 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.
The plant
Section titled “The plant”// --- 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.
Measuring the rates
Section titled “Measuring the rates”// --- 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’som.
Faults that latch
Section titled “Faults that latch”// --- 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.05rad/s on the loudest filtered axis. - Over-momentum:
amax(h) ≥ 0.9N·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.
The mode logic
Section titled “The mode logic”// --- 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.
The attitude law
Section titled “The attitude law”// --- 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:
reference − this the attitude error. Only axis 1 has a commanded angle.self.kp * err + self.kd * wfis the PD command.self.k_detumble * wfis the rate damper.- An
ifover 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.
The wheels
Section titled “The wheels”// --- 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:
- The commanded torque is clipped to ±0.02 N·m of authority.
- A slew limiter,
prev + limit(clipped − prev, ±dt·rate), reaches full torque in 2 s. - 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.
Momentum management
Section titled “Momentum management”// --- 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
Section titled “The top level”// --- 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
fbydelays
Follow one loop:
wheelsproducestau.tddelays it a tick.- The earlier
plantcall consumestd.
@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.
Run it
Section titled “Run it”magnet checkmagnet simulate --dt 0.1 --ticks 6000 > out.csvPlot the probe columns of out.csv:
probe.modeis 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.
Two experiments
Section titled “Two experiments”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.01keeps 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.005loads 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 finished model
Section titled “The finished model”The top-level file is the repository’s known-good example, pinned by the test suite:
// 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(), }}Where next
Section titled “Where next”- Replace the step source with a recorded profile. Delete
reference, add an input port namedtarget, and runmagnet 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.