Skip to content

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.

ModuleLives inFlight software?
EPS Applicationnodes/eps_node.cYes. Rail switching, the fuel gauge, the low-power alert, housekeeping, redundancy.
EPS Power Plantsim/eps_plant.cNo. 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:

ResponsibilitySummary
LoadThree rails following platform mode, the instrument one switched per payload instrument
GaugeA coulomb-counting estimate of state of charge — there is no SoC sensor
AlertingTelling the DHS, on its own initiative, when the charge runs low
RedundancyWhich 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
  └─ vTaskStartScheduler

The node reads config/minscs.conf for four board strapslog_level, active_obc, active_adcs_cpu, active_ttc_cpuand 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 expected

That 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.

TaskPriorityPeriodRole
canrxPRIO_CAN_RX (5)Bounded recvfrom into the RX queue
modelPRIO_CTRL (4)step_ms (500)Requests, then plant, then application (:333)
dispPRIO_APP (3)blockingCAN receive demultiplex (:673)
hkPRIO_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 guard

The 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) and EPS_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_span

Charge 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-anchor

The 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:

QuantityGauge believes ([eps], database)Cell actually is ([eps_dyn], plant)
Capacity45.0 Wh nameplate43.0 Wh, faded
OCV curve26.0 + 3.4 V26.2 + 3.1 V, shifted and flattened
Charge acceptance(invisible to a coulomb counter)0.96

Setting the [eps_dyn] values equal to the gauge_* ones makes the whole split decorative. The voltage anchor then recovers the true state of charge exactly, EPS_SOC_PCT tracks EPS_SOC_TRUE to 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 array

The 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_s

The 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:

RailSAFENOMINALSCIENCENotes
Platform bus0.22 A0.22 A0.22 Aalways on — DHS, ADCS, avionics
Instruments0.50 A0.50 A1.60 Apayload; switched per instrument, see below
Radio0.20 A0.30 A0.40 ATTC transceiver; standby in SAFE
Total0.92 A1.02 A2.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:

ChannelPL_INSTR_*bitDrawConfig key
CameraPL_INSTR_CAMERA01.10 Apl_camera_a
GNSS receiverPL_INSTR_GNSS10.18 Apl_gnss_a
Mass memoryPL_INSTR_MEM20.32 Apl_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:

Modepl_mask_*ChannelsInstrument rail
SAFEpl_mask_safe = 6GNSS + memory0.50 A
NOMINALpl_mask_nominal = 6GNSS + memory0.50 A
SCIENCEpl_mask_science = 7all three1.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 cleared

The 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 NOMINAL

Only 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.

ReportTagdlcLayout
State of charge(untagged)8SoC(2), bus V(2), array A(2), load A(2) — all ×100 unsigned BE
Power railsEPS_RPT_POWER 0xE17instrument(2), radio(2), bus(2) currents ×100
Instrument powerEPS_RPT_PL_PWR 0xE72switch mask(1): bit0 camera, bit1 GNSS, bit2 memory
ThermalEPS_RPT_THERMAL 0xE55battery(2), radiator(2) °C ×100, signed
IlluminationEPS_RPT_ORBIT 0xE66flags(1), illumination(2) ×100 %, beta(2) ×100 °, signed
OBC redundancyEPS_RPT_REDUN 0xE24active id(1), A powered(1), B powered(1)
ADCS-CPU redundancyEPS_RPT_REDUN_ADCS 0xE34as above
TTC-CPU redundancyEPS_RPT_REDUN_TTC 0xE44as 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:

OffsetFieldEncoding
0..1State of charge×100
2..3Bus voltage×100
4..5Array current×100
6..7Load current×100
8..9Instrument rail current×100
10..11Radio rail current×100
12..13Bus rail current×100
14..15Battery temperature×100, signed
16..17Radiator temperature×100, signed
18..19Illuminationfraction ×10000, so 10000 is 100 %
20..21Beta angle×100, signed
22Flagsbit0 eclipse, bit1 orbit fix valid, bit2 ICD violation, bit3 gauge divergence
23Payload switchesbit0 camera, bit1 GNSS, bit2 memory
24Active OBCOBC_ID_A/_B, or 0xFF
25OBC poweredbit0 A, bit1 B
26 / 27Active ADCS board / powered maskas above
28 / 29Active TTC board / powered maskas 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_HK produces 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.

