A note on framing before the diagrams. cislunar-sim is not a service architecture. It is a scientific computing library: imported by a researcher in a script or notebook, exercised by CI on every push, and distributed through PyPI. The two diagrams below therefore describe dependency boundaries, not runtime infrastructure.

The disciplines that matter in production systems (explicit interfaces, reproducible behavior, and validation claims that match reality) matter here too, and a spacecraft simulator just makes them unusually easy to inspect from the outside.

Neither GMAT Nor Orekit Was Built to Be Read

cislunar-sim propagates a spacecraft from low Earth orbit through cislunar space under a physically realistic force environment: Earth’s J2 oblateness, an eight-mascon lunar gravity model derived from GRAIL data, third-body perturbations from the Sun and Moon, atmospheric drag, solar radiation pressure on a sail, eclipse-driven power constraints, and thrust from either an ion engine or a solar sail. It then steers through that environment toward a target orbit using a feedback guidance law, not a learned policy.

The library’s own positioning, stated plainly in its README, is a Python-native alternative to GMAT’s mission-script workflow and Orekit’s JVM dependency. That gap is real. Both tools are mature and independently validated. Neither is something a research engineer can install with pip and read start to finish in an afternoon.

The headline validation claim is just as plainly stated: the physics engine achieves under 0.05 percent SMA error against SGP4-propagated LightSail 2 states, using bundled TLEs, reproducible from a fresh clone. A skeptical reader goes straight to the repository to check that sentence first, which makes it the one to pressure-test.

Two Kinds of Dependency

cislunar-sim has no runtime footprint in the usual sense. There is no service to page and no client to authenticate. Its two consumers are a research or mission engineer running a script or notebook, and a CI pipeline running the test and benchmark suite on every push, and both pull the same package from PyPI.

In a context diagram this would be the external-systems box. For a physics library it splits into two categories, and where the line falls between them says more than the box does.

System context diagram for cislunar-sim showing the research engineer and CI pipeline consuming the package from PyPI, with bundled offline data dependencies on one side and external opt-in dependencies gated behind make validate-external on the other.
Figure 1: What the package touches, split by whether it needs the network to prove anything.

Three data dependencies are bundled and require no network access: Sun and Moon ephemeris from astropy’s DE430 kernel, atmosphere density from an NRLMSISE-00 proxy driven by F10.7 solar flux, and five historical LightSail 2 TLEs used for the orbit-replay comparison. Running make test from a fresh clone exercises all of it, including the headline SMA-error number, without touching the network.

Two dependencies are explicitly out of the repository: the full CelesTrak GP-history archive, roughly 12 MB of JSON across 2,680 TLEs, and SatNOGS beacon frames, roughly 4 MB covering eclipse timing and attitude rates. These feed the deeper cross-checks, the B* drag-swing ratio, eclipse-timing residuals, and the body-rate envelope, and they sit behind a separate make validate-external target with its own acquisition instructions. The boundary is deliberate. A test suite that depends on a 12 MB external archive to pass its core checks is a test suite that occasionally fails for reasons that have nothing to do with the code under test. Keeping the reproducible claim and the deeper claim in separate buckets means the reproducible one never depends on infrastructure outside the repository.

Three Layers, One Direction

The module layout is a clean three-way split: physics, guidance, and validation. Plenty of codebases have three folders, so the interesting part is which direction the dependencies run.

Component diagram showing cislunar.guidance and cislunar.validation both depending downward on cislunar.physics, which imports nothing from either of them. The physics layer contains SpacecraftState, Spacecraft.step, Action, the integrator, event detection, and the force models.
Figure 2: Guidance and validation both depend on physics. Physics depends on neither.

Physics has no import of guidance or validation. It does not know either package exists. Guidance imports physics to read SpacecraftState and construct an Action. Validation imports physics to drive it, and sits beside guidance without depending on it. The asymmetry is the whole design. A bug in a guidance law can never corrupt the state propagation underneath it, and a change to a guidance law never requires touching the integrator, the force models, or the validation harness that proves they are correct.

The Integrator Is Ordinary on Purpose

The integrator is an adaptive Dormand-Prince RK4(5) pair, the same embedded Runge-Kutta family used across general-purpose ODE solvers, chosen for adaptive step control over a fixed-step or symplectic scheme. Force models are independently swappable and summed each step: two-body plus J2 for Earth, J2 and J3 plus an eight-mascon GRAIL model for the Moon, third-body perturbation for the Sun, a Hall-effect ion thruster model with a 60-second warmup delay that actually matters for short burns, McInnes-model solar-sail radiation pressure with albedo, and an NRLMSISE-00 drag proxy.

Event detection, eclipse entry and exit, checkpoint arrival, surface impact, runs as a bisection search for zero-crossings between accepted integrator steps, resolving to sub-second precision without forcing the integrator onto a fixed grid. The precision matters specifically for eclipse-gated ion thrust. Get the eclipse boundary wrong by a few seconds across thousands of orbits and the propellant budget drifts in ways that are easy to miss and expensive to unwind later.

