8.6 KiB
Propagation Call Chain Analysis
Session Date
2026-04-20
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 fromcheck_maneuver_trigger()— eliminates ~24k redundant Kepler solves per Hohmann transfer wait period - Removed dead
angle_between()static helper - Replaced the
angle_between,future_diff, andwraparound_crossingchecks with a cleanerdt_needed <= 0.0guard - 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.
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.
Call Chain: update_simulation()
update_simulation()
│
├── reset_spacecraft_tracking()
│
├── 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()
│
├── execute_pending_maneuvers()
│ └── for each unexecuted maneuver:
│ ├── check_maneuver_trigger()
│ │ ├── TRIGGER_TIME: sim->time >= trigger_value
│ │ └── TRIGGER_TRUE_ANOMALY:
│ │ ├── propagate_orbital_elements() ← per-frame probe
│ │ └── Kepler equation solving
│ └── if triggered:
│ ├── propagate_orbital_elements(dt_to_burn)
│ ├── execute_maneuver()
│ │ ├── apply_impulsive_burn()
│ │ └── cartesian_to_orbital_elements()
│ └── propagate_orbital_elements(remaining_dt)
│
├── update_spacecraft_physics()
│ └── for each craft NOT handled:
│ ├── orbital_elements_to_cartesian() → expected_vel
│ ├── velocity drift check
│ ├── cartesian_to_orbital_elements() (if drift)
│ └── propagate_orbital_elements()
│
├── 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.
// 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()
Location: src/simulation.cpp, execute_pending_maneuvers()
The function performs two distinct responsibilities:
- Checking trigger conditions (calls
check_maneuver_trigger()) - Executing the burn (propagation → burn → propagation)
void execute_pending_maneuvers(SimulationState* sim) {
for (...) {
if (check_maneuver_trigger(maneuver, craft, sim)) { // CHECKING
// ... propagation, burn, more propagation // EXECUTING
}
}
}
The trigger-checking logic (especially the true-anomaly branch with its Kepler equation solving) is interleaved with the execution logic. This makes it difficult to reason about what happens in each phase and complicates testing.
Issue 3: Ambiguous scheduled_dt Semantics
The scheduled_dt field in Maneuver has different meanings depending on trigger type:
| Trigger Type | scheduled_dt meaning |
Set by |
|---|---|---|
TRIGGER_TIME |
Always 0.0 (never set) | Never |
TRIGGER_TRUE_ANOMALY |
Seconds until exact burn position | check_maneuver_trigger() |
In execute_pending_maneuvers():
double dt_to_burn = maneuver->scheduled_dt;
if (dt_to_burn > 0.0) {
craft->orbit = propagate_orbital_elements(craft->orbit, dt_to_burn, ...);
}
// ... burn ...
double remaining_dt = sim->dt - dt_to_burn;
craft->orbit = propagate_orbital_elements(craft->orbit, remaining_dt, ...);
For TRIGGER_TIME, scheduled_dt == 0 is a coincidence — the field is never set. The code works, but the reason is opaque. The branching (if dt_to_burn > 0) exists only for true-anomaly triggers, but a reader cannot tell this from the code alone.
Issue 4: Time-Triggered Burns Propagate from Wrong State
For time triggers, the sequence is:
Frame N: sim->time = 310.0 (trigger_value = 305.0)
check: 310 >= 305 → true
dt_to_burn = 0
remaining_dt = 10
propagate craft by 10s starting from sim->time=310
The craft's orbit state is at sim->time=310, not at the trigger time 305. The burn fires 5s late and the post-burn propagation starts from the wrong orbital position. This is the root cause of the burn timing quantization problem documented in docs/planning/hohmann-rendezvous-quantization-fix.md.
Issue 5: Hardcoded Array Size
Location: src/simulation.cpp, static declaration
static bool spacecraft_handled_this_frame[256];
A global array with a hardcoded size of 256. If more than 256 spacecraft are added, the tracking silently truncates. It should be part of SimulationState or dynamically sized. There is already a FIXME comment on this line.
Issue 6: Duplicated Propagation Logic
The "normal" propagation path in update_spacecraft_physics() and the maneuver path in execute_pending_maneuvers() are essentially duplicates:
Normal path (update_spacecraft_physics):
propagate_orbital_elements(craft->orbit, sim->dt, ...);
orbital_elements_to_cartesian(...);
Maneuver path (execute_pending_maneuvers, when dt_to_burn == 0):
propagate_orbital_elements(craft->orbit, 0, ...); // no-op
orbital_elements_to_cartesian(...);
execute_maneuver(...);
propagate_orbital_elements(craft->orbit, sim->dt, ...);
orbital_elements_to_cartesian(...);
When no sub-step is needed, the maneuver path is: propagate → convert → burn → propagate → convert. The propagation and coordinate conversion logic is duplicated across both code paths.
Call Sites Summary
Before fix (5 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:343 |
execute_pending_maneuvers() |
Pre-burn sub-step propagation | dt_to_burn (0 to sim->dt) |
simulation.cpp:350 |
execute_pending_maneuvers() |
Post-burn remaining propagation | sim->dt - dt_to_burn |
maneuver.cpp:147 |
check_maneuver_trigger() |
True-anomaly look-ahead probe | sim->dt |
After fix (4 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:343 |
execute_pending_maneuvers() |
Pre-burn sub-step propagation | dt_to_burn (0 to sim->dt) |
simulation.cpp:350 |
execute_pending_maneuvers() |
Post-burn remaining propagation | sim->dt - dt_to_burn |
Remaining: 4 call sites, 2 distinct contexts for spacecraft (normal propagation, sub-step execution). Issue 6 (duplicated propagation) remains.