# Propagation Call Chain Analysis ## Session Date 2026-04-20 ## Objective Audit all call sites of `propagate_orbital_elements()` for spacecraft and trace the `update_simulation()` call chain through `execute_pending_maneuvers()` and `update_spacecraft_physics()` to identify inefficiencies and confusing branching. ## Recent Changes ### 2026-04-20: Eliminated Redundant Propagation in `check_maneuver_trigger()` Replaced the per-frame propagation probe in `check_maneuver_trigger()` (true-anomaly branch) with a direct analytical calculation using mean anomaly delta. **What changed:** - Removed `propagate_orbital_elements()` call from `check_maneuver_trigger()` — eliminates ~24k redundant Kepler solves per Hohmann transfer wait period - Removed dead `angle_between()` static helper - Replaced the `angle_between`, `future_diff`, and `wraparound_crossing` checks with a cleaner `dt_needed <= 0.0` guard - Added TODO comment noting elliptical-orbits-only limitation **Impact:** - Same correctness, fewer function calls, simpler logic - All 154 tests pass (240,445 assertions) - For a Hohmann transfer with ~244,000s wait time and DT=10s: ~24,400 fewer `propagate_orbital_elements()` calls **Remaining:** See Issue 1 below — still has a TODO for parabolic/hyperbolic orbit support. ### 2026-04-20: Merged Maneuver Execution into Single Propagation Pass Consolidated `execute_pending_maneuvers()` into `update_spacecraft_physics()` so every spacecraft goes through exactly one propagation path. **What changed:** - Removed `spacecraft_handled_this_frame[256]` static array (magic number) - Removed `reset_spacecraft_tracking()` function - Removed `execute_pending_maneuvers()` function - Check maneuver triggers before propagation in `update_spacecraft_physics` - Propagate to burn time, execute burn, propagate remainder (same order as old code) - Removed `execute_pending_maneuvers` declaration from `simulation.h` **Impact:** - Single propagation call per spacecraft per frame (was 2 separate code paths) - No static array with hardcoded size - Simpler call chain: no separate maneuver pass - Enables sub-step interpolation for true-anomaly triggers - Sets up foundation for interpolated time triggers **Remaining:** Adds TODO in `update_spacecraft_physics` about testing interpolated burns. --- ## Call Chain: `update_simulation()` (Current) ``` update_simulation() │ ├── update_bodies_physics() │ └── for each body: │ ├── find_dominant_body() │ ├── orbital_elements_to_cartesian() → expected_vel │ ├── velocity drift check (every frame) │ ├── cartesian_to_orbital_elements() (if drift > 1e-6) │ └── propagate_orbital_elements() │ ├── compute_global_coordinates() │ ├── update_spacecraft_physics() │ └── for each craft: │ ├── orbital_elements_to_cartesian() → expected_vel │ ├── velocity drift check │ ├── cartesian_to_orbital_elements() (if drift) │ ├── check_maneuver_trigger() ← inline, before propagation │ │ ├── TRIGGER_TIME: sim->time >= trigger_value │ │ └── TRIGGER_TRUE_ANOMALY: │ │ └── analytical mean anomaly delta (no propagation) │ └── if maneuver fired: │ │ ├── propagate_orbital_elements(burn_dt) │ │ ├── execute_maneuver() │ │ │ ├── apply_impulsive_burn() │ │ │ └── cartesian_to_orbital_elements() │ │ └── propagate_orbital_elements(remaining_dt) │ └── else: │ └── propagate_orbital_elements(sim->dt) │ ├── compute_spacecraft_globals() └── sim->time += sim->dt ``` ## Issues Found ### Issue 1: Redundant Propagation for True-Anomaly Triggers **Status:** RESOLVED — eliminated in favor of analytical mean anomaly delta calculation. **Location:** `src/maneuver.cpp`, `check_maneuver_trigger()` (true-anomaly branch) Previously, for every frame that a true-anomaly maneuver was pending, `propagate_orbital_elements()` was called as a "look-ahead" probe to determine if the target angle was approaching. ```cpp // OLD (maneuver.cpp:147) — REMOVED OrbitalElements future_elements = propagate_orbital_elements(craft->orbit, sim->dt, parent->mass); ``` **Resolution:** Replaced with direct analytical computation of `dt_needed` from mean anomaly delta. The analytical solution is more precise (no discretization error) and eliminates ~24k redundant Kepler solves per Hohmann transfer wait period at DT=10s. **Remaining:** The current implementation only handles elliptical orbits. TODO comment added for parabolic (Barker's equation) and hyperbolic branches. ### Issue 2: Mixed Concerns in `execute_pending_maneuvers()` **Status:** RESOLVED — merged into `update_spacecraft_physics()`. The function previously performed two distinct responsibilities: 1. **Checking** trigger conditions (calls `check_maneuver_trigger()`) 2. **Executing** the burn (propagation → burn → propagation) **Resolution:** Trigger checking now happens inline in `update_spacecraft_physics()` before the propagation decision. The code is cleaner: check → decide propagation amount → propagate → burn (if needed) → propagate remainder. ### Issue 3: Ambiguous `scheduled_dt` Semantics **Status:** RESOLVED — both trigger types now set `scheduled_dt` to the exact sub-step offset. | Trigger Type | `scheduled_dt` meaning | Set by | |-------------|----------------------|--------| | `TRIGGER_TIME` | `trigger_value - sim->time` (step start) | `check_maneuver_trigger()` | | `TRIGGER_TRUE_ANOMALY` | Seconds until exact burn position | `check_maneuver_trigger()` | In `update_spacecraft_physics()`: ```cpp if (maneuver_fired) { craft->orbit = propagate_orbital_elements(craft->orbit, burn_dt, ...); execute_maneuver(fired_maneuver, ...); craft->orbit = propagate_orbital_elements(craft->orbit, remaining_dt, ...); } ``` Both trigger types now produce the same `scheduled_dt` semantics: seconds from step start until the burn executes. The only difference is how that value is computed (simple subtraction vs. mean anomaly propagation). ### Issue 4: Time-Triggered Burns Propagate from Wrong State **Status:** RESOLVED — sub-step interpolation implemented (2026-04-26). For time triggers, the sequence is now: ``` Frame N: sim->time = 310.0 (trigger_value = 305.0) check: sim->time >= trigger_value → true dt_to_burn = trigger_value - (sim->time - sim->dt) = 305 - 300 = 5.0 burn_dt = 5.0 remaining_dt = 5.0 propagate(5.0) → craft at trigger position execute_maneuver() → burn applies at sim->time=305 propagate(5.0) → continue simulation ``` The spacecraft propagates to the exact trigger position before the burn, then continues for the remaining timestep. This eliminates the burn timing quantization problem documented in `docs/planning/hohmann-rendezvous-quantization-fix.md`. **Edge case:** When `sim->time > trigger_value` (trigger passed in a previous step), `scheduled_dt` is clamped to 0 and the burn fires immediately at the current position. ### Issue 5: Hardcoded Array Size **Status:** RESOLVED — removed `spacecraft_handled_this_frame[256]` static array. **Location:** Previously `src/simulation.cpp`, static declaration ```cpp // REMOVED static bool spacecraft_handled_this_frame[256]; ``` **Resolution:** The array is no longer needed since maneuver checking is inline in the propagation loop. No separate tracking mechanism required. ### Issue 6: Duplicated Propagation Logic **Status:** RESOLVED — single propagation path. The "normal" propagation path and the maneuver path are now the same code path: ```cpp if (maneuver_fired) { craft->orbit = propagate_orbital_elements(craft->orbit, burn_dt, ...); execute_maneuver(...); craft->orbit = propagate_orbital_elements(craft->orbit, remaining_dt, ...); } else { craft->orbit = propagate_orbital_elements(craft->orbit, sim->dt, ...); } ``` When `burn_dt == 0` and `remaining_dt == sim->dt`, the maneuver path is: propagate(0) → burn → propagate(sim->dt). The normal path is: propagate(sim->dt). The propagation logic is no longer duplicated. ## Call Sites Summary ### Final State (3 call sites) | Location | Function | Context | dt value | |----------|----------|---------|----------| | `simulation.cpp:287` | `update_bodies_physics()` | Normal body propagation | `sim->dt` | | `simulation.cpp:315` | `update_spacecraft_physics()` | Normal craft propagation | `sim->dt` | | `simulation.cpp:338` | `update_spacecraft_physics()` | Pre-burn sub-step propagation | `burn_dt` (0 to `sim->dt`) | | `simulation.cpp:343` | `update_spacecraft_physics()` | Post-burn remaining propagation | `sim->dt - burn_dt` | Remaining: 4 call sites (1 for bodies, 3 for spacecraft), 2 distinct contexts for spacecraft (normal propagation, sub-step execution). ## Remaining Work ### Parabolic/Hyperbolic Orbit Support (TODO in code) The analytical `dt_needed` calculation in `check_maneuver_trigger()` only handles elliptical orbits. TODO comment added for: - Parabolic: Barker's equation `D + D³/3 = M`, where `D = tan(ν/2)` - Hyperbolic: `e·sinh(H) - H = M`, using hyperbolic anomaly `H` ### Code Path Unification (TODO in maneuver.cpp) A TODO was added to combine the TRIGGER_TIME and TRIGGER_TRUE_ANOMALY branches in `check_maneuver_trigger()` into a single code path. Both now compute `scheduled_dt` the same way (sub-step offset), so the logic can be unified. The main complication is maneuver array ordering: when an earlier maneuver fires and changes the orbit, cached trigger times for later maneuvers become stale and must be recomputed.