Compare commits

...

7 Commits

  1. 9
      continue.md
  2. 4
      docs/TODO
  3. 152
      old_tests/test_maneuver_planning.cpp
  4. 65
      old_tests/test_omega_debug.cpp
  5. 128
      scripts/precalc_maneuver_planning.py
  6. 129
      scripts/precalc_omega_debug.py
  7. 81
      tests/test_maneuver_planning.cpp
  8. 24
      tests/test_maneuver_planning.toml
  9. 60
      tests/test_omega_debug.cpp

9
continue.md

@ -5,8 +5,9 @@
### 1. Structure
- One `SCENARIO("description")` per logical test group, with `[tag1][tag2]` annotations
- Run `./build/orbit_test --list-tags` to see tags used by other tests. Original tags from the old test file are a useful starting point, but the implementor has discretion to choose appropriate tags.
- **Use SCENARIO to share setup/teardown across multiple SECTIONs.** Catch2 re-initializes the fixture before each SECTION, so declare shared constants, structs, and variables in the SCENARIO body (between the opening `{` and the first `SECTION`). These persist across all SECTIONs within the SCENARIO.
- Example: a `SimulationState* sim` created once in the SCENARIO body, then each SECTION mutates and tests it independently.
- **Use SCENARIO as a shared fixture** for setup/initialization. SECTIONs represent **different test scenarios** that branch from that fixture with distinct operations. Avoid using SECTIONs as section headers to group assertions about the same result — group related assertions into fewer SECTIONs instead.
- Catch2 re-initializes the fixture before each SECTION, so shared constants, structs, and variables declared in the SCENARIO body run fresh per SECTION. This is intentional: each SECTION should test a different code path or mutation of the fixture.
- Example: one SECTION checks the initial state before modification; a separate SECTION applies a burn and checks all post-burn results.
- Embed expected values directly in `WithinAbs()` calls (see Section 4 for precalc script usage). No need to declare named constants unless the value is reused.
- **No decorative comments.** Do not add `// (Old: ...)` comments, `===` separators, `---` separators, or any other decorative annotations. The SECTION description string is the documentation.
- **Use `REQUIRE()` for integer comparisons**, `WithinAbs()` only for floating-point. E.g., `REQUIRE(sim->body_count == 2)` not `REQUIRE_THAT(sim->body_count, WithinAbs(2.0, 0.001))`.
@ -63,9 +64,11 @@ All constants defined in `src/test_utilities.h` — use those, do not redefine l
- `test_extreme_timescales` — 9 TEST_CASEs → 1 SCENARIO with 11 SECTIONs, all WithinAbs use named constants
- `test_analytical_propagation` — 5 SCENARIOs → 1 SCENARIO with 23 SECTIONs, precalculated values, all WithinAbs use named constants
- `test_moon_orbits` — Multi-body hierarchical propagation with local/global coordinate tracking
- `test_omega_debug` — Burn + element reconstruction + maneuver triggers
- `test_energy` — Energy calculations and conservation tests
- `test_inclined_orbits` — 3D inclined orbit conversions, Molniya orbits, rotation matrices
- `test_maneuvers` — Impulsive burn tests with precalculated values
- `test_maneuver_planning` — Maneuver trigger system (TIME + elliptical TRUE_ANOMALY triggers)
- `test_orbital_period` — Orbital period calculations, SCENARIO/SECTION pattern
- `test_true_anomaly_roundtrip` — True anomaly conversion round-trips, tight tolerances
- `test_physics_utilities` — Vector math, acceleration, matrix ops, rotation matrices, compare_vec3
@ -73,8 +76,6 @@ All constants defined in `src/test_utilities.h` — use those, do not redefine l
- `test_hybrid_burns` — 14 TEST_CASEs → 1 SCENARIO with 22 SECTIONs, impulse + continuous burns, precalculated values; converted 17 qualitative checks to quantitative, replaced hardcoded tolerances with named constants (A_TOL, E_TOL, D_TOL, M_TOL, ANG_TOL, R_TOL)
### Can Refactor Now (sim_engine.py supports all features needed)
- `test_omega_debug` — burn + element reconstruction + maneuver triggers
- `test_maneuver_planning` — maneuver trigger system (TIME + elliptical TRUE_ANOMALY triggers)
- `test_orbit_rendering` — rendering tests (check if sim_engine needed)
- `test_precision_boundaries` — boundary condition tests
- `test_invalid_parent_assignment` — validation/error handling tests