Guidance Cannot Reach Into Physics

The interface between the two layers is two tuples. A guidance law receives position and velocity, and returns a thrust direction and a throttle. Spacecraft.step does not know or care which guidance law produced the Action it is holding, and executes whatever it is given.

thrust_dir, throttle = guidance.steer(sc.state.position, sc.state.velocity)
sc.step(
    Action(attitude_dir_cmd=thrust_dir, thrust_dir=thrust_dir, throttle=throttle),
    dt_requested=60.0,
)

None of that is incidental encapsulation. Because physics does not know guidance exists, the guidance laws are interchangeable: five of them can be tested against one physics engine, instead of trusting five separate physics-plus-guidance systems independently.

Five concrete guidance laws ship in the repository, ranging from GVE-based feedback control to a prograde-burn baseline, and every one of them implements that same contract. None involves a training loop: state goes into a feedback function, the function returns a direction and a throttle, and the loop repeats. There is no policy to train and no learned behavior to trust on inputs it never saw. The specific control laws, including the Q-law formulation and its GVE and GTO variants, are documented in the repository.

A Validation Record That Names Its Own Limits

The validation record is more specific than a marketing sentence, and more honest than most.

The headline SMA-error number, 0.0112 percent in the most recent re-validation run, comes from replaying archival LightSail 2 TLEs through SGP4 to build an hourly reference trajectory, then propagating the same intervals through cislunar-sim’s full force model and comparing. The record is explicit about what that number does and does not establish: SGP4 propagation carries its own error, on the order of one to two kilometers a day for a low orbit, so this comparison measures agreement between the physics engine and SGP4, not agreement between the physics engine and raw telemetry. The claim is narrower than most validation write-ups make, and the library’s own documentation states it as a caveat rather than leaving a reader to work it out.

What the validation record actually shows

Result Value Source
SMA error (1-day archival epoch) 0.0113% bundled TLEs, make test
Radial error 0.007% of orbital radius bundled TLEs, make test
Sail force vs. McInnes (IKAROS params) within 1% bundled, make test
B* swing ratio, early vs. late window 0.231 observed vs. 0.228 predicted (confounded, see update) CelesTrak archive, make validate-external
Eclipse entry timing +3.7s mean, 19.5s std (29 usable entry windows) SatNOGS archive, make validate-external

The eclipse-timing row is described in the library’s own record as promising rather than conclusive, limited by beacon cadence rather than by the shadow model.

The largest single number in the record does not come from the bundled data at all. It comes from splitting the CelesTrak history into an early window and a late one and comparing the ratio of fitted B* drag terms: 0.231 observed against 0.228 predicted, a result that requires the external archive, which is why it sits behind make validate-external instead of the default test suite. When this note was published that agreement was described as the record’s strongest signal. It is not, and the update below explains why.

The record also documents where it falls short. Eclipse-timing validation against SatNOGS beacon frames is described in the library’s own documentation as promising, blocked by beacon-cadence heterogeneity: 77 usable transition windows out of several thousand frames, and those split 29 entries to 48 exits. The entry residuals are centered near zero but carry roughly 20 seconds of scatter at 45-second beacon cadence, which across 29 windows leaves a standard error of about 3.6 seconds on the mean. Not yet a precise validation, and the documentation says so rather than rounding up.

A recent re-validation run also disclosed a real bug: the solar-sail radiation-pressure model had been applying the absorptivity term to the wrong part of the force equation, understating net SRP force. The fix increased net SRP force by roughly 72 percent for LightSail 2’s sail parameters. The correction increased physical fidelity. It did not materially change the library’s principal validation claims, which is what a validation record is supposed to reveal.

One claim did later change, but not because of that fix. It was withdrawn on methodological grounds after this note was first published, and the update below sets out what went wrong and why the numbers themselves still stand.

What This Deliberately Does Not Do

A few decisions here will draw questions from people who work in orbital mechanics professionally, and they are worth addressing directly instead of leaving them as unstated defaults.

RK4(5) over a symplectic integrator. Long-duration propagation is a classic case for symplectic integrators, which conserve a shadow Hamiltonian and avoid the secular energy drift that general-purpose adaptive integrators can accumulate over very long horizons. cislunar-sim does not use one. Most of what this library targets is transfer trajectories measured in days to weeks, not multi-decade propagation, and an adaptive Dormand-Prince pair with tight tolerances is accurate enough over that horizon while being far simpler to extend. Adding a force model to a symplectic scheme usually means re-deriving the splitting; adding one here means writing an acceleration function. A use case needing multi-decade fidelity would be a real argument for a second integrator option, not a reason the first choice was wrong.

