The plants — dynamics and power
Reference documentation for sim/ — the simulated universe and the simulated hardware
that the flight software flies against. See adcs.md and eps.md for the
subsystems that consume it, and index.md for the platform overview.
1. What this is, and what it is not
It is not flight software. Everything under sim/ is plant, and in status it’s a sibling of
hub/ and egse/ — support code, not something that would ever fly.
There are two plants, in two different binaries, and one shared universe underneath them:
| Plant | Binary | Interface | Models |
|---|---|---|---|
| External Dynamics Engine | adcs_node | aocs_icd.h | Rigid body, sensor heads, actuators |
| Power Plant | eps_node | eps_icd.h | Solar array, battery, thermal, transducers |
| (shared universe) | both | — | Orbit, Sun, Earth’s shadow, and the time base |
Each is linked into its own node’s binary, which makes them the only non-flight code inside a node. The reason is physical in both cases: the interface each drives is that board’s own sensor harness — the ADCS’s gyro triad, magnetometer, sun cells, star trackers and tachometers on that PCB, the EPS’s shunts, bus ADC and thermistors inside that box. No raw measurement crosses CAN, and a harness is not something you reach across a process boundary, so each plant sits where its harness sits. Three rules keep that from becoming an excuse:
- A flight application never includes a plant header.
nodes/adcs_node.c’s entire view isaocs_icd.h;nodes/eps_node.c’s iseps_icd.h. The single exception ismain(), which constructs the plant before the scheduler starts — boot code, not control code. - A plant never calls into its application. It reads the RW segment and writes the RO and TRUTH segments. That’s the whole conversation.
- Sharing between the two plants is fine; sharing between a plant and its application is not.
Both sit on
dyn_world.hbecause one spacecraft cannot be in two places. The duplication rule is about a flight application sharing arithmetic with the truth model measuring it, which makes the estimate return its own input; here both consumers are the truth model.
They run inside FreeRTOS nodes, so they inherit the platform’s Posix-port hazards even though they aren’t flight software.
Files
| File | Contents |
|---|---|
sim/dyn_world.h / .c | The shared universe: the time base, the three model vtables, orbit/sun sampling and the Earth’s shadow. Depends on nothing. |
sim/dyn_rand.h / .c | The deterministic noise source, shared by every sensor head in either plant. |
sim/dyn_orbit.c | Simplified SGP4: TLE parsing and J2-secular Kepler propagation (§4). |
sim/dyn_env.c | Tilted-dipole IGRF (§5) and the solar ephemeris. It gives the EPS the sun model it wants and the field model it doesn’t; one unused static isn’t worth splitting the file for. |
sim/dyn_engine.h / .c | The ADCS’s plant: configuration (dyn_cfg_t), orchestration, quaternion helpers, the interface guard. |
sim/dyn_sensors.c | The ADCS’s sensor heads, their noise and their validity (§8). |
sim/dyn_rigid.c | Euler’s equations, reaction wheels, magnetorquers, RK4 (§7). |
sim/eps_plant.h / .c | The EPS’s plant: array illumination, the battery, the two thermal lags, its transducers and its guard (§11). |
Built into adcs_node and eps_node through the ${ARGN} extra-sources argument of
add_freertos_node in CMakeLists.txt — the first four rows into both, the
rest into one each. Putting them in common/ would compile them into all five nodes.
2. The tick
dynStep(dt_s) advances the ADCS’s plant by one control period. The order is fixed and each step
depends on the one before:
1. universe dynWorldStep(dt) -> r, v, s in ECI, eclipse, jd
2. field dynWorldField(w) -> B in ECI
3. rigid body READS THE RW SEGMENT, integrates -> q_true, w_true, wheels
4. sensors degrade the truth -> writes the RO segment
5. publish r, v, wheels, eclipse, truth -> stamps the guardStep 1 is dyn_world.c’s and carries the time base; the EPS’s plant opens its own step the same
way, in its own process. Step 2 is kept separate deliberately — only the ADCS wants the geomagnetic
field, and evaluating a tilted-dipole IGRF twice a second in the EPS for a vector nothing reads is
the kind of cost that accumulates unnoticed. Step 3 is the closed loop: the only read of the RW
segment and the only point at which the flight software’s output affects the world.
The caller is prvControlTask in ../nodes/adcs_node.c, which runs
dynStep() and then the application, in that order, in a single task. That ordering is the
synchronisation design: one thread of control means no lock between the two modules and no
ambiguity about which setpoints the plant integrated. The engine has no task of its own. The EPS
does the same, and had to be changed to be able to
(§11).
Time: integer milliseconds, never truncated seconds
Elapsed time accumulates in integer milliseconds in a uint64_t and is converted to Julian
Date only at the point of use. This isn’t fussiness, and it’s the platform’s rule rather than this
file’s: every model step here is sub-second, so counter += (uint32_t) dt_s adds zero
forever and the model freezes at its phase-0 state without erroring — no eclipse, array current
pinned, and a run that looks plausible until you notice nothing moves. The EPS’s own orbit counter
stood still that way long enough to be baked into sample telemetry; the payload’s GNSS clock
carried the same bug against gnss_period_ms (payload.md §4).
A uint64_t of milliseconds covers 584 million years, so it cannot wrap.
truth.orbit_ms is the low 32 bits of that counter, so it does wrap — after 49 days of
simulated time. It’s a diagnostic, not a clock.
dyn_world.c’s statics are per process, so the two plants are two instances, not one
universe: each holds its own installed models, its own parsed element set and its own elapsed-time
counter started at its own boot instant, and the shipped configuration compounds that with
different time scales ([adcs_dyn] 1 against [eps_dyn] 60). ADCS_ECLIPSE and EPS_ECLIPSE
disagreeing side by side in the DHS’s data pool is expected, not a fault. The honest cure — driving
elapsed time from on-board time once obtIsSynced() — is a deliberate non-goal: it would couple two
plants that are meant to be independent.
3. The model seam
The three environment models are reached through vtables declared in dyn_world.h — one level
below either plant, so both get the same universe and neither owns it:
typedef struct {
const char *name;
int (*init)(const dyn_world_cfg_t *cfg, double *epoch_jd);
void (*propagate)(double jd, float pos_eci_km[3], float vel_eci_kms[3]);
} dyn_orbit_model_t;Only the orbit has an init, because only the orbit has an element set to parse and an epoch to
report. dyn_field_model_t is { name, field } and dyn_sun_model_t is { name, sun }.
The vectors are typed float[3] rather than float[AOCS_AXES] precisely so this header depends
on nothing: AOCS_AXES is 3, but it belongs to the ADCS’s interface and the EPS’s plant has no
business including that.
dynWorldInit installs them (pxOrbit = &xDynOrbitSgp4; and the two like it), and swapping in a
high-fidelity model is those assignments and nothing else. To replace the dipole with a full
IGRF-13: write sim/dyn_env_igrf13.c exporting a const dyn_field_model_t xDynFieldIgrf13, add it
to the add_freertos_node(adcs_node ...) source list, and change one line. No caller changes, no
interface change, and — the point of the exercise — no flight-software change.
All three take Julian Date rather than seconds-since-boot, because both the field and the Sun are functions of absolute time. A model taking an elapsed count would bake the epoch into itself and couldn’t be swapped for one that didn’t.
4. Orbit — dyn_orbit.c
What it parses
A real two-line element set, per the standard — this is the published ISS pair for the shipped
epoch, which is not quite what config/minscs.conf ships (see
§6):
1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9009
2 25544 51.6400 208.9163 0006317 69.9862 290.1993 15.49309239 10003- Fixed columns, extracted into a scratch buffer before
strtod.sscanfwith%lfwould run past a field boundary into the next one whenever a value is short. - Implied decimal point on eccentricity:
0006317means 0.0006317. Read as a plain number it gives an orbit six thousand times too eccentric — a spectacular trajectory and no diagnostic. - Checksum: modulo-10 sum of digits with
-counting one. - Two-digit year windowed at 57; day-of-year counts from 1.0 = January 1, 00:00 UTC.
A set that fails any of this, or the sanity limits (eccentricity below 0.9, perigee above the surface), is rejected, logged as an error, and replaced by a 500 km circular orbit at 51.64° — so a typo in the config degrades the run’s fidelity instead of ending it.
What it propagates
Kepler plus the secular J2 terms that dominate SGP4’s own initialisation:
n0'' = n0 (1 + 3/2 J2 (Re/p)^2 sqrt(1-e^2) (1 - 3/2 sin^2 i)) mean-motion correction
dΩ/dt = -3/2 J2 n (Re/p)^2 cos i nodal regression
dω/dt = 3/4 J2 n (Re/p)^2 (5 cos^2 i - 1) apsidal rotationthen Kepler’s equation M = E − e sin E by Newton–Raphson, bounded at eight iterations: for
e below 0.1 it converges to double precision in three or four, and the bound matters more than the
last digit because an unbounded loop in a periodic task is a hazard rather than a refinement.
Perifocal position and velocity are rotated into ECI by Rz(−Ω) Rx(−i) Rz(−ω), of which only the
first two columns are needed, the perifocal z being zero by definition.
Fidelity envelope
This is not SGP4. It omits:
| Omitted | Consequence |
|---|---|
| Atmospheric drag | BSTAR is parsed and ignored. Semi-major axis does not decay. |
| Short- and long-period terms | Position error of hundreds of metres to a few km over a day. |
| Deep-space extension | Orbits above a 225-minute period are not handled correctly. |
That error only matters relative to what consumes it: a kilometre of along-track error feeds a dipole field model that’s itself 10–20 % wrong and a shadow test accurate to a few seconds of orbit. When that stops being true, §3 is where a real SGP4 goes.
5. Geomagnetic field — dyn_env.c
The full IGRF is a spherical-harmonic expansion to degree 13. Keeping only the three degree-one terms leaves a centred tilted dipole:
B(r) = B0 (Re/r)^3 [ 3 (m̂ · r̂) r̂ − m̂ ]with m̂ = (g11, h11, g10)/B0 and B0 = |(g10, g11, h11)|. Coefficients are IGRF-13 epoch
2020.0 — g10 = −29404.8, g11 = −1450.9, h11 = 4652.5 nT — and are not secularly
advanced, the drift being about 0.05 % a year, far inside the model’s own error.
With g10 negative the moment points broadly south, which is why a compass needle’s north end
points north: the geomagnetic pole in the northern hemisphere is a magnetic south pole.
Two checks any retune must still pass, both falling out of the coefficients above: ≈ 60 µT at the magnetic pole and ≈ 30 µT horizontal at the magnetic equator, at the surface. If a change breaks either, it’s wrong.
GMST is not optional bookkeeping
The field is defined in an Earth-fixed frame and consumed in an inertial one, so the model rotates the position into ECEF by −GMST, evaluates, and rotates the result back. Get this wrong and the field is plausible in magnitude and useless in direction — and B-dot, which depends entirely on direction, quietly stops working.
Accuracy, and what depends on it
A dipole is good to roughly 10–20 % in magnitude and rather better in direction away from the South
Atlantic Anomaly — entirely adequate for B-dot, which needs only the direction of dB/dt to be
approximately right and is famously tolerant of gain error because it’s a damping law. It’s not
adequate for magnetic attitude determination to any real accuracy, which is why the ADCS ranks its
star trackers above TRIAD.
6. Sun and eclipse
Sun — the low-precision Astronomical Almanac series: mean longitude, mean anomaly, equation of
centre, obliquity. Four lines, good to about 0.01° over 1950–2050; the Earth’s orbital eccentricity
is carried (the sin g term), planetary perturbations are not. 0.01° is two orders of magnitude
finer than the sun sensors that consume it, whose cosine response and 1 % noise give a few degrees
at best.
Eclipse — a cylindrical shadow, in dyn_world.c because both plants need it: the spacecraft is
eclipsed when it’s on the anti-sun side of the Earth (r · ŝ < 0) and within one Earth radius of
the shadow axis. Ignoring the penumbra and the Sun’s finite angular size smears the terminator by a
few seconds of orbit — irrelevant to a sun sensor reporting a boolean validity or to an array that
is either lit or not, and a natural place for a conical model later.
The beta angle, and why the shipped element set’s RAAN is not the published one
The solar beta angle decides whether there is an eclipse at all. β is the Sun’s elevation out
of the orbit plane — the angle between ŝ and the plane whose normal is r × v. Eclipse becomes
geometrically impossible once |β| exceeds asin(R⊕/r), about 69.7° at this altitude: the
shadow cylinder simply doesn’t reach the orbit, and the spacecraft is in permanent sunlight from
entirely correct physics.
That’s not hypothetical, and it’s the one trap in this file most likely to be “fixed” back in. The
real ISS element set for the shipped epoch (2024 day 1.5) sits at β = −68.6°, one degree inside
the limit. Flown unmodified it produces no eclipse at all after about eight simulated hours —
which at [eps_dyn] orbit_time_scale = 60 is roughly eight minutes of wall clock, and at
[adcs_dyn]’s shipped scale of 1 is longer than any demonstration run. What an operator sees is an
orbit counter advancing, an array current pinned high and an eclipse flag that never sets: the
exact signature of a frozen orbit counter (§2), from a model that’s working
perfectly.
So the shipped tle_line2 in both [adcs_dyn] and [eps_dyn] carries RAAN 90.0000 where the
published set has 208.9163. Nothing else is touched — the checksum digit is unchanged, so the line
is still format-valid — and at RAAN 90 the beta angle is about −5°, the eclipse fraction 0.39,
and it stays there for ten simulated days. This is not a transcription error and must not be
“corrected” back to the published value. Both plants fly the same element set, because a
spacecraft has one orbit; the ADCS doesn’t care about eclipse and the EPS very much does.
epsPlantInit logs β and the cutoff at boot so the condition announces itself either way:
[EPS] plant: beta=-5.0deg (eclipse cutoff 69.7deg) -- eclipse expected
[EPS] WARN plant: beta=-68.6deg exceeds the 69.7deg eclipse cutoff -- this element set is in
permanent sunlight and NO eclipse will occur7. Rigid body — dyn_rigid.c
Euler’s equations
I ω̇ = τ_mtq + τ_react − ω × (I ω + h_wheel)
τ_mtq = m × B dipole against the local field
τ_react = −τ_wheel every N·m into a rotor comes back out on the bodyThe gyroscopic term carries the total angular momentum, body plus wheels. Dropping the wheel term is the classic error: it makes a momentum-biased spacecraft behave like an unbiased one, so the coupling a real 3-axis controller spends most of its effort fighting simply isn’t there, and the controller looks better than it is.
Conventions
q is ECI→BODY, scalar first, and the kinematics are q̇ = ½ ω_body ⊗ q, expanded to exactly
the four lines the node used before the refactor — deliberately, so that a sign error shows up as a
difference from known-good arithmetic. dynQuatRotate is the matching direction-cosine matrix.
Actuators
Wheels — a first-order speed loop toward the commanded rotor speed with time constant
wheel_tau_s, torque clamped at wheel_hw_max_torque_nm, speed clamped at wheel_hw_max_rpm.
At the speed limit the rotor cannot take more momentum outward, so the torque available to the
body in that direction is gone. The plant does not report saturation anywhere: the flight
software detects it from the tachometers in the RO segment, which is both how a real controller
learns it and the only way consistent with the ICD — an accessor on the plant would be a second
channel between the two modules, and there is deliberately only one.
Magnetorquers — the coil delivers what was commanded, clamped to mtq_hw_max_dipole.
Integration
RK4 over the ten-state vector (quaternion, body rate, three rotor speeds), sub-stepped at
substep_ms within each control period — five sub-steps per 100 ms period by default.
The field is re-rotated into the body frame at every RK4 stage, from that stage’s own attitude. Passing a body-frame field computed once per control period would hold the torque direction fixed across the step, which is exactly wrong for the case that matters: a spacecraft tumbling fast enough to need detumbling turns several degrees inside one control period.
The quaternion is renormalised once per sub-step rather than per stage. RK4 takes it off the unit sphere by O(h⁵), far below the point where renormalising inside the stages would buy anything.
8. Sensor heads — dyn_sensors.c
Each head takes the truth, degrades it the way the real unit would, and publishes into the RO segment with a validity flag. The degradation is the point: a control law tested against perfect measurements is not tested.
| Head | Model | Validity |
|---|---|---|
| Gyro triad | constant bias + random-walk bias (∝√dt) + white noise | always 1 |
| Magnetometer | white noise | always 1 |
| Sun cells (6) | max(0, ŝ_body · n̂_face) per face, dark current in eclipse; the head reconstructs the vector from opposing pairs | 0 in eclipse, and when no cell exceeds sun_threshold |
| Star trackers (2) | truth perturbed by a small rotation about a random axis | 0 inside st_sun_excl_deg of the Sun, or within the Earth’s limb plus st_earth_excl_deg |
| Tachometers | exact rotor speed | n/a |
Two flags are constants. gyro_valid and mag_valid are hard-wired to 1 and never cleared,
because neither head has a failure model. Three application paths are consequently unreachable
today: the !mag_valid early return in prvDetumble, the B-dot filter invalidation, and the
magnetometer validity bit in telemetry. They’re written and correct, but unexercised — worth
knowing before trusting them.
The star-tracker perturbation is a rotation, not independent noise on four components — the latter would leave the quaternion off the unit sphere in a way no real tracker’s output ever is. A blinded tracker keeps its last quaternion rather than zeroing it, because a real unit holds its last solution and zeroing would hand the flight software a non-unit quaternion to trip over if it ignored the flag.
All noise comes from one xorshift32 + Box–Muller seeded from noise_seed, deliberately not
rand(): a run has to be reproducible so a misbehaving detumble can be re-run and watched, and so
two builds of the same configuration produce the same telemetry.
A deliberate omission. A real magnetorquer saturates the magnetometer that shares its spacecraft, which is why flight B-dot implementations duty-cycle the coils and sample the field only while they’re off. It’s left out so the detumble demonstration exercises the control law rather than the sampling schedule.
9. The interface guard
Both plants carry the same mechanism, on the same terms; this section is the account of it, and
adcs.md and eps.md point here.
PUS service 6 isn’t implemented on this platform, and
obdb.md §4
argues against uplinkable raw byte ranges into on-board memory. The useful half of the service is
the other half: [6,9]/[6,10], check a memory region against an expected checksum. The ICD’s
RO+TRUTH segments are that region — [0, AOCS_GUARDED_LEN), which offsetof(aocs_icd_t, rw)
defines as ending exactly where the RW segment begins, so the application’s own outputs are outside
it and writing a wheel command never trips the check.
Once per control cycle:
- the plant writes
roandtruth, thenpusCrc16s the guarded span —dynGuardStamp; - the application runs, and should write only
rw; - the application re-checksums the same span as its last act —
dynGuardCheck.
Step 3’s placement is load-bearing, and the obvious alternative is silently useless. Checking at
the start of the next cycle asks the question after dynStep has already rewritten essentially
every field of the RO segment, so a violation is overwritten before it can be seen and the guard
passes forever — including while the application scribbles over the segment on every tick. That was
a real defect during development, and it’s exactly what the check exists to catch.
The whole argument holds only if the plant and the application run in one task, in sequence, which is what makes the RW segment single-writer (§11).
A mismatch sets AOCS_FDIR_ICD_VIOLATION, logs an error, and rides bit 4 of the
ADCS_RPT_CTRL frame’s FDIR octet to the DHS, which reports it in the same bit of the ADCS
housekeeping packet — ICD_VIOL on the ground. The EPS’s guard is the twin of this one over
eps_icd.h.
Padding is covered by the checksum, so it must be deterministic. xAocsIcd is static
(zero-initialised) and dynReboot memsets the whole image, so padding is zero and stays zero.
10. Configuration — [adcs_dyn]
Read by the node in main() and deliberately not on-board database content: these describe the
spacecraft’s physical construction and the world it flies in — the truth model — and a flight
computer has no business holding that. Nothing here is reachable from the ground. Contrast
[adcs], which holds the control gains and is distributed by the DHS (adcs.md).
| Key | Unit | Default | Notes |
|---|---|---|---|
tle_line1, tle_line2 | — | ISS, 2024 day 1.5, RAAN adjusted (§6) | 69 characters each; CFG_VALUE_LEN is 80 to fit them |
orbit_time_scale | × | 1 | See the warning below |
inertia_xx/_yy/_zz | kg·m² | 0.045 / 0.048 / 0.030 | principal moments, 12U-class |
gyro_bias_x/_y/_z | deg/s | 0.02 / −0.015 / 0.008 | constant bias |
gyro_noise_dps | deg/s | 0.01 | 1σ white |
gyro_walk_dps | deg/s/√s | 0.0005 | why attitude cannot be integrated forever |
mtm_noise_ut | µT | 0.15 | 1σ |
sun_noise | — | 0.01 | 1σ on a normalised cell |
sun_threshold | — | 0.05 | brightest cell below this ⇒ head reports invalid |
st_noise_deg | deg | 0.02 | 1σ attitude error |
st_sun_excl_deg | deg | 30.0 | boresight-to-Sun keep-out half-angle |
st_earth_excl_deg | deg | 20.0 | margin beyond the limb (~70° in LEO) |
wheel_inertia_kgm2 | kg·m² | 4.5e-5 | rotor |
wheel_tau_s | s | 0.25 | speed-loop time constant |
wheel_hw_max_rpm | rpm | 6500 | hardware limit |
wheel_hw_max_torque_nm | N·m | 0.004 | hardware limit |
mtq_hw_max_dipole | A·m² | 3.2 | hardware limit |
substep_ms | ms | 20 | plant integration step |
noise_seed | — | 20260802 | any fixed value makes a run reproducible |
Hardware limits versus commanded limits
*_hw_* here is what the unit can physically deliver. mtq_max_dipole and wheel_max_rpm in
[adcs] are what the control law is permitted to ask for. The two are deliberately separate: a
controller’s clamp is policy the ground may retune; a rotor’s maximum speed is not. Setting the
commanded limit above the hardware limit is legal and interesting — the plant saturates and the
flight software raises AOCS_FDIR_WHEEL_SAT, exactly as it would in orbit.
The magnetorquer is sized so a detumble finishes inside a demonstration run; a flight-representative rod of a few tenths of an A·m² would take tens of minutes, which is correct and dull.
⚠ orbit_time_scale — the one knob that silently invalidates the control loop
It compresses the orbit and environment only, never the rigid-body integration, so eclipse
entry and the field’s rotation are observable in a short run. Leave it at 1 whenever the control
law is under test: compressing the orbit speeds up how fast the field sweeps past the spacecraft
without touching how fast the spacecraft rotates, and B-dot cannot tell those apart. Raise it only
to watch eclipse, the sun angle or the field geometry, none of which care. The full argument, with
the measured numbers, is in the [adcs_dyn] comment block of
config/minscs.conf and is kept in one place deliberately.
11. The EPS Power Plant
sim/eps_plant.c is the second plant, and it’s built on the same three rules as the first: one
interface (eps_icd.h), no calls into the application, and the shared
universe underneath. The application it serves is eps.md.
The step
1. universe dynWorldStep(dt) -> r, v, s in ECI, eclipse
2. geometry beta from r x v and s -> illumination cosine
3. array wing.cos(beta) + body.max(0,r.s) -> generated current
4. battery READS THE RW SEGMENT -> stored energy, terminal voltage
5. thermal two first-order lags -> battery and radiator truth
6. transducers degrade the truth -> writes the RO segment
7. publish orbit state, TRUTH -> stamps the guardStep 4 is the closed loop: the rail currents the flight software left in the RW segment are the discharge, and they’re summed here rather than read pre-summed so the plant cannot be shown a load that doesn’t match the rails the application actually switched.
What the array model can and cannot know
The EPS has no attitude sensor and no access to the ADCS’s estimate — that lives in another
process, and only its heavily reduced echo ever reaches the bus. So the array model uses only what
r, v and ŝ give it: a single-axis sun-tracked wing whose drive turns about the orbit normal
(incidence cos β, no attitude needed) plus a zenith-facing body panel on a platform assumed
nadir-pointing (incidence max(0, r̂·ŝ)). If the platform tumbles, this model is wrong and doesn’t
know it — a true statement about a real EPS rather than a shortcut: without an
array-current-to-attitude cross-check it doesn’t have, it cannot tell “shadowed by a bad attitude”
from “degraded”.
The one thing this plant does that the ADCS’s does not
It models a battery whose properties the flight software gets wrong on purpose. The cell’s true
capacity, open-circuit-voltage curve and charge acceptance live in [eps_dyn]; the fuel gauge’s
beliefs about the first two are on-board database content and are deliberately different numbers.
truth.soc_pct against the gauge’s rw.soc_est_pct is the EPS’s answer to q_true against
q_est, and exactly as with the ADCS’s reference models, making the two sets equal destroys the
measurement (eps.md §4).
The single-writer precondition
§9’s argument — stamp after the plant writes, check as the application’s last act — is only valid if the plant and the application run in one task, in sequence. The ADCS always did. The EPS did not: its rails were latched from the dispatch task while the model task integrated. Making the model task the sole writer of the RW segment was a prerequisite for this plant, not a tidy-up, and it retired a live race on the battery state as a side effect.
There is no reboot entry point. dynReboot exists because the ADCS is one of the
cold-redundant pairs and its board can be power cycled from orbit. The EPS is the box that does
the switching, so eps_plant.h has no counterpart and shouldn’t grow one.
12. Known simplifications
An index, so none of them is a surprise; each is argued where it’s made.
| Area | Simplification | § |
|---|---|---|
| Orbit | no drag, no periodic terms, no deep space — a few km/day against real SGP4 | 4 |
| Field | degree-1 only, no secular drift, no South Atlantic Anomaly | 5 |
| Sun | no planetary perturbations; ~0.01° | 6 |
| Eclipse | cylindrical, no penumbra, no finite solar disc | 6 |
| Torques | magnetic only — no flexibility, slosh, gravity gradient or SRP | 7 |
| Sensors | gyro and magnetometer never fail, so two validity flags are constants and the application’s dropout paths are unexercised | 8 |
| Sensors | sampling is instantaneous and simultaneous — no read latency or skew between heads; no magnetorquer/magnetometer interaction, so no coil duty-cycling | 8 |
| Time | truth.orbit_ms wraps after 49 days of simulated time, and the two plants share physics but not a clock | 2 |
| Power | array output is attitude-independent, absent a wire the EPS does not have; a tumbling spacecraft keeps producing full power | 11 |
| Power | no bus impedance, no switching transient, no discharge-side loss — charge acceptance is modelled, being what a coulomb counter cannot see; the cell does not age over a run | 11 |