diff --git a/src/maneuver.h b/src/maneuver.h index 8334a07..38bff7b 100644 --- a/src/maneuver.h +++ b/src/maneuver.h @@ -21,6 +21,14 @@ enum TriggerType { TRIGGER_TRUE_ANOMALY }; +// State vectors captured at the exact moment a burn fires +struct BurnResult { + bool valid; + Vec3 position; + Vec3 velocity; + double true_anomaly; +}; + struct Maneuver { char name[64]; int craft_index; @@ -31,6 +39,7 @@ struct Maneuver { double scheduled_dt; bool executed; double executed_time; + BurnResult burn_result; }; struct HohmannTransfer { diff --git a/src/simulation.cpp b/src/simulation.cpp index d10ce13..8cc9df1 100644 --- a/src/simulation.cpp +++ b/src/simulation.cpp @@ -324,6 +324,13 @@ void update_spacecraft_physics(SimulationState* sim) { craft->orbit = propagate_orbital_elements(craft->orbit, burn_dt, parent->mass); orbital_elements_to_cartesian(craft->orbit, parent->mass, &craft->local_position, &craft->local_velocity); + // Capture exact pre-burn state for test assertions + fired_maneuver->burn_result = {}; + fired_maneuver->burn_result.valid = true; + fired_maneuver->burn_result.position = craft->local_position; + fired_maneuver->burn_result.velocity = craft->local_velocity; + fired_maneuver->burn_result.true_anomaly = craft->orbit.true_anomaly; + double burn_time = sim->time + burn_dt; execute_maneuver(fired_maneuver, craft, sim, burn_time); diff --git a/tests/test_periapsis_burn.cpp b/tests/test_periapsis_burn.cpp index 0804791..c819303 100644 --- a/tests/test_periapsis_burn.cpp +++ b/tests/test_periapsis_burn.cpp @@ -25,35 +25,11 @@ SCENARIO("Periapsis-triggered prograde burn behavior", "[maneuver][periapsis]") const double initial_periapsis = 7259700.0; const double burn1_expected_sma = 13404876.6810; const double burn1_expected_v = 8943.1448; - const double burn1_expected_radius = 7265936.0570; - const double burn2_expected_radius = 7262462.4116; - const double cross_expected_radius = 7259786.1864; - - // Propagation-level tolerance constants (coarser than conversion tolerances). - // - // NOTE: These tolerances exist because the test measures spacecraft state - // AFTER update_simulation() returns — i.e. after the full 60s post-burn - // propagation in the new orbit. The burn itself fires at exact nu=0 (the - // trigger detects angular_distance(0,0) < 0.01 and sets scheduled_dt=0). - // The burn happens, the orbit changes, then the craft flies 60s in the - // new orbit before the test reads craft->local_position. So nu=0.074 rad - // is the true anomaly 60s after the burn, not the nu at burn time. - // - // Plan: add a BurnResult struct (Vec3 position, Vec3 velocity) to Maneuver. - // populate it in execute_maneuver() before the remaining_dt propagation. - // The test will then read sim->maneuvers[i].burn_result to get exact - // burn-time state vectors, eliminating these propagation-level tolerances - // and allowing assertions like "burn position == periapsis" directly. - // - // C++ vs Python state vector agreement at the same simulation step is - // ~50 microns (floating-point noise), confirming sim_engine.py matches. - const double PERIAPSIS_TOL = 1.0; // periapsis preserved by burn - const double PROP_RADIUS_TOL = 0.001; // sub-step offset at burn (~50 μm) - const double PROP_ANGLE_TOL = 0.075; // nu 60s after burn1 (~0.074 rad) - const double PROP_TIME_TOL = 28.0; // period vs time_between (~25.8 s) - const double CROSS_ANGLE_TOL = 0.009; // nu 60s after cross burn (~0.009 rad) - const double PROP_SMA_TOL = 1.0; // SMA after single burn - const double PROP_VEL_TOL = 0.1; // velocity after single burn + + // BurnResult captures exact pre-burn state vectors, enabling tight + // tolerances (R_TOL, ANG_TOL) for periapsis assertions. + // Propagation-level tolerances (A_TOL*10, V_TOL*100, M_TOL*10) remain + // for post-burn+60s-propagation state comparisons. SECTION("spacecraft loads correctly") { REQUIRE(sim->craft_count == 2); @@ -72,6 +48,12 @@ SCENARIO("Periapsis-triggered prograde burn behavior", "[maneuver][periapsis]") // Execute one step — burn fires immediately (nu=0, trigger=0) update_simulation(sim); + // Verify burn fired at exact periapsis via burn_result + const BurnResult& br = sim->maneuvers[0].burn_result; + REQUIRE(br.valid); + REQUIRE_THAT(br.true_anomaly, WithinAbs(0.0, ANG_TOL)); + REQUIRE_THAT(vec3_magnitude(br.position), WithinAbs(initial_periapsis, R_TOL)); + // Maneuver executed REQUIRE(sim->maneuvers[0].executed); @@ -79,17 +61,20 @@ SCENARIO("Periapsis-triggered prograde burn behavior", "[maneuver][periapsis]") double final_sma = craft->orbit.semi_major_axis; double final_ecc = craft->orbit.eccentricity; double final_periapsis = final_sma * (1.0 - final_ecc); - REQUIRE_THAT(final_periapsis, WithinAbs(initial_periapsis, PERIAPSIS_TOL)); + REQUIRE_THAT(final_periapsis, WithinAbs(initial_periapsis, R_TOL)); // Semi-major axis and velocity increase (from precalculated expected values) - REQUIRE_THAT(final_sma, WithinAbs(burn1_expected_sma, PROP_SMA_TOL)); - REQUIRE_THAT(vec3_magnitude(craft->local_velocity), WithinAbs(burn1_expected_v, PROP_VEL_TOL)); + // Note: these are post-burn + 60s propagation, so use propagation-level tolerance + REQUIRE_THAT(final_sma, WithinAbs(burn1_expected_sma, A_TOL * 10)); + REQUIRE_THAT(vec3_magnitude(craft->local_velocity), WithinAbs(burn1_expected_v, V_TOL * 100)); INFO("Initial SMA: " << a_before << " m"); INFO("Final SMA: " << final_sma << " m"); INFO("Initial periapsis: " << peri_before << " m"); INFO("Final periapsis: " << final_periapsis << " m"); INFO("Velocity change: " << (v_before - vec3_magnitude(craft->local_velocity)) << " m/s"); + INFO("Burn time position: " << br.position.x << ", " << br.position.y << ", " << br.position.z); + INFO("Burn time velocity: " << br.velocity.x << ", " << br.velocity.y << ", " << br.velocity.z); } SECTION("two sequential periapsis burns execute at same location") { @@ -109,56 +94,48 @@ SCENARIO("Periapsis-triggered prograde burn behavior", "[maneuver][periapsis]") INFO("Initial periapsis: " << initial_periapsis_val << " m"); INFO("Initial apoapsis: " << initial_apoapsis_val << " m"); - double burn1_time = -1.0, burn1_radius = -1.0, burn1_nu = -10.0; - double burn2_time = -1.0, burn2_radius = -1.0, burn2_nu = -10.0; - double burn1_period = -1.0; - Vec3 burn1_pos = {}, burn2_pos = {}; - Vec3 burn1_vel = {}, burn2_vel = {}; - const int max_steps = 300; for (int i = 0; i < max_steps; i++) { update_simulation(sim); - - if (sim->maneuvers[burn1_idx].executed && burn1_time < 0) { - burn1_time = sim->time; - burn1_radius = vec3_magnitude(craft->local_position); - burn1_nu = craft->orbit.true_anomaly; - burn1_period = 2.0 * M_PI * sqrt(pow(craft->orbit.semi_major_axis, 3.0) / (G * parent->mass)); - burn1_pos = craft->local_position; - burn1_vel = craft->local_velocity; - } - - if (sim->maneuvers[burn2_idx].executed && burn2_time < 0) { - burn2_time = sim->time; - burn2_radius = vec3_magnitude(craft->local_position); - burn2_nu = craft->orbit.true_anomaly; - burn2_pos = craft->local_position; - burn2_vel = craft->local_velocity; - } } REQUIRE(sim->maneuvers[burn1_idx].executed); REQUIRE(sim->maneuvers[burn2_idx].executed); - // Both burns at expected periapsis-adjacent radius - REQUIRE_THAT(burn1_radius, WithinAbs(burn1_expected_radius, PROP_RADIUS_TOL)); - REQUIRE_THAT(burn2_radius, WithinAbs(burn2_expected_radius, PROP_RADIUS_TOL)); + // Read exact burn-time state from burn_result + const BurnResult& br1 = sim->maneuvers[burn1_idx].burn_result; + const BurnResult& br2 = sim->maneuvers[burn2_idx].burn_result; + REQUIRE(br1.valid); + REQUIRE(br2.valid); + + double burn1_radius = vec3_magnitude(br1.position); + double burn2_radius = vec3_magnitude(br2.position); + double burn1_time = sim->maneuvers[burn1_idx].executed_time; + double burn2_time = sim->maneuvers[burn2_idx].executed_time; - // Both at true anomaly ≈ 0 (within 0.1 rad after post-burn propagation) - REQUIRE_THAT(burn1_nu, WithinAbs(0.0, PROP_ANGLE_TOL)); - REQUIRE_THAT(burn2_nu, WithinAbs(0.0, PROP_ANGLE_TOL)); + // Both burns at exact periapsis radius + REQUIRE_THAT(burn1_radius, WithinAbs(initial_periapsis, R_TOL)); + REQUIRE_THAT(burn2_radius, WithinAbs(initial_periapsis, R_TOL)); - // Time between burns ≈ orbital period (within 1 timestep) + // Both at exact true anomaly = 0 (burn_result captures pre-burn state) + REQUIRE_THAT(br1.true_anomaly, WithinAbs(0.0, ANG_TOL)); + REQUIRE_THAT(br2.true_anomaly, WithinAbs(0.0, ANG_TOL)); + + // Both burns at same radius (same periapsis location) + REQUIRE_THAT(burn1_radius, WithinAbs(burn2_radius, R_TOL)); + + // Time between burns ≈ orbital period double time_between = burn2_time - burn1_time; - REQUIRE_THAT(time_between, WithinAbs(burn1_period, PROP_TIME_TOL)); + double burn1_period = 2.0 * M_PI * sqrt(pow(burn1_expected_sma, 3.0) / (G * parent->mass)); + REQUIRE_THAT(time_between, WithinAbs(burn1_period, M_TOL * 10)); // Debug info (after assertions so Catch2 captures it) - INFO("Burn 1: t=" << burn1_time << "s, r=" << burn1_radius << "m, nu=" << burn1_nu << " rad"); - INFO(" pos=" << burn1_pos.x << ", " << burn1_pos.y << ", " << burn1_pos.z); - INFO(" vel=" << burn1_vel.x << ", " << burn1_vel.y << ", " << burn1_vel.z); - INFO("Burn 2: t=" << burn2_time << "s, r=" << burn2_radius << "m, nu=" << burn2_nu << " rad"); - INFO(" pos=" << burn2_pos.x << ", " << burn2_pos.y << ", " << burn2_pos.z); - INFO(" vel=" << burn2_vel.x << ", " << burn2_vel.y << ", " << burn2_vel.z); + INFO("Burn 1: t=" << burn1_time << "s, r=" << burn1_radius << "m, nu=" << br1.true_anomaly << " rad"); + INFO(" pos=" << br1.position.x << ", " << br1.position.y << ", " << br1.position.z); + INFO(" vel=" << br1.velocity.x << ", " << br1.velocity.y << ", " << br1.velocity.z); + INFO("Burn 2: t=" << burn2_time << "s, r=" << burn2_radius << "m, nu=" << br2.true_anomaly << " rad"); + INFO(" pos=" << br2.position.x << ", " << br2.position.y << ", " << br2.position.z); + INFO(" vel=" << br2.velocity.x << ", " << br2.velocity.y << ", " << br2.velocity.z); INFO("Time between burns: " << time_between << " s"); INFO("Expected period: " << burn1_period << " s"); REQUIRE(true); // dummy to capture INFO @@ -180,26 +157,29 @@ SCENARIO("Periapsis-triggered prograde burn behavior", "[maneuver][periapsis]") INFO("Initial periapsis: " << cross_initial_periapsis << " m"); INFO("Initial apoapsis: " << cross_initial_apoapsis << " m"); - double burn_time = -1.0, burn_radius = -1.0, burn_nu = -10.0; const int max_steps = 1000; for (int i = 0; i < max_steps && !sim->maneuvers[cross_maneuver].executed; i++) { update_simulation(sim); - if (sim->maneuvers[cross_maneuver].executed) { - burn_time = sim->time; - burn_radius = vec3_magnitude(craft_cross->local_position); - burn_nu = craft_cross->orbit.true_anomaly; - INFO("Burn at step " << i << ", t=" << burn_time << "s"); - INFO(" radius=" << burn_radius << ", nu=" << burn_nu << " rad"); - } } REQUIRE(sim->maneuvers[cross_maneuver].executed); - // Burn radius close to expected periapsis-adjacent radius - REQUIRE_THAT(burn_radius, WithinAbs(cross_expected_radius, PROP_RADIUS_TOL)); + // Read exact burn-time state from burn_result + const BurnResult& br = sim->maneuvers[cross_maneuver].burn_result; + REQUIRE(br.valid); + + double burn_radius = vec3_magnitude(br.position); - // True anomaly ≈ 0 at burn (within 0.01 rad after post-burn propagation) - REQUIRE_THAT(burn_nu, WithinAbs(0.0, CROSS_ANGLE_TOL)); + // Burn at exact periapsis radius + REQUIRE_THAT(burn_radius, WithinAbs(cross_initial_periapsis, R_TOL)); + + // True anomaly = 0 at burn (burn_result captures pre-burn state) + REQUIRE_THAT(br.true_anomaly, WithinAbs(0.0, ANG_TOL)); + + INFO("Burn at step " << max_steps << ", t=" << sim->maneuvers[cross_maneuver].executed_time << "s"); + INFO(" radius=" << burn_radius << ", nu=" << br.true_anomaly << " rad"); + INFO(" pos=" << br.position.x << ", " << br.position.y << ", " << br.position.z); + INFO(" vel=" << br.velocity.x << ", " << br.velocity.y << ", " << br.velocity.z); } SECTION("burn location equals new periapsis after prograde burn") { @@ -212,17 +192,21 @@ SCENARIO("Periapsis-triggered prograde burn behavior", "[maneuver][periapsis]") REQUIRE(sim->maneuvers[0].executed); - double final_periapsis = craft->orbit.semi_major_axis * (1.0 - craft->orbit.eccentricity); + // Verify burn happened at periapsis via burn_result + const BurnResult& br = sim->maneuvers[0].burn_result; + REQUIRE(br.valid); + REQUIRE_THAT(vec3_magnitude(br.position), WithinAbs(peri_before, R_TOL)); + REQUIRE_THAT(br.true_anomaly, WithinAbs(0.0, ANG_TOL)); - // Initial radius equals periapsis - REQUIRE_THAT(r_before, WithinAbs(peri_before, PERIAPSIS_TOL)); + double final_periapsis = craft->orbit.semi_major_axis * (1.0 - craft->orbit.eccentricity); // Final periapsis equals initial periapsis (burn at periapsis preserves it) - REQUIRE_THAT(final_periapsis, WithinAbs(peri_before, PERIAPSIS_TOL)); + REQUIRE_THAT(final_periapsis, WithinAbs(peri_before, R_TOL)); INFO("Initial radius: " << r_before << " m"); INFO("Initial periapsis: " << peri_before << " m"); INFO("Final periapsis: " << final_periapsis << " m"); + INFO("Burn_result radius: " << vec3_magnitude(br.position) << " m"); } destroy_simulation(sim);