Q-law over indirect optimal control. GVE-based Q-law is a feedback law. It does not solve a two-point boundary value problem and does not require an initial costate guess, so it does not fail to converge the way indirect shooting methods can on a poor guess. It is also not provably time-optimal the way a correctly converged indirect solution is. For a simulation and guidance-research tool, where robustness and the ability to re-target mid-transfer matter more than shaving the last few percent off transfer time, that is the right trade. For a mission-design tool computing a single trajectory against a fixed, unforgiving propellant budget, it might not be.

Pure Python over a compiled core. GMAT and Orekit both made a different bet here for a reason: raw propagation speed matters when running large Monte Carlo dispersion sets. cislunar-sim’s answer is that a seven-day GTO periapsis raise and a five-day drag-decay case each complete in under a minute on ordinary hardware, which is fast enough for the research and validation use case this library targets. It is not fast enough to replace a compiled propagator in a Monte Carlo campaign with tens of thousands of runs, and it is not trying to be.

What is out of scope for this release. No N-body dynamics beyond Earth, Moon, and Sun. No relativistic corrections. No flight-software emulation: guidance laws run in the simulation loop, not on an emulated flight computer. Attitude dynamics are modeled, quaternion rigid body, reaction wheels, cold-gas RCS, but attitude is not yet a guidance target in its own right, only a consequence of the commanded thrust direction. Each of these is a real feature someone will eventually want, and none of them was free to skip or skipped by accident.

Architecture in most software is something you reconstruct from the source after the fact. Scientific software is worth the effort of stating it up front. Someone deciding whether to trust a number this library produces should be able to see where it came from, what it depends on, and what it does not claim, without reading the implementation first.

A companion piece on CTO Insights, Models Converge on Consensus, Not Correctness, examines a specific episode from building this: what happened when the guidance problem was handed to a language model, repeatedly, and the model kept reaching for the same answer regardless of how the question was framed. That piece is about the model, where this one is about the machine it was asked to help build.

Update - August 2026

This note called the B* swing the record’s strongest quantitative signal. That was wrong. The error was mine in both places: the note repeated a claim the library’s own validation record made, and I wrote both. Both have now been corrected, and the reasoning is worth stating here rather than quietly editing the table above.

The comparison split the CelesTrak archive into a “sailing” window and a “passive” one, the latter described as the period after the sail was furled. LightSail 2 never furled its sail. It deployed on 23 July 2019 and stayed deployed until reentry on 17 November 2022; no furling or retraction occurred at any point in the mission. What varied was attitude, not deployment state, so the physical contrast the comparison assumed does not exist.

The windows are also confounded. They are hardcoded date ranges that inspect no attitude or sail state, and they differ in solar activity (Cycle 25 minimum against the ramp) and in altitude, from near-initial down to reentry. Fitted B* is not a clean ballistic coefficient: it absorbs density-model error, and SGP4’s atmosphere is a fixed profile not driven by observed F10.7, so B* rises as real density outruns it. Altitude decay pushes the same way. Three candidate causes, all acting in the same direction, and the analysis separates none of them. The predicted ratio compounds this by being evaluated at a single fixed condition (720 km, F10.7 = 100) against an archive that runs to reentry.

Under those conditions, agreement of about a percent is better read as coincidence than as confirmation. The observed and predicted numbers are unchanged; what changes is the claim they support. The library’s validation record now carries the same correction and sets out what a controlled comparison would need: windows restricted to comparable solar flux and altitude, or fitted B* normalised against a contemporaneous non-sailing reference object.

A further problem surfaced while checking the archive counts. The two windows are not adjacent: December 2019 to March 2020 falls in neither, dropping 164 TLEs, 6% of the archive. Nothing in the code or the record explains the gap, and its effect is to remove the period in which any change in behaviour would actually appear, which makes a step between the windows look cleaner than the underlying series supports. The figure this note originally gave as the archive size, 2,438, was the number of records falling inside the two windows; the archive holds 2,680, and the 242 outside them are 164 in that gap, 68 dropped for reporting a negative ballistic coefficient, and 10 predating the early window.

That last exclusion matters more than it looks. A negative B* is what a fitter reports when an object gains orbital energy rather than losing it, and none of the 68 fall in the early window, so the filter acts on one side of the comparison only. Including them moves the observed ratio from 0.231 to 0.237 against 0.228 predicted, turning agreement of about a percent into about four. The published number depended on an undocumented one-sided exclusion whose effect nobody had measured.

One smaller correction in the table above: the eclipse-timing figures are entry-only statistics from 29 usable windows, not from all 77 entry and exit windows combined.

A note arguing that validation records should name their own limits is a poor place to leave one unnamed. The corrected record is in VALIDATION_RECORD.md, section 2. A companion field note, How Do You Validate a Trajectory Propagator?, works through the whole validation record on the same terms, including the six separate reasons the B* comparison established nothing.

cislunar-sim is available now on GitHub and PyPI under the MIT license.

Sean O'Hara

Sean O’Hara

Technology leader and Founder of Arbor Engineering Group. He writes about infrastructure, engineering organizations, and the decisions that compound quietly before they surface. Find him at CTO Insights on LinkedIn or on GitHub.