OpcodeActionAcks?
CMD_PING (0x01)yes, CAN_FUNC_ACK with CMD_PING
CMD_REQ_HK (0x02)prvSendAllHk — all eight reportsno; the reply is the housekeeping
CMD_SET_MODE (0x03)requests the mode through ucModeReq, guarded on dlc >= 2yes, echoing the mode
CMD_SET_TIME (0x04)obtSet, only from the DHS and only with dlc >= 7no; 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 idyes, echoing instrument and state
CMD_CFG_BEGIN/_ITEM/_ENDconsumed by obdbHandleFramethe 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.

PairState in redun_tBoot strapSwitch opcodeGround command
On-board computeractive_obc, powered[2][eps] active_obcCMD_OBC_SWITCH (0x22)egse_tc EPS OBC <A|B>
ADCS CPU boardactive_acs, acs_powered[2][eps] active_adcs_cpuCMD_ACS_SWITCH (0x23)egse_tc EPS ACS <A|B>
TTC CPU boardactive_ttc, ttc_powered[2][eps] active_ttc_cpuCMD_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, switch

TTC 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_REDUN and 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_SWITCH to 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

SwitchWho rebootsWhat is wiped
EPS OBC <A|B>the DHSmass 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 ADCSquaternion, body rates (restored to boot values), target, lock, sensor phase, clock; comes up SAFE
EPS TTC <A|B>the TTCdownlink 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 (0x04800x049D, 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 (0x04820x0484, 0x048F0x0493) and three more when the instrument rail became three switched channels (0x04890x048B). 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 ucRelatchPending and the prvRelatchRails/prvSetRails split (§6.1).
  • initial_soc_pct is 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

KeyDefaultMeaning
log_levelinfoofftrace; the file ships info
active_obcAPrimary on-board computer at boot
active_adcs_cpuAPrimary ADCS CPU board at boot
active_ttc_cpuAPrimary 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.

KeyPool idROMFileRange
hk_period_msEPS_HK_PER10001000200..600000
step_msEPS_STEP_MS50050050..10000
soc_low_pctEPS_SOC_LOW30.030.00..100
soc_recover_pctEPS_SOC_REC45.045.00..100
initial_soc_pctEPS_SOC_INIT82.082.00..100
bus_current_aEPS_BUS_A0.400.220..20
pl_camera_aEPS_PL_CAM_A1.101.100..20
pl_gnss_aEPS_PL_GNSS_A0.180.180..20
pl_mem_aEPS_PL_MEM_A0.320.320..20
pl_mask_safe / _nominal / _scienceEPS_PL_MASK_SAFE / _NOM / _SCI6 / 6 / 76 / 6 / 70..7
radio_safe_a / _nominal_a / _science_aEPS_RADIO_SAFE / _NOM / _SCI0.20 / 0.30 / 0.400.20 / 0.30 / 0.400..20
gauge_capacity_whEPS_GAUGE_WH45.045.01.0..10000
gauge_ocv_empty_vEPS_OCV_V026.026.00..100
gauge_ocv_span_vEPS_OCV_SPAN3.43.40.1..100
gauge_anchor_tau_sEPS_GAUGE_TAU120.0120.01.0..10000

bus_current_a is 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, in config/minscs.conf and in the node’s own struct initialiser — but the DP_CFG_CATALOG ROM row still reads 0.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.

KeyDefaultMeaning
tle_line1 / tle_line2ISS, 2024 day 1.5, RAAN 90Element set; the same one [adcs_dyn] flies
orbit_time_scale60Orbit/environment compression; 92.9 min → 92.9 s
array_wing_peak_a1.76Sun-tracked wing, A at normal incidence
array_body_peak_a1.44Zenith body panel, A at normal incidence
batt_capacity_wh43.0True capacity, faded from the 45.0 nameplate
batt_charge_eff0.96Charge acceptance — invisible to a coulomb counter
bus_v_empty / bus_v_span26.2 / 3.1True OCV curve, shifted from the gauge’s belief
therm_batt_tau_s / therm_rad_tau_s45.0 / 8.0Thermal time constants
therm_sink_sun_c / therm_sink_ecl_c34.0 / −18.0Environment sink, sunlit and in shadow
therm_self_c_per_w0.43Battery rise per watt — re-fitted, see §5
shunt_noise_a / volt_noise_v / temp_noise_c0.01 / 0.02 / 0.1Transducer 1-σ noise
noise_seed20260804Any 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. epsPlantInit reports 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.
  • xRedun is 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-distribution

A 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.

Last updated on