4
docs/TODO

@ -12,6 +12,10 @@ If you see modifications to this file in git status, IGNORE them and do not comm
- ensure we're using 'SECTION' macros for setup/teardown
- we're working on a generic python simulator to precalculate, but we should
also use some actual values from real-world missions or textbooks
- there's also the option of using a 3rd party simulator:
- https://github.com/poliastro/poliastro/blob/main/src/poliastro/core/propagation/farnocchia.py
- the (archived) poliastro project has two body propagators that are
similar enough to our Newton-Raphson implementation
- test_utilities:create_orbit_tracker functions could return copies instead of pointers
- functions using pointers could be pass by reference
- min_time should have a default value

152
old_tests/test_maneuver_planning.cpp

@ -1,152 +0,0 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h"
#include "../src/simulation.h"
#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;
TEST_CASE("Maneuver loading from config", "[maneuver][config]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuver_planning.toml"));
REQUIRE(sim->maneuver_count == 2);
REQUIRE(std::string(sim->maneuvers[0].name) == "orbit_raise_1");
REQUIRE(std::string(sim->maneuvers[1].name) == "orbit_raise_2");
REQUIRE(sim->maneuvers[0].trigger_type == TRIGGER_TIME);
REQUIRE(sim->maneuvers[1].trigger_type == TRIGGER_TRUE_ANOMALY);
REQUIRE(sim->maneuvers[0].delta_v == 500.0);
destroy_simulation(sim);
}
TEST_CASE("Time-based trigger executes at correct time", "[maneuver][trigger][time]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuver_planning.toml"));
REQUIRE(sim->maneuver_count == 2);
REQUIRE(!sim->maneuvers[0].executed);
REQUIRE(!sim->maneuvers[1].executed);
double initial_velocity = vec3_magnitude(sim->spacecraft[0].local_velocity);
const double BURN_TIME = 3600.0;
while (sim->time < BURN_TIME + sim->dt) {
update_simulation(sim);
}
REQUIRE(sim->maneuvers[0].executed);
REQUIRE(fabs(sim->maneuvers[0].executed_time - BURN_TIME) < TIME_STEP);
double after_velocity = vec3_magnitude(sim->spacecraft[0].local_velocity);
REQUIRE(after_velocity > initial_velocity);
destroy_simulation(sim);
}
TEST_CASE("True anomaly trigger executes at correct angle", "[maneuver][trigger][true_anomaly]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuver_planning.toml"));
REQUIRE(sim->maneuver_count == 2);
REQUIRE(!sim->maneuvers[1].executed);
double first_burn_velocity = 0.0;
const double FIRST_BURN_TIME = 3600.0;
while (sim->time < FIRST_BURN_TIME) {
update_simulation(sim);
}
first_burn_velocity = vec3_magnitude(sim->spacecraft[0].local_velocity);
const double TARGET_ANOMALY = 3.14159;
(void)TARGET_ANOMALY;
double max_sim_time = FIRST_BURN_TIME + 72000.0;
while (sim->time < max_sim_time) {
update_simulation(sim);
if (sim->maneuvers[1].executed) {
break;
}
}
REQUIRE(sim->maneuvers[1].executed);
double second_burn_velocity = vec3_magnitude(sim->spacecraft[0].local_velocity);
double second_burn_kinetic_energy = 0.5 * sim->spacecraft[0].mass *
second_burn_velocity * second_burn_velocity;
double first_burn_kinetic_energy = 0.5 * sim->spacecraft[0].mass *
first_burn_velocity * first_burn_velocity;
REQUIRE(second_burn_kinetic_energy > first_burn_kinetic_energy);
destroy_simulation(sim);
}
TEST_CASE("Maneuvers only execute once", "[maneuver][execution]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuver_planning.toml"));
const double MAX_TIME = 20000.0;
while (sim->time < MAX_TIME) {
update_simulation(sim);
}
REQUIRE(sim->maneuvers[0].executed);
REQUIRE(sim->maneuvers[1].executed);
double execution_count = 0.0;
for (int i = 0; i < sim->maneuver_count; i++) {
if (sim->maneuvers[i].executed) {
execution_count += 1.0;
}
}
REQUIRE(execution_count == 2.0);
destroy_simulation(sim);
}
TEST_CASE("Duplicate maneuver names fail config load", "[maneuver][config][error]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
bool result = load_system_config(sim, "tests/test_maneuver_planning.toml");
REQUIRE(result);
REQUIRE(sim->maneuver_count == 2);
Maneuver duplicate_maneuver = sim->maneuvers[0];
sim->maneuvers[sim->maneuver_count] = duplicate_maneuver;
(void)sim->maneuver_count;
sim->maneuver_count++;
bool is_duplicate = (std::string(sim->maneuvers[0].name) ==
std::string(sim->maneuvers[2].name));
REQUIRE(is_duplicate);
destroy_simulation(sim);
}

