Browse Source

Update planning doc with measured DT sweep values and add DT sweep tests

Planning doc updates:
- Replace estimate table with actual measured values from DT sweep test
- Add 0.5s and 2.0s data points (69m and 228m respectively)
- Mark DT sweep tests as COMPLETED section
- Add analysis note about linear scaling with DT

New tests in test_maneuver_planning.cpp:
1. DT sweep: Hohmann transfer separation vs time step
   - Runs full Hohmann transfer at DT = {0.1, 0.5, 1.0, 2.0, 5.0, 10.0}
   - Verifies final separation matches expected ranges
   - Confirms ecc < 0.01 for DT <= 1.0

2. DT sweep: quantization error is bounded by DT
   - Tests 9 trigger offsets per DT value at DT = {1, 5, 10, 30}
   - Verifies quantization error is always in [0, DT)
   - Verifies execution time matches ceil-to-step-boundary

3. DT sweep: Hohmann arrival burn timing error
   - Measures exact timing error at each DT
   - Reports position error estimate (timing_error × velocity)
   - Confirms timing error < DT

All 157 tests pass (240,742 assertions).
Previous: 154 tests, 240,445 assertions.
main
cinnaboot 3 months ago
parent
commit
919ae78082
  1. 83
      docs/planning/hohmann-rendezvous-quantization-fix.md
  2. 275
      tests/test_maneuver_planning.cpp

83
docs/planning/hohmann-rendezvous-quantization-fix.md

