ADCS — Attitude Determination and Control
Reference documentation for nodes/adcs_node.c and
include/aocs_icd.h,
the simulated hardware itself has its own reference: dyn_engine.md.
See index.md for the platform overview, dhs.md for the on-board DHS this
node reports to, and eps.md §10 for the redundancy model it
participates in.
CAN address 0x3, PUS APID 0x003. One binary, build/bin/adcs_node.
1. Role and boundaries
The ADCS is the only node built as two decoupled modules sharing one process:
| Module | Lives in | Flight software? |
|---|---|---|
| Control Loop Application | nodes/adcs_node.c | Yes. Determination, detumble, pointing, mode logic, FDIR. |
| External Dynamics Engine | sim/ | No. Orbit, field, Sun, rigid body, sensor heads, actuators — see dyn_engine.md. Orbit/Sun/shadow are shared with the EPS’s power plant; the rest is the ADCS’s alone. |
They communicate through exactly one thing, aocs_icd.h, and nothing else — the application holds
no plant model and never acquires one; the engine never calls into the application. This mirrors
the AOCS/spacecraft separation ECSS-E-ST-60-30C assumes, managed as an interface document per
ECSS-E-ST-40C.
Not here: packets, sequence numbers, the ground link. The node emits raw CAN housekeeping, the DHS turns it into the ground report. It holds no clock of its own and disciplines to the DHS broadcast like every other node.
2. The physical picture
2a. The sensors are on the ADCS board
Gyro triad, magnetometer, six sun cells, two star trackers, wheel tachometers are wired to the
ADCS PCB — analog/SPI/I²C into the ADCS CPU, not bus devices. No raw measurement ever reaches
CAN; the DHS could not read a sun cell if it wanted to. That’s what the module split models: the
Dynamics Engine stands in for the sensor harness, so reading xAocsIcd.ro.gyro[0] is the software
image of reading the gyro’s SPI register. It also sets the subsystem’s rate structure:
ADCS board (adcs_node process) | CAN | DHS | ground
------------------------------------------------------------------------------------------
sensors --(SPI/I2C/analog, board-local)--> ADCS local pool
= engine RO segment, 10 Hz DHS central pool
| |
v |
control loop (TRIAD / B-dot / PID) |
| |
+--> RW: wheel + torquer setpoints |
| (engine reads back -- closed loop) |
| |
+--> q_est, rate, mode, FDIR --1 Hz--> prvIngestHk --> pool
|
prvEmitAdcsHk +--> TM[3,25]
(DHS cadence) | + [20,x]
|
gains in force <--boot push, retried-- OBDB block <--------+---- TC[20,3]
(xCfg, bound) CMD_CFG_BEGIN/_ITEM/_END + NVRAM delta2b. Two data pools
The pool is per-process — every node links common/datapool.c and gets a private dp_entry_t xPool[]. Two pools are in play here:
| ADCS local pool | DHS central pool | |
|---|---|---|
| Lives in | adcs_node | dhs_node |
| Holds | the whole interface image — 63 identifiers (sensors, ground truth, actuator setpoints, gains in force) | the platform mirror, the on-board database, and the 24-identifier echo of this subsystem |
| Turnover | 10 Hz, board-local | 1 Hz, arrives over CAN |
| Ground-reachable | No — only the DHS serves PUS service 20 | Yes |
They share mnemonics for the echoed quantities: ADCS_QE_X is bound in the ADCS to
xAocsIcd.rw.q_est[1], and in the DHS to xPlat.adcs_q[1] — the same distributed-pool pattern the
OBDB uses, unambiguous because a pool never leaves its node. A sun cell has no central-pool entry:
there is no wire from it to the DHS, and inventing one the DHS could never fill would be worse than
having none.
Binding alone doesn’t make a parameter valid. dpRegister leaves a read-only entry
DP_ST_UNDEFINED; only a write through the pool API promotes it, but this node’s plant writes the
bound fields directly. So dpMarkAllValid() promotes the whole pool once, after the first
dynStep (not at bind time, when the image is still zeros). It touches status and update time
only, never a value — which keeps it clear of the interface guard and lets the EPS share the same
primitive.
Thereafter prvIcdValidity (:752) tracks the sensors that genuinely
come and go — sun vector, two star-tracker quaternions — acting only on a transition. The *_VAL
flags themselves stay always valid: “the sun sensor doesn’t see the Sun” is a good measurement, and
invalidating it would hide that from a dead subsystem.
2c. Boot: the DHS pushes mission configuration into the ADCS
The ADCS boots on ROM defaults, then adopts the DHS’s block (CMD_CFG_BEGIN → N ×
CMD_CFG_ITEM → CMD_CFG_END, epoch/count/CRC-16), staged whole by
common/obdb.c (see obdb.md). Because the pool binds rather than
copies, a committed item lands directly in the field the control law reads —
dpApplySet on ADCS_KP writes xCfg.kp and the next cycle uses it; no restart, no apply step.
Three properties this subsystem depends on:
- Idempotent, retried until acknowledged — a rebooted subsystem re-syncs unasked.
- Re-armed after a CPU-board switch — the DHS calls
prvObdbInvalidate(CAN_ADDR_ADCS)and re-pushes within a few ticks. This is whyprvAdcsRebootdeliberately does not restore gains locally: doing so would race the push. - Only the first commit seeds live state —
ucCfgSeedPendingexists because the boot body rates are an initial condition, not a value merely read; re-seeding on a later block would teleport the spacecraft’s rates mid-flight.
2d. Cyclically, the ADCS echoes state back
Once per hk_period_ms the node sends five tagged CAN frames. The DHS’s prvIngestHk writes each
through dpWriteF32/dpWriteI32 — through the pool, never straight to the field — so the central
pool stamps DP_ST_VALID and the coarse-OBT time. That’s the difference between “the quaternion is
identity” and “we haven’t heard from the ADCS.”
The pool is then the only route onward. PUS service 20 serves it directly; prvEmitAdcsHk
builds one TM[3,25] under APID 0x003 from it on the DHS’s own cadence
(§8). The five frames are never relayed.
3. Tasks
Three tasks created in main(), plus one from canInit. The plant has no task of its own — it
runs inside the control task, which makes the tick deterministic with no lock.
| Task | Priority | Cadence | Role |
|---|---|---|---|
ctrl (:828) | PRIO_CTRL (4) | control_period_ms, 100 ms | dynStep() → pool validity → prvAppStep(), fixed order |
disp (:996) | PRIO_APP (3) | blocks on CAN queue | commands, time sync, OBDB blocks, CPU-board switch |
hk (:968) | PRIO_TLM (2) | hk_period_ms, 1000 ms | five-frame echo to the DHS |
canrx | PRIO_CAN_RX (5) | driver | created by canInit |
~512 KiB of the 4 MiB heap, ~128 KiB per task.
ctrl is the sole writer of xApp and the interface image. Anything dispatch needs changed
there is raised as a flag and serviced by ctrl — the pending CPU-board reboot (xRebootTo) and
the first-configuration rate seed (ucCfgSeedPending) both work this way, and so does the
pointing target for the same reason.
4. The interface (ICD)
One contiguous object, xAocsIcd, in three segments:
| Segment | Written by | Read by | Access |
|---|---|---|---|
ro | engine | application | DP_ACC_RO |
truth | engine | nobody on board | DP_ACC_RO |
rw | application | engine | DP_ACC_RW |
Segment order is load-bearing: ro/truth are adjacent and precede rw, so “everything the
application must not write” is one span, [0, AOCS_GUARDED_LEN). Because the pool binds rather
than copies, that object is simultaneously the addressable typed parameter pool and a contiguous
span of RAM — what makes the guard in §7 possible.
Access rights have an honest limit. DP_ACC_RO is enforced by dpCheckSet against the
ground only — it can’t be enforced between the two modules, one process, a C pointer ignoring
the access mask. Discipline between them is checked by checksum after the fact, not the type
system before it.
truth is the real attitude. The application must never read it — cheating at the exercise,
not a bug the guard can catch, since reading leaves no trace. It exists so the ground can
difference q_true against q_est and see what the estimator is worth.
Frames and units
| ECI | Earth-Centred Inertial, true equator and equinox of date |
| BODY | +X, +Y, +Z pierce the six sun-cell faces, in cell order |
q | scalar first (w x y z), unit, ECI→BODY: v_body = q ⊗ v_eci ⊗ q⁻¹ |
| rates | deg/s — every existing config key and telemetry path already is; models convert internally |
| B | microtesla (LEO is 20–50 µT, so a wrong reading is visible by eye) |
| dipole | A·m², body |
| wheels | rpm, signed, about the corresponding body axis |
| r, v | km and km/s, ECI |
The quaternion convention is the single most likely thing to be wrong in work of this kind, stated
once here; dynQuatRotate and the node’s own kinematics both implement the matching
direction-cosine matrix.
Identifiers
65 measurement identifiers of the 127 in the ADCS mirror band, leaving room to grow. Within a
subsystem’s 0xNN__ space, low octet below 0x80 is a measurement, at or above it is on-board
database content (DP_PID_IS_CFG).
| Range | Count | Contents |
|---|---|---|
0x0301–0x0304 | 4 | ADCS_RATE_X/Y/Z, ADCS_LOCKED — predate the split, bound to rw.rate_est / rw.locked |
0x0305–0x032B | 39 | RO segment |
0x032C–0x0332 | 7 | TRUTH segment |
0x0333–0x033F | 13 | RW remainder |
0x0340 | 1 | ADCS_PLAT_MODE — not part of the interface image |
0x0341 | 1 | ADCS_HB — not part of the interface image (§8) |
0x0380–0x038F | 16 | on-board database rows |
Reusing the first four rather than inventing parallel identifiers keeps the existing ground tooling and XTCE database unchanged.
Two identifiers run the other way. Every other row is registered in the ADCS’s local pool and a
subset is echoed to the DHS’s. ADCS_PLAT_MODE and ADCS_HB are registered only in the DHS’s
— its record of what the ADCS said about platform mode, and of the liveness counter it watchdogs.
Neither belongs to the interface image, which is why the band holds 65 identifiers while the image
(§2b) holds 63.
5. Attitude determination
prvDetermine (:340), in order of preference:
- A star tracker, if either is valid — the only source good enough for fine pointing.
- TRIAD (
prvTriad, :298) on the sun and magnetic vectors, against the node’s own reference models. - Gyro propagation of the previous estimate, which drifts.
AOCS_FDIR_NO_ATTITUDE tracks whether an independent fix was obtained (source 1 or 2). Source
3 is dead reckoning, not a fix — its error grows unbounded.
The spacecraft spends ~39% of each orbit in eclipse (the shipped element set’s right ascension is
chosen for that — dyn_engine.md §6), where the sun sensors
report invalid and source 2 is unavailable. This costs nothing: source 1 is preferred anyway and a
star tracker’s sun-exclusion cone doesn’t apply in shadow, so eclipse is actually the easiest
time to determine attitude. At [adcs_dyn] orbit_time_scale = 1 an eclipse lasts ~36 minutes
wall-clock, so a short run may see nothing else.
The on-board reference models, and why they are duplicated
Vector-matching determination needs each direction known in both frames: measured in body by a
sensor, predicted in inertial space by a model. prvRefSunEci
(:231, almanac series) and prvRefFieldEci
(:247, degree-1 dipole with GMST from
prvGmst, :216) are those models, and they are flight software —
a real ADCS carries a sun ephemeris and geomagnetic model for the same reason.
sim/dyn_env.c contains arithmetic that looks the same. Sharing it would destroy the point of
the exercise: the plant’s model is the truth, this one is the spacecraft’s belief, and the
difference between them is exactly the estimation error TRIAD measures. Sharing the code makes
TRIAD return its own input while falsely clearing AOCS_FDIR_NO_ATTITUDE. Two independent copies
is correct design, not duplication to be tidied away; the EPS’s fuel gauge carries the same rule
(eps.md §4).
The clock is an operational precondition
TRIAD is gated on obtIsSynced(). An ephemeris on a free-running clock places the Sun where it was
at the CCSDS epoch, so TRIAD would confidently return an attitude wrong by however far the Earth
has moved since 1958 — worse than the gyro, without the gyro’s honesty about drifting. Fine
determination requires egse_tc DHS TIME NOW first; until it lands, and whenever both trackers
are blind, the node says so through AOCS_FDIR_NO_ATTITUDE.
6. Control
Modes
prvSelectMode (:609), with hysteresis — enter detumble above
detumble_entry_dps, leave below detumble_exit_dps. Same shape as the EPS’s
soc_low_pct/soc_recover_pct: without the gap the controller chatters between laws when the rate
sits on the threshold.
SAFE still detumbles — a safe mode that let the spacecraft keep tumbling would be safe in name
only (rates cost power, break the link, blind the trackers). The node also boots in SAFE, matching
prvAdcsReboot and [dhs] default_mode.
Entering POINT captures the current attitude as the target (prvEnterPointing,
:593). Detumble ends at an arbitrary orientation, so pointing against
whatever target was left from boot would command an average ninety-degree slew; the PID obliges,
the wheels spin past the detumble entry threshold, and the two laws fight indefinitely. Acquiring
the current attitude zeroes the initial error, so pointing begins by holding, and a slew becomes
something the ground asks for deliberately.
B-dot detumble
prvDetumble (:435):
m = −k dB/dt clamped to mtq_max_dipoleThe field measured in body changes because the body rotates, so opposing that change opposes the rotation — no attitude knowledge needed, which is why it’s what runs right after separation. The sign is the whole algorithm — positive spins the spacecraft up, smoothly, with no error indication.
The derivative is low-passed, and must be. Differencing raw samples one control period apart is arithmetically correct and practically useless. The washout form buys an order of magnitude of signal-to-noise for a few degrees of phase lag a damping law doesn’t care about:
filt += a (B − filt), a = dt/(τ+dt)
Bdot = a (B − filt)/dtAt 10 Hz, 0.15 µT sensor, 5 deg/s tumble in a 30 µT field:
Noise on dB/dt | Signal | SNR | |
|---|---|---|---|
| Raw difference | 2.1 µT/s | 2.6 µT/s | ≈ 1 |
| Washout, τ = 1 s | 0.14 µT/s | 2.4 µT/s (0.91 of amplitude) | ≈ 18 |
PID pointing
prvPoint (:529), on the vector part of
q_err = q_est⁻¹ ⊗ q_target (prvAttitudeError, :484), which for
small angles is half the rotation vector. Three details that aren’t cosmetic:
- The scalar part is forced positive —
qand−qare the same attitude, so without this the controller takes the long way round through 340° rather than the short way through 20°. - The derivative is on the measured rate, not the error — differentiating the error would put a step into the command every time the ground changes the target.
- Every term is negated, and the command is an offset from the tachometer. A reaction wheel
accelerates the body opposite to its own acceleration, so a PID computing a desired body torque
must command the wheel the other way. The textbook
kp*err − kd*ratewritten straight into a wheel-speed command inverts both terms, and the derivative then accelerates the rotation it should damp — smoothly, with the error signal looking entirely reasonable. An offset command also makes the commanded quantity a torque, which is what the control law computes.
Anti-windup freezes the integrator whenever a wheel is saturated — the reason the plant models saturation at all.
B-dot keeps running in POINT, damping residual body rate. It does not dump wheel momentum —
that needs m = k (h_wheel × B), whereas B-dot acts on dB/dt and sees almost nothing once the
body is held still. Momentum dumping is a real gap (§11).
Gains
Sized for a second-order response at ωₙ = 0.1 rad/s (~63 s period), ζ = 0.7, against the
[adcs_dyn] inertia and wheel constants — a wheel delivers 1.885e-5 N·m per rpm of command offset,
so kp = 2 I ωₙ²/that and kd = 2 ζ ωₙ I (π/180)/that. Retune if the inertia tensor changes.
7. The interface guard
Mechanism, and why the check is placed at the end of the application step:
dyn_engine.md §9. From this side, the guarded span is
[0, AOCS_GUARDED_LEN) — ro plus truth (§4). prvAppStep
(:655) calls dynGuardCheck() as its last act; a mismatch sets
AOCS_FDIR_ICD_VIOLATION, logs an error, and rides bit 4 of the ADCS_RPT_CTRL FDIR octet to the
DHS and then bit 4 of the ground packet’s FDIR octet — ICD_VIOL in the decoded line
(§8).
prvControlTask re-stamps the guard after prvIcdValidity, which alone among the control task’s
steps writes through the pool.
8. Telemetry
On the bus: five frames
Five tagged reports, ADCS_RPT_* in shared_can.h, built by
prvSendHk (:903) at hk_period_ms.
| Tag | Contents |
|---|---|
0xD1 RATE | body rates X,Y,Z, int16 centi-deg/s |
0xD2 ATT | q_est vector part, int16 ×10000 (scalar recovered from the unit norm) |
0xD3 CTRL | platform mode, control mode, lock, FDIR flags, pointing error, liveness counter (octet 7) |
0xD4 ACT | wheel tachometers, int16 rpm |
0xD5 SENS | magnetometer, int16 deci-µT, plus validity bits |
Every ADCS frame is tagged, unlike the EPS’s state-of-charge frame: the rate report begins with
the high octet of a signed rate, spanning the whole octet, so any tag chosen for a second report
could collide with an ordinary rate value. (The EPS’s untagged frame escapes this only because it
starts with an unsigned state of charge that can’t reach 0xE1.) Tags sort frames on the bus only;
none reaches the ground.
The liveness counter
xApp.hb is incremented in prvControlTask, at the end of every control cycle
(:828) — not in the housekeeping task. That’s the whole point: the
DHS’s watchdog trips on the counter standing still, so a counter advanced by the task that sends
the frame would keep advancing through a wedged control loop and prove nothing. It wraps at 256,
harmless since the DHS compares successive values, not a total.
Two consequences worth knowing before retuning anything:
- The frame carries it only at
dlc == 8, and the DHS reads it only atdlc >= 8. The five reports are built in one reused buffer, so an ADCS predating the counter would deliver the previous frame’s sensor-validity octet there — a value that never changes cycle to cycle, exactly the trip condition. ADCS_HK_PERaliases the watchdog. The DHS sees a new value only as fast as this node emits frames, so raisinghk_period_mspastfdir_period_ms × FDIR_ADCS_TICKSmakes a healthy ADCS look dead. Both are ground-writable throughTC[20,3]; seedhs.md§8.
To the ground: one packet
Five frames on the bus, one packet to the ground — the DHS ingests all five into its pool and
builds one 33-octet TM(3,25) under APID 3 from it, on its own cadence; see
dhs.md §6 for why.
| Offset | Field | Encoding |
|---|---|---|
| 0..5 | Body rate X, Y, Z | ×100 deg/s, signed |
| 6..13 | q_est w, x, y, z | ×10000, signed |
| 14..19 | Magnetometer X, Y, Z | ×10 µT, signed |
| 20..25 | Wheel tachometer 1, 2, 3 | rpm, signed |
| 26..27 | Pointing error | ×100 deg |
| 28 | Platform mode, as the ADCS holds it | SAFE/NOMINAL/SCIENCE |
| 29 | Control mode | IDLE/DETUMBLE/POINT |
| 30 | Pointing lock | 0 or 1 |
| 31 | FDIR flags | bit0 NO_ATTITUDE, bit1 WHEEL_SAT, bit2 RATE_HIGH, bit3 ST_STALE, bit4 ICD_VIOLATION |
| 32 | Sensor validity | bit0 sun, bit1 mag, bit2 st1, bit3 st2, bit4 eclipse |
Two things about the layout:
- All four quaternion components are on the wire, though the
ATTframe carries only the vector part (seven octets was what a CAN payload had left; the scalar follows from the unit norm). The DHS holds all four, so reconstruction — and its clamp against rounding pushing the sum of squares past one — stays on the ground side. - Octet 28 is not
SYS_MODE. It’s the platform mode the ADCS believes it is in, carried asADCS_PLAT_MODE(0x0340);SYS_MODEis the DHS’s own belief. Two identifiers make a disagreement — a mode command that never landed — visible at all.
A one-shot
CMD_REQ_HKproduces no immediate downlink. The ADCS answers with all five frames, but the DHS ingests them into its pool and emits on its own cadence.
Decoded by tools/egse_tm.c and defined in
mdb/minscs.xtce as one container restricted on APID 3 and packet length —
no tag comparison, since there is only one APID-3 layout to tell apart.
9. Redundancy
Two integrated CPU boards, A active, B cold. The model is owned by the EPS and documented in
full there — eps.md §10 covers the high-priority command path, the
per-cycle re-announcement, and what each of the three pairs costs. What this node does with it:
- The EPS re-sends the active board id every cycle as
CMD_ACS_SWITCH, and the ADCS cold-reboots only when it differs from the board it runs. - The dispatch task raises
xRebootTo; the control task performs the reboot (prvAdcsReboot, :808, called fromprvControlTask, :828), so attitude state stays single-writer. prvAdcsRebootwipes the application’s state and callsdynReboot, because cutting board power takes the sensor harness with it: the whole interface image is zeroed, every model reset,obtReset, up in SAFE.- Control gains are not restored locally. The DHS notices the switch,
prvObdbInvalidates its record and re-pushes the database; a local restore would race that push (§2c).
An ADCS-CPU switch does not reboot the DHS; it only tracks xPlat.adcs_cpu and raises
EVT_ACS_SWITCH.
10. Configuration
Two sections: [adcs] is what the flight software decides, [adcs_dyn] is what the
spacecraft is.
[adcs] — mission parameters, distributed by the DHS
Not read by this node from the file (prvLoadConfig, :1072, takes
only log_level). Programmed into the on-board database and pushed over the bus; registered by
prvInitDataPool (:1233) and committed through prvOnCfgCommit
(:1119). Sixteen rows, the file’s values matching the ROM defaults in
DP_CFG_CATALOG exactly:
| Key | Default | Notes |
|---|---|---|
control_period_ms | 100 | control-law rate |
hk_period_ms | 1000 | rate of the echo to the central pool |
lock_tolerance | 0.05 | |rate| below this counts as locked, deg/s |
initial_rate_x/_y/_z | 4.00 / −2.50 / 1.80 | body rates at boot — a genuine tumble |
bdot_gain | 0.50 | A·m² per µT/s |
pid_kp / pid_ki / pid_kd | 48 / 1.0 / 5.8 | see §6 |
pid_integral_max | 20 | integrator clamp |
mtq_max_dipole | 3.00 | commanded limit, A·m² |
wheel_max_rpm | 6000 | commanded limit |
detumble_entry_dps | 1.50 | mode hysteresis |
detumble_exit_dps | 0.30 | mode hysteresis |
st_valid_timeout_ms | 5000 | both trackers blind this long raises FDIR |
control_gain is gone: it was the proportional gain of a rate-damping law the B-dot/PID split
replaced, and a database row nothing reads is a lie to the operator.
[adcs_dyn] — the simulated hardware and universe
Read by this node in main() and deliberately not database content — a flight computer has no
business holding the truth model. Full reference in
dyn_engine.md §10. Note in particular that
orbit_time_scale must stay at 1 whenever the control law is under test.
11. Known gaps
- No EKF. Determination selects a source rather than blending sources.
- TRIAD needs a disciplined clock (§5) — fine determination is unavailable until the ground sets the time.
- No momentum-dumping law. B-dot damps body rate, not wheel momentum; a long pointing campaign would saturate the wheels with nothing to recover them.
- No sun-safe pointing law. SAFE detumbles and then idles; it doesn’t acquire the Sun.
- No slew rate limiting. A large ground-commanded target change runs as fast as the wheels allow, which can drive the rate past the detumble threshold.
- Gyro and magnetometer never fail in the plant, so the application’s dropout paths — the
!mag_validreturn inprvDetumble, the B-dot filter invalidation — are written and correct but unexercised. - The liveness counter proves the control task runs, not that it is right. A loop that steps
the plant and computes nonsense advances
xApp.hbexactly like a healthy one, so the DHS’s watchdog (§8) detects a wedged ADCS and nothing subtler — no on-board check that the control law is converging. - Everything in
dyn_engine.md§12 about the fidelity of the plant itself.
12. Verification
The automated suite (testing.md) reaches the plant but not the control law: it
pins the quaternion helpers, the noise generator’s reproducibility, and the interface guard, and
stops there. Whether B-dot actually detumbles is a question about a trajectory, not a function
call, so verifying the loop means running the platform and watching egse_tm.
Three commands carry most of that:
build/bin/egse_tc DHS PARAM SET ADCS_BDOT_K F32 0.0 # the loop is closed: rate decay stops dead
build/bin/egse_tc DHS PARAM SET ADCS_DTMB_OUT F32 4.5 # relax hysteresis to reach POINT in a demo
build/bin/egse_tc DHS TIME NOW # clears NO_ATT: TRIAD now has an ephemerisThe first exercises the actuator path, interface feedback, TC[20,3] and the database binding at
once. Two things to expect while watching:
- If the rate magnitude grows during DETUMBLE, the B-dot sign is wrong — the single most likely defect in work of this shape.
- Decay is uneven and slow. B-dot damps only the component of ω perpendicular to B;
rotation about the field line is invisible in
dB/dtand bleeds off only as the orbit rotates B. Real magnetic detumbling takes orbits, not minutes, which is why a demonstration relaxesADCS_DTMB_OUT/_INrather than waiting.