65
old_tests/test_omega_debug.cpp

@ -1,65 +0,0 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h"
#include "../src/simulation.h"
#include "../src/orbital_objects.h"
#include "../src/maneuver.h"
#include "../src/config_loader.h"
#include "../src/orbital_mechanics.h"
#include <cmath>
// Test: Check omega after prograde burn that flips eccentricity vector direction
TEST_CASE("Omega calculation after prograde burn", "[omega][debug]") {
double parent_mass = 5.972e24; // Earth mass
// Initial orbit: zero inclination, omega = 0
// Start at apoapsis where eccentricity vector points opposite to position
OrbitalElements elements = {0};
elements.semi_major_axis = 1.0e7;
elements.eccentricity = 0.3;
elements.true_anomaly = M_PI; // Start at apoapsis
elements.inclination = 1e-12; // Tiny inclination to trigger atan2 path
elements.longitude_of_ascending_node = 0.0;
elements.argument_of_periapsis = 0.0;
// Get initial position and velocity
Vec3 pos, vel;
orbital_elements_to_cartesian(elements, parent_mass, &pos, &vel);
// Get initial eccentricity vector
Vec3 v = vel;
double r = vec3_magnitude(pos);
double mu = G * parent_mass;
Vec3 e_vec_initial = vec3_scale(vec3_sub(vec3_scale(vel, r), vec3_scale(pos, vec3_magnitude(vel) / mu)), 1.0 / mu);
double e_initial = vec3_magnitude(e_vec_initial);
INFO("Initial state:");
INFO(" e = " << e_initial);
INFO(" e_vec = (" << e_vec_initial.x << ", " << e_vec_initial.y << ", " << e_vec_initial.z << ")");
INFO(" pos = (" << pos.x << ", " << pos.y << ", " << pos.z << ")");
INFO(" vel = (" << vel.x << ", " << vel.y << ", " << vel.z << ")");
// Apply a prograde burn at periapsis
Vec3 vel_dir = vec3_normalize(vel);
Vec3 delta_v = vec3_scale(vel_dir, 1000.0); // Large burn to flip e_vec
vel = vec3_add(vel, delta_v);
// Reconstruct orbital elements
OrbitalElements new_elements = cartesian_to_orbital_elements(pos, vel, parent_mass);
INFO("After prograde burn:");
INFO(" new omega = " << new_elements.argument_of_periapsis << " rad (" << new_elements.argument_of_periapsis * 180.0 / M_PI << " deg)");
INFO(" new e = " << new_elements.eccentricity);
// Get new eccentricity vector
Vec3 e_vec_new = vec3_scale(vec3_sub(vec3_scale(vel, r), vec3_scale(pos, vec3_magnitude(vel) / mu)), 1.0 / mu);
INFO(" new e_vec = (" << e_vec_new.x << ", " << e_vec_new.y << ", " << e_vec_new.z << ")");
// For zero-inclination orbit, omega is computed from the eccentricity vector
// (longitude of periapsis since ascending node is undefined)
// The key constraint is that omega should be in [0, 2π)
bool omega_in_range = (new_elements.argument_of_periapsis >= 0.0) &&
(new_elements.argument_of_periapsis < 2.0 * M_PI);
REQUIRE(omega_in_range);
}

128
scripts/precalc_maneuver_planning.py