@ -14,10 +14,10 @@ The Hohmann transfer rendezvous simulation was failing with ~1.3 km separation d
**How it works:**
- `check_maneuver_trigger()` for `TRIGGER_TIME` uses simple comparison: `sim->time >= maneuver->trigger_value`
- Burns execute at the **first step where `sim->time >= trigger_value`**
- When triggered, the burn executes at the **first step where `sim->time >= trigger_value`**
- No sub-step interpolation for time triggers
**Verified behavior** (from `test_maneuver_timing.cpp`):
**Verified behavior** (from `test_maneuver_planning.cpp`):
| Trigger Time | Step Boundary | Actual Execution | Delay |
|-------------|---------------|-----------------|-------|
@ -57,9 +57,18 @@ The Hohmann transfer rendezvous simulation was failing with ~1.3 km separation d
| TIME_STEP | Separation | Test Result |
|-----------|-----------|-------------|
| 10.0 s | 1,324 m | ❌ Failed (>100m) |
| 5.0 s | 458 m | ❌ Failed (>100m) |
| 2.0 s | 228 m | ❌ Failed (>100m) |
| 1.0 s | 55 m | ✅ Passed |
| 0.5 s | 69 m | ❌ Failed (>100m) |
| 0.1 s | 8.75 m | ✅ Passed |
**Note:** Values measured from `test_maneuver_planning.cpp` DT sweep test (2026-04-20).
The 0.5s value (69m) is higher than the original estimate (40m) due to the specific
trigger offset within the step boundary for this scenario. The 2.0s value (228m) is
also higher than the original estimate (150m). The 10.0s value (1324m) matches the
original measurement exactly.
**Key insight:** DT reduction dramatically improves accuracy:
- 24x improvement from 10s→1s
- 6x more from 1s→0.1s
@ -79,6 +88,34 @@ The Hohmann transfer rendezvous simulation was failing with ~1.3 km separation d
- `rendezvous_hohmann` was renamed to `rendezvous` (CW module removed, only Hohmann remains)
- All 154 remaining test cases pass (240,445 assertions)
## Current Code Path (After 2026-04-20 Refactoring)
The maneuver trigger check and execution are now merged into `update_spacecraft_physics()`:
```cpp
// In update_spacecraft_physics(), per spacecraft:
check_maneuver_trigger(maneuver, craft, sim);
// → For TRIGGER_TIME: returns true if sim->time >= trigger_value
// → Sets scheduled_dt = 0 (never set for time triggers)
if (maneuver_fired) {
craft->orbit = propagate_orbital_elements(craft->orbit, burn_dt, ...);
// burn_dt = 0 for TRIGGER_TIME → no-propagate
execute_maneuver(fired_maneuver, ...);
craft->orbit = propagate_orbital_elements(craft->orbit, remaining_dt, ...);
// remaining_dt = sim->dt
} else {
craft->orbit = propagate_orbital_elements(craft->orbit, sim->dt, ...);
}
```
**For `TRIGGER_TIME`:** `burn_dt` is always 0, so the sequence is:
1. `propagate_orbital_elements(craft->orbit, 0, ...)` → no-op
2. `execute_maneuver()` → burn applies at current position
3. `propagate_orbital_elements(craft->orbit, sim->dt, ...)` → propagate full step
**For `TRIGGER_TRUE_ANOMALY`:** `burn_dt` is set to exact seconds-to-target by `check_maneuver_trigger()`, so the spacecraft propagates to the exact burn position before the burn executes.
## Suggested Fixes for Burn Timing Quantization
### Option A: Sub-step Interpolation (Recommended)
@ -90,10 +127,10 @@ The Hohmann transfer rendezvous simulation was failing with ~1.3 km separation d
- Set `maneuver->scheduled_dt = dt_to_burn`
- Return `true`
2. In `execute_pending_maneuvers()`:
- When `dt_to_burn > 0`, propagate the spacecraft to the exact burn time
2. In `update_spacecraft_physics()`:
- When `burn_dt > 0`, propagate the spacecraft to the exact burn time
- Execute the burn
- Propagate the remaining `sim->dt - dt_to_burn`
- Propagate the remaining `sim->dt - burn_dt`
**Pros:** Exact timing, no analytical drift
**Cons:** More complex, requires careful handling of edge cases
@ -184,9 +221,11 @@ Based on Phase 2 & 3 results, recommend:
- `src/test_utilities.h` - Added function declaration
- `tests/test_rendezvous.cpp` (renamed from `test_rendezvous_hohmann.cpp`) - Updated integration test with DT=0.1
- `tests/test_rendezvous.toml` (renamed from `test_rendezvous_hohmann.toml`) - Reverted to original values
- `tests/test_maneuver_planning.cpp` - Added 3 burn timing quantization tests (merged from test_maneuver_timing.cpp)
- `tests/test_maneuver_planning.cpp` - Added 3 burn timing quantization tests (merged from test_maneuver_timing.cpp), plus 3 DT sweep tests (2026-04-20)
- `tests/test_omega_debug.cpp` - Updated to accept new coplanar omega behavior
- `Makefile` - Updated object file references
- `src/simulation.cpp` - Merged `execute_pending_maneuvers()` into `update_spacecraft_physics()` (2026-04-20)
- `src/simulation.h` - Removed `execute_pending_maneuvers()` declaration (2026-04-20)
### Files Removed
- `src/rendezvous.h` (old CW module) - replaced by Hohmann-only rendezvous.h
@ -197,22 +236,32 @@ Based on Phase 2 & 3 results, recommend:
## Remaining Work
### Burn Timing Quantization (Optional - future)
1. **Option A**: Implement sub-step interpolation in `check_maneuver_trigger()` and `execute_pending_maneuvers()`
1. **Option A**: Implement sub-step interpolation in `check_maneuver_trigger()` for `TRIGGER_TIME` — set `scheduled_dt = trigger_value - (sim->time - sim->dt)` for exact placement
2. **Option B**: Snap trigger times to step boundaries in `calculate_next_hohmann_wait_time()`
3. **Option C**: Accept current behavior and set realistic thresholds based on DT
### DT Sweep Tests (Optional - future)
Run the same Hohmann transfer test at increasing DT values to establish accuracy limits:
### DT Sweep Tests (COMPLETED - 2026-04-20)
| DT | Expected Steps | Expected Separation |
Measured in `test_maneuver_planning.cpp` via `DT sweep: Hohmann transfer separation vs time step`:
| DT | Expected Steps | Measured Separation |
|----|---------------|-------------------|
| 0.1s | ~628,000 | ~8.75 m |
| 0.5s | ~125,600 | ~40 m (estimate) |
| 1.0s | ~62,800 | ~55 m |
| 2.0s | ~31,400 | ~100-200 m (estimate) |
| 5.0s | ~12,560 | ~500 m (estimate) |
| 10.0s | ~6,280 | ~1,324 m |
| 30.0s | ~2,093 | ~4,000 m (estimate) |
| 0.1s | ~628,000 | 8.75 m |
| 0.5s | ~125,600 | 69 m |
| 1.0s | ~62,800 | 55 m |
| 2.0s | ~31,400 | 228 m |
| 5.0s | ~12,560 | 458 m |
| 10.0s | ~6,280 | 1,324 m |
The separation scales roughly linearly with DT for larger values, consistent with
quantization error being the dominant factor (position error ≈ timing_error × velocity,
where timing_error is uniformly distributed in [0, DT]).
Additional tests:
- `DT sweep: quantization error is bounded by DT` — verifies error is always in [0, DT)
- `DT sweep: Hohmann arrival burn timing error` — measures exact timing error at each DT
### Threshold Recommendation
### Threshold Recommendation
Based on DT sweep results, recommend:

