Parametric Maneuver Simulation Test Harness
Overview
Unit tests mock state per-phase and cannot catch cross-phase integration bugs
(e.g., fuel exhaustion across escape → interplanetary → capture). The simulation
harness runs the real AutomationEngine in a closed loop with in-process
Keplerian physics, testing all 240 valid rendezvous strategy/target/SOI
combinations plus landing scenarios.
Architecture
ManeuverSimulation (harness.py) — tick loop
├── AutomationEngine (production code, unmodified)
│ reads state ──→ DictBackedState (state.py) — in-memory
│ sends gRPC ──→ SteeringCapture (physics.py) — captures commands
├── KeplerianPhysics (physics.py) — integrates between ticks
└── ScenarioConfig (scenarios.py) — defines initial conditions
Tick Loop Flow (Sub-Stepping)
Matches the real tick_loop.py sub-stepping: effective_dt is split into
sub-steps of at most MAX_SUB_DT (10s). Each sub-step runs:
physics.step_ship()— apply thrust, consume fuel, leapfrog-integratephysics.propagate_bodies()— Kepler-propagate celestial bodies- Propagate target entity (station/ship co-orbiting its parent)
- Advance game time by
sub_dt engine.evaluate_all_ships(tick)— reads state, runs phase handlers, sends steering commands- Check termination (maneuver cleared = complete)
Key Design Decision — Instant Attitude Alignment
Phase handlers set ATTITUDE_DIRECTION with a direction vector. The harness
assumes instant alignment (ship attitude quaternion always matches desired
direction). This tests automation logic, not the attitude controller.
Components
DictBackedState (state.py)
In-memory implementation of TickEngineState’s 19 async methods + 1 property.
All dict values are strings (matching Redis hgetall format). Stores ships,
bodies, stations, jumpgates, rules, maneuvers, and archives.
SteeringCapture (physics.py)
Mock gRPC stub replacing PhysicsStub. Captures SetSteeringCommand requests
(attitude_mode, direction, thrust_level, attitude_hold, translation) and
RequestService requests (docking). No network I/O.
KeplerianPhysics (physics.py)
Between-tick integration:
step_ship(): Resolve thrust direction from captured steering state, compute thrust + fuel consumption (matchingsimulation.py), leapfrog integrate, instant attitude alignment, ground detection + AGL computation, update ship dict.propagate_bodies(): Kepler-propagate each body relative to its parent using universal variable formulation (kepler_propagatefromqlaw.py). Uses snapshot-based propagation to prevent sequential-update drift (child bodies compute relative state from pre-update parent positions).
Supported attitude modes: PROGRADE, RETROGRADE, NORMAL, ANTINORMAL, RADIAL, ANTIRADIAL, DIRECTION, HOLD, TARGET_RETROGRADE, SURFACE_RETROGRADE (subtracts body rotation velocity), LOCAL_VERTICAL (points away from body center).
Ground detection: after position integration, computes AGL to all bodies. If
AGL ≤ 0, clamps ship to body surface, zeros velocity, sets landed_body_name.
ScenarioConfig (scenarios.py)
Defines initial conditions for each test scenario:
maneuver_type:"rendezvous"(default) or"land"action_params: extra params liketarget_body,target_lat,target_lontarget_orbit: optional (not used for landing scenarios)
Provides generate_matrix() producing 240 rendezvous combinations,
SMOKE_SCENARIOS for full end-to-end rendezvous runs, LANDING_SCENARIOS
for landing phase tests, and LANDING_SMOKE_SCENARIOS for full landing runs.
ManeuverSimulation (harness.py)
Orchestrates the tick loop. Patches PhysicsStub → SteeringCapture, creates
AutomationEngine, runs tick loop, returns SimResult with outcome, tick
count, phase history, fuel remaining, and final distance/velocity.
Branches rule creation on maneuver_type: rendezvous builds target entity +
rendezvous action, landing builds land action from action_params. Celestial
bodies include rotation parameters (rotation_period, prime_meridian_at_epoch)
needed by the landing automation’s body-frame coordinate transforms.
RvzSimulation is a backward-compatible alias for ManeuverSimulation.
240-Combination Matrix
| Dimension | Values | Count |
|---|---|---|
| Strategy (non-brach) | hohmann, fast, express, parabolic, lambert | 5 |
| Strategy (brach) | brachistochrone | 1 |
| Target type | station, ship, jumpgate, lagrange | 4 |
| SOI type | same, cross | 2 |
| Budget mode (brach only) | efficient, fastest, None + 2 fuel fractions | 5 |
| Ship class | cargo_hauler, fast_frigate, long_range_explorer | 3 |
Non-brachistochrone: 5 × 4 × 2 × 1 × 3 = 120 Brachistochrone: 1 × 4 × 2 × 5 × 3 = 120 Total: 240 scenarios
Landing Scenarios
| Scenario | Ship Class | Target Body | Type | Start Orbit |
|---|---|---|---|---|
land_luna_untargeted_frigate |
fast_frigate | Luna | untargeted | 100 km lunar |
land_luna_targeted_frigate |
fast_frigate | Luna | targeted (0,0) | 100 km lunar |
land_earth_untargeted_hauler |
cargo_hauler | Earth | untargeted | 400 km LEO |
Landing phases (untargeted): deorbit → braking → vertical_descent → terminal → touchdown Landing phases (targeted): plane_change → coast_to_deorbit → deorbit → braking → vertical_descent → terminal → touchdown
Test Layers (Four Layers)
Layer 1 — Phase-Transition Tests (240 scenarios)
Run each scenario for 200 ticks. Verify at least one phase transition occurs (automation engine starts processing the maneuver).
- Runtime: ~24s (200 ticks × ~0.5ms/tick × 240)
- Runs: Every PR
Layer 1b — Landing Phase-Transition Tests (3 scenarios)
Run each landing scenario for 500 ticks. Verify at least one phase transition occurs (automation engine starts processing the landing maneuver).
- Runtime: ~1s
- Runs: Every PR
Layer 2 — Full-Scenario Smoke Tests (~12 rendezvous + 1 landing, @pytest.mark.slow)
Run representative scenarios to completion. Verify outcome == "completed" and
fuel_remaining >= 0.
- Runtime: ~120s (rendezvous) + ~1s (landing)
- Runs: On demand / nightly
Layer 3 — Regression Tests (specific failure modes)
- Demon fuel exhaustion: brachistochrone fastest cross-SOI must not exhaust fuel
- Insufficient Δv abort: 100 kg fuel cross-SOI should abort cleanly
-
Cargo hauler capture fuel: low-TWR ship must reserve capture fuel
- Runtime: ~8s
- Runs: Every PR
Runtime Budget
| Layer | Count | Ticks/test | Time/tick | Total |
|---|---|---|---|---|
| Rendezvous phase-transition | 240 | 200 | ~0.5ms | ~24s |
| Landing phase-transition | 3 | 500 | ~0.5ms | ~1s |
| Full smoke (rendezvous) | 12 | ~20k avg | ~0.5ms | ~120s |
| Full smoke (landing) | 1 | ~663 | ~0.5ms | ~1s |
| Regression | 3 | ~5k avg | ~0.5ms | ~8s |
Phase-transition + regression = ~33s (every PR).
Full smoke = ~121s (marked @pytest.mark.slow).
Standard Orbits
- Same-SOI: Ship at 400 km LEO (Earth), target at 35,786 km GEO
- Cross-SOI: Ship at 400 km LEO (Earth), target at 100 km lunar orbit
Differences: Harness vs Real Game
| Aspect | Harness | Real Game |
|---|---|---|
| Attitude control | Instant alignment — quaternion set to target direction each step | PD controller with ~8s settling time, cosine-scaled thrust during slew |
| Body propagation | Kepler two-body (universal variable) — exact per parent | Leapfrog N-body — bodies experience mutual perturbations |
| Ship gravity | Full N-body from all bodies | Full N-body from all bodies |
| Network | In-process mock gRPC (SteeringCapture) | Real gRPC to physics service |
| State storage | In-memory dicts (DictBackedState) | Redis hashes |
| Time scale | Fixed effective_dt per scenario |
Configurable, dynamic time scale |
| Thrust direction | Applied in commanded direction (ATTITUDE_DIRECTION) | Applied in ship’s actual +Z (body frame forward) |
Known Limitations
Vis-viva circularize divergence: The apse-targeted circularize phase (used by Hohmann, Lambert, and other two-impulse strategies) can diverge in the harness. The vis-viva formula assumes pure two-body dynamics, but the harness integrates with full N-body gravity. Over many apse burns, small perturbations accumulate and the orbit goes hyperbolic, triggering regression stall detection. This causes the automation to loop through circularize → phase → transfer_plan indefinitely.
This does NOT affect brachistochrone scenarios (constant-thrust, no apse targeting) or the 240 phase-transition tests (only 200 ticks, too short for divergence). The smoke scenarios use brachistochrone exclusively.
These limitations reflect harness physics fidelity, not automation bugs. The real game’s physics service handles these cases correctly because the integration and control loop are tightly coupled.