From d892aa8c41989b898f8a8f7232d1e4b008082c53 Mon Sep 17 00:00:00 2001 From: cinnaboot Date: Mon, 4 May 2026 18:05:50 -0400 Subject: [PATCH] feat: rewrite moon orbit config as TOML 1.0 dotted keys and refactor tests - Rewrite precalc_moon_orbits.py to output orbit params as dotted keys (orbit.semi_major_axis = ...) instead of nested tables, conforming to TOML 1.0 spec for Python compatibility - Add print_cpp_expected_values() helper to script output - Add new test_moon_orbits.cpp replacing old_tests version: * MoonData struct now carries int parent_index instead of pointer * Eliminate opaque pointer arithmetic for parent index comparison * Collect failures in vector during sim loops, assert once at end (reduces assertions from 763,230 to 43 per test) - Add test_moon_orbits.toml generated from precalc script --- scripts/precalc_moon_orbits.py | 57 +++++-- tests/test_moon_orbits.cpp | 273 +++++++++++++++++++++++++++++++++ tests/test_moon_orbits.toml | 184 ++++++++++++++++++++++ 3 files changed, 497 insertions(+), 17 deletions(-) create mode 100644 tests/test_moon_orbits.cpp create mode 100644 tests/test_moon_orbits.toml diff --git a/scripts/precalc_moon_orbits.py b/scripts/precalc_moon_orbits.py index 9eaf96e..20d3a4d 100644 --- a/scripts/precalc_moon_orbits.py +++ b/scripts/precalc_moon_orbits.py @@ -225,7 +225,9 @@ def print_toml(): print(f"radius = {SUN_RADIUS}") print("parent_index = -1") print('color = { r = 1.0, g = 1.0, b = 0.0 }') - print("orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }") + print("orbit.semi_major_axis = 0.0") + print("orbit.eccentricity = 0.0") + print("orbit.true_anomaly = 0.0") print() # Planets @@ -242,14 +244,12 @@ def print_toml(): print(f'radius = {p["radius"]}') print(f'parent_index = {p["parent"]}') print('color = { r = 0.5, g = 0.5, b = 0.5 }') - print("orbit = {") - print(f" semi_major_axis = {a_m:.6e},") - print(f" eccentricity = {p['e']},") - print(f" inclination = {inc:.15f},") - print(f" longitude_of_ascending_node = {Omega:.15f},") - print(f" argument_of_periapsis = {omega:.15f},") - print(f" true_anomaly = {nu:.15f}") - print("}") + print(f"orbit.semi_major_axis = {a_m:.6e}") + print(f"orbit.eccentricity = {p['e']}") + print(f"orbit.inclination = {inc:.15f}") + print(f"orbit.longitude_of_ascending_node = {Omega:.15f}") + print(f"orbit.argument_of_periapsis = {omega:.15f}") + print(f"orbit.true_anomaly = {nu:.15f}") print() # Moons @@ -267,14 +267,12 @@ def print_toml(): parent_idx = {"Earth": 2, "Jupiter": 4, "Saturn": 5}[m["parent"]] print(f'parent_index = {parent_idx}') print('color = { r = 0.7, g = 0.7, b = 0.7 }') - print("orbit = {") - print(f" semi_major_axis = {a_m:.6e},") - print(f" eccentricity = {m['e']},") - print(f" inclination = {inc:.15f},") - print(f" longitude_of_ascending_node = {Omega:.15f},") - print(f" argument_of_periapsis = {omega:.15f},") - print(f" true_anomaly = {nu:.15f}") - print("}") + print(f"orbit.semi_major_axis = {a_m:.6e}") + print(f"orbit.eccentricity = {m['e']}") + print(f"orbit.inclination = {inc:.15f}") + print(f"orbit.longitude_of_ascending_node = {Omega:.15f}") + print(f"orbit.argument_of_periapsis = {omega:.15f}") + print(f"orbit.true_anomaly = {nu:.15f}") print() @@ -327,6 +325,25 @@ def print_summary(): print(f'{m["name"]:10s}: r={r:.3f} m, nu={nu*180/math.pi:.2f}deg') +def print_cpp_expected_values(): + """Print C++-style comments with expected values for embedding in test.""" + print("# === Expected period values (from precalc) ===") + print("# Format: name -> period_seconds, tolerance_seconds") + parent_masses = { + "Earth": 5.97e24, "Jupiter": 1.898e27, "Saturn": 5.683e26, + } + for m in MOONS: + a_m = m["a_km"] * 1000.0 + pm = parent_masses[m["parent"]] + mu = G * pm + T = 2.0 * math.pi * math.sqrt(a_m**3 / mu) + T_days = T / 86400.0 + # Tolerance: ~0.5% of period (simulation drift over multiple orbits) + tol = 0.005 * T + print(f'// {m["name"]:10s} T={T:>14.2f}s tol={tol:>8.1f}s ({T_days:.3f}d)') + print() + + if __name__ == "__main__": print_summary() print() @@ -335,3 +352,9 @@ if __name__ == "__main__": print("=" * 60) print() print_toml() + print() + print("=" * 60) + print("# CPP EXPECTED VALUES (copy into test fixture)") + print("=" * 60) + print() + print_cpp_expected_values() diff --git a/tests/test_moon_orbits.cpp b/tests/test_moon_orbits.cpp new file mode 100644 index 0000000..3a4f1b7 --- /dev/null +++ b/tests/test_moon_orbits.cpp @@ -0,0 +1,273 @@ +#include +#include +#include "../src/physics.h" +#include "../src/simulation.h" +#include "../src/config_loader.h" +#include "../src/test_utilities.h" +#include +#include +#include + +using Catch::Matchers::WithinAbs; + +// === Helper: run sim loop, collect failures, assert once at end === +static std::string fmt_time(double t) { + char buf[32]; + snprintf(buf, sizeof(buf), "t=%.0fs", t); + return std::string(buf); +} + +static std::string fmt_drift(double d) { + char buf[32]; + snprintf(buf, sizeof(buf), "drift=%.4f", d); + return std::string(buf); +} + +// === Per-moon period tolerances (0.5% of analytical period, from precalc) === +static const double MOON_PERIOD_TOL = 11861.4; // Moon +static const double IO_PERIOD_TOL = 764.6; // Io +static const double EUROPA_PERIOD_TOL = 1534.5; // Europa +static const double GANYMEDE_PERIOD_TOL = 3091.1; // Ganymede +static const double CALLISTO_PERIOD_TOL = 7210.6; // Callisto +static const double TITAN_PERIOD_TOL = 6889.9; // Titan + + +// === Drift tolerance for distance checks (10% bound) === +static const double DRIFT_REL_TOL = 0.1; + +SCENARIO("Multi-body moon orbital stability and period measurements", + "[moon][earth][jupiter][saturn][galilean][titan]" + "[stability][period][integration][inclined][geometry]") { + // === Fixture === + const double TIME_STEP = 60.0; + const double SECONDS_PER_DAY = 86400.0; + + SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP); + REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml")); + + CelestialBody* earth = &sim->bodies[2]; + CelestialBody* moon = &sim->bodies[8]; + + struct MoonData { + int index; + const char* name; + double expected_seconds; + double max_days; + double min_time_days; + double period_tol; + int parent_index; + }; + + const MoonData galilean[] = { + {9, "Io", 152928.62, 5.0, 1.0, IO_PERIOD_TOL, 4}, + {10, "Europa", 306909.10, 10.0, 2.0, EUROPA_PERIOD_TOL, 4}, + {11, "Ganymede", 618227.12, 15.0, 5.0, GANYMEDE_PERIOD_TOL, 4}, + {12, "Callisto", 1442117.30, 25.0, 10.0, CALLISTO_PERIOD_TOL, 4}, + }; + + const MoonData all_moons[] = { + {8, "Moon", 2372274.33, 35.0, 5.0, MOON_PERIOD_TOL, 2}, + {9, "Io", 152928.62, 5.0, 1.0, IO_PERIOD_TOL, 4}, + {10, "Europa", 306909.10, 10.0, 2.0, EUROPA_PERIOD_TOL, 4}, + {11, "Ganymede", 618227.12, 15.0, 5.0, GANYMEDE_PERIOD_TOL, 4}, + {12, "Callisto", 1442117.30, 25.0, 10.0, CALLISTO_PERIOD_TOL, 4}, + {13, "Titan", 1377976.07, 25.0, 10.0, TITAN_PERIOD_TOL, 5}, + }; + + // === Helper lambdas === + // Run sim loop without assertions; collect failures; assert once at end. + // This tests every timestep but produces only 1 assertion per call. + auto check_parent_stability = [&](CelestialBody* body, int expected_parent, + double max_days) { + double max_time = max_days * SECONDS_PER_DAY; + std::vector failures; + while (sim->time < max_time) { + update_simulation(sim); + if (body->parent_index != expected_parent) { + failures.push_back(fmt_time(sim->time) + ": " + + std::string(body->name) + + " parent=" + std::to_string(body->parent_index) + + " (expected " + std::to_string(expected_parent) + ")"); + } + } + REQUIRE(failures.empty()); + }; + + auto measure_period = [&](int body_index, int parent_index, + double max_days, double min_time_days) { + sim->time = 0.0; + OrbitTracker* tracker = create_orbit_tracker_with_min_time( + body_index, min_time_days * SECONDS_PER_DAY); + double max_time = max_days * SECONDS_PER_DAY; + + while (sim->time < max_time && !tracker->orbit_completed) { + update_simulation(sim); + update_orbit_tracker(tracker, &sim->bodies[body_index], + &sim->bodies[parent_index], sim->time); + } + + double period = tracker->orbit_completed + ? tracker->time_at_completion : -1.0; + destroy_orbit_tracker(tracker); + return period; + }; + + auto check_distance_drift = [&](CelestialBody* body, int parent_index, + double max_days) { + CelestialBody* parent = &sim->bodies[parent_index]; + Vec3 initial_rel = vec3_sub(body->global_position, parent->global_position); + double initial_r = vec3_magnitude(initial_rel); + double max_time = max_days * SECONDS_PER_DAY; + + std::vector failures; + while (sim->time < max_time) { + update_simulation(sim); + Vec3 rel = vec3_sub(body->global_position, parent->global_position); + double r = vec3_magnitude(rel); + double drift = std::fabs(r - initial_r) / initial_r; + if (drift > DRIFT_REL_TOL) { + failures.push_back(fmt_time(sim->time) + ": " + + std::string(body->name) + " " + + fmt_drift(drift)); + } + } + REQUIRE(failures.empty()); + }; + + auto wait_for_all_orbits = [&](int count) { + OrbitTracker* trackers[6] = {}; + for (int i = 0; i < count; i++) { + trackers[i] = create_orbit_tracker_with_min_time( + all_moons[i].index, all_moons[i].min_time_days * SECONDS_PER_DAY); + } + + double max_time = 60.0 * SECONDS_PER_DAY; + int completed_count = 0; + + while (sim->time < max_time && completed_count < count) { + update_simulation(sim); + for (int i = 0; i < count; i++) { + if (!trackers[i]->orbit_completed) { + update_orbit_tracker(trackers[i], &sim->bodies[all_moons[i].index], + &sim->bodies[all_moons[i].parent_index], sim->time); + if (trackers[i]->orbit_completed) { + completed_count++; + double period = trackers[i]->time_at_completion / SECONDS_PER_DAY; + INFO(all_moons[i].name << " completed orbit at " + << period << " days"); + } + } + } + } + + for (int i = 0; i < count; i++) { + REQUIRE(trackers[i]->orbit_completed); + destroy_orbit_tracker(trackers[i]); + } + }; + + // === Sections === + + SECTION("Moon maintains Earth as parent throughout simulation") { + check_parent_stability(moon, 2, 35.0); + } + + SECTION("Moon distance from Earth stays within 10% of initial") { + check_distance_drift(moon, 2, 35.0); + } + + SECTION("Moon completes orbit in ~27.3 days") { + double period = measure_period(8, 2, 35.0, 5.0); + INFO("Measured Moon period: " << period / SECONDS_PER_DAY + << " days (expected: ~27.3)"); + REQUIRE_THAT(period, WithinAbs(2372274.33, MOON_PERIOD_TOL)); + } + + SECTION("Galilean moons maintain Jupiter as parent") { + for (const auto& m : galilean) { + check_parent_stability(&sim->bodies[m.index], 4, 25.0); + } + } + + SECTION("Galilean moons complete orbits in expected periods") { + for (const auto& m : galilean) { + double period = measure_period(m.index, m.parent_index, + m.max_days, m.min_time_days); + INFO(m.name << " period: " << period / SECONDS_PER_DAY + << " days (expected: " << m.expected_seconds / SECONDS_PER_DAY + << ")"); + REQUIRE_THAT(period, WithinAbs(m.expected_seconds, m.period_tol)); + } + } + + SECTION("Galilean moons stay within 10% of initial distance") { + for (const auto& m : galilean) { + check_distance_drift(&sim->bodies[m.index], m.parent_index, 25.0); + } + } + + SECTION("Titan maintains Saturn as parent") { + check_parent_stability(&sim->bodies[13], 5, 25.0); + } + + SECTION("Titan completes orbit in ~15.95 days") { + double period = measure_period(13, 5, 25.0, 10.0); + INFO("Measured Titan period: " << period / SECONDS_PER_DAY + << " days (expected: ~15.95)"); + REQUIRE_THAT(period, WithinAbs(1377976.07, TITAN_PERIOD_TOL)); + } + + SECTION("Titan distance from Saturn stays within 10%") { + check_distance_drift(&sim->bodies[13], 5, 25.0); + } + + SECTION("All moons maintain correct parents over 60-day simulation") { + double max_time = 60.0 * SECONDS_PER_DAY; + std::vector failures; + while (sim->time < max_time) { + update_simulation(sim); + for (int i = 0; i < 6; i++) { + int idx = all_moons[i].index; + int expected = all_moons[i].parent_index; + if (sim->bodies[idx].parent_index != expected) { + failures.push_back(fmt_time(sim->time) + ": " + + std::string(all_moons[i].name) + + " parent=" + + std::to_string(sim->bodies[idx].parent_index) + + " (expected " + std::to_string(expected) + ")"); + } + } + } + REQUIRE(failures.empty()); + } + + SECTION("All moons complete at least one orbit") { + wait_for_all_orbits(6); + } + + SECTION("Moon inclined orbit produces non-zero z-coordinate") { + double max_time = 10.0 * SECONDS_PER_DAY; + while (sim->time < max_time) { + update_simulation(sim); + } + + Vec3 rel = vec3_sub(moon->global_position, earth->global_position); + double z = rel.z; + INFO("Moon z-coordinate relative to Earth: " << z << " m"); + INFO("Moon orbital inclination: " + << (moon->orbit.inclination * 180.0 / M_PI) << " degrees"); + REQUIRE_THAT(z, !WithinAbs(0.0, 10000000.0)); + } + + SECTION("Moon orbital elements loaded correctly from config") { + REQUIRE_THAT(moon->orbit.eccentricity, WithinAbs(0.055, E_TOL)); + REQUIRE_THAT(moon->orbit.inclination, + WithinAbs(5.16 * M_PI / 180.0, ANG_TOL)); + REQUIRE_THAT(moon->orbit.longitude_of_ascending_node, + WithinAbs(125.08 * M_PI / 180.0, ANG_TOL)); + REQUIRE_THAT(moon->orbit.argument_of_periapsis, + WithinAbs(318.15 * M_PI / 180.0, ANG_TOL)); + } + + destroy_simulation(sim); +} diff --git a/tests/test_moon_orbits.toml b/tests/test_moon_orbits.toml new file mode 100644 index 0000000..ffe4491 --- /dev/null +++ b/tests/test_moon_orbits.toml @@ -0,0 +1,184 @@ +# Moon Orbits Test Configuration +# Auto-generated by scripts/precalc_moon_orbits.py +# Data source: docs/planetary_data.md (JPL planetary facts) +# Mean anomaly converted to true anomaly via Kepler's equation + +[[bodies]] +name = "Sun" +mass = 1.989e+30 +radius = 696000000.0 +parent_index = -1 +color = { r = 1.0, g = 1.0, b = 0.0 } +orbit.semi_major_axis = 0.0 +orbit.eccentricity = 0.0 +orbit.true_anomaly = 0.0 + +[[bodies]] +name = "Venus" +mass = 4.87e+24 +radius = 6052000.0 +parent_index = 0 +color = { r = 0.5, g = 0.5, b = 0.5 } +orbit.semi_major_axis = 1.081608e+11 +orbit.eccentricity = 0.007 +orbit.inclination = 0.059166661642608 +orbit.longitude_of_ascending_node = 1.338318470429252 +orbit.argument_of_periapsis = 0.958534825195286 +orbit.true_anomaly = 0.890141231198106 + +[[bodies]] +name = "Earth" +mass = 5.97e+24 +radius = 6378000.0 +parent_index = 0 +color = { r = 0.5, g = 0.5, b = 0.5 } +orbit.semi_major_axis = 1.496000e+11 +orbit.eccentricity = 0.017 +orbit.inclination = 0.000000000000000 +orbit.longitude_of_ascending_node = 0.000000000000000 +orbit.argument_of_periapsis = 1.796641932002963 +orbit.true_anomaly = 6.238578647164619 + +[[bodies]] +name = "Mars" +mass = 6.42e+23 +radius = 3396000.0 +parent_index = 0 +color = { r = 0.5, g = 0.5, b = 0.5 } +orbit.semi_major_axis = 2.279904e+11 +orbit.eccentricity = 0.093 +orbit.inclination = 0.032288591161895 +orbit.longitude_of_ascending_node = 0.864985177288390 +orbit.argument_of_periapsis = 5.000368306963754 +orbit.true_anomaly = 0.407676817724149 + +[[bodies]] +name = "Jupiter" +mass = 1.898e+27 +radius = 71492000.0 +parent_index = 0 +color = { r = 0.5, g = 0.5, b = 0.5 } +orbit.semi_major_axis = 7.783688e+11 +orbit.eccentricity = 0.049 +orbit.inclination = 0.022863813201126 +orbit.longitude_of_ascending_node = 1.753532299478703 +orbit.argument_of_periapsis = 4.786565473594449 +orbit.true_anomaly = 0.378299755400337 + +[[bodies]] +name = "Saturn" +mass = 5.683e+26 +radius = 60268000.0 +parent_index = 0 +color = { r = 0.5, g = 0.5, b = 0.5 } +orbit.semi_major_axis = 1.426735e+12 +orbit.eccentricity = 0.057 +orbit.inclination = 0.043458698374659 +orbit.longitude_of_ascending_node = 1.983741227816755 +orbit.argument_of_periapsis = 5.915618966709580 +orbit.true_anomaly = 5.457583789473037 + +[[bodies]] +name = "Uranus" +mass = 8.68e+25 +radius = 25559000.0 +parent_index = 0 +color = { r = 0.5, g = 0.5, b = 0.5 } +orbit.semi_major_axis = 2.870824e+12 +orbit.eccentricity = 0.046 +orbit.inclination = 0.013439035240356 +orbit.longitude_of_ascending_node = 1.291892712326203 +orbit.argument_of_periapsis = 1.691922176883303 +orbit.true_anomaly = 2.537061863932694 + +[[bodies]] +name = "Neptune" +mass = 1.02e+26 +radius = 24764000.0 +parent_index = 0 +color = { r = 0.5, g = 0.5, b = 0.5 } +orbit.semi_major_axis = 4.498472e+12 +orbit.eccentricity = 0.01 +orbit.inclination = 0.030892327760300 +orbit.longitude_of_ascending_node = 2.299994888278127 +orbit.argument_of_periapsis = 4.767890450598109 +orbit.true_anomaly = 4.516812758860357 + +[[bodies]] +name = "Moon" +mass = 7.35e+22 +radius = 1738000.0 +parent_index = 2 +color = { r = 0.7, g = 0.7, b = 0.7 } +orbit.semi_major_axis = 3.844000e+08 +orbit.eccentricity = 0.055 +orbit.inclination = 0.090058989402907 +orbit.longitude_of_ascending_node = 2.183057828394507 +orbit.argument_of_periapsis = 5.552765015219959 +orbit.true_anomaly = 2.434643529152418 + +[[bodies]] +name = "Io" +mass = 8.93e+23 +radius = 1822000.0 +parent_index = 4 +color = { r = 0.7, g = 0.7, b = 0.7 } +orbit.semi_major_axis = 4.218000e+08 +orbit.eccentricity = 0.004 +orbit.inclination = 0.000000000000000 +orbit.longitude_of_ascending_node = 0.000000000000000 +orbit.argument_of_periapsis = 0.856956662729216 +orbit.true_anomaly = 5.771386752330690 + +[[bodies]] +name = "Europa" +mass = 4.8e+23 +radius = 1561000.0 +parent_index = 4 +color = { r = 0.7, g = 0.7, b = 0.7 } +orbit.semi_major_axis = 6.711000e+08 +orbit.eccentricity = 0.009 +orbit.inclination = 0.008726646259972 +orbit.longitude_of_ascending_node = 3.211405823669566 +orbit.argument_of_periapsis = 0.785398163397448 +orbit.true_anomaly = 6.023780086902404 + +[[bodies]] +name = "Ganymede" +mass = 1.48e+24 +radius = 2631000.0 +parent_index = 4 +color = { r = 0.7, g = 0.7, b = 0.7 } +orbit.semi_major_axis = 1.070400e+09 +orbit.eccentricity = 0.001 +orbit.inclination = 0.003490658503989 +orbit.longitude_of_ascending_node = 1.021017612416683 +orbit.argument_of_periapsis = 3.460987906704756 +orbit.true_anomaly = 5.667675367373862 + +[[bodies]] +name = "Callisto" +mass = 1.08e+24 +radius = 2410000.0 +parent_index = 4 +color = { r = 0.7, g = 0.7, b = 0.7 } +orbit.semi_major_axis = 1.882700e+09 +orbit.eccentricity = 0.007 +orbit.inclination = 0.005235987755983 +orbit.longitude_of_ascending_node = 5.394812717914473 +orbit.argument_of_periapsis = 0.764454212373516 +orbit.true_anomaly = 1.539408451124606 + +[[bodies]] +name = "Titan" +mass = 1.35e+24 +radius = 2575000.0 +parent_index = 5 +color = { r = 0.7, g = 0.7, b = 0.7 } +orbit.semi_major_axis = 1.221900e+09 +orbit.eccentricity = 0.029 +orbit.inclination = 0.005235987755983 +orbit.longitude_of_ascending_node = 1.371828792067543 +orbit.argument_of_periapsis = 1.366592804311560 +orbit.true_anomaly = 0.216397080390910 +