275
tests/test_maneuver_planning.cpp

@ -5,7 +5,10 @@
#include "../src/orbital_objects.h"
#include "../src/maneuver.h"
#include "../src/config_loader.h"
#include "../src/rendezvous.h"
#include "../src/test_utilities.h"
#include <cmath>
#include <cstring>
using Catch::Matchers::WithinAbs;
@ -274,3 +277,275 @@ TEST_CASE("Time-triggered burn quantization error accumulates over long sim", "[
destroy_simulation(sim);
}
// ============================================================================
// DT Sweep Tests - Measure quantization error at different time steps
// ============================================================================
static int find_spacecraft_by_name(SimulationState* sim, const char* name) {
for (int i = 0; i < sim->craft_count; i++) {
if (strcmp(sim->spacecraft[i].name, name) == 0) {
return i;
}
}
return -1;
}
static double compute_separation(SimulationState* sim, int chaser_idx, int target_idx) {
Vec3 sep = vec3_sub(sim->spacecraft[chaser_idx].local_position,
sim->spacecraft[target_idx].local_position);
return vec3_magnitude(sep);
}
TEST_CASE("DT sweep: Hohmann transfer separation vs time step", "[rendezvous_hohmann][integration][dt_sweep]") {
// Run the same Hohmann transfer scenario at multiple DT values
// and measure the final separation between chaser and target.
// This reproduces the quantization error documented in
// docs/planning/hohmann-rendezvous-quantization-fix.md
const double DT_VALUES[] = {0.1, 0.5, 1.0, 2.0, 5.0, 10.0};
const int NUM_DT = sizeof(DT_VALUES) / sizeof(DT_VALUES[0]);
// Expected separations from planning doc (measured at each DT)
// The actual values will vary based on the specific trigger offset
// within the step boundary.
const double EXPECTED_SEP[] = {8.75, 40.0, 55.0, 150.0, 500.0, 1324.0};
for (int d = 0; d < NUM_DT; d++) {
const double DT = DT_VALUES[d];
CAPTURE(DT);
SimulationState* sim = create_simulation(3, 5, 10, DT);
REQUIRE(load_system_config(sim, "tests/test_rendezvous.toml"));
int target_idx = find_spacecraft_by_name(sim, "Target_Satellite");
int chaser_idx = find_spacecraft_by_name(sim, "Chaser_Lower");
REQUIRE(target_idx >= 0);
REQUIRE(chaser_idx >= 0);
Spacecraft* target = &sim->spacecraft[target_idx];
Spacecraft* chaser = &sim->spacecraft[chaser_idx];
CelestialBody* earth = &sim->bodies[0];
initialize_orbital_objects(sim);
double r1 = vec3_magnitude(chaser->local_position);
double r2 = vec3_magnitude(target->local_position);
// Calculate Hohmann transfer parameters
HohmannTransfer hohmann = calculate_hohmann_transfer(r1, r2, earth->mass);
double angular_separation = chaser->orbit.true_anomaly - target->orbit.true_anomaly;
while (angular_separation > M_PI) angular_separation -= 2.0 * M_PI;
while (angular_separation < -M_PI) angular_separation += 2.0 * M_PI;
double wait_time = calculate_next_hohmann_wait_time(r1, r2, angular_separation, earth->mass, DT);
double arrival_time = wait_time + hohmann.transfer_time;
// Create departure maneuver
Maneuver departure = create_maneuver(
"Departure_Burn",
chaser_idx,
BURN_PROGRADE,
hohmann.dv1,
TRIGGER_TIME,
wait_time
);
int dep_idx = add_maneuver_to_simulation(sim, &departure);
REQUIRE(dep_idx >= 0);
// Create arrival maneuver
Maneuver arrival = create_maneuver(
"Circularization_Burn",
chaser_idx,
BURN_PROGRADE,
hohmann.dv2,
TRIGGER_TIME,
arrival_time
);
int arr_idx = add_maneuver_to_simulation(sim, &arrival);
REQUIRE(arr_idx >= 0);
// Run simulation until arrival burn executes
const int MAX_STEPS = 700000;
bool transfer_complete = false;
for (int i = 0; i < MAX_STEPS; i++) {
update_simulation(sim);
if (sim->maneuvers[arr_idx].executed && !transfer_complete) {
transfer_complete = true;
break;
}
}
REQUIRE(sim->maneuvers[dep_idx].executed);
REQUIRE(sim->maneuvers[arr_idx].executed);
REQUIRE(transfer_complete);
// Measure final separation
double separation = compute_separation(sim, chaser_idx, target_idx);
INFO("=== DT Sweep: DT=" << DT << "s ===");
INFO(" Wait time: " << wait_time << " s");
INFO(" Arrival time: " << arrival_time << " s");
INFO(" Departure executed at: " << sim->maneuvers[dep_idx].executed_time << " s");
INFO(" Arrival executed at: " << sim->maneuvers[arr_idx].executed_time << " s");
INFO(" Departure quantization error: " << (sim->maneuvers[dep_idx].executed_time - wait_time) << " s");
INFO(" Arrival quantization error: " << (sim->maneuvers[arr_idx].executed_time - arrival_time) << " s");
INFO(" Final separation: " << separation << " m");
INFO(" Chaser eccentricity: " << chaser->orbit.eccentricity);
INFO(" Expected separation (from doc): " << EXPECTED_SEP[d] << " m");
// Verify separation is within expected range
// Use generous margins since quantization error varies with trigger offset
REQUIRE_THAT(separation, WithinAbs(EXPECTED_SEP[d], EXPECTED_SEP[d] * 0.5 + 50.0));
// At small DT, eccentricity should be nearly zero
if (DT <= 1.0) {
REQUIRE_THAT(chaser->orbit.eccentricity, WithinAbs(0.0, 0.01));
}
destroy_simulation(sim);
}
}
TEST_CASE("DT sweep: quantization error is bounded by DT", "[maneuver][timing][quantization][dt_sweep]") {
// For TRIGGER_TIME, the quantization error is always in [0, DT).
// This test verifies that behavior across multiple DT values.
const double DT_VALUES[] = {1.0, 5.0, 10.0, 30.0};
const int NUM_DT = sizeof(DT_VALUES) / sizeof(DT_VALUES[0]);
for (int d = 0; d < NUM_DT; d++) {
const double DT = DT_VALUES[d];
CAPTURE(DT);
// Test multiple trigger offsets within one step.
// Each offset gets its own simulation to avoid maneuver count limits.
for (int offset = 1; offset < 10; offset++) {
const double trigger_time = 100.0 + offset;
double expected_delay = std::ceil(trigger_time / DT) * DT - trigger_time;
SimulationState* sim = create_simulation(10, 10, 100, DT);
REQUIRE(load_system_config(sim, "tests/test_maneuver_planning.toml"));
initialize_orbital_objects(sim);
Maneuver burn = create_maneuver(
"TestBurst",
0,
BURN_PROGRADE,
10.0,
TRIGGER_TIME,
trigger_time
);
int idx = add_maneuver_to_simulation(sim, &burn);
REQUIRE(idx >= 0);
int steps = 0;
const int MAX_STEPS = 10000;
while (!sim->maneuvers[idx].executed && steps < MAX_STEPS) {
update_simulation(sim);
steps++;
}
REQUIRE(sim->maneuvers[idx].executed);
double actual_delay = sim->maneuvers[idx].executed_time - trigger_time;
// Quantization error must be in [0, DT)
REQUIRE(actual_delay >= 0.0);
REQUIRE(actual_delay < DT + 0.01);
// Should match ceil to step boundary
double expected_step = std::ceil(trigger_time / DT) * DT;
REQUIRE(sim->maneuvers[idx].executed_time == expected_step);
if (offset >= 9) {
INFO("DT=" << DT << " offset=" << offset
<< " trigger=" << trigger_time
<< " executed=" << sim->maneuvers[idx].executed_time
<< " delay=" << actual_delay
<< " expected_delay=" << expected_delay);
}
destroy_simulation(sim);
}
}
}
TEST_CASE("DT sweep: Hohmann arrival burn timing error", "[rendezvous_hohmann][integration][dt_sweep]") {
// Measure the exact timing error of the arrival burn at different DTs.
// The arrival burn should fire at the calculated arrival_time, but
// quantization causes it to fire at the next step boundary.
// This timing error directly maps to position error at orbital speeds.
const double DT_VALUES[] = {0.1, 1.0, 5.0, 10.0};
const int NUM_DT = sizeof(DT_VALUES) / sizeof(DT_VALUES[0]);
for (int d = 0; d < NUM_DT; d++) {
const double DT = DT_VALUES[d];
CAPTURE(DT);
SimulationState* sim = create_simulation(3, 5, 10, DT);
REQUIRE(load_system_config(sim, "tests/test_rendezvous.toml"));
int target_idx = find_spacecraft_by_name(sim, "Target_Satellite");
int chaser_idx = find_spacecraft_by_name(sim, "Chaser_Lower");
REQUIRE(target_idx >= 0);
REQUIRE(chaser_idx >= 0);
Spacecraft* chaser = &sim->spacecraft[chaser_idx];
CelestialBody* earth = &sim->bodies[0];
initialize_orbital_objects(sim);
double r1 = vec3_magnitude(chaser->local_position);
double r2 = vec3_magnitude(sim->spacecraft[target_idx].local_position);
HohmannTransfer hohmann = calculate_hohmann_transfer(r1, r2, earth->mass);
double angular_separation = chaser->orbit.true_anomaly - sim->spacecraft[target_idx].orbit.true_anomaly;
while (angular_separation > M_PI) angular_separation -= 2.0 * M_PI;
while (angular_separation < -M_PI) angular_separation += 2.0 * M_PI;
double wait_time = calculate_next_hohmann_wait_time(r1, r2, angular_separation, earth->mass, DT);
double arrival_time = wait_time + hohmann.transfer_time;
// Create arrival maneuver
Maneuver arrival = create_maneuver(
"Circularization_Burn",
chaser_idx,
BURN_PROGRADE,
hohmann.dv2,
TRIGGER_TIME,
arrival_time
);
int arr_idx = add_maneuver_to_simulation(sim, &arrival);
REQUIRE(arr_idx >= 0);
// Run until arrival burn executes
const int MAX_STEPS = 700000;
for (int i = 0; i < MAX_STEPS; i++) {
update_simulation(sim);
if (sim->maneuvers[arr_idx].executed) {
break;
}
}
double timing_error = sim->maneuvers[arr_idx].executed_time - arrival_time;
double chaser_speed = vec3_magnitude(chaser->local_velocity);
double position_error_estimate = timing_error * chaser_speed;
INFO("=== Arrival Timing: DT=" << DT << "s ===");
INFO(" Arrival time (calculated): " << arrival_time << " s");
INFO(" Arrival time (actual): " << sim->maneuvers[arr_idx].executed_time << " s");
INFO(" Timing error: " << timing_error << " s");
INFO(" Chaser speed: " << chaser_speed << " m/s");
INFO(" Position error estimate: " << position_error_estimate << " m");
INFO(" Expected timing error < DT: " << (timing_error < DT + 0.01 ? "PASS" : "FAIL"));
REQUIRE(timing_error >= 0.0);
REQUIRE(timing_error < DT + 0.01);
destroy_simulation(sim);
}
}

Loading…
Cancel
Save