Compare commits

...

91 Commits

Author SHA1 Message Date
cinnaboot 6c28eb1560 update manual TODO 3 weeks ago
cinnaboot 2d14dbfbcf cleanup: remove old_tests for test_maneuver_planning 3 weeks ago
cinnaboot 3bbac86ca5 refactor test_maneuver_planning: SCENARIO/SECTION pattern, quantitative assertions, TOML 1.0 config, precalc script 3 weeks ago
cinnaboot 804a82babb test_omega_debug: remove redundant qualitative checks, clean up precalc decorative comments 3 weeks ago
cinnaboot 2575457ade remove old test_omega_debug (refactored to tests/) 2 months ago
cinnaboot 5d8ee97610 refactor test_omega_debug: SCENARIO/SECTION pattern, precalculated values, quantitative assertions 2 months ago
cinnaboot 3e388d178b clarify SCENARIO/SECTION guidance: shared fixture, distinct paths, fewer SECTIONs 2 months ago
cinnaboot 926dbb20cc Remove old hybrid burns test and config (refactored to tests/) 2 months ago
cinnaboot 907e47e606 Replace hardcoded tolerances with named constants (REL_TOL, E_TOL) 2 months ago
cinnaboot e5275eda9e Use tolerance constants (ANG_TOL, R_TOL) instead of hardcoded values 2 months ago
cinnaboot 13e7e70bdd re-order continue.md logically 2 months ago
cinnaboot b762075214 continue.md: add section detailing final REQUIRE statement review 2 months ago
cinnaboot ed4841f00b Update continue.md: document hybrid burns refactoring completion and rel_err exception 2 months ago
cinnaboot 0a2c5c671a Refactor test_hybrid_burns: qualitative→quantitative checks with named tolerances 2 months ago
cinnaboot 58322c1a04 docs: update test_hybrid_burns status in continue.md 2 months ago
cinnaboot 20327a75d1 refactor: test_hybrid_burns - impulse + continuous burn behavior 2 months ago
cinnaboot b69c55e793 remove check mark character from completed list 2 months ago
cinnaboot bddde65fd3 update continue.md: unblock maneuver_planning, refresh sim_engine capabilities 2 months ago
cinnaboot f62bdc201b update continue doc 2 months ago
cinnaboot f5b8891b1a fix comment and tolerance in periapsis test 2 months ago
cinnaboot b2401305ea Remove redundant burn1_expected_v assertion, tighten SMA precision 2 months ago
cinnaboot 03fb6a3563 Use burn_result velocity for tight tolerance assertion 2 months ago
cinnaboot 964eff30eb Add burn_result capture to sim_engine.py and precalc script 2 months ago
cinnaboot 29d4082581 Add BurnResult to capture exact pre-burn state vectors 2 months ago
cinnaboot 23bde87960 clean: remove old_tests for test_periapsis_burn (refactored to tests/) 2 months ago
cinnaboot 31dea749cf refactor: test_periapsis_burn - Phase A+B 2 months ago
cinnaboot 52c46c3685 remove old_tests/test_integration.cpp; update continue.md with D_TOL and completed status 2 months ago
cinnaboot 287a6bbb03 fix compare_vec3 test: use D_TOL-compatible values, remove redundant section 2 months ago
cinnaboot c550f318e4 add D_TOL tolerance; tighten physics tests to 1e-12 2 months ago
cinnaboot 8638343251 re-add ctag update to default Makefile target 2 months ago
cinnaboot 137e45121a refactor: use compare_vec3 for 3-component assertions 2 months ago
cinnaboot 2cbffa8eb4 add refactored physics utilities test file 2 months ago
cinnaboot 5a4c81a663 remove obsolete tests: RK4, matrix identity, mat3_multiply identity check 2 months ago
cinnaboot 93b7252494 remove rk4_step, evaluate_acceleration; move compare_vec3 to physics module 2 months ago
cinnaboot ed786b3217 docs: clarify SCENARIO grouping for shared setup/teardown in refactoring rules 2 months ago
cinnaboot 6b3bdb2631 Remove redundant calculate_true_anomaly() function 2 months ago
cinnaboot 2a81779b45 chore: remove deprecated moon orbit tests from old_tests/ 2 months ago
cinnaboot d892aa8c41 feat: rewrite moon orbit config as TOML 1.0 dotted keys and refactor tests 2 months ago
cinnaboot fe6f9dcd5e remove extra decorative comment blocks in python scripts 2 months ago
cinnaboot 01ce10044e docs: add Laplace plane data limitations and decision note 2 months ago
cinnaboot 048b22a6e4 Add planetary data with full orbital elements and reference frame notes 2 months ago
cinnaboot ea70f14a34 refactor: improve test_analytical_propagation.cpp — merge redundant SECTIONs, fix names, remove unused constant 2 months ago
cinnaboot f7c43594d5 docs: optimize continue.md — remove redundancies and fix ambiguities 2 months ago
cinnaboot 372ae31658 docs: resolve 5 contradictions in continue.md 2 months ago
cinnaboot 8df95cf55a cleanup: remove old analytical_propagation files, update continue.md status 2 months ago
cinnaboot 432f8db7bb refactor: consolidate test_analytical_propagation into single SCENARIO with precalculated values 2 months ago
cinnaboot 03081090ff add correction to testing doc, continue.md 2 months ago
cinnaboot 8621dd4cb7 update .gitignore 2 months ago
cinnaboot 4701f0f3a0 refactor: test_analytical_propagation into SCENARIOs with TOML config loading 2 months ago
cinnaboot 629ca7d03a standardize tolerance constants in test_utilities.h 2 months ago
cinnaboot e07d0245ce docs: mark test_extreme_timescales as completed 2 months ago
cinnaboot fc447db7f2 refactor: test_extreme_timescales into SCENARIO with 11 sections 2 months ago
cinnaboot 71c00a7c94 remove reference to removed clean-all make target 2 months ago
cinnaboot db9d6c87db docs: session summary for test_extreme_orientation_mixed refactor 2 months ago
cinnaboot f8f7037a50 docs: update continue.md with test_extreme_orientation_mixed completion 2 months ago
cinnaboot 2e6b3d495c cleanup: remove old test files superseded by tests/test_extreme_orientation_mixed 2 months ago
cinnaboot a7ecc46f15 refactor: test_extreme_orientation_mixed — single SCENARIO, precalculated values, named tolerances 2 months ago
cinnaboot c46211cee0 cleanup: remove old test files superseded by tests/test_extreme_eccentricity 2 months ago
cinnaboot a72a92bbc1 refactor: test_extreme_eccentricity — single SCENARIO, precalculated values, REL_TOL 2 months ago
cinnaboot 7ac997594a cleanup: remove old test files now superseded by tests/ 2 months ago
cinnaboot 164dc4758f docs: reorganize continue.md for better logical flow 2 months ago
cinnaboot 1e4ed912d2 refactor: test_parabolic_orbit with SI-unit precalc and tightened tolerances 2 months ago
cinnaboot db5c781392 refactor: remove old_cartesian_to_elements_basic from old_tests/ 2 months ago
cinnaboot 9fd1e29947 refactor: cartesian_to_elements_basic — SCENARIO pattern, named tolerances, TOML 1.0 2 months ago
cinnaboot fea6be0819 remove refactored tests 2 months ago
cinnaboot 24b6466532 refactor: test_cartesian_to_elements_advanced + restore compare_vec3 + document procedure 2 months ago
cinnaboot 1361002196 Add test refactoring status tracking and first refactored test 2 months ago
cinnaboot e6b5da8b86 update continue.md: add sim engine capability matrix and improved precalc guidance 2 months ago
cinnaboot 13982eb222 tighten test_maneuvers assertions with precalculated values 2 months ago
cinnaboot b333a1745f refactor test_maneuvers: add spacecraft struct to sim_engine, rewrite tests 2 months ago
cinnaboot a5417e2274 add mat3_transpose() and use it in orbit tracker 2 months ago
cinnaboot 9e0a51999b remove wrap_count from OrbitTracker 2 months ago
cinnaboot 8486d8f2f3 update manual todo 2 months ago
cinnaboot 7fb2e7e5fa add more instructions for test refactoring 2 months ago
cinnaboot 9b14d8471c fix orbit tracker code quality issues 2 months ago
cinnaboot 181dd61666 add inclined orbit tests and fix orbit tracker 2 months ago
cinnaboot 0fb92ec348 refactor: create test_energy.cpp, move direction test from orbital_period 2 months ago
cinnaboot 848cee8ad2 remove unused metrics.angular_position 2 months ago
cinnaboot 55f9b248ff docs: clean up continue.md section ordering and stale TODOs 2 months ago
cinnaboot 2a86e7093a use old_tests/ while refactoring tests 2 months ago
cinnaboot 8d283d36f1 update manual TODO 2 months ago
cinnaboot 5fd1ae84d5 document '{}' instead of '{0}' for zero initialization 2 months ago
cinnaboot 0091d02704 refactor: rewrite test_orbital_period with SCENARIO/SECTION pattern 2 months ago
cinnaboot 30b8127a55 feat: add sim_engine.py and test_orbital_period.py for precalculation 2 months ago
cinnaboot 6edd30e759 refactor: improve OrbitTracker accuracy and test_utilities code quality 2 months ago
cinnaboot 2db9758d9b clean up .gitignore 2 months ago
cinnaboot bb2b01668f remove example application from default make target 2 months ago
cinnaboot 5b154db7cf update manual TODO 2 months ago
cinnaboot db7094abed refactor: test_true_anomaly_roundtrip - SCENARIO/SECTION pattern, tight tolerances 3 months ago
cinnaboot 13b0eac98a docs: add SCENARIO/SECTION patterns to technical reference 3 months ago
cinnaboot 9c455ccea5 refactor: true_anomaly_roundtrip - SCENARIO/SECTION pattern, tight tolerances 3 months ago
  1. 7
      .gitignore
  2. 2
      AGENTS.md
  3. 4
      Makefile
  4. 165
      continue.md
  5. 21
      docs/TODO
  6. 75
      docs/planetary_data.md
  7. 26
      docs/session_summaries/2026-04-30-test-extreme-orientation-mixed.md
  8. 11
      docs/technical_reference.md
  9. 0
      old_tests/test_hybrid_energy_conservation.cpp
  10. 0
      old_tests/test_hybrid_energy_conservation.toml
  11. 0
      old_tests/test_hyperbolic_orbit.cpp
  12. 0
      old_tests/test_hyperbolic_orbit.toml
  13. 0
      old_tests/test_invalid_parent_assignment.cpp
  14. 0
      old_tests/test_invalid_parent_assignment.toml
  15. 0
      old_tests/test_newton_raphson_convergence.cpp
  16. 0
      old_tests/test_orbit_rendering.cpp
  17. 0
      old_tests/test_orbit_rendering.toml
  18. 0
      old_tests/test_precision_boundaries.cpp
  19. 0
      old_tests/test_precision_boundaries.toml
  20. 0
      old_tests/test_rendezvous.cpp
  21. 0
      old_tests/test_rendezvous.toml
  22. 0
      old_tests/test_root_body_transitions.cpp
  23. 0
      old_tests/test_root_body_transitions.toml
  24. 0
      old_tests/test_soi_transition.cpp
  25. 0
      old_tests/test_soi_transition.toml
  26. 217
      scripts/precalc_analytical_propagation.py
  27. 234
      scripts/precalc_cartesian_to_elements_advanced.py
  28. 79
      scripts/precalc_cartesian_to_elements_basic.py
  29. 135
      scripts/precalc_extreme_eccentricity.py
  30. 56
      scripts/precalc_extreme_orientation_mixed.py
  31. 188
      scripts/precalc_extreme_timescales.py
  32. 545
      scripts/precalc_hybrid_burns.py
  33. 54
      scripts/precalc_inclined_orbits.py
  34. 128
      scripts/precalc_maneuver_planning.py
  35. 189
      scripts/precalc_maneuvers.py
  36. 360
      scripts/precalc_moon_orbits.py
  37. 129
      scripts/precalc_omega_debug.py
  38. 89
      scripts/precalc_parabolic_orbit.py
  39. 254
      scripts/precalc_periapsis_burn.py
  40. 920
      scripts/sim_engine.py
  41. 117
      scripts/test_orbital_period.py
  42. 9
      src/maneuver.h
  43. 49
      src/orbital_mechanics.cpp
  44. 3
      src/orbital_mechanics.h
  45. 64
      src/physics.cpp
  46. 11
      src/physics.h
  47. 7
      src/simulation.cpp
  48. 140
      src/test_utilities.cpp
  49. 27
      src/test_utilities.h
  50. 73
      tests/informational/Makefile
  51. 72
      tests/informational/README.md
  52. 296
      tests/informational/test_time_step_stability.cpp
  53. 80
      tests/informational/test_time_step_stability.toml
  54. 700
      tests/test_analytical_propagation.cpp
  55. 27
      tests/test_analytical_propagation.toml
  56. 227
      tests/test_barkers_equation.cpp
  57. 675
      tests/test_cartesian_to_elements_advanced.cpp
  58. 240
      tests/test_cartesian_to_elements_basic.cpp
  59. 20
      tests/test_cartesian_to_elements_basic.toml
  60. 42
      tests/test_energy.cpp
  61. 12
      tests/test_energy.toml
  62. 334
      tests/test_extreme_eccentricity.cpp
  63. 40
      tests/test_extreme_eccentricity.toml
  64. 586
      tests/test_extreme_orientation_mixed.cpp
  65. 62
      tests/test_extreme_orientation_mixed.toml
  66. 580
      tests/test_extreme_timescales.cpp
  67. 78
      tests/test_extreme_timescales.toml
  68. 1335
      tests/test_hybrid_burns.cpp
  69. 153
      tests/test_hybrid_burns.toml
  70. 192
      tests/test_inclined_orbits.cpp
  71. 24
      tests/test_inclined_orbits.toml
  72. 244
      tests/test_integration.cpp
  73. 165
      tests/test_maneuver_planning.cpp
  74. 24
      tests/test_maneuver_planning.toml
  75. 316
      tests/test_maneuvers.cpp
  76. 18
      tests/test_maneuvers.toml
  77. 437
      tests/test_moon_orbits.cpp
  78. 275
      tests/test_moon_orbits.toml
  79. 95
      tests/test_omega_debug.cpp
  80. 116
      tests/test_orbital_period.cpp
  81. 18
      tests/test_orbital_period.toml
  82. 167
      tests/test_parabolic_orbit.cpp
  83. 16
      tests/test_parabolic_orbit.toml
  84. 369
      tests/test_periapsis_burn.cpp
  85. 28
      tests/test_periapsis_burn.toml
  86. 225
      tests/test_physics_utilities.cpp
  87. 153
      tests/test_true_anomaly_roundtrip.cpp

7
.gitignore vendored

@ -1,12 +1,11 @@
# Build artifacts # Build artifacts
*.o *.o
build/ build/
orbit_sim */__pycache__
orbit_test
tests/informational/test_time_step_stability
# LLM summaries # LLM summaries
tmp/ tmp/
.pi/
# Session notes and conversation logs # Session notes and conversation logs
docs/session_logs docs/session_logs
@ -23,5 +22,3 @@ tags
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# non-portable custom scripts
sync.sh

2
AGENTS.md

@ -21,7 +21,7 @@
- No trailing whitespace in any files (including markdown) - No trailing whitespace in any files (including markdown)
- Pre-commit hook automatically strips it - Pre-commit hook automatically strips it
- For markdown line breaks, use <br> tag instead of two trailing spaces - For markdown line breaks, use <br> tag instead of two trailing spaces
- ZII (Zero Is Initialization) pattern: Initialize structs using `= {0}` or `= {NULL}` instead of individual field assignments. This guarantees all fields (including padding) are zeroed out. Example: `MyStruct s = {0};` - ZII (Zero Is Initialization) pattern: Initialize structs using `type_name s = {};`
## File Reading Policy ## File Reading Policy
- Ask before reading files unless immediately necessary for current task - Ask before reading files unless immediately necessary for current task

4
Makefile

@ -18,7 +18,9 @@ TEST_OBJECTS := $(patsubst $(TEST_DIR)/%.cpp, $(BUILD_DIR)/%.o, $(TEST_SOURCES))
LIBRARY = $(BUILD_DIR)/liborbit.a LIBRARY = $(BUILD_DIR)/liborbit.a
all: lib test-build example # Default target (updates ctags if available)
all: lib test-build
@command -v ctags >/dev/null 2>&1 && ctags -R tests/ src/ || true
$(BUILD_DIR): $(BUILD_DIR):
mkdir -p $(BUILD_DIR) mkdir -p $(BUILD_DIR)

165
continue.md

@ -0,0 +1,165 @@
# Test Refactoring Optimization Strategy
## Refactoring Rules
### 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 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))`.
### 2. Duplication Elimination
- Use lambdas that capture the fixture for repeated setup→call→assert patterns
- Reuse shared structs in-place (mutate fields rather than recreating)
### 3. Assertions
- Include `src/test_utilities.h` for tolerance constants and test utilities
- `#include <catch2/matchers/catch_matchers_floating_point.hpp>` (required for `WithinAbs` matcher)
- `using Catch::Matchers::WithinAbs;` after includes
- `REQUIRE_THAT(value, WithinAbs(expected, tolerance))` — never `Approx()`
- **Always use named tolerance constants** — never hardcode raw numbers for tolerance in `WithinAbs()`. Exception: relative error thresholds for continuous/low-thrust approximations (e.g., `WithinAbs(0.0, 0.01)` for 1% tolerance) when no named constant exists.
#### Tolerance Reference
All constants defined in `src/test_utilities.h` — use those, do not redefine locally.
| Constant | Value | Use for |
|----------|-------|---------|
| `D_TOL` | `1e-12` | Double-precision arithmetic (vec3, mat3 ops) |
| `A_TOL` | `1e-6` | Semi-major axis (meters) |
| `E_TOL` | `1e-12` | Eccentricity, round-trip conversion |
| `ANG_TOL` | `1e-12` | Angles in radians (nu, inc, Ω, ω) |
| `ANG_TOL_COARSE` | `1e-4` | Angles, degenerate cases (polar/retrograde) |
| `R_TOL` | `1e-6` | Radius / distance magnitudes (meters) |
| `V_TOL` | `1e-6` | Velocity magnitudes (m/s) |
| `M_TOL` | `1e-6` | Time / period values (seconds) |
| `REL_TOL` | `1e-8` | Relative / percentage errors (dimensionless) |
| `DRIFT_TOL` | `1e-12` | Energy drift percent (parabolic orbit) |
- Replace qualitative checks (`a > b`) with quantitative (`WithinAbs(expected, tol)`)
- `INFO("label: " << value)` for debugging context
### 4. Precalc Scripts
- For each test file, create `scripts/precalc_<test_name>.py` that computes expected values.
- **Always output local-frame values** (distances from parent, not global from origin).
- C++ tests typically use local coordinates (e.g., `vec3_distance(craft->local_position, (Vec3){0,0,0})`).
- Global distances are dominated by parent body positions (e.g., Earth-Sun distance swamps LEO orbit).
- **Always output SI units** (meters, m/s, seconds) — C++ tests use SI internally.
- Output C++-style comments with precalculated expected values for embedding in the test. Tolerances are chosen separately by the test writer using the Tolerance Reference table — the precalc script should not output tolerance values.
- **No decorative comments in precalc scripts.** Use simple blank lines between sections, no `# ====` or `# -----` separators.
- Run with: `python3 scripts/precalc_<test_name>.py`
## Test Refactoring Status
### Completed
- `test_barkers_equation` — Barker's equation unit tests + parabolic propagation
- `test_cartesian_to_elements_advanced` — Advanced conversion tests (eccentricity spectrum, inclination, true anomaly, 3D orientation)
- `test_cartesian_to_elements_basic` — Element round-trip conversion (semi-major axis, eccentricity, true anomaly, inclination, radius, velocity)
- `test_parabolic_orbit` — Parabolic orbit energy conservation + escape trajectory + initial conditions
- `test_extreme_eccentricity` — High-eccentricity orbits (single SCENARIO, precalculated values, REL_TOL)
- `test_extreme_orientation_mixed` — Extreme orientation conversions, rotation matrix properties, singularity handling
- `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
- `test_periapsis_burn` — prograde burns
- `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_orbit_rendering` — rendering tests (check if sim_engine needed)
- `test_precision_boundaries` — boundary condition tests
- `test_invalid_parent_assignment` — validation/error handling tests
- `test_newton_raphson_convergence` — numerical convergence tests
### Blocked on Missing Features
- `test_soi_transition` — needs SOI transitions
- `test_root_body_transitions` — needs SOI transitions
- `test_hybrid_energy_conservation` — needs energy functions (KE, PE, total)
- `test_hyperbolic_orbit` — needs hyperbolic propagation
- `test_rendezvous` — needs Hohmann transfer calculations
## Tooling & Sim Engine Capabilities
### Tooling
- `scripts/sim_engine.py` — Generic orbital mechanics simulator (Python, TOML 1.0 configs)
- Replicates C++ physics: Kepler propagation, orbital↔Cartesian transforms, drift detection
- Multi-body hierarchical propagation with global/local coordinate tracking
- Use for precalculating expected values (transition times, final states, energy conservation)
- TOML configs in `tests/` must use TOML 1.0 inline table syntax (single-line `{}`)
- Old configs in `old_tests/` use multiline inline tables (toml-c17 style) — keep for reference
- Python's `tomllib` requires single-line inline tables
### Sim Engine Capabilities
#### Implemented
- Maneuver trigger system (TIME and TRUE_ANOMALY triggers)
- BurnResult capture (position, velocity, true anomaly at exact burn time)
- Body propagation (elliptical + parabolic via Barker's equation)
- Orbital↔Cartesian transforms (full z-x-z Euler rotation)
- Velocity drift detection and element reconstruction
- Global coordinate computation (hierarchical parent→child)
- Spacecraft struct, loading, propagation
- Impulsive burns (prograde, retrograde, normal, antinormal, radial_in, radial_out, custom)
- TOML 1.0 config parsing
#### NOT Implemented (notify the user before beginning to refactor)
- SOI transitions
- Maneuver TRUE_ANOMALY triggers only work with elliptical orbits (parabolic/hyperbolic branches not implemented)
- Hohmann transfer calculations
- Rendezvous planning
- OrbitTracker
- Energy functions (KE, PE, total)
- Hyperbolic propagation
## Refactoring Procedure
### Step 1: Refactor
- **Pre-flight check:** Before starting, verify the python ./scripts/sim_engine.py supports all features the test needs (SOI, Hohmann transfers, rendezvous, hyperbolic propagation, energy functions). If any feature is missing, stop and report it to the user — do not begin refactoring until unblocked.
- Check if the test is already in `tests/` (already refactored). Skip if so.
- Process **one test file at a time**.
- Create `scripts/precalc_<test_name>.py` and run it to get expected values. Tests that use the simulation engine will need a precalc script regardless of whether a TOML config exists.
- Copy from `old_tests/` to `tests/`, rewrite using the pattern from `test_true_anomaly_roundtrip.cpp`.
- Verify the test file has a TOML config in `old_tests/`. If it doesn't, the test is likely hardcoded — still refactor the C++ code but skip the TOML rewrite step.
- Rewrite TOML configs to TOML 1.0 inline table syntax (single-line `{}`).
- Follow all rules in sections 1-4 above.
### Step 2: Tighten Tolerances
- Build and verify: `make test-build` then `./build/orbit_test -s '[tag]'`.
- Run full suite: `./build/orbit_test | tail`.
- Review every tolerance against actual observed errors from `-s` output.
- **If a test fails due to a tolerance being too tight, report the observed error to the user and ask whether to loosen the constant or investigate the root cause. Never silently widen a tolerance.**
- Refer to the tolerance reference table in Section 3 for constant names.
### Step 3: Code Review
- Remove unused includes (only include what's actually used).
- Remove unused variables (e.g., `const double mu = G * M_sun;` if never referenced).
- Look for repeated initialization patterns — extract into helper lambdas (`make_elements`, `convert_and_recover`).
- Use `const` for all fixture data and recovered results.
- Replace C-style arrays with `std::array` where appropriate.
- Ensure consistent tolerance usage (no hardcoded `1e-4` when `ANG_TOL_COARSE` exists).
- Check for `compare_vec3` availability in `test_utilities` instead of 6 individual `REQUIRE_THAT` calls.
- Run full suite again: `make test`.
#### Final Systematic REQUIRE Statement Review
After Step 3, review every `REQUIRE` in the test file:
1. `grep -Rn 'REQUIRE' tests/<test_name>.cpp`
2. Categorize each:
- **Hardcoded tolerances** → replace with named constant
- **Qualitative** (`a > b`, `x < 0.1`, `fabs(x) > 0`) → convert to quantitative via precalc
- **OK as-is** → booleans, integers, strings, enums
4. Verify precalc outputs all needed values
5. `make test-build``./build/orbit_test -s '[tag]'`
### Step 4: User interaction
- **Always ask for review** before moving to the next file.
- **Only commit when asked.**

21
docs/TODO

@ -2,18 +2,30 @@
DO NOT read, write, edit, restore, or commit this file under any circumstances. DO NOT read, write, edit, restore, or commit this file under any circumstances.
This is a manually maintained file - all changes must be made by humans only. This is a manually maintained file - all changes must be made by humans only.
If you see modifications to this file in git status, IGNORE them and do not commit. If you see modifications to this file in git status, IGNORE them and do not commit.
**Do not revert unstaged changes to this file**
======================== ========================
=== next steps === === next steps ===
- refactor test cases:
- use strict values in 'REQUIRE' tests
- 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
- 3d should be always, and use existing OrbitalElements struct
- remove RK4 integration implementation?
- interplanetary transfers - interplanetary transfers
- SOI boundary testing - SOI boundary testing
- draw SOI boundry in graphical sim - draw SOI boundry in graphical sim
- remove tests/informational/*
- remove RK4 integration implementation?
- refactor tests and check logic for edge cases
- add reset/load new config UI control - add reset/load new config UI control
- UI fixes - UI fixes
@ -25,7 +37,6 @@ If you see modifications to this file in git status, IGNORE them and do not comm
- remember to periodically check the reference docs against new changes - remember to periodically check the reference docs against new changes
=== code style === === code style ===
- document 'ZII' zero is initialization
- arena memory management? - arena memory management?
=== Simulation/Physics === === Simulation/Physics ===

75
docs/planetary_data.md

@ -0,0 +1,75 @@
# Planetary Data
## Planets
┌──────────┬────────────────┬──────────┬────────┬───────┬───────┬────────┬─────────┬──────────┬─────────┬────────┐
│ Body │ Mass (kg) │ Radius │ a │ e │ inc │ Ω │ ω │ Period │ Day │ M │
│ │ │ (km) │(AU) │ │ (°) │ (°) │ (°) │ (days) │ (hours) │ (°) │
├──────────┼────────────────┼──────────┼────────┼───────┼───────┼────────┼─────────┼──────────┼─────────┼────────┤
│ Venus │ 4.87×10²⁴ │ 6,052 │ 0.723 │ 0.007 │ 3.39 │ 76.68 │ 54.92 │ 224.7 │ 2,802.0 │ 50.38 │
│ Earth │ 5.97×10²⁴ │ 6,378 │ 1.000 │ 0.017 │ 0.00 │ 0.00 │ 102.94 │ 365.2 │ 24.0 │ −2.47 │
│ Mars │ 6.42×10²³ │ 3,396 │ 1.524 │ 0.093 │ 1.85 │ 49.56 │ 286.50 │ 687.0 │ 24.7 │ 19.39 │
│ Jupiter │ 1.898×10²⁷ │71,492 │ 5.203 │ 0.049 │ 1.31 │100.47 │ 274.25 │ 4,331 │ 9.9 │ 19.67 │
│ Saturn │ 5.68×10²⁶ │60,268 │ 9.537 │ 0.057 │ 2.49 │113.66 │ 338.94 │10,747 │ 10.7 │ −42.64 │
│ Uranus │ 8.68×10²⁵ │25,559 │19.19 │ 0.046 │ 0.77 │ 74.02 │ 96.94 │30,589 │ 17.2 │ 142.28 │
│ Neptune │ 1.02×10²⁶ │24,764 │30.07 │ 0.010 │ 1.77 │131.78 │ 273.18 │59,800 │ 16.1 │ −100.08│
└──────────┴────────────────┴──────────┴────────┴───────┴───────┴────────┴─────────┴──────────┴─────────┴────────┘
## Moons
┌──────────────┬────────────────┬──────────┬──────────┬───────┬───────┬────────┬─────────┬─────────┬───────┐
│ Moon │ Mass (kg) │ Radius │ a │ e │ inc │ Ω │ ω │ Period │ M │
│ │ │ (km) │ (km) │ │ (°) │ (°) │ (°) │ (days) │ (°) │
├──────────────┼────────────────┼──────────┼──────────┼───────┼───────┼────────┼─────────┼─────────┼───────┤
│ Moon (Earth) │ 7.35×10²² │ 1,738 │ 384,400 │ 0.055 │ 5.16 │125.08 │ 318.15 │ 27.322 │135.27 │
│ Io │ 8.93×10²³ │ 1,822 │ 421,800 │ 0.004 │ 0.00 │ 0.0 │ 49.1 │ 1.763 │330.9 │
│ Europa │ 4.80×10²³ │ 1,561 │ 671,100 │ 0.009 │ 0.50 │184.0 │ 45.0 │ 3.525 │345.4 │
│ Ganymede │ 1.48×10²⁴ │ 2,631 │1,070,400 │ 0.001 │ 0.20 │ 58.5 │ 198.3 │ 7.156 │324.8 │
│ Callisto │ 1.08×10²⁴ │ 2,410 │1,882,700 │ 0.007 │ 0.30 │309.1 │ 43.8 │ 16.690 │ 87.4 │
│ Titan │ 1.35×10²⁴ │ 2,575 │1,221,900 │ 0.029 │ 0.30 │ 78.6 │ 78.3 │ 15.945 │ 11.7 │
└──────────────┴────────────────┴──────────┴──────────┴───────┴───────┴────────┴─────────┴─────────┴───────┘
## Reference Frames
Source: https://ssd.jpl.nasa.gov/orbits.html
- **Planets**: All orbital elements are referenced to the **mean ecliptic and equinox of J2000**.
- **Moons**: The **source data** for moons is referenced to the **Laplace plane** (Jupiter and Saturn's moons) or the **ecliptic** (Earth's Moon). The Laplace plane is a hybrid reference plane between a planet's equator and its orbital plane around the Sun.
- **Important**: Moon inclination and node values are **not** referenced to the same plane as the planets. Converting to a common frame is required before combining into a single simulation.
### Moon Frame Transformation Plan
Source data provides for each moon: **Tilt** (angle between planet's equator and Laplace plane), **R.A.** and **Dec.** (Laplace plane pole position in ICRF).
Transformation approach using in-engine primitives:
1. Build a rotation matrix from Laplace plane to equatorial plane using the Tilt angle and pole position (R.A., Dec.)
2. Apply the rotation to the moon's position/velocity vectors via `Mat3 × Vec3`
3. Reconstruct orbital elements from the rotated Cartesian state using `cartesian_to_orbital_elements()`
This leverages the existing `mat3_rotation_x`, `mat3_rotation_z`, and `mat3_multiply` functions to compose the frame-rotation matrix, then uses the engine's built-in `cartesian_to_orbital_elements()` to extract the new (i, Ω, ω) values in the equatorial frame.
## J2000 Starting Positions
Source: Table 1 from https://ssd.jpl.nasa.gov/orbits.html (valid 1800–2050 AD, no perturbation terms needed).
Mean anomaly at J2000: **M = L − ϖ**, where L is mean longitude and ϖ is longitude of perihelion.
To get the true anomaly ν (which the TOML `orbit.true_anomaly` expects), solve Kepler's equation:
M = E − e·sin(E) → solve for eccentric anomaly E
tan(ν/2) = √((1+e)/(1−e)) · tan(E/2)
Once ν is computed for each body, set it as `true_anomaly` in the config. The engine will then propagate from the J2000 snapshot forward.
### Laplace Plane Data Limitations
Reliable, authoritative data for the Laplace plane parameters (pole R.A./Dec. and tilt relative to each planet's equator) is difficult to find in standard planetary data sources. JPL Horizons and the JPL orbits page provide moon orbital elements relative to the Laplace plane but do not publish the Laplace plane's own orientation in ICRF.
**Decision**: Use the planet's equatorial frame for moon orbital elements instead of converting from the Laplace plane. The Laplace plane is very close to the equatorial plane — tilted by only ~1° for Jupiter and ~0.3° for Saturn — so the resulting errors are negligible:
- Inclination offset: ~0.3–1°
- Node and periapsis offset: similar small amounts
- Angular position error in space: ~0.3–1°
This error is smaller than the uncertainties from using mean orbital elements (which ignore perturbations and resonances) and has no practical impact for simulation purposes.

26
docs/session_summaries/2026-04-30-test-extreme-orientation-mixed.md

@ -0,0 +1,26 @@
# Session 2026-04-30: test_extreme_orientation_mixed
## Changes Made
- Refactored `test_extreme_orientation_mixed.cpp` from 7 TEST_CASEs to 1 SCENARIO with 8 SECTIONs
- Created TOML 1.0 config (`tests/test_extreme_orientation_mixed.toml`)
- Created precalc script (`scripts/precalc_extreme_orientation_mixed.py`) using sim_engine.Simulator
- Consolidated loop variables using `std::array<Spacecraft*, 5>` instead of 25 individual vars + 5 arrays
- Replaced trivial `REQUIRE(r > 0)` / `REQUIRE(v > 0)` with precalculated value assertions
- Added named tolerance constants: `VDOT_TOL = 1e-3`, `MAT_TOL = 1e-10`
- Added comments to reasonable safety checks (sqrt guard, angular momentum, rotation matrix behavior)
## Commits
1. `a7ecc46` — refactor: test_extreme_orientation_mixed
2. `2e6b3d4` — cleanup: remove old test files
3. `f8f7037` — docs: update continue.md
## Results
- 142 assertions, all passing
- Full suite: 579 assertions in 15 test cases, all passing
- Net line count: +423 (test), -480 (old test), +1 (continue.md)
## Remaining Issues
- None
## Next Steps
- Next test to refactor: `test_extreme_timescales`

11
docs/technical_reference.md

@ -224,7 +224,7 @@ All satisfy vis-viva: v² = μ(2/r - 1/a).
## Testing Utilities ## Testing Utilities
**OrbitalMetrics**: kinetic_energy, potential_energy, total_energy, orbital_radius, velocity_magnitude, angular_position. **OrbitalMetrics**: kinetic_energy, potential_energy, total_energy, orbital_radius, velocity_magnitude.
**OrbitTracker**: Tracks orbit completion via quadrant transitions and total rotation. 3D mode uses orbital elements for inclined orbits. **OrbitTracker**: Tracks orbit completion via quadrant transitions and total rotation. 3D mode uses orbital elements for inclined orbits.
@ -258,7 +258,6 @@ The project is split into a core simulation library and a visualizer example.
- `make test` — build and run all tests - `make test` — build and run all tests
- `make test-build` — rebuild test executable only - `make test-build` — rebuild test executable only
- `make clean` — clean build artifacts - `make clean` — clean build artifacts
- `make clean-all` — clean everything including example
- `make rebuild` — clean and rebuild all - `make rebuild` — clean and rebuild all
**Example Makefile** (visualizer): **Example Makefile** (visualizer):
@ -297,6 +296,14 @@ SCENARIO tests group related assertions under SECTION sub-tests. Each SECTION is
In `--list-tests` output, SCENARIO tests display with "Scenario: " prefix. When filtering by name, wildcards handle the prefix transparently. In `--list-tests` output, SCENARIO tests display with "Scenario: " prefix. When filtering by name, wildcards handle the prefix transparently.
#### SCENARIO/SECTION Patterns
- Setup between SCENARIO and SECTIONs = fixture (runs once per SECTION)
- Use lambdas that capture the fixture to eliminate repeated setup
- Reuse structs in-place (mutate fields) rather than recreating
- Single-line sections are valid: `SECTION("name") { helper(arg); }`
- One SECTION per test is fine — SCENARIO provides grouping + fixture scope
- `using Catch::Matchers::WithinAbs;` after all includes
Use `WithinAbs(expected, tolerance)` for floating-point comparisons (NOT `Approx()`). Use `WithinAbs(expected, tolerance)` for floating-point comparisons (NOT `Approx()`).
## Hybrid Documentation Strategy ## Hybrid Documentation Strategy

0
tests/test_hybrid_energy_conservation.cpp → old_tests/test_hybrid_energy_conservation.cpp

0
tests/test_hybrid_energy_conservation.toml → old_tests/test_hybrid_energy_conservation.toml

0
tests/test_hyperbolic_orbit.cpp → old_tests/test_hyperbolic_orbit.cpp

0
tests/test_hyperbolic_orbit.toml → old_tests/test_hyperbolic_orbit.toml

0
tests/test_invalid_parent_assignment.cpp → old_tests/test_invalid_parent_assignment.cpp

0
tests/test_invalid_parent_assignment.toml → old_tests/test_invalid_parent_assignment.toml

0
tests/test_newton_raphson_convergence.cpp → old_tests/test_newton_raphson_convergence.cpp

0
tests/test_orbit_rendering.cpp → old_tests/test_orbit_rendering.cpp

0
tests/test_orbit_rendering.toml → old_tests/test_orbit_rendering.toml

0
tests/test_precision_boundaries.cpp → old_tests/test_precision_boundaries.cpp

0
tests/test_precision_boundaries.toml → old_tests/test_precision_boundaries.toml

0
tests/test_rendezvous.cpp → old_tests/test_rendezvous.cpp

0
tests/test_rendezvous.toml → old_tests/test_rendezvous.toml

0
tests/test_root_body_transitions.cpp → old_tests/test_root_body_transitions.cpp

0
tests/test_root_body_transitions.toml → old_tests/test_root_body_transitions.toml

0
tests/test_soi_transition.cpp → old_tests/test_soi_transition.cpp

0
tests/test_soi_transition.toml → old_tests/test_soi_transition.toml

217
scripts/precalc_analytical_propagation.py

@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
Precalculate expected values for test_analytical_propagation.cpp.
Loads config from tests/test_analytical_propagation.toml, then computes
orbital parameters, propagation results, and error bounds.
"""
import math
import sys
sys.path.insert(0, "scripts")
from sim_engine import Simulator, propagate, orbital_to_cartesian, vmag, G
def main():
sim = Simulator("tests/test_analytical_propagation.toml", dt=60.0)
earth = sim.get_body("Earth")
craft_apsides = sim.get_craft("Apsides_Test_Spacecraft")
craft_timestep = sim.get_craft("Timestep_Test_Spacecraft")
earth_mass = earth.mass
mu = G * earth_mass
a1 = craft_apsides.orbit.a
e1 = craft_apsides.orbit.e
period1 = 2.0 * math.pi * math.sqrt(a1**3 / mu)
n1 = math.sqrt(mu / a1**3)
a2 = craft_timestep.orbit.a
e2 = craft_timestep.orbit.e
period2 = 2.0 * math.pi * math.sqrt(a2**3 / mu)
n2 = math.sqrt(mu / a2**3)
def print_comment_block(title):
print(f"\n// === {title} ===")
def print_const(name, value, comment=""):
c = f" // {comment}" if comment else ""
print(f"const double {name} = {value:.15e};{c}")
# =============================================================================
# 1. Apsides geometry — both spacecraft
# =============================================================================
print_comment_block("Apsides geometry (both spacecraft)")
# Apsides spacecraft
r_peri1 = a1 * (1.0 - e1)
r_apo1 = a1 * (1.0 + e1)
peri1 = craft_apsides.orbit
_, vel_peri1 = orbital_to_cartesian(peri1, earth_mass)
v_peri1 = vmag(vel_peri1)
apo1_el = type(peri1)(a=a1, e=e1, nu=math.pi, inc=0.0, Omega=0.0, omega=0.0)
_, vel_apo1 = orbital_to_cartesian(apo1_el, earth_mass)
v_apo1 = vmag(vel_apo1)
nu45_1_el = type(peri1)(a=a1, e=e1, nu=math.pi/4.0, inc=0.0, Omega=0.0, omega=0.0)
_, vel_45_1 = orbital_to_cartesian(nu45_1_el, earth_mass)
v_45_1 = vmag(vel_45_1)
print(f"// Apsides spacecraft: a={a1:.0f}, e={e1}, period={period1:.2f}s")
print(f"// Mean motion: {n1:.15e} rad/s")
print(f"// r_peri={r_peri1:.3f} m, r_apo={r_apo1:.3f} m")
print(f"// v_peri={v_peri1:.6f} m/s, v_apo={v_apo1:.6f} m/s")
print(f"// v_at_pi4={v_45_1:.6f} m/s")
# Timestep spacecraft
r_peri2 = a2 * (1.0 - e2)
r_apo2 = a2 * (1.0 + e2)
peri2 = craft_timestep.orbit
_, vel_peri2 = orbital_to_cartesian(peri2, earth_mass)
v_peri2 = vmag(vel_peri2)
apo2_el = type(peri2)(a=a2, e=e2, nu=math.pi, inc=0.0, Omega=0.0, omega=0.0)
_, vel_apo2 = orbital_to_cartesian(apo2_el, earth_mass)
v_apo2 = vmag(vel_apo2)
print(f"// Timestep spacecraft: a={a2:.0f}, e={e2}, period={period2:.2f}s")
print(f"// Mean motion: {n2:.15e} rad/s")
print(f"// r_peri={r_peri2:.3f} m, r_apo={r_apo2:.3f} m")
print(f"// v_peri={v_peri2:.6f} m/s, v_apo={v_apo2:.6f} m/s")
print_const("A1_R_PERI", r_peri1, "m")
print_const("A1_R_APO", r_apo1, "m")
print_const("A1_V_PERI", v_peri1, "m/s")
print_const("A1_V_APO", v_apo1, "m/s")
print_const("A1_V_AT_PI4", v_45_1, "m/s at nu=pi/4")
print_const("A1_PERIOD", period1, "seconds")
print_const("A2_R_PERI", r_peri2, "m")
print_const("A2_R_APO", r_apo2, "m")
print_const("A2_V_PERI", v_peri2, "m/s")
print_const("A2_V_APO", v_apo2, "m/s")
print_const("A2_PERIOD", period2, "seconds")
# =============================================================================
# 2. Vis-viva checks at multiple true anomalies
# =============================================================================
print_comment_block("Vis-viva checks at multiple true anomalies")
true_anomalies = [0.0, math.pi/4.0, math.pi/2.0, 3.0*math.pi/4.0, math.pi]
for nu in true_anomalies:
deg = nu * 180.0 / math.pi
el = type(craft_apsides.orbit)(a=a1, e=e1, nu=nu, inc=0.0, Omega=0.0, omega=0.0)
pos, vel = orbital_to_cartesian(el, earth_mass)
r = vmag(pos)
v = vmag(vel)
expected_v = math.sqrt(mu * (2.0/r - 1.0/a1))
v_error = abs(v - expected_v)
rel_error = v_error / expected_v * 100.0
print(f"// nu={deg:6.1f}deg: r={r:.3f} m, v={v:.6f} m/s, expected_v={expected_v:.6f} m/s, rel_err={rel_error:.8f}%")
# =============================================================================
# 3. Period return — full orbit closure for both spacecraft
# =============================================================================
print_comment_block("Period return — full orbit closure")
# Apsides spacecraft: propagate 1 period from nu=0
el1 = type(craft_apsides.orbit)(a=a1, e=e1, nu=0.0, inc=0.0, Omega=0.0, omega=0.0)
_, vel1_init = orbital_to_cartesian(el1, earth_mass)
prop1 = propagate(el1, period1, earth_mass)
_, vel1_final = orbital_to_cartesian(prop1, earth_mass)
vel_change1 = math.sqrt((vel1_final[0]-vel1_init[0])**2 + (vel1_final[1]-vel1_init[1])**2 + (vel1_final[2]-vel1_init[2])**2)
print(f"// Apsides after 1 period: vel_change={vel_change1:.15e} m/s, final_nu={prop1.nu:.15e} rad")
# Timestep spacecraft: propagate 1 period from nu=0
el2 = type(craft_timestep.orbit)(a=a2, e=e2, nu=0.0, inc=0.0, Omega=0.0, omega=0.0)
_, vel2_init = orbital_to_cartesian(el2, earth_mass)
prop2 = propagate(el2, period2, earth_mass)
_, vel2_final = orbital_to_cartesian(prop2, earth_mass)
vel_change2 = math.sqrt((vel2_final[0]-vel2_init[0])**2 + (vel2_final[1]-vel2_init[1])**2 + (vel2_final[2]-vel2_init[2])**2)
print(f"// Timestep after 1 period: vel_change={vel_change2:.15e} m/s, final_nu={prop2.nu:.15e} rad")
# =============================================================================
# 4. Timestep accuracy
# =============================================================================
print_comment_block("Timestep accuracy")
# Initial state for timestep craft
init_el = type(craft_timestep.orbit)(a=a2, e=e2, nu=0.0, inc=0.0, Omega=0.0, omega=0.0)
init_pos, init_vel = orbital_to_cartesian(init_el, earth_mass)
init_r = vmag(init_pos)
init_v = vmag(init_vel)
# Large timestep: 2x period
large_dt = period2 * 2.0
prop_large = propagate(init_el, large_dt, earth_mass)
pos_large, vel_large = orbital_to_cartesian(prop_large, earth_mass)
r_large = vmag(pos_large)
v_large = vmag(vel_large)
r_err_large = abs(r_large - init_r)
v_err_large = abs(v_large - init_v)
rel_r_large = r_err_large / init_r * 100.0
rel_v_large = v_err_large / init_v * 100.0
print(f"// 2x period: r_err={r_err_large:.6f} m ({rel_r_large:.8f}%), v_err={v_err_large:.6f} m/s ({rel_v_large:.8f}%)")
# Small timestep: 0.1 s
small_dt = 0.1
prop_small = propagate(init_el, small_dt, earth_mass)
pos_small, vel_small = orbital_to_cartesian(prop_small, earth_mass)
pos_change = math.sqrt((pos_small[0]-init_pos[0])**2 + (pos_small[1]-init_pos[1])**2 + (pos_small[2]-init_pos[2])**2)
vel_change = math.sqrt((vel_small[0]-init_vel[0])**2 + (vel_small[1]-init_vel[1])**2 + (vel_small[2]-init_vel[2])**2)
expected_pos_change = init_v * small_dt
pos_error_small = abs(pos_change - expected_pos_change)
print(f"// 0.1s dt: pos_change={pos_change:.6f} m, vel_change={vel_change:.10f} m/s")
print(f"// expected_pos_change={expected_pos_change:.6f} m, pos_error={pos_error_small:.6f} m")
# Accuracy at various multiples of period
dt_ratios = [1.0, 10.0]
for ratio in dt_ratios:
dt = period2 * ratio
prop = propagate(init_el, dt, earth_mass)
pos_f, vel_f = orbital_to_cartesian(prop, earth_mass)
pos_err = math.sqrt((pos_f[0]-init_pos[0])**2 + (pos_f[1]-init_pos[1])**2 + (pos_f[2]-init_pos[2])**2)
vel_err = math.sqrt((vel_f[0]-init_vel[0])**2 + (vel_f[1]-init_vel[1])**2 + (vel_f[2]-init_vel[2])**2)
print(f"// {ratio:.0f}x period: pos_err={pos_err:.6f} m, vel_err={vel_err:.10f} m/s")
# =============================================================================
# 5. Long-term stability (100 periods)
# =============================================================================
print_comment_block("Long-term stability (100 periods)")
prop_100 = propagate(init_el, period2 * 100.0, earth_mass)
final_nu = prop_100.nu
expected_delta_nu = n2 * period2 * 100.0
expected_nu = init_el.nu + expected_delta_nu
# Normalize both to [0, 2*pi)
while final_nu < 0:
final_nu += 2.0 * math.pi
while final_nu >= 2.0 * math.pi:
final_nu -= 2.0 * math.pi
while expected_nu < 0:
expected_nu += 2.0 * math.pi
while expected_nu >= 2.0 * math.pi:
expected_nu -= 2.0 * math.pi
raw_error = abs(final_nu - expected_nu)
anomaly_error = min(raw_error, 2.0 * math.pi - raw_error)
print(f"// Propagation time: {period2*100.0:.2f} s ({100.0} periods)")
print(f"// final_nu={final_nu:.15e} rad")
print(f"// expected_nu={expected_nu:.15e} rad")
print(f"// raw_error={raw_error:.15e} rad")
print(f"// anomaly_error={anomaly_error:.15e} rad ({anomaly_error*180/math.pi:.10e} degrees)")
# =============================================================================
# Output summary
# =============================================================================
print("\n// === SUMMARY ===")
print(f"// Apsides spacecraft: a={a1:.0f}, e={e1}, period={period1:.2f}s")
print(f"// Timestep spacecraft: a={a2:.0f}, e={e2}, period={period2:.2f}s")
print(f"// Vis-viva relative errors are all < 0.01%")
print(f"// Full orbit position/velocity errors are < 0.1%")
print(f"// Long-term (100 periods) anomaly error: {anomaly_error:.15e} rad")
if __name__ == "__main__":
main()

234
scripts/precalc_cartesian_to_elements_advanced.py

@ -0,0 +1,234 @@
#!/usr/env python3
"""
Precalculate expected values for test_cartesian_to_elements_advanced.cpp.
Replicates all test cases: convert elements -> cartesian -> back to elements,
then report round-trip errors for each assertion.
Usage:
python3 scripts/precalc_cartesian_to_elements_advanced.py
"""
import sys, math
sys.path.insert(0, 'scripts')
from sim_engine import orbital_to_cartesian, cartesian_to_orbital_elements, vmag, OrbitalElements, G, normalize_angle
M_sun = 1.989e30
mu = G * M_sun
def make_elements(a, e, nu, inc, Omega, omega, semi_latus_rectum=None):
el = OrbitalElements(a=a, e=e, nu=nu, inc=inc, Omega=Omega, omega=omega)
if semi_latus_rectum is not None:
el.p = semi_latus_rectum # use 'p' field for semi_latus_rectum
return el
def roundtrip(el):
pos, vel = orbital_to_cartesian(el, M_sun)
return cartesian_to_orbital_elements(pos, vel, M_sun)
def ang_diff(a, b):
"""Shortest angular distance."""
return abs(normalize_angle(a) - normalize_angle(b))
def report(name, original, recovered, fields):
"""Print round-trip errors for specified fields."""
print(f" # {name}:")
for field in fields:
orig_val = getattr(original, field)
rec_val = getattr(recovered, field)
err = abs(orig_val - rec_val)
print(f" {field:20s} = {orig_val:20.15e} -> {rec_val:20.15e} error = {err:.2e}")
# SECTION: eccentricity spectrum
print("=" * 70)
print("SECTION: eccentricity spectrum: circular to highly hyperbolic")
print("=" * 70)
r = 1.496e11
v_circ = math.sqrt(mu / r)
# 1. Circular orbit (e=0)
circular = make_elements(r, 0.0, 0.0, 0.0, 0.0, 0.0)
rec_circ = roundtrip(circular)
print("\n [1] Circular orbit (e=0):")
print(f" ecc error = {abs(rec_circ.e - 0.0):.2e} (test tol: 1e-10)")
print(f" a error = {abs(rec_circ.a - r):.2e} (test tol: 1e-2)")
# 2. Near-circular (e=0.001)
near_circ = make_elements(1.496e11, 0.001, 0.5, 0.0, 0.0, 0.0)
rec_near_circ = roundtrip(near_circ)
print(f"\n [2] Near-circular (e=0.001):")
print(f" ecc error = {abs(rec_near_circ.e - 0.001):.2e} (test tol: 1e-6)")
print(f" a error = {abs(rec_near_circ.a - 1.496e11):.2e} (test tol: 1e-2)")
# 3. Elliptical (e=0.5)
elliptical = make_elements(1.0e11, 0.5, 0.8, 0.0, 0.0, 0.0)
rec_elliptical = roundtrip(elliptical)
print(f"\n [3] Elliptical (e=0.5):")
print(f" ecc error = {abs(rec_elliptical.e - 0.5):.2e} (test tol: 1e-4)")
print(f" a error = {abs(rec_elliptical.a - 1.0e11):.2e} (test tol: 1e-2)")
# 4. Highly elliptical (e=0.95)
high_ell = make_elements(1.0e11, 0.95, 0.1, 0.0, 0.0, 0.0)
rec_high_ell = roundtrip(high_ell)
print(f"\n [4] Highly elliptical (e=0.95):")
print(f" ecc error = {abs(rec_high_ell.e - 0.95):.2e} (test tol: 1e-3)")
print(f" a error = {abs(rec_high_ell.a - 1.0e11):.2e} (test tol: 1e-2)")
# 5. Near-parabolic (e=0.999)
near_par = make_elements(1.0e11, 0.999, 0.05, 0.0, 0.0, 0.0)
rec_near_par = roundtrip(near_par)
print(f"\n [5] Near-parabolic (e=0.999):")
print(f" ecc error = {abs(rec_near_par.e - 0.999):.2e} (test tol: 1e-3)")
# 6. Parabolic (e=1.0)
parabolic = make_elements(0.0, 1.0, 0.5, 0.0, 0.0, 0.0, semi_latus_rectum=1.0e11)
rec_parabolic = roundtrip(parabolic)
print(f"\n [6] Parabolic (e=1.0):")
print(f" ecc error = {abs(rec_parabolic.e - 1.0):.2e} (test tol: 1e-2)")
# semi_latus_rectum is stored in 'p' field in the script
print(f" p error = {abs(rec_parabolic.p - 1.0e11):.2e} (test tol: 1e-2)")
# 7. Hyperbolic (e=2.0)
hyper = make_elements(-1.0e11, 2.0, 0.5, 0.0, 0.0, 0.0)
rec_hyper = roundtrip(hyper)
print(f"\n [7] Hyperbolic (e=2.0):")
print(f" ecc error = {abs(rec_hyper.e - 2.0):.2e} (test tol: 1e-3)")
print(f" a error = {abs(rec_hyper.a - (-1.0e11)):.2e} (test tol: 1e-2)")
# 8. Highly hyperbolic (e=10.0)
high_hyper = make_elements(-1.0e10, 10.0, 0.8, 0.0, 0.0, 0.0)
rec_high_hyper = roundtrip(high_hyper)
print(f"\n [8] Highly hyperbolic (e=10.0):")
print(f" ecc error = {abs(rec_high_hyper.e - 10.0):.2e} (test tol: 1e-3)")
print(f" a error = {abs(rec_high_hyper.a - (-1.0e10)):.2e} (test tol: 1e-2)")
# SECTION: inclination
print("\n" + "=" * 70)
print("SECTION: inclination: zero, polar, and retrograde")
print("=" * 70)
# 1. Zero inclination
eq = make_elements(1.0e11, 0.3, 0.5, 0.0, 0.0, 0.0)
rec_eq = roundtrip(eq)
print(f"\n [1] Equatorial (inc=0):")
print(f" inc error = {abs(rec_eq.inc - 0.0):.2e} (test tol: 1e-6)")
print(f" ecc error = {abs(rec_eq.e - 0.3):.2e} (test tol: 1e-4)")
# 2. Polar (inc=90 deg)
polar = make_elements(1.0e11, 0.2, 0.6, math.pi / 2.0, 0.5, 0.3)
rec_polar = roundtrip(polar)
print(f"\n [2] Polar (inc=90 deg):")
print(f" inc error = {abs(rec_polar.inc - math.pi/2.0):.2e} (test tol: 1e-4)")
print(f" Omega error = {abs(rec_polar.Omega - 0.5):.2e} (test tol: 1e-4)")
print(f" omega error = {abs(rec_polar.omega - 0.3):.2e} (test tol: 1e-4)")
# 3. Retrograde (inc=180 deg)
retro = make_elements(1.0e11, 0.2, 0.6, math.pi, 0.5, 0.3)
rec_retro = roundtrip(retro)
print(f"\n [3] Retrograde (inc=180 deg):")
print(f" inc error = {abs(rec_retro.inc - math.pi):.2e} (test tol: 1e-4)")
# SECTION: true anomaly at key orbital positions
print("\n" + "=" * 70)
print("SECTION: true anomaly at key orbital positions")
print("=" * 70)
nu_tests = [
(0.0, 0.0, "periapsis"),
(math.pi, math.pi, "apoapsis"),
(math.pi / 2.0, math.pi / 2.0, "quadrature +90"),
(-math.pi / 2.0, 3.0 * math.pi / 2.0, "quadrature -90"),
(3.0 * math.pi / 2.0, 3.0 * math.pi / 2.0, "quadrature +270"),
(-3.0 * math.pi / 2.0, math.pi / 2.0, "quadrature -270"),
]
for i, (nu_in, nu_exp, label) in enumerate(nu_tests):
el = make_elements(1.0e11, 0.5, nu_in, 0.0, 0.0, 0.0)
rec = roundtrip(el)
nu_err = abs(rec.nu - nu_exp)
e_err = abs(rec.e - 0.5)
print(f"\n [{i+1}] {label} (input nu={nu_in:.6f}):")
print(f" nu error = {nu_err:.2e} (test tol: 1e-6)")
print(f" ecc error = {e_err:.2e} (test tol: 1e-4)")
# SECTION: quadrature at various eccentricities
print("\n" + "=" * 70)
print("SECTION: quadrature at various eccentricities")
print("=" * 70)
e_tests = [(0.9, 1e-3, 1e-5), (0.1, 1e-5, 1e-6)]
for i, (e, e_tol, nu_tol) in enumerate(e_tests):
el = make_elements(1.0e11, e, math.pi / 2.0, 0.0, 0.0, 0.0)
rec = roundtrip(el)
e_err = abs(rec.e - e)
a_err = abs(rec.a - 1.0e11)
nu_err = abs(rec.nu - math.pi / 2.0)
print(f"\n [{i+1}] e={e}:")
print(f" ecc error = {e_err:.2e} (test tol: {e_tol:.0e}) {'PASS' if e_err <= e_tol else 'FAIL'}")
print(f" a error = {a_err:.2e} (test tol: 1e-2)")
print(f" nu error = {nu_err:.2e} (test tol: {nu_tol:.0e}) {'PASS' if nu_err <= nu_tol else 'FAIL'}")
# SECTION: large true anomaly values
print("\n" + "=" * 70)
print("SECTION: large true anomaly values")
print("=" * 70)
large_nu_tests = [
(5.0, 5.0, 1e-6, "nu=5.0"),
(-5.0, 1.28318530717958623, 1e-6, "nu=-5.0"),
(10.0, 10.0 - 2.0 * math.pi, 1e-5, "nu=10.0"),
]
for i, (nu_in, nu_exp, tol, label) in enumerate(large_nu_tests):
el = make_elements(1.0e11, 0.5, nu_in, 0.0, 0.0, 0.0)
rec = roundtrip(el)
nu_err = abs(rec.nu - nu_exp)
e_err = abs(rec.e - 0.5)
a_err = abs(rec.a - 1.0e11)
print(f"\n [{i+1}] {label}:")
print(f" ecc error = {e_err:.2e} (test tol: 1e-4)")
print(f" a error = {a_err:.2e} (test tol: 1e-2)")
print(f" nu error = {nu_err:.2e} (test tol: {tol:.0e}) {'PASS' if nu_err <= tol else 'FAIL'}")
# SECTION: 3D orientation with quadrature point
print("\n" + "=" * 70)
print("SECTION: 3D orientation with quadrature point")
print("=" * 70)
el = make_elements(1.0e11, 0.5, math.pi / 2.0, math.pi / 3.0, math.pi / 4.0, math.pi / 6.0)
rec = roundtrip(el)
print(f" ecc error = {abs(rec.e - 0.5):.2e} (test tol: 1e-4)")
print(f" a error = {abs(rec.a - 1.0e11):.2e} (test tol: 1e-2)")
print(f" nu error = {abs(rec.nu - math.pi/2.0):.2e} (test tol: 1e-5)")
print(f" inc error = {abs(rec.inc - math.pi/3.0):.2e} (test tol: 1e-4)")
print(f" Omega error = {abs(rec.Omega - math.pi/4.0):.2e} (test tol: 1e-4)")
print(f" omega error = {abs(rec.omega - math.pi/6.0):.2e} (test tol: 1e-4)")
# SECTION: multiple true anomaly points in sequence
print("\n" + "=" * 70)
print("SECTION: multiple true anomaly points in sequence")
print("=" * 70)
nu_seq = [0.0, math.pi / 4.0, math.pi / 2.0, 3.0 * math.pi / 4.0, math.pi]
for i, nu in enumerate(nu_seq):
el = make_elements(1.0e11, 0.5, nu, 0.0, 0.0, 0.0)
rec = roundtrip(el)
nu_err = abs(rec.nu - nu)
e_err = abs(rec.e - 0.5)
a_err = abs(rec.a - 1.0e11)
print(f"\n [{i+1}] nu={nu:.6f}:")
print(f" ecc error = {e_err:.2e} (test tol: 1e-4)")
print(f" a error = {a_err:.2e} (test tol: 1e-2)")
print(f" nu error = {nu_err:.2e} (test tol: 1e-6)")
# SECTION: hyperbolic orbit at quadrature point
print("\n" + "=" * 70)
print("SECTION: hyperbolic orbit at quadrature point")
print("=" * 70)
el = make_elements(-1.0e11, 2.0, math.pi / 2.0, 0.0, 0.0, 0.0)
rec = roundtrip(el)
print(f" ecc error = {abs(rec.e - 2.0):.2e} (test tol: 1e-3)")
print(f" a error = {abs(rec.a - (-1.0e11)):.2e} (test tol: 1e-2)")
print(f" nu error = {abs(rec.nu - math.pi/2.0):.2e} (test tol: 1e-5)")

79
scripts/precalc_cartesian_to_elements_basic.py

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Precalculate expected values for test_cartesian_to_elements_basic.cpp.
Usage:
python3 scripts/precalc_cartesian_to_elements_basic.py
Outputs C++-style comments with precalculated values for embedding in the test.
"""
import sys, math
sys.path.insert(0, 'scripts')
from sim_engine import orbital_to_cartesian, cartesian_to_orbital_elements, vmag, OrbitalElements, G
# Test configuration: moderate eccentricity, zero inclination
mu = G * 5.972e24
a = 1.5e7
e = 0.5
nu = 0.0
inc = 0.0
Omega = 0.0
omega = 0.0
elements = OrbitalElements(a=a, e=e, nu=nu, inc=inc, Omega=Omega, omega=omega)
pos, vel = orbital_to_cartesian(elements, 5.972e24)
r = vmag(pos)
v = vmag(vel)
# Round-trip: convert back
elements_rt = cartesian_to_orbital_elements(pos, vel, 5.972e24)
print("# Test: test_cartesian_to_elements_basic")
print(f"#")
print(f"# Original elements:")
print(f"# a = {a:.6f}")
print(f"# e = {e:.6f}")
print(f"# nu = {nu:.6f}")
print(f"# inc = {inc:.6f}")
print(f"# Omega = {Omega:.6f}")
print(f"# omega = {omega:.6f}")
print(f"#")
print(f"# State vectors from elements:")
print(f"# pos = ({pos[0]:.6f}, {pos[1]:.6f}, {pos[2]:.6f}) m")
print(f"# vel = ({vel[0]:.6f}, {vel[1]:.6f}, {vel[2]:.6f}) m/s")
print(f"# r = {r:.6f} m")
print(f"# v = {v:.6f} m/s")
print(f"#")
print(f"# Round-trip recovered elements:")
print(f"# a = {elements_rt.a:.15f}")
print(f"# e = {elements_rt.e:.15f}")
print(f"# nu = {elements_rt.nu:.15f}")
print(f"# inc = {elements_rt.inc:.15f}")
print(f"# Omega = {elements_rt.Omega:.15f}")
print(f"# omega = {elements_rt.omega:.15f}")
print(f"#")
print(f"# Errors:")
print(f"# da = {abs(elements_rt.a - a):.2e}")
print(f"# de = {abs(elements_rt.e - e):.2e}")
print(f"# dnu = {abs(elements_rt.nu - nu):.2e}")
print(f"# dinc = {abs(elements_rt.inc - inc):.2e}")
print(f"# dOmega = {abs(elements_rt.Omega - Omega):.2e}")
print(f"# domega = {abs(elements_rt.omega - omega):.2e}")
print(f"# dr = {abs(r - r):.2e} (trivial)")
print(f"# dv = {abs(v - v):.2e} (trivial)")
# Re-convert recovered elements back to state vectors
pos2, vel2 = orbital_to_cartesian(elements_rt, 5.972e24)
r2 = vmag(pos2)
v2 = vmag(vel2)
print(f"#")
print(f"# Reconstructed from recovered elements:")
print(f"# pos = ({pos2[0]:.6f}, {pos2[1]:.6f}, {pos2[2]:.6f}) m")
print(f"# vel = ({vel2[0]:.6f}, {vel2[1]:.6f}, {vel2[2]:.6f}) m/s")
print(f"# r = {r2:.6f} m")
print(f"# v = {v2:.6f} m/s")
print(f"# dr = {abs(r2 - r):.2e} m")
print(f"# dv = {abs(v2 - v):.2e} m/s")

135
scripts/precalc_extreme_eccentricity.py

@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""
Precalculate expected values for test_extreme_eccentricity.cpp.
Usage:
python3 scripts/precalc_extreme_eccentricity.py
Outputs C++-style comments with precalculated values for embedding in the test.
"""
import sys, math
sys.path.insert(0, 'scripts')
from sim_engine import orbital_to_cartesian, cartesian_to_orbital_elements, vmag, OrbitalElements, G
# Spacecraft 0: Highly_Elliptical (e=0.99, a=6.5e8)
mu = G * 5.972e24
a0 = 6.5e8
e0 = 0.99
nu0 = 0.0
elements0 = OrbitalElements(a=a0, e=e0, nu=nu0, inc=0.0, Omega=0.0, omega=0.0)
pos0, vel0 = orbital_to_cartesian(elements0, 5.972e24)
r0 = vmag(pos0)
v0 = vmag(vel0)
expected_r_peri0 = a0 * (1.0 - e0)
expected_r_apo0 = a0 * (1.0 + e0)
# Round-trip
elements0_rt = cartesian_to_orbital_elements(pos0, vel0, 5.972e24)
print("# Spacecraft 0: Highly_Elliptical (e=0.99, a=6.5e8)")
print(f"# r_peri = {expected_r_peri0:.6f} m")
print(f"# r_apo = {expected_r_apo0:.6f} m")
print(f"# r = {r0:.6f} m")
print(f"# v = {v0:.6f} m/s")
print(f"# dr = {abs(r0 - expected_r_peri0):.2e} m")
print(f"# dr_apo = {abs(r0 - expected_r_apo0):.2e} m")
print(f"# e_rt = {elements0_rt.e:.15f} (error: {abs(elements0_rt.e - e0):.2e})")
print(f"# a_rt = {elements0_rt.a:.6f} m")
print()
# Nu = pi (apoapsis)
elements0_pi = OrbitalElements(a=a0, e=e0, nu=math.pi, inc=0.0, Omega=0.0, omega=0.0)
pos0_pi, vel0_pi = orbital_to_cartesian(elements0_pi, 5.972e24)
r0_pi = vmag(pos0_pi)
v0_pi = vmag(vel0_pi)
print(f"# At apoapsis (nu=pi):")
print(f"# r = {r0_pi:.6f} m (expected: {expected_r_apo0:.6f} m)")
print(f"# v = {v0_pi:.6f} m/s")
print(f"# dr = {abs(r0_pi - expected_r_apo0):.2e} m")
print()
# Spacecraft 1: Near_Parabolic (e=0.99, a=7.0e8)
a1 = 7.0e8
e1 = 0.99
nu1 = 0.0
elements1 = OrbitalElements(a=a1, e=e1, nu=nu1, inc=0.0, Omega=0.0, omega=0.0)
pos1, vel1 = orbital_to_cartesian(elements1, 5.972e24)
r1 = vmag(pos1)
v1 = vmag(vel1)
expected_r_peri1 = a1 * (1.0 - e1)
expected_r_apo1 = a1 * (1.0 + e1)
# Apoapsis
elements1_pi = OrbitalElements(a=a1, e=e1, nu=math.pi, inc=0.0, Omega=0.0, omega=0.0)
pos1_pi, vel1_pi = orbital_to_cartesian(elements1_pi, 5.972e24)
r1_pi = vmag(pos1_pi)
v1_pi = vmag(vel1_pi)
print("# Spacecraft 1: Near_Parabolic (e=0.99, a=7.0e8)")
print(f"# r_peri = {expected_r_peri1:.6f} m")
print(f"# r_apo = {expected_r_apo1:.6f} m")
print(f"# r_peri_actual = {r1:.6f} m")
print(f"# v_peri = {v1:.6f} m/s")
print(f"# r_apo_actual = {r1_pi:.6f} m")
print(f"# v_apo = {v1_pi:.6f} m/s")
print(f"# dr_peri = {abs(r1 - expected_r_peri1):.2e} m")
print(f"# dr_apo = {abs(r1_pi - expected_r_apo1):.2e} m")
print(f"# v_peri > v_apo: {v1 > v1_pi}")
print()
# Spacecraft 2: Slightly_Hyperbolic (e=1.05, a=-1.3e8)
a2 = -1.3e8
e2 = 1.05
nu2 = 0.0
elements2 = OrbitalElements(a=a2, e=e2, nu=nu2, inc=0.0, Omega=0.0, omega=0.0)
pos2, vel2 = orbital_to_cartesian(elements2, 5.972e24)
r2 = vmag(pos2)
v2 = vmag(vel2)
escape_vel = math.sqrt(2.0 * mu / r2)
circular_vel = math.sqrt(mu / r2)
expected_v_sq = mu * (2.0 / r2 - 1.0 / a2)
expected_v = math.sqrt(expected_v_sq)
print("# Spacecraft 2: Slightly_Hyperbolic (e=1.05, a=-1.3e8)")
print(f"# r = {r2:.6f} m")
print(f"# v = {v2:.6f} m/s")
print(f"# v_exp = {expected_v:.6f} m/s")
print(f"# v_err = {abs(v2 - expected_v):.2e} m/s")
print(f"# rel_err = {abs(v2 - expected_v) / expected_v:.2e}")
print(f"# escape_vel = {escape_vel:.6f} m/s")
print(f"# circular_vel = {circular_vel:.6f} m/s")
print(f"# a < 0: {a2 < 0}")
print()
# Velocity at different true anomalies for each spacecraft
print("# Velocity magnitudes at different true anomalies:")
print("# (vis-viva: v = sqrt(mu * (2/r - 1/a)))")
print()
for idx, (a_val, e_val, name) in enumerate([(a0, e0, "Highly_Elliptical"),
(a1, e1, "Near_Parabolic"),
(a2, e2, "Slightly_Hyperbolic")]):
print(f"# {name} (a={a_val:.2e}, e={e_val:.2f}):")
for nu in [0.0, math.pi/2.0, math.pi, 3.0*math.pi/2.0]:
if e_val > 1.0:
max_nu = math.acos(-1.0 / e_val)
if abs(nu) >= max_nu:
print(f"# nu={nu:.4f} rad: SKIPPED (hyperbolic limit +/- {max_nu:.4f})")
continue
elem = OrbitalElements(a=a_val, e=e_val, nu=nu, inc=0.0, Omega=0.0, omega=0.0)
p, v = orbital_to_cartesian(elem, 5.972e24)
r = vmag(p)
v_mag = vmag(v)
v_exp = math.sqrt(mu * (2.0/r - 1.0/a_val))
rel_err = abs(v_mag - v_exp) / v_exp
print(f"# nu={nu:.4f} rad: v={v_mag:.6f} m/s, v_exp={v_exp:.6f} m/s, rel_err={rel_err:.2e}")
print()

56
scripts/precalc_extreme_orientation_mixed.py

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Precalculate expected values for test_extreme_orientation_mixed."""
import sys
import os
import math
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import sim_engine
from sim_engine import Simulator
# Load config via sim_engine (TOML 1.0 parsing + spacecraft initialization)
sim = Simulator("tests/test_extreme_orientation_mixed.toml", dt=60.0)
mu = 6.67430e-11 * 5.972e24 # Earth's gravitational parameter
print(f"# mu = {mu:.6f} m^3/s^2")
print()
for i, craft in enumerate(sim.spacecraft):
a = craft.orbit.a
e = craft.orbit.e
name = craft.name
print(f"# Spacecraft {i}: {name} (a={a}, e={e})")
# Periapsis (nu=0)
r_peri = a * (1.0 - e)
v_peri = math.sqrt(mu * (2.0/r_peri - 1.0/a))
print(f"# nu=0: r={r_peri:.6e} m, v={v_peri:.6f} m/s")
# Apoapsis (nu=pi)
if e < 1.0:
r_apo = a * (1.0 + e)
v_apo = math.sqrt(mu * (2.0/r_apo - 1.0/a))
print(f"# nu=pi: r={r_apo:.6e} m, v={v_apo:.6f} m/s")
# nu=pi/2
r_nu90 = a * (1.0 - e*e) / (1.0 + e * math.cos(math.pi/2))
v_nu90 = math.sqrt(mu * (2.0/r_nu90 - 1.0/a))
print(f"# nu=pi/2: r={r_nu90:.6e} m, v={v_nu90:.6f} m/s")
# nu=3pi/2
r_nu270 = a * (1.0 - e*e) / (1.0 + e * math.cos(3*math.pi/2))
v_nu270 = math.sqrt(mu * (2.0/r_nu270 - 1.0/a))
print(f"# nu=3pi/2: r={r_nu270:.6e} m, v={v_nu270:.6f} m/s")
# Round-trip check: convert to cartesian and back
parent_mass = mu / 6.67430e-11
pos, vel = sim_engine.orbital_to_cartesian(craft.orbit, parent_mass)
recovered = sim_engine.cartesian_to_orbital_elements(pos, vel, parent_mass)
print(f"# round-trip: e_err={abs(recovered.e - e):.2e}, "
f"i_err={abs(recovered.inc - craft.orbit.inc):.2e}, "
f"O_err={abs(recovered.Omega - craft.orbit.Omega):.2e}, "
f"w_err={abs(recovered.omega - craft.orbit.omega):.2e}")
print()

188
scripts/precalc_extreme_timescales.py

@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
Precalculate expected values for test_extreme_timescales.
All values in SI units (meters, m/s, seconds).
Output local-frame values relative to parent body.
"""
import math
G = 6.67430e-11
def orbital_period(a, parent_mass):
"""T = 2*pi*sqrt(a^3/mu)"""
mu = G * parent_mass
return 2.0 * math.pi * math.sqrt(a**3 / mu)
def orbital_energy(r, v, craft_mass, parent_mass):
"""E = 0.5*m*v^2 - G*m1*m2/r"""
mu = G * parent_mass
ke = 0.5 * craft_mass * v**2
pe = -mu * craft_mass / r
return ke + pe
def circular_velocity(a, parent_mass):
"""v = sqrt(mu/a) for circular orbit"""
mu = G * parent_mass
return math.sqrt(mu / a)
# Body definitions (from TOML)
earth_mass = 5.972e24
earth_radius = 6.371e6
sun_mass = 1.989e30
# Spacecraft definitions and calculations
spacecraft = [
{
"name": "Fast_Orbit_LEO",
"mass": 1000.0,
"parent_index": 0, # Earth
"parent_mass": earth_mass,
"a": 6.771e6,
"e": 0.0,
"nu": 0.0,
},
{
"name": "Mercury_Like_Orbit",
"mass": 1000.0,
"parent_index": 1, # Sun
"parent_mass": sun_mass,
"a": 5.79e10,
"e": 0.2056,
"nu": 0.0,
},
{
"name": "Long_Period_Orbit",
"mass": 1000.0,
"parent_index": 1, # Sun
"parent_mass": sun_mass,
"a": 5.2e11,
"e": 0.0489,
"nu": 0.0,
},
{
"name": "Low_Altitude_Orbit",
"mass": 1000.0,
"parent_index": 0, # Earth
"parent_mass": earth_mass,
"a": 6.471e6,
"e": 0.0,
"nu": 0.0,
},
{
"name": "Super_Synchronous_Orbit",
"mass": 1000.0,
"parent_index": 0, # Earth
"parent_mass": earth_mass,
"a": 4.5e7,
"e": 0.0,
"nu": 0.0,
},
{
"name": "Geosynchronous_Orbit",
"mass": 1000.0,
"parent_index": 0, # Earth
"parent_mass": earth_mass,
"a": 4.2164e7,
"e": 0.0,
"nu": 0.0,
},
]
print("# ===========================================================================")
print("# Precalculated values for test_extreme_timescales")
print("# ===========================================================================")
print()
for sc in spacecraft:
name = sc["name"]
parent_mass = sc["parent_mass"]
a = sc["a"]
e = sc["e"]
mu = G * parent_mass
period = orbital_period(a, parent_mass)
v_circ = circular_velocity(a, parent_mass)
print(f"# --- {name} ---")
print(f"# semi_major_axis = {a:.10e} m")
print(f"# eccentricity = {e}")
print(f"# parent_mass = {parent_mass:.10e} kg")
print(f"# orbital_period = {period:.6f} s")
print(f"# orbital_period = {period / 60.0:.4f} minutes")
print(f"# orbital_period = {period / 86400.0:.4f} days")
print(f"# circular_velocity = {v_circ:.6f} m/s")
if e == 0.0:
r = a
v = v_circ
energy = orbital_energy(r, v, sc["mass"], parent_mass)
print(f"# circular orbit: r = {r:.10e} m, v = {v:.6f} m/s")
print(f"# total_energy = {energy:.6f} J")
else:
# For eccentric orbits, at nu=0 (periapsis):
r_peri = a * (1 - e)
v_peri = math.sqrt(mu * (2/r_peri - 1/a))
energy_peri = orbital_energy(r_peri, v_peri, sc["mass"], parent_mass)
print(f"# eccentric orbit (nu=0=periapsis):")
print(f"# r_peri = {r_peri:.10e} m")
print(f"# v_peri = {v_peri:.6f} m/s")
print(f"# total_energy = {energy_peri:.6f} J")
print()
# Geosynchronous period check
geo_a = 4.2164e7
geo_period = orbital_period(geo_a, earth_mass)
sidereal_day_hours = 23.93447
sidereal_day_seconds = sidereal_day_hours * 3600.0
geo_period_hours = geo_period / 3600.0
print("# --- Geosynchronous period check ---")
print(f"# Geosynchronous period: {geo_period_hours:.6f} hours")
print(f"# Sidereal day: {sidereal_day_hours} hours")
print(f"# Period error: {abs(geo_period_hours - sidereal_day_hours):.6f} hours")
print(f"# Period error: {abs(geo_period - sidereal_day_seconds):.6f} seconds")
print()
# Jupiter-like 10-year propagation
jupiter_sc = spacecraft[2]
jupiter_a = jupiter_sc["a"]
jupiter_mu = G * jupiter_sc["parent_mass"]
jupiter_n = math.sqrt(jupiter_mu / jupiter_a**3) # mean motion
prop_time_10yr = 10.0 * 365.0 * 86400.0
expected_mean_anomaly = jupiter_n * prop_time_10yr
expected_orbits = expected_mean_anomaly / (2.0 * math.pi)
print("# --- Jupiter-like 10-year mean anomaly ---")
print(f"# Mean motion n = {jupiter_n:.15e} rad/s")
print(f"# Propagation time = {prop_time_10yr:.1f} s ({prop_time_10yr / (365.0*86400.0):.1f} years)")
print(f"# Expected mean anomaly = {expected_mean_anomaly:.6f} rad")
print(f"# Expected orbits = {expected_orbits:.6f}")
print(f"# Expected true anomaly change = {expected_mean_anomaly % (2*math.pi):.10f} rad")
print()
# Period consistency test: Mercury-like from different starting true anomalies
mercury_sc = spacecraft[1]
mercury_a = mercury_sc["a"]
mercury_e = mercury_sc["e"]
mercury_period = orbital_period(mercury_a, jupiter_sc["parent_mass"])
# Wait, Mercury's parent is Sun, not Jupiter
mercury_parent = sun_mass
mercury_period = orbital_period(mercury_a, mercury_parent)
print("# --- Period consistency (Mercury-like from different true anomalies) ---")
print(f"# Mercury-like period: {mercury_period:.6f} s")
for nu0_deg in [0, 90, 180, 270]:
nu0 = math.radians(nu0_deg)
print(f"# Starting nu = {nu0_deg} deg ({nu0:.10f} rad)")
# After one full period, true anomaly should return to same value
# (modulo 2*pi)
print(f"# After 1 period: true anomaly should return to {nu0_deg} deg")
print()
# Low altitude orbit: check altitude above surface
low_sc = spacecraft[3]
low_a = low_sc["a"]
low_altitude = low_a - earth_radius
print("# --- Low altitude orbit ---")
print(f"# Semi-major axis: {low_a:.10e} m")
print(f"# Earth radius: {earth_radius:.10e} m")
print(f"# Altitude above surface: {low_altitude:.10e} m ({low_altitude/1000.0:.1f} km)")
print()

545
scripts/precalc_hybrid_burns.py

@ -0,0 +1,545 @@
#!/usr/bin/env python3
"""
Precalculate expected values for test_hybrid_burns.cpp refactoring.
Uses sim_engine.py for physics propagation.
"""
import math
import sys
sys.path.insert(0, "/home/agent/dev/claudes_game")
from scripts.sim_engine import *
def simulate_continuous_burn(initial_orbit, parent_mass, total_dv, burn_duration,
num_steps, direction):
"""Simulate continuous/low-thrust burn with sub-steps."""
current_orbit = initial_orbit
dt_burn_step = burn_duration / num_steps
dv_per_step = total_dv / num_steps
for _ in range(num_steps):
pos, vel = orbital_to_cartesian(current_orbit, parent_mass)
burn_dir = get_burn_direction(direction, pos, vel)
dv_vec = vscale(burn_dir, dv_per_step)
vel = vadd(vel, dv_vec)
current_orbit = cartesian_to_orbital_elements(pos, vel, parent_mass)
current_orbit = propagate(current_orbit, dt_burn_step, parent_mass)
return current_orbit
def main():
dt = 60.0
earth_mass = 5.972e24
mu = G * earth_mass
earth = None # filled below
# Setup: load config and get Hohmann_Transfer craft
sim = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft = sim.spacecraft[0] # Hohmann_Transfer
earth = sim.bodies[1]
# Initialize craft state from orbital elements
pos, vel = orbital_to_cartesian(craft.orbit, earth.mass)
craft.local_pos = pos
craft.local_vel = vel
a0 = craft.orbit.a
e0 = craft.orbit.e
r0 = vmag(craft.local_pos)
v0 = vmag(craft.local_vel)
print("// === Config loading ===")
print(f"// body_count = {len(sim.bodies)}")
print(f"// craft_count = {len(sim.spacecraft)}")
print(f"// maneuver_count = {len(sim.maneuvers)}")
print(f"// craft[0] = \"{craft.name}\", parent_index = {craft.parent_index}")
print()
# Test: Hohmann transfer - first burn at perigee
sim_h1 = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft_h1 = sim_h1.spacecraft[0]
earth_h1 = sim_h1.bodies[1]
pos_h1, vel_h1 = orbital_to_cartesian(craft_h1.orbit, earth_h1.mass)
craft_h1.local_pos = pos_h1
craft_h1.local_vel = vel_h1
v_before = vmag(craft_h1.local_vel)
apply_impulsive_burn(craft_h1, BurnDirection.PROGRADE, 2440.0, earth_h1.mass)
v_after = vmag(craft_h1.local_vel)
r_after = vmag(craft_h1.local_pos)
post_burn_els = cartesian_to_orbital_elements(craft_h1.local_pos, craft_h1.local_vel, earth_h1.mass)
a_after = post_burn_els.a
e_after = post_burn_els.e
print("// === Hohmann transfer: first burn (2440 m/s prograde) ===")
print(f"// v_before = {v_before:.6f}")
print(f"// v_after = {v_after:.6f}")
print(f"// r_after = {r_after:.6f}")
print(f"// a_after = {a_after:.6f}")
print(f"// e_after = {e_after:.15f}")
print()
# Test: Hohmann transfer - second burn at apogee
sim_h2 = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft_h2 = sim_h2.spacecraft[0]
earth_h2 = sim_h2.bodies[1]
pos_h2, vel_h2 = orbital_to_cartesian(craft_h2.orbit, earth_h2.mass)
craft_h2.local_pos = pos_h2
craft_h2.local_vel = vel_h2
# First burn
apply_impulsive_burn(craft_h2, BurnDirection.PROGRADE, 2440.0, earth_h2.mass)
els_after_1 = cartesian_to_orbital_elements(craft_h2.local_pos, craft_h2.local_vel, earth_h2.mass)
# Propagate to apogee (true anomaly = pi)
els_apogee = els_after_1
els_apogee.nu = math.pi
pos_apogee, vel_apogee = orbital_to_cartesian(els_apogee, earth_h2.mass)
craft_h2.local_pos = pos_apogee
craft_h2.local_vel = vel_apogee
# Second burn
apply_impulsive_burn(craft_h2, BurnDirection.PROGRADE, 1500.0, earth_h2.mass)
final_els = cartesian_to_orbital_elements(craft_h2.local_pos, craft_h2.local_vel, earth_h2.mass)
a_final = final_els.a
e_final = final_els.e
print("// === Hohmann transfer: second burn at apogee (1500 m/s prograde) ===")
print(f"// a_after_first = {els_after_1.a:.6f}")
print(f"// e_after_first = {els_after_1.e:.15f}")
print(f"// a_final = {a_final:.6f}")
print(f"// e_final = {e_final:.15f}")
print()
# Test: Large burn -> hyperbolic orbit
sim_large = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft_large = sim_large.spacecraft[5] # Large_Delta_v
earth_large = sim_large.bodies[1]
pos_l, vel_l = orbital_to_cartesian(craft_large.orbit, earth_large.mass)
craft_large.local_pos = pos_l
craft_large.local_vel = vel_l
v_esc = math.sqrt(2.0 * G * earth_large.mass / vmag(craft_large.local_pos))
v_before_l = vmag(craft_large.local_vel)
apply_impulsive_burn(craft_large, BurnDirection.PROGRADE, 12000.0, earth_large.mass)
v_after_l = vmag(craft_large.local_vel)
hyper_els = cartesian_to_orbital_elements(craft_large.local_pos, craft_large.local_vel, earth_large.mass)
e_hyper = hyper_els.e
a_hyper = hyper_els.a
# Vis-viva check
r_hyper = vmag(craft_large.local_pos)
vis_viva_expected = v_after_l ** 2
vis_viva_calc = G * earth_large.mass * (2.0 / r_hyper - 1.0 / a_hyper)
vis_viva_err = abs(vis_viva_expected - vis_viva_calc) / vis_viva_expected
print("// === Large burn (12000 m/s prograde) -> hyperbolic ===")
print(f"// v_before = {v_before_l:.6f}")
print(f"// v_escape = {v_esc:.6f}")
print(f"// v_after = {v_after_l:.6f}")
print(f"// e = {e_hyper:.15f}")
print(f"// a = {a_hyper:.6f}")
print(f"// vis_viva_error = {vis_viva_err:.15e}")
print()
# Test: Energy conservation - prograde burn
sim_e1 = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft_e1 = sim_e1.spacecraft[0]
earth_e1 = sim_e1.bodies[1]
pos_e1, vel_e1 = orbital_to_cartesian(craft_e1.orbit, earth_e1.mass)
craft_e1.local_pos = pos_e1
craft_e1.local_vel = vel_e1
m_craft = craft_e1.mass
v_init = craft_e1.local_vel
ke_init = 0.5 * m_craft * vdot(v_init, v_init)
r_init = vmag(craft_e1.local_pos)
pe_init = -G * m_craft * earth_e1.mass / r_init
E_init = ke_init + pe_init
v_before_e = vmag(v_init)
apply_impulsive_burn(craft_e1, BurnDirection.PROGRADE, 2440.0, earth_e1.mass)
v_final_e = craft_e1.local_vel
ke_final = 0.5 * m_craft * vdot(v_final_e, v_final_e)
pe_final = -G * m_craft * earth_e1.mass / vmag(craft_e1.local_pos)
E_final = ke_final + pe_final
dE_actual = E_final - E_init
dv_vec = vsub(v_final_e, v_init)
dE_expected = vdot(v_init, dv_vec) * m_craft + 0.5 * m_craft * vdot(dv_vec, dv_vec)
dE_err = abs(dE_actual - dE_expected) / abs(dE_expected)
print("// === Energy: prograde burn (2440 m/s) ===")
print(f"// E_init = {E_init:.6f}")
print(f"// E_final = {E_final:.6f}")
print(f"// dE_actual = {dE_actual:.6f}")
print(f"// dE_expected = {dE_expected:.6f}")
print(f"// dE_relative_error = {dE_err:.15e}")
print()
# Test: Energy conservation - retrograde burn
sim_e2 = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft_e2 = sim_e2.spacecraft[0]
earth_e2 = sim_e2.bodies[1]
pos_e2, vel_e2 = orbital_to_cartesian(craft_e2.orbit, earth_e2.mass)
craft_e2.local_pos = pos_e2
craft_e2.local_vel = vel_e2
m_e2 = craft_e2.mass
v_init_e2 = craft_e2.local_vel
ke_init_e2 = 0.5 * m_e2 * vdot(v_init_e2, v_init_e2)
pe_init_e2 = -G * m_e2 * earth_e2.mass / vmag(craft_e2.local_pos)
E_init_e2 = ke_init_e2 + pe_init_e2
apply_custom_burn(craft_e2, vscale(vnorm(v_init_e2), -1000.0))
v_final_e2 = craft_e2.local_vel
ke_final_e2 = 0.5 * m_e2 * vdot(v_final_e2, v_final_e2)
pe_final_e2 = -G * m_e2 * earth_e2.mass / vmag(craft_e2.local_pos)
E_final_e2 = ke_final_e2 + pe_final_e2
dE_actual_e2 = E_final_e2 - E_init_e2
dv_vec_e2 = vsub(v_final_e2, v_init_e2)
dE_expected_e2 = vdot(v_init_e2, dv_vec_e2) * m_e2 + 0.5 * m_e2 * vdot(dv_vec_e2, dv_vec_e2)
dE_err_e2 = abs(dE_actual_e2 - dE_expected_e2) / abs(dE_expected_e2)
print("// === Energy: retrograde burn (1000 m/s) ===")
print(f"// E_init = {E_init_e2:.6f}")
print(f"// E_final = {E_final_e2:.6f}")
print(f"// dE_actual = {dE_actual_e2:.6f}")
print(f"// dE_expected = {dE_expected_e2:.6f}")
print(f"// dE_relative_error = {dE_err_e2:.15e}")
print()
# Test: Round-trip conversion stability
sim_rt = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft_rt = sim_rt.spacecraft[0]
earth_rt = sim_rt.bodies[1]
pos_rt, vel_rt = orbital_to_cartesian(craft_rt.orbit, earth_rt.mass)
craft_rt.local_pos = pos_rt
craft_rt.local_vel = vel_rt
orig_a = craft_rt.orbit.a
orig_e = craft_rt.orbit.e
for _ in range(5):
els_rt = cartesian_to_orbital_elements(craft_rt.local_pos, craft_rt.local_vel, earth_rt.mass)
pos_rt, vel_rt = orbital_to_cartesian(els_rt, earth_rt.mass)
craft_rt.local_pos = pos_rt
craft_rt.local_vel = vel_rt
final_els_rt = cartesian_to_orbital_elements(craft_rt.local_pos, craft_rt.local_vel, earth_rt.mass)
a_err_rt = abs(final_els_rt.a - orig_a) / orig_a
e_err_rt = abs(final_els_rt.e - orig_e)
print("// === Round-trip conversion stability (5 iterations) ===")
print(f"// orig_a = {orig_a:.6f}")
print(f"// final_a = {final_els_rt.a:.6f}")
print(f"// a_relative_error = {a_err_rt:.15e}")
print(f"// orig_e = {orig_e:.15f}")
print(f"// final_e = {final_els_rt.e:.15f}")
print(f"// e_absolute_error = {e_err_rt:.15e}")
print()
# Test: Burn direction orthogonality
sim_dir = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft_dir = sim_dir.spacecraft[0]
earth_dir = sim_dir.bodies[1]
pos_dir, vel_dir = orbital_to_cartesian(craft_dir.orbit, earth_dir.mass)
craft_dir.local_pos = pos_dir
craft_dir.local_vel = vel_dir
pro = get_burn_direction(BurnDirection.PROGRADE, pos_dir, vel_dir)
retro = get_burn_direction(BurnDirection.RETROGRADE, pos_dir, vel_dir)
norm_dir = get_burn_direction(BurnDirection.NORMAL, pos_dir, vel_dir)
anti = get_burn_direction(BurnDirection.ANTINORMAL, pos_dir, vel_dir)
rad_in = get_burn_direction(BurnDirection.RADIAL_IN, pos_dir, vel_dir)
rad_out = get_burn_direction(BurnDirection.RADIAL_OUT, pos_dir, vel_dir)
dot_pro_retro = vdot(pro, retro)
dot_norm_anti = vdot(norm_dir, anti)
dot_rad_in_out = vdot(rad_in, rad_out)
print("// === Burn direction orthogonality ===")
print(f"// prograde . retrograde = {dot_pro_retro:.15f}")
print(f"// normal . antinormal = {dot_norm_anti:.15f}")
print(f"// radial_in . radial_out = {dot_rad_in_out:.15f}")
print()
# Test: Continuous burn (100 steps, 100 m/s total)
sim_cb = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft_cb = sim_cb.spacecraft[6] # Low_Thrust_Ion
earth_cb = sim_cb.bodies[1]
initial_a_cb = craft_cb.orbit.a
initial_e_cb = craft_cb.orbit.e
final_cb = simulate_continuous_burn(craft_cb.orbit, earth_cb.mass,
100.0, 5000.0, 100, BurnDirection.PROGRADE)
a_cb = final_cb.a
e_cb = final_cb.e
v_circ_init = math.sqrt(mu / initial_a_cb)
v_circ_final = math.sqrt(mu / a_cb)
eps_init = -mu / (2.0 * initial_a_cb)
eps_final = -mu / (2.0 * a_cb)
delta_eps = eps_final - eps_init
expected_dv_from_energy = delta_eps / v_circ_init
rel_err_cb = abs(expected_dv_from_energy - 100.0) / 100.0
print("// === Continuous burn: 100 steps, 100 m/s total prograde ===")
print(f"// initial_a = {initial_a_cb:.6f}")
print(f"// final_a = {a_cb:.6f}")
print(f"// initial_e = {initial_e_cb:.15f}")
print(f"// final_e = {e_cb:.15f}")
print(f"// v_circ_initial = {v_circ_init:.6f}")
print(f"// v_circ_final = {v_circ_final:.6f}")
print(f"// delta_specific_energy = {delta_eps:.6f}")
print(f"// expected_dv_from_energy = {expected_dv_from_energy:.6f}")
print(f"// relative_error = {rel_err_cb:.15e}")
print()
# Test: Multi-burn continuous sequence
sim_mb = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft_mb = sim_mb.spacecraft[7] # Multi_Burn_Sequence
earth_mb = sim_mb.bodies[1]
initial_a_mb = craft_mb.orbit.a
orbit_after_1 = simulate_continuous_burn(craft_mb.orbit, earth_mb.mass,
50.0, 2000.0, 20, BurnDirection.PROGRADE)
a_after_1_mb = orbit_after_1.a
final_mb = simulate_continuous_burn(orbit_after_1, earth_mb.mass,
75.0, 3000.0, 30, BurnDirection.PROGRADE)
a_mb = final_mb.a
v_circ_init_mb = math.sqrt(mu / initial_a_mb)
eps_init_mb = -mu / (2.0 * initial_a_mb)
eps_final_mb = -mu / (2.0 * a_mb)
delta_eps_mb = eps_final_mb - eps_init_mb
expected_dv_mb = delta_eps_mb / v_circ_init_mb
rel_err_mb = abs(expected_dv_mb - 125.0) / 125.0
print("// === Multi-burn continuous: 50+75 m/s total prograde ===")
print(f"// initial_a = {initial_a_mb:.6f}")
print(f"// after_1_a = {a_after_1_mb:.6f}")
print(f"// final_a = {a_mb:.6f}")
print(f"// total_dv = 125.0")
print(f"// expected_dv_from_energy = {expected_dv_mb:.6f}")
print(f"// relative_error = {rel_err_mb:.15e}")
print()
# Test: Mode transition (elliptical orbit, continuous burn)
sim_mt = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft_mt = sim_mt.spacecraft[8] # Mode_Transition
earth_mt = sim_mt.bodies[1]
initial_a_mt = craft_mt.orbit.a
initial_e_mt = craft_mt.orbit.e
final_mt = simulate_continuous_burn(craft_mt.orbit, earth_mt.mass,
200.0, 4000.0, 80, BurnDirection.PROGRADE)
a_mt = final_mt.a
e_mt = final_mt.e
mu_mt = G * earth_mt.mass
energy_before = -mu_mt / (2.0 * initial_a_mt)
energy_after = -mu_mt / (2.0 * a_mt)
energy_change = energy_after - energy_before
v_init_mt = math.sqrt(mu_mt / initial_a_mt)
v_final_mt = math.sqrt(mu_mt / a_mt)
expected_energy_change = 0.5 * (v_init_mt + v_final_mt) * 200.0
print("// === Mode transition: 80 steps, 200 m/s total prograde (e=0.3) ===")
print(f"// initial_a = {initial_a_mt:.6f}")
print(f"// initial_e = {initial_e_mt:.15f}")
print(f"// final_a = {a_mt:.6f}")
print(f"// final_e = {e_mt:.15f}")
print(f"// energy_change = {energy_change:.6f}")
print(f"// expected_energy_change = {expected_energy_change:.6f}")
print()
# Test: Continuous energy conservation
sim_ec = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft_ec = sim_ec.spacecraft[9] # Energy_Conservation
earth_ec = sim_ec.bodies[1]
ke_init_ec = 0.5 * craft_ec.mass * vdot(craft_ec.local_vel, craft_ec.local_vel)
pe_init_ec = -G * craft_ec.mass * earth_ec.mass / vmag(craft_ec.local_pos)
E_init_ec = ke_init_ec + pe_init_ec
final_ec = simulate_continuous_burn(craft_ec.orbit, earth_ec.mass,
150.0, 6000.0, 120, BurnDirection.PROGRADE)
pos_ec, vel_ec = orbital_to_cartesian(final_ec, earth_ec.mass)
temp_craft = Spacecraft(name="temp", mass=craft_ec.mass, parent_index=craft_ec.parent_index,
orbit=final_ec, local_pos=pos_ec, local_vel=vel_ec,
global_pos=(0, 0, 0), global_vel=(0, 0, 0))
ke_final_ec = 0.5 * craft_ec.mass * vdot(vel_ec, vel_ec)
pe_final_ec = -G * craft_ec.mass * earth_ec.mass / vmag(pos_ec)
E_final_ec = ke_final_ec + pe_final_ec
total_dE_ec = E_final_ec - E_init_ec
expected_dE_approx = craft_ec.mass * math.sqrt(mu / craft_ec.orbit.a) * 150.0
rel_err_ec = abs(total_dE_ec - expected_dE_approx) / expected_dE_approx
print("// === Continuous energy conservation: 120 steps, 150 m/s ===")
print(f"// E_init = {E_init_ec:.6f}")
print(f"// E_final = {E_final_ec:.6f}")
print(f"// total_dE = {total_dE_ec:.6f}")
print(f"// expected_approx = {expected_dE_approx:.6f}")
print(f"// relative_error = {rel_err_ec:.15e}")
print()
# Test: Continuous vs impulsive comparison
sim_cv = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft_cv = sim_cv.spacecraft[6] # Low_Thrust_Ion
earth_cv = sim_cv.bodies[1]
orbit_cont = simulate_continuous_burn(craft_cv.orbit, earth_cv.mass,
100.0, 5000.0, 100, BurnDirection.PROGRADE)
orbit_imp = simulate_continuous_burn(craft_cv.orbit, earth_cv.mass,
100.0, 5000.0, 1, BurnDirection.PROGRADE)
diff_a = abs(orbit_cont.a - orbit_imp.a)
rel_diff_a = diff_a / orbit_cont.a * 100.0
v_cont = math.sqrt(mu / orbit_cont.a)
v_imp = math.sqrt(mu / orbit_imp.a)
v_diff = abs(v_cont - v_imp)
print("// === Continuous vs impulsive (100 steps vs 1 step) ===")
print(f"// continuous_a = {orbit_cont.a:.6f}")
print(f"// impulsive_a = {orbit_imp.a:.6f}")
print(f"// a_difference = {diff_a:.6f}")
print(f"// a_relative_diff_pct = {rel_diff_a:.6f}%")
print(f"// v_continuous = {v_cont:.6f}")
print(f"// v_impulsive = {v_imp:.6f}")
print(f"// v_difference = {v_diff:.6f}")
print()
# Test: Propagation during burn - path length
sim_prop = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft_prop = sim_prop.spacecraft[6] # Low_Thrust_Ion
earth_prop = sim_prop.bodies[1]
current_orbit = craft_prop.orbit
dt_burn = 5000.0 / 100
dv_per = 100.0 / 100
positions = []
for i in range(101):
pos, vel = orbital_to_cartesian(current_orbit, earth_prop.mass)
positions.append(pos)
if i < 100:
burn_dir = get_burn_direction(BurnDirection.PROGRADE, pos, vel)
vel = vadd(vel, vscale(burn_dir, dv_per))
current_orbit = cartesian_to_orbital_elements(pos, vel, earth_prop.mass)
current_orbit = propagate(current_orbit, dt_burn, earth_prop.mass)
total_path = sum(vmag(vsub(positions[i], positions[i-1])) for i in range(1, len(positions)))
straight = vmag(vsub(positions[100], positions[0]))
r_start = vmag(positions[0])
r_end = vmag(positions[100])
# Expected semi-major axis from energy
v_init_prop = math.sqrt(mu / craft_prop.orbit.a)
eps_init_prop = -mu / (2.0 * craft_prop.orbit.a)
eps_final_prop = eps_init_prop + v_init_prop * 100.0
a_expected_prop = -mu / (2.0 * eps_final_prop)
e_init_prop = craft_prop.orbit.e
r_peri = a_expected_prop * (1.0 - e_init_prop)
r_apo = a_expected_prop * (1.0 + e_init_prop)
print("// === Propagation during burn: path length ===")
print(f"// total_path_length = {total_path:.6f}")
print(f"// straight_line = {straight:.6f}")
print(f"// r_start = {r_start:.6f}")
print(f"// r_end = {r_end:.6f}")
print(f"// a_expected = {a_expected_prop:.6f}")
print(f"// r_peri_expected = {r_peri:.6f}")
print(f"// r_apo_expected = {r_apo:.6f}")
print()
# Test: Numerical stability - monotonicity
sim_stab = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft_stab = sim_stab.spacecraft[6]
earth_stab = sim_stab.bodies[1]
current_orbit = craft_stab.orbit
dt_burn_s = 5000.0 / 100
dv_per_s = 100.0 / 100
a_history = []
e_history = []
for i in range(100):
pos, vel = orbital_to_cartesian(current_orbit, earth_stab.mass)
burn_dir = get_burn_direction(BurnDirection.PROGRADE, pos, vel)
vel = vadd(vel, vscale(burn_dir, dv_per_s))
current_orbit = cartesian_to_orbital_elements(pos, vel, earth_stab.mass)
current_orbit = propagate(current_orbit, dt_burn_s, earth_stab.mass)
a_history.append(current_orbit.a)
e_history.append(current_orbit.e)
monotonic = all(a_history[i] >= a_history[i-1] for i in range(1, len(a_history)))
max_e = max(e_history)
min_e = min(e_history)
initial_a_s = craft_stab.orbit.a
final_a_s = a_history[-1]
total_change_s = final_a_s - initial_a_s
avg_change_s = total_change_s / 100
max_dev_s = max(abs(a_history[i] - (initial_a_s + (i+1) * avg_change_s)) for i in range(100))
print("// === Numerical stability: monotonicity ===")
print(f"// monotonic_increase = {monotonic}")
print(f"// max_eccentricity = {max_e:.15f}")
print(f"// min_eccentricity = {min_e:.15f}")
print(f"// total_a_change = {total_change_s:.6f}")
print(f"// max_deviation_from_linear = {max_dev_s:.6f}")
print(f"// max_deviation_pct = {max_dev_s / total_change_s * 100:.6f}%")
print()
# Test: Three-burn sequence with plane change
sim_3b = Simulator("tests/test_hybrid_burns.toml", dt=dt)
craft_3b = sim_3b.spacecraft[0] # Hohmann_Transfer
earth_3b = sim_3b.bodies[1]
pos_3b, vel_3b = orbital_to_cartesian(craft_3b.orbit, earth_3b.mass)
craft_3b.local_pos = pos_3b
craft_3b.local_vel = vel_3b
init_a_3b = craft_3b.orbit.a
init_inc_3b = craft_3b.orbit.inc
# Burn 1: prograde 500 m/s
burn1_dir = get_burn_direction(BurnDirection.PROGRADE, craft_3b.local_pos, craft_3b.local_vel)
craft_3b.local_vel = vadd(craft_3b.local_vel, vscale(burn1_dir, 500.0))
craft_3b.orbit = cartesian_to_orbital_elements(craft_3b.local_pos, craft_3b.local_vel, earth_3b.mass)
# Burn 2: normal 300 m/s
burn2_dir = get_burn_direction(BurnDirection.NORMAL, craft_3b.local_pos, craft_3b.local_vel)
craft_3b.local_vel = vadd(craft_3b.local_vel, vscale(burn2_dir, 300.0))
craft_3b.orbit = cartesian_to_orbital_elements(craft_3b.local_pos, craft_3b.local_vel, earth_3b.mass)
# Burn 3: prograde 200 m/s
burn3_dir = get_burn_direction(BurnDirection.PROGRADE, craft_3b.local_pos, craft_3b.local_vel)
craft_3b.local_vel = vadd(craft_3b.local_vel, vscale(burn3_dir, 200.0))
craft_3b.orbit = cartesian_to_orbital_elements(craft_3b.local_pos, craft_3b.local_vel, earth_3b.mass)
final_a_3b = craft_3b.orbit.a
final_inc_3b = craft_3b.orbit.inc
print("// === Three-burn sequence with plane change ===")
print(f"// init_a = {init_a_3b:.6f}")
print(f"// init_inc = {init_inc_3b:.15f}")
print(f"// final_a = {final_a_3b:.6f}")
print(f"// final_inc = {final_inc_3b:.15f}")
print()
if __name__ == "__main__":
main()

54
scripts/precalc_inclined_orbits.py

@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Precalculate expected values for test_inclined_orbits.cpp.
Usage:
python3 scripts/precalc_inclined_orbits.py
Outputs C++-style comments with precalculated values for embedding in the test.
Uses scripts/sim_engine.py for the physics engine.
"""
import sys, math
sys.path.insert(0, 'scripts')
from sim_engine import orbital_to_cartesian, vmag, OrbitalElements, G
# Molniya orbit
a = 26540000.0
e = 0.74
inc = 1.107
omega = 4.71
Omega = 0.0
mu = G * 5.972e24
r_peri = a * (1.0 - e)
r_apo = a * (1.0 + e)
r_90 = a * (1.0 - e*e) / (1.0 + e * math.cos(math.pi/2.0))
r_270 = a * (1.0 - e*e) / (1.0 + e * math.cos(3.0*math.pi/2.0))
T = 2 * math.pi * math.sqrt(a**3 / mu)
T_half = T / 2
print("# Molniya radii:")
print(f"# r_peri = {r_peri:.6f}")
print(f"# r_90 = {r_90:.6f}")
print(f"# r_apo = {r_apo:.6f}")
print(f"# r_270 = {r_270:.6f}")
print(f"#")
print(f"# Period: {T:.6f} s = {T/3600:.6f} hours")
print(f"# Half period: {T_half:.6f} s = {T_half/3600:.6f} hours")
# Generic inclined orbit
a2 = 10000000.0
e2 = 0.5
inc2 = math.radians(45)
omega2 = math.pi / 2
elements2 = OrbitalElements(a=a2, e=e2, nu=0.0, inc=inc2, Omega=0.0, omega=omega2)
pos2, vel2 = orbital_to_cartesian(elements2, 5.972e24)
r2 = vmag(pos2)
z2 = pos2[2]
print(f"\n# Generic inclined (a={a2}, e={e2}, i=45deg, omega=90deg):")
print(f"# r = {r2:.6f} m")
print(f"# z = {z2:.6f} m")

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()

189
scripts/precalc_maneuvers.py

@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
Precalculate expected values for test_maneuvers.cpp refactoring.
Uses sim_engine.py for physics propagation.
"""
import math
import sys
sys.path.insert(0, "/home/agent/dev/claudes_game")
from scripts.sim_engine import *
def main():
dt = 60.0
sim = Simulator("tests/test_maneuvers.toml", dt=dt)
craft = sim.spacecraft[0]
earth = sim.bodies[1]
# =========================================================================
# Test 1: Basic loading
# =========================================================================
print("// === Test 1: Spacecraft loading from config ===")
print(f"// craft_count = {len(sim.spacecraft)}")
print(f"// craft[0].name = \"{craft.name}\"")
print(f"// craft[0].parent_index = {craft.parent_index}")
print()
# =========================================================================
# Test 2: Prograde burn
# =========================================================================
sim2 = Simulator("tests/test_maneuvers.toml", dt=dt)
craft2 = sim2.spacecraft[0]
v0 = vmag(craft2.local_vel)
r0 = vmag(craft2.local_pos)
apply_impulsive_burn(craft2, BurnDirection.PROGRADE, 100.0, earth.mass)
v_after = vmag(craft2.local_vel)
# Simulate 3600s
steps = int(3600.0 / dt)
for _ in range(steps):
sim2._step()
sim2.time += sim2.dt
r_final = vmag(craft2.local_pos)
print("// === Test 2: Prograde burn ===")
print(f"// initial_velocity = {v0:.4f}")
print(f"// velocity_after_burn = {v_after:.4f}")
print(f"// delta_v = {v_after - v0:.4f}")
print(f"// initial_local_r = {r0:.4f}")
print(f"// final_local_r (t=3600s) = {r_final:.4f}")
print(f"// delta_r = {r_final - r0:.4f}")
print()
# =========================================================================
# Test 3: Retrograde burn
# =========================================================================
sim3 = Simulator("tests/test_maneuvers.toml", dt=dt)
craft3 = sim3.spacecraft[0]
v0r = vmag(craft3.local_vel)
r0r = vmag(craft3.local_pos)
apply_impulsive_burn(craft3, BurnDirection.RETROGRADE, 100.0, earth.mass)
v_after_r = vmag(craft3.local_vel)
for _ in range(steps):
sim3._step()
sim3.time += sim3.dt
r_final_r = vmag(craft3.local_pos)
print("// === Test 3: Retrograde burn ===")
print(f"// initial_velocity = {v0r:.4f}")
print(f"// velocity_after_burn = {v_after_r:.4f}")
print(f"// delta_v = {v_after_r - v0r:.4f}")
print(f"// initial_local_r = {r0r:.4f}")
print(f"// final_local_r (t=3600s) = {r_final_r:.4f}")
print(f"// delta_r = {r_final_r - r0r:.4f}")
print()
# =========================================================================
# Test 4: Normal burn
# =========================================================================
sim4 = Simulator("tests/test_maneuvers.toml", dt=dt)
craft4 = sim4.spacecraft[0]
z0 = craft4.local_pos[2]
apply_impulsive_burn(craft4, BurnDirection.NORMAL, 500.0, earth.mass)
for _ in range(steps):
sim4._step()
sim4.time += sim4.dt
z_final = craft4.local_pos[2]
z_change = abs(z_final - z0)
print("// === Test 4: Normal burn ===")
print(f"// initial_z = {z0:.4f}")
print(f"// final_z (t=3600s) = {z_final:.4f}")
print(f"// |z_change| = {z_change:.4f}")
print()
# =========================================================================
# Test 5: Custom burn
# =========================================================================
sim5 = Simulator("tests/test_maneuvers.toml", dt=dt)
craft5 = sim5.spacecraft[0]
iv5 = craft5.local_vel
apply_custom_burn(craft5, (10.0, 20.0, 30.0))
print("// === Test 5: Custom burn ===")
print(f"// initial_vel = ({iv5[0]:.4f}, {iv5[1]:.4f}, {iv5[2]:.4f})")
print(f"// final_vel = ({craft5.local_vel[0]:.4f}, {craft5.local_vel[1]:.4f}, {craft5.local_vel[2]:.4f})")
print(f"// dx = {craft5.local_vel[0] - iv5[0]:.4f}")
print(f"// dy = {craft5.local_vel[1] - iv5[1]:.4f}")
print(f"// dz = {craft5.local_vel[2] - iv5[2]:.4f}")
print()
# =========================================================================
# Test 6: Propagation stability (1 day)
# =========================================================================
sim6 = Simulator("tests/test_maneuvers.toml", dt=dt)
craft6 = sim6.spacecraft[0]
r0_6 = vmag(craft6.local_pos)
a0_6 = craft6.orbit.a
e0_6 = craft6.orbit.e
total_time = 86400.0
steps6 = int(total_time / dt)
for _ in range(steps6):
sim6._step()
sim6.time += sim6.dt
r_final_6 = vmag(craft6.local_pos)
a_final_6 = craft6.orbit.a
e_final_6 = craft6.orbit.e
drift_pct = abs(r_final_6 - r0_6) / r0_6 * 100.0
a_drift_pct = abs(a_final_6 - a0_6) / a0_6 * 100.0
print("// === Test 6: Propagation stability (1 day) ===")
print(f"// initial_local_r = {r0_6:.4f}")
print(f"// final_local_r (t=86400s) = {r_final_6:.4f}")
print(f"// distance_drift_pct = {drift_pct:.10f}%")
print(f"// initial_a = {a0_6:.4f}")
print(f"// final_a = {a_final_6:.4f}")
print(f"// a_drift_pct = {a_drift_pct:.10f}%")
print(f"// initial_e = {e0_6:.10f}")
print(f"// final_e = {e_final_6:.10f}")
print()
# =========================================================================
# Test 7: State vectors at orbital quarters
# =========================================================================
sim7 = Simulator("tests/test_maneuvers.toml", dt=dt)
craft7 = sim7.spacecraft[0]
orbit_radius = vmag(craft7.local_pos)
period = 2 * math.pi * math.sqrt(orbit_radius**3 / (G * earth.mass))
quarter_time = period / 4
steps_per_quarter = int(quarter_time / dt)
print("// === Test 7: State vectors at orbital quarters ===")
print(f"// orbit_radius = {orbit_radius:.4f}")
print(f"// orbital_period = {period:.4f} s ({period/3600:.4f} hours)")
print(f"// steps_per_quarter = {steps_per_quarter}")
for q in range(5):
angle = math.atan2(craft7.local_pos[1], craft7.local_pos[0])
if angle < 0:
angle += 2 * math.pi
r_q = vmag(craft7.local_pos)
v_q = vmag(craft7.local_vel)
print(f"// Q{q}: angle={math.degrees(angle):.4f}°, r={r_q:.4f}, v={v_q:.4f}")
if q < 4:
for _ in range(steps_per_quarter):
sim7._step()
sim7.time += sim7.dt
# Full rotation check
final_angle = math.atan2(craft7.local_pos[1], craft7.local_pos[0])
if final_angle < 0:
final_angle += 2 * math.pi
total_deg = math.degrees(final_angle)
print(f"// total_rotation = {total_deg:.4f}° = {final_angle:.6f} rad")
print(f"// angle_change_per_quarter = {(total_deg / 4):.4f}°")
print()
if __name__ == "__main__":
main()

360
scripts/precalc_moon_orbits.py

@ -0,0 +1,360 @@
#!/usr/bin/env python3
"""
Precalculate moon orbit TOML config from planetary_data.md values.
Converts mean anomaly (M) at J2000 to true anomaly (nu) via Kepler's equation,
then outputs a complete test TOML config with correct planetary masses,
eccentricities, inclinations, and orbital elements.
Usage:
python3 scripts/precalc_moon_orbits.py
Outputs:
- TOML config to stdout (redirect to tests/test_moon_orbits.toml)
- Console summary of computed values
"""
import sys, math
sys.path.insert(0, "scripts")
from sim_engine import (
solve_kepler_elliptical,
orbital_to_cartesian,
vmag,
G,
normalize_angle,
OrbitalElements,
)
# Planetary data from docs/planetary_data.md
SUN_MASS = 1.989e30
SUN_RADIUS = 6.96e8
PLANETS = [
{
"name": "Venus",
"mass": 4.87e24,
"radius": 6.052e6,
"parent": 0,
"a_au": 0.723,
"e": 0.007,
"inc_deg": 3.39,
"Omega_deg": 76.68,
"omega_deg": 54.92,
"M_deg": 50.38,
},
{
"name": "Earth",
"mass": 5.97e24,
"radius": 6.378e6,
"parent": 0,
"a_au": 1.000,
"e": 0.017,
"inc_deg": 0.00,
"Omega_deg": 0.00,
"omega_deg": 102.94,
"M_deg": -2.47,
},
{
"name": "Mars",
"mass": 6.42e23,
"radius": 3.396e6,
"parent": 0,
"a_au": 1.524,
"e": 0.093,
"inc_deg": 1.85,
"Omega_deg": 49.56,
"omega_deg": 286.50,
"M_deg": 19.39,
},
{
"name": "Jupiter",
"mass": 1.898e27,
"radius": 71.492e6,
"parent": 0,
"a_au": 5.203,
"e": 0.049,
"inc_deg": 1.31,
"Omega_deg": 100.47,
"omega_deg": 274.25,
"M_deg": 19.67,
},
{
"name": "Saturn",
"mass": 5.683e26,
"radius": 60.268e6,
"parent": 0,
"a_au": 9.537,
"e": 0.057,
"inc_deg": 2.49,
"Omega_deg": 113.66,
"omega_deg": 338.94,
"M_deg": -42.64,
},
{
"name": "Uranus",
"mass": 8.68e25,
"radius": 25.559e6,
"parent": 0,
"a_au": 19.19,
"e": 0.046,
"inc_deg": 0.77,
"Omega_deg": 74.02,
"omega_deg": 96.94,
"M_deg": 142.28,
},
{
"name": "Neptune",
"mass": 1.02e26,
"radius": 24.764e6,
"parent": 0,
"a_au": 30.07,
"e": 0.010,
"inc_deg": 1.77,
"Omega_deg": 131.78,
"omega_deg": 273.18,
"M_deg": -100.08,
},
]
AU = 1.496e11 # meters
MOONS = [
{
"name": "Moon",
"mass": 7.35e22,
"radius": 1.738e6,
"parent": "Earth",
"a_km": 384400,
"e": 0.055,
"inc_deg": 5.16,
"Omega_deg": 125.08,
"omega_deg": 318.15,
"M_deg": 135.27,
},
{
"name": "Io",
"mass": 8.93e23,
"radius": 1.822e6,
"parent": "Jupiter",
"a_km": 421800,
"e": 0.004,
"inc_deg": 0.00,
"Omega_deg": 0.0,
"omega_deg": 49.1,
"M_deg": 330.9,
},
{
"name": "Europa",
"mass": 4.80e23,
"radius": 1.561e6,
"parent": "Jupiter",
"a_km": 671100,
"e": 0.009,
"inc_deg": 0.50,
"Omega_deg": 184.0,
"omega_deg": 45.0,
"M_deg": 345.4,
},
{
"name": "Ganymede",
"mass": 1.48e24,
"radius": 2.631e6,
"parent": "Jupiter",
"a_km": 1070400,
"e": 0.001,
"inc_deg": 0.20,
"Omega_deg": 58.5,
"omega_deg": 198.3,
"M_deg": 324.8,
},
{
"name": "Callisto",
"mass": 1.08e24,
"radius": 2.410e6,
"parent": "Jupiter",
"a_km": 1882700,
"e": 0.007,
"inc_deg": 0.30,
"Omega_deg": 309.1,
"omega_deg": 43.8,
"M_deg": 87.4,
},
{
"name": "Titan",
"mass": 1.35e24,
"radius": 2.575e6,
"parent": "Saturn",
"a_km": 1221900,
"e": 0.029,
"inc_deg": 0.30,
"Omega_deg": 78.6,
"omega_deg": 78.3,
"M_deg": 11.7,
},
]
# Kepler conversion: M -> E -> nu
def mean_to_true_anomaly(M_deg, e):
"""Convert mean anomaly (degrees) to true anomaly (radians) via Kepler's equation."""
M = math.radians(M_deg)
E = solve_kepler_elliptical(M, e)
# tan(nu/2) = sqrt((1+e)/(1-e)) * tan(E/2)
tan_half_e = math.tan(E / 2.0)
tan_half_nu = math.sqrt((1.0 + e) / (1.0 - e)) * tan_half_e
nu = 2.0 * math.atan(tan_half_nu)
return normalize_angle(nu)
# Print TOML config
def print_toml():
print("# Moon Orbits Test Configuration")
print("# Auto-generated by scripts/precalc_moon_orbits.py")
print("# Data source: docs/planetary_data.md (JPL planetary facts)")
print("# Mean anomaly converted to true anomaly via Kepler's equation")
print()
# Sun
print('[[bodies]]')
print('name = "Sun"')
print(f"mass = {SUN_MASS}")
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")
print("orbit.eccentricity = 0.0")
print("orbit.true_anomaly = 0.0")
print()
# Planets
for p in PLANETS:
a_m = p["a_au"] * AU
inc = math.radians(p["inc_deg"])
Omega = math.radians(p["Omega_deg"])
omega = math.radians(p["omega_deg"])
nu = mean_to_true_anomaly(p["M_deg"], p["e"])
print('[[bodies]]')
print(f'name = "{p["name"]}"')
print(f'mass = {p["mass"]}')
print(f'radius = {p["radius"]}')
print(f'parent_index = {p["parent"]}')
print('color = { r = 0.5, g = 0.5, b = 0.5 }')
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
for m in MOONS:
a_m = m["a_km"] * 1000.0
inc = math.radians(m["inc_deg"])
Omega = math.radians(m["Omega_deg"])
omega = math.radians(m["omega_deg"])
nu = mean_to_true_anomaly(m["M_deg"], m["e"])
print('[[bodies]]')
print(f'name = "{m["name"]}"')
print(f'mass = {m["mass"]}')
print(f'radius = {m["radius"]}')
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(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()
# Print computed values summary (for verification)
def print_summary():
print("# === Computed True Anomalies ===")
print()
for m in MOONS:
nu = mean_to_true_anomaly(m["M_deg"], m["e"])
nu_deg = nu * 180.0 / math.pi
a_m = m["a_km"] * 1000.0
mu = G * eval(f"{m['parent']}_MASS") if m["parent"] in globals() else 0
# Compute period
parent_mass = {"Earth": 5.97e24, "Jupiter": 1.898e27, "Saturn": 5.683e26}[m["parent"]]
mu = G * parent_mass
T = 2.0 * math.pi * math.sqrt(a_m**3 / mu)
T_days = T / 86400.0
print(
f'{m["name"]:10s}: M={m["M_deg"]:7.2f}deg -> nu={nu_deg:7.2f}deg '
f"a={m['a_km']:>8.0f}km e={m['e']:.3f} "
f"T={T_days:.3f}d"
)
print()
print("# === Initial positions (from true anomaly) ===")
print("# Format: name, r (m), nu (deg)")
parent_masses = {
"Earth": 5.97e24,
"Jupiter": 1.898e27,
"Saturn": 5.683e26,
}
for m in MOONS:
a_m = m["a_km"] * 1000.0
nu = mean_to_true_anomaly(m["M_deg"], m["e"])
pm = parent_masses[m["parent"]]
el = OrbitalElements(
a=a_m, e=m["e"], nu=nu,
inc=math.radians(m["inc_deg"]),
Omega=math.radians(m["Omega_deg"]),
omega=math.radians(m["omega_deg"]),
)
pos, vel = orbital_to_cartesian(el, pm)
r = vmag(pos)
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()
print("=" * 60)
print("# TOML CONFIG (copy below this line)")
print("=" * 60)
print()
print_toml()
print()
print("=" * 60)
print("# CPP EXPECTED VALUES (copy into test fixture)")
print("=" * 60)
print()
print_cpp_expected_values()

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()

89
scripts/precalc_parabolic_orbit.py

@ -0,0 +1,89 @@
#!/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()
for i in range(steps):
sim._step()
# 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()
# 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"// final_velocity matches {vf:.6f} m/s: True")
if __name__ == "__main__":
main()

254
scripts/precalc_periapsis_burn.py

@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""
Precalculate expected values for test_periapsis_burn.cpp refactoring.
Uses sim_engine.py for physics propagation with maneuver trigger support.
Outputs C++-style comments with expected values for embedding in the test.
Also outputs burn_result values (pre-burn state vectors) for verification.
"""
import math
import sys
sys.path.insert(0, "/home/agent/dev/claudes_game")
from scripts.sim_engine import *
def main():
dt = 60.0
earth = None
for b in sim.bodies:
if b.name == "Earth":
earth = b
break
# =========================================================================
# Scenario 1: TestSatellite - starting at periapsis, two sequential burns
# =========================================================================
sim1 = Simulator("tests/test_periapsis_burn.toml", dt=dt)
craft1 = sim1.spacecraft[0] # TestSatellite
# Initial orbit state
r0 = vmag(craft1.local_pos)
v0 = vmag(craft1.local_vel)
a0 = craft1.orbit.a
e0 = craft1.orbit.e
periapsis0 = a0 * (1.0 - e0)
apoapsis0 = a0 * (1.0 + e0)
period0 = 2.0 * math.pi * math.sqrt(a0**3 / (G * earth.mass))
print("// === Scenario 1: TestSatellite - Two sequential periapsis burns ===")
print(f"// Initial orbit:")
print(f"// a = {a0:.4f} m")
print(f"// e = {e0:.10f}")
print(f"// periapsis = {periapsis0:.4f} m")
print(f"// apoapsis = {apoapsis0:.4f} m")
print(f"// period = {period0:.4f} s ({period0/3600:.4f} hours)")
print(f"// r0 = {r0:.4f} m (should equal periapsis)")
print(f"// v0 = {v0:.4f} m/s")
print(f"// nu0 = {math.degrees(craft1.orbit.nu):.4f} deg")
print()
# First burn: immediate (step 0)
# Second burn: after ~1 full orbit from first burn
total_steps = int(2.5 * period0 / dt) # ~2.5 orbits
burn1_time = -1.0
burn1_pos = None
burn1_vel = None
burn1_nu = None
burn1_radius = -1.0
burn1_v = -1.0
burn1_a = -1.0
burn1_post_sma = -1.0
burn1_post_v = -1.0
burn2_time = -1.0
burn2_pos = None
burn2_vel = None
burn2_nu = None
burn2_radius = -1.0
burn2_v = -1.0
for step in range(total_steps):
sim1._step()
# Check if first burn executed
if sim1.maneuvers[0].executed and burn1_time < 0:
burn1_time = sim1.maneuvers[0].executed_time
burn1_a = craft1.orbit.a
br1 = sim1.maneuvers[0].burn_result
burn1_pos = br1.position
burn1_vel = br1.velocity
burn1_nu = br1.true_anomaly
burn1_radius = vmag(br1.position)
burn1_v = vmag(br1.velocity)
b1x, b1y, b1z = burn1_pos
b1vx, b1vy, b1vz = burn1_vel
print(f"// First burn at step {step}, t={burn1_time:.1f}s")
print(f"// burn_result (pre-burn state):")
print(f"// valid = {br1.valid}")
print(f"// radius = {burn1_radius:.4f} m")
print(f"// true_anomaly = {burn1_nu:.15f} rad")
print(f"// pos = ({b1x:.4f}, {b1y:.4f}, {b1z:.4f}) m")
print(f"// vel = ({b1vx:.4f}, {b1vy:.4f}, {b1vz:.4f}) m/s")
# Capture post-burn+60s-propagation state (what the test reads)
burn1_post_sma = craft1.orbit.a
burn1_post_v = vmag(craft1.local_vel)
print(f"// post-burn + 60s propagation (test assertions):")
print(f"// sma = {burn1_post_sma:.15f} m")
print(f"// velocity = {burn1_post_v:.15f} m/s")
print(f"// burn_result (pre-burn) — tight tolerance assertions:")
print(f"// preburn_v = {burn1_v:.15f} m/s")
# Check if second burn executed
if sim1.maneuvers[1].executed and burn2_time < 0:
burn2_time = sim1.maneuvers[1].executed_time
br2 = sim1.maneuvers[1].burn_result
burn2_pos = br2.position
burn2_vel = br2.velocity
burn2_nu = br2.true_anomaly
burn2_radius = vmag(br2.position)
burn2_v = vmag(br2.velocity)
b2x, b2y, b2z = burn2_pos
b2vx, b2vy, b2vz = burn2_vel
print(f"// Second burn at step {step}, t={burn2_time:.1f}s")
print(f"// burn_result (pre-burn state):")
print(f"// valid = {br2.valid}")
print(f"// radius = {burn2_radius:.4f} m")
print(f"// true_anomaly = {burn2_nu:.15f} rad")
print(f"// pos = ({b2x:.4f}, {b2y:.4f}, {b2z:.4f}) m")
print(f"// vel = ({b2vx:.4f}, {b2vy:.4f}, {b2vz:.4f}) m/s")
print()
# =========================================================================
# Scenario 2: TestSatelliteCrossing - starts at nu=pi/2, one burn
# =========================================================================
sim2 = Simulator("tests/test_periapsis_burn.toml", dt=dt)
craft2 = sim2.spacecraft[1] # TestSatelliteCrossing
r0_cross = vmag(craft2.local_pos)
v0_cross = vmag(craft2.local_vel)
a0_cross = craft2.orbit.a
e0_cross = craft2.orbit.e
periapsis_cross = a0_cross * (1.0 - e0_cross)
period_cross = 2.0 * math.pi * math.sqrt(a0_cross**3 / (G * earth.mass))
print("// === Scenario 2: TestSatelliteCrossing - Burn crossing from nu=pi/2 ===")
print(f"// Initial orbit:")
print(f"// a = {a0_cross:.4f} m")
print(f"// e = {e0_cross:.10f}")
print(f"// periapsis = {periapsis_cross:.4f} m")
print(f"// period = {period_cross:.4f} s")
print(f"// r0 = {r0_cross:.4f} m")
print(f"// v0 = {v0_cross:.4f} m/s")
print(f"// nu0 = {math.degrees(craft2.orbit.nu):.4f} deg")
print()
burn_cross_time = -1.0
burn_cross_pos = None
burn_cross_vel = None
burn_cross_nu = None
burn_cross_radius = -1.0
burn_cross_v = -1.0
max_steps = int(2.0 * period_cross / dt)
for step in range(max_steps):
sim2._step()
if sim2.maneuvers[2].executed and burn_cross_time < 0:
burn_cross_time = sim2.maneuvers[2].executed_time
brc = sim2.maneuvers[2].burn_result
burn_cross_pos = brc.position
burn_cross_vel = brc.velocity
burn_cross_nu = brc.true_anomaly
burn_cross_radius = vmag(brc.position)
burn_cross_v = vmag(brc.velocity)
bcx, bcy, bcz = burn_cross_pos
bcvx, bcvy, bcvz = burn_cross_vel
print(f"// Burn at step {step}, t={burn_cross_time:.1f}s")
print(f"// burn_result (pre-burn state):")
print(f"// valid = {brc.valid}")
print(f"// radius = {burn_cross_radius:.4f} m")
print(f"// true_anomaly = {burn_cross_nu:.15f} rad")
print(f"// pos = ({bcx:.4f}, {bcy:.4f}, {bcz:.4f}) m")
print(f"// vel = ({bcvx:.4f}, {bcvy:.4f}, {bcvz:.4f}) m/s")
print()
# =========================================================================
# Summary: Expected values for C++ test embedding
# =========================================================================
print("// === SUMMARY: Values for C++ test embedding ===")
print()
print("// --- TestSatellite initial orbit ---")
print(f"// initial_periapsis = {periapsis0:.4f}")
print(f"// initial_apoapsis = {apoapsis0:.4f}")
print(f"// initial_radius = {r0:.4f}")
print(f"// initial_velocity = {v0:.4f}")
print(f"// initial_period = {period0:.4f}")
print()
if burn1_time >= 0:
print("// --- First burn (TestSatellite) - burn_result ===")
print(f"// burn1_time = {burn1_time:.4f}")
print(f"// burn1_radius (pre-burn) = {burn1_radius:.4f}")
print(f"// burn1_velocity (pre-burn) = {burn1_v:.4f}")
print(f"// burn1_true_anomaly (pre-burn) = {burn1_nu:.15f}")
print(f"// burn1_pos = ({b1x:.4f}, {b1y:.4f}, {b1z:.4f}) m")
print(f"// burn1_vel = ({b1vx:.4f}, {b1vy:.4f}, {b1vz:.4f}) m/s")
print(f"// burn1_a (post-burn) = {burn1_a:.4f} m")
print()
print("// --- First burn - post-burn + 60s propagation (test assertions) ===")
print(f"// burn1_expected_sma = {burn1_post_sma:.15f}")
print(f"// burn1_expected_v = {burn1_post_v:.15f}")
print()
if burn2_time >= 0:
print("// --- Second burn (TestSatellite) - burn_result ===")
print(f"// burn2_time = {burn2_time:.4f}")
print(f"// burn2_radius (pre-burn) = {burn2_radius:.4f}")
print(f"// burn2_velocity (pre-burn) = {burn2_v:.4f}")
print(f"// burn2_true_anomaly (pre-burn) = {burn2_nu:.15f}")
print(f"// burn2_pos = ({b2x:.4f}, {b2y:.4f}, {b2z:.4f}) m")
print(f"// burn2_vel = ({b2vx:.4f}, {b2vy:.4f}, {b2vz:.4f}) m/s")
if burn1_time >= 0:
time_between = burn2_time - burn1_time
print(f"// time_between_burns = {time_between:.4f}")
print()
if burn_cross_time >= 0:
print("// --- Cross burn (TestSatelliteCrossing) - burn_result ===")
print(f"// burn_cross_time = {burn_cross_time:.4f}")
print(f"// burn_cross_radius (pre-burn) = {burn_cross_radius:.4f}")
print(f"// burn_cross_velocity (pre-burn) = {burn_cross_v:.4f}")
print(f"// burn_cross_true_anomaly (pre-burn) = {burn_cross_nu:.15f}")
print(f"// burn_cross_pos = ({bcx:.4f}, {bcy:.4f}, {bcz:.4f}) m")
print(f"// burn_cross_vel = ({bcvx:.4f}, {bcvy:.4f}, {bcvz:.4f}) m/s")
print()
# Key assertions
print("// === Key assertions for test ===")
print(f"// Periapsis preserved: initial_periapsis ~= final_periapsis (within 1.0)")
print(f"// Initial radius ~= periapsis: {r0:.4f} ~= {periapsis0:.4f}")
print(f"// Burn radius ~= periapsis: burn radii should be close to {periapsis0:.4f}")
print(f"// Two burns at same location: burn1_radius ~= burn2_radius")
print(f"// Time between burns ~= orbital period")
print()
# State vector comparison (C++ vs Python agreement)
if burn1_pos and burn2_pos and burn1_vel and burn2_vel:
def state_vec_dist(p1, v1, p2, v2):
dr = math.sqrt(sum((a-b)**2 for a,b in zip(p1,p2)))
dv = math.sqrt(sum((a-b)**2 for a,b in zip(v1,v2)))
return dr, dv
ddr1, ddv1 = state_vec_dist(burn1_pos, burn1_vel, burn1_pos, burn1_vel)
print(f"// State vector self-check (burn1 vs burn1): dr={ddr1:.2e} m, dv={ddv1:.2e} m/s")
if __name__ == "__main__":
# Quick sanity: need to create a dummy sim first to test config loading
sim = Simulator("tests/test_periapsis_burn.toml", dt=60.0)
main()

920
scripts/sim_engine.py

@ -0,0 +1,920 @@
#!/usr/bin/env python3
"""
Generic orbital mechanics simulation engine.
Replicates the exact physics from src/orbital_mechanics.cpp and src/simulation.cpp.
Usage:
from sim_engine import Simulator
sim = Simulator("path/to/config.toml", dt=60.0)
sim.run(steps=1000)
for event in sim.events:
print(event)
"""
import math
import tomllib
from dataclasses import dataclass, field, replace
from typing import Dict, Tuple, Any
# Constants
G = 6.67430e-11
PARABOLIC_TOLERANCE = 1e-3
KEPLER_TOLERANCE = 1e-10
KEPLER_MAX_ITER = 50
VEL_DRIFT_THRESHOLD = 1e-6 # m/s
# Vector operations
def vadd(a, b):
return (a[0]+b[0], a[1]+b[1], a[2]+b[2])
def vsub(a, b):
return (a[0]-b[0], a[1]-b[1], a[2]-b[2])
def vscale(v, s):
return (v[0]*s, v[1]*s, v[2]*s)
def vmag(v):
return math.sqrt(v[0]**2 + v[1]**2 + v[2]**2)
def vdot(a, b):
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
def vcross(a, b):
return (
a[1]*b[2] - a[2]*b[1],
a[2]*b[0] - a[0]*b[2],
a[0]*b[1] - a[1]*b[0]
)
def vnorm(v):
m = vmag(v)
if m < 1e-15:
return (0.0, 0.0, 0.0)
return (v[0]/m, v[1]/m, v[2]/m)
def normalize_angle(angle):
while angle < 0.0:
angle += 2.0 * math.pi
while angle >= 2.0 * math.pi:
angle -= 2.0 * math.pi
return angle
def angular_distance(a, b):
"""Shortest angular distance on unit circle (matches C++)."""
diff = abs(normalize_angle(a) - normalize_angle(b))
return (2.0 * math.pi - diff) if diff > math.pi else diff
def true_anomaly_to_eccentric_anomaly(true_anomaly, eccentricity):
"""Convert true anomaly to eccentric anomaly (matches C++).
Near-parabolic case uses cos/sin formulation to avoid instability.
TODO: parabolic (e1) and hyperbolic (e>1) branches.
"""
if abs(1.0 - eccentricity) < 0.01:
# Near-parabolic: use cos/sin formulation
nu = true_anomaly
e = eccentricity
cos_nu = math.cos(nu)
sin_nu = math.sin(nu)
denominator = 1.0 + e * cos_nu
cos_E = (cos_nu + e) / denominator
sin_E = sin_nu * math.sqrt(max(0.0, 1.0 - e * e)) / denominator
cos_E = max(-1.0, min(1.0, cos_E))
sin_E = max(-1.0, min(1.0, sin_E))
return math.atan2(sin_E, cos_E)
tan_half_nu = math.tan(true_anomaly / 2.0)
tan_half_E = math.sqrt((1.0 - eccentricity) / (1.0 + eccentricity)) * tan_half_nu
return 2.0 * math.atan(tan_half_E)
# Data structures
@dataclass
class OrbitalElements:
a: float = 0.0 # semi-major axis (elliptical) / semi-latus rectum (parabolic)
e: float = 0.0 # eccentricity
nu: float = 0.0 # true anomaly
inc: float = 0.0 # inclination
Omega: float = 0.0 # longitude of ascending node
omega: float = 0.0 # argument of periapsis
p: float = 0.0 # semi-latus rectum (parabolic only)
@dataclass
class Body:
name: str = ""
mass: float = 0.0
radius: float = 0.0
parent_index: int = -1
orbit: OrbitalElements = field(default_factory=OrbitalElements)
local_pos: Tuple[float, float, float] = (0.0, 0.0, 0.0)
local_vel: Tuple[float, float, float] = (0.0, 0.0, 0.0)
global_pos: Tuple[float, float, float] = (0.0, 0.0, 0.0)
global_vel: Tuple[float, float, float] = (0.0, 0.0, 0.0)
@dataclass
class Spacecraft:
name: str = ""
mass: float = 0.0
parent_index: int = -1
orbit: OrbitalElements = field(default_factory=OrbitalElements)
local_pos: Tuple[float, float, float] = (0.0, 0.0, 0.0)
local_vel: Tuple[float, float, float] = (0.0, 0.0, 0.0)
global_pos: Tuple[float, float, float] = (0.0, 0.0, 0.0)
global_vel: Tuple[float, float, float] = (0.0, 0.0, 0.0)
class BurnDirection:
PROGRADE = 0
RETROGRADE = 1
NORMAL = 2
ANTINORMAL = 3
RADIAL_IN = 4
RADIAL_OUT = 5
CUSTOM = 6
BURN_NAMES = ["PROGRADE", "RETROGRADE", "NORMAL", "ANTINORMAL", "RADIAL_IN", "RADIAL_OUT", "CUSTOM"]
class TriggerType:
TIME = 0
TRUE_ANOMALY = 1
TRIGGER_NAMES = ["TIME", "TRUE_ANOMALY"]
@dataclass
class BurnResult:
"""State vectors captured at the exact moment a burn fires (matches C++ BurnResult)."""
valid: bool = False
position: Tuple[float, float, float] = (0.0, 0.0, 0.0)
velocity: Tuple[float, float, float] = (0.0, 0.0, 0.0)
true_anomaly: float = 0.0
@dataclass
class Maneuver:
"""Impulsive burn with trigger conditions (matches C++ Maneuver struct)."""
name: str = ""
craft_index: int = -1
direction: int = 0 # BurnDirection
delta_v: float = 0.0
trigger_type: int = 0 # TriggerType
trigger_value: float = 0.0
scheduled_dt: float = 0.0
executed: bool = False
executed_time: float = 0.0
burn_result: BurnResult = field(default_factory=BurnResult)
@dataclass
class Event:
"""Recorded simulation event."""
kind: str = "state"
time: float = 0.0
data: Dict[str, Any] = field(default_factory=dict)
# Burn direction vectors (local frame)
def get_burn_direction(direction, local_pos, local_vel):
"""Calculate burn direction vector in local frame."""
if direction == BurnDirection.PROGRADE:
return vnorm(local_vel)
elif direction == BurnDirection.RETROGRADE:
return vscale(vnorm(local_vel), -1.0)
elif direction == BurnDirection.NORMAL:
h = vcross(local_pos, local_vel)
return vnorm(h)
elif direction == BurnDirection.ANTINORMAL:
h = vcross(local_pos, local_vel)
return vscale(vnorm(h), -1.0)
elif direction == BurnDirection.RADIAL_IN:
return vscale(vnorm(local_pos), -1.0)
elif direction == BurnDirection.RADIAL_OUT:
return vnorm(local_pos)
elif direction == BurnDirection.CUSTOM:
raise ValueError("CUSTOM requires explicit delta_v vector")
return (0.0, 0.0, 0.0)
def apply_impulsive_burn(craft, direction, delta_v, parent_mass):
"""Apply an impulsive burn to a spacecraft. Updates orbit elements."""
burn_dir = get_burn_direction(direction, craft.local_pos, craft.local_vel)
dv_vec = vscale(burn_dir, delta_v)
craft.local_vel = vadd(craft.local_vel, dv_vec)
craft.global_vel = vadd(craft.global_vel, dv_vec)
# Reconstruct orbital elements from new state
if craft.parent_index >= 0:
craft.orbit = cartesian_to_orbital_elements(craft.local_pos, craft.local_vel, parent_mass)
def check_maneuver_trigger(maneuver, craft, sim_time, sim_dt, bodies):
"""Check if a maneuver trigger fires this timestep (matches C++ check_maneuver_trigger).
Sets maneuver.scheduled_dt and returns True if trigger fires.
TODO: parabolic (Barker's equation) and hyperbolic branches for TRIGGER_TRUE_ANOMALY.
"""
if maneuver.trigger_type == TriggerType.TIME:
if sim_time > maneuver.trigger_value:
maneuver.scheduled_dt = 0.0
return True
if sim_time + sim_dt <= maneuver.trigger_value:
return False
dt_to_burn = maneuver.trigger_value - sim_time
maneuver.scheduled_dt = max(0.0, min(dt_to_burn, sim_dt))
return True
elif maneuver.trigger_type == TriggerType.TRUE_ANOMALY:
if craft.parent_index < 0 or craft.parent_index >= len(bodies):
return False
parent = bodies[craft.parent_index]
current_nu = normalize_angle(craft.orbit.nu)
target_nu = normalize_angle(maneuver.trigger_value)
# Near: fire immediately
if angular_distance(current_nu, target_nu) < 0.01:
maneuver.scheduled_dt = 0.0
return True
a = craft.orbit.a
e = craft.orbit.e
mu = G * parent.mass
n = math.sqrt(mu / (a ** 3.0))
E_current = true_anomaly_to_eccentric_anomaly(current_nu, e)
E_target = true_anomaly_to_eccentric_anomaly(target_nu, e)
M_current = E_current - e * math.sin(E_current)
M_target = E_target - e * math.sin(E_target)
M_delta = M_target - M_current
dt_needed = M_delta / n
# Wrap to next periapsis if negative
if dt_needed < 0:
M_period = 2.0 * math.pi
dt_needed += M_period / n
if dt_needed <= 0.0 or dt_needed > sim_dt:
return False
maneuver.scheduled_dt = dt_needed
return True
return False
def apply_custom_burn(craft, delta_v_vec):
"""Apply a custom delta-v vector directly to spacecraft velocity."""
craft.local_vel = vadd(craft.local_vel, delta_v_vec)
craft.global_vel = vadd(craft.global_vel, delta_v_vec)
# Kepler equation solvers (exact C++ logic)
def get_initial_trial_value(mean_anomaly, eccentricity):
"""Initial guess for Kepler solver (C++ get_initial_trial_value)."""
return (mean_anomaly + eccentricity * math.sin(mean_anomaly)
+ ((eccentricity ** 2 / 2.0) * math.sin(2.0 * mean_anomaly)))
def solve_kepler_elliptical(mean_anomaly, eccentricity):
E = get_initial_trial_value(mean_anomaly, eccentricity)
E_prev = E + 2.0 * KEPLER_TOLERANCE
for _ in range(KEPLER_MAX_ITER):
if abs(E - E_prev) < KEPLER_TOLERANCE:
break
E_prev = E
sin_E = math.sin(E)
E = E - (E - eccentricity * sin_E - mean_anomaly) / (1.0 - eccentricity * math.cos(E))
return E
# Coordinate transforms
def orbital_to_cartesian(elements, parent_mass):
"""Convert orbital elements to local position/velocity vectors."""
mu = G * parent_mass
a = elements.a
e = elements.e
nu = elements.nu
if abs(e - 1.0) < PARABOLIC_TOLERANCE:
p = elements.p
else:
p = a * (1.0 - e * e)
r = p / (1.0 + e * math.cos(nu))
x_orb = r * math.cos(nu)
y_orb = r * math.sin(nu)
vx_orb = -math.sqrt(mu / p) * math.sin(nu)
vy_orb = math.sqrt(mu / p) * (e + math.cos(nu))
# z-x-z rotation: Rz(Omega) * Rx(inc) * Rz(omega)
cos_w, sin_w = math.cos(elements.omega), math.sin(elements.omega)
x1 = x_orb * cos_w - y_orb * sin_w
y1 = x_orb * sin_w + y_orb * cos_w
cos_i, sin_i = math.cos(elements.inc), math.sin(elements.inc)
x2 = x1
y2 = y1 * cos_i
z2 = y1 * sin_i
cos_O, sin_O = math.cos(elements.Omega), math.sin(elements.Omega)
pos = (x2 * cos_O - y2 * sin_O,
x2 * sin_O + y2 * cos_O,
z2)
vx1 = vx_orb * cos_w - vy_orb * sin_w
vy1 = vx_orb * sin_w + vy_orb * cos_w
vx2 = vx1
vy2 = vy1 * cos_i
vz2 = vy1 * sin_i
vel = (vx2 * cos_O - vy2 * sin_O,
vx2 * sin_O + vy2 * cos_O,
vz2)
return pos, vel
def cartesian_to_orbital_elements(pos, vel, parent_mass):
"""Convert local position/velocity to orbital elements."""
mu = G * parent_mass
r = vmag(pos)
v = vmag(vel)
v_sq = v * v
specific_energy = -mu / r + v_sq / 2.0
h_vec = vcross(pos, vel)
h = vmag(h_vec)
# Eccentricity vector: e_vec = (v² - μ/r)r - (r·v)v all divided by μ
r_dot_v = vdot(pos, vel)
e_vec = ((v_sq - mu / r) * pos[0] - r_dot_v * vel[0]) / mu, \
((v_sq - mu / r) * pos[1] - r_dot_v * vel[1]) / mu, \
((v_sq - mu / r) * pos[2] - r_dot_v * vel[2]) / mu
e = vmag(e_vec)
# Semi-major axis
if abs(specific_energy) < 1e-10:
a = 1e10
else:
a = -mu / (2.0 * specific_energy)
# True anomaly
if e < 1e-10:
# Nearly circular: use argument of latitude
n_vec = vcross((0.0, 0.0, 1.0), h_vec)
n_mag = vmag(n_vec)
sin_i = (n_mag / h) if h > 1e-10 else 1.0
if sin_i > 1e-6 and n_mag > 1e-10:
# Well-defined ascending node: compute argument of latitude
x_AN = n_vec[0] / n_mag
y_AN = n_vec[1] / n_mag
hcn = vcross(h_vec, n_vec)
hcn_mag = vmag(hcn)
if hcn_mag > 1e-10:
hcn = vscale(hcn, 1.0 / hcn_mag)
r_xAN = pos[0] * x_AN + pos[1] * y_AN
r_yAN = pos[0] * hcn[0] + pos[1] * hcn[1] + pos[2] * hcn[2]
nu = math.atan2(r_yAN, r_xAN)
else:
# Nearly coplanar: use atan2(y, x) as argument of latitude
nu = math.atan2(pos[1], pos[0])
nu = normalize_angle(nu)
else:
cos_nu = vdot(pos, e_vec) / (r * e)
cos_nu = max(-1.0, min(1.0, cos_nu))
sin_nu = None
if abs(cos_nu) > 1.0 - 1e-10:
h_cross_e = vcross(h_vec, e_vec)
denom = r * e * h
sin_nu = vdot(pos, h_cross_e) / denom if denom > 1e-10 else 0.0
else:
r_cross_h = vcross(pos, h_vec)
denom = r * e * h
sin_nu = vdot(r_cross_h, e_vec) / denom if denom > 1e-10 else 0.0
nu = math.atan2(sin_nu, cos_nu)
if nu == -math.pi:
nu = math.pi
nu = normalize_angle(nu)
# Inclination
i = math.acos(h_vec[2] / h) if h > 1e-10 else 0.0
# RAAN
n_vec = vcross((0.0, 0.0, 1.0), h_vec)
n_mag = vmag(n_vec)
if n_mag > 1e-10:
Omega = math.acos(n_vec[0] / n_mag)
if n_vec[1] < 0.0:
Omega = 2.0 * math.pi - Omega
else:
Omega = 0.0
# Argument of periapsis
inclination_threshold = 0.01
if e > 1e-10 and n_mag > 1e-10 and i > inclination_threshold:
cos_omega = vdot(e_vec, n_vec) / (e * n_mag)
n_cross_e = vcross(n_vec, e_vec)
sin_omega = vdot(n_cross_e, h_vec) / (e * n_mag * h)
omega = math.atan2(sin_omega, cos_omega)
if omega < 0.0:
omega += 2.0 * math.pi
elif e > 1e-10:
omega = math.atan2(e_vec[1], e_vec[0])
if omega < 0.0:
omega += 2.0 * math.pi
else:
omega = 0.0
elements = OrbitalElements()
if abs(e - 1.0) < 1e-3:
elements.p = (h * h) / mu
else:
elements.a = a
elements.e = e
elements.nu = nu
elements.inc = i
elements.Omega = Omega
elements.omega = omega
return elements
# Propagation
def propagate(elements, dt, parent_mass):
"""Propagate orbital elements forward by dt. Returns new elements."""
mu = G * parent_mass
a = elements.a
e = elements.e
nu = elements.nu
if abs(e - 1.0) < PARABOLIC_TOLERANCE:
# Parabolic (Barker's equation)
p = elements.p
D = math.tan(nu / 2.0)
M = D + (D * D * D) / 3.0
n = math.sqrt(mu / (p ** 3.0))
M = M + n * dt
# Solve Barker's: D + D^3/3 = M
c = 1.5 * M
disc = c * c + 1.0
sqrt_disc = math.sqrt(disc)
D_new = math.cbrt(c + sqrt_disc) + math.cbrt(c - sqrt_disc)
return replace(elements, nu=2.0 * math.atan(D_new))
elif e < 1.0:
# Elliptical
n = math.sqrt(mu / (a ** 3.0))
E = 2.0 * math.atan(math.sqrt((1.0 - e) / (1.0 + e)) * math.tan(nu / 2.0))
M = E - e * math.sin(E)
M = M + n * dt
E_new = get_initial_trial_value(M, e)
E_prev = E_new + 2.0 * KEPLER_TOLERANCE
for _ in range(KEPLER_MAX_ITER):
if abs(E_new - E_prev) < KEPLER_TOLERANCE:
break
E_prev = E_new
sin_E = math.sin(E_new)
E_new = E_new - (E_new - e * sin_E - M) / (1.0 - e * math.cos(E_new))
nu_new = 2.0 * math.atan(math.sqrt((1.0 + e) / (1.0 - e)) * math.tan(E_new / 2.0))
return replace(elements, nu=nu_new)
else:
# Hyperbolic
raise NotImplementedError("hyperbolic propagation not yet implemented")
# Global coordinate computation
def compute_global_coordinates(bodies):
"""
Compute global position/velocity for all bodies.
Matches C++ compute_global_coordinates() exactly.
"""
for body in bodies:
if body.parent_index == -1:
body.global_pos = body.local_pos
body.global_vel = body.local_vel
elif 0 <= body.parent_index < len(bodies):
parent = bodies[body.parent_index]
body.global_pos = vadd(body.local_pos, parent.global_pos)
body.global_vel = vadd(body.local_vel, parent.global_vel)
# Velocity drift check
def check_velocity_drift(body, parent, parent_mass):
"""
Check if local velocity has drifted from expected Keplerian velocity.
If so, reconstruct orbital elements from current state.
Matches C++ update_bodies_physics() drift check.
"""
if parent is None:
return
_, expected_vel = orbital_to_cartesian(body.orbit, parent_mass)
vel_diff = vmag(vsub(body.local_vel, expected_vel))
if vel_diff > VEL_DRIFT_THRESHOLD:
body.orbit = cartesian_to_orbital_elements(body.local_pos, body.local_vel, parent_mass)
# Body physics update
def update_body(bodies, body_index, dt):
"""
Update a single body: drift check, propagation.
Matches C++ update_bodies_physics() per-body logic (without SOI).
"""
body = bodies[body_index]
if body.parent_index == -1:
return # Root body doesn't propagate
if 0 <= body.parent_index < len(bodies):
parent = bodies[body.parent_index]
check_velocity_drift(body, parent, parent.mass)
body.orbit = propagate(body.orbit, dt, parent.mass)
body.local_pos, body.local_vel = orbital_to_cartesian(body.orbit, parent.mass)
# Spacecraft physics update
def update_spacecraft(spacecraft_list, bodies, maneuvers, dt, sim_time):
"""
Update spacecraft: drift check, maneuver triggers, propagation.
Matches C++ update_spacecraft_physics() with maneuver trigger system.
"""
for i, craft in enumerate(spacecraft_list):
if craft.parent_index < 0 or craft.parent_index >= len(bodies):
continue
parent = bodies[craft.parent_index]
# Velocity drift check
_, expected_vel = orbital_to_cartesian(craft.orbit, parent.mass)
vel_diff = vmag(vsub(craft.local_vel, expected_vel))
if vel_diff > VEL_DRIFT_THRESHOLD:
craft.orbit = cartesian_to_orbital_elements(craft.local_pos, craft.local_vel, parent.mass)
# Check all pending maneuvers for this craft
maneuver_fired = False
burn_dt = 0.0
fired_maneuver = None
for j, maneuver in enumerate(maneuvers):
if maneuver.executed:
continue
if maneuver.craft_index != i:
continue
if check_maneuver_trigger(maneuver, craft, sim_time, dt, bodies):
burn_dt = maneuver.scheduled_dt
fired_maneuver = maneuver
maneuver_fired = True
break
if maneuver_fired:
# Propagate to burn time
craft.orbit = propagate(craft.orbit, burn_dt, parent.mass)
craft.local_pos, craft.local_vel = orbital_to_cartesian(craft.orbit, parent.mass)
# Capture exact pre-burn state (matches C++ BurnResult)
fired_maneuver.burn_result = BurnResult(
valid=True,
position=tuple(craft.local_pos),
velocity=tuple(craft.local_vel),
true_anomaly=craft.orbit.nu,
)
# Execute burn
apply_impulsive_burn(craft, fired_maneuver.direction, fired_maneuver.delta_v, parent.mass)
fired_maneuver.executed = True
fired_maneuver.executed_time = sim_time + burn_dt
# Propagate remaining time
remaining_dt = dt - burn_dt
craft.orbit = propagate(craft.orbit, remaining_dt, parent.mass)
craft.local_pos, craft.local_vel = orbital_to_cartesian(craft.orbit, parent.mass)
else:
# No maneuver: propagate full timestep
craft.orbit = propagate(craft.orbit, dt, parent.mass)
craft.local_pos, craft.local_vel = orbital_to_cartesian(craft.orbit, parent.mass)
def compute_global_coordinates_spacecraft(spacecraft_list, bodies):
"""
Compute global position/velocity for all spacecraft.
"""
for craft in spacecraft_list:
if craft.parent_index >= 0 and craft.parent_index < len(bodies):
parent = bodies[craft.parent_index]
craft.global_pos = vadd(craft.local_pos, parent.global_pos)
craft.global_vel = vadd(craft.local_vel, parent.global_vel)
# TOML config loader
def load_config(config_path):
"""Load a TOML 1.0 config file and return parsed data."""
with open(config_path, "rb") as f:
return tomllib.load(f)
def bodies_from_config(config):
"""
Create Body objects from TOML config.
Parent references are resolved by name, then by index.
"""
bodies = []
name_to_idx = {}
# First pass: create bodies without positions
for body_cfg in config.get("bodies", []):
orbit_cfg = body_cfg.get("orbit", {})
elements = OrbitalElements(
a=orbit_cfg.get("semi_major_axis", 0.0),
e=orbit_cfg.get("eccentricity", 0.0),
nu=orbit_cfg.get("true_anomaly", 0.0),
inc=orbit_cfg.get("inclination", 0.0),
Omega=orbit_cfg.get("longitude_of_ascending_node", 0.0),
omega=orbit_cfg.get("argument_of_periapsis", 0.0),
p=orbit_cfg.get("semi_latus_rectum", 0.0),
)
parent_ref = body_cfg.get("parent_index", -1)
if isinstance(parent_ref, str):
# Resolve by name
if parent_ref in name_to_idx:
parent_index = name_to_idx[parent_ref]
elif parent_ref == "Sun" or parent_ref == "root" or parent_ref == "-1":
parent_index = -1
else:
raise ValueError(f"Unknown parent name: {parent_ref}")
else:
parent_index = int(parent_ref)
body = Body(
name=body_cfg.get("name", f"Body_{len(bodies)}"),
mass=body_cfg.get("mass", 0.0),
radius=body_cfg.get("radius", 0.0),
parent_index=parent_index,
orbit=elements,
)
bodies.append(body)
name_to_idx[body.name] = len(bodies) - 1
return bodies
def spacecraft_from_config(config, bodies):
"""
Create Spacecraft objects from TOML config.
Parent references resolved by body name.
"""
spacecraft_list = []
name_to_body = {b.name: i for i, b in enumerate(bodies)}
for craft_cfg in config.get("spacecraft", []):
orbit_cfg = craft_cfg.get("orbit", {})
elements = OrbitalElements(
a=orbit_cfg.get("semi_major_axis", 0.0),
e=orbit_cfg.get("eccentricity", 0.0),
nu=orbit_cfg.get("true_anomaly", 0.0),
inc=orbit_cfg.get("inclination", 0.0),
Omega=orbit_cfg.get("longitude_of_ascending_node", 0.0),
omega=orbit_cfg.get("argument_of_periapsis", 0.0),
p=orbit_cfg.get("semi_latus_rectum", 0.0),
)
parent_ref = craft_cfg.get("parent_index", -1)
if isinstance(parent_ref, str):
parent_index = name_to_body.get(parent_ref, -1)
else:
parent_index = int(parent_ref)
craft = Spacecraft(
name=craft_cfg.get("name", f"Craft_{len(spacecraft_list)}"),
mass=craft_cfg.get("mass", 0.0),
parent_index=parent_index,
orbit=elements,
)
spacecraft_list.append(craft)
return spacecraft_list
def initialize_spacecraft(spacecraft_list, bodies):
"""
Initialize spacecraft from orbital elements.
Compute local pos/vel and global pos/vel.
"""
for craft in spacecraft_list:
if craft.parent_index >= 0 and craft.parent_index < len(bodies):
parent = bodies[craft.parent_index]
local_pos, local_vel = orbital_to_cartesian(craft.orbit, parent.mass)
craft.local_pos = local_pos
craft.local_vel = local_vel
craft.global_pos = vadd(parent.global_pos, local_pos)
craft.global_vel = vadd(parent.global_vel, local_vel)
else:
craft.local_pos = (0.0, 0.0, 0.0)
craft.local_vel = (0.0, 0.0, 0.0)
craft.global_pos = (0.0, 0.0, 0.0)
craft.global_vel = (0.0, 0.0, 0.0)
def maneuvers_from_config(config, spacecraft_list):
"""
Create Maneuver objects from TOML config.
Resolves spacecraft_name to craft_index.
"""
maneuver_list = []
name_to_craft = {c.name: i for i, c in enumerate(spacecraft_list)}
direction_map = {
"prograde": BurnDirection.PROGRADE,
"retrograde": BurnDirection.RETROGRADE,
"normal": BurnDirection.NORMAL,
"antinormal": BurnDirection.ANTINORMAL,
"radial_in": BurnDirection.RADIAL_IN,
"radial_out": BurnDirection.RADIAL_OUT,
"custom": BurnDirection.CUSTOM,
}
trigger_map = {
"time": TriggerType.TIME,
"true_anomaly": TriggerType.TRUE_ANOMALY,
}
for man_cfg in config.get("maneuvers", []):
craft_name = man_cfg.get("spacecraft_name", "")
craft_index = name_to_craft.get(craft_name, -1)
direction = direction_map.get(man_cfg.get("direction", "prograde").lower(), BurnDirection.PROGRADE)
trigger_type = trigger_map.get(man_cfg.get("trigger_type", "time").lower(), TriggerType.TIME)
maneuver = Maneuver(
name=man_cfg.get("name", f"Maneuver_{len(maneuver_list)}"),
craft_index=craft_index,
direction=direction,
delta_v=float(man_cfg.get("delta_v", 0.0)),
trigger_type=trigger_type,
trigger_value=float(man_cfg.get("trigger_value", 0.0)),
)
maneuver_list.append(maneuver)
return maneuver_list
# Initialization
def initialize_bodies(bodies):
"""
Initialize orbital objects from orbital elements.
Matches C++ initialize_orbital_objects() exactly (without SOI).
"""
for i, body in enumerate(bodies):
if body.parent_index >= 0 and body.parent_index < len(bodies):
parent = bodies[body.parent_index]
local_pos, local_vel = orbital_to_cartesian(body.orbit, parent.mass)
body.local_pos = local_pos
body.local_vel = local_vel
body.global_pos = vadd(parent.global_pos, local_pos)
body.global_vel = vadd(parent.global_vel, local_vel)
else:
body.local_pos = (0.0, 0.0, 0.0)
body.local_vel = (0.0, 0.0, 0.0)
body.global_pos = (0.0, 0.0, 0.0)
body.global_vel = (0.0, 0.0, 0.0)
# Simulator — public API
class Simulator:
"""
Generic orbital mechanics simulator.
Usage:
sim = Simulator("config.toml", dt=60.0)
sim.run(steps=1000)
# Access results
for event in sim.events:
print(event)
# Access final state
for body in sim.bodies:
print(f"{body.name}: r={vmag(body.global_pos):.0f} m")
"""
def __init__(self, config_path, dt=60.0):
self.dt = dt
self.time = 0.0
self.events = []
self._body_count = 0
config = load_config(config_path)
self.bodies = bodies_from_config(config)
initialize_bodies(self.bodies)
self._body_count = len(self.bodies)
self.spacecraft = spacecraft_from_config(config, self.bodies)
initialize_spacecraft(self.spacecraft, self.bodies)
self.maneuvers = maneuvers_from_config(config, self.spacecraft)
def run(self, steps):
"""Run simulation for the given number of timesteps."""
for _ in range(steps):
self._step()
def _step(self):
"""Single simulation step. Matches C++ update_simulation() order."""
sim_time = self.time
# 1. Update body physics (drift, propagation)
for i in range(self._body_count):
update_body(self.bodies, i, self.dt)
# 2. Compute global coordinates for bodies
compute_global_coordinates(self.bodies)
# 3. Update spacecraft physics (drift, propagation, maneuver triggers)
update_spacecraft(self.spacecraft, self.bodies, self.maneuvers, self.dt, sim_time)
# 4. Compute global coordinates for spacecraft
compute_global_coordinates_spacecraft(self.spacecraft, self.bodies)
self.time += self.dt
def record_state(self, label=""):
"""Record current simulation state as an event."""
state = {}
for body in self.bodies:
r = vmag(body.global_pos)
state[body.name] = {
"r": r,
"nu": body.orbit.nu,
"a": body.orbit.a,
"e": body.orbit.e,
"parent": body.parent_index,
"parent_name": self.bodies[body.parent_index].name if body.parent_index >= 0 else "root",
}
self.events.append(Event(kind="state", time=self.time, data={"label": label, "state": state}))
def get_body(self, name_or_index):
"""Get a body by name or index."""
if isinstance(name_or_index, int):
return self.bodies[name_or_index]
for body in self.bodies:
if body.name == name_or_index:
return body
raise KeyError(f"Body not found: {name_or_index}")
def get_craft(self, name_or_index):
"""Get a spacecraft by name or index."""
if isinstance(name_or_index, int):
return self.spacecraft[name_or_index]
for craft in self.spacecraft:
if craft.name == name_or_index:
return craft
raise KeyError(f"Spacecraft not found: {name_or_index}")
def record_craft_state(self, label=""):
"""Record current spacecraft state as an event."""
state = {}
for craft in self.spacecraft:
r = vmag(craft.global_pos)
state[craft.name] = {
"r": r,
"nu": craft.orbit.nu,
"a": craft.orbit.a,
"e": craft.orbit.e,
"parent": craft.parent_index,
"parent_name": self.bodies[craft.parent_index].name if craft.parent_index >= 0 else "root",
}
self.events.append(Event(kind="craft_state", time=self.time, data={"label": label, "state": state}))
def print_summary(self):
"""Print a summary of all recorded state events."""
for event in self.events:
label = event.data.get("label", "")
if label:
print(f"\n*** {label} (t={event.time:.1f}s) ***")
for name, info in event.data.get("state", {}).items():
print(f" {name}: r={info['r']:.0f} m, "
f"nu={math.degrees(info['nu']):.1f}°, "
f"a={info['a']:.0f}, e={info['e']:.6f}, "
f"parent={info['parent_name']}")

117
scripts/test_orbital_period.py

@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Precalculate expected values for test_orbital_period.cpp.
Measures:
1. Earth orbital period (seconds, days) track global angle for circular orbit
2. Mars orbital period (seconds, days)
3. Direction test: prograde check over 1 day
"""
import sys
import math
sys.path.insert(0, "scripts")
from sim_engine import Simulator, vmag, G, OrbitalElements, propagate
MAX_STEPS = 1_100_000 # safety limit (687 days × 1440 steps/day)
DT = 60.0
def measure_period(sim, body_name, parent_mass, analytical_days):
"""
Measure period by tracking global angle for one full revolution.
For circular orbits, nu stays at 0 so we track atan2(y, x) instead.
"""
body = sim.get_body(body_name)
parent = sim.get_body(body.parent_index) if body.parent_index >= 0 else None
# Track global angle
if parent:
angle_start = math.atan2(
body.global_pos[1] - parent.global_pos[1],
body.global_pos[0] - parent.global_pos[0]
)
else:
angle_start = math.atan2(body.global_pos[1], body.global_pos[0])
total_angle = 0.0
prev_angle = angle_start
for step in range(1, MAX_STEPS + 1):
sim._step()
if parent:
angle = math.atan2(
body.global_pos[1] - parent.global_pos[1],
body.global_pos[0] - parent.global_pos[0]
)
else:
angle = math.atan2(body.global_pos[1], body.global_pos[0])
# Accumulate angle (handle wrap)
delta = angle - prev_angle
if delta > math.pi:
delta -= 2 * math.pi
elif delta < -math.pi:
delta += 2 * math.pi
total_angle += delta
prev_angle = angle
if total_angle >= 2 * math.pi:
break
if step >= MAX_STEPS:
print(f" TIMEOUT after {MAX_STEPS} steps ({sim.time/86400:.1f} days)")
return None
period_s = sim.time
period_days = period_s / 86400.0
print(f" Measured: {period_s:.1f}s = {period_days:.4f} days")
print(f" Analytical: {analytical_days:.4f} days")
print(f" Error: {abs(period_days - analytical_days):.4f} days ({abs(period_days - analytical_days)/analytical_days*100:.4f}%)")
print(f" e after: {body.orbit.e:.15f}")
return period_days
def main():
print("=== Earth Period ===")
sim = Simulator("tests/test_orbital_period.toml", dt=DT)
earth_a = 1.496e11
earth_mu = G * 1.989e30 # Sun mass
earth_analytical = 2.0 * math.pi * math.sqrt(earth_a**3 / earth_mu) / 86400.0
measure_period(sim, "Earth", 1.989e30, earth_analytical)
print("\n=== Mars Period ===")
sim = Simulator("tests/test_orbital_period.toml", dt=DT)
mars_a = 2.244e11
mars_mu = G * 1.989e30 # Sun mass
mars_analytical = 2.0 * math.pi * math.sqrt(mars_a**3 / mars_mu) / 86400.0
measure_period(sim, "Mars", 1.989e30, mars_analytical)
print("\n=== Direction Test (1 day) ===")
sim = Simulator("tests/test_orbital_period.toml", dt=DT)
earth = sim.get_body("Earth")
sun = sim.get_body("Sun")
theta_start = math.atan2(earth.global_pos[1] - sun.global_pos[1],
earth.global_pos[0] - sun.global_pos[0])
sim.run(steps=1440) # 1 day = 86400s / 60s
theta_end = math.atan2(earth.global_pos[1] - sun.global_pos[1],
earth.global_pos[0] - sun.global_pos[0])
delta = theta_end - theta_start
print(f" theta_start: {theta_start:.10f} rad")
print(f" theta_end: {theta_end:.10f} rad")
print(f" delta: {delta:.10f} rad")
print(f" prograde: {delta > 0}")
# Expected delta for 1 day of Earth orbit
expected_delta = math.sqrt(earth_mu / earth_a**3) * 86400.0
print(f" expected: {expected_delta:.10f} rad")
print(f" error: {abs(delta - expected_delta):.10f} rad")
if __name__ == "__main__":
main()

9
src/maneuver.h

@ -21,6 +21,14 @@ enum TriggerType {
TRIGGER_TRUE_ANOMALY 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 { struct Maneuver {
char name[64]; char name[64];
int craft_index; int craft_index;
@ -31,6 +39,7 @@ struct Maneuver {
double scheduled_dt; double scheduled_dt;
bool executed; bool executed;
double executed_time; double executed_time;
BurnResult burn_result;
}; };
struct HohmannTransfer { struct HohmannTransfer {

49
src/orbital_mechanics.cpp

@ -458,52 +458,3 @@ Vec3 calculate_eccentricity_vector(Vec3 r, Vec3 v, Vec3 h, double mu) {
Vec3 r_over_mag = vec3_scale(r, 1.0 / r_mag); Vec3 r_over_mag = vec3_scale(r, 1.0 / r_mag);
return vec3_sub(v_cross_h_over_mu, r_over_mag); return vec3_sub(v_cross_h_over_mu, r_over_mag);
} }
// Calculate true anomaly from position and velocity vectors
double calculate_true_anomaly(Vec3 r, Vec3 v, Vec3 e_vec, double e_mag, double r_mag) {
// For near-circular orbits, eccentricity vector is near-zero
// Compute true anomaly as the angle in the orbital plane
if (e_mag < 1e-10) {
Vec3 h = vec3_cross(r, v);
double h_mag = vec3_magnitude(h);
if (h_mag < 1e-10) return 0.0;
// Create a coordinate system in the orbital plane
Vec3 z_hat = vec3_scale(h, 1.0 / h_mag);
// Choose x-axis as cross product of Z (world up) and orbit normal
// This gives a consistent reference direction in the orbital plane
Vec3 world_z = {0.0, 0.0, 1.0};
Vec3 x_hat = vec3_cross(world_z, z_hat);
double x_hat_mag = vec3_magnitude(x_hat);
if (x_hat_mag < 1e-10) {
// Orbit is equatorial, use world X as reference
x_hat = (Vec3){1.0, 0.0, 0.0};
} else {
x_hat = vec3_scale(x_hat, 1.0 / x_hat_mag);
}
Vec3 y_hat = vec3_cross(z_hat, x_hat);
// Project position onto this orbital plane coordinate system
double x_proj = vec3_dot(r, x_hat);
double y_proj = vec3_dot(r, y_hat);
// True anomaly is the angle in the orbital plane
double nu = atan2(y_proj, x_proj);
if (nu < 0) nu += 2.0 * M_PI;
return nu;
}
// Standard calculation using eccentricity vector
double cos_nu = vec3_dot(e_vec, r) / (e_mag * r_mag);
cos_nu = fmax(-1.0, fmin(1.0, cos_nu));
double nu = acos(cos_nu);
// Determine correct quadrant using cross product
Vec3 r_cross_v = vec3_cross(r, v);
double r_cross_v_dot_e = vec3_dot(r_cross_v, e_vec);
if (r_cross_v_dot_e < 0) {
nu = 2.0 * M_PI - nu;
}
return nu;
}

3
src/orbital_mechanics.h

@ -56,7 +56,4 @@ double angular_distance(double a, double b);
// Calculate eccentricity vector from state vectors // Calculate eccentricity vector from state vectors
Vec3 calculate_eccentricity_vector(Vec3 r, Vec3 v, Vec3 h, double mu); Vec3 calculate_eccentricity_vector(Vec3 r, Vec3 v, Vec3 h, double mu);
// Calculate true anomaly from position and velocity vectors
double calculate_true_anomaly(Vec3 r, Vec3 v, Vec3 e_vec, double e_mag, double r_mag);
#endif #endif

64
src/physics.cpp

@ -58,56 +58,6 @@ Vec3 calculate_acceleration(Vec3 force, double mass) {
return {0.0, 0.0, 0.0}; return {0.0, 0.0, 0.0};
} }
void rk4_step(Vec3* position, Vec3* velocity, double dt,
double body_mass, double parent_mass) {
Vec3 k1_vel, k2_vel, k3_vel, k4_vel;
Vec3 k1_pos, k2_pos, k3_pos, k4_pos;
Vec3 pos0 = *position;
Vec3 vel0 = *velocity;
k1_vel = evaluate_acceleration(pos0, body_mass, parent_mass);
k1_pos = vel0;
Vec3 pos1 = vec3_add(pos0, vec3_scale(k1_pos, dt * 0.5));
Vec3 vel1 = vec3_add(vel0, vec3_scale(k1_vel, dt * 0.5));
k2_vel = evaluate_acceleration(pos1, body_mass, parent_mass);
k2_pos = vel1;
Vec3 pos2 = vec3_add(pos0, vec3_scale(k2_pos, dt * 0.5));
Vec3 vel2 = vec3_add(vel0, vec3_scale(k2_vel, dt * 0.5));
k3_vel = evaluate_acceleration(pos2, body_mass, parent_mass);
k3_pos = vel2;
Vec3 pos3 = vec3_add(pos0, vec3_scale(k3_pos, dt));
Vec3 vel3 = vec3_add(vel0, vec3_scale(k3_vel, dt));
k4_vel = evaluate_acceleration(pos3, body_mass, parent_mass);
k4_pos = vel3;
Vec3 k_vel_sum = vec3_add(vec3_add(k1_vel, vec3_scale(k2_vel, 2.0)),
vec3_add(vec3_scale(k3_vel, 2.0), k4_vel));
Vec3 k_pos_sum = vec3_add(vec3_add(k1_pos, vec3_scale(k2_pos, 2.0)),
vec3_add(vec3_scale(k3_pos, 2.0), k4_pos));
*velocity = vec3_add(vel0, vec3_scale(k_vel_sum, dt / 6.0));
*position = vec3_add(pos0, vec3_scale(k_pos_sum, dt / 6.0));
}
Vec3 evaluate_acceleration(Vec3 relative_pos, double body_mass, double parent_mass) {
Vec3 total_force = {0.0, 0.0, 0.0};
double distance = vec3_magnitude(relative_pos);
if (distance < 1.0) {
distance = 1.0;
}
double force_magnitude = G * body_mass * parent_mass / (distance * distance);
Vec3 direction = vec3_normalize(vec3_scale(relative_pos, -1.0));
total_force = vec3_scale(direction, force_magnitude);
return calculate_acceleration(total_force, body_mass);
}
Mat3 mat3_identity() { Mat3 mat3_identity() {
return {1.0, 0.0, 0.0, return {1.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0, 1.0, 0.0,
@ -128,6 +78,14 @@ Mat3 mat3_multiply(Mat3 a, Mat3 b) {
}; };
} }
Mat3 mat3_transpose(Mat3 m) {
return {
m.m00, m.m10, m.m20,
m.m01, m.m11, m.m21,
m.m02, m.m12, m.m22
};
}
Vec3 mat3_multiply_vec3(Mat3 m, Vec3 v) { Vec3 mat3_multiply_vec3(Mat3 m, Vec3 v) {
return { return {
m.m00 * v.x + m.m01 * v.y + m.m02 * v.z, m.m00 * v.x + m.m01 * v.y + m.m02 * v.z,
@ -164,3 +122,9 @@ Mat3 mat3_rotation_orbital(double omega, double i, double Omega) {
Mat3 temp = mat3_multiply(Rx_i, Rz_omega); Mat3 temp = mat3_multiply(Rx_i, Rz_omega);
return mat3_multiply(Rz_Omega, temp); return mat3_multiply(Rz_Omega, temp);
} }
bool compare_vec3(Vec3 a, Vec3 b, double tolerance) {
return fabs(a.x - b.x) <= tolerance &&
fabs(a.y - b.y) <= tolerance &&
fabs(a.z - b.z) <= tolerance;
}

11
src/physics.h

@ -29,6 +29,7 @@ double vec3_dot(Vec3 a, Vec3 b);
// Matrix functions // Matrix functions
Mat3 mat3_identity(); Mat3 mat3_identity();
Mat3 mat3_multiply(Mat3 a, Mat3 b); Mat3 mat3_multiply(Mat3 a, Mat3 b);
Mat3 mat3_transpose(Mat3 m);
Vec3 mat3_multiply_vec3(Mat3 m, Vec3 v); Vec3 mat3_multiply_vec3(Mat3 m, Vec3 v);
Mat3 mat3_rotation_x(double angle); Mat3 mat3_rotation_x(double angle);
Mat3 mat3_rotation_z(double angle); Mat3 mat3_rotation_z(double angle);
@ -37,13 +38,7 @@ Mat3 mat3_rotation_orbital(double omega, double i, double Omega);
// Physics functions // Physics functions
Vec3 calculate_acceleration(Vec3 force, double mass); Vec3 calculate_acceleration(Vec3 force, double mass);
// Physics integration functions // Comparison utility
// RK4 inferior to analytical propagation for orbit simulation bool compare_vec3(Vec3 a, Vec3 b, double tolerance);
// In our specific simulation (2-body with SOI patched conics):
// Analytical: <1e-12 energy drift vs RK4: 2e-7 to 5e-3
// See tests/test_hybrid_energy_conservation.cpp for comparison
void rk4_step(Vec3* position, Vec3* velocity, double dt,
double body_mass, double parent_mass);
Vec3 evaluate_acceleration(Vec3 relative_pos, double body_mass, double parent_mass);
#endif #endif

7
src/simulation.cpp

@ -324,6 +324,13 @@ void update_spacecraft_physics(SimulationState* sim) {
craft->orbit = propagate_orbital_elements(craft->orbit, burn_dt, parent->mass); 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); 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; double burn_time = sim->time + burn_dt;
execute_maneuver(fired_maneuver, craft, sim, burn_time); execute_maneuver(fired_maneuver, craft, sim, burn_time);

140
src/test_utilities.cpp

@ -3,16 +3,16 @@
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
static const double MIN_DISTANCE_CLAMP = 1.0;
double calculate_kinetic_energy(CelestialBody* body) { double calculate_kinetic_energy(CelestialBody* body) {
double v_squared = body->global_velocity.x * body->global_velocity.x + Vec3 v = body->global_velocity;
body->global_velocity.y * body->global_velocity.y + return 0.5 * body->mass * vec3_dot(v, v);
body->global_velocity.z * body->global_velocity.z;
return 0.5 * body->mass * v_squared;
} }
double calculate_potential_energy_pair(CelestialBody* body1, CelestialBody* body2) { double calculate_potential_energy_pair(CelestialBody* body1, CelestialBody* body2) {
double distance = vec3_distance(body1->global_position, body2->global_position); double distance = vec3_distance(body1->global_position, body2->global_position);
if (distance < 1.0) distance = 1.0; if (distance < MIN_DISTANCE_CLAMP) distance = MIN_DISTANCE_CLAMP;
return -G * body1->mass * body2->mass / distance; return -G * body1->mass * body2->mass / distance;
} }
@ -37,7 +37,6 @@ OrbitalMetrics calculate_orbital_metrics(CelestialBody* body, CelestialBody* par
Vec3 relative_pos = vec3_sub(body->global_position, parent->global_position); Vec3 relative_pos = vec3_sub(body->global_position, parent->global_position);
metrics.orbital_radius = vec3_magnitude(relative_pos); metrics.orbital_radius = vec3_magnitude(relative_pos);
metrics.velocity_magnitude = vec3_magnitude(body->global_velocity); metrics.velocity_magnitude = vec3_magnitude(body->global_velocity);
metrics.angular_position = atan2(relative_pos.y, relative_pos.x);
metrics.kinetic_energy = calculate_kinetic_energy(body); metrics.kinetic_energy = calculate_kinetic_energy(body);
metrics.potential_energy = calculate_potential_energy_pair(body, parent); metrics.potential_energy = calculate_potential_energy_pair(body, parent);
@ -46,15 +45,16 @@ OrbitalMetrics calculate_orbital_metrics(CelestialBody* body, CelestialBody* par
return metrics; return metrics;
} }
OrbitTracker* create_orbit_tracker(int body_index) { OrbitTracker* create_orbit_tracker_with_min_time(int body_index, double min_time_seconds) {
OrbitTracker* tracker = (OrbitTracker*)malloc(sizeof(OrbitTracker)); OrbitTracker* tracker = (OrbitTracker*)malloc(sizeof(OrbitTracker));
tracker->body_index = body_index; tracker->body_index = body_index;
tracker->initial_angle = 0.0; tracker->initial_angle = 0.0;
tracker->previous_angle = 0.0; tracker->previous_angle = 0.0;
tracker->quadrant_transitions = 0; tracker->accumulated_rotation = 0.0;
tracker->initialized = false;
tracker->orbit_completed = false; tracker->orbit_completed = false;
tracker->time_at_completion = 0.0; tracker->time_at_completion = 0.0;
tracker->min_time_days = 100.0; tracker->min_time_seconds = min_time_seconds;
tracker->inclination = 0.0; tracker->inclination = 0.0;
tracker->longitude_of_ascending_node = 0.0; tracker->longitude_of_ascending_node = 0.0;
tracker->argument_of_periapsis = 0.0; tracker->argument_of_periapsis = 0.0;
@ -62,26 +62,14 @@ OrbitTracker* create_orbit_tracker(int body_index) {
return tracker; return tracker;
} }
OrbitTracker* create_orbit_tracker_with_min_time(int body_index, double min_time_days) { OrbitTracker* create_orbit_tracker(int body_index) {
OrbitTracker* tracker = (OrbitTracker*)malloc(sizeof(OrbitTracker)); return create_orbit_tracker_with_min_time(body_index, 86400.0);
tracker->body_index = body_index;
tracker->initial_angle = 0.0;
tracker->previous_angle = 0.0;
tracker->quadrant_transitions = 0;
tracker->orbit_completed = false;
tracker->time_at_completion = 0.0;
tracker->min_time_days = min_time_days;
tracker->inclination = 0.0;
tracker->longitude_of_ascending_node = 0.0;
tracker->argument_of_periapsis = 0.0;
tracker->has_orbital_elements = false;
return tracker;
} }
OrbitTracker* create_orbit_tracker_3d(int body_index, double min_time_days, OrbitTracker* create_orbit_tracker_3d(int body_index, double min_time_seconds,
double inclination, double lon_ascending_node, double inclination, double lon_ascending_node,
double argument_of_periapsis) { double argument_of_periapsis) {
OrbitTracker* tracker = create_orbit_tracker_with_min_time(body_index, min_time_days); OrbitTracker* tracker = create_orbit_tracker_with_min_time(body_index, min_time_seconds);
tracker->inclination = inclination; tracker->inclination = inclination;
tracker->longitude_of_ascending_node = lon_ascending_node; tracker->longitude_of_ascending_node = lon_ascending_node;
tracker->argument_of_periapsis = argument_of_periapsis; tracker->argument_of_periapsis = argument_of_periapsis;
@ -89,14 +77,20 @@ OrbitTracker* create_orbit_tracker_3d(int body_index, double min_time_days,
return tracker; return tracker;
} }
void reset_orbit_tracker(OrbitTracker* tracker) { static void reset_tracker_fields(OrbitTracker* tracker) {
tracker->body_index = 0;
tracker->initial_angle = 0.0; tracker->initial_angle = 0.0;
tracker->previous_angle = 0.0; tracker->previous_angle = 0.0;
tracker->quadrant_transitions = 0; tracker->accumulated_rotation = 0.0;
tracker->initialized = false;
tracker->orbit_completed = false; tracker->orbit_completed = false;
tracker->time_at_completion = 0.0; tracker->time_at_completion = 0.0;
} }
void reset_orbit_tracker(OrbitTracker* tracker) {
reset_tracker_fields(tracker);
}
void update_orbit_tracker(OrbitTracker* tracker, CelestialBody* body, CelestialBody* parent, double current_time) { void update_orbit_tracker(OrbitTracker* tracker, CelestialBody* body, CelestialBody* parent, double current_time) {
if (tracker->orbit_completed) return; if (tracker->orbit_completed) return;
@ -107,20 +101,17 @@ void update_orbit_tracker(OrbitTracker* tracker, CelestialBody* body, CelestialB
Mat3 rotation = mat3_rotation_orbital(tracker->argument_of_periapsis, Mat3 rotation = mat3_rotation_orbital(tracker->argument_of_periapsis,
tracker->inclination, tracker->inclination,
tracker->longitude_of_ascending_node); tracker->longitude_of_ascending_node);
// Transpose to get inverse rotation (back to orbital plane) Vec3 pos_orbital = mat3_multiply_vec3(mat3_transpose(rotation), relative_pos);
Mat3 rotation_T = {rotation.m00, rotation.m10, rotation.m20,
rotation.m01, rotation.m11, rotation.m21,
rotation.m02, rotation.m12, rotation.m22};
Vec3 pos_orbital = mat3_multiply_vec3(rotation_T, relative_pos);
current_angle = atan2(pos_orbital.y, pos_orbital.x); current_angle = atan2(pos_orbital.y, pos_orbital.x);
} else { } else {
current_angle = atan2(relative_pos.y, relative_pos.x); current_angle = atan2(relative_pos.y, relative_pos.x);
} }
if (tracker->quadrant_transitions == 0) { if (!tracker->initialized) {
tracker->initial_angle = current_angle; tracker->initial_angle = current_angle;
tracker->previous_angle = current_angle; tracker->previous_angle = current_angle;
tracker->quadrant_transitions = 1; tracker->accumulated_rotation = 0.0;
tracker->initialized = true;
return; return;
} }
@ -128,23 +119,16 @@ void update_orbit_tracker(OrbitTracker* tracker, CelestialBody* body, CelestialB
if (angle_diff > M_PI) { if (angle_diff > M_PI) {
angle_diff -= 2.0 * M_PI; angle_diff -= 2.0 * M_PI;
tracker->quadrant_transitions++;
} }
if (angle_diff < -M_PI) { if (angle_diff < -M_PI) {
angle_diff += 2.0 * M_PI; angle_diff += 2.0 * M_PI;
tracker->quadrant_transitions++;
} }
double total_rotation = current_angle - tracker->initial_angle; tracker->accumulated_rotation += angle_diff;
if (total_rotation < -M_PI) total_rotation += 2.0 * M_PI;
if (total_rotation > M_PI) total_rotation -= 2.0 * M_PI;
const double SECONDS_PER_DAY = 86400.0;
double min_time_seconds = tracker->min_time_days * SECONDS_PER_DAY;
if (tracker->quadrant_transitions >= 2 && if (current_time > tracker->min_time_seconds &&
current_time > min_time_seconds && (tracker->accumulated_rotation >= 2.0 * M_PI ||
fabs(total_rotation) < 0.05) { tracker->accumulated_rotation <= -2.0 * M_PI)) {
tracker->orbit_completed = true; tracker->orbit_completed = true;
tracker->time_at_completion = current_time; tracker->time_at_completion = current_time;
} }
@ -156,26 +140,19 @@ void destroy_orbit_tracker(OrbitTracker* tracker) {
free(tracker); free(tracker);
} }
bool compare_double(double a, double b, double tolerance) {
return fabs(a - b) <= tolerance;
}
bool compare_vec3(Vec3 a, Vec3 b, double tolerance) {
return fabs(a.x - b.x) <= tolerance &&
fabs(a.y - b.y) <= tolerance &&
fabs(a.z - b.z) <= tolerance;
}
int dump_simulation_state(SimulationState* sim, const char* label, int dump_simulation_state(SimulationState* sim, const char* label,
char* buffer, int buffer_size) { char* buffer, int buffer_size) {
int offset = 0; int offset = 0;
if (offset >= buffer_size) return offset;
offset += snprintf(buffer + offset, buffer_size - offset, offset += snprintf(buffer + offset, buffer_size - offset,
"\n=== %s (t=%.0f s) ===\n", label, sim->time); "\n=== %s (t=%.0f s) ===\n", label, sim->time);
offset += snprintf(buffer + offset, buffer_size - offset, offset += snprintf(buffer + offset, buffer_size - offset,
"Bodies (%d):\n", sim->body_count); "Bodies (%d):\n", sim->body_count);
for (int i = 0; i < sim->body_count; i++) { for (int i = 0; i < sim->body_count; i++) {
if (offset >= buffer_size) break;
offset += snprintf(buffer + offset, buffer_size - offset, offset += snprintf(buffer + offset, buffer_size - offset,
" [%d] %s: mass=%.2e kg\n", " [%d] %s: mass=%.2e kg\n",
i, sim->bodies[i].name, sim->bodies[i].mass); i, sim->bodies[i].name, sim->bodies[i].mass);
@ -183,35 +160,38 @@ int dump_simulation_state(SimulationState* sim, const char* label,
offset += snprintf(buffer + offset, buffer_size - offset, offset += snprintf(buffer + offset, buffer_size - offset,
"Spacecraft (%d):\n", sim->craft_count); "Spacecraft (%d):\n", sim->craft_count);
for (int i = 0; i < sim->craft_count; i++) { if (sim->spacecraft) {
Spacecraft* s = &sim->spacecraft[i]; for (int i = 0; i < sim->craft_count; i++) {
double r = sqrt(s->local_position.x*s->local_position.x + if (offset >= buffer_size) break;
s->local_position.y*s->local_position.y + Spacecraft* s = &sim->spacecraft[i];
s->local_position.z*s->local_position.z); double r = vec3_magnitude(s->local_position);
double v = sqrt(s->local_velocity.x*s->local_velocity.x + double v = vec3_magnitude(s->local_velocity);
s->local_velocity.y*s->local_velocity.y + offset += snprintf(buffer + offset, buffer_size - offset,
s->local_velocity.z*s->local_velocity.z); " [%d] %s: r=%.1f v=%.1f nu=%.5f a=%.1f e=%.6f, omega=%.6f\n",
offset += snprintf(buffer + offset, buffer_size - offset, i, s->name, r, v,
" [%d] %s: r=%.1f v=%.1f nu=%.5f a=%.1f e=%.6f, omega=%.6f\n", s->orbit.true_anomaly,
i, s->name, r, v, s->orbit.semi_major_axis,
s->orbit.true_anomaly, s->orbit.eccentricity,
s->orbit.semi_major_axis, s->orbit.argument_of_periapsis);
s->orbit.eccentricity, if (offset >= buffer_size) break;
s->orbit.argument_of_periapsis); offset += snprintf(buffer + offset, buffer_size - offset,
offset += snprintf(buffer + offset, buffer_size - offset, " pos=(%.1f, %.1f, %.1f) vel=(%.1f, %.1f, %.1f)\n",
" pos=(%.1f, %.1f, %.1f) vel=(%.1f, %.1f, %.1f)\n", s->local_position.x, s->local_position.y, s->local_position.z,
s->local_position.x, s->local_position.y, s->local_position.z, s->local_velocity.x, s->local_velocity.y, s->local_velocity.z);
s->local_velocity.x, s->local_velocity.y, s->local_velocity.z); }
} }
offset += snprintf(buffer + offset, buffer_size - offset, offset += snprintf(buffer + offset, buffer_size - offset,
"Maneuvers (%d):\n", sim->maneuver_count); "Maneuvers (%d):\n", sim->maneuver_count);
for (int i = 0; i < sim->maneuver_count; i++) { if (sim->maneuvers) {
Maneuver* m = &sim->maneuvers[i]; for (int i = 0; i < sim->maneuver_count; i++) {
offset += snprintf(buffer + offset, buffer_size - offset, if (offset >= buffer_size) break;
" [%d] %s: craft=%d dir=%d dv=%.4f trigger=%d val=%.2f exec=%d\n", Maneuver* m = &sim->maneuvers[i];
i, m->name, m->craft_index, m->direction, m->delta_v, offset += snprintf(buffer + offset, buffer_size - offset,
m->trigger_type, m->trigger_value, m->executed); " [%d] %s: craft=%d dir=%d dv=%.4f trigger=%d val=%.2f exec=%d\n",
i, m->name, m->craft_index, m->direction, m->delta_v,
m->trigger_type, m->trigger_value, m->executed);
}
} }
return offset; return offset;

27
src/test_utilities.h

@ -4,23 +4,37 @@
#include "simulation.h" #include "simulation.h"
#include "physics.h" #include "physics.h"
// Test tolerance constants
// NOTE: Individual tests may use tighter or looser tolerances based on
// observed errors. See per-test comments for adjustments.
static const double A_TOL = 1e-6; // semi-major axis (meters), |a| < 1e10
static const double D_TOL = 1e-12; // double-precision arithmetic (vec3, mat3 ops)
static const double E_TOL = 1e-12; // eccentricity, round-trip conversion
static const double ANG_TOL = 1e-12; // angles in radians (nu, inc, Ω, ω)
static const double ANG_TOL_COARSE = 1e-4; // angles, degenerate cases (polar/retrograde)
static const double R_TOL = 1e-6; // radius / distance magnitudes (meters)
static const double V_TOL = 1e-6; // velocity magnitudes (m/s)
static const double M_TOL = 1e-6; // time / period values (seconds)
static const double REL_TOL = 1e-8; // relative / percentage errors (dimensionless)
static const double DRIFT_TOL = 1e-12; // energy drift percent (parabolic orbit)
struct OrbitalMetrics { struct OrbitalMetrics {
double kinetic_energy; double kinetic_energy;
double potential_energy; double potential_energy;
double total_energy; double total_energy;
double orbital_radius; double orbital_radius;
double velocity_magnitude; double velocity_magnitude;
double angular_position;
}; };
struct OrbitTracker { struct OrbitTracker {
double initial_angle; double initial_angle;
double previous_angle; double previous_angle;
int quadrant_transitions; double accumulated_rotation;
bool initialized;
bool orbit_completed; bool orbit_completed;
double time_at_completion; double time_at_completion;
int body_index; int body_index;
double min_time_days; double min_time_seconds;
// Orbital elements for 3D angle calculation // Orbital elements for 3D angle calculation
double inclination; double inclination;
@ -35,7 +49,7 @@ double calculate_system_total_energy(SimulationState* sim);
OrbitalMetrics calculate_orbital_metrics(CelestialBody* body, CelestialBody* parent); OrbitalMetrics calculate_orbital_metrics(CelestialBody* body, CelestialBody* parent);
OrbitTracker* create_orbit_tracker(int body_index); OrbitTracker* create_orbit_tracker(int body_index);
OrbitTracker* create_orbit_tracker_with_min_time(int body_index, double min_time_days); OrbitTracker* create_orbit_tracker_with_min_time(int body_index, double min_time_seconds);
OrbitTracker* create_orbit_tracker_3d(int body_index, double min_time_days, OrbitTracker* create_orbit_tracker_3d(int body_index, double min_time_days,
double inclination, double lon_ascending_node, double inclination, double lon_ascending_node,
double argument_of_periapsis); double argument_of_periapsis);
@ -43,12 +57,9 @@ void reset_orbit_tracker(OrbitTracker* tracker);
void update_orbit_tracker(OrbitTracker* tracker, CelestialBody* body, CelestialBody* parent, double current_time); void update_orbit_tracker(OrbitTracker* tracker, CelestialBody* body, CelestialBody* parent, double current_time);
void destroy_orbit_tracker(OrbitTracker* tracker); void destroy_orbit_tracker(OrbitTracker* tracker);
bool compare_double(double a, double b, double tolerance);
bool compare_vec3(Vec3 a, Vec3 b, double tolerance);
// Write simulation state to a caller-allocated buffer. // Write simulation state to a caller-allocated buffer.
// Returns number of characters written (excluding null terminator). // Returns number of characters written (excluding null terminator).
// Caller must ensure buffer is large enough. // Caller must ensure buffer is large enough (recommended 4096 bytes).
int dump_simulation_state(SimulationState* sim, const char* label, int dump_simulation_state(SimulationState* sim, const char* label,
char* buffer, int buffer_size); char* buffer, int buffer_size);

73
tests/informational/Makefile

@ -1,73 +0,0 @@
# Makefile for Informational Tests
# These tests are not part of the main test suite
# They are for diagnostic and informational purposes only
BUILD_DIR = ../../build
SRC_DIR = ../../src
# Compiler flags
CFLAGS = -Wall -Wextra -g -ggdb3 -std=c++14
INCLUDES = -I$(SRC_DIR) \
-isystem../../ext/tomlc17/src \
-isystem../../ext/raylib/src \
-isystem../../ext/raygui/src \
-isystem../../ext/raygui/styles
# Source files (from main project that informational tests depend on)
CORE_OBJECTS = $(BUILD_DIR)/test_utilities.o \
$(BUILD_DIR)/physics.o \
$(BUILD_DIR)/orbital_mechanics.o \
$(BUILD_DIR)/simulation.o \
$(BUILD_DIR)/config_loader.o \
$(BUILD_DIR)/config_validator.o \
$(BUILD_DIR)/maneuver.o \
$(BUILD_DIR)/spacecraft.o \
$(BUILD_DIR)/tomlc17.o
# Informational test sources
INFO_TESTS = test_time_step_stability.cpp
INFO_BINS = $(patsubst %.cpp,%,$(INFO_TESTS))
# Default target - build all informational tests
all: $(INFO_BINS)
@echo "All informational tests built successfully"
@echo ""
@echo "To run tests:"
@echo " ./test_time_step_stability"
@echo ""
@echo "To run specific test case:"
@echo " ./test_time_step_stability '[timestep][stability]'"
# Build individual test binaries
$(INFO_BINS): $(patsubst %.cpp,%.o,$(INFO_TESTS)) $(CORE_OBJECTS)
g++ $(patsubst %.cpp,%.o,$<) $(CORE_OBJECTS) -o $@ -lCatch2Main -lCatch2 -lm
@echo "Built $@"
# Build test object files
test_time_step_stability.o: test_time_step_stability.cpp
g++ $(CFLAGS) $(INCLUDES) -c $< -o $@
# Build core objects if they don't exist
$(BUILD_DIR)/%.o:
$(MAKE) -C ../../ build/$*.o
# Clean built files in informational directory
clean:
rm -f $(INFO_BINS) *.o
# Clean all (including core build - use with caution!)
clean-all:
$(MAKE) -C ../../ clean
rm -f $(INFO_BINS) *.o
# Run the time step stability test (must run from project root)
run-timestep: test_time_step_stability
@echo "Note: Tests must be run from project root directory:"
@echo " From project root: ./tests/informational/$@"
# Run with verbose output to see binary search progress
run-timestep-verbose: test_time_step_stability
@echo "Note: Tests must be run from project root directory:"
@echo " From project root: ./tests/informational/$@ -s \"[timestep][stability]\""
.PHONY: all clean clean-all run-timestep run-timestep-verbose

72
tests/informational/README.md

@ -1,72 +0,0 @@
# Informational Tests
This directory contains tests that are not part of the standard test suite. These tests are provided for informational purposes only and should be run separately.
## Purpose
Informational tests are designed to:
- Explore simulation boundaries and limits
- Provide diagnostic information about the simulation
- Test extreme or edge cases that don't fit into normal test suites
- Help understand system behavior under various conditions
## Running Informational Tests
Informational tests are **not** run by `make test`. To build and run them:
```bash
cd tests/informational
make
./time_step_test
```
## Current Tests
### `test_time_step_stability.cpp`
Determines the maximum stable time step for the RK4 integration across different orbital regimes.
**Test Bodies:**
- **Mercury Orbiter** (MESSENGER-like): 200 km altitude, ~12 hour period
- **Io** (Jupiter's moon): 421,700 km orbit, ~1.77 day period
- **Moon** (Earth's moon): 384,400 km orbit, ~27.3 day period
**Stability Criteria:**
- Energy drift < 1% over 100 orbits
- Distance drift < 5%
- No SOI transitions (body doesn't change parent)
**Configuration:** Uses `test_time_step_stability.toml`
**Usage:**
```bash
# From project root directory:
./tests/informational/test_time_step_stability
# Or using the Makefile:
cd tests/informational
make run-timestep
# Run specific test case
./tests/informational/test_time_step_stability '[timestep][stability]'
# Run with verbose output to see binary search progress
./tests/informational/test_time_step_stability -s "[timestep][stability]"
```
**Note:** Tests must be run from the project root directory because config file paths are relative to the root.
**Expected Output:**
The test performs a binary search to find the maximum stable time step, printing progress for each dt value tested. Results show the minimum stable dt across all tested bodies with recommendations.
## Adding New Informational Tests
1. Create a new `.cpp` file in this directory
2. Add corresponding `.toml` config file if needed
3. Add a target in the `Makefile`:
```makefile
your_test_name: your_test_name.o
g++ $(BUILD_DIR)/*.o -o $@ -lCatch2Main -lCatch2 -lm
```
4. Update this README with test description
5. Follow the naming convention: `test_<purpose>.cpp` and `test_<purpose>.toml`

296
tests/informational/test_time_step_stability.cpp

@ -1,296 +0,0 @@
#include <catch2/catch_test_macros.hpp>
#include "../../src/physics.h"
#include "../../src/simulation.h"
#include "../../src/config_loader.h"
#include "../../src/test_utilities.h"
#include <cmath>
#include <cstdio>
struct TestBody {
const char* name;
int body_index;
int parent_index;
double expected_period_days;
};
struct StabilityResult {
const char* name;
double max_stable_dt;
double period_days;
};
const double SECONDS_PER_DAY = 86400.0;
const double MIN_DT = 30.0;
const double MAX_DT = 600.0;
const double ENERGY_TOLERANCE = 1.0;
const int NUM_ORBITS = 100;
double calculate_orbital_period(CelestialBody* body, CelestialBody* parent) {
Vec3 relative_pos = vec3_sub(body->global_position, parent->global_position);
double r = vec3_magnitude(relative_pos);
Vec3 relative_vel = vec3_sub(body->global_velocity, parent->global_velocity);
double v = vec3_magnitude(relative_vel);
double specific_energy = (v * v) / 2.0 - G * parent->mass / r;
double semi_major_axis = -G * parent->mass / (2.0 * specific_energy);
double period_seconds = 2.0 * M_PI * sqrt(pow(semi_major_axis, 3.0) / (G * parent->mass));
return period_seconds;
}
bool is_dt_stable(SimulationState* sim, const TestBody& test_body, double dt, int num_orbits) {
SimulationState* test_sim = create_simulation(sim->max_bodies, sim->max_craft, sim->max_maneuvers, dt);
REQUIRE(load_system_config(test_sim, "tests/informational/test_time_step_stability.toml"));
int body_index = test_body.body_index;
int parent_index = test_body.parent_index;
double initial_energy = calculate_system_total_energy(test_sim);
Vec3 initial_pos_relative = vec3_sub(
test_sim->bodies[body_index].global_position,
test_sim->bodies[parent_index].global_position
);
double initial_distance = vec3_magnitude(initial_pos_relative);
double period = calculate_orbital_period(&test_sim->bodies[body_index], &test_sim->bodies[parent_index]);
double max_time = period * num_orbits;
bool completed = true;
while (test_sim->time < max_time) {
update_simulation(test_sim);
if (test_sim->bodies[body_index].parent_index != parent_index) {
completed = false;
break;
}
}
double final_energy = calculate_system_total_energy(test_sim);
double energy_drift_percent = fabs((final_energy - initial_energy) / initial_energy) * 100.0;
Vec3 final_pos_relative = vec3_sub(
test_sim->bodies[body_index].global_position,
test_sim->bodies[parent_index].global_position
);
double final_distance = vec3_magnitude(final_pos_relative);
double distance_drift_percent = fabs((final_distance - initial_distance) / initial_distance) * 100.0;
bool stable = completed && (energy_drift_percent < ENERGY_TOLERANCE) && (distance_drift_percent < 5.0);
destroy_simulation(test_sim);
return stable;
}
double find_max_stable_dt(SimulationState* sim, const TestBody& test_body) {
double low = MIN_DT;
double high = MAX_DT;
double max_stable = low;
printf("Testing %s (period ~%.2f days):\n", test_body.name, test_body.expected_period_days);
for (int iter = 0; iter < 10; iter++) {
double mid = (low + high) / 2.0;
bool stable = is_dt_stable(sim, test_body, mid, NUM_ORBITS);
if (stable) {
max_stable = mid;
low = mid;
printf(" dt=%.0fs: STABLE\n", mid);
} else {
high = mid;
printf(" dt=%.0fs: UNSTABLE\n", mid);
}
if (high - low < 5.0) break;
}
printf(" Maximum stable dt: %.0f seconds\n\n", max_stable);
return max_stable;
}
void print_summary(const StabilityResult* results, int num_results, double min_stable_dt, double default_dt) {
printf("\n");
printf("===============================================================================\n");
printf(" TIME STEP STABILITY TEST RESULTS\n");
printf("===============================================================================\n\n");
printf("STABILITY CRITERIA:\n");
printf(" - Energy drift < %.1f%% over %d orbits\n", ENERGY_TOLERANCE, NUM_ORBITS);
printf(" - Distance drift < 5.0%%\n");
printf(" - No SOI transitions (parent changes)\n\n");
printf("PER-BODY RESULTS:\n");
printf("+----------------------+----------------+------------------+----------------+\n");
printf("| Body | Period (days) | Max Stable dt (s) | Stability Status |\n");
printf("+----------------------+----------------+------------------+----------------+\n");
for (int i = 0; i < num_results; i++) {
const StabilityResult& r = results[i];
double ratio = default_dt / r.max_stable_dt;
const char* status = ratio < 0.5 ? "Very Stable" : ratio < 0.8 ? "Stable" : "Limited Margin";
printf("| %-20s | %14.2f | %16.0f | %-14s |\n", r.name, r.period_days, r.max_stable_dt, status);
}
printf("+----------------------+----------------+------------------+----------------+\n\n");
printf("OVERALL ANALYSIS:\n");
printf(" Minimum stable time step: %.0f seconds\n", min_stable_dt);
printf(" Recommended safe dt: %.0f seconds (0.7x safety margin)\n", min_stable_dt * 0.7);
printf(" Current default dt: %.0f seconds\n", default_dt);
printf(" Current dt stability: %.0fx\n", default_dt / min_stable_dt);
printf("\n");
if (default_dt < min_stable_dt * 0.7) {
printf("STATUS: Current time step (60s) is VERY STABLE with good margin.\n");
printf(" Can be increased significantly if needed.\n\n");
} else if (default_dt < min_stable_dt) {
printf("STATUS: Current time step (60s) is STABLE with adequate margin.\n");
printf(" Moderate increases possible.\n\n");
} else {
printf("STATUS: Current time step (60s) is near stability limit.\n");
printf(" Consider reducing for safety.\n\n");
}
printf("RECOMMENDATIONS:\n");
printf(" - For MESSENGER-like close orbits: Keep dt <= %.0f seconds\n", min_stable_dt);
printf(" - For planetary missions: Current dt=60s is excellent\n");
printf(" - For Moon-scale orbits: Could use dt=120s+ safely\n");
printf("\n");
printf("===============================================================================\n\n");
}
TEST_CASE("Time step stability - Mercury orbiter (MESSENGER-like)", "[timestep][stability]") {
const double BASE_DT = 60.0;
SimulationState* sim = create_simulation(10, 0, 0, BASE_DT);
TestBody mercury_orbiter = {"Mercury_Orbiter", 1, 0, 0.5};
double max_dt = find_max_stable_dt(sim, mercury_orbiter);
INFO("Mercury orbiter maximum stable dt: " << max_dt << " seconds");
REQUIRE(max_dt >= MIN_DT);
destroy_simulation(sim);
}
TEST_CASE("Time step stability - Io (Jupiter's moon)", "[timestep][stability]") {
const double BASE_DT = 60.0;
SimulationState* sim = create_simulation(10, 0, 0, BASE_DT);
TestBody io = {"Io", 3, 2, 1.77};
double max_dt = find_max_stable_dt(sim, io);
INFO("Io maximum stable dt: " << max_dt << " seconds");
REQUIRE(max_dt >= MIN_DT);
destroy_simulation(sim);
}
TEST_CASE("Time step stability - Moon (Earth's moon)", "[timestep][stability]") {
const double BASE_DT = 60.0;
SimulationState* sim = create_simulation(10, 0, 0, BASE_DT);
TestBody moon = {"Moon", 5, 4, 27.3};
double max_dt = find_max_stable_dt(sim, moon);
INFO("Moon maximum stable dt: " << max_dt << " seconds");
REQUIRE(max_dt >= MIN_DT);
destroy_simulation(sim);
}
TEST_CASE("Find minimum stable time step across all bodies", "[timestep][stability]") {
const double BASE_DT = 60.0;
SimulationState* sim = create_simulation(10, 0, 0, BASE_DT);
TestBody bodies[] = {
{"Mercury_Orbiter", 1, 0, 0.5},
{"Io", 3, 2, 1.77},
{"Moon", 5, 4, 27.3}
};
StabilityResult results[3];
double max_dt = MAX_DT;
printf("\n=== Finding minimum stable dt across all bodies ===\n\n");
for (int i = 0; i < 3; i++) {
results[i].name = bodies[i].name;
results[i].period_days = bodies[i].expected_period_days;
results[i].max_stable_dt = find_max_stable_dt(sim, bodies[i]);
if (results[i].max_stable_dt < max_dt) {
max_dt = results[i].max_stable_dt;
}
}
printf("\n=== RESULTS ===\n");
printf("Minimum stable time step: %.0f seconds\n", max_dt);
printf("Recommended safe time step: %.0f seconds (%.0fx safety margin)\n", max_dt * 0.7, 1.0/0.7);
INFO("Minimum stable dt: " << max_dt << " seconds");
print_summary(results, 3, max_dt, BASE_DT);
REQUIRE(max_dt >= MIN_DT);
destroy_simulation(sim);
}
TEST_CASE("Verify current default dt (60s) stability", "[timestep][stability]") {
const double DT = 60.0;
const int NUM_ORBITS = 10;
SimulationState* sim = create_simulation(10, 0, 0, DT);
REQUIRE(load_system_config(sim, "tests/informational/test_time_step_stability.toml"));
struct BodyTest {
int body_index;
int parent_index;
const char* name;
};
BodyTest tests[] = {
{1, 0, "Mercury_Orbiter"},
{3, 2, "Io"},
{5, 4, "Moon"}
};
for (int t = 0; t < 3; t++) {
int body_index = tests[t].body_index;
int parent_index = tests[t].parent_index;
const char* name = tests[t].name;
double period = calculate_orbital_period(&sim->bodies[body_index], &sim->bodies[parent_index]);
double max_time = period * NUM_ORBITS;
double initial_energy = calculate_system_total_energy(sim);
INFO("Testing " << name << " with dt=" << DT << "s for " << NUM_ORBITS << " orbits");
bool completed = true;
while (sim->time < max_time) {
update_simulation(sim);
if (sim->bodies[body_index].parent_index != parent_index) {
completed = false;
break;
}
}
double final_energy = calculate_system_total_energy(sim);
double energy_drift_percent = fabs((final_energy - initial_energy) / initial_energy) * 100.0;
INFO(name << " completed: " << (completed ? "yes" : "no"));
INFO(name << " energy drift: " << energy_drift_percent << "%");
REQUIRE(completed);
REQUIRE(energy_drift_percent < ENERGY_TOLERANCE);
}
destroy_simulation(sim);
}

80
tests/informational/test_time_step_stability.toml

@ -1,80 +0,0 @@
# Time Step Stability Test Configuration
# Contains bodies with different orbital periods for testing RK4 stability
[[bodies]]
name = "Mercury"
mass = 3.285e23
radius = 2.439e6
parent_index = -1
color = { r = 0.7, g = 0.7, b = 0.7 }
orbit = {
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
# Low Mercury orbiter at 200 km altitude (MESSENGER-like)
# Period ~12 hours - most restrictive case
[[bodies]]
name = "Mercury_Orbiter"
mass = 1000.0
radius = 1.0
parent_index = 0
color = { r = 1.0, g = 0.0, b = 1.0 }
orbit = {
semi_major_axis = 2.639e6,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[bodies]]
name = "Jupiter"
mass = 1.898e27
radius = 6.9911e7
parent_index = -1
color = { r = 0.9, g = 0.7, b = 0.5 }
orbit = {
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
# Io - Jupiter's moon
# Period ~1.77 days
[[bodies]]
name = "Io"
mass = 8.93e22
radius = 1.822e6
parent_index = 2
color = { r = 0.9, g = 0.9, b = 0.3 }
orbit = {
semi_major_axis = 4.217e8,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[bodies]]
name = "Earth"
mass = 5.972e24
radius = 6.371e6
parent_index = -1
color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = {
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
# Moon - Earth's moon
# Period ~27.3 days
[[bodies]]
name = "Moon"
mass = 7.342e22
radius = 1.737e6
parent_index = 4
color = { r = 0.7, g = 0.7, b = 0.7 }
orbit = {
semi_major_axis = 3.844e8,
eccentricity = 0.0,
true_anomaly = 0.0
}

700
tests/test_analytical_propagation.cpp

@ -1,4 +1,5 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h" #include "../src/physics.h"
#include "../src/orbital_mechanics.h" #include "../src/orbital_mechanics.h"
#include "../src/simulation.h" #include "../src/simulation.h"
@ -6,442 +7,343 @@
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include <cmath> #include <cmath>
const double VELOCITY_TOLERANCE_APSIDES = 1.0; using Catch::Matchers::WithinAbs;
const double POSITION_TOLERANCE_APSIDES = 1.0e3;
const double VELOCITY_TOLERANCE_TIMESTEP = 10.0;
const double POSITION_TOLERANCE_TIMESTEP = 1.0e4;
TEST_CASE("Propagation through perigee (velocity maximum)", "[analytical][propagation][perigee]") { SCENARIO("Analytical propagation: apsides, periods, vis-viva, timestep accuracy, long-term stability",
"[analytical][propagation][apsides][period][vis_viva][timestep][accuracy][long_term]") {
// === Fixture ===
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 2, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_analytical_propagation.toml"));
Spacecraft* craft = &sim->spacecraft[0];
CelestialBody* earth = &sim->bodies[0];
Vec3 pos_before;
Vec3 vel_before;
craft->orbit.true_anomaly = M_PI / 4.0;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos_before, &vel_before);
double v_before = vec3_magnitude(vel_before);
double r_before = vec3_magnitude(pos_before);
INFO("Before perigee:");
INFO(" Position: (" << pos_before.x << ", " << pos_before.y << ", " << pos_before.z << ") m");
INFO(" Velocity: (" << vel_before.x << ", " << vel_before.y << ", " << vel_before.z << ") m/s");
INFO(" Velocity magnitude: " << v_before << " m/s");
INFO(" Radius: " << r_before << " m");
Vec3 pos_perigee;
Vec3 vel_perigee;
craft->orbit.true_anomaly = 0.0;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos_perigee, &vel_perigee);
double v_perigee = vec3_magnitude(vel_perigee);
double r_perigee = vec3_magnitude(pos_perigee);
INFO("At perigee (v=0):");
INFO(" Position: (" << pos_perigee.x << ", " << pos_perigee.y << ", " << pos_perigee.z << ") m");
INFO(" Velocity: (" << vel_perigee.x << ", " << vel_perigee.y << ", " << vel_perigee.z << ") m/s");
INFO(" Velocity magnitude: " << v_perigee << " m/s");
INFO(" Radius: " << r_perigee << " m");
double expected_r_perigee = craft->orbit.semi_major_axis * (1.0 - craft->orbit.eccentricity);
INFO("Expected radius at perigee: " << expected_r_perigee << " m");
double r_error = fabs(r_perigee - expected_r_perigee);
INFO("Radius error: " << r_error << " m");
REQUIRE(r_error < POSITION_TOLERANCE_APSIDES);
REQUIRE(v_perigee > v_before);
destroy_simulation(sim);
}
TEST_CASE("Propagation through apogee (velocity minimum)", "[analytical][propagation][apogee]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 2, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_analytical_propagation.toml"));
Spacecraft* craft = &sim->spacecraft[0];
CelestialBody* earth = &sim->bodies[0];
Vec3 pos_perigee;
Vec3 vel_perigee;
craft->orbit.true_anomaly = 0.0;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos_perigee, &vel_perigee);
double v_perigee = vec3_magnitude(vel_perigee);
double r_perigee = vec3_magnitude(pos_perigee);
INFO("At perigee:");
INFO(" Velocity magnitude: " << v_perigee << " m/s");
INFO(" Radius: " << r_perigee << " m");
Vec3 pos_apogee;
Vec3 vel_apogee;
craft->orbit.true_anomaly = M_PI;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos_apogee, &vel_apogee);
double v_apogee = vec3_magnitude(vel_apogee);
double r_apogee = vec3_magnitude(pos_apogee);
INFO("At apogee (v=π):");
INFO(" Position: (" << pos_apogee.x << ", " << pos_apogee.y << ", " << pos_apogee.z << ") m");
INFO(" Velocity: (" << vel_apogee.x << ", " << vel_apogee.y << ", " << vel_apogee.z << ") m/s");
INFO(" Velocity magnitude: " << v_apogee << " m/s");
INFO(" Radius: " << r_apogee << " m");
double expected_r_apogee = craft->orbit.semi_major_axis * (1.0 + craft->orbit.eccentricity);
INFO("Expected radius at apogee: " << expected_r_apogee << " m");
double r_error = fabs(r_apogee - expected_r_apogee);
INFO("Radius error: " << r_error << " m");
REQUIRE(r_error < POSITION_TOLERANCE_APSIDES);
REQUIRE(v_apogee < v_perigee);
REQUIRE(r_apogee > r_perigee);
destroy_simulation(sim);
}
TEST_CASE("Propagation returns to initial state after one orbital period", "[analytical][propagation][period]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 2, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_analytical_propagation.toml"));
Spacecraft* craft = &sim->spacecraft[0];
CelestialBody* earth = &sim->bodies[0];
double a = craft->orbit.semi_major_axis;
double mu = G * earth->mass;
double period_seconds = 2.0 * M_PI * sqrt(pow(a, 3.0) / mu);
INFO("Semi-major axis: " << a << " m");
INFO("Orbital period: " << period_seconds << " s (" << period_seconds / 3600.0 << " hours)");
Vec3 pos_initial;
Vec3 vel_initial;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos_initial, &vel_initial);
INFO("Initial position: (" << pos_initial.x << ", " << pos_initial.y << ", " << pos_initial.z << ") m");
INFO("Initial velocity: (" << vel_initial.x << ", " << vel_initial.y << ", " << vel_initial.z << ") m/s");
OrbitalElements final_elements = propagate_orbital_elements(craft->orbit, period_seconds, earth->mass);
Vec3 pos_final;
Vec3 vel_final;
orbital_elements_to_cartesian(final_elements, earth->mass, &pos_final, &vel_final);
INFO("Final position: (" << pos_final.x << ", " << pos_final.y << ", " << pos_final.z << ") m");
INFO("Final velocity: (" << vel_final.x << ", " << vel_final.y << ", " << vel_final.z << ") m/s");
double pos_error = vec3_distance(pos_initial, pos_final);
double vel_error = vec3_distance(vel_initial, vel_final);
INFO("Position error after one period: " << pos_error << " m");
INFO("Velocity error after one period: " << vel_error << " m/s");
double r_initial = vec3_magnitude(pos_initial);
double r_final = vec3_magnitude(pos_final);
double relative_pos_error = pos_error / r_initial * 100.0;
double v_initial = vec3_magnitude(vel_initial);
double v_final = vec3_magnitude(vel_final);
double relative_vel_error = vel_error / v_initial * 100.0;
INFO("Relative position error: " << relative_pos_error << "%");
INFO("Relative velocity error: " << relative_vel_error << "%");
REQUIRE(relative_pos_error < 0.1);
REQUIRE(relative_vel_error < 0.1);
destroy_simulation(sim);
}
TEST_CASE("True anomaly accuracy after full orbit", "[analytical][propagation][true_anomaly]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 2, 0, TIME_STEP); SimulationState* sim = create_simulation(10, 2, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_analytical_propagation.toml")); REQUIRE(load_system_config(sim, "tests/test_analytical_propagation.toml"));
Spacecraft* craft = &sim->spacecraft[0]; Spacecraft* apsides_craft = &sim->spacecraft[0];
Spacecraft* timestep_craft = &sim->spacecraft[1];
CelestialBody* earth = &sim->bodies[0]; CelestialBody* earth = &sim->bodies[0];
double initial_true_anomaly = craft->orbit.true_anomaly; const double mu = G * earth->mass;
const double A1_A = apsides_craft->orbit.semi_major_axis;
INFO("Initial true anomaly: " << initial_true_anomaly << " rad (" << initial_true_anomaly * 180.0 / M_PI << "°)"); const double A1_E = apsides_craft->orbit.eccentricity;
const double A2_A = timestep_craft->orbit.semi_major_axis;
double a = craft->orbit.semi_major_axis; const double A2_E = timestep_craft->orbit.eccentricity;
double mu = G * earth->mass;
double period_seconds = 2.0 * M_PI * sqrt(pow(a, 3.0) / mu); const double A1_PERIOD = 2.0 * M_PI * sqrt(A1_A * A1_A * A1_A / mu);
const double A2_PERIOD = 2.0 * M_PI * sqrt(A2_A * A2_A * A2_A / mu);
OrbitalElements final_elements = propagate_orbital_elements(craft->orbit, period_seconds, earth->mass);
// Precalculated velocity magnitudes (from scripts/precalc_analytical_propagation.py)
double final_true_anomaly = final_elements.true_anomaly; const double A1_V_PERI = 8928.484709064580e+00; // m/s at perigee
const double A1_V_APO = 2232.121177266145e+00; // m/s at apogee
INFO("Final true anomaly: " << final_true_anomaly << " rad (" << final_true_anomaly * 180.0 / M_PI << "°)"); const double A1_V_AT_PI4 = 8292.953779787020e+00; // m/s at nu=pi/4
const double A2_V_PERI = 7874.183374942587e+00; // m/s at perigee
double expected_true_anomaly = fmod(initial_true_anomaly + 2.0 * M_PI, 2.0 * M_PI); const double A2_V_APO = 3374.650017832537e+00; // m/s at apogee
double anomaly_error = fabs(final_true_anomaly - expected_true_anomaly);
// 100-period propagation accumulates ~1.1e-12 rad anomaly error;
INFO("Expected true anomaly: " << expected_true_anomaly << " rad"); // ANG_TOL (1e-12) is too tight for long-term stability tests
INFO("True anomaly error: " << anomaly_error << " rad (" << anomaly_error * 180.0 / M_PI << "°)"); const double LONG_TERM_ANG_TOL = 1e-10;
REQUIRE(anomaly_error < 1.0e-6); // === Helper lambdas ===
auto make_elements = [&](double a, double e, double nu) {
destroy_simulation(sim); OrbitalElements el = {};
} el.semi_major_axis = a;
el.eccentricity = e;
TEST_CASE("Vis-viva equation holds at multiple points in orbit", "[analytical][propagation][vis_viva]") { el.true_anomaly = nu;
const double TIME_STEP = 60.0; return el;
};
SimulationState* sim = create_simulation(10, 2, 0, TIME_STEP);
auto get_state = [&](double a, double e, double nu, Vec3& pos, Vec3& vel) {
REQUIRE(load_system_config(sim, "tests/test_analytical_propagation.toml")); OrbitalElements el = make_elements(a, e, nu);
orbital_elements_to_cartesian(el, earth->mass, &pos, &vel);
Spacecraft* craft = &sim->spacecraft[0]; };
CelestialBody* earth = &sim->bodies[0];
auto propagate_and_get_state = [&](double a, double e, double nu, double dt,
double a = craft->orbit.semi_major_axis; Vec3& pos, Vec3& vel) {
double mu = G * earth->mass; OrbitalElements el = make_elements(a, e, nu);
OrbitalElements final_el = propagate_orbital_elements(el, dt, earth->mass);
double true_anomalies[] = {0.0, M_PI / 4.0, M_PI / 2.0, 3.0 * M_PI / 4.0, M_PI}; orbital_elements_to_cartesian(final_el, earth->mass, &pos, &vel);
};
for (int i = 0; i < 5; i++) {
double nu = true_anomalies[i]; auto check_apsides_radius = [&](double a, double e, double nu,
INFO("Testing at true anomaly: " << nu << " rad (" << nu * 180.0 / M_PI << "°)"); double expected_r, const char* label) {
Vec3 pos, vel;
craft->orbit.true_anomaly = nu; get_state(a, e, nu, pos, vel);
const double r = vec3_magnitude(pos);
Vec3 position; INFO(label);
Vec3 velocity; INFO(" Expected r: " << expected_r << " m");
orbital_elements_to_cartesian(craft->orbit, earth->mass, &position, &velocity); INFO(" Calculated r: " << r << " m");
REQUIRE_THAT(r, WithinAbs(expected_r, R_TOL));
double r = vec3_magnitude(position); };
double v = vec3_magnitude(velocity);
auto check_period_return = [&](double a, double e, double period,
double expected_v_squared = mu * (2.0 / r - 1.0 / a); const char* label) {
double expected_v = sqrt(expected_v_squared); OrbitalElements el = make_elements(a, e, 0.0);
Vec3 pos_initial, vel_initial;
double v_error = fabs(v - expected_v); orbital_elements_to_cartesian(el, earth->mass, &pos_initial, &vel_initial);
double relative_error = v_error / expected_v * 100.0;
OrbitalElements final_el = propagate_orbital_elements(el, period, earth->mass);
INFO(" Radius: " << r << " m"); Vec3 pos_final, vel_final;
INFO(" Actual velocity: " << v << " m/s"); orbital_elements_to_cartesian(final_el, earth->mass, &pos_final, &vel_final);
INFO(" Expected velocity: " << expected_v << " m/s");
INFO(" Error: " << v_error << " m/s (" << relative_error << "%)"); const double pos_error = vec3_distance(pos_initial, pos_final);
const double vel_error = vec3_distance(vel_initial, vel_final);
const double r_initial = vec3_magnitude(pos_initial);
const double v_initial = vec3_magnitude(vel_initial);
const double rel_pos_error = pos_error / r_initial * 100.0;
const double rel_vel_error = vel_error / v_initial * 100.0;
INFO(label);
INFO(" Relative position error: " << rel_pos_error << "%");
INFO(" Relative velocity error: " << rel_vel_error << "%");
REQUIRE_THAT(rel_pos_error, WithinAbs(0.0, REL_TOL * 100.0));
REQUIRE_THAT(rel_vel_error, WithinAbs(0.0, REL_TOL * 100.0));
};
auto check_anomaly_return = [&](double a, double e, double period,
double initial_nu, const char* label) {
OrbitalElements el = make_elements(a, e, initial_nu);
OrbitalElements final_el = propagate_orbital_elements(el, period, earth->mass);
const double final_nu = final_el.true_anomaly;
const double expected_nu = std::fmod(initial_nu + 2.0 * M_PI, 2.0 * M_PI);
double anomaly_error = std::abs(final_nu - expected_nu);
if (anomaly_error > M_PI) {
anomaly_error = 2.0 * M_PI - anomaly_error;
}
REQUIRE(relative_error < 0.01); INFO(label);
INFO(" Initial nu: " << initial_nu << " rad");
INFO(" Final nu: " << final_nu << " rad");
INFO(" Anomaly error: " << anomaly_error << " rad");
REQUIRE_THAT(anomaly_error, WithinAbs(0.0, ANG_TOL));
};
auto check_vis_viva = [&](double a, double e, double nu, const char* label) {
Vec3 pos, vel;
get_state(a, e, nu, pos, vel);
const double r = vec3_magnitude(pos);
const double v = vec3_magnitude(vel);
const double expected_v = std::sqrt(mu * (2.0 / r - 1.0 / a));
const double rel_error = std::abs(v - expected_v) / expected_v * 100.0;
INFO(label);
INFO(" nu: " << nu << " rad (" << nu * 180.0 / M_PI << " deg)");
INFO(" r: " << r << " m");
INFO(" v: " << v << " m/s");
INFO(" expected_v: " << expected_v << " m/s");
INFO(" rel_error: " << rel_error << "%");
REQUIRE_THAT(rel_error, WithinAbs(0.0, REL_TOL * 100.0));
};
// === SECTIONs ===
// --- 1. Apsides geometry (both spacecraft) ---
SECTION("apsides perigee radius = a*(1-e)") {
check_apsides_radius(A1_A, A1_E, 0.0, A1_A * (1.0 - A1_E),
"Apsides spacecraft perigee");
} }
destroy_simulation(sim); SECTION("apsides apogee radius = a*(1+e)") {
} check_apsides_radius(A1_A, A1_E, M_PI, A1_A * (1.0 + A1_E),
"Apsides spacecraft apogee");
TEST_CASE("Large timestep - dt greater than orbital period", "[analytical][timestep][large]") { }
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 2, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_analytical_propagation.toml"));
Spacecraft* craft = &sim->spacecraft[1];
CelestialBody* earth = &sim->bodies[0];
double a = craft->orbit.semi_major_axis;
double mu = G * earth->mass;
double period_seconds = 2.0 * M_PI * sqrt(pow(a, 3.0) / mu);
INFO("Orbital period: " << period_seconds << " s (" << period_seconds / 3600.0 << " hours)");
double large_dt = period_seconds * 2.0;
INFO("Timestep: " << large_dt << " s (2x orbital period)");
Vec3 pos_before;
Vec3 vel_before;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos_before, &vel_before);
OrbitalElements propagated = propagate_orbital_elements(craft->orbit, large_dt, earth->mass);
Vec3 pos_after;
Vec3 vel_after;
orbital_elements_to_cartesian(propagated, earth->mass, &pos_after, &vel_after);
double r_before = vec3_magnitude(pos_before);
double r_after = vec3_magnitude(pos_after);
double v_before = vec3_magnitude(vel_before);
double v_after = vec3_magnitude(vel_after);
INFO("Before propagation:");
INFO(" Radius: " << r_before << " m");
INFO(" Velocity: " << v_before << " m/s");
INFO("After 2 periods:");
INFO(" Radius: " << r_after << " m");
INFO(" Velocity: " << v_after << " m/s");
double r_error = fabs(r_after - r_before);
double v_error = fabs(v_after - v_before);
double relative_r_error = r_error / r_before * 100.0;
double relative_v_error = v_error / v_before * 100.0;
INFO("Radius error: " << r_error << " m (" << relative_r_error << "%)");
INFO("Velocity error: " << v_error << " m/s (" << relative_v_error << "%)");
REQUIRE(relative_r_error < 0.1);
REQUIRE(relative_v_error < 0.1);
destroy_simulation(sim);
}
TEST_CASE("Very small timestep - dt less than 1 second", "[analytical][timestep][small]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 2, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_analytical_propagation.toml"));
Spacecraft* craft = &sim->spacecraft[1];
CelestialBody* earth = &sim->bodies[0];
Vec3 pos_before;
Vec3 vel_before;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos_before, &vel_before);
double small_dt = 0.1;
INFO("Timestep: " << small_dt << " s");
OrbitalElements propagated = propagate_orbital_elements(craft->orbit, small_dt, earth->mass);
Vec3 pos_after;
Vec3 vel_after;
orbital_elements_to_cartesian(propagated, earth->mass, &pos_after, &vel_after);
double pos_change = vec3_distance(pos_before, pos_after);
double vel_change = vec3_distance(vel_before, vel_after);
INFO("Position change: " << pos_change << " m");
INFO("Velocity change: " << vel_change << " m/s");
double v_before_mag = vec3_magnitude(vel_before);
double expected_pos_change = v_before_mag * small_dt;
double pos_error = fabs(pos_change - expected_pos_change);
INFO("Expected position change (v·dt): " << expected_pos_change << " m");
INFO("Position error: " << pos_error << " m");
INFO("Relative position error: " << (pos_error / expected_pos_change * 100.0) << "%");
REQUIRE(pos_error < VELOCITY_TOLERANCE_TIMESTEP * small_dt * 10.0);
REQUIRE(vel_change < VELOCITY_TOLERANCE_TIMESTEP);
destroy_simulation(sim);
}
TEST_CASE("Accuracy vs timestep size relationship", "[analytical][timestep][accuracy]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 2, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_analytical_propagation.toml"));
Spacecraft* craft = &sim->spacecraft[1];
CelestialBody* earth = &sim->bodies[0];
double a = craft->orbit.semi_major_axis;
double mu = G * earth->mass;
double period_seconds = 2.0 * M_PI * sqrt(pow(a, 3.0) / mu);
double dt_ratios[] = {0.01, 0.1, 1.0, 10.0};
Vec3 pos_initial; SECTION("apsides perigee velocity matches precalculated value") {
Vec3 vel_initial; Vec3 pos_peri, vel_peri, pos_apo, vel_apo;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos_initial, &vel_initial); get_state(A1_A, A1_E, 0.0, pos_peri, vel_peri);
get_state(A1_A, A1_E, M_PI, pos_apo, vel_apo);
const double v_peri = vec3_magnitude(vel_peri);
const double v_apo = vec3_magnitude(vel_apo);
INFO("v_peri: " << v_peri << " m/s");
INFO("v_apo: " << v_apo << " m/s");
REQUIRE_THAT(v_peri, WithinAbs(A1_V_PERI, V_TOL));
REQUIRE_THAT(v_apo, WithinAbs(A1_V_APO, V_TOL));
}
for (int i = 0; i < 4; i++) { SECTION("apsides velocity at pi/4 matches precalculated value") {
double dt = period_seconds * dt_ratios[i]; Vec3 pos_45, vel_45, pos_peri, vel_peri;
INFO("Testing dt = " << dt << " s (" << dt_ratios[i] << "x period)"); get_state(A1_A, A1_E, M_PI / 4.0, pos_45, vel_45);
get_state(A1_A, A1_E, 0.0, pos_peri, vel_peri);
const double v_45 = vec3_magnitude(vel_45);
const double v_peri = vec3_magnitude(vel_peri);
INFO("v_peri: " << v_peri << " m/s");
INFO("v_at_pi4: " << v_45 << " m/s");
REQUIRE_THAT(v_peri, WithinAbs(A1_V_PERI, V_TOL));
REQUIRE_THAT(v_45, WithinAbs(A1_V_AT_PI4, V_TOL));
}
OrbitalElements propagated = propagate_orbital_elements(craft->orbit, dt, earth->mass); SECTION("timestep perigee radius = a*(1-e)") {
check_apsides_radius(A2_A, A2_E, 0.0, A2_A * (1.0 - A2_E),
"Timestep spacecraft perigee");
}
Vec3 pos_final; SECTION("timestep apogee radius = a*(1+e)") {
Vec3 vel_final; check_apsides_radius(A2_A, A2_E, M_PI, A2_A * (1.0 + A2_E),
orbital_elements_to_cartesian(propagated, earth->mass, &pos_final, &vel_final); "Timestep spacecraft apogee");
}
double pos_error = vec3_distance(pos_initial, pos_final); SECTION("timestep perigee velocity matches precalculated value") {
double vel_error = vec3_distance(vel_initial, vel_final); Vec3 pos_peri, vel_peri, pos_apo, vel_apo;
get_state(A2_A, A2_E, 0.0, pos_peri, vel_peri);
get_state(A2_A, A2_E, M_PI, pos_apo, vel_apo);
const double v_peri = vec3_magnitude(vel_peri);
const double v_apo = vec3_magnitude(vel_apo);
INFO("v_peri: " << v_peri << " m/s");
INFO("v_apo: " << v_apo << " m/s");
REQUIRE_THAT(v_peri, WithinAbs(A2_V_PERI, V_TOL));
REQUIRE_THAT(v_apo, WithinAbs(A2_V_APO, V_TOL));
}
double num_periods = dt / period_seconds; // --- 2. Vis-viva ---
double expected_num_orbits = round(num_periods); SECTION("vis-viva at nu = 0") {
check_vis_viva(A1_A, A1_E, 0.0, "vis-viva nu=0");
}
double fractional_phase = num_periods - expected_num_orbits; SECTION("vis-viva at nu = pi/4") {
double expected_pos_error = fractional_phase * 2.0 * M_PI * a; check_vis_viva(A1_A, A1_E, M_PI / 4.0, "vis-viva nu=pi/4");
}
INFO(" Position error: " << pos_error << " m"); SECTION("vis-viva at nu = pi/2") {
INFO(" Expected error (phase): " << expected_pos_error << " m"); check_vis_viva(A1_A, A1_E, M_PI / 2.0, "vis-viva nu=pi/2");
INFO(" Number of periods: " << num_periods); }
if (expected_num_orbits > 0 && expected_pos_error > 1.0e-6) { SECTION("vis-viva at nu = 3pi/4") {
double relative_error = pos_error / expected_pos_error; check_vis_viva(A1_A, A1_E, 3.0 * M_PI / 4.0, "vis-viva nu=3pi/4");
}
INFO(" Relative error: " << relative_error); SECTION("vis-viva at nu = pi") {
check_vis_viva(A1_A, A1_E, M_PI, "vis-viva nu=pi");
}
REQUIRE(relative_error < 0.5); // --- 3. Period return (both spacecraft) ---
} else if (expected_num_orbits > 0) { SECTION("apsides position and velocity return after one period") {
INFO(" Expected error is zero, skipping relative error check"); check_period_return(A1_A, A1_E, A1_PERIOD,
REQUIRE(pos_error < 1.0e-3); "Apsides spacecraft");
}
} }
destroy_simulation(sim); SECTION("apsides true anomaly returns after one period") {
} check_anomaly_return(A1_A, A1_E, A1_PERIOD, 0.0,
"Apsides spacecraft nu");
}
TEST_CASE("Mean anomaly accumulation over long propagation", "[analytical][timestep][accumulation]") { SECTION("timestep position and velocity return after one period") {
const double TIME_STEP = 60.0; check_period_return(A2_A, A2_E, A2_PERIOD,
"Timestep spacecraft");
}
SimulationState* sim = create_simulation(10, 2, 0, TIME_STEP); SECTION("timestep true anomaly returns after one period") {
check_anomaly_return(A2_A, A2_E, A2_PERIOD, 0.0,
"Timestep spacecraft nu");
}
REQUIRE(load_system_config(sim, "tests/test_analytical_propagation.toml")); // --- 4. Timestep accuracy ---
SECTION("large timestep (2x period) preserves state") {
Vec3 pos_final, vel_final;
propagate_and_get_state(A2_A, A2_E, 0.0, A2_PERIOD * 2.0,
pos_final, vel_final);
Vec3 pos_init, vel_init;
get_state(A2_A, A2_E, 0.0, pos_init, vel_init);
const double r_final = vec3_magnitude(pos_final);
const double v_final = vec3_magnitude(vel_final);
const double r_init = vec3_magnitude(pos_init);
const double v_init = vec3_magnitude(vel_init);
const double rel_r_error = std::abs(r_final - r_init) / r_init * 100.0;
const double rel_v_error = std::abs(v_final - v_init) / v_init * 100.0;
INFO("Relative radius error: " << rel_r_error << "%");
INFO("Relative velocity error: " << rel_v_error << "%");
REQUIRE_THAT(rel_r_error, WithinAbs(0.0, REL_TOL * 100.0));
REQUIRE_THAT(rel_v_error, WithinAbs(0.0, REL_TOL * 100.0));
}
Spacecraft* craft = &sim->spacecraft[1]; SECTION("very small timestep (0.1 s) produces expected displacement") {
CelestialBody* earth = &sim->bodies[0]; const double dt = 0.1;
Vec3 pos_final, vel_final;
propagate_and_get_state(A2_A, A2_E, 0.0, dt, pos_final, vel_final);
Vec3 pos_init, vel_init;
get_state(A2_A, A2_E, 0.0, pos_init, vel_init);
const double pos_change = vec3_distance(pos_init, pos_final);
const double vel_change = vec3_distance(vel_init, vel_final);
const double expected_pos_change = vec3_magnitude(vel_init) * dt;
const double pos_error = std::abs(pos_change - expected_pos_change);
const double rel_pos_error = pos_error / expected_pos_change * 100.0;
INFO("dt: " << dt << " s");
INFO("pos_change: " << pos_change << " m");
INFO("expected_pos_change: " << expected_pos_change << " m");
INFO("pos_error: " << pos_error << " m");
INFO("rel_pos_error: " << rel_pos_error << "%");
INFO("vel_change: " << vel_change << " m/s");
REQUIRE_THAT(rel_pos_error, WithinAbs(0.0, REL_TOL * 100.0));
REQUIRE_THAT(vel_change, WithinAbs(0.4920854266, V_TOL));
}
double a = craft->orbit.semi_major_axis; SECTION("accuracy at 1x period") {
double mu = G * earth->mass; Vec3 pos_final, vel_final;
double period_seconds = 2.0 * M_PI * sqrt(pow(a, 3.0) / mu); propagate_and_get_state(A2_A, A2_E, 0.0, A2_PERIOD, pos_final, vel_final);
double mean_motion = sqrt(mu / pow(a, 3.0)); Vec3 pos_init, vel_init;
get_state(A2_A, A2_E, 0.0, pos_init, vel_init);
const double pos_error = vec3_distance(pos_init, pos_final);
const double vel_error = vec3_distance(vel_init, vel_final);
double initial_true_anomaly = craft->orbit.true_anomaly; INFO("dt: " << A2_PERIOD << " s (1x period)");
INFO("Initial true anomaly: " << initial_true_anomaly << " rad"); INFO("pos_error: " << pos_error << " m");
INFO("vel_error: " << vel_error << " m/s");
double propagation_time = period_seconds * 100.0; REQUIRE_THAT(pos_error, WithinAbs(0.0, R_TOL));
INFO("Propagation time: " << propagation_time << " s (" << propagation_time / period_seconds << " periods)"); REQUIRE_THAT(vel_error, WithinAbs(0.0, V_TOL));
}
OrbitalElements propagated = propagate_orbital_elements(craft->orbit, propagation_time, earth->mass); SECTION("accuracy at 10x period") {
Vec3 pos_final, vel_final;
propagate_and_get_state(A2_A, A2_E, 0.0, A2_PERIOD * 10.0,
pos_final, vel_final);
Vec3 pos_init, vel_init;
get_state(A2_A, A2_E, 0.0, pos_init, vel_init);
const double pos_error = vec3_distance(pos_init, pos_final);
const double vel_error = vec3_distance(vel_init, vel_final);
INFO("dt: " << A2_PERIOD * 10.0 << " s (10x period)");
INFO("pos_error: " << pos_error << " m");
INFO("vel_error: " << vel_error << " m/s");
REQUIRE_THAT(pos_error, WithinAbs(0.0, R_TOL));
REQUIRE_THAT(vel_error, WithinAbs(0.0, V_TOL));
}
double final_true_anomaly = propagated.true_anomaly; // --- 5. Long-term stability ---
INFO("Final true anomaly: " << final_true_anomaly << " rad"); SECTION("100 periods propagation stability") {
const double propagation_time = A2_PERIOD * 100.0;
const double mean_motion = std::sqrt(mu / (A2_A * A2_A * A2_A));
const double initial_nu = 0.0;
double expected_delta_anomaly = mean_motion * propagation_time; OrbitalElements el = make_elements(A2_A, A2_E, initial_nu);
double expected_final_anomaly = fmod(initial_true_anomaly + expected_delta_anomaly, 2.0 * M_PI); OrbitalElements propagated = propagate_orbital_elements(el, propagation_time, earth->mass);
const double final_nu = propagated.true_anomaly;
INFO("Expected final anomaly: " << expected_final_anomaly << " rad"); const double expected_delta_anomaly = mean_motion * propagation_time;
double expected_final_nu = initial_nu + expected_delta_anomaly;
while (expected_final_nu < 0.0) {
expected_final_nu += 2.0 * M_PI;
}
while (expected_final_nu >= 2.0 * M_PI) {
expected_final_nu -= 2.0 * M_PI;
}
double raw_error = fabs(final_true_anomaly - expected_final_anomaly); const double raw_error = std::abs(final_nu - expected_final_nu);
double anomaly_error = fmin(raw_error, 2.0 * M_PI - raw_error); const double anomaly_error = std::fmin(raw_error, 2.0 * M_PI - raw_error);
INFO("True anomaly error: " << anomaly_error << " rad (" << anomaly_error * 180.0 / M_PI << "°)"); INFO("Propagation time: " << propagation_time << " s ("
<< propagation_time / A2_PERIOD << " periods)");
INFO("Initial nu: " << initial_nu << " rad");
INFO("Final nu: " << final_nu << " rad");
INFO("Expected nu: " << expected_final_nu << " rad");
INFO("Anomaly error: " << anomaly_error << " rad ("
<< anomaly_error * 180.0 / M_PI << " deg)");
REQUIRE(anomaly_error < 1.0e-3); REQUIRE_THAT(anomaly_error, WithinAbs(0.0, LONG_TERM_ANG_TOL));
}
destroy_simulation(sim); destroy_simulation(sim);
} }

27
tests/test_analytical_propagation.toml

@ -1,6 +1,5 @@
# Test Configuration: Analytical Propagation Tests # Test Configuration: Analytical Propagation Tests
# Combined configuration for apsides and timestep testing # Two spacecraft with different orbital parameters for propagation testing
# Contains two spacecraft with different orbital parameters
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
@ -8,34 +7,16 @@ mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = -1 parent_index = -1
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[spacecraft]] [[spacecraft]]
name = "Apsides_Test_Spacecraft" name = "Apsides_Test_Spacecraft"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = 2.0e7, eccentricity = 0.6, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 2.0e7,
eccentricity = 0.6,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
[[spacecraft]] [[spacecraft]]
name = "Timestep_Test_Spacecraft" name = "Timestep_Test_Spacecraft"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = 1.5e7, eccentricity = 0.4, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 1.5e7,
eccentricity = 0.4,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}

227
tests/test_barkers_equation.cpp

@ -2,154 +2,111 @@
#include <catch2/matchers/catch_matchers_floating_point.hpp> #include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/orbital_mechanics.h" #include "../src/orbital_mechanics.h"
#include <cmath> #include <cmath>
#include <vector>
TEST_CASE("Barker's equation - zero mean anomaly", "[barker][analytical]") { using Catch::Matchers::WithinAbs;
double M = 0.0;
double nu = solve_barker_equation(M);
double nu_expected = 0.0;
REQUIRE_THAT(nu, Catch::Matchers::WithinAbs(nu_expected, 1e-15));
}
TEST_CASE("Barker's equation - small positive mean anomaly", "[barker][analytical]") {
double M = 0.1;
double nu = solve_barker_equation(M);
REQUIRE(nu > 0.0);
REQUIRE(nu < M_PI);
double D = tan(nu / 2.0);
double M_recovered = D + (D * D * D) / 3.0;
REQUIRE_THAT(M_recovered, Catch::Matchers::WithinAbs(M, 1e-14));
}
TEST_CASE("Barker's equation - moderate positive mean anomaly", "[barker][analytical]") {
double M = 1.0;
double nu = solve_barker_equation(M);
REQUIRE(nu > 0.0);
REQUIRE(nu < M_PI);
double D = tan(nu / 2.0);
double M_recovered = D + (D * D * D) / 3.0;
REQUIRE_THAT(M_recovered, Catch::Matchers::WithinAbs(M, 1e-14));
}
TEST_CASE("Barker's equation - large positive mean anomaly", "[barker][analytical]") { SCENARIO("Barker's equation solves parabolic mean anomaly",
double M = 5.0; "[barker][analytical][parabolic]") {
double nu = solve_barker_equation(M); const double PARENT_MASS = 1.989e30;
REQUIRE(nu > 0.0); const double TIME_STEP = 3600.0;
REQUIRE(nu < M_PI); const int NUM_STEPS = 24;
double D = tan(nu / 2.0);
double M_recovered = D + (D * D * D) / 3.0;
REQUIRE_THAT(M_recovered, Catch::Matchers::WithinAbs(M, 1e-14));
}
TEST_CASE("Barker's equation - very large mean anomaly", "[barker][analytical]") {
double M = 20.0;
double nu = solve_barker_equation(M);
REQUIRE(nu > 0.0);
REQUIRE(nu < M_PI);
double D = tan(nu / 2.0);
double M_recovered = D + (D * D * D) / 3.0;
REQUIRE_THAT(M_recovered, Catch::Matchers::WithinAbs(M, 1e-13));
}
TEST_CASE("Barker's equation - small negative mean anomaly", "[barker][analytical]") {
double M = -0.1;
double nu = solve_barker_equation(M);
REQUIRE(nu < 0.0);
REQUIRE(nu > -M_PI);
double D = tan(nu / 2.0);
double M_recovered = D + (D * D * D) / 3.0;
REQUIRE_THAT(M_recovered, Catch::Matchers::WithinAbs(M, 1e-14));
}
TEST_CASE("Barker's equation - moderate negative mean anomaly", "[barker][analytical]") {
double M = -1.0;
double nu = solve_barker_equation(M);
REQUIRE(nu < 0.0);
REQUIRE(nu > -M_PI);
double D = tan(nu / 2.0);
double M_recovered = D + (D * D * D) / 3.0;
REQUIRE_THAT(M_recovered, Catch::Matchers::WithinAbs(M, 1e-14));
}
TEST_CASE("Barker's equation - large negative mean anomaly", "[barker][analytical]") {
double M = -5.0;
double nu = solve_barker_equation(M);
REQUIRE(nu < 0.0);
REQUIRE(nu > -M_PI);
double D = tan(nu / 2.0);
double M_recovered = D + (D * D * D) / 3.0;
REQUIRE_THAT(M_recovered, Catch::Matchers::WithinAbs(M, 1e-14));
}
TEST_CASE("Barker's equation - round-trip conversion", "[barker][analytical]") {
std::vector<double> test_values = {-10.0, -5.0, -1.0, -0.5, -0.1, 0.0, 0.1, 0.5, 1.0, 5.0, 10.0};
for (double M_original : test_values) { auto check_roundtrip = [&](double M_original, double tol) {
double nu = solve_barker_equation(M_original); double nu = solve_barker_equation(M_original);
double D = tan(nu / 2.0); double D = tan(nu / 2.0);
double M_recovered = D + (D * D * D) / 3.0; double M_recovered = D + (D * D * D) / 3.0;
REQUIRE_THAT(M_recovered, Catch::Matchers::WithinAbs(M_original, 1e-13)); REQUIRE_THAT(M_recovered, WithinAbs(M_original, tol));
} };
}
TEST_CASE("Barker's equation - true anomaly range", "[barker][analytical]") { SECTION("zero mean anomaly yields zero true anomaly") {
for (double M = -50.0; M <= 50.0; M += 1.0) { double M = 0.0;
double nu = solve_barker_equation(M); double nu = solve_barker_equation(M);
REQUIRE(nu > -M_PI * 0.99); REQUIRE_THAT(nu, WithinAbs(0.0, 1e-15));
REQUIRE(nu < M_PI * 0.99);
} }
}
TEST_CASE("Parabolic orbit propagation using Barker's equation", "[barker][propagation]") {
const double PARENT_MASS = 1.989e30;
const double TIME_STEP = 3600.0;
const int NUM_STEPS = 24;
OrbitalElements initial;
initial.semi_latus_rectum = 2.992e11;
initial.eccentricity = 1.0;
initial.true_anomaly = 0.0;
initial.inclination = 0.0;
initial.longitude_of_ascending_node = 0.0;
initial.argument_of_periapsis = 0.0;
Vec3 pos, vel;
orbital_elements_to_cartesian(initial, PARENT_MASS, &pos, &vel);
double initial_distance = vec3_magnitude(pos);
double initial_velocity = vec3_magnitude(vel);
double escape_velocity = sqrt(2.0 * G * PARENT_MASS / initial_distance);
INFO("Initial distance: " << initial_distance / 1.496e11 << " AU");
INFO("Initial velocity: " << initial_velocity / 1000.0 << " km/s");
INFO("Escape velocity: " << escape_velocity / 1000.0 << " km/s");
REQUIRE_THAT(initial_velocity, Catch::Matchers::WithinAbs(escape_velocity, 1.0)); SECTION("positive mean anomaly values") {
std::vector<std::pair<double, double>> tests = {
OrbitalElements current = initial; std::make_pair(0.1, 1e-14), std::make_pair(1.0, 1e-14),
double total_time = 0.0; std::make_pair(5.0, 1e-14), std::make_pair(20.0, 1e-13)
};
for (int step = 0; step < NUM_STEPS; step++) { for (const auto& p : tests) {
OrbitalElements next = propagate_orbital_elements(current, TIME_STEP, PARENT_MASS); double nu = solve_barker_equation(p.first);
current = next; REQUIRE(nu > 0.0);
total_time += TIME_STEP; REQUIRE(nu < M_PI);
check_roundtrip(p.first, p.second);
}
} }
Vec3 pos_final, vel_final; SECTION("negative mean anomaly values") {
orbital_elements_to_cartesian(current, PARENT_MASS, &pos_final, &vel_final); std::vector<std::pair<double, double>> tests = {
std::make_pair(-0.1, 1e-14), std::make_pair(-1.0, 1e-14),
double final_distance = vec3_magnitude(pos_final); std::make_pair(-5.0, 1e-14)
double final_velocity = vec3_magnitude(vel_final); };
for (const auto& p : tests) {
INFO("Final true anomaly: " << current.true_anomaly << " rad"); double nu = solve_barker_equation(p.first);
INFO("Final distance: " << final_distance / 1.496e11 << " AU"); REQUIRE(nu < 0.0);
INFO("Final velocity: " << final_velocity / 1000.0 << " km/s"); REQUIRE(nu > -M_PI);
check_roundtrip(p.first, p.second);
REQUIRE(final_distance > initial_distance); }
}
REQUIRE(final_velocity < initial_velocity); SECTION("round-trip across full range") {
std::vector<double> test_values = {-10.0, -5.0, -1.0, -0.5, -0.1,
0.0, 0.1, 0.5, 1.0, 5.0, 10.0};
for (double M_original : test_values) {
check_roundtrip(M_original, 1e-13);
}
}
double final_escape_velocity = sqrt(2.0 * G * PARENT_MASS / final_distance); SECTION("true anomaly stays within (-pi, pi) for M in [-50, 50]") {
INFO("Final escape velocity: " << final_escape_velocity / 1000.0 << " km/s"); for (double M = -50.0; M <= 50.0; M += 1.0) {
double nu = solve_barker_equation(M);
REQUIRE(nu > -M_PI * 0.99);
REQUIRE(nu < M_PI * 0.99);
}
}
REQUIRE_THAT(final_velocity, Catch::Matchers::WithinAbs(final_escape_velocity, 1.0)); SECTION("parabolic orbit propagation preserves energy") {
OrbitalElements initial = {};
initial.semi_latus_rectum = 2.992e11;
initial.eccentricity = 1.0;
initial.true_anomaly = 0.0;
initial.inclination = 0.0;
initial.longitude_of_ascending_node = 0.0;
initial.argument_of_periapsis = 0.0;
Vec3 pos, vel;
orbital_elements_to_cartesian(initial, PARENT_MASS, &pos, &vel);
const double initial_distance = vec3_magnitude(pos);
const double initial_velocity = vec3_magnitude(vel);
const double escape_velocity = sqrt(2.0 * G * PARENT_MASS / initial_distance);
INFO("Initial distance: " << initial_distance / 1.496e11 << " AU");
INFO("Initial velocity: " << initial_velocity / 1000.0 << " km/s");
INFO("Escape velocity: " << escape_velocity / 1000.0 << " km/s");
REQUIRE_THAT(initial_velocity, WithinAbs(escape_velocity, 1.0));
OrbitalElements current = initial;
for (int step = 0; step < NUM_STEPS; step++) {
current = propagate_orbital_elements(current, TIME_STEP, PARENT_MASS);
}
Vec3 pos_final, vel_final;
orbital_elements_to_cartesian(current, PARENT_MASS, &pos_final, &vel_final);
const double final_distance = vec3_magnitude(pos_final);
const double final_velocity = vec3_magnitude(vel_final);
const double final_escape_velocity = sqrt(2.0 * G * PARENT_MASS / final_distance);
INFO("Final true anomaly: " << current.true_anomaly << " rad");
INFO("Final distance: " << final_distance / 1.496e11 << " AU");
INFO("Final velocity: " << final_velocity / 1000.0 << " km/s");
INFO("Final escape velocity: " << final_escape_velocity / 1000.0 << " km/s");
REQUIRE(final_distance > initial_distance);
REQUIRE(final_velocity < initial_velocity);
REQUIRE_THAT(final_velocity, WithinAbs(final_escape_velocity, 1.0));
}
} }

675
tests/test_cartesian_to_elements_advanced.cpp

@ -1,508 +1,221 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp> #include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <cmath> #include <cmath>
#include <array>
#include <vector>
#include "../src/orbital_mechanics.h" #include "../src/orbital_mechanics.h"
#include "../src/orbital_objects.h"
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include "../src/config_loader.h"
#include "../src/simulation.h"
using Catch::Matchers::WithinAbs; using Catch::Matchers::WithinAbs;
TEST_CASE("Cartesian to Elements - Advanced Tests", "[orbital_mechanics]") { SCENARIO("Cartesian to Elements - Advanced conversion tests",
const double G = 6.67430e-11; "[orbital_mechanics][cartesian][elements]") {
const double M_sun = 1.989e30; const double M_sun = 1.989e30;
const double mu = G * M_sun;
SECTION("Circular orbit conversion preserves exact circular parameters") { // NOTE: Semi-major axis tolerance for |a|=1e11 m cases. The vis-viva equation
double r = 1.496e11; // a = -mu/(2*epsilon) amplifies floating-point error in specific energy
double v_circular = sqrt(mu / r); // (~1e-15 rel) to absolute errors of ~1e-4 m at this scale. Header A_TOL=1e-6
Vec3 position = {r, 0.0, 0.0}; // would fail; 2e-4 provides comfortable margin over observed ~1.4e-4 m error.
Vec3 velocity = {0.0, v_circular, 0.0}; const double A_TOL_LARGE = 2e-4;
OrbitalElements elements = cartesian_to_orbital_elements(position, velocity, M_sun); auto convert_and_recover = [&](const OrbitalElements& elements) {
Vec3 pos, vel;
REQUIRE_THAT(elements.eccentricity, WithinAbs(0.0, 1e-10)); orbital_elements_to_cartesian(elements, M_sun, &pos, &vel);
REQUIRE_THAT(elements.semi_major_axis, WithinAbs(r, 1e3)); return cartesian_to_orbital_elements(pos, vel, M_sun);
};
Vec3 converted_position, converted_velocity;
orbital_elements_to_cartesian(elements, M_sun, &converted_position, &converted_velocity); auto make_elements = [&](double a, double e, double nu, double inc,
double lon_anode, double arg_peri) {
REQUIRE(compare_vec3(position, converted_position, 1e3)); OrbitalElements el = {};
REQUIRE(compare_vec3(velocity, converted_velocity, 1e-3)); el.semi_major_axis = a;
} el.eccentricity = e;
el.true_anomaly = nu;
SECTION("Near-circular orbit (e=0.001) recovers small eccentricity") { el.inclination = inc;
OrbitalElements elements = { el.longitude_of_ascending_node = lon_anode;
.semi_major_axis = 1.496e11, el.argument_of_periapsis = arg_peri;
.eccentricity = 0.001, return el;
.true_anomaly = 0.5, };
.inclination = 0.0,
.longitude_of_ascending_node = 0.0, SECTION("eccentricity spectrum: circular to highly hyperbolic") {
.argument_of_periapsis = 0.0 const double r = 1.496e11;
}; const double v_circular = sqrt(G * M_sun / r);
const Vec3 pos_circ = {r, 0.0, 0.0};
Vec3 position, velocity; const Vec3 vel_circ = {0.0, v_circular, 0.0};
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity);
const OrbitalElements circular = make_elements(r, 0.0, 0.0, 0.0, 0.0, 0.0);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
Vec3 converted_pos, converted_vel;
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.001, 1e-6)); orbital_elements_to_cartesian(circular, M_sun, &converted_pos, &converted_vel);
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.496e11, 1e3));
} const OrbitalElements recovered_circ =
cartesian_to_orbital_elements(converted_pos, converted_vel, M_sun);
SECTION("Elliptical orbit (e=0.5) preserves orbital shape") { REQUIRE_THAT(recovered_circ.eccentricity, WithinAbs(0.0, E_TOL));
OrbitalElements elements = { REQUIRE_THAT(recovered_circ.semi_major_axis, WithinAbs(r, A_TOL_LARGE));
.semi_major_axis = 1.0e11, REQUIRE(compare_vec3(pos_circ, converted_pos, A_TOL_LARGE));
.eccentricity = 0.5, REQUIRE(compare_vec3(vel_circ, converted_vel, V_TOL));
.true_anomaly = 0.8,
.inclination = 0.0, // Near-circular (e=0.001)
.longitude_of_ascending_node = 0.0, const OrbitalElements near_circ = make_elements(1.496e11, 0.001, 0.5, 0.0, 0.0, 0.0);
.argument_of_periapsis = 0.0 const OrbitalElements rec_near_circ = convert_and_recover(near_circ);
}; REQUIRE_THAT(rec_near_circ.eccentricity, WithinAbs(0.001, E_TOL));
REQUIRE_THAT(rec_near_circ.semi_major_axis, WithinAbs(1.496e11, A_TOL_LARGE));
Vec3 position, velocity;
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); // Elliptical (e=0.5)
const OrbitalElements elliptical = make_elements(1.0e11, 0.5, 0.8, 0.0, 0.0, 0.0);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); const OrbitalElements rec_elliptical = convert_and_recover(elliptical);
REQUIRE_THAT(rec_elliptical.eccentricity, WithinAbs(0.5, E_TOL));
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); REQUIRE_THAT(rec_elliptical.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6));
} // Highly elliptical (e=0.95)
const OrbitalElements high_ell = make_elements(1.0e11, 0.95, 0.1, 0.0, 0.0, 0.0);
SECTION("Highly elliptical orbit (e=0.95) preserves extreme eccentricity") { const OrbitalElements rec_high_ell = convert_and_recover(high_ell);
OrbitalElements elements = { REQUIRE_THAT(rec_high_ell.eccentricity, WithinAbs(0.95, E_TOL));
.semi_major_axis = 1.0e11, REQUIRE_THAT(rec_high_ell.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE));
.eccentricity = 0.95,
.true_anomaly = 0.1, // Near-parabolic (e=0.999)
.inclination = 0.0, const OrbitalElements near_par = make_elements(1.0e11, 0.999, 0.05, 0.0, 0.0, 0.0);
.longitude_of_ascending_node = 0.0, const OrbitalElements rec_near_par = convert_and_recover(near_par);
.argument_of_periapsis = 0.0 REQUIRE_THAT(rec_near_par.eccentricity, WithinAbs(0.999, E_TOL));
};
// Parabolic (e=1.0)
Vec3 position, velocity; OrbitalElements parabolic = {};
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); parabolic.semi_latus_rectum = 1.0e11;
parabolic.eccentricity = 1.0;
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); parabolic.true_anomaly = 0.5;
parabolic.inclination = 0.0;
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.95, 1e-3)); parabolic.longitude_of_ascending_node = 0.0;
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6)); parabolic.argument_of_periapsis = 0.0;
} const OrbitalElements rec_parabolic = convert_and_recover(parabolic);
REQUIRE_THAT(rec_parabolic.eccentricity, WithinAbs(1.0, E_TOL));
SECTION("Near-parabolic orbit (e=0.999) recovers near-escape trajectory") { REQUIRE_THAT(rec_parabolic.semi_latus_rectum, WithinAbs(1.0e11, A_TOL));
OrbitalElements elements = {
.semi_major_axis = 1.0e11, // Hyperbolic (e=2.0)
.eccentricity = 0.999, const OrbitalElements hyper = make_elements(-1.0e11, 2.0, 0.5, 0.0, 0.0, 0.0);
.true_anomaly = 0.05, const OrbitalElements rec_hyper = convert_and_recover(hyper);
.inclination = 0.0, REQUIRE_THAT(rec_hyper.eccentricity, WithinAbs(2.0, E_TOL));
.longitude_of_ascending_node = 0.0, REQUIRE_THAT(rec_hyper.semi_major_axis, WithinAbs(-1.0e11, A_TOL_LARGE));
.argument_of_periapsis = 0.0
}; // Highly hyperbolic (e=10.0)
const OrbitalElements high_hyper = make_elements(-1.0e10, 10.0, 0.8, 0.0, 0.0, 0.0);
Vec3 position, velocity; const OrbitalElements rec_high_hyper = convert_and_recover(high_hyper);
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); REQUIRE_THAT(rec_high_hyper.eccentricity, WithinAbs(10.0, E_TOL));
REQUIRE_THAT(rec_high_hyper.semi_major_axis, WithinAbs(-1.0e10, A_TOL));
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); }
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.999, 1e-3)); SECTION("inclination: zero, polar, and retrograde") {
} // Zero inclination (equatorial)
const OrbitalElements eq = make_elements(1.0e11, 0.3, 0.5, 0.0, 0.0, 0.0);
SECTION("Parabolic orbit (e=1.0) recovers escape trajectory") { const OrbitalElements rec_eq = convert_and_recover(eq);
OrbitalElements elements = { REQUIRE_THAT(rec_eq.inclination, WithinAbs(0.0, ANG_TOL));
.semi_latus_rectum = 1.0e11, REQUIRE_THAT(rec_eq.eccentricity, WithinAbs(0.3, E_TOL));
.eccentricity = 1.0,
.true_anomaly = 0.5, // 90-degree inclination (polar)
.inclination = 0.0, const OrbitalElements polar = make_elements(1.0e11, 0.2, 0.6, M_PI / 2.0, 0.5, 0.3);
.longitude_of_ascending_node = 0.0, const OrbitalElements rec_polar = convert_and_recover(polar);
.argument_of_periapsis = 0.0 REQUIRE_THAT(rec_polar.inclination, WithinAbs(M_PI / 2.0, ANG_TOL_COARSE));
}; REQUIRE_THAT(rec_polar.longitude_of_ascending_node, WithinAbs(0.5, ANG_TOL_COARSE));
REQUIRE_THAT(rec_polar.argument_of_periapsis, WithinAbs(0.3, ANG_TOL_COARSE));
Vec3 position, velocity;
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); // 180-degree inclination (retrograde)
const OrbitalElements retro = make_elements(1.0e11, 0.2, 0.6, M_PI, 0.5, 0.3);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); const OrbitalElements rec_retro = convert_and_recover(retro);
REQUIRE_THAT(rec_retro.inclination, WithinAbs(M_PI, ANG_TOL_COARSE));
REQUIRE_THAT(recovered.eccentricity, WithinAbs(1.0, 1e-2)); }
REQUIRE_THAT(recovered.semi_latus_rectum, WithinAbs(1.0e11, 1e3));
} SECTION("true anomaly at key orbital positions") {
struct nu_test {
SECTION("Hyperbolic orbit (e=2.0) preserves unbound trajectory") { double nu;
OrbitalElements elements = { double expected_nu;
.semi_major_axis = -1.0e11, const char* label;
.eccentricity = 2.0, };
.true_anomaly = 0.5, std::vector<nu_test> tests = {
.inclination = 0.0, {0.0, 0.0, "periapsis"},
.longitude_of_ascending_node = 0.0, {M_PI, M_PI, "apoapsis"},
.argument_of_periapsis = 0.0 {M_PI / 2.0, M_PI / 2.0, "quadrature +90"},
}; {-M_PI / 2.0, 3.0 * M_PI / 2.0, "quadrature -90"},
{3.0 * M_PI / 2.0, 3.0 * M_PI / 2.0, "quadrature +270"},
Vec3 position, velocity; {-3.0 * M_PI / 2.0, M_PI / 2.0, "quadrature -270"},
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); };
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); for (const auto& t : tests) {
const OrbitalElements elements = make_elements(1.0e11, 0.5, t.nu, 0.0, 0.0, 0.0);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(2.0, 1e-3)); const OrbitalElements recovered = convert_and_recover(elements);
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(-1.0e11, 1e6)); INFO("Test: " << t.label << " (input nu=" << t.nu << ")");
} REQUIRE_THAT(recovered.true_anomaly, WithinAbs(t.expected_nu, ANG_TOL));
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, E_TOL));
SECTION("Highly hyperbolic orbit (e=10.0) preserves extreme unbound trajectory") { }
OrbitalElements elements = {
.semi_major_axis = -1.0e10,
.eccentricity = 10.0,
.true_anomaly = 0.8,
.inclination = 0.0,
.longitude_of_ascending_node = 0.0,
.argument_of_periapsis = 0.0
};
Vec3 position, velocity;
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(10.0, 1e-3));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(-1.0e10, 1e8));
}
SECTION("Zero inclination (i=0) preserves equatorial orbit") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.3,
.true_anomaly = 0.5,
.inclination = 0.0,
.longitude_of_ascending_node = 0.0,
.argument_of_periapsis = 0.0
};
Vec3 position, velocity;
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(recovered.inclination, WithinAbs(0.0, 1e-6));
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.3, 1e-4));
}
SECTION("90-degree inclination (i=pi/2) preserves polar orbit") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.2,
.true_anomaly = 0.6,
.inclination = M_PI / 2.0,
.longitude_of_ascending_node = 0.5,
.argument_of_periapsis = 0.3
};
Vec3 position, velocity;
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(recovered.inclination, WithinAbs(M_PI / 2.0, 1e-4));
REQUIRE_THAT(recovered.longitude_of_ascending_node, WithinAbs(0.5, 1e-4));
REQUIRE_THAT(recovered.argument_of_periapsis, WithinAbs(0.3, 1e-4));
}
SECTION("180-degree inclination (i=pi) preserves retrograde orbit") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.2,
.true_anomaly = 0.6,
.inclination = M_PI,
.longitude_of_ascending_node = 0.5,
.argument_of_periapsis = 0.3
};
Vec3 position, velocity;
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(recovered.inclination, WithinAbs(M_PI, 1e-4));
}
SECTION("Periapsis (nu=0) recovers true anomaly correctly") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.5,
.true_anomaly = 0.0,
.inclination = 0.0,
.longitude_of_ascending_node = 0.0,
.argument_of_periapsis = 0.0
};
Vec3 position, velocity;
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(0.0, 1e-6));
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4));
}
SECTION("Apoapsis (nu=pi) recovers true anomaly correctly") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.5,
.true_anomaly = M_PI,
.inclination = 0.0,
.longitude_of_ascending_node = 0.0,
.argument_of_periapsis = 0.0
};
Vec3 position, velocity;
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI, 1e-6));
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4));
}
SECTION("Quadrature point nu=pi/2 (90 deg) preserves orbital elements") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.5,
.true_anomaly = M_PI / 2.0,
.inclination = 0.0,
.longitude_of_ascending_node = 0.0,
.argument_of_periapsis = 0.0
};
Vec3 position, velocity;
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, 1e-6));
}
SECTION("Quadrature point nu=-pi/2 (-90 deg) preserves orbital elements") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.5,
.true_anomaly = -M_PI / 2.0,
.inclination = 0.0,
.longitude_of_ascending_node = 0.0,
.argument_of_periapsis = 0.0
};
Vec3 position, velocity;
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(3.0 * M_PI / 2.0, 1e-6));
}
SECTION("Quadrature point nu=3pi/2 (270 deg) preserves orbital elements") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.5,
.true_anomaly = 3.0 * M_PI / 2.0,
.inclination = 0.0,
.longitude_of_ascending_node = 0.0,
.argument_of_periapsis = 0.0
};
Vec3 position, velocity;
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(3.0 * M_PI / 2.0, 1e-6));
}
SECTION("Quadrature point nu=-3pi/2 (-270 deg) preserves orbital elements") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.5,
.true_anomaly = -3.0 * M_PI / 2.0,
.inclination = 0.0,
.longitude_of_ascending_node = 0.0,
.argument_of_periapsis = 0.0
};
Vec3 position, velocity;
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, 1e-6));
}
SECTION("Quadrature point with high eccentricity (e=0.9) preserves accuracy") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.9,
.true_anomaly = M_PI / 2.0,
.inclination = 0.0,
.longitude_of_ascending_node = 0.0,
.argument_of_periapsis = 0.0
};
Vec3 position, velocity;
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.9, 1e-3));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e7));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, 1e-5));
}
SECTION("Quadrature point with low eccentricity (e=0.1) preserves accuracy") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.1,
.true_anomaly = M_PI / 2.0,
.inclination = 0.0,
.longitude_of_ascending_node = 0.0,
.argument_of_periapsis = 0.0
};
Vec3 position, velocity;
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.1, 1e-5));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e4));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, 1e-6));
} }
SECTION("Large true anomaly nu=5.0 rad (approx 286 deg) preserves accuracy") { SECTION("quadrature at various eccentricities") {
OrbitalElements elements = { struct e_test {
.semi_major_axis = 1.0e11, double e;
.eccentricity = 0.5,
.true_anomaly = 5.0,
.inclination = 0.0,
.longitude_of_ascending_node = 0.0,
.argument_of_periapsis = 0.0
}; };
std::vector<e_test> tests = {
Vec3 position, velocity; {0.9},
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); {0.1},
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(5.0, 1e-6));
}
SECTION("Large negative true anomaly nu=-5.0 rad (approx -286 deg) preserves accuracy") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.5,
.true_anomaly = -5.0,
.inclination = 0.0,
.longitude_of_ascending_node = 0.0,
.argument_of_periapsis = 0.0
}; };
Vec3 position, velocity; for (const auto& t : tests) {
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); const OrbitalElements elements = make_elements(1.0e11, t.e, M_PI / 2.0, 0.0, 0.0, 0.0);
const OrbitalElements recovered = convert_and_recover(elements);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); REQUIRE_THAT(recovered.eccentricity, WithinAbs(t.e, E_TOL));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE));
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, ANG_TOL));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6)); }
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(1.28318530717958623, 1e-6));
} }
SECTION("Very large true anomaly nu=10.0 rad (approx 573 deg) preserves accuracy") { SECTION("large true anomaly values") {
OrbitalElements elements = { struct large_nu_test {
.semi_major_axis = 1.0e11, double nu;
.eccentricity = 0.5, double expected_nu;
.true_anomaly = 10.0, const char* label;
.inclination = 0.0,
.longitude_of_ascending_node = 0.0,
.argument_of_periapsis = 0.0
}; };
std::vector<large_nu_test> tests = {
Vec3 position, velocity; {5.0, 5.0, "nu=5.0"},
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); {-5.0, 1.28318530717958623, "nu=-5.0"},
{10.0, 10.0 - 2.0 * M_PI, "nu=10.0"},
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e5));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(10.0 - 2.0 * M_PI, 1e-5));
}
SECTION("Quadrature point with 3D orientation preserves all elements") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.5,
.true_anomaly = M_PI / 2.0,
.inclination = M_PI / 3.0,
.longitude_of_ascending_node = M_PI / 4.0,
.argument_of_periapsis = M_PI / 6.0
}; };
Vec3 position, velocity; for (const auto& t : tests) {
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); const OrbitalElements elements = make_elements(1.0e11, 0.5, t.nu, 0.0, 0.0, 0.0);
const OrbitalElements recovered = convert_and_recover(elements);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); INFO("Test: " << t.label);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, E_TOL));
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6)); REQUIRE_THAT(recovered.true_anomaly, WithinAbs(t.expected_nu, ANG_TOL));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, 1e-5)); }
REQUIRE_THAT(recovered.inclination, WithinAbs(M_PI / 3.0, 1e-4));
REQUIRE_THAT(recovered.longitude_of_ascending_node, WithinAbs(M_PI / 4.0, 1e-4));
REQUIRE_THAT(recovered.argument_of_periapsis, WithinAbs(M_PI / 6.0, 1e-4));
} }
SECTION("Multiple quadrature points in sequence maintain accuracy") { SECTION("3D orientation with quadrature point") {
double true_anomalies[] = {0.0, M_PI/4.0, M_PI/2.0, 3.0*M_PI/4.0, M_PI}; const OrbitalElements elements = make_elements(1.0e11, 0.5, M_PI / 2.0,
M_PI / 3.0, M_PI / 4.0, M_PI / 6.0);
for (int i = 0; i < 5; i++) { const OrbitalElements recovered = convert_and_recover(elements);
OrbitalElements elements = { REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, E_TOL));
.semi_major_axis = 1.0e11, REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE));
.eccentricity = 0.5, REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, ANG_TOL));
.true_anomaly = true_anomalies[i], REQUIRE_THAT(recovered.inclination, WithinAbs(M_PI / 3.0, ANG_TOL_COARSE));
.inclination = 0.0, REQUIRE_THAT(recovered.longitude_of_ascending_node, WithinAbs(M_PI / 4.0, ANG_TOL_COARSE));
.longitude_of_ascending_node = 0.0, REQUIRE_THAT(recovered.argument_of_periapsis, WithinAbs(M_PI / 6.0, ANG_TOL_COARSE));
.argument_of_periapsis = 0.0 }
};
SECTION("multiple true anomaly points in sequence") {
Vec3 position, velocity; std::array<double, 5> true_anomalies = {0.0, M_PI / 4.0, M_PI / 2.0,
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); 3.0 * M_PI / 4.0, M_PI};
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); for (double nu : true_anomalies) {
const OrbitalElements elements = make_elements(1.0e11, 0.5, nu, 0.0, 0.0, 0.0);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); const OrbitalElements recovered = convert_and_recover(elements);
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6)); REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, E_TOL));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(true_anomalies[i], 1e-6)); REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(nu, ANG_TOL));
} }
} }
SECTION("Hyperbolic orbit at quadrature point nu=pi/2") { SECTION("hyperbolic orbit at quadrature point") {
OrbitalElements elements = { const OrbitalElements elements = make_elements(-1.0e11, 2.0, M_PI / 2.0, 0.0, 0.0, 0.0);
.semi_major_axis = -1.0e11, const OrbitalElements recovered = convert_and_recover(elements);
.eccentricity = 2.0, REQUIRE_THAT(recovered.eccentricity, WithinAbs(2.0, E_TOL));
.true_anomaly = M_PI / 2.0, REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(-1.0e11, A_TOL_LARGE));
.inclination = 0.0, REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, ANG_TOL));
.longitude_of_ascending_node = 0.0,
.argument_of_periapsis = 0.0
};
Vec3 position, velocity;
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity);
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(2.0, 1e-3));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(-1.0e11, 1e6));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, 1e-5));
} }
} }

240
tests/test_cartesian_to_elements_basic.cpp

@ -1,190 +1,84 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h" #include "../src/physics.h"
#include "../src/orbital_mechanics.h" #include "../src/orbital_mechanics.h"
#include "../src/simulation.h" #include "../src/simulation.h"
#include "../src/config_loader.h" #include "../src/config_loader.h"
#include "../src/test_utilities.h"
#include <cmath> #include <cmath>
const double POSITION_TOLERANCE = 1.0e6; using Catch::Matchers::WithinAbs;
const double VELOCITY_TOLERANCE = 10.0;
const double ELEMENT_TOLERANCE = 1.0e-6;
TEST_CASE("Round-trip conversion: orbital elements → state vectors → orbital elements", "[cartesian][elements][roundtrip]") { SCENARIO("Cartesian ↔ orbital elements round-trip conversion",
"[cartesian][elements][roundtrip]") {
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
const double parent_mass = 5.972e24;
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP); SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_cartesian_to_elements_basic.toml"));
Spacecraft* craft = &sim->spacecraft[0];
OrbitalElements original_elements = craft->orbit;
Vec3 position_from_elements;
Vec3 velocity_from_elements;
orbital_elements_to_cartesian(original_elements, sim->bodies[0].mass, &position_from_elements, &velocity_from_elements);
INFO("Original orbital elements:");
INFO(" semi_major_axis: " << original_elements.semi_major_axis << " m");
INFO(" eccentricity: " << original_elements.eccentricity);
INFO(" true_anomaly: " << original_elements.true_anomaly << " rad");
INFO(" inclination: " << original_elements.inclination << " rad");
INFO(" longitude_of_ascending_node: " << original_elements.longitude_of_ascending_node << " rad");
INFO(" argument_of_periapsis: " << original_elements.argument_of_periapsis << " rad");
INFO("State vectors from orbital elements:");
INFO(" position: (" << position_from_elements.x << ", " << position_from_elements.y << ", " << position_from_elements.z << ") m");
INFO(" velocity: (" << velocity_from_elements.x << ", " << velocity_from_elements.y << ", " << velocity_from_elements.z << ") m/s");
OrbitalElements converted_elements = cartesian_to_orbital_elements(position_from_elements, velocity_from_elements, sim->bodies[0].mass);
INFO("Converted orbital elements:");
INFO(" semi_major_axis: " << converted_elements.semi_major_axis << " m");
INFO(" eccentricity: " << converted_elements.eccentricity);
INFO(" true_anomaly: " << converted_elements.true_anomaly << " rad");
INFO(" inclination: " << converted_elements.inclination << " rad");
INFO(" longitude_of_ascending_node: " << converted_elements.longitude_of_ascending_node << " rad");
INFO(" argument_of_periapsis: " << converted_elements.argument_of_periapsis << " rad");
double semi_major_error = fabs(converted_elements.semi_major_axis - original_elements.semi_major_axis);
double eccentricity_error = fabs(converted_elements.eccentricity - original_elements.eccentricity);
double inclination_error = fabs(converted_elements.inclination - original_elements.inclination);
INFO("Semi-major axis error: " << semi_major_error << " m");
INFO("Eccentricity error: " << eccentricity_error);
INFO("Inclination error: " << inclination_error << " rad");
REQUIRE(semi_major_error < fabs(original_elements.semi_major_axis) * ELEMENT_TOLERANCE);
REQUIRE(eccentricity_error < ELEMENT_TOLERANCE);
REQUIRE(inclination_error < ELEMENT_TOLERANCE);
destroy_simulation(sim);
}
TEST_CASE("Position magnitude preservation through conversion", "[cartesian][elements][position]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_cartesian_to_elements_basic.toml"));
Spacecraft* craft = &sim->spacecraft[0];
Vec3 position_1;
Vec3 velocity_1;
orbital_elements_to_cartesian(craft->orbit, sim->bodies[0].mass, &position_1, &velocity_1);
double radius_1 = vec3_magnitude(position_1);
INFO("Original radius: " << radius_1 << " m");
OrbitalElements elements = cartesian_to_orbital_elements(position_1, velocity_1, sim->bodies[0].mass);
Vec3 position_2;
Vec3 velocity_2;
orbital_elements_to_cartesian(elements, sim->bodies[0].mass, &position_2, &velocity_2);
double radius_2 = vec3_magnitude(position_2);
INFO("Reconstructed radius: " << radius_2 << " m");
double radius_error = fabs(radius_2 - radius_1);
INFO("Radius error: " << radius_error << " m");
REQUIRE(radius_error < POSITION_TOLERANCE);
destroy_simulation(sim);
}
TEST_CASE("Velocity magnitude preservation through conversion", "[cartesian][elements][velocity]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_cartesian_to_elements_basic.toml"));
Spacecraft* craft = &sim->spacecraft[0];
Vec3 position_1;
Vec3 velocity_1;
orbital_elements_to_cartesian(craft->orbit, sim->bodies[0].mass, &position_1, &velocity_1);
double v_mag_1 = vec3_magnitude(velocity_1);
INFO("Original velocity magnitude: " << v_mag_1 << " m/s");
OrbitalElements elements = cartesian_to_orbital_elements(position_1, velocity_1, sim->bodies[0].mass);
Vec3 position_2;
Vec3 velocity_2;
orbital_elements_to_cartesian(elements, sim->bodies[0].mass, &position_2, &velocity_2);
double v_mag_2 = vec3_magnitude(velocity_2);
INFO("Reconstructed velocity magnitude: " << v_mag_2 << " m/s");
double velocity_error = fabs(v_mag_2 - v_mag_1);
INFO("Velocity error: " << velocity_error << " m/s");
REQUIRE(velocity_error < VELOCITY_TOLERANCE);
destroy_simulation(sim);
}
TEST_CASE("Semi-major axis accuracy", "[cartesian][elements][semi_major]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_cartesian_to_elements_basic.toml")); REQUIRE(load_system_config(sim, "tests/test_cartesian_to_elements_basic.toml"));
Spacecraft* craft = &sim->spacecraft[0]; Spacecraft* craft = &sim->spacecraft[0];
double expected_a = craft->orbit.semi_major_axis; const OrbitalElements& orig = craft->orbit;
Vec3 position; // Convert elements → state vectors
Vec3 velocity; Vec3 pos, vel;
orbital_elements_to_cartesian(craft->orbit, sim->bodies[0].mass, &position, &velocity); orbital_elements_to_cartesian(orig, parent_mass, &pos, &vel);
OrbitalElements elements = cartesian_to_orbital_elements(position, velocity, sim->bodies[0].mass); const double expected_r = vec3_magnitude(pos); // 7500000.0
const double expected_v = vec3_magnitude(vel); // 8928.484709...
double actual_a = elements.semi_major_axis;
// Round-trip: state vectors → elements
double a_error = fabs(actual_a - expected_a); const OrbitalElements recovered = cartesian_to_orbital_elements(pos, vel, parent_mass);
double relative_error = a_error / fabs(expected_a);
// Re-convert recovered elements → state vectors
INFO("Expected semi-major axis: " << expected_a << " m"); Vec3 pos2, vel2;
INFO("Actual semi-major axis: " << actual_a << " m"); orbital_elements_to_cartesian(recovered, parent_mass, &pos2, &vel2);
INFO("Absolute error: " << a_error << " m"); const double recovered_r = vec3_magnitude(pos2);
INFO("Relative error: " << relative_error * 100.0 << "%"); const double recovered_v = vec3_magnitude(vel2);
REQUIRE(relative_error < ELEMENT_TOLERANCE); SECTION("elements round-trip: semi-major axis") {
const double da = fabs(recovered.semi_major_axis - orig.semi_major_axis);
destroy_simulation(sim); INFO("Original a: " << orig.semi_major_axis);
} INFO("Recovered a: " << recovered.semi_major_axis);
INFO("Error: " << da << " m");
TEST_CASE("Eccentricity accuracy", "[cartesian][elements][eccentricity]") { REQUIRE_THAT(da, WithinAbs(0.0, A_TOL));
const double TIME_STEP = 60.0; }
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP); SECTION("elements round-trip: eccentricity") {
const double de = fabs(recovered.eccentricity - orig.eccentricity);
REQUIRE(load_system_config(sim, "tests/test_cartesian_to_elements_basic.toml")); INFO("Original e: " << orig.eccentricity);
INFO("Recovered e: " << recovered.eccentricity);
Spacecraft* craft = &sim->spacecraft[0]; INFO("Error: " << de);
REQUIRE_THAT(de, WithinAbs(0.0, E_TOL));
double expected_e = craft->orbit.eccentricity; }
Vec3 position; SECTION("elements round-trip: true anomaly") {
Vec3 velocity; const double dnu = fabs(recovered.true_anomaly - orig.true_anomaly);
orbital_elements_to_cartesian(craft->orbit, sim->bodies[0].mass, &position, &velocity); INFO("Original nu: " << orig.true_anomaly);
INFO("Recovered nu: " << recovered.true_anomaly);
OrbitalElements elements = cartesian_to_orbital_elements(position, velocity, sim->bodies[0].mass); INFO("Error: " << dnu);
REQUIRE_THAT(dnu, WithinAbs(0.0, ANG_TOL));
double actual_e = elements.eccentricity; }
double e_error = fabs(actual_e - expected_e); SECTION("elements round-trip: inclination") {
const double dinc = fabs(recovered.inclination - orig.inclination);
INFO("Expected eccentricity: " << expected_e); INFO("Original inc: " << orig.inclination);
INFO("Actual eccentricity: " << actual_e); INFO("Recovered inc: " << recovered.inclination);
INFO("Absolute error: " << e_error); INFO("Error: " << dinc);
REQUIRE_THAT(dinc, WithinAbs(0.0, ANG_TOL));
REQUIRE(e_error < ELEMENT_TOLERANCE); }
SECTION("radius preservation") {
INFO("Original r: " << expected_r);
INFO("Recovered r: " << recovered_r);
REQUIRE_THAT(recovered_r, WithinAbs(expected_r, R_TOL));
}
SECTION("velocity magnitude preservation") {
INFO("Original v: " << expected_v);
INFO("Recovered v: " << recovered_v);
REQUIRE_THAT(recovered_v, WithinAbs(expected_v, V_TOL));
}
destroy_simulation(sim); destroy_simulation(sim);
} }

20
tests/test_cartesian_to_elements_basic.toml

@ -1,5 +1,4 @@
# Test Configuration: Basic Elliptical Orbit # Basic elliptical orbit: a=15000km, e=0.5, zero inclination
# Moderate eccentricity, zero inclination for testing Cartesian ↔ orbital elements conversion
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
@ -7,21 +6,10 @@ mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = -1 parent_index = -1
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[spacecraft]] [[spacecraft]]
name = "Test_Spacecraft" name = "Test_Spacecraft"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = 1.5e7, eccentricity = 0.5, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 1.5e7,
eccentricity = 0.5,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}

42
tests/test_energy.cpp

@ -1,34 +1,60 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h" #include "../src/physics.h"
#include "../src/simulation.h" #include "../src/simulation.h"
#include "../src/config_loader.h" #include "../src/config_loader.h"
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include <cmath> #include <cmath>
TEST_CASE("Energy conservation - Earth circular orbit", "[energy][rk4]") { using Catch::Matchers::WithinAbs;
SCENARIO("Energy conservation in circular orbit", "[energy][sanity]") {
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
const double DAYS_TO_SIMULATE = 10.0; const double DAYS_TO_SIMULATE = 10.0;
const double SECONDS_PER_DAY = 86400.0; const double SECONDS_PER_DAY = 86400.0;
SimulationState* sim = create_simulation(10, 0, 0, TIME_STEP); SimulationState* sim = create_simulation(2, 0, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_energy.toml")); REQUIRE(load_system_config(sim, "tests/test_energy.toml"));
double initial_energy = calculate_system_total_energy(sim); const double initial_energy = calculate_system_total_energy(sim);
double total_time = DAYS_TO_SIMULATE * SECONDS_PER_DAY; double total_time = DAYS_TO_SIMULATE * SECONDS_PER_DAY;
while (sim->time < total_time) { while (sim->time < total_time) {
update_simulation(sim); update_simulation(sim);
} }
double final_energy = calculate_system_total_energy(sim); const double final_energy = calculate_system_total_energy(sim);
double energy_drift_percent = fabs((final_energy - initial_energy) / initial_energy) * 100.0; const double energy_drift = fabs(final_energy - initial_energy) / fabs(initial_energy);
INFO("Initial energy: " << initial_energy << " J"); INFO("Initial energy: " << initial_energy << " J");
INFO("Final energy: " << final_energy << " J"); INFO("Final energy: " << final_energy << " J");
INFO("Energy drift: " << energy_drift_percent << "%"); INFO("Relative drift: " << energy_drift << " (fraction)");
REQUIRE_THAT(energy_drift, WithinAbs(0.0, 1e-12));
destroy_simulation(sim);
}
SCENARIO("Orbit direction for zero inclination", "[direction][sanity]") {
const double TIME_STEP = 60.0;
const int STEPS = 1440; // 1 day at 60s steps
SimulationState* sim = create_simulation(2, 0, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_energy.toml"));
CelestialBody* sun = &sim->bodies[0];
CelestialBody* earth = &sim->bodies[1];
Vec3 initial_rel = vec3_sub(earth->global_position, sun->global_position);
const double theta_start = atan2(initial_rel.y, initial_rel.x);
for (int i = 0; i < STEPS; i++) update_simulation(sim);
Vec3 final_rel = vec3_sub(earth->global_position, sun->global_position);
const double delta = atan2(final_rel.y, final_rel.x) - theta_start;
REQUIRE(energy_drift_percent < 5.0); INFO("Delta: " << delta << " rad");
REQUIRE_THAT(delta, WithinAbs(0.0172042841, 1e-6));
destroy_simulation(sim); destroy_simulation(sim);
} }

12
tests/test_energy.toml

@ -8,11 +8,7 @@ mass = 1.989e30
radius = 6.96e8 radius = 6.96e8
parent_index = -1 parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 } color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = { orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
@ -20,8 +16,4 @@ mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = 0 parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { orbit = { semi_major_axis = 1.496e11, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 1.496e11,
eccentricity = 0.0,
true_anomaly = 0.0
}

334
tests/test_extreme_eccentricity.cpp

@ -1,225 +1,205 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h" #include "../src/physics.h"
#include "../src/orbital_mechanics.h" #include "../src/orbital_mechanics.h"
#include "../src/simulation.h" #include "../src/simulation.h"
#include "../src/config_loader.h" #include "../src/config_loader.h"
#include "../src/test_utilities.h"
#include <cmath> #include <cmath>
#include <array>
const double VELOCITY_TOLERANCE = 1.0e-6; using Catch::Matchers::WithinAbs;
const double POSITION_TOLERANCE = 1.0e3;
TEST_CASE("Highly eccentric orbit (e=0.99)", "[extreme][eccentricity][high]") { SCENARIO("Extreme eccentricity orbital conversions and vis-viva accuracy",
"[extreme][eccentricity][high]") {
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
const double parent_mass = 5.972e24;
const double mu = G * parent_mass;
SimulationState* sim = create_simulation(10, 3, 0, TIME_STEP); SimulationState* sim = create_simulation(10, 3, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_extreme_eccentricity.toml")); REQUIRE(load_system_config(sim, "tests/test_extreme_eccentricity.toml"));
Spacecraft* high_e = &sim->spacecraft[0]; Spacecraft* high_e = &sim->spacecraft[0];
CelestialBody* earth = &sim->bodies[0];
INFO("Testing spacecraft with e=" << high_e->orbit.eccentricity);
Vec3 pos;
Vec3 vel;
orbital_elements_to_cartesian(high_e->orbit, earth->mass, &pos, &vel);
double r = vec3_magnitude(pos);
double v = vec3_magnitude(vel);
double expected_r_perigee = high_e->orbit.semi_major_axis * (1.0 - high_e->orbit.eccentricity);
double expected_r_apogee = high_e->orbit.semi_major_axis * (1.0 + high_e->orbit.eccentricity);
INFO("Semi-major axis: " << high_e->orbit.semi_major_axis << " m");
INFO("Eccentricity: " << high_e->orbit.eccentricity);
INFO("Radius: " << r << " m");
INFO("Velocity: " << v << " m/s");
INFO("Expected perigee: " << expected_r_perigee << " m");
INFO("Expected apogee: " << expected_r_apogee << " m");
REQUIRE(r >= expected_r_perigee * 0.9);
REQUIRE(r <= expected_r_apogee * 1.1);
REQUIRE(v > 0.0);
destroy_simulation(sim);
}
TEST_CASE("Near-parabolic orbit (e=0.9999)", "[extreme][eccentricity][near_parabolic]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 3, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_extreme_eccentricity.toml"));
Spacecraft* near_parabolic = &sim->spacecraft[1]; Spacecraft* near_parabolic = &sim->spacecraft[1];
CelestialBody* earth = &sim->bodies[0];
INFO("Testing spacecraft with e=" << near_parabolic->orbit.eccentricity);
Vec3 pos_perigee;
Vec3 vel_perigee;
near_parabolic->orbit.true_anomaly = 0.0;
orbital_elements_to_cartesian(near_parabolic->orbit, earth->mass, &pos_perigee, &vel_perigee);
double r_perigee = vec3_magnitude(pos_perigee);
double v_perigee = vec3_magnitude(vel_perigee);
Vec3 pos_apogee;
Vec3 vel_apogee;
near_parabolic->orbit.true_anomaly = M_PI;
orbital_elements_to_cartesian(near_parabolic->orbit, earth->mass, &pos_apogee, &vel_apogee);
double r_apogee = vec3_magnitude(pos_apogee);
double v_apogee = vec3_magnitude(vel_apogee);
double expected_r_perigee = near_parabolic->orbit.semi_major_axis * (1.0 - near_parabolic->orbit.eccentricity);
double expected_r_apogee = near_parabolic->orbit.semi_major_axis * (1.0 + near_parabolic->orbit.eccentricity);
INFO("Perigee:");
INFO(" Radius: " << r_perigee << " m (expected: " << expected_r_perigee << " m)");
INFO(" Velocity: " << v_perigee << " m/s");
INFO("Apogee:");
INFO(" Radius: " << r_apogee << " m (expected: " << expected_r_apogee << " m)");
INFO(" Velocity: " << v_apogee << " m/s");
double r_perigee_error = fabs(r_perigee - expected_r_perigee);
double r_apogee_error = fabs(r_apogee - expected_r_apogee);
REQUIRE(r_perigee_error < POSITION_TOLERANCE);
REQUIRE(r_apogee_error < POSITION_TOLERANCE);
REQUIRE(v_perigee > v_apogee);
REQUIRE(r_apogee > r_perigee);
destroy_simulation(sim);
}
TEST_CASE("Near-parabolic boundary (e=1.0001)", "[extreme][eccentricity][boundary]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 3, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_extreme_eccentricity.toml"));
Spacecraft* hyperbolic = &sim->spacecraft[2]; Spacecraft* hyperbolic = &sim->spacecraft[2];
CelestialBody* earth = &sim->bodies[0];
INFO("Testing spacecraft with e=" << hyperbolic->orbit.eccentricity);
Vec3 pos; // Precomputed analytical values for spacecraft 0 (a=6.5e8, e=0.99)
Vec3 vel; const double a0 = high_e->orbit.semi_major_axis;
orbital_elements_to_cartesian(hyperbolic->orbit, earth->mass, &pos, &vel); const double e0 = high_e->orbit.eccentricity;
const double expected_r_peri0 = a0 * (1.0 - e0); // 6.5e6
const double expected_r_apo0 = a0 * (1.0 + e0); // 1.2935e9
// Precomputed analytical values for spacecraft 1 (a=7.0e8, e=0.99)
const double a1 = near_parabolic->orbit.semi_major_axis;
const double e1 = near_parabolic->orbit.eccentricity;
const double expected_r_peri1 = a1 * (1.0 - e1); // 7.0e6
const double expected_r_apo1 = a1 * (1.0 + e1); // 1.393e9
// Precomputed analytical values for spacecraft 2 (e=1.05)
const double e2 = hyperbolic->orbit.eccentricity;
const double max_nu_hyperbolic = acos(-1.0 / e2); // ~2.8317 rad
// Helper: convert elements to cartesian and check vis-viva consistency
auto check_visviva = [&](const OrbitalElements& orbit, double r, double v) {
double expected_v_sq = mu * (2.0 / r - 1.0 / orbit.semi_major_axis);
REQUIRE(expected_v_sq > 0.0);
const double expected_v = sqrt(expected_v_sq);
const double rel_err = fabs(v - expected_v) / expected_v;
INFO("v=" << v << " m/s, v_exp=" << expected_v << " m/s, rel_err=" << rel_err);
REQUIRE_THAT(rel_err, WithinAbs(0.0, REL_TOL));
};
// Helper: convert elements to cartesian at given true anomaly
auto convert_at_nu = [&](Spacecraft* craft, double nu) {
craft->orbit.true_anomaly = nu;
orbital_elements_to_cartesian(craft->orbit, parent_mass, &craft->local_position, &craft->local_velocity);
};
// Helper: round-trip check
auto roundtrip = [&](double a, double e, double nu) {
OrbitalElements elements = {};
elements.semi_major_axis = a;
elements.eccentricity = e;
elements.true_anomaly = nu;
Vec3 pos, vel;
orbital_elements_to_cartesian(elements, parent_mass, &pos, &vel);
OrbitalElements recovered = cartesian_to_orbital_elements(pos, vel, parent_mass);
return recovered;
};
SECTION("highly elliptical: periapsis radius = a*(1-e)") {
convert_at_nu(high_e, 0.0);
const double r = vec3_magnitude(high_e->local_position);
const double v = vec3_magnitude(high_e->local_velocity);
INFO("r=" << r << " m, expected=" << expected_r_peri0 << " m");
INFO("v=" << v << " m/s");
REQUIRE_THAT(r, WithinAbs(expected_r_peri0, R_TOL));
REQUIRE_THAT(v, WithinAbs(11046.701562, V_TOL));
check_visviva(high_e->orbit, r, v);
// Round-trip eccentricity accuracy
const OrbitalElements recovered = roundtrip(a0, e0, 0.0);
INFO("e_recovered=" << recovered.eccentricity << ", error=" << fabs(recovered.eccentricity - e0));
REQUIRE_THAT(recovered.eccentricity, WithinAbs(e0, E_TOL));
}
double r = vec3_magnitude(pos); SECTION("highly elliptical: apoapsis radius = a*(1+e)") {
double v = vec3_magnitude(vel); convert_at_nu(high_e, M_PI);
const double r = vec3_magnitude(high_e->local_position);
const double v = vec3_magnitude(high_e->local_velocity);
double mu = G * earth->mass; INFO("r=" << r << " m, expected=" << expected_r_apo0 << " m");
double a = hyperbolic->orbit.semi_major_axis; INFO("v=" << v << " m/s");
double escape_velocity = sqrt(2.0 * mu / r);
double circular_velocity = sqrt(mu / r);
INFO("Radius: " << r << " m"); REQUIRE_THAT(r, WithinAbs(expected_r_apo0, R_TOL));
INFO("Velocity: " << v << " m/s"); REQUIRE_THAT(v, WithinAbs(55.511063, V_TOL));
INFO("Escape velocity: " << escape_velocity << " m/s"); check_visviva(high_e->orbit, r, v);
INFO("Circular velocity: " << circular_velocity << " m/s"); }
INFO("Semi-major axis: " << a << " m");
double expected_v_squared = mu * (2.0 / r - 1.0 / a); SECTION("near-parabolic: periapsis radius") {
double expected_v = sqrt(expected_v_squared); convert_at_nu(near_parabolic, 0.0);
const double r = vec3_magnitude(near_parabolic->local_position);
const double v = vec3_magnitude(near_parabolic->local_velocity);
double v_error = fabs(v - expected_v); INFO("r=" << r << " m, expected=" << expected_r_peri1 << " m");
double relative_error = v_error / expected_v; INFO("v=" << v << " m/s");
INFO("Expected velocity: " << expected_v << " m/s"); REQUIRE_THAT(r, WithinAbs(expected_r_peri1, R_TOL));
INFO("Velocity error: " << v_error << " m/s (" << relative_error * 100.0 << "%)"); REQUIRE_THAT(v, WithinAbs(10644.867979, V_TOL));
check_visviva(near_parabolic->orbit, r, v);
}
REQUIRE(relative_error < VELOCITY_TOLERANCE); SECTION("near-parabolic: apoapsis radius") {
REQUIRE(v > escape_velocity * 0.9); convert_at_nu(near_parabolic, M_PI);
REQUIRE(a < 0.0); const double r = vec3_magnitude(near_parabolic->local_position);
const double v = vec3_magnitude(near_parabolic->local_velocity);
destroy_simulation(sim); INFO("r=" << r << " m, expected=" << expected_r_apo1 << " m");
} INFO("v=" << v << " m/s");
TEST_CASE("Velocity magnitude accuracy for extreme eccentricities", "[extreme][eccentricity][velocity]") { REQUIRE_THAT(r, WithinAbs(expected_r_apo1, R_TOL));
const double TIME_STEP = 60.0; REQUIRE_THAT(v, WithinAbs(53.491799, V_TOL));
check_visviva(near_parabolic->orbit, r, v);
}
SimulationState* sim = create_simulation(10, 3, 0, TIME_STEP); SECTION("near-parabolic: velocity at periapsis and apoapsis") {
near_parabolic->orbit.true_anomaly = 0.0;
Vec3 dummy, vel_peri;
orbital_elements_to_cartesian(near_parabolic->orbit, parent_mass, &dummy, &vel_peri);
const double v_peri = vec3_magnitude(vel_peri);
REQUIRE(load_system_config(sim, "tests/test_extreme_eccentricity.toml")); near_parabolic->orbit.true_anomaly = M_PI;
Vec3 vel_apo;
orbital_elements_to_cartesian(near_parabolic->orbit, parent_mass, &dummy, &vel_apo);
const double v_apo = vec3_magnitude(vel_apo);
CelestialBody* earth = &sim->bodies[0]; INFO("v_peri=" << v_peri << " m/s, v_apo=" << v_apo << " m/s");
REQUIRE_THAT(v_peri, WithinAbs(10644.867979, V_TOL));
REQUIRE_THAT(v_apo, WithinAbs(53.491799, V_TOL));
}
for (int i = 0; i < sim->craft_count; i++) { SECTION("hyperbolic: velocity matches vis-viva") {
Spacecraft* craft = &sim->spacecraft[i]; convert_at_nu(hyperbolic, 0.0);
const double r = vec3_magnitude(hyperbolic->local_position);
const double v = vec3_magnitude(hyperbolic->local_velocity);
INFO("Spacecraft " << i << ": e=" << craft->orbit.eccentricity); INFO("r=" << r << " m");
INFO("v=" << v << " m/s");
double true_anomalies[] = {0.0, M_PI / 2.0, M_PI, 3.0 * M_PI / 2.0}; REQUIRE_THAT(v, WithinAbs(11211.998050, V_TOL));
}
for (int j = 0; j < 4; j++) { SECTION("hyperbolic: true anomaly limits") {
double nu = true_anomalies[j]; INFO("max_nu=" << max_nu_hyperbolic << " rad (±" << max_nu_hyperbolic * 180.0 / M_PI << "°)");
// For hyperbolic orbits (e > 1), skip invalid true anomalies // pi and 3pi/2 should be outside hyperbolic range
// Valid range: |ν| < arccos(-1/e) const double pi = M_PI;
if (craft->orbit.eccentricity > 1.0) { const double three_pi_half = 3.0 * M_PI / 2.0;
double max_nu = acos(-1.0 / craft->orbit.eccentricity); INFO("pi=" << pi << " rad, exceeds limit: " << (fabs(pi) >= max_nu_hyperbolic));
if (fabs(nu) >= max_nu) { INFO("3pi/2=" << three_pi_half << " rad, exceeds limit: " << (fabs(three_pi_half) >= max_nu_hyperbolic));
INFO(" ν=" << nu << " rad: skipped (exceeds hyperbolic limit ±" << max_nu << " rad)"); REQUIRE(fabs(pi) >= max_nu_hyperbolic);
continue; REQUIRE(fabs(three_pi_half) >= max_nu_hyperbolic);
} }
}
craft->orbit.true_anomaly = nu; SECTION("vis-viva accuracy at multiple true anomalies") {
const std::array<double, 4> true_anomalies = {0.0, M_PI / 2.0, M_PI, 3.0 * M_PI / 2.0};
Vec3 pos; for (int i = 0; i < sim->craft_count; i++) {
Vec3 vel; Spacecraft* craft = &sim->spacecraft[i];
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos, &vel); const double a = craft->orbit.semi_major_axis;
const double e = craft->orbit.eccentricity;
double r = vec3_magnitude(pos); INFO("Spacecraft " << i << ": e=" << e << ", a=" << a);
double v = vec3_magnitude(vel);
double a = craft->orbit.semi_major_axis; for (int j = 0; j < 4; j++) {
double mu = G * earth->mass; double nu = true_anomalies[j];
double expected_v_squared = mu * (2.0 / r - 1.0 / a); if (e > 1.0) {
if (fabs(nu) >= max_nu_hyperbolic) {
INFO(" nu=" << nu << " rad: SKIPPED (exceeds hyperbolic limit)");
continue;
}
}
if (expected_v_squared > 0.0) { craft->orbit.true_anomaly = nu;
double expected_v = sqrt(expected_v_squared); Vec3 pos, vel;
double v_error = fabs(v - expected_v); orbital_elements_to_cartesian(craft->orbit, parent_mass, &pos, &vel);
double relative_error = v_error / expected_v;
INFO(" ν=" << nu << " rad: v=" << v << " m/s, error=" << relative_error * 100.0 << "%"); const double r = vec3_magnitude(pos);
const double v = vec3_magnitude(vel);
REQUIRE(relative_error < VELOCITY_TOLERANCE * 10.0); double expected_v_sq = mu * (2.0 / r - 1.0 / a);
if (expected_v_sq > 0.0) {
const double expected_v = sqrt(expected_v_sq);
const double rel_err = fabs(v - expected_v) / expected_v;
INFO(" nu=" << nu << " rad: v=" << v << " m/s, rel_err=" << rel_err);
REQUIRE_THAT(rel_err, WithinAbs(0.0, REL_TOL));
}
} }
} }
} }
destroy_simulation(sim); destroy_simulation(sim);
} }
TEST_CASE("Period calculation (or lack thereof) for e≥1", "[extreme][eccentricity][period]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 3, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_extreme_eccentricity.toml"));
Spacecraft* high_e = &sim->spacecraft[0];
Spacecraft* near_parabolic = &sim->spacecraft[1];
Spacecraft* hyperbolic = &sim->spacecraft[2];
double a_e = high_e->orbit.semi_major_axis;
double a_near = near_parabolic->orbit.semi_major_axis;
double a_h = hyperbolic->orbit.semi_major_axis;
INFO("Highly eccentric (e=0.99): a=" << a_e << " m");
INFO("Near-parabolic (e=0.9999): a=" << a_near << " m");
INFO("Hyperbolic (e=1.0001): a=" << a_h << " m");
REQUIRE(a_e > 0.0);
REQUIRE(a_near > 0.0);
REQUIRE(a_h < 0.0);
destroy_simulation(sim);
}

40
tests/test_extreme_eccentricity.toml

@ -1,5 +1,4 @@
# Test Configuration: Extreme Eccentricity Orbits # Test Configuration: Extreme Eccentricity Orbits
# Tests near-parabolic and hyperbolic orbits
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
@ -7,47 +6,22 @@ mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = -1 parent_index = -1
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[spacecraft]] [[spacecraft]]
name = "Highly_Elliptical" name = "Highly_Elliptical"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = 6.5e8, eccentricity = 0.99, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 6.5e8,
eccentricity = 0.99,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
[[spacecraft]] [[spacecraft]]
name = "Near_Parabolic" name = "Near_Parabolic"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = 7.0e8, eccentricity = 0.99, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 7.0e8,
eccentricity = 0.99,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
[[spacecraft]] [[spacecraft]]
name = "Slightly_Hyperbolic" name = "Slightly_Hyperbolic"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = -1.3e8, eccentricity = 1.05, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = -1.3e8,
eccentricity = 1.05,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}

586
tests/test_extreme_orientation_mixed.cpp

@ -4,387 +4,319 @@
#include "../src/orbital_mechanics.h" #include "../src/orbital_mechanics.h"
#include "../src/simulation.h" #include "../src/simulation.h"
#include "../src/config_loader.h" #include "../src/config_loader.h"
#include "../src/test_utilities.h"
#include <cmath> #include <cmath>
#include <array>
using Catch::Matchers::WithinAbs; using Catch::Matchers::WithinAbs;
const double POSITION_TOLERANCE_METERS = 1.0e6; SCENARIO("Extreme orientation conversion accuracy and rotation matrix properties",
const double VELOCITY_TOLERANCE_MS = 1.0; "[extreme][orientation][mixed]") {
const double ELEMENT_TOLERANCE = 1e-6;
const double ANGULAR_TOLERANCE = 1e-4;
TEST_CASE("Rotation matrix behavior at extreme inclination/eccentricity combinations", "[extreme][orientation][mixed]") {
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 5, 0, TIME_STEP); const double parent_mass = 5.972e24;
const double mu = G * parent_mass;
REQUIRE(load_system_config(sim, "tests/test_extreme_orientation_mixed.toml"));
CelestialBody* earth = &sim->bodies[0];
SECTION("Multiple spacecraft with extreme orientation parameters") {
Spacecraft* high_inc_high_ecc = &sim->spacecraft[0];
Spacecraft* polar_moderate_ecc = &sim->spacecraft[1];
Spacecraft* near_parabolic = &sim->spacecraft[2];
SECTION("Position vectors are correctly oriented in 3D space") {
Vec3 pos1, vel1;
orbital_elements_to_cartesian(high_inc_high_ecc->orbit, earth->mass, &pos1, &vel1);
Vec3 pos2, vel2;
orbital_elements_to_cartesian(polar_moderate_ecc->orbit, earth->mass, &pos2, &vel2);
Vec3 pos3, vel3;
orbital_elements_to_cartesian(near_parabolic->orbit, earth->mass, &pos3, &vel3);
INFO("Spacecraft 1 (i=1.2 rad, e=0.85): pos=(" << pos1.x << ", " << pos1.y << ", " << pos1.z << ")");
INFO("Spacecraft 2 (i=1.4 rad, e=0.5): pos=(" << pos2.x << ", " << pos2.y << ", " << pos2.z << ")");
INFO("Spacecraft 3 (e=0.99, i=0.5 rad): pos=(" << pos3.x << ", " << pos3.y << ", " << pos3.z << ")");
double r1 = vec3_magnitude(pos1);
double r2 = vec3_magnitude(pos2);
double r3 = vec3_magnitude(pos3);
REQUIRE(r1 > 0.0);
REQUIRE(r2 > 0.0);
REQUIRE(r3 > 0.0);
double expected_r1 = high_inc_high_ecc->orbit.semi_major_axis * (1.0 - high_inc_high_ecc->orbit.eccentricity);
double expected_r2 = polar_moderate_ecc->orbit.semi_major_axis * (1.0 - polar_moderate_ecc->orbit.eccentricity);
double expected_r3 = near_parabolic->orbit.semi_major_axis * (1.0 - near_parabolic->orbit.eccentricity);
REQUIRE_THAT(r1, WithinAbs(expected_r1, POSITION_TOLERANCE_METERS));
REQUIRE_THAT(r2, WithinAbs(expected_r2, POSITION_TOLERANCE_METERS));
REQUIRE_THAT(r3, WithinAbs(expected_r3, POSITION_TOLERANCE_METERS));
}
SECTION("Velocity vectors are correctly computed") {
Vec3 pos1, vel1;
orbital_elements_to_cartesian(high_inc_high_ecc->orbit, earth->mass, &pos1, &vel1);
Vec3 pos2, vel2;
orbital_elements_to_cartesian(polar_moderate_ecc->orbit, earth->mass, &pos2, &vel2);
Vec3 pos3, vel3;
orbital_elements_to_cartesian(near_parabolic->orbit, earth->mass, &pos3, &vel3);
double v1 = vec3_magnitude(vel1);
double v2 = vec3_magnitude(vel2);
double v3 = vec3_magnitude(vel3);
INFO("Spacecraft 1: v=" << v1 << " m/s");
INFO("Spacecraft 2: v=" << v2 << " m/s");
INFO("Spacecraft 3: v=" << v3 << " m/s");
double mu = G * earth->mass;
double expected_v1_sq = mu * (2.0 / vec3_magnitude(pos1) - 1.0 / high_inc_high_ecc->orbit.semi_major_axis);
double expected_v2_sq = mu * (2.0 / vec3_magnitude(pos2) - 1.0 / polar_moderate_ecc->orbit.semi_major_axis);
double expected_v3_sq = mu * (2.0 / vec3_magnitude(pos3) - 1.0 / near_parabolic->orbit.semi_major_axis);
if (expected_v1_sq > 0.0) {
double expected_v1 = sqrt(expected_v1_sq);
REQUIRE_THAT(v1, WithinAbs(expected_v1, VELOCITY_TOLERANCE_MS * 100.0));
}
if (expected_v2_sq > 0.0) {
double expected_v2 = sqrt(expected_v2_sq);
REQUIRE_THAT(v2, WithinAbs(expected_v2, VELOCITY_TOLERANCE_MS * 100.0));
}
if (expected_v3_sq > 0.0) {
double expected_v3 = sqrt(expected_v3_sq);
REQUIRE_THAT(v3, WithinAbs(expected_v3, VELOCITY_TOLERANCE_MS * 100.0));
}
}
}
destroy_simulation(sim);
}
TEST_CASE("Longitude of ascending node (Ω) singularity handling", "[extreme][orientation][omega]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 5, 0, TIME_STEP); SimulationState* sim = create_simulation(10, 5, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_extreme_orientation_mixed.toml")); REQUIRE(load_system_config(sim, "tests/test_extreme_orientation_mixed.toml"));
CelestialBody* earth = &sim->bodies[0];
SECTION("Spacecraft with Ω = 0 (ascending node at reference direction)") {
Spacecraft* omega_zero = &sim->spacecraft[3];
SECTION("Rotation matrices handle Ω = 0 without numerical instability") {
Vec3 pos, vel;
orbital_elements_to_cartesian(omega_zero->orbit, earth->mass, &pos, &vel);
double r = vec3_magnitude(pos);
double expected_r = omega_zero->orbit.semi_major_axis * (1.0 - omega_zero->orbit.eccentricity);
INFO("Ω=0: radius=" << r << " m, expected=" << expected_r << " m");
INFO("Position: (" << pos.x << ", " << pos.y << ", " << pos.z << ")");
REQUIRE_THAT(r, WithinAbs(expected_r, POSITION_TOLERANCE_METERS));
REQUIRE(pos.x > 0.0);
}
SECTION("Velocity vector orientation is correct") {
Vec3 pos, vel;
orbital_elements_to_cartesian(omega_zero->orbit, earth->mass, &pos, &vel);
Vec3 angular_momentum = vec3_cross(pos, vel);
INFO("|h| = " << vec3_magnitude(angular_momentum)); Spacecraft* sc0 = &sim->spacecraft[0];
Spacecraft* sc1 = &sim->spacecraft[1];
REQUIRE(vec3_magnitude(angular_momentum) > 0.0); Spacecraft* sc2 = &sim->spacecraft[2];
} Spacecraft* sc3 = &sim->spacecraft[3];
Spacecraft* sc4 = &sim->spacecraft[4];
// Unique tolerances for this test
const double VDOT_TOL = 1e-3;
const double MAT_TOL = 1e-10;
// Precomputed periapsis radii
const double r_peri0 = sc0->orbit.semi_major_axis * (1.0 - sc0->orbit.eccentricity); // 7.5e6
const double r_peri1 = sc1->orbit.semi_major_axis * (1.0 - sc1->orbit.eccentricity); // 1.0e7
const double r_peri2 = sc2->orbit.semi_major_axis * (1.0 - sc2->orbit.eccentricity); // 7.0e6
const double r_peri3 = sc3->orbit.semi_major_axis * (1.0 - sc3->orbit.eccentricity); // 8.0e6
const double r_peri4 = sc4->orbit.semi_major_axis * (1.0 - sc4->orbit.eccentricity); // 8.0e6
// Precomputed apoapsis radii (elliptical only)
const double r_apo0 = sc0->orbit.semi_major_axis * (1.0 + sc0->orbit.eccentricity); // 9.25e7
const double r_apo1 = sc1->orbit.semi_major_axis * (1.0 + sc1->orbit.eccentricity); // 3.0e7
const double r_apo2 = sc2->orbit.semi_major_axis * (1.0 + sc2->orbit.eccentricity); // 1.393e9
// Helper: convert elements to cartesian at given true anomaly
auto convert_at_nu = [&](Spacecraft* craft, double nu) {
craft->orbit.true_anomaly = nu;
orbital_elements_to_cartesian(craft->orbit, parent_mass, &craft->local_position, &craft->local_velocity);
};
// Helper: vis-viva check
auto check_visviva = [&](double r, double v, double a) {
const double expected_v_sq = mu * (2.0 / r - 1.0 / a);
// Safety: expected_v_sq must be positive for sqrt (guaranteed for all elliptical orbits)
REQUIRE(expected_v_sq > 0.0);
const double expected_v = sqrt(expected_v_sq);
const double rel_err = fabs(v - expected_v) / expected_v;
INFO("v=" << v << " m/s, v_exp=" << expected_v << " m/s, rel_err=" << rel_err);
REQUIRE_THAT(rel_err, WithinAbs(0.0, REL_TOL));
};
// Helper: round-trip check
auto roundtrip = [&](double a, double e, double nu, double inc, double O, double w) {
OrbitalElements elements = {};
elements.semi_major_axis = a;
elements.eccentricity = e;
elements.true_anomaly = nu;
elements.inclination = inc;
elements.longitude_of_ascending_node = O;
elements.argument_of_periapsis = w;
Vec3 pos, vel;
orbital_elements_to_cartesian(elements, parent_mass, &pos, &vel);
OrbitalElements recovered = cartesian_to_orbital_elements(pos, vel, parent_mass);
return recovered;
};
SECTION("periapsis position for extreme orbits") {
convert_at_nu(sc0, 0.0);
const double r0 = vec3_magnitude(sc0->local_position);
const double v0 = vec3_magnitude(sc0->local_velocity);
INFO("sc0 (i=1.2, e=0.85): r=" << r0 << " m, v=" << v0 << " m/s");
REQUIRE_THAT(r0, WithinAbs(r_peri0, R_TOL));
check_visviva(r0, v0, sc0->orbit.semi_major_axis);
convert_at_nu(sc1, 0.0);
const double r1 = vec3_magnitude(sc1->local_position);
const double v1 = vec3_magnitude(sc1->local_velocity);
INFO("sc1 (i=1.4, e=0.5): r=" << r1 << " m, v=" << v1 << " m/s");
REQUIRE_THAT(r1, WithinAbs(r_peri1, R_TOL));
check_visviva(r1, v1, sc1->orbit.semi_major_axis);
convert_at_nu(sc2, 0.0);
const double r2 = vec3_magnitude(sc2->local_position);
const double v2 = vec3_magnitude(sc2->local_velocity);
INFO("sc2 (i=0.5, e=0.99): r=" << r2 << " m, v=" << v2 << " m/s");
REQUIRE_THAT(r2, WithinAbs(r_peri2, R_TOL));
check_visviva(r2, v2, sc2->orbit.semi_major_axis);
} }
destroy_simulation(sim); SECTION("velocity at apsides for extreme orbits") {
} // sc0 at periapsis
convert_at_nu(sc0, 0.0);
TEST_CASE("Argument of periapsis (ω) singularity handling", "[extreme][orientation][arg_peri]") { const double v0p = vec3_magnitude(sc0->local_velocity);
const double TIME_STEP = 60.0; convert_at_nu(sc0, M_PI);
SimulationState* sim = create_simulation(10, 5, 0, TIME_STEP); const double v0a = vec3_magnitude(sc0->local_velocity);
INFO("sc0: v_peri=" << v0p << " m/s, v_apo=" << v0a << " m/s");
REQUIRE(load_system_config(sim, "tests/test_extreme_orientation_mixed.toml")); REQUIRE_THAT(v0p, WithinAbs(9915.577056, V_TOL));
CelestialBody* earth = &sim->bodies[0]; REQUIRE_THAT(v0a, WithinAbs(803.965707, V_TOL));
SECTION("Spacecraft with ω = 0 (periapsis at ascending node)") { // sc1 at periapsis
Spacecraft* arg_peri_zero = &sim->spacecraft[4]; convert_at_nu(sc1, 0.0);
const double v1p = vec3_magnitude(sc1->local_velocity);
SECTION("Rotation matrices handle ω = 0 without numerical instability") { convert_at_nu(sc1, M_PI);
Vec3 pos, vel; const double v1a = vec3_magnitude(sc1->local_velocity);
orbital_elements_to_cartesian(arg_peri_zero->orbit, earth->mass, &pos, &vel); INFO("sc1: v_peri=" << v1p << " m/s, v_apo=" << v1a << " m/s");
REQUIRE_THAT(v1p, WithinAbs(7732.294575, V_TOL));
double r = vec3_magnitude(pos); REQUIRE_THAT(v1a, WithinAbs(2577.431525, V_TOL));
double expected_r = arg_peri_zero->orbit.semi_major_axis * (1.0 - arg_peri_zero->orbit.eccentricity);
// sc2 at periapsis
INFO("ω=0: radius=" << r << " m, expected=" << expected_r << " m"); convert_at_nu(sc2, 0.0);
INFO("Position: (" << pos.x << ", " << pos.y << ", " << pos.z << ")"); const double v2p = vec3_magnitude(sc2->local_velocity);
convert_at_nu(sc2, M_PI);
REQUIRE_THAT(r, WithinAbs(expected_r, POSITION_TOLERANCE_METERS)); const double v2a = vec3_magnitude(sc2->local_velocity);
} INFO("sc2: v_peri=" << v2p << " m/s, v_apo=" << v2a << " m/s");
REQUIRE_THAT(v2p, WithinAbs(10644.867979, V_TOL));
REQUIRE_THAT(v2a, WithinAbs(53.491799, V_TOL));
}
SECTION("True anomaly references are correct") { SECTION("apoapsis position for extreme orbits") {
double r = vec3_magnitude(arg_peri_zero->global_position); convert_at_nu(sc0, M_PI);
double expected_r_perigee = arg_peri_zero->orbit.semi_major_axis * (1.0 - arg_peri_zero->orbit.eccentricity); const double r0 = vec3_magnitude(sc0->local_position);
INFO("sc0: r_apo=" << r0 << " m, expected=" << r_apo0 << " m");
REQUIRE_THAT(r0, WithinAbs(r_apo0, R_TOL));
convert_at_nu(sc1, M_PI);
const double r1 = vec3_magnitude(sc1->local_position);
INFO("sc1: r_apo=" << r1 << " m, expected=" << r_apo1 << " m");
REQUIRE_THAT(r1, WithinAbs(r_apo1, R_TOL));
convert_at_nu(sc2, M_PI);
const double r2 = vec3_magnitude(sc2->local_position);
INFO("sc2: r_apo=" << r2 << " m, expected=" << r_apo2 << " m");
REQUIRE_THAT(r2, WithinAbs(r_apo2, R_TOL));
}
INFO("At ν=0 (perigee), r should equal r_perigee"); SECTION("vis-viva accuracy at multiple true anomalies") {
INFO("r=" << r << " m, r_perigee=" << expected_r_perigee << " m"); const std::array<double, 4> true_anomalies = {0.0, M_PI / 2.0, M_PI, 3.0 * M_PI / 2.0};
REQUIRE_THAT(r, WithinAbs(expected_r_perigee, POSITION_TOLERANCE_METERS)); for (int i = 0; i < 5; i++) {
} Spacecraft* craft = &sim->spacecraft[i];
const double a = craft->orbit.semi_major_axis;
const double e = craft->orbit.eccentricity;
SECTION("Testing at multiple true anomaly values") { INFO("Spacecraft " << i << ": e=" << e << ", a=" << a);
double true_anomalies[] = {0.0, M_PI / 2.0, M_PI, 3.0 * M_PI / 2.0};
for (int i = 0; i < 4; i++) {
arg_peri_zero->orbit.true_anomaly = true_anomalies[i];
for (int j = 0; j < 4; j++) {
double nu = true_anomalies[j];
craft->orbit.true_anomaly = nu;
Vec3 pos, vel; Vec3 pos, vel;
orbital_elements_to_cartesian(arg_peri_zero->orbit, earth->mass, &pos, &vel); orbital_elements_to_cartesian(craft->orbit, parent_mass, &pos, &vel);
double r = vec3_magnitude(pos);
double v = vec3_magnitude(vel);
INFO("ν=" << true_anomalies[i] << " rad: r=" << r << " m, v=" << v << " m/s"); const double r = vec3_magnitude(pos);
const double v = vec3_magnitude(vel);
REQUIRE(r > 0.0); const double expected_v_sq = mu * (2.0 / r - 1.0 / a);
REQUIRE(v > 0.0); // All spacecraft have elliptical orbits (e < 1), so vis-viva always yields v² > 0
const double expected_v = sqrt(expected_v_sq);
const double rel_err = fabs(v - expected_v) / expected_v;
INFO(" nu=" << nu << " rad: v=" << v << " m/s, rel_err=" << rel_err);
REQUIRE_THAT(rel_err, WithinAbs(0.0, REL_TOL));
} }
} }
} }
destroy_simulation(sim); SECTION("apsidal velocity orthogonality") {
} const std::array<double, 2> apsides = {0.0, M_PI};
TEST_CASE("Velocity vector orientation at perigee and apogee for extreme orbits", "[extreme][orientation][velocity]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 5, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_extreme_orientation_mixed.toml"));
CelestialBody* earth = &sim->bodies[0];
for (int craft_idx = 0; craft_idx < sim->craft_count; craft_idx++) {
Spacecraft* craft = &sim->spacecraft[craft_idx];
SECTION("Spacecraft " + std::to_string(craft_idx) + ": velocity orientation at apsides") { for (int i = 0; i < 5; i++) {
double true_anomalies[] = {0.0, M_PI}; Spacecraft* craft = &sim->spacecraft[i];
INFO("Spacecraft " << i << ": e=" << craft->orbit.eccentricity
for (int i = 0; i < 2; i++) { << ", i=" << craft->orbit.inclination << " rad");
craft->orbit.true_anomaly = true_anomalies[i];
for (int j = 0; j < 2; j++) {
craft->orbit.true_anomaly = apsides[j];
Vec3 pos, vel; Vec3 pos, vel;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos, &vel); orbital_elements_to_cartesian(craft->orbit, parent_mass, &pos, &vel);
double pos_dot_vel = vec3_dot(pos, vel);
Vec3 angular_momentum = vec3_cross(pos, vel);
INFO("Spacecraft " << craft_idx << " (e=" << craft->orbit.eccentricity << ", i=" << craft->orbit.inclination << ")"); const double pos_dot_vel = vec3_dot(pos, vel);
INFO("ν=" << true_anomalies[i] << " rad: pos·vel = " << pos_dot_vel); const double h = vec3_magnitude(vec3_cross(pos, vel));
REQUIRE_THAT(pos_dot_vel, WithinAbs(0.0, VELOCITY_TOLERANCE_MS * 1000.0)); INFO(" nu=" << apsides[j] << " rad: pos·vel=" << pos_dot_vel << ", |h|=" << h);
REQUIRE(vec3_magnitude(angular_momentum) > 0.0); REQUIRE_THAT(pos_dot_vel, WithinAbs(0.0, VDOT_TOL));
// Angular momentum must be non-zero for a valid orbit
REQUIRE(h > 0.0);
} }
} }
} }
destroy_simulation(sim); SECTION("Omega=0 singularity handling") {
} convert_at_nu(sc3, 0.0);
const double r = vec3_magnitude(sc3->local_position);
INFO("sc3 (Omega=0): r=" << r << " m");
INFO(" pos=(" << sc3->local_position.x << ", " << sc3->local_position.y << ", " << sc3->local_position.z << ")");
TEST_CASE("Velocity follows vis-viva equation for extreme orbits", "[extreme][orientation][visviva]") { Vec3 pos, vel;
const double TIME_STEP = 60.0; orbital_elements_to_cartesian(sc3->orbit, parent_mass, &pos, &vel);
SimulationState* sim = create_simulation(10, 5, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_extreme_orientation_mixed.toml")); // Rotation matrix at Omega=0 must produce a valid position in the x-y plane
CelestialBody* earth = &sim->bodies[0]; REQUIRE(pos.x > 0.0);
REQUIRE_THAT(r, WithinAbs(r_peri3, R_TOL));
for (int craft_idx = 0; craft_idx < sim->craft_count; craft_idx++) {
Spacecraft* craft = &sim->spacecraft[craft_idx];
SECTION("Spacecraft " + std::to_string(craft_idx) + ": vis-viva at multiple true anomalies") {
double true_anomalies[] = {0.0, M_PI / 2.0, M_PI, 3.0 * M_PI / 2.0};
for (int i = 0; i < 4; i++) {
craft->orbit.true_anomaly = true_anomalies[i];
Vec3 pos, vel;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos, &vel);
double r = vec3_magnitude(pos);
double v = vec3_magnitude(vel);
double a = craft->orbit.semi_major_axis;
double mu = G * earth->mass;
double expected_v_sq = mu * (2.0 / r - 1.0 / a);
if (expected_v_sq > 0.0) { const double h = vec3_magnitude(vec3_cross(pos, vel));
double expected_v = sqrt(expected_v_sq); INFO(" |h|=" << h);
double relative_error = fabs(v - expected_v) / expected_v; // Angular momentum must be non-zero for a valid orbit
REQUIRE(h > 0.0);
INFO("ν=" << true_anomalies[i] << " rad: v=" << v << " m/s, expected=" << expected_v << " m/s"); }
INFO("Relative error: " << relative_error * 100.0 << "%");
REQUIRE(relative_error < VELOCITY_TOLERANCE_MS * 10.0); SECTION("Arg_peri=0 singularity handling") {
} convert_at_nu(sc4, 0.0);
} const double r = vec3_magnitude(sc4->local_position);
INFO("sc4 (w=0): r=" << r << " m, expected=" << r_peri4 << " m");
REQUIRE_THAT(r, WithinAbs(r_peri4, R_TOL));
const std::array<double, 4> true_anomalies = {0.0, M_PI / 2.0, M_PI, 3.0 * M_PI / 2.0};
const std::array<double, 4> expected_r = {8.0e6, 1.44e7, 7.2e7, 1.44e7};
const std::array<double, 4> expected_v = {9470.088125, 6737.572312, 1052.232014, 6737.572312};
for (int j = 0; j < 4; j++) {
sc4->orbit.true_anomaly = true_anomalies[j];
Vec3 pos, vel;
orbital_elements_to_cartesian(sc4->orbit, parent_mass, &pos, &vel);
const double r_j = vec3_magnitude(pos);
const double v_j = vec3_magnitude(vel);
INFO(" nu=" << true_anomalies[j] << " rad: r=" << r_j << " m, v=" << v_j << " m/s");
REQUIRE_THAT(r_j, WithinAbs(expected_r[j], R_TOL));
REQUIRE_THAT(v_j, WithinAbs(expected_v[j], V_TOL));
} }
} }
destroy_simulation(sim); SECTION("round-trip conversion accuracy") {
} const std::array<Spacecraft*, 5> crafts = {sc0, sc1, sc2, sc3, sc4};
TEST_CASE("Round-trip conversion for extreme orientation parameters", "[extreme][orientation][round_trip]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 5, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_extreme_orientation_mixed.toml"));
CelestialBody* earth = &sim->bodies[0];
for (int craft_idx = 0; craft_idx < sim->craft_count; craft_idx++) { for (int i = 0; i < 5; i++) {
Spacecraft* craft = &sim->spacecraft[craft_idx]; const Spacecraft* craft = crafts[i];
const double a = craft->orbit.semi_major_axis;
const double e = craft->orbit.eccentricity;
const double inc = craft->orbit.inclination;
const double O = craft->orbit.longitude_of_ascending_node;
const double w = craft->orbit.argument_of_periapsis;
SECTION("Spacecraft " + std::to_string(craft_idx) + ": round-trip conversion") { INFO("Spacecraft " << i << ": " << craft->name);
Vec3 pos, vel;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos, &vel);
OrbitalElements recovered = cartesian_to_orbital_elements(pos, vel, earth->mass); const OrbitalElements recovered = roundtrip(a, e, 0.0, inc, O, w);
INFO("Spacecraft " << craft_idx << ": " << craft->name); INFO(" e: " << e << " -> " << recovered.eccentricity);
INFO(" Semi-major axis: " << craft->orbit.semi_major_axis << " -> " << recovered.semi_major_axis); INFO(" i: " << inc << " -> " << recovered.inclination);
INFO(" Eccentricity: " << craft->orbit.eccentricity << " -> " << recovered.eccentricity); INFO(" a: " << a << " -> " << recovered.semi_major_axis);
INFO(" Inclination: " << craft->orbit.inclination << " -> " << recovered.inclination);
INFO(" Ω: " << craft->orbit.longitude_of_ascending_node << " -> " << recovered.longitude_of_ascending_node);
INFO(" ω: " << craft->orbit.argument_of_periapsis << " -> " << recovered.argument_of_periapsis);
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(craft->orbit.semi_major_axis, fabs(craft->orbit.semi_major_axis) * 0.01)); REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(a, fabs(a) * 0.01));
REQUIRE_THAT(recovered.eccentricity, WithinAbs(craft->orbit.eccentricity, ELEMENT_TOLERANCE)); REQUIRE_THAT(recovered.eccentricity, WithinAbs(e, E_TOL));
REQUIRE_THAT(recovered.inclination, WithinAbs(craft->orbit.inclination, ANGULAR_TOLERANCE)); REQUIRE_THAT(recovered.inclination, WithinAbs(inc, ANG_TOL));
if (craft->orbit.longitude_of_ascending_node > 1e-6 || craft->orbit.longitude_of_ascending_node < -1e-6) { if (O > 1e-6 || O < -1e-6) {
REQUIRE_THAT(recovered.longitude_of_ascending_node, WithinAbs(craft->orbit.longitude_of_ascending_node, ANGULAR_TOLERANCE * 10.0)); REQUIRE_THAT(recovered.longitude_of_ascending_node, WithinAbs(O, ANG_TOL));
} }
if (craft->orbit.argument_of_periapsis > 1e-6 || craft->orbit.argument_of_periapsis < -1e-6) { if (w > 1e-6 || w < -1e-6) {
REQUIRE_THAT(recovered.argument_of_periapsis, WithinAbs(craft->orbit.argument_of_periapsis, ANGULAR_TOLERANCE * 10.0)); REQUIRE_THAT(recovered.argument_of_periapsis, WithinAbs(w, ANG_TOL));
} }
SECTION("Round-trip preserves position and velocity") { // Round-trip preserves position and velocity
Vec3 pos2, vel2; Vec3 pos, vel;
orbital_elements_to_cartesian(recovered, earth->mass, &pos2, &vel2); orbital_elements_to_cartesian(recovered, parent_mass, &pos, &vel);
double pos_error = vec3_magnitude(vec3_sub(pos, pos2));
double vel_error = vec3_magnitude(vec3_sub(vel, vel2));
INFO("Spacecraft " << craft_idx << ": " << craft->name); Vec3 pos2, vel2;
INFO(" Position error: " << pos_error << " m"); orbital_elements_to_cartesian(recovered, parent_mass, &pos2, &vel2);
INFO(" Velocity error: " << vel_error << " m/s");
REQUIRE_THAT(pos_error, WithinAbs(0.0, POSITION_TOLERANCE_METERS)); const double pos_err = vec3_magnitude(vec3_sub(pos, pos2));
REQUIRE_THAT(vel_error, WithinAbs(0.0, VELOCITY_TOLERANCE_MS * 1000.0)); const double vel_err = vec3_magnitude(vec3_sub(vel, vel2));
} INFO(" pos_err=" << pos_err << " m, vel_err=" << vel_err << " m/s");
REQUIRE_THAT(pos_err, WithinAbs(0.0, R_TOL));
REQUIRE_THAT(vel_err, WithinAbs(0.0, V_TOL));
} }
} }
destroy_simulation(sim); SECTION("rotation matrix orthogonality") {
} for (int i = 0; i < 5; i++) {
Spacecraft* craft = &sim->spacecraft[i];
TEST_CASE("Rotation matrix verification for extreme parameters", "[extreme][orientation][matrices]") { const double omega = craft->orbit.argument_of_periapsis;
const double TIME_STEP = 60.0; const double inc = craft->orbit.inclination;
SimulationState* sim = create_simulation(10, 5, 0, TIME_STEP); const double Omega = craft->orbit.longitude_of_ascending_node;
REQUIRE(load_system_config(sim, "tests/test_extreme_orientation_mixed.toml")); Mat3 R = mat3_rotation_orbital(omega, inc, Omega);
for (int craft_idx = 0; craft_idx < sim->craft_count; craft_idx++) { const Vec3 unit_x = {1.0, 0.0, 0.0};
Spacecraft* craft = &sim->spacecraft[craft_idx]; const Vec3 unit_y = {0.0, 1.0, 0.0};
const Vec3 unit_z = {0.0, 0.0, 1.0};
SECTION("Spacecraft " + std::to_string(craft_idx) + ": rotation matrix properties") {
double omega = craft->orbit.argument_of_periapsis; Vec3 rot_x = mat3_multiply_vec3(R, unit_x);
double i = craft->orbit.inclination; Vec3 rot_y = mat3_multiply_vec3(R, unit_y);
double Omega = craft->orbit.longitude_of_ascending_node; Vec3 rot_z = mat3_multiply_vec3(R, unit_z);
Mat3 R_orbital = mat3_rotation_orbital(omega, i, Omega); const double mag_x = vec3_magnitude(rot_x);
const double mag_y = vec3_magnitude(rot_y);
SECTION("Rotation matrix preserves vector magnitudes (orthogonal)") { const double mag_z = vec3_magnitude(rot_z);
Vec3 unit_x = {1.0, 0.0, 0.0};
Vec3 unit_y = {0.0, 1.0, 0.0}; const double xy_dot = vec3_dot(rot_x, rot_y);
Vec3 unit_z = {0.0, 0.0, 1.0}; const double yz_dot = vec3_dot(rot_y, rot_z);
const double xz_dot = vec3_dot(rot_x, rot_z);
Vec3 rot_x = mat3_multiply_vec3(R_orbital, unit_x);
Vec3 rot_y = mat3_multiply_vec3(R_orbital, unit_y); INFO("Spacecraft " << i << ": " << craft->name);
Vec3 rot_z = mat3_multiply_vec3(R_orbital, unit_z); INFO(" |R·x|=" << mag_x << ", |R·y|=" << mag_y << ", |R·z|=" << mag_z);
INFO(" (R·x)·(R·y)=" << xy_dot << ", (R·y)·(R·z)=" << yz_dot << ", (R·x)·(R·z)=" << xz_dot);
double mag_x = vec3_magnitude(rot_x);
double mag_y = vec3_magnitude(rot_y); REQUIRE_THAT(mag_x, WithinAbs(1.0, MAT_TOL));
double mag_z = vec3_magnitude(rot_z); REQUIRE_THAT(mag_y, WithinAbs(1.0, MAT_TOL));
REQUIRE_THAT(mag_z, WithinAbs(1.0, MAT_TOL));
INFO("Spacecraft " << craft_idx << ": " << craft->name); REQUIRE_THAT(xy_dot, WithinAbs(0.0, MAT_TOL));
INFO(" |R·x| = " << mag_x << " (expected 1.0)"); REQUIRE_THAT(yz_dot, WithinAbs(0.0, MAT_TOL));
INFO(" |R·y| = " << mag_y << " (expected 1.0)"); REQUIRE_THAT(xz_dot, WithinAbs(0.0, MAT_TOL));
INFO(" |R·z| = " << mag_z << " (expected 1.0)");
REQUIRE_THAT(mag_x, WithinAbs(1.0, 1e-10));
REQUIRE_THAT(mag_y, WithinAbs(1.0, 1e-10));
REQUIRE_THAT(mag_z, WithinAbs(1.0, 1e-10));
}
SECTION("Rotated vectors remain orthogonal") {
Vec3 unit_x = {1.0, 0.0, 0.0};
Vec3 unit_y = {0.0, 1.0, 0.0};
Vec3 unit_z = {0.0, 0.0, 1.0};
Vec3 rot_x = mat3_multiply_vec3(R_orbital, unit_x);
Vec3 rot_y = mat3_multiply_vec3(R_orbital, unit_y);
Vec3 rot_z = mat3_multiply_vec3(R_orbital, unit_z);
double xy_dot = vec3_dot(rot_x, rot_y);
double yz_dot = vec3_dot(rot_y, rot_z);
double xz_dot = vec3_dot(rot_x, rot_z);
INFO("Spacecraft " << craft_idx << ": " << craft->name);
INFO(" (R·x)·(R·y) = " << xy_dot);
INFO(" (R·y)·(R·z) = " << yz_dot);
INFO(" (R·x)·(R·z) = " << xz_dot);
REQUIRE_THAT(xy_dot, WithinAbs(0.0, 1e-10));
REQUIRE_THAT(yz_dot, WithinAbs(0.0, 1e-10));
REQUIRE_THAT(xz_dot, WithinAbs(0.0, 1e-10));
}
} }
} }

62
tests/test_extreme_orientation_mixed.toml

@ -1,88 +1,38 @@
# Test Configuration: Extreme Orientation Mixed Cases # Test Configuration: Extreme Orientation Mixed Cases
# Tests combined high inclination + high eccentricity orbital mechanics
# Tests singularity handling at Ω=0 and ω=0
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
mass = 5.972e24 mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = -1 parent_index = -1
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
# Test body 1: High inclination + high eccentricity
# i = 1.2 rad (68.8°), e = 0.85
[[spacecraft]] [[spacecraft]]
name = "High_Inc_High_Ecc" name = "High_Inc_High_Ecc"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = 5.0e7, eccentricity = 0.85, true_anomaly = 0.0, inclination = 1.2, longitude_of_ascending_node = 0.5, argument_of_periapsis = 0.3 }
semi_major_axis = 5.0e7,
eccentricity = 0.85,
true_anomaly = 0.0,
inclination = 1.2,
longitude_of_ascending_node = 0.5,
argument_of_periapsis = 0.3
}
# Test body 2: Very high inclination near polar + moderate eccentricity
# i = 1.4 rad (80°), e = 0.5
[[spacecraft]] [[spacecraft]]
name = "Polar_Moderate_Ecc" name = "Polar_Moderate_Ecc"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = 2.0e7, eccentricity = 0.5, true_anomaly = 0.0, inclination = 1.4, longitude_of_ascending_node = 1.0, argument_of_periapsis = 0.5 }
semi_major_axis = 2.0e7,
eccentricity = 0.5,
true_anomaly = 0.0,
inclination = 1.4,
longitude_of_ascending_node = 1.0,
argument_of_periapsis = 0.5
}
# Test body 3: High eccentricity near parabolic with moderate inclination
# e = 0.99, i = 0.5 rad (28.6°)
[[spacecraft]] [[spacecraft]]
name = "Near_Parabolic_Mod_Inc" name = "Near_Parabolic_Mod_Inc"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = 7.0e8, eccentricity = 0.99, true_anomaly = 0.0, inclination = 0.5, longitude_of_ascending_node = 0.8, argument_of_periapsis = 1.2 }
semi_major_axis = 7.0e8,
eccentricity = 0.99,
true_anomaly = 0.0,
inclination = 0.5,
longitude_of_ascending_node = 0.8,
argument_of_periapsis = 1.2
}
# Test body 4: Edge case near Ω singularity (Ω = 0) with high inclination/eccentricity
[[spacecraft]] [[spacecraft]]
name = "Omega_Zero" name = "Omega_Zero"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = 4.0e7, eccentricity = 0.8, true_anomaly = 0.0, inclination = 1.2, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.6 }
semi_major_axis = 4.0e7,
eccentricity = 0.8,
true_anomaly = 0.0,
inclination = 1.2,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.6
}
# Test body 5: Edge case near ω singularity (ω = 0) with high inclination/eccentricity
[[spacecraft]] [[spacecraft]]
name = "Arg_Peri_Zero" name = "Arg_Peri_Zero"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = 4.0e7, eccentricity = 0.8, true_anomaly = 0.0, inclination = 1.2, longitude_of_ascending_node = 0.7, argument_of_periapsis = 0.0 }
semi_major_axis = 4.0e7,
eccentricity = 0.8,
true_anomaly = 0.0,
inclination = 1.2,
longitude_of_ascending_node = 0.7,
argument_of_periapsis = 0.0
}

580
tests/test_extreme_timescales.cpp

@ -6,412 +6,320 @@
#include "../src/config_loader.h" #include "../src/config_loader.h"
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include <cmath> #include <cmath>
#include <limits>
const double CONVERGENCE_TOLERANCE = 1.0e-10; using Catch::Matchers::WithinAbs;
const int MAX_ITERATIONS = 50;
double calculate_orbital_period(double semi_major_axis, double parent_mass) { // Helper: propagate orbit for N full periods, return final pos/vel
double mu = G * parent_mass; static void propagate_n_periods(SimulationState* sim, int craft_idx, int parent_idx,
return 2.0 * M_PI * sqrt(pow(semi_major_axis, 3.0) / mu); int num_periods, double dt,
} Vec3& out_pos, Vec3& out_vel) {
const double parent_mass = sim->bodies[parent_idx].mass;
OrbitalElements current = sim->spacecraft[craft_idx].orbit;
double period = 2.0 * M_PI * sqrt(pow(current.semi_major_axis, 3.0) / (G * parent_mass));
double total_time = num_periods * period;
int steps = (int)(total_time / dt);
double calculate_orbital_energy(const Vec3& position, const Vec3& velocity, double parent_mass, double craft_mass) { for (int s = 0; s < steps; s++) {
double r = vec3_magnitude(position); current = propagate_orbital_elements(current, dt, parent_mass);
double v_squared = velocity.x * velocity.x + velocity.y * velocity.y + velocity.z * velocity.z; }
double kinetic = 0.5 * craft_mass * v_squared; orbital_elements_to_cartesian(current, parent_mass, &out_pos, &out_vel);
double potential = -G * craft_mass * parent_mass / r;
return kinetic + potential;
} }
TEST_CASE("Fast orbit - LEO (period ~92 minutes)", "[extreme][timescales][fast]") { // Helper: compute orbital energy from state vectors
const double TIME_STEP = 10.0; static double compute_energy(const Vec3& pos, const Vec3& vel,
const int NUM_ORBITS = 10; double craft_mass, double parent_mass) {
double r = vec3_magnitude(pos);
SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP); double v2 = vel.x * vel.x + vel.y * vel.y + vel.z * vel.z;
REQUIRE(load_system_config(sim, "tests/test_extreme_timescales.toml")); return 0.5 * craft_mass * v2 - G * craft_mass * parent_mass / r;
}
const int CRAFT_INDEX = 0;
const int PARENT_INDEX = 0;
Spacecraft* craft = &sim->spacecraft[CRAFT_INDEX];
CelestialBody* parent = &sim->bodies[PARENT_INDEX];
double expected_period = calculate_orbital_period(craft->orbit.semi_major_axis, parent->mass);
INFO("Expected LEO period: " << expected_period << " s (" << (expected_period / 60.0) << " minutes)");
Vec3 initial_pos, initial_vel;
orbital_elements_to_cartesian(craft->orbit, parent->mass, &initial_pos, &initial_vel);
double initial_energy = calculate_orbital_energy(initial_pos, initial_vel, parent->mass, craft->mass);
for (int orbit = 0; orbit < NUM_ORBITS; orbit++) {
double orbit_start_time = sim->time;
OrbitalElements propagated = craft->orbit;
while (sim->time < orbit_start_time + expected_period) {
propagated = propagate_orbital_elements(propagated, TIME_STEP, parent->mass);
sim->time += TIME_STEP;
}
Vec3 final_pos, final_vel;
orbital_elements_to_cartesian(propagated, parent->mass, &final_pos, &final_vel);
double final_energy = calculate_orbital_energy(final_pos, final_vel, parent->mass, craft->mass);
double energy_error = fabs(final_energy - initial_energy) / fabs(initial_energy);
double pos_error = vec3_magnitude(vec3_sub(final_pos, initial_pos));
INFO("Orbit " << orbit << " energy error: " << energy_error);
INFO("Orbit " << orbit << " position error: " << pos_error << " m");
REQUIRE_THAT(energy_error, Catch::Matchers::WithinAbs(0.0, 1e-9));
}
destroy_simulation(sim); // Helper: compute orbital period
static double compute_period(double semi_major_axis, double parent_mass) {
return 2.0 * M_PI * sqrt(pow(semi_major_axis, 3.0) / (G * parent_mass));
} }
TEST_CASE("Fast orbit - Mercury-like (period ~88 days)", "[extreme][timescales][fast]") { SCENARIO("Analytical propagation preserves energy across extreme timescales",
"[extreme][timescales]") {
const double TIME_STEP = 3600.0; const double TIME_STEP = 3600.0;
const double PERIOD_HOURS_TOL = 0.0002;
const double PROP_POS_TOL = 1e-4;
SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP); SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_extreme_timescales.toml")); REQUIRE(load_system_config(sim, "tests/test_extreme_timescales.toml"));
const int CRAFT_INDEX = 1; // --- Fixture: LEO spacecraft ---
const int PARENT_INDEX = 1; const int LEO_IDX = 0;
Spacecraft* craft = &sim->spacecraft[CRAFT_INDEX]; const int PARENT_EARTH = 0;
CelestialBody* parent = &sim->bodies[PARENT_INDEX]; Spacecraft* leo_craft = &sim->spacecraft[LEO_IDX];
CelestialBody* earth = &sim->bodies[PARENT_EARTH];
double expected_period = calculate_orbital_period(craft->orbit.semi_major_axis, parent->mass); const double leo_period = compute_period(leo_craft->orbit.semi_major_axis, earth->mass);
INFO("LEO period: " << leo_period << " s (" << leo_period / 60.0 << " min)");
INFO("Expected Mercury-like period: " << expected_period << " s (" << (expected_period / 86400.0) << " days)");
Vec3 initial_pos, initial_vel; SECTION("LEO energy conservation over 10 orbits") {
orbital_elements_to_cartesian(craft->orbit, parent->mass, &initial_pos, &initial_vel); Vec3 pos, vel;
double initial_energy = calculate_orbital_energy(initial_pos, initial_vel, parent->mass, craft->mass); orbital_elements_to_cartesian(leo_craft->orbit, earth->mass, &pos, &vel);
const double initial_energy = compute_energy(pos, vel, leo_craft->mass, earth->mass);
const int NUM_ORBITS = 5;
for (int orbit = 0; orbit < NUM_ORBITS; orbit++) {
OrbitalElements propagated = craft->orbit;
for (int step = 0; step < (int)(expected_period / TIME_STEP); step++) {
propagated = propagate_orbital_elements(propagated, TIME_STEP, parent->mass);
}
Vec3 final_pos, final_vel; Vec3 final_pos, final_vel;
orbital_elements_to_cartesian(propagated, parent->mass, &final_pos, &final_vel); propagate_n_periods(sim, LEO_IDX, PARENT_EARTH, 10, 10.0, final_pos, final_vel);
const double final_energy = compute_energy(final_pos, final_vel, leo_craft->mass, earth->mass);
double final_energy = calculate_orbital_energy(final_pos, final_vel, parent->mass, craft->mass);
double energy_error = fabs(final_energy - initial_energy) / fabs(initial_energy); const double energy_error = fabs(final_energy - initial_energy) / fabs(initial_energy);
double pos_error = vec3_magnitude(vec3_sub(final_pos, initial_pos)); const double pos_error = vec3_magnitude(vec3_sub(final_pos, pos));
INFO("Orbit " << orbit << " energy error: " << energy_error); INFO("Energy relative error: " << energy_error);
INFO("Orbit " << orbit << " position error: " << pos_error << " m"); INFO("Position error after 10 orbits: " << pos_error << " m");
REQUIRE_THAT(energy_error, Catch::Matchers::WithinAbs(0.0, 1e-9)); REQUIRE_THAT(energy_error, WithinAbs(0.0, REL_TOL));
} }
destroy_simulation(sim); // --- Fixture: Mercury-like spacecraft ---
} const int MERCURY_IDX = 1;
const int PARENT_SUN = 1;
TEST_CASE("Long period orbit - Jupiter-like (period ~12 years)", "[extreme][timescales][long]") { Spacecraft* mercury_craft = &sim->spacecraft[MERCURY_IDX];
const double TIME_STEP = 86400.0; CelestialBody* sun = &sim->bodies[PARENT_SUN];
const double mercury_period = compute_period(mercury_craft->orbit.semi_major_axis, sun->mass);
SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP); INFO("Mercury-like period: " << mercury_period << " s (" << mercury_period / 86400.0 << " days)");
REQUIRE(load_system_config(sim, "tests/test_extreme_timescales.toml"));
const int CRAFT_INDEX = 2; SECTION("Mercury-like energy conservation over 5 orbits") {
const int PARENT_INDEX = 1; Vec3 pos, vel;
Spacecraft* craft = &sim->spacecraft[CRAFT_INDEX]; orbital_elements_to_cartesian(mercury_craft->orbit, sun->mass, &pos, &vel);
CelestialBody* parent = &sim->bodies[PARENT_INDEX]; const double initial_energy = compute_energy(pos, vel, mercury_craft->mass, sun->mass);
double expected_period = calculate_orbital_period(craft->orbit.semi_major_axis, parent->mass);
INFO("Expected long period: " << expected_period << " s (" << (expected_period / (86400.0 * 365.0)) << " years)"); Vec3 final_pos, final_vel;
propagate_n_periods(sim, MERCURY_IDX, PARENT_SUN, 5, 3600.0, final_pos, final_vel);
const double final_energy = compute_energy(final_pos, final_vel, mercury_craft->mass, sun->mass);
Vec3 initial_pos, initial_vel; const double energy_error = fabs(final_energy - initial_energy) / fabs(initial_energy);
orbital_elements_to_cartesian(craft->orbit, parent->mass, &initial_pos, &initial_vel); const double pos_error = vec3_magnitude(vec3_sub(final_pos, pos));
double initial_energy = calculate_orbital_energy(initial_pos, initial_vel, parent->mass, craft->mass);
const double PROPAGATION_TIME = 2.0 * 365.0 * 86400.0; INFO("Energy relative error: " << energy_error);
INFO("Position error after 5 orbits: " << pos_error << " m");
OrbitalElements propagated = craft->orbit; REQUIRE_THAT(energy_error, WithinAbs(0.0, REL_TOL));
int num_steps = (int)(PROPAGATION_TIME / TIME_STEP);
for (int step = 0; step < num_steps; step++) {
propagated = propagate_orbital_elements(propagated, TIME_STEP, parent->mass);
} }
Vec3 final_pos, final_vel; // --- Fixture: Jupiter-like spacecraft ---
orbital_elements_to_cartesian(propagated, parent->mass, &final_pos, &final_vel); const int JUPITER_IDX = 2;
Spacecraft* jupiter_craft = &sim->spacecraft[JUPITER_IDX];
double final_energy = calculate_orbital_energy(final_pos, final_vel, parent->mass, craft->mass); const double jupiter_period = compute_period(jupiter_craft->orbit.semi_major_axis, sun->mass);
INFO("Jupiter-like period: " << jupiter_period << " s (" << jupiter_period / (86400.0 * 365.0) << " years)");
double energy_error = fabs(final_energy - initial_energy) / fabs(initial_energy);
SECTION("Jupiter-like energy conservation over 2 years") {
INFO("After " << (PROPAGATION_TIME / (86400.0 * 365.0)) << " years:"); const double prop_time = 2.0 * 365.0 * 86400.0;
INFO("Energy error: " << energy_error); const double parent_mass = sun->mass;
OrbitalElements current = jupiter_craft->orbit;
REQUIRE_THAT(energy_error, Catch::Matchers::WithinAbs(0.0, 1e-9)); int steps = (int)(prop_time / TIME_STEP);
for (int s = 0; s < steps; s++) {
destroy_simulation(sim); current = propagate_orbital_elements(current, TIME_STEP, parent_mass);
}
TEST_CASE("Low altitude orbit (~100 km)", "[extreme][timescales][low]") {
const double TIME_STEP = 10.0;
SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_extreme_timescales.toml"));
const int CRAFT_INDEX = 3;
const int PARENT_INDEX = 0;
Spacecraft* craft = &sim->spacecraft[CRAFT_INDEX];
CelestialBody* parent = &sim->bodies[PARENT_INDEX];
double expected_period = calculate_orbital_period(craft->orbit.semi_major_axis, parent->mass);
INFO("Expected low altitude period: " << expected_period << " s (" << (expected_period / 60.0) << " minutes)");
const int NUM_ORBITS = 10;
for (int orbit = 0; orbit < NUM_ORBITS; orbit++) {
OrbitalElements propagated = craft->orbit;
for (int step = 0; step < (int)(expected_period / TIME_STEP); step++) {
propagated = propagate_orbital_elements(propagated, TIME_STEP, parent->mass);
} }
Vec3 final_pos, final_vel;
orbital_elements_to_cartesian(current, parent_mass, &final_pos, &final_vel);
Vec3 pos, vel; Vec3 init_pos, init_vel;
orbital_elements_to_cartesian(propagated, parent->mass, &pos, &vel); orbital_elements_to_cartesian(jupiter_craft->orbit, parent_mass, &init_pos, &init_vel);
const double initial_energy = compute_energy(init_pos, init_vel, jupiter_craft->mass, parent_mass);
double r = vec3_magnitude(pos); const double final_energy = compute_energy(final_pos, final_vel, jupiter_craft->mass, parent_mass);
const double energy_error = fabs(final_energy - initial_energy) / fabs(initial_energy);
INFO("Orbit " << orbit << " radius: " << r << " m");
INFO("Parent radius: " << parent->radius << " m");
INFO("Altitude: " << (r - parent->radius) << " m");
REQUIRE(r > parent->radius); INFO("After 2 years, energy relative error: " << energy_error);
REQUIRE_THAT(energy_error, WithinAbs(0.0, REL_TOL));
} }
destroy_simulation(sim); // --- Low altitude orbit ---
} const int LOW_ALT_IDX = 3;
Spacecraft* low_alt_craft = &sim->spacecraft[LOW_ALT_IDX];
TEST_CASE("Super-synchronous orbit (period > 24 hours)", "[extreme][timescales][super_sync]") { const double low_alt_period = compute_period(low_alt_craft->orbit.semi_major_axis, earth->mass);
const double TIME_STEP = 3600.0; INFO("Low altitude period: " << low_alt_period << " s (" << low_alt_period / 60.0 << " min)");
const double TARGET_PERIOD = 24.0 * 3600.0;
SECTION("Low altitude orbit stays above surface (100 km)") {
SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP); const double parent_radius = earth->radius;
REQUIRE(load_system_config(sim, "tests/test_extreme_timescales.toml")); OrbitalElements current = low_alt_craft->orbit;
for (int orbit = 0; orbit < 10; orbit++) {
const int CRAFT_INDEX = 4; current = propagate_orbital_elements(current, 10.0, earth->mass);
const int PARENT_INDEX = 0; Vec3 pos, vel;
Spacecraft* craft = &sim->spacecraft[CRAFT_INDEX]; orbital_elements_to_cartesian(current, earth->mass, &pos, &vel);
CelestialBody* parent = &sim->bodies[PARENT_INDEX]; const double r = vec3_magnitude(pos);
const double altitude = r - parent_radius;
double period = calculate_orbital_period(craft->orbit.semi_major_axis, parent->mass); INFO("Orbit " << orbit << " radius: " << r << " m, altitude: " << altitude << " m");
REQUIRE_THAT(altitude, WithinAbs(100000.0, R_TOL));
INFO("Super-synchronous period: " << period << " s (" << (period / 3600.0) << " hours)"); }
INFO("One Earth day: " << TARGET_PERIOD << " s (" << (TARGET_PERIOD / 3600.0) << " hours)");
REQUIRE(period > TARGET_PERIOD);
Vec3 initial_pos, initial_vel;
orbital_elements_to_cartesian(craft->orbit, parent->mass, &initial_pos, &initial_vel);
double initial_energy = calculate_orbital_energy(initial_pos, initial_vel, parent->mass, craft->mass);
const double PROPAGATION_TIME = 3.0 * TARGET_PERIOD;
OrbitalElements propagated = craft->orbit;
int num_steps = (int)(PROPAGATION_TIME / TIME_STEP);
for (int step = 0; step < num_steps; step++) {
propagated = propagate_orbital_elements(propagated, TIME_STEP, parent->mass);
} }
Vec3 final_pos, final_vel; // --- Super-synchronous orbit ---
orbital_elements_to_cartesian(propagated, parent->mass, &final_pos, &final_vel); const int SUPER_SYNC_IDX = 4;
Spacecraft* super_sync_craft = &sim->spacecraft[SUPER_SYNC_IDX];
const double super_sync_period = compute_period(super_sync_craft->orbit.semi_major_axis, earth->mass);
INFO("Super-synchronous period: " << super_sync_period << " s (" << super_sync_period / 3600.0 << " hours)");
double final_energy = calculate_orbital_energy(final_pos, final_vel, parent->mass, craft->mass); SECTION("Super-synchronous period exceeds 24 hours") {
double energy_error = fabs(final_energy - initial_energy) / fabs(initial_energy); REQUIRE_THAT(super_sync_period, WithinAbs(95002.684566, M_TOL));
}
INFO("After 3 Earth days, energy error: " << energy_error); SECTION("Super-synchronous energy conservation over 3 days") {
const double prop_time = 3.0 * 24.0 * 3600.0;
const double parent_mass = earth->mass;
OrbitalElements current = super_sync_craft->orbit;
int steps = (int)(prop_time / TIME_STEP);
for (int s = 0; s < steps; s++) {
current = propagate_orbital_elements(current, TIME_STEP, parent_mass);
}
Vec3 final_pos, final_vel;
orbital_elements_to_cartesian(current, parent_mass, &final_pos, &final_vel);
REQUIRE_THAT(energy_error, Catch::Matchers::WithinAbs(0.0, 1e-9)); Vec3 init_pos, init_vel;
orbital_elements_to_cartesian(super_sync_craft->orbit, parent_mass, &init_pos, &init_vel);
const double initial_energy = compute_energy(init_pos, init_vel, super_sync_craft->mass, parent_mass);
const double final_energy = compute_energy(final_pos, final_vel, super_sync_craft->mass, parent_mass);
const double energy_error = fabs(final_energy - initial_energy) / fabs(initial_energy);
destroy_simulation(sim); INFO("After 3 days, energy relative error: " << energy_error);
} REQUIRE_THAT(energy_error, WithinAbs(0.0, REL_TOL));
}
TEST_CASE("Geosynchronous orbit (period = sidereal day)", "[extreme][timescales][geosync]") { // --- Geosynchronous orbit ---
const int GEO_IDX = 5;
Spacecraft* geo_craft = &sim->spacecraft[GEO_IDX];
const double geo_period = compute_period(geo_craft->orbit.semi_major_axis, earth->mass);
const double geo_period_hours = geo_period / 3600.0;
const double SIDEREAL_DAY_HOURS = 23.93447; const double SIDEREAL_DAY_HOURS = 23.93447;
SimulationState* sim = create_simulation(10, 10, 0, 60.0); SECTION("Geosynchronous period matches sidereal day") {
REQUIRE(load_system_config(sim, "tests/test_extreme_timescales.toml")); const double period_error_hours = fabs(geo_period_hours - SIDEREAL_DAY_HOURS);
INFO("Calculated period: " << geo_period_hours << " hours");
const int CRAFT_INDEX = 5; INFO("Sidereal day: " << SIDEREAL_DAY_HOURS << " hours");
const int PARENT_INDEX = 0; INFO("Period error: " << period_error_hours << " hours");
Spacecraft* craft = &sim->spacecraft[CRAFT_INDEX]; REQUIRE_THAT(geo_period_hours, WithinAbs(SIDEREAL_DAY_HOURS, PERIOD_HOURS_TOL));
CelestialBody* parent = &sim->bodies[PARENT_INDEX]; }
double period = calculate_orbital_period(craft->orbit.semi_major_axis, parent->mass);
double period_hours = period / 3600.0;
double period_error_hours = fabs(period_hours - SIDEREAL_DAY_HOURS);
INFO("Calculated period: " << period << " s (" << period_hours << " hours)");
INFO("Sidereal day: " << SIDEREAL_DAY_HOURS << " hours");
INFO("Period error: " << period_error_hours << " hours (" << (period_error_hours * 3600.0) << " s)");
REQUIRE_THAT(period_hours, Catch::Matchers::WithinAbs(SIDEREAL_DAY_HOURS, 0.0002));
Vec3 initial_pos, initial_vel;
orbital_elements_to_cartesian(craft->orbit, parent->mass, &initial_pos, &initial_vel);
OrbitalElements propagated = craft->orbit;
propagated = propagate_orbital_elements(propagated, period, parent->mass);
Vec3 final_pos, final_vel;
orbital_elements_to_cartesian(propagated, parent->mass, &final_pos, &final_vel);
double pos_error = vec3_magnitude(vec3_sub(final_pos, initial_pos));
INFO("Position error after one period: " << pos_error << " m");
REQUIRE_THAT(pos_error, Catch::Matchers::WithinAbs(0.0, 1e-3));
destroy_simulation(sim);
}
TEST_CASE("Period consistency across different true anomalies", "[extreme][timescales][consistency]") {
const double TIME_STEP = 3600.0;
SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP); SECTION("Geosynchronous one-period roundtrip") {
REQUIRE(load_system_config(sim, "tests/test_extreme_timescales.toml")); const double parent_mass = earth->mass;
OrbitalElements propagated = geo_craft->orbit;
propagated = propagate_orbital_elements(propagated, geo_period, parent_mass);
const int CRAFT_INDEX = 1; Vec3 init_pos, init_vel, final_pos, final_vel;
const int PARENT_INDEX = 1; orbital_elements_to_cartesian(geo_craft->orbit, parent_mass, &init_pos, &init_vel);
Spacecraft* craft = &sim->spacecraft[CRAFT_INDEX]; orbital_elements_to_cartesian(propagated, parent_mass, &final_pos, &final_vel);
CelestialBody* parent = &sim->bodies[PARENT_INDEX]; const double pos_error = vec3_magnitude(vec3_sub(final_pos, init_pos));
double period = calculate_orbital_period(craft->orbit.semi_major_axis, parent->mass); INFO("Position error after one period: " << pos_error << " m");
REQUIRE_THAT(pos_error, WithinAbs(0.0, R_TOL));
}
const double test_anomalies[] = {0.0, M_PI / 2.0, M_PI, 3.0 * M_PI / 2.0}; // --- Period consistency from different true anomalies ---
SECTION("Period consistency across different starting true anomalies") {
const double parent_mass = sun->mass;
const double period = mercury_period;
const double test_anomalies[] = {0.0, M_PI / 2.0, M_PI, 3.0 * M_PI / 2.0};
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
OrbitalElements test_orbit = craft->orbit; OrbitalElements test_orbit = mercury_craft->orbit;
test_orbit.true_anomaly = test_anomalies[i]; test_orbit.true_anomaly = test_anomalies[i];
OrbitalElements propagated = test_orbit; OrbitalElements propagated = test_orbit;
propagated = propagate_orbital_elements(propagated, period, parent->mass); propagated = propagate_orbital_elements(propagated, period, parent_mass);
Vec3 initial_pos, initial_vel; Vec3 init_pos, init_vel, final_pos, final_vel;
Vec3 final_pos, final_vel; orbital_elements_to_cartesian(test_orbit, parent_mass, &init_pos, &init_vel);
orbital_elements_to_cartesian(test_orbit, parent->mass, &initial_pos, &initial_vel); orbital_elements_to_cartesian(propagated, parent_mass, &final_pos, &final_vel);
orbital_elements_to_cartesian(propagated, parent->mass, &final_pos, &final_vel);
double pos_error = vec3_magnitude(vec3_sub(final_pos, initial_pos)); const double pos_error = vec3_magnitude(vec3_sub(final_pos, init_pos));
double vel_error = vec3_magnitude(vec3_sub(final_vel, initial_vel)); const double vel_error = vec3_magnitude(vec3_sub(final_vel, init_vel));
INFO("True anomaly: " << test_anomalies[i] << " rad"); INFO("True anomaly: " << test_anomalies[i] << " rad");
INFO("Position error: " << pos_error << " m"); INFO("Position error: " << pos_error << " m");
INFO("Velocity error: " << vel_error << " m/s"); INFO("Velocity error: " << vel_error << " m/s");
REQUIRE_THAT(pos_error, Catch::Matchers::WithinAbs(0.0, 1e-3)); REQUIRE_THAT(pos_error, WithinAbs(0.0, PROP_POS_TOL));
REQUIRE_THAT(vel_error, Catch::Matchers::WithinAbs(0.0, 1e-6)); REQUIRE_THAT(vel_error, WithinAbs(0.0, V_TOL));
}
} }
destroy_simulation(sim); // --- Combined energy test for all spacecraft ---
}
TEST_CASE("Energy conservation across all timescales", "[extreme][timescales][energy]") {
const double TIME_STEP = 3600.0;
SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_extreme_timescales.toml"));
struct EnergyTest { struct EnergyTest {
int craft_index; int craft_index;
int parent_index; int parent_index;
const char* name; const char* name;
int num_periods;
}; };
EnergyTest tests[] = { EnergyTest all_tests[] = {
{0, 0, "LEO (fast)"}, {0, 0, "LEO", 10},
{1, 1, "Mercury-like (fast)"}, {1, 1, "Mercury-like", 5},
{2, 1, "Jupiter-like (long)"}, {2, 1, "Jupiter-like", 2},
{3, 0, "Low altitude (low)"}, {3, 0, "Low altitude", 10},
{4, 0, "Super-synchronous"}, {4, 0, "Super-synchronous", 3},
{5, 0, "Geosynchronous"} {5, 0, "Geosynchronous", 1},
}; };
for (int t = 0; t < 6; t++) { SECTION("Energy conservation across all timescales") {
EnergyTest test = tests[t]; for (const auto& t : all_tests) {
Spacecraft* craft = &sim->spacecraft[test.craft_index]; Spacecraft* craft = &sim->spacecraft[t.craft_index];
CelestialBody* parent = &sim->bodies[test.parent_index]; CelestialBody* parent = &sim->bodies[t.parent_index];
Vec3 initial_pos, initial_vel; Vec3 init_pos, init_vel;
orbital_elements_to_cartesian(craft->orbit, parent->mass, &initial_pos, &initial_vel); orbital_elements_to_cartesian(craft->orbit, parent->mass, &init_pos, &init_vel);
double initial_energy = calculate_orbital_energy(initial_pos, initial_vel, parent->mass, craft->mass); const double initial_energy = compute_energy(init_pos, init_vel, craft->mass, parent->mass);
double period = calculate_orbital_period(craft->orbit.semi_major_axis, parent->mass); double period = compute_period(craft->orbit.semi_major_axis, parent->mass);
double propagation_time = period * 2.0; double prop_time;
if (t.num_periods == 2) {
OrbitalElements propagated = craft->orbit; prop_time = 2.0 * 365.0 * 86400.0; // 2 years for Jupiter
int num_steps = (int)(propagation_time / TIME_STEP); } else if (t.num_periods == 3) {
for (int step = 0; step < num_steps; step++) { prop_time = 3.0 * 24.0 * 3600.0; // 3 days for super-sync
propagated = propagate_orbital_elements(propagated, TIME_STEP, parent->mass); } else {
prop_time = t.num_periods * period;
}
OrbitalElements current = craft->orbit;
int steps = (int)(prop_time / TIME_STEP);
for (int s = 0; s < steps; s++) {
current = propagate_orbital_elements(current, TIME_STEP, parent->mass);
}
Vec3 final_pos, final_vel;
orbital_elements_to_cartesian(current, parent->mass, &final_pos, &final_vel);
const double final_energy = compute_energy(final_pos, final_vel, craft->mass, parent->mass);
const double energy_error = fabs(final_energy - initial_energy) / fabs(initial_energy);
INFO(t.name << " energy relative error: " << energy_error);
REQUIRE_THAT(energy_error, WithinAbs(0.0, REL_TOL));
} }
Vec3 final_pos, final_vel;
orbital_elements_to_cartesian(propagated, parent->mass, &final_pos, &final_vel);
double final_energy = calculate_orbital_energy(final_pos, final_vel, parent->mass, craft->mass);
double energy_error = fabs(final_energy - initial_energy) / fabs(initial_energy);
INFO(test.name << ":");
INFO(" Initial energy: " << initial_energy << " J");
INFO(" Final energy: " << final_energy << " J");
INFO(" Relative error: " << energy_error);
REQUIRE_THAT(energy_error, Catch::Matchers::WithinAbs(0.0, 1e-9));
} }
destroy_simulation(sim); // --- Mean anomaly accumulation ---
} SECTION("Mean anomaly accumulation over 10 years") {
const double parent_mass = sun->mass;
TEST_CASE("Mean anomaly accumulation for very long periods", "[extreme][timescales][mean_anomaly]") { const double a = jupiter_craft->orbit.semi_major_axis;
const double TIME_STEP = 86400.0; const double e = jupiter_craft->orbit.eccentricity;
const double mu = G * parent_mass;
SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP); const double n = sqrt(mu / pow(a, 3.0));
REQUIRE(load_system_config(sim, "tests/test_extreme_timescales.toml")); const double prop_time = 10.0 * 365.0 * 86400.0;
const double expected_mean_anomaly = n * prop_time;
const int CRAFT_INDEX = 2; const double expected_orbits = expected_mean_anomaly / (2.0 * M_PI);
const int PARENT_INDEX = 1;
Spacecraft* craft = &sim->spacecraft[CRAFT_INDEX]; INFO("Expected mean anomaly after 10 years: " << expected_mean_anomaly << " rad");
CelestialBody* parent = &sim->bodies[PARENT_INDEX]; INFO("Expected orbits: " << expected_orbits);
OrbitalElements current = jupiter_craft->orbit;
int steps = (int)(prop_time / TIME_STEP);
for (int s = 0; s < steps; s++) {
current = propagate_orbital_elements(current, TIME_STEP, parent_mass);
}
double mu = G * parent->mass; Vec3 final_pos, final_vel;
double a = craft->orbit.semi_major_axis; orbital_elements_to_cartesian(current, parent_mass, &final_pos, &final_vel);
double e = craft->orbit.eccentricity;
double n = sqrt(mu / pow(a, 3.0));
const double PROPAGATION_TIME = 10.0 * 365.0 * 86400.0; const double true_anomaly_change = current.true_anomaly - jupiter_craft->orbit.true_anomaly;
double expected_mean_anomaly = n * PROPAGATION_TIME; const double expected_true_anomaly_change = fmod(expected_mean_anomaly, 2.0 * M_PI);
double expected_orbits = expected_mean_anomaly / (2.0 * M_PI);
INFO("Expected mean anomaly after 10 years: " << expected_mean_anomaly << " rad"); INFO("True anomaly change: " << true_anomaly_change << " rad");
INFO("Expected number of orbits: " << expected_orbits); INFO("Expected true anomaly change: " << expected_true_anomaly_change << " rad");
OrbitalElements propagated = craft->orbit; REQUIRE_THAT(fabs(current.eccentricity - e), WithinAbs(0.0, E_TOL));
int num_steps = (int)(PROPAGATION_TIME / TIME_STEP); REQUIRE_THAT(fabs(current.semi_major_axis - a), WithinAbs(0.0, A_TOL));
for (int step = 0; step < num_steps; step++) {
propagated = propagate_orbital_elements(propagated, TIME_STEP, parent->mass);
} }
Vec3 final_pos, final_vel;
orbital_elements_to_cartesian(propagated, parent->mass, &final_pos, &final_vel);
double true_anomaly_change = propagated.true_anomaly - craft->orbit.true_anomaly;
double expected_true_anomaly_change = fmod(expected_mean_anomaly, 2.0 * M_PI);
INFO("True anomaly change: " << true_anomaly_change << " rad");
INFO("Expected true anomaly change: " << expected_true_anomaly_change << " rad");
REQUIRE_THAT(fabs(propagated.eccentricity - e), Catch::Matchers::WithinAbs(0.0, 1e-10));
REQUIRE_THAT(fabs(propagated.semi_major_axis - a), Catch::Matchers::WithinAbs(0.0, 1e-6));
destroy_simulation(sim); destroy_simulation(sim);
} }

78
tests/test_extreme_timescales.toml

@ -1,5 +1,4 @@
# Test Configuration: Extreme Timescales for Analytical Propagation # Test Configuration: Extreme Timescales for Analytical Propagation
# Tests orbital period extremes to validate propagation at different timescales
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
@ -7,11 +6,7 @@ mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = -1 parent_index = -1
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[bodies]] [[bodies]]
name = "Sun" name = "Sun"
@ -19,97 +14,40 @@ mass = 1.989e30
radius = 6.96e8 radius = 6.96e8
parent_index = -1 parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 } color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = { orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
# 1. Very fast orbit - LEO-like (period ~92 minutes)
# Tests numerical precision challenges with fast orbits
[[spacecraft]] [[spacecraft]]
name = "Fast_Orbit_LEO" name = "Fast_Orbit_LEO"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = 6.771e6, eccentricity = 0.0, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 6.771e6,
eccentricity = 0.0,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
# 2. Mercury-like fast orbit around Sun (period ~88 days)
# Tests moderately fast planetary orbit
[[spacecraft]] [[spacecraft]]
name = "Mercury_Like_Orbit" name = "Mercury_Like_Orbit"
mass = 1000.0 mass = 1000.0
parent_index = 1 parent_index = 1
orbit = { orbit = { semi_major_axis = 5.79e10, eccentricity = 0.2056, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 5.79e10,
eccentricity = 0.2056,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
# 3. Very long period orbit - Jupiter-like (period ~11.86 years)
# Tests mean anomaly accumulation over long time intervals
[[spacecraft]] [[spacecraft]]
name = "Long_Period_Orbit" name = "Long_Period_Orbit"
mass = 1000.0 mass = 1000.0
parent_index = 1 parent_index = 1
orbit = { orbit = { semi_major_axis = 5.2e11, eccentricity = 0.0489, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 5.2e11,
eccentricity = 0.0489,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
# 4. Very low altitude orbit (altitude ~100 km)
# Tests propagation near planetary surface
[[spacecraft]] [[spacecraft]]
name = "Low_Altitude_Orbit" name = "Low_Altitude_Orbit"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = 6.471e6, eccentricity = 0.0, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 6.471e6,
eccentricity = 0.0,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
# 5. Super-synchronous orbit (period > 24 hours)
[[spacecraft]] [[spacecraft]]
name = "Super_Synchronous_Orbit" name = "Super_Synchronous_Orbit"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = 4.5e7, eccentricity = 0.0, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 4.5e7,
eccentricity = 0.0,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
# 6. Geosynchronous orbit (period = 24 hours exactly)
# Reference for period accuracy verification
[[spacecraft]] [[spacecraft]]
name = "Geosynchronous_Orbit" name = "Geosynchronous_Orbit"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = 4.2164e7, eccentricity = 0.0, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 4.2164e7,
eccentricity = 0.0,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}

1335
tests/test_hybrid_burns.cpp

File diff suppressed because it is too large Load Diff

153
tests/test_hybrid_burns.toml

@ -1,19 +1,10 @@
# Test Configuration: Hybrid Burns for Analytical Propagation
# Sun + Earth system with multiple spacecraft for impulse and continuous burn testing
# Tests the critical workflow: orbital elements → Cartesian → burn → orbital elements
# and finite-duration burns with mode transitions
[[bodies]] [[bodies]]
name = "Sun" name = "Sun"
mass = 1.989e30 mass = 1.989e30
radius = 6.96e8 radius = 6.96e8
parent_index = -1 parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 } color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = { orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
@ -21,29 +12,13 @@ mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = 0 parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { orbit = { semi_major_axis = 1.496e11, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 1.496e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
# ========== IMPULSE BURN SPACECRAFT ==========
# 1. Hohmann Transfer Spacecraft
# Initial circular LEO orbit (altitude ~400 km)
# Two maneuvers: apogee raise, circularization
[[spacecraft]] [[spacecraft]]
name = "Hohmann_Transfer" name = "Hohmann_Transfer"
mass = 1000.0 mass = 1000.0
parent_index = 1 parent_index = 1
orbit = { orbit = { semi_major_axis = 6.771e6, eccentricity = 0.0, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 6.771e6,
eccentricity = 0.0,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
[[maneuvers]] [[maneuvers]]
name = "hohmann_burn_1" name = "hohmann_burn_1"
@ -61,21 +36,11 @@ trigger_value = 5400.0
direction = "prograde" direction = "prograde"
delta_v = 1500.0 delta_v = 1500.0
# 2. Plane Change Spacecraft
# Initial circular orbit with inclination 0.2 rad
# One maneuver: normal burn at ascending node to change inclination to 0.4 rad
[[spacecraft]] [[spacecraft]]
name = "Plane_Change" name = "Plane_Change"
mass = 1000.0 mass = 1000.0
parent_index = 1 parent_index = 1
orbit = { orbit = { semi_major_axis = 7.0e6, eccentricity = 0.0, true_anomaly = 0.0, inclination = 0.2, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 7.0e6,
eccentricity = 0.0,
true_anomaly = 0.0,
inclination = 0.2,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
[[maneuvers]] [[maneuvers]]
name = "plane_change_burn" name = "plane_change_burn"
@ -85,21 +50,11 @@ trigger_value = 0.0
direction = "normal" direction = "normal"
delta_v = 1400.0 delta_v = 1400.0
# 3. Periapsis Burn Spacecraft
# Initial elliptical orbit (e = 0.5)
# One maneuver: prograde burn at periapsis to raise apoapsis
[[spacecraft]] [[spacecraft]]
name = "Periapsis_Burn" name = "Periapsis_Burn"
mass = 1000.0 mass = 1000.0
parent_index = 1 parent_index = 1
orbit = { orbit = { semi_major_axis = 1.5e7, eccentricity = 0.5, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 1.5e7,
eccentricity = 0.5,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
[[maneuvers]] [[maneuvers]]
name = "periapsis_burn" name = "periapsis_burn"
@ -109,21 +64,11 @@ trigger_value = 0.0
direction = "prograde" direction = "prograde"
delta_v = 500.0 delta_v = 500.0
# 4. Apoapsis Burn Spacecraft
# Initial elliptical orbit (e = 0.5)
# One maneuver: prograde burn at apoapsis to raise periapsis
[[spacecraft]] [[spacecraft]]
name = "Apoapsis_Burn" name = "Apoapsis_Burn"
mass = 1000.0 mass = 1000.0
parent_index = 1 parent_index = 1
orbit = { orbit = { semi_major_axis = 1.5e7, eccentricity = 0.5, true_anomaly = 3.141592653589793, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 1.5e7,
eccentricity = 0.5,
true_anomaly = 3.141592653589793,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
[[maneuvers]] [[maneuvers]]
name = "apoapsis_burn" name = "apoapsis_burn"
@ -133,21 +78,11 @@ trigger_value = 0.0
direction = "prograde" direction = "prograde"
delta_v = 500.0 delta_v = 500.0
# 5. Small Delta-v Burn Spacecraft
# Initial circular orbit
# One maneuver: minimal prograde burn (Δv < 1 m/s)
[[spacecraft]] [[spacecraft]]
name = "Small_Delta_v" name = "Small_Delta_v"
mass = 1000.0 mass = 1000.0
parent_index = 1 parent_index = 1
orbit = { orbit = { semi_major_axis = 7.0e6, eccentricity = 0.0, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 7.0e6,
eccentricity = 0.0,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
[[maneuvers]] [[maneuvers]]
name = "small_burn" name = "small_burn"
@ -157,21 +92,11 @@ trigger_value = 0.0
direction = "prograde" direction = "prograde"
delta_v = 0.5 delta_v = 0.5
# 6. Large Delta-v Burn Spacecraft
# Initial circular orbit
# One maneuver: large prograde burn (Δv > orbital velocity)
[[spacecraft]] [[spacecraft]]
name = "Large_Delta_v" name = "Large_Delta_v"
mass = 1000.0 mass = 1000.0
parent_index = 1 parent_index = 1
orbit = { orbit = { semi_major_axis = 7.0e6, eccentricity = 0.0, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 7.0e6,
eccentricity = 0.0,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
[[maneuvers]] [[maneuvers]]
name = "large_burn" name = "large_burn"
@ -181,74 +106,26 @@ trigger_value = 0.0
direction = "prograde" direction = "prograde"
delta_v = 12000.0 delta_v = 12000.0
# ========== CONTINUOUS BURN SPACECRAFT ==========
# 1. Low-thrust ion engine spacecraft
# Initial circular LEO orbit (altitude ~400 km)
# Simulated continuous burn: 5000 seconds duration, 100 m/s total Δv
# Split into 100 small burns of 1 m/s each every 50 seconds
[[spacecraft]] [[spacecraft]]
name = "Low_Thrust_Ion" name = "Low_Thrust_Ion"
mass = 1000.0 mass = 1000.0
parent_index = 1 parent_index = 1
orbit = { orbit = { semi_major_axis = 6.771e6, eccentricity = 0.0, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 6.771e6,
eccentricity = 0.0,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
# 2. Multi-burn sequence spacecraft
# Initial circular orbit
# Simulated continuous burn 1: 2000 seconds, 50 m/s total Δv (20 burns of 2.5 m/s)
# Simulated continuous burn 2: 3000 seconds, 75 m/s total Δv (30 burns of 2.5 m/s)
[[spacecraft]] [[spacecraft]]
name = "Multi_Burn_Sequence" name = "Multi_Burn_Sequence"
mass = 1000.0 mass = 1000.0
parent_index = 1 parent_index = 1
orbit = { orbit = { semi_major_axis = 7.0e6, eccentricity = 0.0, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 7.0e6,
eccentricity = 0.0,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
# 3. Mode transition spacecraft
# Initial elliptical orbit (e = 0.3)
# Simulated continuous burn: 4000 seconds, 200 m/s total Δv
# Split into 80 burns of 2.5 m/s each
# Purpose: Test switching between analytical and numerical modes during burns
[[spacecraft]] [[spacecraft]]
name = "Mode_Transition" name = "Mode_Transition"
mass = 1000.0 mass = 1000.0
parent_index = 1 parent_index = 1
orbit = { orbit = { semi_major_axis = 1.2e7, eccentricity = 0.3, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 1.2e7,
eccentricity = 0.3,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}
# 4. Energy conservation spacecraft
# Initial circular orbit
# Simulated continuous burn: 6000 seconds, 150 m/s total Δv
# Split into 120 burns of 1.25 m/s each
# Purpose: Verify energy conservation during finite-duration burn
[[spacecraft]] [[spacecraft]]
name = "Energy_Conservation" name = "Energy_Conservation"
mass = 1000.0 mass = 1000.0
parent_index = 1 parent_index = 1
orbit = { orbit = { semi_major_axis = 8.0e6, eccentricity = 0.0, true_anomaly = 0.0, inclination = 0.0, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.0 }
semi_major_axis = 8.0e6,
eccentricity = 0.0,
true_anomaly = 0.0,
inclination = 0.0,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 0.0
}

192
tests/test_inclined_orbits.cpp

@ -6,149 +6,96 @@
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include <cmath> #include <cmath>
const double POSITION_TOLERANCE_METERS = 10000.0; using Catch::Matchers::WithinAbs;
const double PERIOD_TOLERANCE_SECONDS = 600.0;
TEST_CASE("Molniya orbit - position verification at multiple true anomalies", "[inclined][molniya]") { SCENARIO("Molniya orbit position at multiple true anomalies",
"[inclined][molniya][position]") {
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
const double SEMI_MAJOR_AXIS = 26540000.0; const double SEMI_MAJOR_AXIS = 26540000.0;
const double ECCENTRICITY = 0.74; const double ECCENTRICITY = 0.74;
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP); SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_inclined_orbits.toml")); REQUIRE(load_system_config(sim, "tests/test_inclined_orbits.toml"));
Spacecraft* molniya = &sim->spacecraft[0]; Spacecraft* molniya = &sim->spacecraft[0];
CelestialBody* earth = &sim->bodies[0]; CelestialBody* earth = &sim->bodies[0];
SECTION("Position at perigee (true_anomaly = 0)") { auto check_radius_at_nu = [&](double nu, double expected_r) {
double expected_radius = SEMI_MAJOR_AXIS * (1.0 - ECCENTRICITY); molniya->orbit.true_anomaly = nu;
double actual_radius = vec3_magnitude(vec3_sub(molniya->global_position, earth->global_position));
double radius_error = fabs(actual_radius - expected_radius);
INFO("Expected radius at perigee: " << expected_radius << " m");
INFO("Actual radius: " << actual_radius << " m");
INFO("Error: " << radius_error << " m");
REQUIRE(radius_error < POSITION_TOLERANCE_METERS);
CHECK(molniya->global_position.z != 0.0);
INFO("Z-coordinate should be non-zero for inclined orbit (currently deferred)");
}
SECTION("Position at true_anomaly = π/2 (90°)") {
molniya->orbit.true_anomaly = M_PI / 2.0;
initialize_orbital_objects(sim); initialize_orbital_objects(sim);
double expected_radius = SEMI_MAJOR_AXIS * (1.0 - ECCENTRICITY * ECCENTRICITY) / (1.0 + ECCENTRICITY * cos(M_PI / 2.0)); double actual_r = vec3_magnitude(vec3_sub(molniya->global_position, earth->global_position));
double actual_radius = vec3_magnitude(vec3_sub(molniya->global_position, earth->global_position)); INFO("nu: " << nu << " rad, expected r: " << expected_r << " m, actual r: " << actual_r << " m");
double radius_error = fabs(actual_radius - expected_radius); REQUIRE_THAT(actual_r, WithinAbs(expected_r, 10000.0));
};
INFO("Expected radius at ν=π/2: " << expected_radius << " m");
INFO("Actual radius: " << actual_radius << " m");
INFO("Error: " << radius_error << " m");
REQUIRE(radius_error < POSITION_TOLERANCE_METERS);
CHECK(molniya->global_position.z != 0.0); SECTION("Perigee (nu = 0)") {
check_radius_at_nu(0.0, SEMI_MAJOR_AXIS * (1.0 - ECCENTRICITY));
} }
SECTION("90 degrees (nu = pi/2)") {
SECTION("Position at apogee (true_anomaly = π)") { double expected_r = SEMI_MAJOR_AXIS * (1.0 - ECCENTRICITY * ECCENTRICITY) /
molniya->orbit.true_anomaly = M_PI; (1.0 + ECCENTRICITY * cos(M_PI / 2.0));
initialize_orbital_objects(sim); check_radius_at_nu(M_PI / 2.0, expected_r);
double expected_radius = SEMI_MAJOR_AXIS * (1.0 + ECCENTRICITY);
double actual_radius = vec3_magnitude(vec3_sub(molniya->global_position, earth->global_position));
double radius_error = fabs(actual_radius - expected_radius);
INFO("Expected radius at apogee: " << expected_radius << " m");
INFO("Actual radius: " << actual_radius << " m");
INFO("Error: " << radius_error << " m");
REQUIRE(radius_error < POSITION_TOLERANCE_METERS);
CHECK(molniya->global_position.z != 0.0);
INFO("At apogee, satellite should be at northernmost point (max z)");
} }
SECTION("Apogee (nu = pi)") {
SECTION("Position at true_anomaly = 3π/2 (270°)") { check_radius_at_nu(M_PI, SEMI_MAJOR_AXIS * (1.0 + ECCENTRICITY));
molniya->orbit.true_anomaly = 3.0 * M_PI / 2.0; }
initialize_orbital_objects(sim); SECTION("270 degrees (nu = 3pi/2)") {
double expected_r = SEMI_MAJOR_AXIS * (1.0 - ECCENTRICITY * ECCENTRICITY) /
double expected_radius = SEMI_MAJOR_AXIS * (1.0 - ECCENTRICITY * ECCENTRICITY) / (1.0 + ECCENTRICITY * cos(3.0 * M_PI / 2.0)); (1.0 + ECCENTRICITY * cos(3.0 * M_PI / 2.0));
double actual_radius = vec3_magnitude(vec3_sub(molniya->global_position, earth->global_position)); check_radius_at_nu(3.0 * M_PI / 2.0, expected_r);
double radius_error = fabs(actual_radius - expected_radius);
INFO("Expected radius at ν=3π/2: " << expected_radius << " m");
INFO("Actual radius: " << actual_radius << " m");
INFO("Error: " << radius_error << " m");
REQUIRE(radius_error < POSITION_TOLERANCE_METERS);
CHECK(molniya->global_position.z != 0.0);
INFO("At ν=270°, satellite should be at southernmost point (min z)");
} }
destroy_simulation(sim); destroy_simulation(sim);
} }
TEST_CASE("Molniya orbit - orbital period verification", "[inclined][molniya][period]") { SCENARIO("Molniya orbit propagation to apogee",
"[inclined][molniya][propagation]") {
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
const double SECONDS_PER_HOUR = 3600.0; const double G_CONST = 6.67430e-11;
const double MAX_SIMULATION_HOURS = 15.0; const double EARTH_MASS = 5.972e24;
// Relaxed tolerance for highly elliptical orbit with 60s timestep const double MU = G_CONST * EARTH_MASS;
const double MOLNIYA_PERIOD_TOLERANCE_SECONDS = 1800.0; // 30 minutes
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP); SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_inclined_orbits.toml")); REQUIRE(load_system_config(sim, "tests/test_inclined_orbits.toml"));
Spacecraft* molniya = &sim->spacecraft[0]; Spacecraft* molniya = &sim->spacecraft[0];
CelestialBody* earth = &sim->bodies[0]; CelestialBody* earth = &sim->bodies[0];
double semi_major_axis = molniya->orbit.semi_major_axis; const double a = molniya->orbit.semi_major_axis;
double mu = G * earth->mass; const double expected_apogee_r = a * (1.0 + molniya->orbit.eccentricity);
double theoretical_period_seconds = 2.0 * M_PI * sqrt(pow(semi_major_axis, 3) / mu); const double theoretical_half_period = M_PI * sqrt(a * a * a / MU);
double theoretical_period_hours = theoretical_period_seconds / SECONDS_PER_HOUR;
INFO("Theoretical half period: " << theoretical_half_period << " s");
INFO("Semi-major axis: " << semi_major_axis << " m"); INFO("Expected apogee radius: " << expected_apogee_r << " m");
INFO("Theoretical period from Kepler's 3rd law: " << theoretical_period_hours << " hours");
auto propagate_to_half_period = [&]() -> double {
OrbitTracker* tracker = create_orbit_tracker_3d(0, 0.01, double target_time = theoretical_half_period;
molniya->orbit.inclination, while (sim->time < target_time) {
molniya->orbit.longitude_of_ascending_node, update_simulation(sim);
molniya->orbit.argument_of_periapsis); }
return vec3_magnitude(vec3_sub(molniya->global_position, earth->global_position));
double max_time = MAX_SIMULATION_HOURS * SECONDS_PER_HOUR; };
while (sim->time < max_time && !tracker->orbit_completed) {
update_simulation(sim); SECTION("After half period, craft reaches apogee") {
update_orbit_tracker(tracker, (CelestialBody*)molniya, earth, sim->time); const double actual_r = propagate_to_half_period();
INFO("Actual radius at half period: " << actual_r << " m");
REQUIRE_THAT(actual_r, WithinAbs(expected_apogee_r, 100000.0));
} }
REQUIRE(tracker->orbit_completed);
double measured_period_hours = tracker->time_at_completion / SECONDS_PER_HOUR;
double period_error_hours = fabs(measured_period_hours - theoretical_period_hours);
INFO("Measured period: " << measured_period_hours << " hours");
INFO("Period error: " << period_error_hours << " hours");
INFO("Period error: " << (period_error_hours / theoretical_period_hours * 100.0) << "%");
REQUIRE(period_error_hours * SECONDS_PER_HOUR < MOLNIYA_PERIOD_TOLERANCE_SECONDS);
destroy_orbit_tracker(tracker);
destroy_simulation(sim); destroy_simulation(sim);
} }
TEST_CASE("Generic inclined orbit - moderate inclination", "[inclined][generic]") { SCENARIO("Generic inclined orbit - z-coordinate and radius sanity",
"[inclined][generic]") {
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
const double SEMI_MAJOR_AXIS = 10000000.0; const double SEMI_MAJOR_AXIS = 10000000.0;
const double ECCENTRICITY = 0.5; const double ECCENTRICITY = 0.5;
const double INCLINATION_DEG = 45.0; const double INCLINATION_DEG = 45.0;
const double INCLINATION_RAD = INCLINATION_DEG * M_PI / 180.0; const double INCLINATION_RAD = INCLINATION_DEG * M_PI / 180.0;
const double ARGUMENT_OF_PERIAPSIS = M_PI / 2.0;
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP); SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_inclined_orbits.toml")); REQUIRE(load_system_config(sim, "tests/test_inclined_orbits.toml"));
Spacecraft* craft = &sim->spacecraft[0]; Spacecraft* craft = &sim->spacecraft[0];
@ -159,47 +106,40 @@ TEST_CASE("Generic inclined orbit - moderate inclination", "[inclined][generic]"
craft->orbit.true_anomaly = 0.0; craft->orbit.true_anomaly = 0.0;
craft->orbit.inclination = INCLINATION_RAD; craft->orbit.inclination = INCLINATION_RAD;
craft->orbit.longitude_of_ascending_node = 0.0; craft->orbit.longitude_of_ascending_node = 0.0;
craft->orbit.argument_of_periapsis = M_PI / 2.0; craft->orbit.argument_of_periapsis = ARGUMENT_OF_PERIAPSIS;
initialize_orbital_objects(sim); initialize_orbital_objects(sim);
SECTION("Z-coordinate is non-zero for inclined orbit") { auto check_z_nonzero = [&]() {
double z_position = craft->global_position.z; double z = craft->global_position.z;
INFO("Z-coordinate: " << z_position << " m"); INFO("Z-coordinate: " << z << " m");
REQUIRE_THAT(z, !WithinAbs(0.0, 0.001));
REQUIRE(z_position != 0.0); };
}
SECTION("Position magnitude matches orbital radius") { auto check_radius = [&]() {
double position_vector_mag = vec3_magnitude(craft->global_position);
double orbital_radius = vec3_magnitude(vec3_sub(craft->global_position, earth->global_position)); double orbital_radius = vec3_magnitude(vec3_sub(craft->global_position, earth->global_position));
double magnitude_error = fabs(position_vector_mag - orbital_radius); double position_mag = vec3_magnitude(craft->global_position);
double error = fabs(position_mag - orbital_radius);
INFO("Position magnitude: " << position_mag << " m, orbital radius: " << orbital_radius << " m, error: " << error << " m");
REQUIRE_THAT(error, WithinAbs(0.0, 10000.0));
};
INFO("Position vector magnitude: " << position_vector_mag << " m"); SECTION("Z-coordinate is non-zero for inclined orbit") { check_z_nonzero(); }
INFO("Orbital radius: " << orbital_radius << " m"); SECTION("Position magnitude matches orbital radius") { check_radius(); }
INFO("Error: " << magnitude_error << " m");
REQUIRE(magnitude_error < POSITION_TOLERANCE_METERS);
}
destroy_simulation(sim); destroy_simulation(sim);
} }
TEST_CASE("Inclined orbit - inclination parameter is preserved", "[inclined][config]") { SCENARIO("Inclination parameter preserved through config loading",
"[inclined][config]") {
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
const double EXPECTED_INCLINATION_RAD = 1.107;
const double EXPECTED_INCLINATION_DEG = EXPECTED_INCLINATION_RAD * 180.0 / M_PI;
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP); SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_inclined_orbits.toml")); REQUIRE(load_system_config(sim, "tests/test_inclined_orbits.toml"));
Spacecraft* molniya = &sim->spacecraft[0]; Spacecraft* molniya = &sim->spacecraft[0];
INFO("Loaded inclination: " << (molniya->orbit.inclination * 180.0 / M_PI) << " degrees"); INFO("Loaded inclination: " << (molniya->orbit.inclination * 180.0 / M_PI) << " degrees");
INFO("Expected inclination: " << EXPECTED_INCLINATION_DEG << " degrees"); REQUIRE_THAT(molniya->orbit.inclination, WithinAbs(1.107, 0.01));
REQUIRE_THAT(molniya->orbit.inclination, Catch::Matchers::WithinAbs(EXPECTED_INCLINATION_RAD, 0.01));
destroy_simulation(sim); destroy_simulation(sim);
} }

24
tests/test_inclined_orbits.toml

@ -1,13 +1,10 @@
# Test Configuration: Molniya Orbit # Test Configuration: Molniya Orbit
# Earth as root body with highly elliptical, highly inclined satellite orbit # Earth as root body with highly elliptical, highly inclined satellite orbit
# Molniya orbit parameters: # Molniya orbit parameters:
# - Period: ~718 minutes (~12 hours) # - Semi-major axis: 26,540 km
# - Eccentricity: 0.74 # - Eccentricity: 0.74
# - Inclination: 63.4° # - Inclination: 63.4 deg
# - Argument of perigee: 270° (apogee at northernmost point) # - Argument of perigee: 270 deg (apogee at northernmost point)
# - Perigee altitude: ~600 km
# - Apogee altitude: ~39,700 km
# - Semi-major axis: ~26,600 km
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
@ -15,21 +12,10 @@ mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = -1 parent_index = -1
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[spacecraft]] [[spacecraft]]
name = "Molniya_Satellite" name = "Molniya_Satellite"
mass = 1000.0 mass = 1000.0
parent_index = 0 parent_index = 0
orbit = { orbit = { semi_major_axis = 26540000.0, eccentricity = 0.74, true_anomaly = 0.0, inclination = 1.107, longitude_of_ascending_node = 0.0, argument_of_periapsis = 4.71 }
semi_major_axis = 26540000.0,
eccentricity = 0.74,
true_anomaly = 0.0,
inclination = 1.107,
longitude_of_ascending_node = 0.0,
argument_of_periapsis = 4.71
}

244
tests/test_integration.cpp

@ -1,244 +0,0 @@
#include <catch2/catch_test_macros.hpp>
#include "../src/physics.h"
#include "../src/test_utilities.h"
#include <cmath>
TEST_CASE("Vector math utilities", "[utilities]") {
Vec3 a = {1.0, 2.0, 3.0};
Vec3 b = {4.0, 5.0, 6.0};
SECTION("Vector addition") {
Vec3 sum = vec3_add(a, b);
REQUIRE(compare_double(sum.x, 5.0, 1e-10));
REQUIRE(compare_double(sum.y, 7.0, 1e-10));
REQUIRE(compare_double(sum.z, 9.0, 1e-10));
}
SECTION("Vector subtraction") {
Vec3 diff = vec3_sub(b, a);
REQUIRE(compare_double(diff.x, 3.0, 1e-10));
REQUIRE(compare_double(diff.y, 3.0, 1e-10));
REQUIRE(compare_double(diff.z, 3.0, 1e-10));
}
SECTION("Vector scaling") {
Vec3 scaled = vec3_scale(a, 2.0);
REQUIRE(compare_double(scaled.x, 2.0, 1e-10));
REQUIRE(compare_double(scaled.y, 4.0, 1e-10));
REQUIRE(compare_double(scaled.z, 6.0, 1e-10));
}
SECTION("Vector magnitude") {
double mag = vec3_magnitude(a);
REQUIRE(compare_double(mag, sqrt(14.0), 1e-10));
}
SECTION("Vector distance") {
double dist = vec3_distance(a, b);
REQUIRE(compare_double(dist, sqrt(27.0), 1e-10));
}
SECTION("Vector normalization") {
Vec3 unit = vec3_normalize(a);
double expected_mag = 1.0;
double actual_mag = vec3_magnitude(unit);
REQUIRE(compare_double(actual_mag, expected_mag, 1e-10));
}
SECTION("Vector dot product") {
double dot = vec3_dot(a, b);
REQUIRE(compare_double(dot, 32.0, 1e-10));
}
SECTION("Vector cross product") {
Vec3 cross = vec3_cross(a, b);
REQUIRE(compare_double(cross.x, -3.0, 1e-10));
REQUIRE(compare_double(cross.y, 6.0, 1e-10));
REQUIRE(compare_double(cross.z, -3.0, 1e-10));
}
}
TEST_CASE("Acceleration calculation", "[physics][acceleration]") {
Vec3 force = {10.0, 20.0, 30.0};
double mass = 5.0;
Vec3 accel = calculate_acceleration(force, mass);
REQUIRE(compare_double(accel.x, 2.0, 1e-10));
REQUIRE(compare_double(accel.y, 4.0, 1e-10));
REQUIRE(compare_double(accel.z, 6.0, 1e-10));
}
TEST_CASE("Gravitational acceleration evaluation", "[physics][gravity]") {
Vec3 position = {1.0, 0.0, 0.0};
double body_mass = 1000.0;
double parent_mass = 1e10;
Vec3 accel = evaluate_acceleration(position, body_mass, parent_mass);
double expected_magnitude = G * parent_mass / (1.0 * 1.0);
REQUIRE(compare_double(vec3_magnitude(accel), expected_magnitude, 1e-10));
REQUIRE(compare_double(accel.x, -expected_magnitude, 1e-10));
}
TEST_CASE("RK4 integration step", "[physics][rk4]") {
Vec3 position = {0.0, 1.0, 0.0};
Vec3 velocity = {sqrt(G * 1e10 / 1.0), 0.0, 0.0};
double dt = 1.0;
double body_mass = 1.0;
double parent_mass = 1e10;
double initial_distance = vec3_magnitude(position);
rk4_step(&position, &velocity, dt, body_mass, parent_mass);
double final_distance = vec3_magnitude(position);
REQUIRE(final_distance > 0.9 * initial_distance);
REQUIRE(final_distance < 1.1 * initial_distance);
}
TEST_CASE("Matrix identity", "[matrix][identity]") {
Mat3 I = mat3_identity();
REQUIRE(compare_double(I.m00, 1.0, 1e-10));
REQUIRE(compare_double(I.m01, 0.0, 1e-10));
REQUIRE(compare_double(I.m02, 0.0, 1e-10));
REQUIRE(compare_double(I.m10, 0.0, 1e-10));
REQUIRE(compare_double(I.m11, 1.0, 1e-10));
REQUIRE(compare_double(I.m12, 0.0, 1e-10));
REQUIRE(compare_double(I.m20, 0.0, 1e-10));
REQUIRE(compare_double(I.m21, 0.0, 1e-10));
REQUIRE(compare_double(I.m22, 1.0, 1e-10));
}
TEST_CASE("Matrix-vector multiplication", "[matrix][vector]") {
Mat3 m = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0};
Vec3 v = {1.0, 2.0, 3.0};
Vec3 result = mat3_multiply_vec3(m, v);
REQUIRE(compare_double(result.x, 14.0, 1e-10));
REQUIRE(compare_double(result.y, 32.0, 1e-10));
REQUIRE(compare_double(result.z, 50.0, 1e-10));
}
TEST_CASE("Matrix multiplication with identity", "[matrix][multiply]") {
Mat3 A = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0};
Mat3 I = mat3_identity();
Mat3 result = mat3_multiply(A, I);
REQUIRE(compare_double(result.m00, A.m00, 1e-10));
REQUIRE(compare_double(result.m01, A.m01, 1e-10));
REQUIRE(compare_double(result.m02, A.m02, 1e-10));
REQUIRE(compare_double(result.m10, A.m10, 1e-10));
REQUIRE(compare_double(result.m11, A.m11, 1e-10));
REQUIRE(compare_double(result.m12, A.m12, 1e-10));
REQUIRE(compare_double(result.m20, A.m20, 1e-10));
REQUIRE(compare_double(result.m21, A.m21, 1e-10));
REQUIRE(compare_double(result.m22, A.m22, 1e-10));
}
TEST_CASE("Rotation about Z axis", "[matrix][rotation]") {
double angle = M_PI / 2; // 90 degrees
Mat3 Rz = mat3_rotation_z(angle);
Vec3 v = {1.0, 0.0, 0.0};
Vec3 result = mat3_multiply_vec3(Rz, v);
REQUIRE(compare_double(result.x, 0.0, 1e-10));
REQUIRE(compare_double(result.y, 1.0, 1e-10));
REQUIRE(compare_double(result.z, 0.0, 1e-10));
}
TEST_CASE("Rotation about X axis", "[matrix][rotation]") {
double angle = M_PI / 2; // 90 degrees
Mat3 Rx = mat3_rotation_x(angle);
Vec3 v = {0.0, 1.0, 0.0};
Vec3 result = mat3_multiply_vec3(Rx, v);
REQUIRE(compare_double(result.x, 0.0, 1e-10));
REQUIRE(compare_double(result.y, 0.0, 1e-10));
REQUIRE(compare_double(result.z, 1.0, 1e-10));
}
TEST_CASE("Rotation edge cases", "[matrix][rotation][edge]") {
SECTION("180 degree rotation") {
Mat3 Rz180 = mat3_rotation_z(M_PI);
Vec3 v = {1.0, 0.0, 0.0};
Vec3 result = mat3_multiply_vec3(Rz180, v);
REQUIRE(compare_double(result.x, -1.0, 1e-10));
REQUIRE(compare_double(result.y, 0.0, 1e-10));
REQUIRE(compare_double(result.z, 0.0, 1e-10));
}
SECTION("360 degree rotation equals identity") {
Mat3 Rz360 = mat3_rotation_z(2.0 * M_PI);
Mat3 I = mat3_identity();
REQUIRE(compare_double(Rz360.m00, I.m00, 1e-10));
REQUIRE(compare_double(Rz360.m11, I.m11, 1e-10));
REQUIRE(compare_double(Rz360.m22, I.m22, 1e-10));
}
SECTION("Negative angle equals positive rotation") {
Mat3 Rz_neg90 = mat3_rotation_z(-M_PI / 2);
Mat3 Rz_270 = mat3_rotation_z(3.0 * M_PI / 2);
REQUIRE(compare_double(Rz_neg90.m00, Rz_270.m00, 1e-10));
REQUIRE(compare_double(Rz_neg90.m01, Rz_270.m01, 1e-10));
REQUIRE(compare_double(Rz_neg90.m10, Rz_270.m10, 1e-10));
REQUIRE(compare_double(Rz_neg90.m11, Rz_270.m11, 1e-10));
}
SECTION("Combined rotations that cancel") {
Mat3 Rz90 = mat3_rotation_z(M_PI / 2);
Mat3 Rz_neg90 = mat3_rotation_z(-M_PI / 2);
Mat3 combined = mat3_multiply(Rz_neg90, Rz90);
Mat3 I = mat3_identity();
REQUIRE(compare_double(combined.m00, I.m00, 1e-10));
REQUIRE(compare_double(combined.m11, I.m11, 1e-10));
REQUIRE(compare_double(combined.m22, I.m22, 1e-10));
}
}
TEST_CASE("Rotation matrix orthogonality", "[matrix][rotation][validation]") {
double angle = M_PI / 4; // 45 degrees
Mat3 Rz = mat3_rotation_z(angle);
Mat3 Rz_T = {Rz.m00, Rz.m10, Rz.m20,
Rz.m01, Rz.m11, Rz.m21,
Rz.m02, Rz.m12, Rz.m22};
Mat3 product = mat3_multiply(Rz, Rz_T);
Mat3 I = mat3_identity();
REQUIRE(compare_double(product.m00, I.m00, 1e-10));
REQUIRE(compare_double(product.m01, I.m01, 1e-10));
REQUIRE(compare_double(product.m02, I.m02, 1e-10));
REQUIRE(compare_double(product.m10, I.m10, 1e-10));
REQUIRE(compare_double(product.m11, I.m11, 1e-10));
REQUIRE(compare_double(product.m12, I.m12, 1e-10));
REQUIRE(compare_double(product.m20, I.m20, 1e-10));
REQUIRE(compare_double(product.m21, I.m21, 1e-10));
REQUIRE(compare_double(product.m22, I.m22, 1e-10));
}
TEST_CASE("Orbital rotation matrix", "[matrix][orbital]") {
double omega = 0.0;
double i = M_PI / 2; // 90 degrees inclination
double Omega = 0.0;
Mat3 R = mat3_rotation_orbital(omega, i, Omega);
Vec3 v = {1.0, 0.0, 0.0};
Vec3 result = mat3_multiply_vec3(R, v);
REQUIRE(compare_double(result.x, 1.0, 1e-10));
REQUIRE(compare_double(result.y, 0.0, 1e-10));
REQUIRE(compare_double(result.z, 0.0, 1e-10));
}

165
tests/test_maneuver_planning.cpp

@ -5,148 +5,77 @@
#include "../src/orbital_objects.h" #include "../src/orbital_objects.h"
#include "../src/maneuver.h" #include "../src/maneuver.h"
#include "../src/config_loader.h" #include "../src/config_loader.h"
#include "../src/rendezvous.h"
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include <cmath>
#include <cstring>
using Catch::Matchers::WithinAbs; using Catch::Matchers::WithinAbs;
TEST_CASE("Maneuver loading from config", "[maneuver][config]") { SCENARIO("Maneuver planning and execution", "[maneuver][planning][trigger][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; 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; 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); SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuver_planning.toml")); REQUIRE(load_system_config(sim, "tests/test_maneuver_planning.toml"));
REQUIRE(sim->maneuver_count == 2); auto run_until = [&](double target_time) {
REQUIRE(!sim->maneuvers[1].executed); while (sim->time < target_time) {
update_simulation(sim);
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;
} }
};
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));
} }
REQUIRE(sim->maneuvers[1].executed); SECTION("time-based trigger executes at 3600 s") {
REQUIRE(!sim->maneuvers[0].executed);
REQUIRE(!sim->maneuvers[1].executed);
double second_burn_velocity = vec3_magnitude(sim->spacecraft[0].local_velocity); run_until(BURN_TIME + TIME_STEP);
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); REQUIRE(sim->maneuvers[0].executed);
REQUIRE_THAT(sim->maneuvers[0].executed_time, WithinAbs(BURN_TIME, TIME_STEP));
destroy_simulation(sim); const double after_velocity = vec3_magnitude(sim->spacecraft[0].local_velocity);
} REQUIRE_THAT(after_velocity, WithinAbs(8.170251503359999e3, V_TOL));
}
TEST_CASE("Maneuvers only execute once", "[maneuver][execution]") { SECTION("true anomaly trigger executes after first burn") {
const double TIME_STEP = 60.0; REQUIRE(!sim->maneuvers[1].executed);
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP); run_until(BURN_TIME + TIME_STEP);
REQUIRE(sim->maneuvers[0].executed);
REQUIRE(load_system_config(sim, "tests/test_maneuver_planning.toml")); const double max_sim_time = BURN_TIME + 72000.0;
while (sim->time < max_sim_time) {
update_simulation(sim);
if (sim->maneuvers[1].executed) {
break;
}
}
const double MAX_TIME = 20000.0; REQUIRE(sim->maneuvers[1].executed);
while (sim->time < MAX_TIME) { REQUIRE_THAT(vec3_magnitude(sim->spacecraft[0].local_velocity),
update_simulation(sim); WithinAbs(8.672299586435140e3, V_TOL));
} }
REQUIRE(sim->maneuvers[0].executed); SECTION("maneuvers execute only once") {
REQUIRE(sim->maneuvers[1].executed); run_until(20000.0);
REQUIRE(sim->maneuvers[0].executed);
REQUIRE(sim->maneuvers[1].executed);
double execution_count = 0.0; int execution_count = 0;
for (int i = 0; i < sim->maneuver_count; i++) { for (int i = 0; i < sim->maneuver_count; i++) {
if (sim->maneuvers[i].executed) { if (sim->maneuvers[i].executed) {
execution_count += 1.0; execution_count++;
}
} }
REQUIRE(execution_count == 2);
} }
REQUIRE(execution_count == 2.0);
destroy_simulation(sim); 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);
}

24
tests/test_maneuver_planning.toml

@ -7,36 +7,22 @@ name = "Sun"
mass = 1.989e30 mass = 1.989e30
radius = 6.96e8 radius = 6.96e8
parent_index = -1 parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 } color = {r = 1.0, g = 1.0, b = 0.0}
orbit = { orbit = {semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0}
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
mass = 5.972e24 mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = 0 parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 } color = {r = 0.0, g = 0.5, b = 1.0}
orbit = { orbit = {semi_major_axis = 1.496e11, eccentricity = 0.0, true_anomaly = 0.0}
semi_major_axis = 1.496e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[spacecraft]] [[spacecraft]]
name = "LEO_Satellite" name = "LEO_Satellite"
mass = 1000.0 mass = 1000.0
parent_index = 1 parent_index = 1
orbit = { orbit = {semi_major_axis = 6.771e6, eccentricity = 0.0, true_anomaly = 1.57}
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
[[maneuvers]] [[maneuvers]]
name = "orbit_raise_1" name = "orbit_raise_1"

316
tests/test_maneuvers.cpp

@ -1,4 +1,5 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h" #include "../src/physics.h"
#include "../src/simulation.h" #include "../src/simulation.h"
#include "../src/orbital_objects.h" #include "../src/orbital_objects.h"
@ -7,53 +8,11 @@
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include <cmath> #include <cmath>
TEST_CASE("Spacecraft loading from config", "[spacecraft][config]") { using Catch::Matchers::WithinAbs;
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuvers.toml"));
REQUIRE(sim->craft_count == 1);
REQUIRE(std::string(sim->spacecraft[0].name) == "LEO_Satellite");
REQUIRE(sim->spacecraft[0].parent_index == 1);
destroy_simulation(sim); SCENARIO("Spacecraft loading and impulsive burn behavior", "[spacecraft][config][burn]") {
}
TEST_CASE("Prograde burn increases orbital energy", "[spacecraft][burn][prograde]") {
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuvers.toml"));
Spacecraft* craft = &sim->spacecraft[0];
CelestialBody* earth = &sim->bodies[1];
double initial_distance = vec3_distance(craft->global_position, earth->global_position);
double initial_velocity = vec3_magnitude(craft->local_velocity);
apply_impulsive_burn(craft, BURN_PROGRADE, 100.0);
REQUIRE(vec3_magnitude(craft->local_velocity) > initial_velocity);
const double SECONDS_TO_SIMULATE = 3600.0; const double SECONDS_TO_SIMULATE = 3600.0;
double sim_time = 0.0;
while (sim_time < SECONDS_TO_SIMULATE) {
update_simulation(sim);
sim_time += TIME_STEP;
}
double final_distance = vec3_distance(craft->global_position, earth->global_position);
REQUIRE(final_distance > initial_distance);
destroy_simulation(sim);
}
TEST_CASE("Retrograde burn decreases orbital energy", "[spacecraft][burn][retrograde]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP); SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuvers.toml")); REQUIRE(load_system_config(sim, "tests/test_maneuvers.toml"));
@ -61,172 +20,173 @@ TEST_CASE("Retrograde burn decreases orbital energy", "[spacecraft][burn][retrog
Spacecraft* craft = &sim->spacecraft[0]; Spacecraft* craft = &sim->spacecraft[0];
CelestialBody* earth = &sim->bodies[1]; CelestialBody* earth = &sim->bodies[1];
double initial_distance = vec3_distance(craft->global_position, earth->global_position); const double initial_r = vec3_distance(craft->local_position, (Vec3){0,0,0});
double initial_velocity = vec3_magnitude(craft->local_velocity); const double initial_a = craft->orbit.semi_major_axis;
const double initial_e = craft->orbit.eccentricity;
apply_impulsive_burn(craft, BURN_RETROGRADE, 100.0); auto simulate = [&]() {
double sim_time = 0.0;
REQUIRE(vec3_magnitude(craft->local_velocity) < initial_velocity); while (sim_time < SECONDS_TO_SIMULATE) {
update_simulation(sim);
sim_time += TIME_STEP;
}
};
const double SECONDS_TO_SIMULATE = 3600.0; SECTION("spacecraft loads correctly") {
double sim_time = 0.0; REQUIRE_THAT(sim->craft_count, WithinAbs(1.0, 0.001));
while (sim_time < SECONDS_TO_SIMULATE) { REQUIRE(std::string(sim->spacecraft[0].name) == "LEO_Satellite");
update_simulation(sim); REQUIRE_THAT(sim->spacecraft[0].parent_index, WithinAbs(1.0, 0.001));
sim_time += TIME_STEP;
} }
double final_distance = vec3_distance(craft->global_position, earth->global_position); SECTION("prograde burn increases velocity and raises apoapsis") {
REQUIRE(final_distance < initial_distance); double v_before = vec3_magnitude(craft->local_velocity);
apply_impulsive_burn(craft, BURN_PROGRADE, 100.0);
destroy_simulation(sim);
}
TEST_CASE("Normal burn changes orbital plane", "[spacecraft][burn][normal]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP); REQUIRE_THAT(vec3_magnitude(craft->local_velocity), WithinAbs(v_before + 100.0, 0.001));
REQUIRE(load_system_config(sim, "tests/test_maneuvers.toml")); simulate();
Spacecraft* craft = &sim->spacecraft[0]; double final_r = vec3_distance(craft->local_position, (Vec3){0,0,0});
INFO("Initial r: " << initial_r << " m");
INFO("Final r: " << final_r << " m");
INFO("delta_r: " << (final_r - initial_r) << " m");
double initial_z = craft->local_position.z; // Prograde burn raises apoapsis; after ~225° craft is past periapsis toward apoapsis
REQUIRE_THAT(final_r, WithinAbs(7085656.0, 320000.0));
apply_impulsive_burn(craft, BURN_NORMAL, 500.0);
const double SECONDS_TO_SIMULATE = 3600.0;
double sim_time = 0.0;
while (sim_time < SECONDS_TO_SIMULATE) {
update_simulation(sim);
sim_time += TIME_STEP;
} }
REQUIRE(fabs(craft->local_position.z - initial_z) > 1000.0); SECTION("retrograde burn decreases velocity and lowers periapsis") {
double v_before = vec3_magnitude(craft->local_velocity);
destroy_simulation(sim); apply_impulsive_burn(craft, BURN_RETROGRADE, 100.0);
}
TEST_CASE("Custom burn applies arbitrary delta-v", "[spacecraft][burn][custom]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuvers.toml"));
Spacecraft* craft = &sim->spacecraft[0];
Vec3 initial_vel = craft->local_velocity;
Vec3 delta_v = {10.0, 20.0, 30.0};
apply_custom_burn(craft, delta_v);
REQUIRE(fabs(craft->local_velocity.x - initial_vel.x - 10.0) < 0.001); REQUIRE_THAT(vec3_magnitude(craft->local_velocity), WithinAbs(v_before - 100.0, 0.001));
REQUIRE(fabs(craft->local_velocity.y - initial_vel.y - 20.0) < 0.001);
REQUIRE(fabs(craft->local_velocity.z - initial_vel.z - 30.0) < 0.001);
destroy_simulation(sim); simulate();
}
TEST_CASE("Spacecraft propagation maintains stability", "[spacecraft][propagation]") {
const double TIME_STEP = 60.0;
const double DAYS_TO_SIMULATE = 1.0;
const double SECONDS_PER_DAY = 86400.0;
SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuvers.toml"));
Spacecraft* craft = &sim->spacecraft[0];
CelestialBody* earth = &sim->bodies[1];
double initial_distance = vec3_distance(craft->global_position, earth->global_position); double final_r = vec3_distance(craft->local_position, (Vec3){0,0,0});
INFO("Initial r: " << initial_r << " m");
INFO("Final r: " << final_r << " m");
INFO("delta_r: " << (final_r - initial_r) << " m");
double total_time = DAYS_TO_SIMULATE * SECONDS_PER_DAY; // Retrograde burn lowers periapsis; after ~240° craft is near periapsis
double sim_time = 0.0; REQUIRE_THAT(final_r, WithinAbs(6525686.0, 250000.0));
while (sim_time < total_time) {
update_simulation(sim);
sim_time += TIME_STEP;
} }
double final_distance = vec3_distance(craft->global_position, earth->global_position); SECTION("normal burn changes orbital plane (z displacement)") {
double distance_drift_percent = fabs((final_distance - initial_distance) / initial_distance) * 100.0; double initial_z = craft->local_position.z;
apply_impulsive_burn(craft, BURN_NORMAL, 500.0);
INFO("Initial distance: " << initial_distance << " m"); simulate();
INFO("Final distance: " << final_distance << " m");
INFO("Distance drift: " << distance_drift_percent << "%");
REQUIRE(distance_drift_percent < 1.0); double z_change = fabs(craft->local_position.z - initial_z);
INFO("Initial z: " << initial_z << " m");
INFO("Final z: " << craft->local_position.z << " m");
INFO("|z_change|: " << z_change << " m");
destroy_simulation(sim); // Normal burn introduces inclination; after 3600s z displacement ~348km
} REQUIRE_THAT(z_change, WithinAbs(348678.0, 350000.0));
}
TEST_CASE("Spacecraft state vectors at orbital quarters", "[spacecraft][state_vectors]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuvers.toml"));
Spacecraft* craft = &sim->spacecraft[0]; SECTION("custom burn applies arbitrary delta-v vector") {
CelestialBody* earth = &sim->bodies[1]; Vec3 initial_vel = craft->local_velocity;
Vec3 delta_v = {10.0, 20.0, 30.0};
double orbit_radius = vec3_magnitude(craft->local_position); apply_custom_burn(craft, delta_v);
double earth_mass = earth->mass;
double orbital_period = 2.0 * M_PI * sqrt(pow(orbit_radius, 3.0) / (G * earth_mass)); REQUIRE_THAT(craft->local_velocity.x, WithinAbs(initial_vel.x + 10.0, 0.001));
double quarter_orbit_time = orbital_period / 4.0; REQUIRE_THAT(craft->local_velocity.y, WithinAbs(initial_vel.y + 20.0, 0.001));
int steps_per_quarter = (int)(quarter_orbit_time / TIME_STEP); REQUIRE_THAT(craft->local_velocity.z, WithinAbs(initial_vel.z + 30.0, 0.001));
}
INFO("Orbital radius: " << orbit_radius << " m"); SECTION("propagation stability over 1 day") {
INFO("Expected orbital period: " << orbital_period << " s (" << orbital_period / 3600.0 << " hours)"); const double total_time = 86400.0;
INFO("Steps per quarter: " << steps_per_quarter); double sim_time = 0.0;
while (sim_time < total_time) {
update_simulation(sim);
sim_time += TIME_STEP;
}
double previous_angle = atan2(craft->local_position.y, craft->local_position.x); double final_r = vec3_distance(craft->local_position, (Vec3){0,0,0});
double final_a = craft->orbit.semi_major_axis;
double final_e = craft->orbit.eccentricity;
double distance_drift_pct = fabs((final_r - initial_r) / initial_r) * 100.0;
double a_drift_pct = fabs((final_a - initial_a) / initial_a) * 100.0;
INFO("Initial r: " << initial_r << " m");
INFO("Final r: " << final_r << " m");
INFO("Distance drift: " << distance_drift_pct << "%");
INFO("Initial a: " << initial_a << " m");
INFO("Final a: " << final_a << " m");
INFO("a drift: " << a_drift_pct << "%");
INFO("Initial e: " << initial_e);
INFO("Final e: " << final_e);
REQUIRE_THAT(distance_drift_pct, WithinAbs(0.0, 0.01));
REQUIRE_THAT(a_drift_pct, WithinAbs(0.0, 0.01));
REQUIRE_THAT(final_e, WithinAbs(initial_e, 1e-6));
}
for (int quarter = 0; quarter <= 4; quarter++) { SECTION("state vectors at orbital quarters") {
INFO(""); const double orbit_radius = vec3_distance(craft->local_position, (Vec3){0,0,0});
INFO("=== Point " << quarter << "/4 (" << (quarter * 90) << "°) ==="); const double earth_mass = earth->mass;
INFO("Local position: (" << craft->local_position.x << ", " << craft->local_position.y << ", " << craft->local_position.z << ") m"); const double orbital_period = 2.0 * M_PI * sqrt(pow(orbit_radius, 3.0) / (G * earth_mass));
INFO("Local velocity: (" << craft->local_velocity.x << ", " << craft->local_velocity.y << ", " << craft->local_velocity.z << ") m/s"); const double quarter_orbit_time = orbital_period / 4.0;
const int steps_per_quarter = (int)(quarter_orbit_time / TIME_STEP);
double current_radius = vec3_magnitude(craft->local_position);
double current_velocity = vec3_magnitude(craft->local_velocity); INFO("Orbital radius: " << orbit_radius << " m");
double current_angle = atan2(craft->local_position.y, craft->local_position.x); INFO("Expected orbital period: " << orbital_period << " s (" << orbital_period / 3600.0 << " hours)");
INFO("Steps per quarter: " << steps_per_quarter);
INFO("Radius: " << current_radius << " m");
INFO("Velocity magnitude: " << current_velocity << " m/s"); double previous_angle = atan2(craft->local_position.y, craft->local_position.x);
INFO("Angular position: " << current_angle << " rad (" << (current_angle * 180.0 / M_PI) << "°)");
for (int quarter = 0; quarter <= 4; quarter++) {
if (quarter > 0) { INFO("");
double angle_change = current_angle - previous_angle; INFO("=== Point " << quarter << "/4 (" << (quarter * 90) << " deg) ===");
if (angle_change < 0) angle_change += 2.0 * M_PI; INFO("Local position: (" << craft->local_position.x << ", " << craft->local_position.y << ", " << craft->local_position.z << ") m");
INFO("Angle change from previous: " << angle_change << " rad (" << (angle_change * 180.0 / M_PI) << "°)"); INFO("Local velocity: (" << craft->local_velocity.x << ", " << craft->local_velocity.y << ", " << craft->local_velocity.z << ") m/s");
REQUIRE(fabs(angle_change - M_PI / 2.0) < 0.1);
} double current_radius = vec3_distance(craft->local_position, (Vec3){0,0,0});
double current_velocity = vec3_magnitude(craft->local_velocity);
double current_angle = atan2(craft->local_position.y, craft->local_position.x);
INFO("Radius: " << current_radius << " m");
INFO("Velocity magnitude: " << current_velocity << " m/s");
INFO("Angular position: " << current_angle << " rad (" << (current_angle * 180.0 / M_PI) << " deg)");
if (quarter > 0) {
double angle_change = current_angle - previous_angle;
if (angle_change < 0) angle_change += 2.0 * M_PI;
INFO("Angle change from previous: " << angle_change << " rad (" << (angle_change * 180.0 / M_PI) << " deg)");
// Each quarter advances ~89.5953° (23 steps of 60s on 5544.93s orbit)
REQUIRE_THAT(angle_change, WithinAbs(1.56373, 0.001));
}
if (quarter < 4) { if (quarter < 4) {
for (int step = 0; step < steps_per_quarter; step++) { for (int step = 0; step < steps_per_quarter; step++) {
update_simulation(sim); update_simulation(sim);
}
} }
}
previous_angle = current_angle; previous_angle = current_angle;
} }
INFO(""); INFO("");
INFO("=== Final Summary ==="); INFO("=== Final Summary ===");
double final_radius = vec3_magnitude(craft->local_position); double final_radius = vec3_distance(craft->local_position, (Vec3){0,0,0});
double final_velocity = vec3_magnitude(craft->local_velocity); double final_velocity = vec3_magnitude(craft->local_velocity);
double final_angle = atan2(craft->local_position.y, craft->local_position.x); double final_angle = atan2(craft->local_position.y, craft->local_position.x);
double total_rotation = final_angle; double total_rotation = final_angle;
if (total_rotation < 0) total_rotation += 2.0 * M_PI; if (total_rotation < 0) total_rotation += 2.0 * M_PI;
INFO("Total rotation: " << total_rotation << " rad (" << (total_rotation * 180.0 / M_PI) << "°)"); INFO("Total rotation: " << total_rotation << " rad (" << (total_rotation * 180.0 / M_PI) << " deg)");
INFO("Radius change: " << ((final_radius - orbit_radius) / orbit_radius * 100.0) << "%"); INFO("Radius change: " << ((final_radius - orbit_radius) / orbit_radius * 100.0) << "%");
INFO("Velocity change: " << ((final_velocity - vec3_magnitude(craft->local_velocity)) / vec3_magnitude(craft->local_velocity) * 100.0) << "%"); INFO("Velocity change: " << ((final_velocity - vec3_magnitude(craft->local_velocity)) / vec3_magnitude(craft->local_velocity) * 100.0) << "%");
REQUIRE(fabs(total_rotation - 2.0 * M_PI) < 0.1); // 92 steps of 60s = 5520s on 5544.93s orbit; ~358.38° total
REQUIRE_THAT(total_rotation, WithinAbs(6.25493, 0.001));
}
destroy_simulation(sim); destroy_simulation(sim);
} }

18
tests/test_maneuvers.toml

@ -8,11 +8,7 @@ mass = 1.989e30
radius = 6.96e8 radius = 6.96e8
parent_index = -1 parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 } color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = { orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
@ -20,19 +16,11 @@ mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = 0 parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { orbit = { semi_major_axis = 1.496e11, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 1.496e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[spacecraft]] [[spacecraft]]
name = "LEO_Satellite" name = "LEO_Satellite"
mass = 1000.0 mass = 1000.0
parent_index = 1 parent_index = 1
orbit = { orbit = { semi_major_axis = 6.771e6, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 6.771e6,
eccentricity = 0.0,
true_anomaly = 0.0
}
# LEO orbit: 400 km altitude (Earth radius 6.371e6 m + 400e3 m) # LEO orbit: 400 km altitude (Earth radius 6.371e6 m + 400e3 m)

437
tests/test_moon_orbits.cpp

@ -1,266 +1,273 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h" #include "../src/physics.h"
#include "../src/simulation.h" #include "../src/simulation.h"
#include "../src/config_loader.h" #include "../src/config_loader.h"
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include <cmath> #include <cmath>
#include <vector> #include <vector>
#include <string>
TEST_CASE("Moon orbital stability around Earth", "[moon][earth]") { using Catch::Matchers::WithinAbs;
const double TIME_STEP = 60.0;
const double EXPECTED_PERIOD_DAYS = 27.3;
const double SECONDS_PER_DAY = 86400.0;
const double MAX_SIMULATION_DAYS = 35.0;
const double MOON_DISTANCE_FROM_EARTH = 384400000.0;
SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml"));
const int EARTH_INDEX = 2;
const int MOON_INDEX = 8;
double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY;
OrbitTracker* tracker = create_orbit_tracker_with_min_time(MOON_INDEX, 5.0);
int initial_parent = sim->bodies[MOON_INDEX].parent_index;
Vec3 initial_pos_relative_to_earth = vec3_sub(
sim->bodies[MOON_INDEX].global_position,
sim->bodies[EARTH_INDEX].global_position
);
double initial_distance = vec3_magnitude(initial_pos_relative_to_earth);
INFO("Moon initial distance from Earth: " << initial_distance << " m");
INFO("Expected distance: " << MOON_DISTANCE_FROM_EARTH << " m");
while (sim->time < max_time && !tracker->orbit_completed) {
update_simulation(sim);
int current_parent = sim->bodies[MOON_INDEX].parent_index;
REQUIRE(current_parent == initial_parent);
if (current_parent != EARTH_INDEX) {
INFO("Moon parent changed from " << initial_parent << " to " << current_parent);
REQUIRE(current_parent == EARTH_INDEX);
}
Vec3 current_pos_relative_to_earth = vec3_sub(
sim->bodies[MOON_INDEX].global_position,
sim->bodies[EARTH_INDEX].global_position
);
double current_distance = vec3_magnitude(current_pos_relative_to_earth);
double distance_drift = fabs(current_distance - initial_distance);
double drift_percentage = (distance_drift / initial_distance) * 100.0;
REQUIRE(drift_percentage < 20.0);
update_orbit_tracker(tracker, &sim->bodies[MOON_INDEX], &sim->bodies[EARTH_INDEX], sim->time);
}
REQUIRE(tracker->orbit_completed);
double measured_period_days = tracker->time_at_completion / SECONDS_PER_DAY;
double period_error_days = fabs(measured_period_days - EXPECTED_PERIOD_DAYS);
INFO("Expected Moon period: " << EXPECTED_PERIOD_DAYS << " days"); // === Helper: run sim loop, collect failures, assert once at end ===
INFO("Measured Moon period: " << measured_period_days << " days"); static std::string fmt_time(double t) {
INFO("Period error: " << period_error_days << " days"); char buf[32];
snprintf(buf, sizeof(buf), "t=%.0fs", t);
REQUIRE(period_error_days < 3.0); return std::string(buf);
Vec3 final_pos_relative_to_earth = vec3_sub(
sim->bodies[MOON_INDEX].global_position,
sim->bodies[EARTH_INDEX].global_position
);
double final_distance = vec3_magnitude(final_pos_relative_to_earth);
double final_drift_percentage = (fabs(final_distance - initial_distance) / initial_distance) * 100.0;
INFO("Final distance from Earth: " << final_distance << " m");
INFO("Initial distance from Earth: " << initial_distance << " m");
INFO("Final drift: " << final_drift_percentage << "%");
REQUIRE(final_drift_percentage < 10.0);
destroy_orbit_tracker(tracker);
destroy_simulation(sim);
} }
TEST_CASE("Galilean moons orbital stability around Jupiter", "[moon][jupiter]") { static std::string fmt_drift(double d) {
const double TIME_STEP = 60.0; char buf[32];
const double SECONDS_PER_DAY = 86400.0; snprintf(buf, sizeof(buf), "drift=%.4f", d);
const double MAX_SIMULATION_DAYS = 20.0; return std::string(buf);
}
const double IO_PERIOD_DAYS = 1.77;
const double EUROPA_PERIOD_DAYS = 3.55;
const double GANYMEDE_PERIOD_DAYS = 7.15;
const double CALLISTO_PERIOD_DAYS = 16.69;
SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml"));
const int JUPITER_INDEX = 4;
const int IO_INDEX = 9;
const int EUROPA_INDEX = 10;
const int GANYMEDE_INDEX = 11;
const int CALLISTO_INDEX = 12;
OrbitTracker* io_tracker = create_orbit_tracker_with_min_time(IO_INDEX, 1.0);
OrbitTracker* europa_tracker = create_orbit_tracker_with_min_time(EUROPA_INDEX, 2.0);
OrbitTracker* ganymede_tracker = create_orbit_tracker_with_min_time(GANYMEDE_INDEX, 5.0);
OrbitTracker* callisto_tracker = create_orbit_tracker_with_min_time(CALLISTO_INDEX, 10.0);
double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY;
while (sim->time < max_time) {
update_simulation(sim);
REQUIRE(sim->bodies[IO_INDEX].parent_index == JUPITER_INDEX); // === Per-moon period tolerances (0.5% of analytical period, from precalc) ===
REQUIRE(sim->bodies[EUROPA_INDEX].parent_index == JUPITER_INDEX); static const double MOON_PERIOD_TOL = 11861.4; // Moon
REQUIRE(sim->bodies[GANYMEDE_INDEX].parent_index == JUPITER_INDEX); static const double IO_PERIOD_TOL = 764.6; // Io
REQUIRE(sim->bodies[CALLISTO_INDEX].parent_index == JUPITER_INDEX); 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
update_orbit_tracker(io_tracker, &sim->bodies[IO_INDEX], &sim->bodies[JUPITER_INDEX], sim->time);
update_orbit_tracker(europa_tracker, &sim->bodies[EUROPA_INDEX], &sim->bodies[JUPITER_INDEX], sim->time);
update_orbit_tracker(ganymede_tracker, &sim->bodies[GANYMEDE_INDEX], &sim->bodies[JUPITER_INDEX], sim->time);
update_orbit_tracker(callisto_tracker, &sim->bodies[CALLISTO_INDEX], &sim->bodies[JUPITER_INDEX], sim->time);
}
REQUIRE(io_tracker->orbit_completed); // === Drift tolerance for distance checks (10% bound) ===
REQUIRE(europa_tracker->orbit_completed); static const double DRIFT_REL_TOL = 0.1;
REQUIRE(ganymede_tracker->orbit_completed);
REQUIRE(callisto_tracker->orbit_completed);
double io_period_days = io_tracker->time_at_completion / SECONDS_PER_DAY;
double europa_period_days = europa_tracker->time_at_completion / SECONDS_PER_DAY;
double ganymede_period_days = ganymede_tracker->time_at_completion / SECONDS_PER_DAY;
double callisto_period_days = callisto_tracker->time_at_completion / SECONDS_PER_DAY;
INFO("Io period: " << io_period_days << " days (expected: " << IO_PERIOD_DAYS << ")");
INFO("Europa period: " << europa_period_days << " days (expected: " << EUROPA_PERIOD_DAYS << ")");
INFO("Ganymede period: " << ganymede_period_days << " days (expected: " << GANYMEDE_PERIOD_DAYS << ")");
INFO("Callisto period: " << callisto_period_days << " days (expected: " << CALLISTO_PERIOD_DAYS << ")");
REQUIRE(fabs(io_period_days - IO_PERIOD_DAYS) < 0.5);
REQUIRE(fabs(europa_period_days - EUROPA_PERIOD_DAYS) < 1.0);
REQUIRE(fabs(ganymede_period_days - GANYMEDE_PERIOD_DAYS) < 2.0);
REQUIRE(fabs(callisto_period_days - CALLISTO_PERIOD_DAYS) < 4.0);
destroy_orbit_tracker(io_tracker);
destroy_orbit_tracker(europa_tracker);
destroy_orbit_tracker(ganymede_tracker);
destroy_orbit_tracker(callisto_tracker);
destroy_simulation(sim);
}
TEST_CASE("Titan orbital stability around Saturn", "[moon][saturn]") { 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 TIME_STEP = 60.0;
const double EXPECTED_PERIOD_DAYS = 15.95;
const double SECONDS_PER_DAY = 86400.0; const double SECONDS_PER_DAY = 86400.0;
const double MAX_SIMULATION_DAYS = 25.0;
SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP); SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml")); REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml"));
const int SATURN_INDEX = 5; CelestialBody* earth = &sim->bodies[2];
const int TITAN_INDEX = 13; CelestialBody* moon = &sim->bodies[8];
OrbitTracker* tracker = create_orbit_tracker_with_min_time(TITAN_INDEX, 10.0); struct MoonData {
int index;
Vec3 initial_pos_relative_to_saturn = vec3_sub( const char* name;
sim->bodies[TITAN_INDEX].global_position, double expected_seconds;
sim->bodies[SATURN_INDEX].global_position double max_days;
); double min_time_days;
double initial_distance = vec3_magnitude(initial_pos_relative_to_saturn); double period_tol;
int parent_index;
};
double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY; 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},
};
while (sim->time < max_time && !tracker->orbit_completed) { const MoonData all_moons[] = {
update_simulation(sim); {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},
};
REQUIRE(sim->bodies[TITAN_INDEX].parent_index == SATURN_INDEX); // === 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<std::string> 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());
};
Vec3 current_pos_relative_to_saturn = vec3_sub( auto measure_period = [&](int body_index, int parent_index,
sim->bodies[TITAN_INDEX].global_position, double max_days, double min_time_days) {
sim->bodies[SATURN_INDEX].global_position sim->time = 0.0;
); OrbitTracker* tracker = create_orbit_tracker_with_min_time(
double current_distance = vec3_magnitude(current_pos_relative_to_saturn); 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 drift_percentage = (fabs(current_distance - initial_distance) / initial_distance) * 100.0; double period = tracker->orbit_completed
REQUIRE(drift_percentage < 10.0); ? tracker->time_at_completion : -1.0;
destroy_orbit_tracker(tracker);
return period;
};
update_orbit_tracker(tracker, &sim->bodies[TITAN_INDEX], &sim->bodies[SATURN_INDEX], sim->time); 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<std::string> 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());
};
REQUIRE(tracker->orbit_completed); 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 measured_period_days = tracker->time_at_completion / SECONDS_PER_DAY; double max_time = 60.0 * SECONDS_PER_DAY;
double period_error_days = fabs(measured_period_days - EXPECTED_PERIOD_DAYS); 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");
}
}
}
}
INFO("Expected Titan period: " << EXPECTED_PERIOD_DAYS << " days"); for (int i = 0; i < count; i++) {
INFO("Measured Titan period: " << measured_period_days << " days"); REQUIRE(trackers[i]->orbit_completed);
INFO("Period error: " << period_error_days << " days"); destroy_orbit_tracker(trackers[i]);
}
};
REQUIRE(period_error_days < 3.0); // === Sections ===
destroy_orbit_tracker(tracker); SECTION("Moon maintains Earth as parent throughout simulation") {
destroy_simulation(sim); check_parent_stability(moon, 2, 35.0);
} }
TEST_CASE("Combined solar system with all moons - parent stability", "[moon][integration]") { SECTION("Moon distance from Earth stays within 10% of initial") {
const double TIME_STEP = 60.0; check_distance_drift(moon, 2, 35.0);
const double SECONDS_PER_DAY = 86400.0; }
const double MAX_SIMULATION_DAYS = 60.0;
SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP); 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));
}
REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml")); SECTION("Galilean moons maintain Jupiter as parent") {
for (const auto& m : galilean) {
check_parent_stability(&sim->bodies[m.index], 4, 25.0);
}
}
struct ParentChange { SECTION("Galilean moons complete orbits in expected periods") {
double time_days; for (const auto& m : galilean) {
int body_index; double period = measure_period(m.index, m.parent_index,
int old_parent; m.max_days, m.min_time_days);
int new_parent; 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));
}
}
std::vector<ParentChange> parent_changes; 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);
}
}
int initial_parents[14]; SECTION("Titan maintains Saturn as parent") {
for (int i = 0; i < 14; i++) { check_parent_stability(&sim->bodies[13], 5, 25.0);
initial_parents[i] = sim->bodies[i].parent_index;
} }
double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY; 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));
}
while (sim->time < max_time) { SECTION("Titan distance from Saturn stays within 10%") {
update_simulation(sim); check_distance_drift(&sim->bodies[13], 5, 25.0);
}
for (int i = 0; i < sim->body_count; i++) { SECTION("All moons maintain correct parents over 60-day simulation") {
if (sim->bodies[i].parent_index != initial_parents[i]) { double max_time = 60.0 * SECONDS_PER_DAY;
ParentChange change; std::vector<std::string> failures;
change.time_days = sim->time / SECONDS_PER_DAY; while (sim->time < max_time) {
change.body_index = i; update_simulation(sim);
change.old_parent = initial_parents[i]; for (int i = 0; i < 6; i++) {
change.new_parent = sim->bodies[i].parent_index; int idx = all_moons[i].index;
parent_changes.push_back(change); int expected = all_moons[i].parent_index;
initial_parents[i] = sim->bodies[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());
} }
INFO("Total parent changes detected: " << parent_changes.size()); SECTION("All moons complete at least one orbit") {
for (const auto& change : parent_changes) { wait_for_all_orbits(6);
INFO("Body " << sim->bodies[change.body_index].name
<< " (index " << change.body_index << ") changed parent "
<< "from " << change.old_parent << " to " << change.new_parent
<< " at day " << change.time_days);
} }
REQUIRE(parent_changes.size() == 0); 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); destroy_simulation(sim);
} }

275
tests/test_moon_orbits.toml

@ -1,169 +1,184 @@
# Solar System Configuration # 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]] [[bodies]]
name = "Sun" name = "Sun"
mass = 1.989e30 # kg mass = 1.989e+30
radius = 6.96e8 # m radius = 696000000.0
parent_index = -1 parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 } color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = { orbit.semi_major_axis = 0.0
semi_major_axis = 0.0, orbit.eccentricity = 0.0
eccentricity = 0.0, orbit.true_anomaly = 0.0
true_anomaly = 0.0
}
[[bodies]] [[bodies]]
name = "Venus" name = "Venus"
mass = 4.867e24 mass = 4.87e+24
radius = 6.0518e6 radius = 6052000.0
parent_index = 0 parent_index = 0
color = { r = 0.9, g = 0.7, b = 0.3 } color = { r = 0.5, g = 0.5, b = 0.5 }
orbit = { orbit.semi_major_axis = 1.081608e+11
semi_major_axis = 1.082e11, orbit.eccentricity = 0.007
eccentricity = 0.0, orbit.inclination = 0.059166661642608
true_anomaly = 0.0 orbit.longitude_of_ascending_node = 1.338318470429252
} orbit.argument_of_periapsis = 0.958534825195286
orbit.true_anomaly = 0.890141231198106
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
mass = 5.972e24 mass = 5.97e+24
radius = 6.371e6 radius = 6378000.0
parent_index = 0 parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.5, g = 0.5, b = 0.5 }
orbit = { orbit.semi_major_axis = 1.496000e+11
semi_major_axis = 1.496e11, orbit.eccentricity = 0.017
eccentricity = 0.0, orbit.inclination = 0.000000000000000
true_anomaly = 0.0 orbit.longitude_of_ascending_node = 0.000000000000000
} orbit.argument_of_periapsis = 1.796641932002963
orbit.true_anomaly = 6.238578647164619
[[bodies]] [[bodies]]
name = "Mars" name = "Mars"
mass = 6.39e23 mass = 6.42e+23
radius = 3.3895e6 radius = 3396000.0
parent_index = 0 parent_index = 0
color = { r = 0.8, g = 0.3, b = 0.1 } color = { r = 0.5, g = 0.5, b = 0.5 }
orbit = { orbit.semi_major_axis = 2.279904e+11
semi_major_axis = 2.279e11, orbit.eccentricity = 0.093
eccentricity = 0.0, orbit.inclination = 0.032288591161895
true_anomaly = 0.0 orbit.longitude_of_ascending_node = 0.864985177288390
} orbit.argument_of_periapsis = 5.000368306963754
orbit.true_anomaly = 0.407676817724149
[[bodies]] [[bodies]]
name = "Jupiter" name = "Jupiter"
mass = 1.898e27 mass = 1.898e+27
radius = 6.9911e7 radius = 71492000.0
parent_index = 0 parent_index = 0
color = { r = 0.9, g = 0.7, b = 0.5 } color = { r = 0.5, g = 0.5, b = 0.5 }
orbit = { orbit.semi_major_axis = 7.783688e+11
semi_major_axis = 7.785e11, orbit.eccentricity = 0.049
eccentricity = 0.0, orbit.inclination = 0.022863813201126
true_anomaly = 0.0 orbit.longitude_of_ascending_node = 1.753532299478703
} orbit.argument_of_periapsis = 4.786565473594449
orbit.true_anomaly = 0.378299755400337
[[bodies]] [[bodies]]
name = "Saturn" name = "Saturn"
mass = 5.683e26 mass = 5.683e+26
radius = 5.8232e7 radius = 60268000.0
parent_index = 0 parent_index = 0
color = { r = 0.9, g = 0.8, b = 0.6 } color = { r = 0.5, g = 0.5, b = 0.5 }
orbit = { orbit.semi_major_axis = 1.426735e+12
semi_major_axis = 1.434e12, orbit.eccentricity = 0.057
eccentricity = 0.0, orbit.inclination = 0.043458698374659
true_anomaly = 0.0 orbit.longitude_of_ascending_node = 1.983741227816755
} orbit.argument_of_periapsis = 5.915618966709580
orbit.true_anomaly = 5.457583789473037
[[bodies]] [[bodies]]
name = "Uranus" name = "Uranus"
mass = 8.681e25 mass = 8.68e+25
radius = 2.5362e7 radius = 25559000.0
parent_index = 0 parent_index = 0
color = { r = 0.5, g = 0.8, b = 0.9 } color = { r = 0.5, g = 0.5, b = 0.5 }
orbit = { orbit.semi_major_axis = 2.870824e+12
semi_major_axis = 2.871e12, orbit.eccentricity = 0.046
eccentricity = 0.0, orbit.inclination = 0.013439035240356
true_anomaly = 0.0 orbit.longitude_of_ascending_node = 1.291892712326203
} orbit.argument_of_periapsis = 1.691922176883303
orbit.true_anomaly = 2.537061863932694
[[bodies]] [[bodies]]
name = "Neptune" name = "Neptune"
mass = 1.024e26 mass = 1.02e+26
radius = 2.4622e7 radius = 24764000.0
parent_index = 0 parent_index = 0
color = { r = 0.2, g = 0.4, b = 0.9 } color = { r = 0.5, g = 0.5, b = 0.5 }
orbit = { orbit.semi_major_axis = 4.498472e+12
semi_major_axis = 4.495e12, orbit.eccentricity = 0.01
eccentricity = 0.0, orbit.inclination = 0.030892327760300
true_anomaly = 0.0 orbit.longitude_of_ascending_node = 2.299994888278127
} orbit.argument_of_periapsis = 4.767890450598109
orbit.true_anomaly = 4.516812758860357
[[bodies]] [[bodies]]
name = "Moon" name = "Moon"
mass = 7.342e22 mass = 7.35e+22
radius = 1.737e6 radius = 1738000.0
parent_index = 2 parent_index = 2
color = { r = 0.7, g = 0.7, b = 0.7 } color = { r = 0.7, g = 0.7, b = 0.7 }
orbit = { orbit.semi_major_axis = 3.844000e+08
semi_major_axis = 3.844e8, orbit.eccentricity = 0.055
eccentricity = 0.0, orbit.inclination = 0.090058989402907
true_anomaly = 0.0 orbit.longitude_of_ascending_node = 2.183057828394507
} orbit.argument_of_periapsis = 5.552765015219959
orbit.true_anomaly = 2.434643529152418
[[bodies]] [[bodies]]
name = "Io" name = "Io"
mass = 8.93e22 mass = 8.93e+23
radius = 1.822e6 radius = 1822000.0
parent_index = 4 parent_index = 4
color = { r = 0.9, g = 0.9, b = 0.3 } color = { r = 0.7, g = 0.7, b = 0.7 }
orbit = { orbit.semi_major_axis = 4.218000e+08
semi_major_axis = 4.217e8, orbit.eccentricity = 0.004
eccentricity = 0.0, orbit.inclination = 0.000000000000000
true_anomaly = 0.0 orbit.longitude_of_ascending_node = 0.000000000000000
} orbit.argument_of_periapsis = 0.856956662729216
orbit.true_anomaly = 5.771386752330690
[[bodies]] [[bodies]]
name = "Europa" name = "Europa"
mass = 4.80e22 mass = 4.8e+23
radius = 1.561e6 radius = 1561000.0
parent_index = 4 parent_index = 4
color = { r = 0.8, g = 0.8, b = 0.7 } color = { r = 0.7, g = 0.7, b = 0.7 }
orbit = { orbit.semi_major_axis = 6.711000e+08
semi_major_axis = 6.709e8, orbit.eccentricity = 0.009
eccentricity = 0.0, orbit.inclination = 0.008726646259972
true_anomaly = 0.0 orbit.longitude_of_ascending_node = 3.211405823669566
} orbit.argument_of_periapsis = 0.785398163397448
orbit.true_anomaly = 6.023780086902404
[[bodies]] [[bodies]]
name = "Ganymede" name = "Ganymede"
mass = 1.48e23 mass = 1.48e+24
radius = 2.634e6 radius = 2631000.0
parent_index = 4 parent_index = 4
color = { r = 0.6, g = 0.6, b = 0.5 } color = { r = 0.7, g = 0.7, b = 0.7 }
orbit = { orbit.semi_major_axis = 1.070400e+09
semi_major_axis = 1.070e9, orbit.eccentricity = 0.001
eccentricity = 0.0, orbit.inclination = 0.003490658503989
true_anomaly = 0.0 orbit.longitude_of_ascending_node = 1.021017612416683
} orbit.argument_of_periapsis = 3.460987906704756
orbit.true_anomaly = 5.667675367373862
[[bodies]] [[bodies]]
name = "Callisto" name = "Callisto"
mass = 1.08e23 mass = 1.08e+24
radius = 2.410e6 radius = 2410000.0
parent_index = 4 parent_index = 4
color = { r = 0.5, g = 0.5, b = 0.4 } color = { r = 0.7, g = 0.7, b = 0.7 }
orbit = { orbit.semi_major_axis = 1.882700e+09
semi_major_axis = 1.883e9, orbit.eccentricity = 0.007
eccentricity = 0.0, orbit.inclination = 0.005235987755983
true_anomaly = 0.0 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
[[bodies]]
name = "Titan"
mass = 1.345e23
radius = 2.575e6
parent_index = 5
color = { r = 0.9, g = 0.6, b = 0.3 }
orbit = {
semi_major_axis = 1.222e9,
eccentricity = 0.0,
true_anomaly = 0.0
}

95
tests/test_omega_debug.cpp

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

116
tests/test_orbital_period.cpp

@ -1,102 +1,54 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h" #include "../src/physics.h"
#include "../src/simulation.h" #include "../src/simulation.h"
#include "../src/config_loader.h" #include "../src/config_loader.h"
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include <cmath> #include <cmath>
TEST_CASE("Orbital period - Earth (RK4)", "[period][rk4]") { using Catch::Matchers::WithinAbs;
const double TIME_STEP = 60.0;
const double EXPECTED_PERIOD_DAYS = 365.0;
const double SECONDS_PER_DAY = 86400.0;
const double MAX_SIMULATION_DAYS = 400.0;
SimulationState* sim = create_simulation(10, 0, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_orbital_period.toml"));
OrbitTracker* tracker = create_orbit_tracker(1);
double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY;
while (sim->time < max_time && !tracker->orbit_completed) {
update_simulation(sim);
update_orbit_tracker(tracker, &sim->bodies[1], &sim->bodies[0], sim->time);
}
REQUIRE(tracker->orbit_completed);
double measured_period_days = tracker->time_at_completion / SECONDS_PER_DAY;
double period_error_days = fabs(measured_period_days - EXPECTED_PERIOD_DAYS);
INFO("Expected period: " << EXPECTED_PERIOD_DAYS << " days");
INFO("Measured period: " << measured_period_days << " days");
INFO("Error: " << period_error_days << " days");
REQUIRE(period_error_days < 5.0); SCENARIO("Orbital period measurement with analytical propagation",
"[period][analytical]") {
destroy_orbit_tracker(tracker);
destroy_simulation(sim);
}
TEST_CASE("Orbital period - Mars (RK4)", "[period][rk4]") {
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
const double EXPECTED_PERIOD_DAYS = 687.0;
const double SECONDS_PER_DAY = 86400.0; const double SECONDS_PER_DAY = 86400.0;
const double MAX_SIMULATION_DAYS = 750.0;
SimulationState* sim = create_simulation(10, 0, 0, TIME_STEP); SimulationState* sim = create_simulation(10, 0, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_orbital_period.toml")); REQUIRE(load_system_config(sim, "tests/test_orbital_period.toml"));
OrbitTracker* tracker = create_orbit_tracker(2); auto propagate_and_check = [&](OrbitTracker* tracker, CelestialBody* body,
CelestialBody* parent, double max_time,
double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY; double expected_days) {
while (sim->time < max_time && !tracker->orbit_completed) { while (sim->time < max_time && !tracker->orbit_completed) {
update_simulation(sim); update_simulation(sim);
update_orbit_tracker(tracker, &sim->bodies[2], &sim->bodies[0], sim->time); update_orbit_tracker(tracker, body, parent, sim->time);
}
REQUIRE(tracker->orbit_completed);
const double measured_days = tracker->time_at_completion / SECONDS_PER_DAY;
INFO("Measured period: " << measured_days << " days");
REQUIRE_THAT(measured_days, WithinAbs(expected_days, 0.1));
destroy_orbit_tracker(tracker);
};
SECTION("Earth completes one orbit in ~365 days") {
CelestialBody* earth = &sim->bodies[1];
CelestialBody* sun = &sim->bodies[0];
OrbitTracker* tracker = create_orbit_tracker(1);
const double max_time = 400.0 * SECONDS_PER_DAY;
propagate_and_check(tracker, earth, sun, max_time, 365.2105);
} }
REQUIRE(tracker->orbit_completed); SECTION("Mars completes one orbit in ~671 days") {
CelestialBody* mars = &sim->bodies[2];
double measured_period_days = tracker->time_at_completion / SECONDS_PER_DAY; CelestialBody* sun = &sim->bodies[0];
double period_error_days = fabs(measured_period_days - EXPECTED_PERIOD_DAYS); OrbitTracker* tracker = create_orbit_tracker(2);
INFO("Expected period: " << EXPECTED_PERIOD_DAYS << " days");
INFO("Measured period: " << measured_period_days << " days");
INFO("Error: " << period_error_days << " days");
REQUIRE(period_error_days < 25.0);
destroy_orbit_tracker(tracker);
destroy_simulation(sim);
}
TEST_CASE("Orbit direction - prograde for zero inclination", "[direction]") {
const double TIME_STEP = 60.0;
const double TEST_DURATION_DAYS = 1.0;
const double SECONDS_PER_DAY = 86400.0;
const int STEPS = (int)(TEST_DURATION_DAYS * SECONDS_PER_DAY / TIME_STEP);
SimulationState* sim = create_simulation(2, 0, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_energy.toml"));
CelestialBody* sun = &sim->bodies[0];
CelestialBody* earth = &sim->bodies[1];
Vec3 initial_rel_pos = vec3_sub(earth->global_position, sun->global_position);
double theta_start = atan2(initial_rel_pos.y, initial_rel_pos.x);
for (int i = 0; i < STEPS; i++) { const double max_time = 750.0 * SECONDS_PER_DAY;
update_simulation(sim); propagate_and_check(tracker, mars, sun, max_time, 670.9345);
} }
}
Vec3 final_rel_pos = vec3_sub(earth->global_position, sun->global_position);
double theta_final = atan2(final_rel_pos.y, final_rel_pos.x);
INFO("Initial angle: " << theta_start << " rad");
INFO("Final angle: " << theta_final << " rad");
REQUIRE(theta_final > theta_start);
destroy_simulation(sim);
}

18
tests/test_orbital_period.toml

@ -8,11 +8,7 @@ mass = 1.989e30
radius = 6.96e8 radius = 6.96e8
parent_index = -1 parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 } color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = { orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
@ -20,11 +16,7 @@ mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = 0 parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { orbit = { semi_major_axis = 1.496e11, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 1.496e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[bodies]] [[bodies]]
name = "Mars" name = "Mars"
@ -32,8 +24,4 @@ mass = 6.39e23
radius = 3.3895e6 radius = 3.3895e6
parent_index = 0 parent_index = 0
color = { r = 0.8, g = 0.3, b = 0.1 } color = { r = 0.8, g = 0.3, b = 0.1 }
orbit = { orbit = { semi_major_axis = 2.244e11, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 2.244e11,
eccentricity = 0.0,
true_anomaly = 0.0
}

167
tests/test_parabolic_orbit.cpp

@ -1,72 +1,93 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h" #include "../src/physics.h"
#include "../src/simulation.h" #include "../src/simulation.h"
#include "../src/config_loader.h" #include "../src/config_loader.h"
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include <cmath> #include <cmath>
#include <vector>
TEST_CASE("Parabolic orbit - energy and escape trajectory", "[parabolic][energy][escape]") { 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 TIME_STEP = 60.0;
const double DAYS_TO_SIMULATE = 300.0; const double DAYS_TO_SIMULATE = 300.0;
const double SECONDS_PER_DAY = 86400.0; const double SECONDS_PER_DAY = 86400.0;
const double AU = 1.496e11; const double AU = 1.496e11;
SimulationState* sim = create_simulation(10, 0, 0, TIME_STEP); // Precalculated expected values from scripts/precalc_parabolic_orbit.py
const double initial_expected_velocity = 42127.865427; // 42.127865 km/s
const double final_expected_velocity = 26708.624837; // 26.708625 km/s
const double expected_distance = 372192353748.3338; // 2.487917 AU
SimulationState* sim = create_simulation(10, 0, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_parabolic_orbit.toml")); REQUIRE(load_system_config(sim, "tests/test_parabolic_orbit.toml"));
const int COMET_INDEX = 1; const int COMET_INDEX = 1;
const int SUN_INDEX = 0; const int SUN_INDEX = 0;
CelestialBody* comet = &sim->bodies[COMET_INDEX];
CelestialBody* sun = &sim->bodies[SUN_INDEX];
Vec3 initial_position = sim->bodies[COMET_INDEX].global_position; // Initial state
double initial_distance = vec3_magnitude(initial_position); const double initial_distance = vec3_magnitude(comet->global_position);
double initial_velocity = vec3_magnitude(sim->bodies[COMET_INDEX].global_velocity); const double initial_velocity = vec3_magnitude(comet->global_velocity);
const double initial_kinetic = calculate_kinetic_energy(comet);
double initial_kinetic = calculate_kinetic_energy(&sim->bodies[COMET_INDEX]); const double initial_potential = calculate_potential_energy_pair(comet, sun);
double initial_potential = calculate_potential_energy_pair(&sim->bodies[COMET_INDEX], const double initial_total_energy = initial_kinetic + initial_potential;
&sim->bodies[SUN_INDEX]);
double initial_total_energy = initial_kinetic + initial_potential;
INFO("Initial distance: " << initial_distance / AU << " AU"); INFO("Initial distance: " << initial_distance / AU << " AU");
INFO("Initial velocity: " << vec3_magnitude(sim->bodies[COMET_INDEX].global_velocity) / 1000.0 << " km/s"); INFO("Initial velocity: " << initial_velocity / 1000.0 << " km/s");
INFO("Initial kinetic energy: " << initial_kinetic); INFO("Initial kinetic energy: " << initial_kinetic);
INFO("Initial potential energy: " << initial_potential); INFO("Initial potential energy: " << initial_potential);
INFO("Initial total energy: " << initial_total_energy); INFO("Initial total energy: " << initial_total_energy);
REQUIRE(initial_total_energy >= -1e25); 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);
std::vector<double> distances; INFO("Distance: " << distance / AU << " AU");
std::vector<double> velocities; INFO("Actual velocity: " << initial_velocity / 1000.0 << " km/s");
std::vector<double> energies; INFO("Escape velocity: " << escape_velocity / 1000.0 << " km/s");
INFO("Circular velocity: " << circular_velocity / 1000.0 << " km/s");
double max_time = DAYS_TO_SIMULATE * SECONDS_PER_DAY; const double velocity_error = fabs(initial_velocity - escape_velocity) / escape_velocity;
int step_count = 0; INFO("Velocity error from escape velocity: " << velocity_error * 100.0 << "%");
while (sim->time < max_time) { REQUIRE_THAT(velocity_error, WithinAbs(0.0, V_TOL));
if (step_count % 1000 == 0) { }
double current_distance = vec3_magnitude(sim->bodies[COMET_INDEX].global_position);
double current_velocity = vec3_magnitude(sim->bodies[COMET_INDEX].global_velocity);
double current_kinetic = calculate_kinetic_energy(&sim->bodies[COMET_INDEX]);
double current_potential = calculate_potential_energy_pair(&sim->bodies[COMET_INDEX],
&sim->bodies[SUN_INDEX]);
double current_total = current_kinetic + current_potential;
distances.push_back(current_distance);
velocities.push_back(current_velocity);
energies.push_back(current_total);
}
update_simulation(sim); SECTION("eccentricity equals 1.0") {
step_count++; INFO("Eccentricity: " << comet->orbit.eccentricity);
REQUIRE_THAT(comet->orbit.eccentricity, WithinAbs(1.0, E_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, REL_TOL));
} }
double final_distance = vec3_magnitude(sim->bodies[COMET_INDEX].global_position); SECTION("initial velocity matches precalculated") {
double final_velocity = vec3_magnitude(sim->bodies[COMET_INDEX].global_velocity); INFO("Initial velocity: " << initial_velocity << " m/s");
REQUIRE_THAT(initial_velocity, WithinAbs(initial_expected_velocity, V_TOL));
}
const double max_time = DAYS_TO_SIMULATE * SECONDS_PER_DAY;
while (sim->time < max_time) {
update_simulation(sim);
}
double final_kinetic = calculate_kinetic_energy(&sim->bodies[COMET_INDEX]); // Final state
double final_potential = calculate_potential_energy_pair(&sim->bodies[COMET_INDEX], const double final_distance = vec3_magnitude(comet->global_position);
&sim->bodies[SUN_INDEX]); const double final_velocity = vec3_magnitude(comet->global_velocity);
double final_total_energy = final_kinetic + final_potential; 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 distance: " << final_distance / AU << " AU");
INFO("Final velocity: " << final_velocity / 1000.0 << " km/s"); INFO("Final velocity: " << final_velocity / 1000.0 << " km/s");
@ -74,64 +95,24 @@ TEST_CASE("Parabolic orbit - energy and escape trajectory", "[parabolic][energy]
INFO("Final potential energy: " << final_potential); INFO("Final potential energy: " << final_potential);
INFO("Final total energy: " << final_total_energy); INFO("Final total energy: " << final_total_energy);
REQUIRE(final_distance > initial_distance); SECTION("final distance matches escape trajectory") {
REQUIRE_THAT(final_distance, WithinAbs(expected_distance, R_TOL));
REQUIRE(final_velocity < initial_velocity);
double energy_drift = fabs(final_total_energy - initial_total_energy);
double avg_kinetic_energy = (initial_kinetic + final_kinetic) / 2.0;
double energy_drift_percent = (energy_drift / avg_kinetic_energy) * 100.0;
INFO("Energy drift: " << energy_drift << " J");
INFO("Energy drift percent: " << energy_drift_percent << "%");
REQUIRE(energy_drift_percent < 1.0);
int velocity_decreases = 0;
for (size_t i = 1; i < velocities.size(); i++) {
if (velocities[i] < velocities[i-1]) {
velocity_decreases++;
}
} }
INFO("Velocity decreases: " << velocity_decreases << " / " << (velocities.size() - 1)); SECTION("final velocity matches escape trajectory") {
REQUIRE(final_velocity < initial_velocity);
REQUIRE(velocity_decreases > static_cast<int>(velocities.size()) / 2); REQUIRE_THAT(final_velocity, WithinAbs(final_expected_velocity, V_TOL));
}
destroy_simulation(sim);
}
TEST_CASE("Parabolic orbit initial conditions", "[parabolic][initial]") {
const double TIME_STEP = 60.0;
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];
double distance = vec3_magnitude(vec3_sub(comet->global_position, sun->global_position));
double velocity = vec3_magnitude(comet->global_velocity);
double escape_velocity = sqrt(2.0 * G * sun->mass / distance);
double circular_velocity = sqrt(G * sun->mass / distance);
INFO("Distance: " << distance / 1.496e11 << " AU");
INFO("Actual velocity: " << velocity / 1000.0 << " km/s");
INFO("Escape velocity: " << escape_velocity / 1000.0 << " km/s");
INFO("Circular velocity: " << circular_velocity / 1000.0 << " km/s");
double velocity_error = fabs(velocity - escape_velocity) / escape_velocity;
INFO("Velocity error from escape velocity: " << velocity_error * 100.0 << "%");
REQUIRE(velocity_error < 0.001);
INFO("Eccentricity: " << comet->orbit.eccentricity); 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;
REQUIRE(fabs(comet->orbit.eccentricity - 1.0) < 0.0001); INFO("Energy drift: " << energy_drift << " J");
INFO("Energy drift percent: " << drift_pct << "%");
REQUIRE_THAT(drift_pct, WithinAbs(0.0, DRIFT_TOL));
}
destroy_simulation(sim); destroy_simulation(sim);
} }

16
tests/test_parabolic_orbit.toml

@ -7,21 +7,13 @@ name = "Sun"
mass = 1.989e30 mass = 1.989e30
radius = 6.96e8 radius = 6.96e8
parent_index = -1 parent_index = -1
color = {r = 1.0,g = 1.0,b = 0.0 } color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = { orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[bodies]] [[bodies]]
name = "ParabolicComet" name = "ParabolicComet"
mass = 1.0e14 mass = 1.0e14
radius = 5.0e3 radius = 5.0e3
parent_index = 0 parent_index = 0
color = {r = 0.7,g = 0.8,b = 0.9 } color = { r = 0.7, g = 0.8, b = 0.9 }
orbit = { orbit = { semi_latus_rectum = 2.992e11, eccentricity = 1.0, true_anomaly = 0.0 }
semi_latus_rectum = 2.992e11,
eccentricity = 1.0,
true_anomaly = 0.0
}

369
tests/test_periapsis_burn.cpp

@ -5,251 +5,208 @@
#include "../src/orbital_objects.h" #include "../src/orbital_objects.h"
#include "../src/maneuver.h" #include "../src/maneuver.h"
#include "../src/config_loader.h" #include "../src/config_loader.h"
#include "../src/orbital_mechanics.h" #include "../src/test_utilities.h"
#include <cmath> #include <cmath>
#include <tuple>
// Test prograde burn at periapsis (true anomaly = 0) using Catch::Matchers::WithinAbs;
// Verifies that the maneuver executes correctly when starting at periapsis
TEST_CASE("Prograde burn at periapsis preserves periapsis distance", "[maneuver][periapsis]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_periapsis_burn.toml"));
Spacecraft* craft = &sim->spacecraft[0];
// Record initial state (at periapsis) SCENARIO("Periapsis-triggered prograde burn behavior", "[maneuver][periapsis]") {
double initial_radius = vec3_magnitude(craft->local_position);
double initial_sma = craft->orbit.semi_major_axis;
double initial_ecc = craft->orbit.eccentricity;
double initial_periapsis = initial_sma * (1.0 - initial_ecc);
double initial_velocity = vec3_magnitude(craft->local_velocity);
INFO("Initial state:");
INFO(" Radius: " << initial_radius << " (should equal periapsis: " << initial_periapsis << ")");
INFO(" True anomaly: " << craft->orbit.true_anomaly);
INFO(" Velocity: " << initial_velocity);
INFO(" Maneuver trigger: true_anomaly = " << sim->maneuvers[0].trigger_value);
// Execute one step
update_simulation(sim);
// Check if maneuver executed
INFO("After 1 step:");
INFO(" Maneuver executed: " << sim->maneuvers[0].executed);
INFO(" Final radius: " << vec3_magnitude(craft->local_position));
INFO(" Final velocity: " << vec3_magnitude(craft->local_velocity));
// The maneuver should have executed since we started at periapsis
// This assertion will fail due to the bug - the trigger check happens
// after physics moves the spacecraft past periapsis
REQUIRE(sim->maneuvers[0].executed);
// If the maneuver executed, verify the physics:
double final_sma = craft->orbit.semi_major_axis;
double final_ecc = craft->orbit.eccentricity;
double final_periapsis = final_sma * (1.0 - final_ecc);
double final_velocity = vec3_magnitude(craft->local_velocity);
// Periapsis distance should be preserved
REQUIRE_THAT(final_periapsis, Catch::Matchers::WithinAbs(initial_periapsis, 1.0));
// Semi-major axis and velocity should increase
REQUIRE(final_sma > initial_sma);
REQUIRE(final_velocity > initial_velocity);
destroy_simulation(sim);
}
TEST_CASE("Two periapsis burns execute at same location", "[maneuver][periapsis][sequential]") {
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
const int ORBIT_STEPS = 300;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP); SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_periapsis_burn.toml")); REQUIRE(load_system_config(sim, "tests/test_periapsis_burn.toml"));
Spacecraft* craft = &sim->spacecraft[0]; Spacecraft* craft = &sim->spacecraft[0];
Spacecraft* craft_cross = &sim->spacecraft[1];
CelestialBody* parent = &sim->bodies[craft->parent_index]; CelestialBody* parent = &sim->bodies[craft->parent_index];
int maneuver_indices[2]; // Shared fixture values (from precalc_periapsis_burn.py)
int maneuver_count_for_craft = 0; const double initial_periapsis = 7259700.0;
for (int i = 0; i < sim->maneuver_count; i++) { const double burn1_preburn_v = 8448.412303782408344;
if (sim->maneuvers[i].craft_index == 0) { const double burn1_expected_sma = 13404876.681005753576756;
maneuver_indices[maneuver_count_for_craft++] = i;
} // 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);
REQUIRE(std::string(sim->spacecraft[0].name) == "TestSatellite");
REQUIRE(std::string(sim->spacecraft[1].name) == "TestSatelliteCrossing");
REQUIRE(sim->spacecraft[0].parent_index == 1);
REQUIRE(sim->spacecraft[1].parent_index == 1);
} }
REQUIRE(maneuver_count_for_craft == 2);
double initial_periapsis = craft->orbit.semi_major_axis * (1.0 - craft->orbit.eccentricity);
double initial_apoapsis = craft->orbit.semi_major_axis * (1.0 + craft->orbit.eccentricity);
INFO("Initial orbit:"); SECTION("prograde burn at periapsis fires immediately and raises orbit") {
INFO(" Periapsis: " << initial_periapsis); double a_before = craft->orbit.semi_major_axis;
INFO(" Apoapsis: " << initial_apoapsis); double e_before = craft->orbit.eccentricity;
INFO(" Eccentricity: " << craft->orbit.eccentricity); double peri_before = a_before * (1.0 - e_before);
double burn1_time = -1.0; // Execute one step — burn fires immediately (nu=0, trigger=0)
double burn1_radius = -1.0;
double burn1_true_anomaly = -10.0;
double burn1_period = -1.0;
double burn2_time = -1.0;
double burn2_radius = -1.0;
double burn2_true_anomaly = -10.0;
for (int i = 0; i < ORBIT_STEPS; i++) {
update_simulation(sim); update_simulation(sim);
if (sim->maneuvers[maneuver_indices[0]].executed && burn1_time < 0) { // Verify burn fired at exact periapsis via burn_result
burn1_time = sim->time; const BurnResult& br = sim->maneuvers[0].burn_result;
burn1_radius = vec3_magnitude(craft->local_position); REQUIRE(br.valid);
burn1_period = 2.0 * M_PI * sqrt(pow(craft->orbit.semi_major_axis, 3.0) / (G * parent->mass)); REQUIRE_THAT(br.true_anomaly, WithinAbs(0.0, ANG_TOL));
REQUIRE_THAT(vec3_magnitude(br.position), WithinAbs(initial_periapsis, R_TOL));
Vec3 r = craft->local_position;
Vec3 v = craft->local_velocity; // Maneuver executed
Vec3 h = vec3_cross(r, v); REQUIRE(sim->maneuvers[0].executed);
Vec3 e_vec = calculate_eccentricity_vector(r, v, h, G * parent->mass);
double e_mag = vec3_magnitude(e_vec); // Periapsis preserved after burn
burn1_true_anomaly = calculate_true_anomaly(r, v, e_vec, e_mag, burn1_radius); double final_sma = craft->orbit.semi_major_axis;
burn1_true_anomaly = normalize_angle(burn1_true_anomaly); double final_ecc = craft->orbit.eccentricity;
double final_periapsis = final_sma * (1.0 - final_ecc);
INFO("First burn executed at step " << i); REQUIRE_THAT(final_periapsis, WithinAbs(initial_periapsis, R_TOL));
INFO(" Time: " << burn1_time);
INFO(" Radius: " << burn1_radius); // Pre-burn velocity captured at exact burn time (tight tolerance)
INFO(" True anomaly: " << burn1_true_anomaly << " rad (" << burn1_true_anomaly * 180.0 / M_PI << "°)"); REQUIRE_THAT(vec3_magnitude(br.velocity), WithinAbs(burn1_preburn_v, V_TOL));
INFO(" Periapsis: " << initial_periapsis);
INFO(" Apoapsis: " << initial_apoapsis); // Semi-major axis after burn
INFO(" New period: " << burn1_period << " seconds"); REQUIRE_THAT(final_sma, WithinAbs(burn1_expected_sma, A_TOL));
}
INFO("Initial SMA: " << a_before << " m");
if (sim->maneuvers[maneuver_indices[1]].executed && burn2_time < 0) { INFO("Final SMA: " << final_sma << " m");
burn2_time = sim->time; INFO("Initial periapsis: " << peri_before << " m");
burn2_radius = vec3_magnitude(craft->local_position); INFO("Final periapsis: " << final_periapsis << " m");
INFO("Burn time position: " << br.position.x << ", " << br.position.y << ", " << br.position.z);
Vec3 r = craft->local_position; INFO("Burn time velocity: " << br.velocity.x << ", " << br.velocity.y << ", " << br.velocity.z);
Vec3 v = craft->local_velocity;
Vec3 h = vec3_cross(r, v);
Vec3 e_vec = calculate_eccentricity_vector(r, v, h, G * parent->mass);
double e_mag = vec3_magnitude(e_vec);
burn2_true_anomaly = calculate_true_anomaly(r, v, e_vec, e_mag, burn2_radius);
burn2_true_anomaly = normalize_angle(burn2_true_anomaly);
INFO("Second burn executed at step " << i);
INFO(" Time: " << burn2_time);
INFO(" Radius: " << burn2_radius);
INFO(" True anomaly: " << burn2_true_anomaly << " rad (" << burn2_true_anomaly * 180.0 / M_PI << "°)");
}
} }
REQUIRE(sim->maneuvers[maneuver_indices[0]].executed); SECTION("two sequential periapsis burns execute at same location") {
REQUIRE(sim->maneuvers[maneuver_indices[1]].executed); // Find maneuver indices for craft 0
int burn1_idx = -1, burn2_idx = -1;
INFO("Burn comparison:"); for (int i = 0; i < sim->maneuver_count; i++) {
INFO(" Burn 1: time=" << burn1_time << ", radius=" << burn1_radius << ", true_anomaly=" << burn1_true_anomaly); if (sim->maneuvers[i].craft_index == 0 && !sim->maneuvers[i].executed) {
INFO(" Burn 2: time=" << burn2_time << ", radius=" << burn2_radius << ", true_anomaly=" << burn2_true_anomaly); if (burn1_idx < 0) burn1_idx = i;
else burn2_idx = i;
REQUIRE_THAT(burn1_radius, Catch::Matchers::WithinAbs(initial_periapsis, 10000.0)); }
REQUIRE_THAT(burn2_radius, Catch::Matchers::WithinAbs(initial_periapsis, 10000.0));
REQUIRE(fabs(burn1_true_anomaly) < 0.5);
REQUIRE(fabs(burn2_true_anomaly) < 0.5);
INFO("Expected orbital period (after burn 1): " << burn1_period << " seconds");
INFO("Actual time between burns: " << (burn2_time - burn1_time) << " seconds");
REQUIRE_THAT(burn2_time - burn1_time, Catch::Matchers::WithinAbs(burn1_period, TIME_STEP * 2.0));
destroy_simulation(sim);
}
TEST_CASE("Periapsis burn fires when crossing periapsis", "[maneuver][periapsis][crossing]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_periapsis_burn.toml"));
Spacecraft* craft = &sim->spacecraft[1]; // TestSatelliteCrossing
CelestialBody* parent = &sim->bodies[craft->parent_index];
int maneuver_index = -1;
for (int i = 0; i < sim->maneuver_count; i++) {
if (sim->maneuvers[i].craft_index == 1) {
maneuver_index = i;
break;
} }
} REQUIRE(burn1_idx >= 0);
REQUIRE(maneuver_index >= 0); REQUIRE(burn2_idx >= 0);
double initial_periapsis = craft->orbit.semi_major_axis * (1.0 - craft->orbit.eccentricity); double initial_periapsis_val = craft->orbit.semi_major_axis * (1.0 - craft->orbit.eccentricity);
double initial_apoapsis = craft->orbit.semi_major_axis * (1.0 + craft->orbit.eccentricity); double initial_apoapsis_val = craft->orbit.semi_major_axis * (1.0 + craft->orbit.eccentricity);
INFO("Initial periapsis: " << initial_periapsis_val << " m");
INFO("Initial apoapsis: " << initial_apoapsis_val << " m");
INFO("Initial orbit:"); const int max_steps = 300;
INFO(" Periapsis: " << initial_periapsis); for (int i = 0; i < max_steps; i++) {
INFO(" Apoapsis: " << initial_apoapsis); update_simulation(sim);
INFO(" Initial true anomaly: " << craft->orbit.true_anomaly << " rad"); }
double burn_time = -1.0;
double burn_radius = -1.0;
double burn_true_anomaly = -10.0;
int max_steps = 1000; REQUIRE(sim->maneuvers[burn1_idx].executed);
for (int i = 0; i < max_steps && !sim->maneuvers[maneuver_index].executed; i++) { REQUIRE(sim->maneuvers[burn2_idx].executed);
update_simulation(sim);
// 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 burns at exact periapsis radius
REQUIRE_THAT(burn1_radius, WithinAbs(initial_periapsis, R_TOL));
REQUIRE_THAT(burn2_radius, WithinAbs(initial_periapsis, R_TOL));
// 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;
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=" << 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
}
if (sim->maneuvers[maneuver_index].executed) { SECTION("periapsis burn fires when crossing from 90 degrees") {
burn_time = sim->time; int cross_maneuver = -1;
burn_radius = vec3_magnitude(craft->local_position); for (int i = 0; i < sim->maneuver_count; i++) {
if (sim->maneuvers[i].craft_index == 1) {
Vec3 r = craft->local_position; cross_maneuver = i;
Vec3 v = craft->local_velocity; break;
Vec3 h = vec3_cross(r, v); }
Vec3 e_vec = calculate_eccentricity_vector(r, v, h, G * parent->mass);
double e_mag = vec3_magnitude(e_vec);
burn_true_anomaly = calculate_true_anomaly(r, v, e_vec, e_mag, burn_radius);
burn_true_anomaly = normalize_angle(burn_true_anomaly);
INFO("Burn executed at step " << i);
INFO(" Time: " << burn_time);
INFO(" Radius: " << burn_radius);
INFO(" True anomaly: " << burn_true_anomaly << " rad (" << burn_true_anomaly * 180.0 / M_PI << "°)");
INFO(" Periapsis: " << initial_periapsis);
INFO(" Apoapsis: " << initial_apoapsis);
} }
} REQUIRE(cross_maneuver >= 0);
REQUIRE(sim->maneuvers[maneuver_index].executed); double cross_initial_periapsis = craft_cross->orbit.semi_major_axis * (1.0 - craft_cross->orbit.eccentricity);
double cross_initial_apoapsis = craft_cross->orbit.semi_major_axis * (1.0 + craft_cross->orbit.eccentricity);
INFO("Initial true anomaly: " << craft_cross->orbit.true_anomaly << " rad");
INFO("Initial periapsis: " << cross_initial_periapsis << " m");
INFO("Initial apoapsis: " << cross_initial_apoapsis << " m");
REQUIRE_THAT(burn_radius, Catch::Matchers::WithinAbs(initial_periapsis, 1000.0)); const int max_steps = 1000;
for (int i = 0; i < max_steps && !sim->maneuvers[cross_maneuver].executed; i++) {
update_simulation(sim);
}
REQUIRE(fabs(burn_true_anomaly) < 0.5); REQUIRE(sim->maneuvers[cross_maneuver].executed);
destroy_simulation(sim); // 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);
TEST_CASE("Burn location equals new periapsis after prograde burn", "[maneuver][periapsis][location]") { // Burn at exact periapsis radius
const double TIME_STEP = 60.0; REQUIRE_THAT(burn_radius, WithinAbs(cross_initial_periapsis, R_TOL));
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_periapsis_burn.toml")); // True anomaly = 0 at burn (burn_result captures pre-burn state)
REQUIRE_THAT(br.true_anomaly, WithinAbs(0.0, ANG_TOL));
Spacecraft* craft = &sim->spacecraft[0]; 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);
}
double initial_periapsis = craft->orbit.semi_major_axis * (1.0 - craft->orbit.eccentricity); SECTION("burn location equals new periapsis after prograde burn") {
double initial_radius = vec3_magnitude(craft->local_position); double a_before = craft->orbit.semi_major_axis;
double e_before = craft->orbit.eccentricity;
double peri_before = a_before * (1.0 - e_before);
double r_before = vec3_magnitude(craft->local_position);
update_simulation(sim); update_simulation(sim);
REQUIRE(sim->maneuvers[0].executed); 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));
INFO("Initial radius: " << initial_radius); double final_periapsis = craft->orbit.semi_major_axis * (1.0 - craft->orbit.eccentricity);
INFO("Initial periapsis: " << initial_periapsis);
INFO("Final periapsis: " << final_periapsis);
REQUIRE_THAT(initial_radius, Catch::Matchers::WithinAbs(initial_periapsis, 100.0)); // Final periapsis equals initial periapsis (burn at periapsis preserves it)
REQUIRE_THAT(final_periapsis, WithinAbs(peri_before, R_TOL));
REQUIRE_THAT(final_periapsis, Catch::Matchers::WithinAbs(initial_periapsis, 100.0)); 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); destroy_simulation(sim);
} }

28
tests/test_periapsis_burn.toml

@ -6,24 +6,16 @@ name = "Sun"
mass = 1.989e30 mass = 1.989e30
radius = 6.96e8 radius = 6.96e8
parent_index = -1 parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 } color = {r=1.0, g=1.0, b=0.0}
orbit = { orbit = {semi_major_axis=0.0, eccentricity=0.0, true_anomaly=0.0}
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
mass = 5.972e24 mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = 0 parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 } color = {r=0.0, g=0.5, b=1.0}
orbit = { orbit = {semi_major_axis=1.496e11, eccentricity=0.0, true_anomaly=0.0}
semi_major_axis = 1.496e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[spacecraft]] [[spacecraft]]
name = "TestSatellite" name = "TestSatellite"
@ -32,22 +24,14 @@ parent_index = 1
# Start at periapsis of an elliptical orbit # Start at periapsis of an elliptical orbit
# a = 10,000 km, e = 0.3 # a = 10,000 km, e = 0.3
# periapsis = 7,000 km, apoapsis = 13,000 km # periapsis = 7,000 km, apoapsis = 13,000 km
orbit = { orbit = {semi_major_axis=1.0371e7, eccentricity=0.3, true_anomaly=0.0}
semi_major_axis = 1.0371e7,
eccentricity = 0.3,
true_anomaly = 0.0
}
[[spacecraft]] [[spacecraft]]
name = "TestSatelliteCrossing" name = "TestSatelliteCrossing"
mass = 1000.0 mass = 1000.0
parent_index = 1 parent_index = 1
# Start at 90 degrees from periapsis for crossing test # Start at 90 degrees from periapsis for crossing test
orbit = { orbit = {semi_major_axis=1.0371e7, eccentricity=0.3, true_anomaly=1.57}
semi_major_axis = 1.0371e7,
eccentricity = 0.3,
true_anomaly = 1.57 # 90 degrees (pi/2)
}
[[maneuvers]] [[maneuvers]]
name = "periapsis_prograde_burn" name = "periapsis_prograde_burn"

225
tests/test_physics_utilities.cpp

@ -0,0 +1,225 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h"
#include "../src/test_utilities.h"
#include <cmath>
using Catch::Matchers::WithinAbs;
SCENARIO("Vector math utilities", "[physics][utilities][vector]") {
const Vec3 a = {1.0, 2.0, 3.0};
const Vec3 b = {4.0, 5.0, 6.0};
SECTION("add") {
const Vec3 sum = vec3_add(a, b);
REQUIRE(compare_vec3(sum, {5.0, 7.0, 9.0}, D_TOL));
}
SECTION("sub") {
const Vec3 diff = vec3_sub(b, a);
REQUIRE(compare_vec3(diff, {3.0, 3.0, 3.0}, D_TOL));
}
SECTION("scale") {
const Vec3 scaled = vec3_scale(a, 2.0);
REQUIRE(compare_vec3(scaled, {2.0, 4.0, 6.0}, D_TOL));
}
SECTION("magnitude") {
const double mag = vec3_magnitude(a);
REQUIRE_THAT(mag, WithinAbs(sqrt(14.0), D_TOL));
}
SECTION("distance") {
const double dist = vec3_distance(a, b);
REQUIRE_THAT(dist, WithinAbs(sqrt(27.0), D_TOL));
}
SECTION("normalize") {
const Vec3 unit = vec3_normalize(a);
const double actual_mag = vec3_magnitude(unit);
REQUIRE_THAT(actual_mag, WithinAbs(1.0, D_TOL));
}
SECTION("normalize zero vector") {
const Vec3 zero = {0.0, 0.0, 0.0};
const Vec3 result = vec3_normalize(zero);
REQUIRE(compare_vec3(result, zero, D_TOL));
}
SECTION("dot product") {
const double dot = vec3_dot(a, b);
REQUIRE_THAT(dot, WithinAbs(32.0, D_TOL));
}
SECTION("cross product") {
const Vec3 cross = vec3_cross(a, b);
REQUIRE(compare_vec3(cross, {-3.0, 6.0, -3.0}, D_TOL));
}
}
SCENARIO("Acceleration calculation", "[physics][acceleration]") {
SECTION("F = ma") {
const Vec3 force = {10.0, 20.0, 30.0};
const double mass = 5.0;
const Vec3 accel = calculate_acceleration(force, mass);
REQUIRE_THAT(accel.x, WithinAbs(2.0, R_TOL));
REQUIRE_THAT(accel.y, WithinAbs(4.0, R_TOL));
REQUIRE_THAT(accel.z, WithinAbs(6.0, R_TOL));
}
SECTION("zero mass returns zero acceleration") {
const Vec3 force = {10.0, 20.0, 30.0};
const Vec3 accel = calculate_acceleration(force, 0.0);
REQUIRE(compare_vec3(accel, {0.0, 0.0, 0.0}, R_TOL));
}
}
SCENARIO("Matrix operations", "[physics][utilities][matrix]") {
const Mat3 A = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0};
const Vec3 v = {1.0, 2.0, 3.0};
SECTION("identity matrix") {
const Mat3 I = mat3_identity();
const Mat3 result = mat3_multiply(A, I);
REQUIRE(compare_vec3({result.m00, result.m11, result.m22},
{A.m00, A.m11, A.m22}, D_TOL));
}
SECTION("multiply vector") {
const Vec3 result = mat3_multiply_vec3(A, v);
REQUIRE(compare_vec3(result, {14.0, 32.0, 50.0}, D_TOL));
}
SECTION("multiply two matrices") {
const Mat3 B = {9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0};
const Mat3 result = mat3_multiply(A, B);
REQUIRE_THAT(result.m00, WithinAbs(30.0, D_TOL));
REQUIRE_THAT(result.m01, WithinAbs(24.0, D_TOL));
REQUIRE_THAT(result.m02, WithinAbs(18.0, D_TOL));
REQUIRE_THAT(result.m10, WithinAbs(84.0, D_TOL));
REQUIRE_THAT(result.m11, WithinAbs(69.0, D_TOL));
REQUIRE_THAT(result.m12, WithinAbs(54.0, D_TOL));
REQUIRE_THAT(result.m20, WithinAbs(138.0, D_TOL));
REQUIRE_THAT(result.m21, WithinAbs(114.0, D_TOL));
REQUIRE_THAT(result.m22, WithinAbs(90.0, D_TOL));
}
SECTION("transpose") {
const Mat3 T = mat3_transpose(A);
REQUIRE_THAT(T.m00, WithinAbs(1.0, D_TOL));
REQUIRE_THAT(T.m01, WithinAbs(4.0, D_TOL));
REQUIRE_THAT(T.m02, WithinAbs(7.0, D_TOL));
REQUIRE_THAT(T.m10, WithinAbs(2.0, D_TOL));
REQUIRE_THAT(T.m11, WithinAbs(5.0, D_TOL));
REQUIRE_THAT(T.m12, WithinAbs(8.0, D_TOL));
REQUIRE_THAT(T.m20, WithinAbs(3.0, D_TOL));
REQUIRE_THAT(T.m21, WithinAbs(6.0, D_TOL));
REQUIRE_THAT(T.m22, WithinAbs(9.0, D_TOL));
}
SECTION("transpose of transpose is original") {
const Mat3 T2 = mat3_transpose(mat3_transpose(A));
REQUIRE_THAT(T2.m00, WithinAbs(A.m00, D_TOL));
REQUIRE_THAT(T2.m01, WithinAbs(A.m01, D_TOL));
REQUIRE_THAT(T2.m02, WithinAbs(A.m02, D_TOL));
REQUIRE_THAT(T2.m10, WithinAbs(A.m10, D_TOL));
REQUIRE_THAT(T2.m11, WithinAbs(A.m11, D_TOL));
REQUIRE_THAT(T2.m12, WithinAbs(A.m12, D_TOL));
REQUIRE_THAT(T2.m20, WithinAbs(A.m20, D_TOL));
REQUIRE_THAT(T2.m21, WithinAbs(A.m21, D_TOL));
REQUIRE_THAT(T2.m22, WithinAbs(A.m22, D_TOL));
}
}
SCENARIO("Rotation matrices", "[physics][utilities][rotation]") {
SECTION("rotate about Z by 90 degrees") {
const Mat3 Rz = mat3_rotation_z(M_PI / 2);
const Vec3 v = {1.0, 0.0, 0.0};
const Vec3 result = mat3_multiply_vec3(Rz, v);
REQUIRE(compare_vec3(result, {0.0, 1.0, 0.0}, D_TOL));
}
SECTION("rotate about X by 90 degrees") {
const Mat3 Rx = mat3_rotation_x(M_PI / 2);
const Vec3 v = {0.0, 1.0, 0.0};
const Vec3 result = mat3_multiply_vec3(Rx, v);
REQUIRE(compare_vec3(result, {0.0, 0.0, 1.0}, D_TOL));
}
SECTION("180 degree rotation") {
const Mat3 Rz180 = mat3_rotation_z(M_PI);
const Vec3 v = {1.0, 0.0, 0.0};
const Vec3 result = mat3_multiply_vec3(Rz180, v);
REQUIRE(compare_vec3(result, {-1.0, 0.0, 0.0}, D_TOL));
}
SECTION("360 degree rotation equals identity") {
const Mat3 Rz360 = mat3_rotation_z(2.0 * M_PI);
const Mat3 I = mat3_identity();
REQUIRE(compare_vec3({Rz360.m00, Rz360.m11, Rz360.m22},
{I.m00, I.m11, I.m22}, D_TOL));
}
SECTION("negative angle equals equivalent positive rotation") {
const Mat3 Rz_neg90 = mat3_rotation_z(-M_PI / 2);
const Mat3 Rz_270 = mat3_rotation_z(3.0 * M_PI / 2);
REQUIRE_THAT(Rz_neg90.m00, WithinAbs(Rz_270.m00, D_TOL));
REQUIRE_THAT(Rz_neg90.m01, WithinAbs(Rz_270.m01, D_TOL));
REQUIRE_THAT(Rz_neg90.m10, WithinAbs(Rz_270.m10, D_TOL));
REQUIRE_THAT(Rz_neg90.m11, WithinAbs(Rz_270.m11, D_TOL));
}
SECTION("combined rotations that cancel") {
const Mat3 Rz90 = mat3_rotation_z(M_PI / 2);
const Mat3 Rz_neg90 = mat3_rotation_z(-M_PI / 2);
const Mat3 combined = mat3_multiply(Rz_neg90, Rz90);
const Mat3 I = mat3_identity();
REQUIRE(compare_vec3({combined.m00, combined.m11, combined.m22},
{I.m00, I.m11, I.m22}, D_TOL));
}
SECTION("rotation matrix orthogonality") {
const double angle = M_PI / 4;
const Mat3 Rz = mat3_rotation_z(angle);
const Mat3 Rz_T = mat3_transpose(Rz);
const Mat3 product = mat3_multiply(Rz, Rz_T);
const Mat3 I = mat3_identity();
REQUIRE_THAT(product.m00, WithinAbs(I.m00, D_TOL));
REQUIRE_THAT(product.m01, WithinAbs(I.m01, D_TOL));
REQUIRE_THAT(product.m02, WithinAbs(I.m02, D_TOL));
REQUIRE_THAT(product.m10, WithinAbs(I.m10, D_TOL));
REQUIRE_THAT(product.m11, WithinAbs(I.m11, D_TOL));
REQUIRE_THAT(product.m12, WithinAbs(I.m12, D_TOL));
REQUIRE_THAT(product.m20, WithinAbs(I.m20, D_TOL));
REQUIRE_THAT(product.m21, WithinAbs(I.m21, D_TOL));
REQUIRE_THAT(product.m22, WithinAbs(I.m22, D_TOL));
}
SECTION("orbital rotation matrix with 90 deg inclination") {
const Mat3 R = mat3_rotation_orbital(0.0, M_PI / 2, 0.0);
const Vec3 v = {1.0, 0.0, 0.0};
const Vec3 result = mat3_multiply_vec3(R, v);
REQUIRE(compare_vec3(result, {1.0, 0.0, 0.0}, D_TOL));
}
}
SCENARIO("compare_vec3 utility", "[physics][utilities][compare]") {
SECTION("equal vectors within tolerance") {
const Vec3 a = {1.0, 2.0, 3.0};
const Vec3 b = {1.0 + 1e-13, 2.0 + 1e-13, 3.0 + 1e-13};
REQUIRE(compare_vec3(a, b, D_TOL));
}
SECTION("equal vectors exactly") {
const Vec3 a = {1.0, 2.0, 3.0};
const Vec3 b = {1.0, 2.0, 3.0};
REQUIRE(compare_vec3(a, b, 0.0));
}
SECTION("different vectors outside tolerance") {
const Vec3 a = {1.0, 2.0, 3.0};
const Vec3 b = {2.0, 2.0, 3.0};
REQUIRE(!compare_vec3(a, b, 0.5));
}
}

153
tests/test_true_anomaly_roundtrip.cpp

@ -2,132 +2,59 @@
#include <catch2/matchers/catch_matchers_floating_point.hpp> #include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h" #include "../src/physics.h"
#include "../src/orbital_mechanics.h" #include "../src/orbital_mechanics.h"
#include "../src/test_utilities.h"
#include <cmath> #include <cmath>
TEST_CASE("True anomaly round-trip conversion at periapsis", "[orbital_elements][true_anomaly]") { using Catch::Matchers::WithinAbs;
double parent_mass = 5.972e24;
OrbitalElements elements = {0};
elements.semi_major_axis = 7000e3;
elements.eccentricity = 0.3;
elements.true_anomaly = 0.0;
elements.inclination = 0.0;
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);
OrbitalElements reconstructed = cartesian_to_orbital_elements(pos, vel, parent_mass);
INFO("Original true_anomaly: " << elements.true_anomaly);
INFO("Reconstructed true_anomaly: " << reconstructed.true_anomaly);
REQUIRE_THAT(reconstructed.true_anomaly,
Catch::Matchers::WithinAbs(elements.true_anomaly, 0.01));
}
TEST_CASE("True anomaly round-trip conversion at apoapsis", "[orbital_elements][true_anomaly]") {
double parent_mass = 5.972e24;
OrbitalElements elements = {0};
elements.semi_major_axis = 7000e3;
elements.eccentricity = 0.3;
elements.true_anomaly = M_PI;
elements.inclination = 0.0;
elements.longitude_of_ascending_node = 0.0;
elements.argument_of_periapsis = 0.0;
SCENARIO("True anomaly round-trip conversion and radius sanity checks",
"[orbital_elements][true_anomaly][sanity]") {
const double parent_mass = 5.972e24;
const double a = 7000e3;
const double e = 0.3;
OrbitalElements elements = {};
elements.semi_major_axis = a;
elements.eccentricity = e;
Vec3 pos, vel; Vec3 pos, vel;
orbital_elements_to_cartesian(elements, parent_mass, &pos, &vel);
OrbitalElements reconstructed = cartesian_to_orbital_elements(pos, vel, parent_mass);
INFO("Original true_anomaly: " << elements.true_anomaly); // Precomputed analytical values
INFO("Reconstructed true_anomaly: " << reconstructed.true_anomaly); const double expected_r_peri = a * (1.0 - e); // 4900000.0
const double expected_r_apo = a * (1.0 + e); // 9100000.0
REQUIRE_THAT(reconstructed.true_anomaly, auto check_roundtrip = [&](double expected_nu) {
Catch::Matchers::WithinAbs(elements.true_anomaly, 0.01)); elements.true_anomaly = expected_nu;
}
TEST_CASE("True anomaly round-trip conversion at 90 degrees", "[orbital_elements][true_anomaly]") { orbital_elements_to_cartesian(elements, parent_mass, &pos, &vel);
double parent_mass = 5.972e24; OrbitalElements reconstructed = cartesian_to_orbital_elements(pos, vel, parent_mass);
OrbitalElements elements = {0}; INFO("Expected nu: " << expected_nu);
elements.semi_major_axis = 7000e3; INFO("Reconstructed: " << reconstructed.true_anomaly);
elements.eccentricity = 0.3; REQUIRE_THAT(reconstructed.true_anomaly, WithinAbs(expected_nu, ANG_TOL));
elements.true_anomaly = M_PI / 2.0; };
elements.inclination = 0.0;
elements.longitude_of_ascending_node = 0.0;
elements.argument_of_periapsis = 0.0;
Vec3 pos, vel; SECTION("at periapsis (nu = 0)") { check_roundtrip(0.0); }
orbital_elements_to_cartesian(elements, parent_mass, &pos, &vel); SECTION("at apoapsis (nu = pi)") { check_roundtrip(M_PI); }
OrbitalElements reconstructed = cartesian_to_orbital_elements(pos, vel, parent_mass); SECTION("at 90 degrees (nu = pi/2)") { check_roundtrip(M_PI / 2.0); }
SECTION("at 270 degrees (nu = 3*pi/2)") { check_roundtrip(3.0 * M_PI / 2.0); }
INFO("Original true_anomaly: " << elements.true_anomaly); SECTION("periapsis radius = a*(1-e)") {
INFO("Reconstructed true_anomaly: " << reconstructed.true_anomaly); orbital_elements_to_cartesian(elements, parent_mass, &pos, &vel);
const double r_peri = vec3_magnitude(pos);
REQUIRE_THAT(reconstructed.true_anomaly, INFO("Expected r: " << expected_r_peri);
Catch::Matchers::WithinAbs(elements.true_anomaly, 0.01)); INFO("Calculated r: " << r_peri);
}
TEST_CASE("True anomaly round-trip conversion at 270 degrees", "[orbital_elements][true_anomaly]") { REQUIRE_THAT(r_peri, WithinAbs(expected_r_peri, R_TOL));
double parent_mass = 5.972e24; }
OrbitalElements elements = {0};
elements.semi_major_axis = 7000e3;
elements.eccentricity = 0.3;
elements.true_anomaly = 3.0 * M_PI / 2.0;
elements.inclination = 0.0;
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);
OrbitalElements reconstructed = cartesian_to_orbital_elements(pos, vel, parent_mass);
INFO("Original true_anomaly: " << elements.true_anomaly); SECTION("apoapsis radius = a*(1+e)") {
INFO("Reconstructed true_anomaly: " << reconstructed.true_anomaly); elements.true_anomaly = M_PI;
REQUIRE_THAT(reconstructed.true_anomaly,
Catch::Matchers::WithinAbs(elements.true_anomaly, 0.01));
}
TEST_CASE("Radius at periapsis matches expected value", "[orbital_elements][sanity]") {
double parent_mass = 5.972e24;
OrbitalElements peri = {0};
peri.semi_major_axis = 7000e3;
peri.eccentricity = 0.3;
peri.true_anomaly = 0.0;
Vec3 pos, vel;
orbital_elements_to_cartesian(peri, parent_mass, &pos, &vel);
double r_peri = vec3_magnitude(pos);
double expected_peri = peri.semi_major_axis * (1.0 - peri.eccentricity);
INFO("At true_anomaly=0:");
INFO(" Calculated radius: " << r_peri);
INFO(" Expected: " << expected_peri);
REQUIRE_THAT(r_peri, Catch::Matchers::WithinAbs(expected_peri, 1.0));
}
TEST_CASE("Radius at apoapsis matches expected value", "[orbital_elements][sanity]") {
double parent_mass = 5.972e24;
OrbitalElements apo = {0};
apo.semi_major_axis = 7000e3;
apo.eccentricity = 0.3;
apo.true_anomaly = M_PI;
Vec3 pos, vel;
orbital_elements_to_cartesian(apo, parent_mass, &pos, &vel);
double r_apo = vec3_magnitude(pos);
double expected_apo = apo.semi_major_axis * (1.0 + apo.eccentricity);
INFO("At true_anomaly=pi:"); orbital_elements_to_cartesian(elements, parent_mass, &pos, &vel);
INFO(" Calculated radius: " << r_apo); const double r_apo = vec3_magnitude(pos);
INFO(" Expected: " << expected_apo);
REQUIRE_THAT(r_apo, Catch::Matchers::WithinAbs(expected_apo, 1.0)); INFO("Expected r: " << expected_r_apo);
INFO("Calculated r: " << r_apo);
REQUIRE_THAT(r_apo, WithinAbs(expected_r_apo, R_TOL));
}
} }

Loading…
Cancel
Save