@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""
Precalculate expected values for test_maneuver_planning.cpp refactoring.
Computes velocities and energy after time-based and true anomaly-based
maneuver triggers.
"""
import math
import sys
sys.path.insert(0, "/home/agent/dev/claudes_game")
from scripts.sim_engine import *
def main():
G_const = G
earth_mass = 5.972e24
mu = G_const * earth_mass
# Initial circular orbit at r = 6.771e6 m
r0 = 6.771e6
v_circular = math.sqrt(mu / r0)
print("// Initial circular orbit")
print(f"// r = {r0} m")
print(f"// v_circular = {v_circular:.15e} m/s")
print()
# Run the full simulation with the TOML config
sim = Simulator("tests/test_maneuver_planning.toml", dt=60.0)
# Get initial craft velocity
craft = sim.get_craft("LEO_Satellite")
v_initial = vmag(craft.local_vel)
print(f"// Initial craft velocity from config: {v_initial:.15e} m/s")
print()
# Run to just before first burn (t=3600)
steps_to_first_burn = int(3600.0 / 60.0) # 60 steps
sim.run(steps_to_first_burn)
craft = sim.get_craft("LEO_Satellite")
v_before_burn1 = vmag(craft.local_vel)
t_before = sim.time
# Check if maneuver[0] executed
man = sim.maneuvers[0]
print("// First burn (time trigger at 3600.0 s)")
print(f"// Time at step end: {t_before:.1f} s")
print(f"// Executed: {man.executed}")
print(f"// Executed time: {man.executed_time:.15e} s")
print(f"// Velocity before burn: {v_before_burn1:.15e} m/s")
print()
# Continue a bit more to ensure burn fires
sim.run(1)
man = sim.maneuvers[0]
craft = sim.get_craft("LEO_Satellite")
v_after_burn1 = vmag(craft.local_vel)
a_after_burn1 = craft.orbit.a
e_after_burn1 = craft.orbit.e
r_after_burn1 = vmag(craft.local_pos)
print(f"// After stepping past burn:")
print(f"// Executed: {man.executed}")
print(f"// Executed time: {man.executed_time:.15e} s")
print(f"// Velocity after burn: {v_after_burn1:.15e} m/s")
print(f"// Semi-major axis: {a_after_burn1:.15e} m")
print(f"// Eccentricity: {e_after_burn1:.15e}")
print(f"// Radius: {r_after_burn1:.15e} m")
print(f"// KE after first burn: {0.5 * 1000.0 * v_after_burn1 * v_after_burn1:.15e} J")
print()
# Continue until second burn fires (true anomaly 0.0)
max_additional_steps = 2000 # should be enough
second_burn_fired = False
for step in range(max_additional_steps):
sim.run(1)
if sim.maneuvers[1].executed:
second_burn_fired = True
break
craft = sim.get_craft("LEO_Satellite")
v_after_burn2 = vmag(craft.local_vel)
a_after_burn2 = craft.orbit.a
e_after_burn2 = craft.orbit.e
man2 = sim.maneuvers[1]
print("// Second burn (true anomaly trigger at 0.0)")
print(f"// Fired: {second_burn_fired}")
print(f"// Executed time: {man2.executed_time:.15e} s")
print(f"// Sim time: {sim.time:.1f} s")
print(f"// Velocity after second burn: {v_after_burn2:.15e} m/s")
print(f"// Semi-major axis: {a_after_burn2:.15e} m")
print(f"// Eccentricity: {e_after_burn2:.15e}")
print(f"// KE after second burn: {0.5 * 1000.0 * v_after_burn2 * v_after_burn2:.15e} J")
print()
# Also get the exact burn result for second burn
br = man2.burn_result
print(f"// Pre-burn state at second burn:")
print(f"// pos = {br.position}")
print(f"// vel = {br.velocity}")
print(f"// true_anomaly = {br.true_anomaly:.15e} rad")
print()
# Run beyond well past to verify no extra executions
sim.run(500)
exec_count = sum(1 for m in sim.maneuvers if m.executed)
print("// After extra simulation")
print(f"// Total executed: {exec_count}")
for i, m in enumerate(sim.maneuvers):
print(f"// Maneuver[{i}] '{m.name}': executed={m.executed}, time={m.executed_time:.1f} s")
print()
print("// For WithinAbs assertions:")
print(f"// v_initial := {v_circular:.15e}")
print(f"// v_after_burn1 := {v_after_burn1:.15e}")
print(f"// a_after_burn1 := {a_after_burn1:.15e}")
print(f"// e_after_burn1 := {e_after_burn1:.15e}")
print(f"// v_after_burn2 := {v_after_burn2:.15e}")
print(f"// a_after_burn2 := {a_after_burn2:.15e}")
print(f"// e_after_burn2 := {e_after_burn2:.15e}")
print(f"// executed_time_1 := {man.executed_time:.15e}")
print(f"// executed_time_2 := {man2.executed_time:.15e}")
if __name__ == "__main__":
main()

129
scripts/precalc_omega_debug.py

@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
Precalculate expected values for test_omega_debug.cpp refactoring.
Computes expected orbital elements after a prograde burn at apoapsis.
"""
import math
import sys
sys.path.insert(0, "/home/agent/dev/claudes_game")
from scripts.sim_engine import *
def main():
earth_mass = 5.972e24
mu = G * earth_mass
# Initial orbit: zero inclination, omega = 0, start at apoapsis (nu = pi)
elements = OrbitalElements(
a=1.0e7,
e=0.3,
nu=math.pi,
inc=1e-12,
Omega=0.0,
omega=0.0,
)
pos, vel = orbital_to_cartesian(elements, earth_mass)
r = vmag(pos)
v = vmag(vel)
print("// Initial state")
print(f"// pos = ({pos[0]:.15e}, {pos[1]:.15e}, {pos[2]:.15e}) m")
print(f"// vel = ({vel[0]:.15e}, {vel[1]:.15e}, {vel[2]:.15e}) m/s")
print(f"// r = {r:.15e} m")
print(f"// v = {v:.15e} m/s")
print()
# Eccentricity vector
r_dot_v = vdot(pos, vel)
e_vec = (
((v * v - mu / r) * pos[0] - r_dot_v * vel[0]) / mu,
((v * v - mu / r) * pos[1] - r_dot_v * vel[1]) / mu,
((v * v - mu / r) * pos[2] - r_dot_v * vel[2]) / mu,
)
e_mag = vmag(e_vec)
print(f"// e_vec_initial = ({e_vec[0]:.15e}, {e_vec[1]:.15e}, {e_vec[2]:.15e})")
print(f"// e_initial = {e_mag:.15e}")
print()
# Apply prograde burn (1000 m/s)
burn_dir = get_burn_direction(BurnDirection.PROGRADE, pos, vel)
dv = 1000.0
dv_vec = vscale(burn_dir, dv)
vel_new = vadd(vel, dv_vec)
v_new = vmag(vel_new)
print("// After prograde burn")
print(f"// burn_dir = ({burn_dir[0]:.15e}, {burn_dir[1]:.15e}, {burn_dir[2]:.15e})")
print(f"// vel_new = ({vel_new[0]:.15e}, {vel_new[1]:.15e}, {vel_new[2]:.15e}) m/s")
print(f"// v_new = {v_new:.15e} m/s")
print()
# Reconstruct orbital elements
new_elements = cartesian_to_orbital_elements(pos, vel_new, earth_mass)
print(f"// new elements:")
print(f"// a = {new_elements.a:.15e} m")
print(f"// e = {new_elements.e:.15e}")
print(f"// nu = {new_elements.nu:.15e} rad ({math.degrees(new_elements.nu):.6f} deg)")
print(f"// inc = {new_elements.inc:.15e} rad")
print(f"// Omega = {new_elements.Omega:.15e} rad")
print(f"// omega = {new_elements.omega:.15e} rad ({math.degrees(new_elements.omega):.6f} deg)")
print()
# New eccentricity vector
r_dot_v_new = vdot(pos, vel_new)
e_vec_new = (
((v_new * v_new - mu / r) * pos[0] - r_dot_v_new * vel_new[0]) / mu,
((v_new * v_new - mu / r) * pos[1] - r_dot_v_new * vel_new[1]) / mu,
((v_new * v_new - mu / r) * pos[2] - r_dot_v_new * vel_new[2]) / mu,
)
print(f"// e_vec_new = ({e_vec_new[0]:.15e}, {e_vec_new[1]:.15e}, {e_vec_new[2]:.15e})")
print(f"// e_new = {vmag(e_vec_new):.15e}")
print()
# Verify omega is in [0, 2*pi)
print("// Omega range check")
print(f"// omega = {new_elements.omega:.15e} rad")
print(f"// omega in [0, 2*pi)? {0.0 <= new_elements.omega < 2.0 * math.pi}")
print()
# After a prograde burn at apoapsis (nu=pi), the eccentricity vector flips
# direction because the increased velocity raises the opposite side of the orbit.
# This means omega should change from 0 to approximately pi.
#
# Rationale: at apoapsis, position and velocity are perpendicular.
# A prograde burn adds velocity along the velocity direction, increasing energy.
# The eccentricity vector formula: e_vec = (v^2 - mu/r)*r/μ - (r·v)*v/μ
# At apoapsis: r·v = 0, so e_vec = (v^2 - mu/r) * r / mu
# After prograde burn, v increases, so (v^2 - mu/r) becomes more positive,
# making e_vec more aligned with r direction.
# Since at apoapsis, r points opposite to periapsis direction (for ω=0),
# the eccentricity vector flips, meaning periapsis moves to the opposite side,
# so ω → π.
print("// Expected test values")
print(f"// a_expected = {new_elements.a:.15e}")
print(f"// e_expected = {new_elements.e:.15e}")
print(f"// omega_expected = {new_elements.omega:.15e} rad ({math.degrees(new_elements.omega):.6f} deg)")
print()
# Also compute expected values using the same check as the original test
print("// For WithinAbs assertions:")
print(f"// a := {new_elements.a:.15e}")
print(f"// e := {new_elements.e:.15e}")
print(f"// omega := {new_elements.omega:.15e}")
print(f"// inc := {new_elements.inc:.15e}")
print(f"// Omega := {new_elements.Omega:.15e}")
print(f"// nu := {new_elements.nu:.15e}")
print(f"// r := {r:.15e}")
print(f"// v_new := {v_new:.15e}")
if __name__ == "__main__":
main()

