EPS — the Electrical and Power Subsystem
Reference documentation for nodes/eps_node.c, the power, thermal and redundancy subsystem of
the simulator. See index.md for the platform overview and
dhs.md for the on-board DHS this node reports to.
CAN address 0x4, PUS APID 0x004. One binary, build/bin/eps_node.
The file ships [eps] log_level = info, so the status line quoted throughout this document
appears without further action; debug adds the gauge line (§4).
1. Role and boundaries
The EPS is two decoupled modules sharing one process, separated by an ICD rather than by an address space — the same arrangement the ADCS has, for the same reasons.
| Module | Lives in | Flight software? |
|---|---|---|
| EPS Application | nodes/eps_node.c | Yes. Rail switching, the fuel gauge, the low-power alert, housekeeping, redundancy. |
| EPS Power Plant | sim/eps_plant.c | No. Solar array, battery, thermal behaviour, transducers. See dyn_engine.md. |
They communicate through exactly one thing, include/eps_icd.h, and
nothing else. The application holds no model of the battery and must never acquire one.
sim/eps_plant.c is linked into the EPS binary because the interface it drives is the EPS board’s
own harness — array and load shunts, bus ADC, the two thermistors are analog/I²C devices inside
that box, no raw shunt reading ever crosses CAN, and a harness isn’t reached across a process
boundary.
The orbit, the Sun and the Earth’s shadow come from sim/dyn_world.c,
shared with the ADCS’s dynamics engine — one spacecraft, one set of physics. See
§4 for why the two nodes still disagree about where in the orbit it is.
What the application owns:
| Responsibility | Summary |
|---|---|
| Load | Three rails following platform mode, the instrument one switched per payload instrument |
| Gauge | A coulomb-counting estimate of state of charge — there is no SoC sensor |
| Alerting | Telling the DHS, on its own initiative, when the charge runs low |
| Redundancy | Which unit of each cold-redundant pair is powered — for all three pairs |
Redundancy lives here because the EPS is the subsystem that switches power — which is why the switch telecommand is high-priority and reaches this node without the DHS in the loop (§10). The thermal model sits in this plant for the same kind of reason: eclipse and electrical load drive it, and the model already holds both.
Not here: packets, telemetry sequence numbers, the ground link, a clock of its own. The node emits raw CAN frames and disciplines to the DHS’s broadcast like every other.
2. Boot sequence
logInit("EPS")
└─ prvLoadConfig :966 board straps + the whole [eps_dyn] plant description
└─ prvInitDataPool :1150
├─ dpInit
├─ dpRegisterCfg × 19 publish this node's mission parameters
├─ prvBindIcd :1063 bind the interface image into the local pool
├─ dpApplyRomDefaults bring them to their compiled-in values
├─ dpStart
├─ obdbInit(CAN_ADDR_EPS, prvOnCfgCommit)
└─ rails at NOMINAL, gauge at soc0 from ROM
└─ epsPlantInit(&xPlantCfg) parse the element set; report beta and eclipse duty
└─ epsPlantSeed(initial_soc_pct) charge the cell, equalise the structure
└─ canInit(CAN_ADDR_EPS) creates the "canrx" driver task
└─ xTaskCreate × 3
└─ vTaskStartSchedulerThe node reads config/minscs.conf for four board straps — log_level, active_obc,
active_adcs_cpu, active_ttc_cpu — and the whole of [eps_dyn], the simulated hardware and
universe, deliberately not database content (§12). Everything else
in [eps] is read by the DHS, programmed into the on-board database and distributed back over
the bus (§11).
epsPlantInit logs the solar beta angle and the eclipse cutoff the element set implies:
[EPS] plant: beta=-5.7deg (eclipse cutoff 69.8deg) -- eclipse expectedThat line isn’t decoration. An element set above the cutoff produces no eclipse from correct
physics, and the telemetry then looks like a frozen orbit model — why the shipped element set’s
right ascension isn’t the published one (§12, and
dyn_engine.md §6 for the full account). Above the cutoff the
line becomes a WARN naming the problem outright.
Between dpApplyRomDefaults and the arrival of its database block the node runs on compiled-in
ROM defaults, appending (rom) to its status line. It is not held inert — a box that refused
to work until configured would have no way back if the DHS never came up.
3. Tasks
Three created in main(); canInit() creates a fourth. Each costs ~128 KiB of the 4 MiB FreeRTOS
heap, so the EPS’s task budget is ≈ 512 KiB.
| Task | Priority | Period | Role |
|---|---|---|---|
canrx | PRIO_CAN_RX (5) | — | Bounded recvfrom into the RX queue |
model | PRIO_CTRL (4) | step_ms (500) | Requests, then plant, then application (:333) |
disp | PRIO_APP (3) | blocking | CAN receive demultiplex (:673) |
hk | PRIO_TLM (2) | hk_period_ms (1000) | Eight HK frames + three announcements (:593) |
The model task’s step is three things in a fixed order, and the order is the synchronisation design — one thread, so no lock between plant and application and no ambiguity about which rail currents the battery discharged against:
prvServiceRequests :337 act on what dispatch asked for writes RW
epsPlantStep array, battery, thermal, sensors writes RO + TRUTH, stamps the guard
prvAppStep :415 the fuel gauge and the alert writes RW, ends by checking the guardThe single-writer rule
The RW segment has exactly one writer, the model task
(prvServiceRequests, prvAppStep). A
telecommand changing the platform mode doesn’t latch the rails; it sets ucModeReq, and the model
task acts on it at the top of its next step. A database commit doesn’t reseed the battery; it sets
a pending flag — same shape as the ADCS’s pending-reboot and pending-seed flags, same reason, and
what makes the interface guard meaningful: the stamp/check pair has to bracket exactly one task’s
execution or it asserts nothing.
The interface guard
Mechanism and the reason the check sits at the end of the application step:
dyn_engine.md §9. On this side, the guarded span is
[0, offsetof(eps_icd_t, rw)) — the plant stamps it, prvAppStep
(:316) checks it as its last act, and the two points bracket exactly
the application’s own execution. A mismatch sets EPS_FDIR_ICD_VIOLATION, logs once, and rides
bit 2 of the illumination report’s flag octet to the DHS and then bit 2 of the ground packet’s
flags (§8).
The EPS has no mutexes. The model task owns the whole interface image; dispatch owns xRedun
and the request flags; the housekeeping task only reads, unsynchronised. The guard is what makes
that safe to assert rather than merely hope.
4. The power model
epsPlantStep in sim/eps_plant.c runs every step_ms. It is the plant,
not the flight software — none of what follows is visible to eps_node.c except through the
interface image.
Orbit and eclipse
The plant calls dynWorldStep — the same simplified-SGP4 propagator, almanac Sun and cylindrical
shadow test the ADCS flies (dyn_engine.md) — and reads the answer.
[eps_dyn] orbit_time_scale = 60 compresses the 92.9-minute orbit into a 92.9-second one,
so a full cycle — about 57 s sunlit, 36 s eclipse — is visible in a demonstration run. The EPS
can make that trade where the ADCS cannot: compressing the orbit ruins B-dot, which can’t
distinguish a field sweeping past from a spacecraft rotating, but nothing in a power model
integrates against the environment’s rate of change.
ADCS_ECLIPSE(0x032B) andEPS_ECLIPSE(0x0403) are not expected to agree. The two plants are separate processes, each counting elapsed time from its own boot instant at a different time scale (1 against 60), so they agree about the orbit’s shape and never about its phase. Driving elapsed time from on-board time would cure it and is a deliberate non-goal: it would couple two plants meant to be independent instances.
Illumination and the array
The EPS has no attitude sensor and no access to the ADCS’s attitude, so the plant models only what position and Sun direction give it — two panels, neither needing attitude:
array_a = wing_peak_a · cos β + body_peak_a · max(0, r̂ · ŝ) (0 in eclipse)The first term is a single-axis sun-tracked wing whose drive turns about the orbit normal, so
it can null the in-plane angle but not the out-of-plane one, following cos β. The second is a
zenith-facing body panel on a platform assumed nadir-pointing.
With the shipped sizings (1.76 A and 1.44 A) the array sweeps 1.76 A → 3.20 A → 1.76 A, then
0.00 A for the eclipse. Because it’s derived rather than fitted, the endpoints degrade as
cos β through the beta season, and the platform turns net-negative above |β| ≈ 45° — the
eclipse-free high-beta season costs more in cosine loss than it saves in shadow, the one way to
reach an autonomous low-power SAFE from orbital geometry rather than a retune.
If the platform tumbles this model is wrong and can’t know it — true of a real EPS too, which can’t tell “shadowed by a bad attitude” from “degraded” without a cross-check it doesn’t have.
The battery
load_a = rail_bus_a + rail_instr_a + rail_radio_a ← the closed loop: the application's output
net_w = (array_a − load_a) × bus_v
stored_wh += (net_w charging ? × batt_charge_eff : net_w) × dt_s / 3600 clamped to [0, capacity]
bus_v = bus_v_empty + soc_true/100 × bus_v_spanCharge acceptance applies to a net charge only — a cell hands over what it holds, so applying the efficiency in both directions would quietly make it better at discharging than at charging.
Summing the rails here rather than reading a pre-summed total is deliberate: the plant can’t be shown a load that doesn’t match the rails the application actually switched.
The fuel gauge — and why the estimate is not the truth
There is no state-of-charge sensor, here or on any spacecraft ever flown. The plant keeps the
real value in the TRUTH segment where nothing on board may read it; the flight software runs a
gauge, in prvGaugeStep (:166):
net_w = (measured array current − measured load current) × measured bus voltage
est += net_w × dt/3600 / EPS_GAUGE_WH × 100 coulomb count
ocv_soc = (measured bus voltage − EPS_OCV_V0) / EPS_OCV_SPAN × 100
est += (ocv_soc − est) × dt / EPS_GAUGE_TAU slow re-anchorThe difference between EPS_SOC_PCT (the estimate) and EPS_SOC_TRUE (the plant’s truth) is this
subsystem’s answer to the ADCS’s q_true against q_est — a real difference, because the gauge’s
beliefs are deliberately not the plant’s facts. The shipped cell is an aged one:
| Quantity | Gauge believes ([eps], database) | Cell actually is ([eps_dyn], plant) |
|---|---|---|
| Capacity | 45.0 Wh nameplate | 43.0 Wh, faded |
| OCV curve | 26.0 + 3.4 V | 26.2 + 3.1 V, shifted and flattened |
| Charge acceptance | (invisible to a coulomb counter) | 0.96 |
Setting the
[eps_dyn]values equal to thegauge_*ones makes the whole split decorative. The voltage anchor then recovers the true state of charge exactly,EPS_SOC_PCTtracksEPS_SOC_TRUEto within sensor noise, and the estimator measures nothing — the same failure as an ADCS sharing its reference models with the plant that measures it.
On the shipped configuration the error runs about −0.05% at rest and −0.4% under load: a
coulomb counter lags while current flows and the voltage anchor pulls it back when the load drops.
The sign matters operationally — near empty the curve mismatch makes the gauge over-report, the
direction that safes the spacecraft late. [eps] log_level = debug appends the one line
deliberately unreachable from the ground — what the gauge is actually worth:
[EPS] gauge: est=75.22% true=75.64% err=-0.42% (stored 32.52Wh)The default configuration is net-positive
Integrated over a full orbit the shipped parameters generate more than they consume — about +0.6%/orbit in SAFE — so the spacecraft won’t safe itself on power in the shipped beta season unless you make it. Either threshold or load will do it, through service 20, taking effect on the next model step:
build/bin/egse_tc DHS PARAM SET EPS_SOC_LOW F32 99 # move the threshold up to meet the battery
build/bin/egse_tc DHS PARAM SET EPS_PL_CAM_A F32 4.0 # or make the load exceed the arrayThe gauge never reads a clean 100%: the aged cell’s flattened OCV curve settles the anchor a few percent low at full charge, what a real gauge with a stale curve does.
5. The thermal model
Two first-order lags toward the same environment sink, plus self-heating on the battery:
sink = in_eclipse ? therm_sink_ecl_c : therm_sink_sun_c
self = load_current × bus_voltage × therm_self_c_per_w
batt_temp_c += ((sink + self) − batt_temp_c) × dt_s / therm_batt_tau_s
rad_temp_c += (sink − rad_temp_c) × dt_s / therm_rad_tau_sThe battery is a large thermal mass (τ = 45 s) and self-heats with the electrical load; the radiator follows the environment quickly (τ = 8 s). An eclipse crossing opens a visible gradient between the two — on the shipped configuration in SAFE, the radiator swings −17.5°C to +34.2°C while the battery lags at +16.6°C to +40.2°C.
The battery temperature reaches the DHS’s data pool as EPS_TEMP_BATT, where the decision loop
checks it against [dhs] therm_limit_hi_c (45°C) and raises a latched EVT_THERM_LIMIT — the EPS
models temperature, the DHS judges it. The margin is real but not large — the battery peaks
near 40°C against a 45°C limit — and SCIENCE mode exceeds it, at roughly 54°C with a 2.4 A
load.
therm_self_c_per_w (0.43) is a lumped stand-in for how strongly dissipation warms the cell,
fitted against the duty cycle: the real orbit spends 57 s of its 92.9 s sunlit against a 45 s
time constant, so the battery approaches its asymptote closely. Retuning the orbit or the time
scale without refitting this coefficient can put the SAFE-mode peak within thermistor noise of the
limit, raising a latched EVT_THERM_LIMIT on a healthy spacecraft.
6. Load rails and platform mode
Three rails, summed into load_current:
| Rail | SAFE | NOMINAL | SCIENCE | Notes |
|---|---|---|---|---|
| Platform bus | 0.22 A | 0.22 A | 0.22 A | always on — DHS, ADCS, avionics |
| Instruments | 0.50 A | 0.50 A | 1.60 A | payload; switched per instrument, see below |
| Radio | 0.20 A | 0.30 A | 0.40 A | TTC transceiver; standby in SAFE |
| Total | 0.92 A | 1.02 A | 2.22 A |
The rails are the flight software’s one actuator: the plant discharges the battery by exactly
what prvSetRails (:149) writes into the RW segment, and summing them
plant-side means the battery can’t be shown a load that doesn’t match the rails the application
switched.
prvSetRails runs on the model task only (§3). A CMD_SET_MODE telecommand sets
ucModeReq and returns; a CMD_PL_PWR sets a slot in ucPlPwrReq[]; a database commit sets
ucRelatchPending. All three are picked up by prvServiceRequests
(:251) at the top of the next step, within one model period, and the
log line naming the applied rails comes from there rather than the dispatch path.
A configuration change still has to ask for a re-latch: the rails are latched, not read live, so new rail currents would otherwise sit in the parameter table doing nothing until the platform happened to change mode.
6.1 The instrument rail is three switched channels
The payload card carries three instruments, and they aren’t interchangeable loads — an operator
shedding the imager to save the battery doesn’t thereby want to stop navigating, or lose access to
files already captured. So each has its own switch and draw, and rail_instr_a is not an
independent setpoint — it is their sum, computed by prvApplyInstrRail
(:111), the only function allowed to write it:
| Channel | PL_INSTR_* | bit | Draw | Config key |
|---|---|---|---|---|
| Camera | PL_INSTR_CAMERA | 0 | 1.10 A | pl_camera_a |
| GNSS receiver | PL_INSTR_GNSS | 1 | 0.18 A | pl_gnss_a |
| Mass memory | PL_INSTR_MEM | 2 | 0.32 A | pl_mem_a |
The load switches sit upstream of the rail’s shunt, so the plant sees a total and can’t see the breakdown — exactly what a real EPS measures, and why the ICD gained three switch flags rather than three rail currents.
Each mode names the channels it powers by default, as a bit mask over the table above:
| Mode | pl_mask_* | Channels | Instrument rail |
|---|---|---|---|
| SAFE | pl_mask_safe = 6 | GNSS + memory | 0.50 A |
| NOMINAL | pl_mask_nominal = 6 | GNSS + memory | 0.50 A |
| SCIENCE | pl_mask_science = 7 | all three | 1.60 A |
The imager is shed below SCIENCE. The GNSS and mass memory stay powered in every mode,
including SAFE — gating them on mode would leave the shipped default_mode = SAFE emitting no
payload telemetry and refusing to downlink files already on the card. The GNSS’s draw used to be
counted inside bus_current_a; giving it a channel is what makes it switchable at all, and
bus_current_a lost its share accordingly (0.40 → 0.22 A, though see
§12 — the ROM default didn’t follow).
Defaults, not locks. egse_tc EPS PLPWR <CAM|GNSS|MEM> <ON|OFF> overrides one channel:
$ build/bin/egse_tc EPS PLPWR CAM ON
[EPS] payload camera ON -> instrument rail 1.60A
[PAYLOAD] instrument power 0x6 -> 0x7 (camera on, gnss on, memory on)The override lives until the next mode change, which re-latches all three to that mode’s
default set: an override is an override of the current mode, not a standing instruction. A
durable decision belongs in pl_mask_*, database content precisely so it has somewhere to live.
A mode change re-latches all three channels; a database commit does not. A commit sets
ucRelatchPending, and prvServiceRequests answers it with prvRelatchRails
(:138) — which re-reads the rail currents and leaves every switch
where it is. prvSetRails (:149), which re-defaults the switches from
the mode’s mask, runs on a mode change alone. Without that split, a push on any unrelated parameter
would quietly undo a switch the ground had commanded. A new mask therefore takes effect the next
time the platform enters that mode, what a default means.
The switch state is telemetered as EPS_RPT_PL_PWR (§8) and announced to the
payload every housekeeping cycle as CMD_PL_PWR; see payload.md for what the card
does about it. The payload gets no vote — a load switch is on the EPS side of the harness.
7. The low-power alert
The EPS raises and clears the alert on its own initiative — an EPS doesn’t ask permission, it
tells the commander. Hysteresis (prvCheckAlerts, :227): latch on
the way down at soc_low_pct (30%), clear only well above at soc_recover_pct (45%), so a battery
hovering at the limit can’t spam the bus.
The alert acts on the gauge’s estimate, the only state of charge the flight software has. If the coulomb count has drifted, the spacecraft safes itself early or late by exactly that drift — honest behaviour for a real power system, and why gauge accuracy matters to an operator (§4).
Both edges are announced. The frame is three octets:
data[0] = CMD_LOW_POWER (0x20)
data[1] = state of charge, whole percent
data[2] = 1 asserted / 0 clearedThe third octet isn’t decoration. The DHS holds no power model of its own — only the mirror this alert fills — so without it the DHS’s low-power flag latches for the rest of the run, holding the platform in SAFE and refusing every non-SAFE mode telecommand. A frame without it reads as an assertion, so an older EPS still safes the platform and never releases it.
Two thresholds, two judgements. The EPS clears the alert at soc_recover_pct; the DHS
decides separately whether to leave SAFE, at its own [dhs] soc_resume_pct (50%):
[EPS] SoC 25.2% below 30% -- alerting DHS
[DHS] EPS reports low power (SoC 25%) → low power asserted -- safing the platform
[EPS] SoC recovered to 45.0% -- clearing the alert
[DHS] EPS clears low power (SoC 45%) → power recovered (SoC 50.2%) -- resuming NOMINALOnly an autonomous low-power SAFE auto-recovers. A commanded SAFE is held until the ground says
otherwise — see dhs.md §8.
8. Telemetry
Eight housekeeping frames per cycle, all CAN_FUNC_HK to the DHS. prvSendAllHk
(:577) is the single source for both the periodic task and the
on-demand request, so the two can’t drift apart.
Eight frames on the bus, one packet to the ground — the DHS ingests all eight into its pool
and builds one 30-octet TM(3,25) under APID 4 from it, on its own cadence; see
dhs.md §6 for why.
| Report | Tag | dlc | Layout |
|---|---|---|---|
| State of charge | (untagged) | 8 | SoC(2), bus V(2), array A(2), load A(2) — all ×100 unsigned BE |
| Power rails | EPS_RPT_POWER 0xE1 | 7 | instrument(2), radio(2), bus(2) currents ×100 |
| Instrument power | EPS_RPT_PL_PWR 0xE7 | 2 | switch mask(1): bit0 camera, bit1 GNSS, bit2 memory |
| Thermal | EPS_RPT_THERMAL 0xE5 | 5 | battery(2), radiator(2) °C ×100, signed |
| Illumination | EPS_RPT_ORBIT 0xE6 | 6 | flags(1), illumination(2) ×100 %, beta(2) ×100 °, signed |
| OBC redundancy | EPS_RPT_REDUN 0xE2 | 4 | active id(1), A powered(1), B powered(1) |
| ADCS-CPU redundancy | EPS_RPT_REDUN_ADCS 0xE3 | 4 | as above |
| TTC-CPU redundancy | EPS_RPT_REDUN_TTC 0xE4 | 4 | as above |
The illumination report’s flag octet is bit0 in eclipse, bit1 orbit fix valid, bit2 ICD
violation, bit3 fuel-gauge divergence. bit1 isn’t decoration: a one-shot CMD_REQ_HK can reach
prvSendAllHk before the model task has ever run, and without it a zeroed image would read as a
spacecraft at beta zero in full sun.
Eclipse is telemetered, not inferred from a zero array current. An array can read zero while sunlit if it’s pointed wrongly, and non-zero in shadow, so a flag the plant computed from orbit geometry is worth a wire.
The packet the ground receives
30 octets, prvEmitEpsHk in ../nodes/dhs_node.c, TM(3,25) under
APID 4 — the same fields as the eight frames above, in one sample:
| Offset | Field | Encoding |
|---|---|---|
| 0..1 | State of charge | ×100 |
| 2..3 | Bus voltage | ×100 |
| 4..5 | Array current | ×100 |
| 6..7 | Load current | ×100 |
| 8..9 | Instrument rail current | ×100 |
| 10..11 | Radio rail current | ×100 |
| 12..13 | Bus rail current | ×100 |
| 14..15 | Battery temperature | ×100, signed |
| 16..17 | Radiator temperature | ×100, signed |
| 18..19 | Illumination | fraction ×10000, so 10000 is 100 % |
| 20..21 | Beta angle | ×100, signed |
| 22 | Flags | bit0 eclipse, bit1 orbit fix valid, bit2 ICD violation, bit3 gauge divergence |
| 23 | Payload switches | bit0 camera, bit1 GNSS, bit2 memory |
| 24 | Active OBC | OBC_ID_A/_B, or 0xFF |
| 25 | OBC powered | bit0 A, bit1 B |
| 26 / 27 | Active ADCS board / powered mask | as above |
| 28 / 29 | Active TTC board / powered mask | as above |
The flag and switch octets keep the bit layouts the CAN frames use, so one description serves both
sides of the hop. The three active-board octets carry 0xFF until the DHS has heard a redundancy
report: a freshly booted computer can’t know which board is running, and asserting A would make
the first report from a spacecraft on B look like a switch that never happened.
A one-shot
CMD_REQ_HKproduces no immediate downlink. The EPS answers it with all eight frames, but the DHS ingests them into its pool and emits on its own cadence, so the request refreshes what the next scheduled packet will say.
Why the state-of-charge frame needs no tag
It fills all eight octets, leaving no room for one. It’s safe because its first octet is the
high byte of SoC in centi-percent, which for a physical 0–100% can’t exceed 10000 / 256 =
0x27. Every tag value is ≥ 0xE1, so the two can never be confused.
The DHS’s ingest chain tests every tag before it falls through to this frame, so the tag values are
what make the arrangement safe, not the length. The odd frame lengths (six octets for
illumination, two for instrument power) are vestigial: they date from when each frame was relayed
as its own packet and had to be told apart on the ground by length alone. That constraint still
binds every subsystem whose frames are relayed — see mdb.md.
A new report tag must be added to the DHS’s ingest chain in the same change, or it’s read as a
state of charge rather than ignored. The obligation and the reason are on the DHS side:
dhs.md §6.
On the ground
One line per cycle, because one packet per cycle:
EPS HK (3,25) SoC=81.6% bus=28.76V array=0.00A load=0.91A | rails instr=0.50 radio=0.20
bus=0.22 A | batt=+23.68C rad=-8.40C | ECLIPSE illum=0.00% beta=-5.63deg |
payload cam=off gnss=ON mem=ON | OBC-A(A+ B-) ACS-A(A+ B-) TTC-A(A+ B-)(wrapped here; egse_tm prints it on one line). Each redundancy group is the active board followed
by which of the pair are powered.
The node’s own status line adds what telemetry doesn’t carry:
SoC=81.5% bus=28.83V array=1.72A load=0.92A (instr 0.50[-GM] radio 0.30 bus 0.22)
batt=20.7C rad=-12.5C [sunlit] beta=-5.6 obt=24 (free) active=OBC-A (rom)[eclipse]/[sunlit] is the fastest way to confirm the orbit is advancing; beta tells you
whether an eclipse is possible at all; (free) means the clock isn’t yet disciplined; (rom)
means the on-board database hasn’t arrived. [CGM] beside the instrument rail is the switch state
at a glance — camera, GNSS, memory, a dash for each channel that’s off.
Currents are truncated to hundredths, and a sum truncates once more than a single value: 1.10 +
0.18 + 0.32 telemeters as 1.59A, not 1.60A.
At log_level = debug the status line is followed by the gauge line (§4).
9. Commands accepted
prvDispatchTask (:673) takes CAN_FUNC_PKT first (the
high-priority telecommand path, §10), then hands CAN_FUNC_CMD frames to
obdbHandleFrame before its own switch, so database frames are consumed there.
| Opcode | Action | Acks? |
|---|---|---|
CMD_PING (0x01) | — | yes, CAN_FUNC_ACK with CMD_PING |
CMD_REQ_HK (0x02) | prvSendAllHk — all eight reports | no; the reply is the housekeeping |
CMD_SET_MODE (0x03) | requests the mode through ucModeReq, guarded on dlc >= 2 | yes, echoing the mode |
CMD_SET_TIME (0x04) | obtSet, only from the DHS and only with dlc >= 7 | no; a broadcast the DHS does not track |
CMD_PL_PWR (0x2A) | requests one instrument switch through ucPlPwrReq[], guarded on dlc >= 3 and on a known instrument id | yes, echoing instrument and state |
CMD_CFG_BEGIN/_ITEM/_END | consumed by obdbHandleFrame | the OBDB layer replies |
The dlc guards are not defensive padding. prvSetRails maps any unrecognised mode to
NOMINAL, so a short CMD_SET_MODE would silently relatch the rails rather than be rejected;
data[1]/data[2] on a short CMD_PL_PWR are stale octets that would switch an arbitrary
instrument an arbitrary way. The ADCS and TTC guard their board-switch commands the same way.
CMD_PL_PWR travels in two directions: three octets ({ opcode, instrument, on }) is the
ground’s request arriving from the DHS, two octets ({ opcode, mask }) is the EPS’s announcement
to the payload. No node receives both forms, so they can’t be confused — the same
one-opcode-two-roles arrangement CMD_ACS_SWITCH uses (§10).
The ack is sent immediately while the rails change up to one model period later: the command has
been accepted, not yet applied. An acked command completes with TM[1,7] and an unacked one
would time out, which is why CMD_REQ_HK needs no ack — the DHS treats an HK dispatch as its own
completion (dhs.md §4).
10. Redundancy
This section is the platform’s full account of cold redundancy; dhs.md, ttc.md and
adcs.md §9 describe only what their own node does on a switch and link
here for the rest.
The EPS owns the redundancy state for three cold-redundant pairs, because the EPS is the
subsystem that switches power. Each pair is A active, B cold; exactly one unit is powered, and a
switch models cutting power to one and booting the other. There is still only one process per
subsystem — the second unit is EPS bookkeeping (redun_t), not a second binary.
| Pair | State in redun_t | Boot strap | Switch opcode | Ground command |
|---|---|---|---|---|
| On-board computer | active_obc, powered[2] | [eps] active_obc | CMD_OBC_SWITCH (0x22) | egse_tc EPS OBC <A|B> |
| ADCS CPU board | active_acs, acs_powered[2] | [eps] active_adcs_cpu | CMD_ACS_SWITCH (0x23) | egse_tc EPS ACS <A|B> |
| TTC CPU board | active_ttc, ttc_powered[2] | [eps] active_ttc_cpu | CMD_TTC_SWITCH (0x24) | egse_tc EPS TTC <A|B> |
These are board straps, deliberately not database content. The high-priority switch reaches the EPS without the DHS in the loop precisely so it survives a DHS that’s down; putting the state in a DHS-distributed block would undo that.
Each switch opcode plays two roles, told apart by the destination node: from the ground it’s the request, and from the EPS to the ADCS or TTC it’s the reboot signal.
The high-priority path
egse_tc EPS OBC B
└─ PUS TC, APID 4, service 8, subtype 2 (PUS_ST_FUNC_PERFORM_HP), app = { 0x22, 0x01 }
└─ EGSE relays bytes unchanged
└─ TTC: service==FUNCTION && subtype==PERFORM_HP ?
canPktSend(APID low nibble) ← straight to EPS
: canPktSend(CAN_ADDR_DHS) ← everything else
└─ EPS prvHandleCriticalPkt :812 — reassemble, decode, re-validate, switchTTC classifies and forwards; it never decodes — it routes on the packet’s own APID nibble and
validates nothing beyond “this is a TC”. The endpoint decodes, so prvHandleCriticalPkt
(:640) re-checks everything: CRC via pusDecode, type, service,
subtype, app_len >= 2. A packet failing any of it is logged and dropped.
No verification report is produced for a high-priority command. The DHS never sees the packet,
and neither TTC nor the EPS emits service 1, so the ground’s ACK_ACCEPT|ACK_COMPLETE request
goes unanswered. The only confirmation is the next EPS housekeeping packet, which carries all
three pairs at once (§8). That’s the price of bypassing the computer that owns
verification, and the right trade for a command whose purpose is to work when that computer is
dead.
The asymmetry that shapes the design
The three switches look alike from the ground and are not alike on board:
- The DHS infers its own switch. It’s a housekeeping collector, so it watches
EPS_RPT_REDUNand reboots itself when the active OBC changes under it. The EPS need only report. - The ADCS and TTC must be told. Neither collects housekeeping, so neither can see the
redundancy report. The EPS therefore also sends
CMD_ACS_SWITCH/CMD_TTC_SWITCHto that node every housekeeping cycle (prvNotifyPair, :501) — an owner re-announcing authoritative state to an idempotent receiver, the same idea behind the DHS’s retry-until-acked database distribution.
That per-cycle re-announcement is only safe because the receiver cold-reboots on difference, not
on receipt: each compares data[1] against the board it’s running and does nothing if it
matches. Break that test and the node reboots once a second.
One algorithm, three rows
All three pairs are driven from a single table, xRedunPairs (:472),
over which prvSendRedunPair (:488), prvNotifyPair
(:501) and prvSwitchPair (:517) are the
whole implementation. The asymmetry above is the one thing the rows differ in that matters: the
OBC’s notify_cmd is zero, and prvNotifyPair returns immediately on it, because that’s the
pair whose partner works the switch out for itself. Unit ids are 0 and 1 for every pair — they
index the powered[2] arrays — asserted at compile time rather than left implied.
What each switch costs
| Switch | Who reboots | What is wiped |
|---|---|---|
EPS OBC <A|B> | the DHS | mass memory, event log, TM counters, verification table, reassembly, platform mirror, ground-defined parameters, clock; comes up SAFE. NVRAM mirror and database survive |
EPS ACS <A|B> | the ADCS | quaternion, body rates (restored to boot values), target, lock, sensor phase, clock; comes up SAFE |
EPS TTC <A|B> | the TTC | downlink ring, TM/TC counters, uplink reassembly, clock; drops the ground link so it re-dials. Spacecraft ID survives — it is board identity from config |
An ADCS or TTC switch leaves the DHS untouched: it only tracks the active board, raises
EVT_ACS_SWITCH / EVT_TTC_SWITCH, and marks that subsystem’s database block stale so the next
pass reconfigures it. The platform clock, mass memory and mode carry on. Only an OBC switch reboots
the DHS.
Because TTC is the uplink path, a TC EPS TTC command reaches the spacecraft only while the
active board is alive — planned board rotation, not dead-board recovery.
11. The on-board database
The EPS receives 19 mission parameters from the DHS over the bus — the largest block in the
mission, and the one CFG_STAGE_MAX (32) is sized against. It registers them with dpRegisterCfg
in prvInitDataPool (:928); the DHS holds the matching rows
(0x0480–0x049D, with holes) in its own pool, so TC DHS PARAM SET EPS_SOC_LOW F32 20 retunes a
threshold three hops away, bumps the database generation and re-distributes automatically.
The holes in that range are permanent, and the live constraint is that they stay holes. Eight
parameters left this block when the EPS gained a plant (0x0482–0x0484, 0x048F–0x0493) and
three more when the instrument rail became three switched channels (0x0489–0x048B). Their
replacements took new identifiers rather than the vacated slots, because the NVRAM delta has
its own version gate: a stored override of a parameter that no longer exists can then only be
dropped, never applied to a different one. OBDB_VERSION carries those two bumps (3 and 4); see
obdb.md for the full history.
The gauge reads its calibration live on each step, so a retune takes effect on the next cycle
with no further action. Two things don’t, and prvOnCfgCommit (:848)
exists for them — though it no longer acts, it asks:
static void prvOnCfgCommit(int first)
{
if (first)
{
ucCfgSeedPending = 1; /* a boot value, not a live one */
}
ucRelatchPending = 1; /* rails are latched, so re-latch */
}- Rail currents are latched, and otherwise only re-read on a mode change — hence
ucRelatchPendingand theprvRelatchRails/prvSetRailssplit (§6.1). initial_soc_pctis applied on the first configuration only, to both the cell and the gauge — a fuel gauge powers up believing whatever it was last told. Setting it later would teleport the battery, the same rule the ADCS applies to its boot body rates.
The indirection through the two flags is the single-writer rule (§3): this callback runs on the dispatch task, synchronously and before the acknowledgement, and the RW segment belongs to the model task.
first is non-zero exactly once per process lifetime, on the first successful commit — the
callback fires only on a fully validated block (all parameters, or none), and fires an unbounded
number of times with first == 0 because the DHS refreshes a ready subsystem every
obdb_refresh_ms.
12. Configuration reference
Two sections of config/minscs.conf, and the split between them is the whole point: [eps] is
what the flight software decides, [eps_dyn] is what the spacecraft is. Every key has a
compiled-in default, so a missing key — or the whole file — still boots.
Board straps — read by this node
| Key | Default | Meaning |
|---|---|---|
log_level | info | off…trace; the file ships info |
active_obc | A | Primary on-board computer at boot |
active_adcs_cpu | A | Primary ADCS CPU board at boot |
active_ttc_cpu | A | Primary TTC CPU board at boot |
[eps] mission parameters — read by the DHS, distributed back
Every row has two defaults, and they are not the same thing. The ROM column is the
compiled-in DP_CFG_CATALOG value in include/datapool.h — what the
node runs on between dpApplyRomDefaults and the arrival of its database block. The file
column is what config/minscs.conf ships, which the DHS programmes into the database and pushes
back over the bus. They agree on every row but one.
| Key | Pool id | ROM | File | Range |
|---|---|---|---|---|
hk_period_ms | EPS_HK_PER | 1000 | 1000 | 200..600000 |
step_ms | EPS_STEP_MS | 500 | 500 | 50..10000 |
soc_low_pct | EPS_SOC_LOW | 30.0 | 30.0 | 0..100 |
soc_recover_pct | EPS_SOC_REC | 45.0 | 45.0 | 0..100 |
initial_soc_pct | EPS_SOC_INIT | 82.0 | 82.0 | 0..100 |
bus_current_a | EPS_BUS_A | 0.40 | 0.22 | 0..20 |
pl_camera_a | EPS_PL_CAM_A | 1.10 | 1.10 | 0..20 |
pl_gnss_a | EPS_PL_GNSS_A | 0.18 | 0.18 | 0..20 |
pl_mem_a | EPS_PL_MEM_A | 0.32 | 0.32 | 0..20 |
pl_mask_safe / _nominal / _science | EPS_PL_MASK_SAFE / _NOM / _SCI | 6 / 6 / 7 | 6 / 6 / 7 | 0..7 |
radio_safe_a / _nominal_a / _science_a | EPS_RADIO_SAFE / _NOM / _SCI | 0.20 / 0.30 / 0.40 | 0.20 / 0.30 / 0.40 | 0..20 |
gauge_capacity_wh | EPS_GAUGE_WH | 45.0 | 45.0 | 1.0..10000 |
gauge_ocv_empty_v | EPS_OCV_V0 | 26.0 | 26.0 | 0..100 |
gauge_ocv_span_v | EPS_OCV_SPAN | 3.4 | 3.4 | 0.1..100 |
gauge_anchor_tau_s | EPS_GAUGE_TAU | 120.0 | 120.0 | 1.0..10000 |
bus_current_ais the one row where the two disagree, and it isn’t documented as deliberate. The GNSS receiver’s draw used to be counted inside this rail; when it became its own switched channel (§6.1) the rail lost its share, 0.40 → 0.22, inconfig/minscs.confand in the node’s own struct initialiser — but theDP_CFG_CATALOGROM row still reads0.40f. The visible effect: an EPS running before its database block arrives, or one whose DHS never comes up, draws 0.18 A more on the platform rail than the shipped configuration intends. Treat this as an open question rather than as documented behaviour (§13).
Ranges are enforced twice: the DHS clamps when programming the database from the file, and
dpCheckSet rejects an out-of-range service-20 write at acceptance, so a bad value never reaches
the bus.
[eps_dyn] — the simulated hardware and universe
Read by this node in main(), not database content, and not reachable from the ground. See
sim/eps_plant.h.
| Key | Default | Meaning |
|---|---|---|
tle_line1 / tle_line2 | ISS, 2024 day 1.5, RAAN 90 | Element set; the same one [adcs_dyn] flies |
orbit_time_scale | 60 | Orbit/environment compression; 92.9 min → 92.9 s |
array_wing_peak_a | 1.76 | Sun-tracked wing, A at normal incidence |
array_body_peak_a | 1.44 | Zenith body panel, A at normal incidence |
batt_capacity_wh | 43.0 | True capacity, faded from the 45.0 nameplate |
batt_charge_eff | 0.96 | Charge acceptance — invisible to a coulomb counter |
bus_v_empty / bus_v_span | 26.2 / 3.1 | True OCV curve, shifted from the gauge’s belief |
therm_batt_tau_s / therm_rad_tau_s | 45.0 / 8.0 | Thermal time constants |
therm_sink_sun_c / therm_sink_ecl_c | 34.0 / −18.0 | Environment sink, sunlit and in shadow |
therm_self_c_per_w | 0.43 | Battery rise per watt — re-fitted, see §5 |
shunt_noise_a / volt_noise_v / temp_noise_c | 0.01 / 0.02 / 0.1 | Transducer 1-σ noise |
noise_seed | 20260804 | Any fixed value makes a whole run reproducible |
The element set’s right ascension is deliberately not the published one, and must not be “fixed” — RAAN 90 puts β near −5° and gives the eclipse cycle this page quotes, where the real ISS pair for that epoch produces none at all. See
dyn_engine.md§6.
Setting batt_capacity_wh, bus_v_empty and bus_v_span equal to the gauge_* keys makes the
gauge exact and the split decorative (§4).
13. Known gaps and history
bus_current_a’s ROM default (0.40 A) does not match the shipped file value (0.22 A) (§12). Possibly a missed edit when the GNSS gained its own channel; every other row in the block agrees.- A high-beta element set produces no eclipse from perfectly correct physics, and the telemetry
is indistinguishable from a frozen orbit model — a mistake this platform has already made once,
with a truncating orbit counter since deleted.
epsPlantInitreports beta and the eclipse cutoff at boot (§2) so the condition announces itself. - No verification report for a high-priority command (§10) — inherent to bypassing the DHS, not an oversight.
xRedunis read without synchronisation by the housekeeping task while dispatch writes it. The fields are single bytes and the worst case is one cosmetically inconsistent report.- No health monitoring or automatic failover. The EPS owns the redundancy state and sees the whole bus, so this is the natural place for it, but a switch today is always commanded.
- The default configuration is net-positive on power (§4), so the spacecraft won’t safe itself unless retuned — a property of the shipped parameters, not of the model.
- Power is modelled, not simulated. No bus impedance, no switching transient, no discharge-side loss, no ageing over the run. Charge acceptance is modelled, because it’s what the fuel gauge can’t see.
- Array output does not depend on attitude, and cannot (§4). A tumbling spacecraft would keep producing full power here.
14. Quick reference
build/bin/egse_tc EPS PING # ack via the DHS's verification report
build/bin/egse_tc EPS HK # all eight CAN reports; refreshes the pool, no
# immediate downlink -- see §8
build/bin/egse_tc EPS MODE SCIENCE # relatch the rails: 2.22 A total
build/bin/egse_tc EPS PLPWR CAM ON # power one instrument channel
build/bin/egse_tc EPS PLPWR GNSS OFF # ...and the GNSS stops reporting
build/bin/egse_tc EPS OBC B # high priority, bypasses the DHS
build/bin/egse_tc EPS ACS B # high priority
build/bin/egse_tc EPS TTC B # high priority
build/bin/egse_tc DHS PARAM SET EPS_PL_CAM_A F32 4.0 # force a drain; watch the gauge diverge
build/bin/egse_tc DHS PARAM GET EPS_SOC_PCT EPS_TEMP_BATT EPS_BETA_DEG EPS_ECLIPSE
build/bin/egse_tc DHS PARAM GET EPS_PL_CAM_ON EPS_PL_GNSS_ON EPS_PL_MEM_ON
build/bin/egse_tc DHS CONFIG EPS # force a database re-distributionA PLPWR override lasts until the next mode change; pl_mask_* is where a durable decision goes.
The orbit is not retunable from the ground — [eps_dyn] orbit_time_scale is read at boot only,
the point of the split: the ground doesn’t get to move the spacecraft. Watch a full cycle on the
node’s own log, about 57 s sunlit and 36 s eclipse.