Browse Source
- Merge initial conditions check into single SCENARIO - Tighten energy check to relative error (1e-10) vs KE - Replace qualitative checks with quantitative WithinAbs assertions - Use named tolerance constants throughout fixture - Update precalc_parabolic_orbit.py to output SI units (m, m/s) - Precalculate expected values with full precision from Python - Python and C++ produce identical results in SI units - Add semi_latus_rectum support in sim_engine.py for parabolic orbitstest-refactor
5 changed files with 288 additions and 1 deletions
@ -0,0 +1,118 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Precalculate expected values for test_parabolic_orbit. |
||||||
|
Simulates a parabolic comet orbiting the Sun for 300 days. |
||||||
|
""" |
||||||
|
|
||||||
|
import math |
||||||
|
import sys |
||||||
|
sys.path.insert(0, "scripts") |
||||||
|
from sim_engine import Simulator, vmag, G |
||||||
|
|
||||||
|
def main(): |
||||||
|
sim = Simulator("tests/test_parabolic_orbit.toml", dt=60.0) |
||||||
|
|
||||||
|
comet = sim.get_body("ParabolicComet") |
||||||
|
sun = sim.get_body("Sun") |
||||||
|
|
||||||
|
# Initial state |
||||||
|
r0 = vmag(comet.global_pos) |
||||||
|
v0 = vmag(comet.global_vel) |
||||||
|
mu = G * sun.mass |
||||||
|
escape_v0 = math.sqrt(2.0 * mu / r0) |
||||||
|
circular_v0 = math.sqrt(mu / r0) |
||||||
|
|
||||||
|
print(f"// === Initial Conditions (SI units) ===") |
||||||
|
print(f"// Distance: {r0:.6f} m ({r0 / 1.496e11:.6f} AU)") |
||||||
|
print(f"// Velocity: {v0:.6f} m/s ({v0 / 1000.0:.6f} km/s)") |
||||||
|
print(f"// Escape velocity: {escape_v0:.6f} m/s ({escape_v0 / 1000.0:.6f} km/s)") |
||||||
|
print(f"// Circular velocity: {circular_v0:.6f} m/s ({circular_v0 / 1000.0:.6f} km/s)") |
||||||
|
print(f"// Velocity error from escape: {(abs(v0 - escape_v0) / escape_v0) * 100.0:.6f}%") |
||||||
|
print(f"// Eccentricity: {comet.orbit.e:.6f}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Energy at start (local frame, comet relative to sun) |
||||||
|
KE0 = 0.5 * comet.mass * v0**2 |
||||||
|
PE0 = -mu * comet.mass / r0 |
||||||
|
E0 = KE0 + PE0 |
||||||
|
|
||||||
|
print(f"// === Energy (Joules) ===") |
||||||
|
print(f"// Initial KE: {KE0:.6e}") |
||||||
|
print(f"// Initial PE: {PE0:.6e}") |
||||||
|
print(f"// Initial total E: {E0:.6e}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Run simulation for 300 days |
||||||
|
total_seconds = 300.0 * 86400.0 |
||||||
|
steps = int(total_seconds / sim.dt) |
||||||
|
print(f"// Total steps: {steps}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Record states every 1000 steps |
||||||
|
distances = [] |
||||||
|
velocities = [] |
||||||
|
energies = [] |
||||||
|
|
||||||
|
for i in range(steps): |
||||||
|
sim._step() |
||||||
|
|
||||||
|
if i % 1000 == 0: |
||||||
|
r = vmag(comet.global_pos) |
||||||
|
v = vmag(comet.global_vel) |
||||||
|
KE = 0.5 * comet.mass * v**2 |
||||||
|
PE = -mu * comet.mass / r |
||||||
|
E = KE + PE |
||||||
|
distances.append(r) |
||||||
|
velocities.append(v) |
||||||
|
energies.append(E) |
||||||
|
|
||||||
|
print(f"// Step {i}: t={sim.time/86400.0:.1f} days, r={r:.6f} m ({r/1.496e11:.4f} AU), v={v:.6f} m/s ({v/1000.0:.4f} km/s), E={E:.6e}") |
||||||
|
|
||||||
|
# Final state |
||||||
|
rf = vmag(comet.global_pos) |
||||||
|
vf = vmag(comet.global_vel) |
||||||
|
KEf = 0.5 * comet.mass * vf**2 |
||||||
|
PEf = -mu * comet.mass / rf |
||||||
|
Ef = KEf + PEf |
||||||
|
|
||||||
|
print() |
||||||
|
print(f"// === Final State (t=300 days) ===") |
||||||
|
print(f"// Distance: {rf:.6f} m ({rf / 1.496e11:.6f} AU)") |
||||||
|
print(f"// Velocity: {vf:.6f} m/s ({vf / 1000.0:.6f} km/s)") |
||||||
|
print(f"// Final KE: {KEf:.6e}") |
||||||
|
print(f"// Final PE: {PEf:.6e}") |
||||||
|
print(f"// Final total E: {Ef:.6e}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Energy drift |
||||||
|
avg_KE = (KE0 + KEf) / 2.0 |
||||||
|
energy_drift = abs(Ef - E0) |
||||||
|
energy_drift_pct = (energy_drift / avg_KE) * 100.0 if avg_KE > 0 else 0.0 |
||||||
|
|
||||||
|
print(f"// === Energy Drift ===") |
||||||
|
print(f"// Absolute drift: {energy_drift:.6e} J") |
||||||
|
print(f"// Drift percent: {energy_drift_pct:.6f}%") |
||||||
|
print() |
||||||
|
|
||||||
|
# Velocity trend |
||||||
|
vel_decreases = 0 |
||||||
|
for i in range(1, len(velocities)): |
||||||
|
if velocities[i] < velocities[i-1]: |
||||||
|
vel_decreases += 1 |
||||||
|
total_checks = len(velocities) - 1 |
||||||
|
|
||||||
|
print(f"// === Velocity Trend ===") |
||||||
|
print(f"// Velocity decreases: {vel_decreases} / {total_checks}") |
||||||
|
print(f"// Ratio: {vel_decreases / total_checks:.2%}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Assertions summary |
||||||
|
print(f"// === Assertions ===") |
||||||
|
print(f"// final_distance ({rf:.2f} m) > initial_distance ({r0:.2f} m): {rf > r0}") |
||||||
|
print(f"// final_velocity ({vf:.2f} m/s) < initial_velocity ({v0:.2f} m/s): {vf < v0}") |
||||||
|
print(f"// E0 >= -1e25: {E0 >= -1e25}") |
||||||
|
print(f"// energy_drift_pct < 1.0: {energy_drift_pct < 1.0}") |
||||||
|
print(f"// vel_decreases > total/2: {vel_decreases > total_checks // 2}") |
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
main() |
||||||
@ -0,0 +1,145 @@ |
|||||||
|
#include <catch2/catch_test_macros.hpp> |
||||||
|
#include <catch2/matchers/catch_matchers_floating_point.hpp> |
||||||
|
#include "../src/physics.h" |
||||||
|
#include "../src/simulation.h" |
||||||
|
#include "../src/config_loader.h" |
||||||
|
#include "../src/test_utilities.h" |
||||||
|
#include <cmath> |
||||||
|
#include <vector> |
||||||
|
|
||||||
|
using Catch::Matchers::WithinAbs; |
||||||
|
|
||||||
|
SCENARIO("Parabolic orbit - escape trajectory and initial conditions", |
||||||
|
"[parabolic][energy][escape][initial]") { |
||||||
|
// Fixture constants
|
||||||
|
const double TIME_STEP = 60.0; |
||||||
|
const double DAYS_TO_SIMULATE = 300.0; |
||||||
|
const double SECONDS_PER_DAY = 86400.0; |
||||||
|
const double AU = 1.496e11; |
||||||
|
|
||||||
|
// Tolerance constants (precise per observed errors)
|
||||||
|
const double V_ESCAPE_TOL = 1e-6; // velocity match to escape velocity
|
||||||
|
const double ECC_TOL = 1e-4; // eccentricity = 1.0
|
||||||
|
const double ENERGY_REL_TOL = 1e-10; // energy relative error
|
||||||
|
const double DIST_TOL = 1.0; // final distance (m) - Python/C++ match to 0.27m
|
||||||
|
const double VEL_TOL = 0.001; // final velocity (m/s) - Python/C++ match to 0.2mm/s
|
||||||
|
const double DRIFT_TOL = 1e-12; // energy drift percent
|
||||||
|
const double VEL_DECREASE_TOL = 0.9; // velocity decrease ratio
|
||||||
|
|
||||||
|
SimulationState* sim = create_simulation(10, 0, 0, TIME_STEP); |
||||||
|
REQUIRE(load_system_config(sim, "tests/test_parabolic_orbit.toml")); |
||||||
|
|
||||||
|
const int COMET_INDEX = 1; |
||||||
|
const int SUN_INDEX = 0; |
||||||
|
CelestialBody* comet = &sim->bodies[COMET_INDEX]; |
||||||
|
CelestialBody* sun = &sim->bodies[SUN_INDEX]; |
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const double initial_distance = vec3_magnitude(comet->global_position); |
||||||
|
const double initial_velocity = vec3_magnitude(comet->global_velocity); |
||||||
|
const double initial_kinetic = calculate_kinetic_energy(comet); |
||||||
|
const double initial_potential = calculate_potential_energy_pair(comet, sun); |
||||||
|
const double initial_total_energy = initial_kinetic + initial_potential; |
||||||
|
|
||||||
|
INFO("Initial distance: " << initial_distance / AU << " AU"); |
||||||
|
INFO("Initial velocity: " << initial_velocity / 1000.0 << " km/s"); |
||||||
|
INFO("Initial kinetic energy: " << initial_kinetic); |
||||||
|
INFO("Initial potential energy: " << initial_potential); |
||||||
|
INFO("Initial total energy: " << initial_total_energy); |
||||||
|
|
||||||
|
SECTION("velocity matches escape velocity") { |
||||||
|
const double distance = vec3_distance(comet->global_position, sun->global_position); |
||||||
|
const double escape_velocity = sqrt(2.0 * G * sun->mass / distance); |
||||||
|
const double circular_velocity = sqrt(G * sun->mass / distance); |
||||||
|
|
||||||
|
INFO("Distance: " << distance / AU << " AU"); |
||||||
|
INFO("Actual velocity: " << initial_velocity / 1000.0 << " km/s"); |
||||||
|
INFO("Escape velocity: " << escape_velocity / 1000.0 << " km/s"); |
||||||
|
INFO("Circular velocity: " << circular_velocity / 1000.0 << " km/s"); |
||||||
|
|
||||||
|
const double velocity_error = fabs(initial_velocity - escape_velocity) / escape_velocity; |
||||||
|
INFO("Velocity error from escape velocity: " << velocity_error * 100.0 << "%"); |
||||||
|
REQUIRE_THAT(velocity_error, WithinAbs(0.0, V_ESCAPE_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("eccentricity equals 1.0") { |
||||||
|
INFO("Eccentricity: " << comet->orbit.eccentricity); |
||||||
|
REQUIRE_THAT(comet->orbit.eccentricity, WithinAbs(1.0, ECC_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("total energy near zero (relative to KE)") { |
||||||
|
// For a parabolic orbit, total energy should be zero. Due to
|
||||||
|
// floating-point cancellation of two large terms (~8.87e22), the
|
||||||
|
// absolute value is ~1.68e7 J, but the relative error is ~2e-16.
|
||||||
|
const double relative_error = fabs(initial_total_energy) / initial_kinetic; |
||||||
|
INFO("Initial total energy: " << initial_total_energy << " J"); |
||||||
|
INFO("Relative error: " << relative_error); |
||||||
|
REQUIRE_THAT(relative_error, WithinAbs(0.0, ENERGY_REL_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
// Record velocities for trend analysis (every 1000 steps)
|
||||||
|
std::vector<double> velocities; |
||||||
|
velocities.push_back(initial_velocity); |
||||||
|
|
||||||
|
const double max_time = DAYS_TO_SIMULATE * SECONDS_PER_DAY; |
||||||
|
int step_count = 0; |
||||||
|
while (sim->time < max_time) { |
||||||
|
if (step_count % 1000 == 0) { |
||||||
|
velocities.push_back(vec3_magnitude(comet->global_velocity)); |
||||||
|
} |
||||||
|
update_simulation(sim); |
||||||
|
step_count++; |
||||||
|
} |
||||||
|
|
||||||
|
// Final state
|
||||||
|
const double final_distance = vec3_magnitude(comet->global_position); |
||||||
|
const double final_velocity = vec3_magnitude(comet->global_velocity); |
||||||
|
const double final_kinetic = calculate_kinetic_energy(comet); |
||||||
|
const double final_potential = calculate_potential_energy_pair(comet, sun); |
||||||
|
const double final_total_energy = final_kinetic + final_potential; |
||||||
|
|
||||||
|
INFO("Final distance: " << final_distance / AU << " AU"); |
||||||
|
INFO("Final velocity: " << final_velocity / 1000.0 << " km/s"); |
||||||
|
INFO("Final kinetic energy: " << final_kinetic); |
||||||
|
INFO("Final potential energy: " << final_potential); |
||||||
|
INFO("Final total energy: " << final_total_energy); |
||||||
|
|
||||||
|
// Precalculated expected values from scripts/precalc_parabolic_orbit.py
|
||||||
|
const double expected_distance = 372192353748.3338; // 2.487917 AU
|
||||||
|
const double expected_velocity = 26708.624837; // 26.708625 km/s
|
||||||
|
|
||||||
|
SECTION("final distance matches escape trajectory") { |
||||||
|
REQUIRE_THAT(final_distance, WithinAbs(expected_distance, DIST_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("final velocity matches escape trajectory") { |
||||||
|
REQUIRE_THAT(final_velocity, WithinAbs(expected_velocity, VEL_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("energy drift near zero") { |
||||||
|
const double energy_drift = fabs(final_total_energy - initial_total_energy); |
||||||
|
const double avg_kinetic = (initial_kinetic + final_kinetic) / 2.0; |
||||||
|
const double drift_pct = (energy_drift / avg_kinetic) * 100.0; |
||||||
|
|
||||||
|
INFO("Energy drift: " << energy_drift << " J"); |
||||||
|
INFO("Energy drift percent: " << drift_pct << "%"); |
||||||
|
REQUIRE_THAT(drift_pct, WithinAbs(0.0, DRIFT_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("velocity monotonically decreases (escape trajectory)") { |
||||||
|
int velocity_decreases = 0; |
||||||
|
for (size_t i = 1; i < velocities.size(); i++) { |
||||||
|
if (velocities[i] < velocities[i - 1]) { |
||||||
|
velocity_decreases++; |
||||||
|
} |
||||||
|
} |
||||||
|
const int total_checks = static_cast<int>(velocities.size()) - 1; |
||||||
|
const double decrease_ratio = static_cast<double>(velocity_decreases) / total_checks; |
||||||
|
|
||||||
|
INFO("Velocity decreases: " << velocity_decreases << " / " << total_checks); |
||||||
|
INFO("Decrease ratio: " << decrease_ratio); |
||||||
|
REQUIRE_THAT(decrease_ratio, WithinAbs(1.0, 1.0 - VEL_DECREASE_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
destroy_simulation(sim); |
||||||
|
} |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
# Test Configuration: Sun + Parabolic Comet |
||||||
|
# Comet with parabolic orbit (eccentricity = 1.0) |
||||||
|
# Escape trajectory - total energy = 0 |
||||||
|
|
||||||
|
[[bodies]] |
||||||
|
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 } |
||||||
|
|
||||||
|
[[bodies]] |
||||||
|
name = "ParabolicComet" |
||||||
|
mass = 1.0e14 |
||||||
|
radius = 5.0e3 |
||||||
|
parent_index = 0 |
||||||
|
color = { r = 0.7, g = 0.8, b = 0.9 } |
||||||
|
orbit = { semi_latus_rectum = 2.992e11, eccentricity = 1.0, true_anomaly = 0.0 } |
||||||
Loading…
Reference in new issue