81
tests/test_maneuver_planning.cpp

@ -0,0 +1,81 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h"
#include "../src/simulation.h"
#include "../src/orbital_objects.h"
#include "../src/maneuver.h"
#include "../src/config_loader.h"
#include "../src/test_utilities.h"
using Catch::Matchers::WithinAbs;
SCENARIO("Maneuver planning and execution", "[maneuver][planning][trigger][config]") {
const double TIME_STEP = 60.0;
const double BURN_TIME = 3600.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuver_planning.toml"));
auto run_until = [&](double target_time) {
while (sim->time < target_time) {
update_simulation(sim);
}
};
SECTION("maneuvers load from config with correct properties") {
REQUIRE(sim->maneuver_count == 2);
REQUIRE(std::string(sim->maneuvers[0].name) == "orbit_raise_1");
REQUIRE(std::string(sim->maneuvers[1].name) == "orbit_raise_2");
REQUIRE(sim->maneuvers[0].trigger_type == TRIGGER_TIME);
REQUIRE(sim->maneuvers[1].trigger_type == TRIGGER_TRUE_ANOMALY);
REQUIRE_THAT(sim->maneuvers[0].delta_v, WithinAbs(500.0, D_TOL));
}
SECTION("time-based trigger executes at 3600 s") {
REQUIRE(!sim->maneuvers[0].executed);
REQUIRE(!sim->maneuvers[1].executed);
run_until(BURN_TIME + TIME_STEP);
REQUIRE(sim->maneuvers[0].executed);
REQUIRE_THAT(sim->maneuvers[0].executed_time, WithinAbs(BURN_TIME, TIME_STEP));
const double after_velocity = vec3_magnitude(sim->spacecraft[0].local_velocity);
REQUIRE_THAT(after_velocity, WithinAbs(8.170251503359999e3, V_TOL));
}
SECTION("true anomaly trigger executes after first burn") {
REQUIRE(!sim->maneuvers[1].executed);
run_until(BURN_TIME + TIME_STEP);
REQUIRE(sim->maneuvers[0].executed);
const double max_sim_time = BURN_TIME + 72000.0;
while (sim->time < max_sim_time) {
update_simulation(sim);
if (sim->maneuvers[1].executed) {
break;
}
}
REQUIRE(sim->maneuvers[1].executed);
REQUIRE_THAT(vec3_magnitude(sim->spacecraft[0].local_velocity),
WithinAbs(8.672299586435140e3, V_TOL));
}
SECTION("maneuvers execute only once") {
run_until(20000.0);
REQUIRE(sim->maneuvers[0].executed);
REQUIRE(sim->maneuvers[1].executed);
int execution_count = 0;
for (int i = 0; i < sim->maneuver_count; i++) {
if (sim->maneuvers[i].executed) {
execution_count++;
}
}
REQUIRE(execution_count == 2);
}
destroy_simulation(sim);
}

