Skip to content

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:

ModuleLives inFlight software?
Control Loop Applicationnodes/adcs_node.cYes. Determination, detumble, pointing, mode logic, FDIR.
External Dynamics Enginesim/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 delta

2b. 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 poolDHS central pool
Lives inadcs_nodedhs_node
Holdsthe 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
Turnover10 Hz, board-local1 Hz, arrives over CAN
Ground-reachableNo — only the DHS serves PUS service 20Yes

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_BEGINN × CMD_CFG_ITEMCMD_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 why prvAdcsReboot deliberately does not restore gains locally: doing so would race the push.
  • Only the first commit seeds live stateucCfgSeedPending exists 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.

TaskPriorityCadenceRole
ctrl (:828)PRIO_CTRL (4)control_period_ms, 100 msdynStep() → pool validity → prvAppStep(), fixed order
disp (:996)PRIO_APP (3)blocks on CAN queuecommands, time sync, OBDB blocks, CPU-board switch
hk (:968)PRIO_TLM (2)hk_period_ms, 1000 msfive-frame echo to the DHS
canrxPRIO_CAN_RX (5)drivercreated 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:

SegmentWritten byRead byAccess
roengineapplicationDP_ACC_RO
truthenginenobody on boardDP_ACC_RO
rwapplicationengineDP_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

ECIEarth-Centred Inertial, true equator and equinox of date
BODY+X, +Y, +Z pierce the six sun-cell faces, in cell order
qscalar first (w x y z), unit, ECI→BODY: v_body = q ⊗ v_eci ⊗ q⁻¹
ratesdeg/s — every existing config key and telemetry path already is; models convert internally
Bmicrotesla (LEO is 20–50 µT, so a wrong reading is visible by eye)
dipoleA·m², body
wheelsrpm, signed, about the corresponding body axis
r, vkm 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).

RangeCountContents
0x03010x03044ADCS_RATE_X/Y/Z, ADCS_LOCKED — predate the split, bound to rw.rate_est / rw.locked
0x03050x032B39RO segment
0x032C0x03327TRUTH segment
0x03330x033F13RW remainder
0x03401ADCS_PLAT_MODEnot part of the interface image
0x03411ADCS_HBnot part of the interface image (§8)
0x03800x038F16on-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:

  1. A star tracker, if either is valid — the only source good enough for fine pointing.
  2. TRIAD (prvTriad, :298) on the sun and magnetic vectors, against the node’s own reference models.
  3. 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_dipole

The 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)/dt

At 10 Hz, 0.15 µT sensor, 5 deg/s tumble in a 30 µT field:

Noise on dB/dtSignalSNR
Raw difference2.1 µT/s2.6 µT/s≈ 1
Washout, τ = 1 s0.14 µT/s2.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 positiveq and −q are 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*rate written 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.

TagContents
0xD1 RATEbody rates X,Y,Z, int16 centi-deg/s
0xD2 ATTq_est vector part, int16 ×10000 (scalar recovered from the unit norm)
0xD3 CTRLplatform mode, control mode, lock, FDIR flags, pointing error, liveness counter (octet 7)
0xD4 ACTwheel tachometers, int16 rpm
0xD5 SENSmagnetometer, 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 at dlc >= 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_PER aliases the watchdog. The DHS sees a new value only as fast as this node emits frames, so raising hk_period_ms past fdir_period_ms × FDIR_ADCS_TICKS makes a healthy ADCS look dead. Both are ground-writable through TC[20,3]; see dhs.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.

OffsetFieldEncoding
0..5Body rate X, Y, Z×100 deg/s, signed
6..13q_est w, x, y, z×10000, signed
14..19Magnetometer X, Y, Z×10 µT, signed
20..25Wheel tachometer 1, 2, 3rpm, signed
26..27Pointing error×100 deg
28Platform mode, as the ADCS holds itSAFE/NOMINAL/SCIENCE
29Control modeIDLE/DETUMBLE/POINT
30Pointing lock0 or 1
31FDIR flagsbit0 NO_ATTITUDE, bit1 WHEEL_SAT, bit2 RATE_HIGH, bit3 ST_STALE, bit4 ICD_VIOLATION
32Sensor validitybit0 sun, bit1 mag, bit2 st1, bit3 st2, bit4 eclipse

Two things about the layout:

  • All four quaternion components are on the wire, though the ATT frame 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 as ADCS_PLAT_MODE (0x0340); SYS_MODE is the DHS’s own belief. Two identifiers make a disagreement — a mode command that never landed — visible at all.

A one-shot CMD_REQ_HK produces 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 thereeps.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 from prvControlTask, :828), so attitude state stays single-writer.
  • prvAdcsReboot wipes the application’s state and calls dynReboot, 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:

KeyDefaultNotes
control_period_ms100control-law rate
hk_period_ms1000rate of the echo to the central pool
lock_tolerance0.05|rate| below this counts as locked, deg/s
initial_rate_x/_y/_z4.00 / −2.50 / 1.80body rates at boot — a genuine tumble
bdot_gain0.50A·m² per µT/s
pid_kp / pid_ki / pid_kd48 / 1.0 / 5.8see §6
pid_integral_max20integrator clamp
mtq_max_dipole3.00commanded limit, A·m²
wheel_max_rpm6000commanded limit
detumble_entry_dps1.50mode hysteresis
detumble_exit_dps0.30mode hysteresis
st_valid_timeout_ms5000both 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_valid return in prvDetumble, 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.hb exactly 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 ephemeris

The 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/dt and bleeds off only as the orbit rotates B. Real magnetic detumbling takes orbits, not minutes, which is why a demonstration relaxes ADCS_DTMB_OUT/_IN rather than waiting.
Last updated on