24
old_tests/test_maneuver_planning.toml → tests/test_maneuver_planning.toml

@ -7,36 +7,22 @@ name = "Sun"
mass = 1.989e30
radius = 6.96e8
parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = {
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
color = {r = 1.0, g = 1.0, b = 0.0}
orbit = {semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0}
[[bodies]]
name = "Earth"
mass = 5.972e24
radius = 6.371e6
parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = {
semi_major_axis = 1.496e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
color = {r = 0.0, g = 0.5, b = 1.0}
orbit = {semi_major_axis = 1.496e11, eccentricity = 0.0, true_anomaly = 0.0}
[[spacecraft]]
name = "LEO_Satellite"
mass = 1000.0
parent_index = 1
orbit = {
semi_major_axis = 6.771e6,
eccentricity = 0.0,
true_anomaly = 1.57
}
# LEO orbit: 400 km altitude (Earth radius 6.371e6 m + 400e3 m)
# Start at true_anomaly = 1.57 (90 degrees) to avoid triggering immediately
orbit = {semi_major_axis = 6.771e6, eccentricity = 0.0, true_anomaly = 1.57}
[[maneuvers]]
name = "orbit_raise_1"

60
tests/test_omega_debug.cpp

@ -0,0 +1,60 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h"
#include "../src/orbital_mechanics.h"
#include "../src/test_utilities.h"
#include <cmath>
using Catch::Matchers::WithinAbs;
SCENARIO("Omega reconstruction after prograde burn at apoapsis", "[omega][debug]") {
const double parent_mass = 5.972e24;
const double mu = G * parent_mass;
OrbitalElements elements = {};
elements.semi_major_axis = 1.0e7;
elements.eccentricity = 0.3;
elements.true_anomaly = M_PI;
elements.inclination = 1e-12;
elements.longitude_of_ascending_node = 0.0;
elements.argument_of_periapsis = 0.0;
Vec3 pos = {}, vel = {};
orbital_elements_to_cartesian(elements, parent_mass, &pos, &vel);
const double r = vec3_magnitude(pos);
const double v = vec3_magnitude(vel);
const double r_dot_v = vec3_dot(pos, vel);
const Vec3 e_vec = {
((v * v - mu / r) * pos.x - r_dot_v * vel.x) / mu,
((v * v - mu / r) * pos.y - r_dot_v * vel.y) / mu,
((v * v - mu / r) * pos.z - r_dot_v * vel.z) / mu,
};
const double e_initial = vec3_magnitude(e_vec);
SECTION("initial apoapsis state is correct") {
REQUIRE_THAT(r, WithinAbs(1.3e7, R_TOL));
REQUIRE_THAT(v, WithinAbs(4632.763232589246, V_TOL));
REQUIRE_THAT(e_initial, WithinAbs(0.3, E_TOL));
REQUIRE_THAT(e_vec.x / e_initial, WithinAbs(1.0, E_TOL));
REQUIRE_THAT(e_vec.y, WithinAbs(0.0, E_TOL));
REQUIRE_THAT(e_vec.z, WithinAbs(0.0, E_TOL));
}
SECTION("prograde burn at apoapsis flips periapsis") {
const Vec3 burn_dir = vec3_normalize(vel);
const Vec3 delta_v = vec3_scale(burn_dir, 1000.0);
const Vec3 vel_new = vec3_add(vel, delta_v);
const double v_new = vec3_magnitude(vel_new);
const OrbitalElements new_elements = cartesian_to_orbital_elements(pos, vel_new, parent_mass);
REQUIRE_THAT(new_elements.semi_major_axis, WithinAbs(1.346885753127762e7, A_TOL));
REQUIRE_THAT(new_elements.eccentricity, WithinAbs(3.481049006486453e-2, E_TOL));
REQUIRE_THAT(new_elements.argument_of_periapsis, WithinAbs(M_PI, ANG_TOL));
REQUIRE_THAT(new_elements.true_anomaly, WithinAbs(0.0, ANG_TOL));
REQUIRE_THAT(new_elements.inclination, WithinAbs(0.0, ANG_TOL));
REQUIRE_THAT(v_new, WithinAbs(5632.763232589246, V_TOL));
}
}
Loading…
Cancel
Save