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
*.o
build/
orbit_sim
orbit_test
tests/informational/test_time_step_stability
*/__pycache__
# LLM summaries
tmp/
.pi/
# Session notes and conversation logs
docs/session_logs
@ -23,5 +22,3 @@ tags
.DS_Store
Thumbs.db
# non-portable custom scripts
sync.sh

2
AGENTS.md

@ -21,7 +21,7 @@
- No trailing whitespace in any files (including markdown)
- Pre-commit hook automatically strips it
- 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
- 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
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):
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.
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.
**Do not revert unstaged changes to this file**
========================
=== 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
- SOI boundary testing
- 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
- 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
=== code style ===
- document 'ZII' zero is initialization
- arena memory management?
=== 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
**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.
@ -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` — rebuild test executable only
- `make clean` — clean build artifacts
- `make clean-all` — clean everything including example
- `make rebuild` — clean and rebuild all
**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.
#### 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()`).
## 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
};
// State vectors captured at the exact moment a burn fires
struct BurnResult {
bool valid;
Vec3 position;
Vec3 velocity;
double true_anomaly;
};
struct Maneuver {
char name[64];
int craft_index;
@ -31,6 +39,7 @@ struct Maneuver {
double scheduled_dt;
bool executed;
double executed_time;
BurnResult burn_result;
};
struct HohmannTransfer {

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

64
src/physics.cpp

@ -58,56 +58,6 @@ Vec3 calculate_acceleration(Vec3 force, double mass) {
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() {
return {1.0, 0.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) {
return {
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);
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
Mat3 mat3_identity();
Mat3 mat3_multiply(Mat3 a, Mat3 b);
Mat3 mat3_transpose(Mat3 m);
Vec3 mat3_multiply_vec3(Mat3 m, Vec3 v);
Mat3 mat3_rotation_x(double angle);
Mat3 mat3_rotation_z(double angle);
@ -37,13 +38,7 @@ Mat3 mat3_rotation_orbital(double omega, double i, double Omega);
// Physics functions
Vec3 calculate_acceleration(Vec3 force, double mass);
// Physics integration functions
// RK4 inferior to analytical propagation for orbit simulation
// 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);
// Comparison utility
bool compare_vec3(Vec3 a, Vec3 b, double tolerance);
#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);
orbital_elements_to_cartesian(craft->orbit, parent->mass, &craft->local_position, &craft->local_velocity);
// Capture exact pre-burn state for test assertions
fired_maneuver->burn_result = {};
fired_maneuver->burn_result.valid = true;
fired_maneuver->burn_result.position = craft->local_position;
fired_maneuver->burn_result.velocity = craft->local_velocity;
fired_maneuver->burn_result.true_anomaly = craft->orbit.true_anomaly;
double burn_time = sim->time + burn_dt;
execute_maneuver(fired_maneuver, craft, sim, burn_time);

140
src/test_utilities.cpp

@ -3,16 +3,16 @@
#include <cmath>
#include <cstdio>
static const double MIN_DISTANCE_CLAMP = 1.0;
double calculate_kinetic_energy(CelestialBody* body) {
double v_squared = body->global_velocity.x * body->global_velocity.x +
body->global_velocity.y * body->global_velocity.y +
body->global_velocity.z * body->global_velocity.z;
return 0.5 * body->mass * v_squared;
Vec3 v = body->global_velocity;
return 0.5 * body->mass * vec3_dot(v, v);
}
double calculate_potential_energy_pair(CelestialBody* body1, CelestialBody* body2) {
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;
}
@ -37,7 +37,6 @@ OrbitalMetrics calculate_orbital_metrics(CelestialBody* body, CelestialBody* par
Vec3 relative_pos = vec3_sub(body->global_position, parent->global_position);
metrics.orbital_radius = vec3_magnitude(relative_pos);
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.potential_energy = calculate_potential_energy_pair(body, parent);
@ -46,15 +45,16 @@ OrbitalMetrics calculate_orbital_metrics(CelestialBody* body, CelestialBody* par
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));
tracker->body_index = body_index;
tracker->initial_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->time_at_completion = 0.0;
tracker->min_time_days = 100.0;
tracker->min_time_seconds = min_time_seconds;
tracker->inclination = 0.0;
tracker->longitude_of_ascending_node = 0.0;
tracker->argument_of_periapsis = 0.0;
@ -62,26 +62,14 @@ OrbitTracker* create_orbit_tracker(int body_index) {
return tracker;
}
OrbitTracker* create_orbit_tracker_with_min_time(int body_index, double min_time_days) {
OrbitTracker* tracker = (OrbitTracker*)malloc(sizeof(OrbitTracker));
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(int body_index) {
return create_orbit_tracker_with_min_time(body_index, 86400.0);
}
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 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->longitude_of_ascending_node = lon_ascending_node;
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;
}
void reset_orbit_tracker(OrbitTracker* tracker) {
static void reset_tracker_fields(OrbitTracker* tracker) {
tracker->body_index = 0;
tracker->initial_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->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) {
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,
tracker->inclination,
tracker->longitude_of_ascending_node);
// Transpose to get inverse rotation (back to orbital plane)
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);
Vec3 pos_orbital = mat3_multiply_vec3(mat3_transpose(rotation), relative_pos);
current_angle = atan2(pos_orbital.y, pos_orbital.x);
} else {
current_angle = atan2(relative_pos.y, relative_pos.x);
}
if (tracker->quadrant_transitions == 0) {
if (!tracker->initialized) {
tracker->initial_angle = current_angle;
tracker->previous_angle = current_angle;
tracker->quadrant_transitions = 1;
tracker->accumulated_rotation = 0.0;
tracker->initialized = true;
return;
}
@ -128,23 +119,16 @@ void update_orbit_tracker(OrbitTracker* tracker, CelestialBody* body, CelestialB
if (angle_diff > M_PI) {
angle_diff -= 2.0 * M_PI;
tracker->quadrant_transitions++;
}
if (angle_diff < -M_PI) {
angle_diff += 2.0 * M_PI;
tracker->quadrant_transitions++;
}
double total_rotation = current_angle - tracker->initial_angle;
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;
tracker->accumulated_rotation += angle_diff;
if (tracker->quadrant_transitions >= 2 &&
current_time > min_time_seconds &&
fabs(total_rotation) < 0.05) {
if (current_time > tracker->min_time_seconds &&
(tracker->accumulated_rotation >= 2.0 * M_PI ||
tracker->accumulated_rotation <= -2.0 * M_PI)) {
tracker->orbit_completed = true;
tracker->time_at_completion = current_time;
}
@ -156,26 +140,19 @@ void destroy_orbit_tracker(OrbitTracker* 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,
char* buffer, int buffer_size) {
int offset = 0;
if (offset >= buffer_size) return offset;
offset += snprintf(buffer + offset, buffer_size - offset,
"\n=== %s (t=%.0f s) ===\n", label, sim->time);
offset += snprintf(buffer + offset, buffer_size - offset,
"Bodies (%d):\n", sim->body_count);
for (int i = 0; i < sim->body_count; i++) {
if (offset >= buffer_size) break;
offset += snprintf(buffer + offset, buffer_size - offset,
" [%d] %s: mass=%.2e kg\n",
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,
"Spacecraft (%d):\n", sim->craft_count);
for (int i = 0; i < sim->craft_count; i++) {
Spacecraft* s = &sim->spacecraft[i];
double r = sqrt(s->local_position.x*s->local_position.x +
s->local_position.y*s->local_position.y +
s->local_position.z*s->local_position.z);
double v = sqrt(s->local_velocity.x*s->local_velocity.x +
s->local_velocity.y*s->local_velocity.y +
s->local_velocity.z*s->local_velocity.z);
offset += snprintf(buffer + offset, buffer_size - offset,
" [%d] %s: r=%.1f v=%.1f nu=%.5f a=%.1f e=%.6f, omega=%.6f\n",
i, s->name, r, v,
s->orbit.true_anomaly,
s->orbit.semi_major_axis,
s->orbit.eccentricity,
s->orbit.argument_of_periapsis);
offset += snprintf(buffer + offset, buffer_size - offset,
" pos=(%.1f, %.1f, %.1f) vel=(%.1f, %.1f, %.1f)\n",
s->local_position.x, s->local_position.y, s->local_position.z,
s->local_velocity.x, s->local_velocity.y, s->local_velocity.z);
if (sim->spacecraft) {
for (int i = 0; i < sim->craft_count; i++) {
if (offset >= buffer_size) break;
Spacecraft* s = &sim->spacecraft[i];
double r = vec3_magnitude(s->local_position);
double v = vec3_magnitude(s->local_velocity);
offset += snprintf(buffer + offset, buffer_size - offset,
" [%d] %s: r=%.1f v=%.1f nu=%.5f a=%.1f e=%.6f, omega=%.6f\n",
i, s->name, r, v,
s->orbit.true_anomaly,
s->orbit.semi_major_axis,
s->orbit.eccentricity,
s->orbit.argument_of_periapsis);
if (offset >= buffer_size) break;
offset += snprintf(buffer + offset, buffer_size - offset,
" pos=(%.1f, %.1f, %.1f) vel=(%.1f, %.1f, %.1f)\n",
s->local_position.x, s->local_position.y, s->local_position.z,
s->local_velocity.x, s->local_velocity.y, s->local_velocity.z);
}
}
offset += snprintf(buffer + offset, buffer_size - offset,
"Maneuvers (%d):\n", sim->maneuver_count);
for (int i = 0; i < sim->maneuver_count; i++) {
Maneuver* m = &sim->maneuvers[i];
offset += snprintf(buffer + offset, buffer_size - offset,
" [%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);
if (sim->maneuvers) {
for (int i = 0; i < sim->maneuver_count; i++) {
if (offset >= buffer_size) break;
Maneuver* m = &sim->maneuvers[i];
offset += snprintf(buffer + offset, buffer_size - offset,
" [%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;

27
src/test_utilities.h

@ -4,23 +4,37 @@
#include "simulation.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 {
double kinetic_energy;
double potential_energy;
double total_energy;
double orbital_radius;
double velocity_magnitude;
double angular_position;
};
struct OrbitTracker {
double initial_angle;
double previous_angle;
int quadrant_transitions;
double accumulated_rotation;
bool initialized;
bool orbit_completed;
double time_at_completion;
int body_index;
double min_time_days;
double min_time_seconds;
// Orbital elements for 3D angle calculation
double inclination;
@ -35,7 +49,7 @@ double calculate_system_total_energy(SimulationState* sim);
OrbitalMetrics calculate_orbital_metrics(CelestialBody* body, CelestialBody* parent);
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,
double inclination, double lon_ascending_node,
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 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.
// 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,
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/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h"
#include "../src/orbital_mechanics.h"
#include "../src/simulation.h"
@ -6,442 +7,343 @@
#include "../src/test_utilities.h"
#include <cmath>
const double VELOCITY_TOLERANCE_APSIDES = 1.0;
const double POSITION_TOLERANCE_APSIDES = 1.0e3;
const double VELOCITY_TOLERANCE_TIMESTEP = 10.0;
const double POSITION_TOLERANCE_TIMESTEP = 1.0e4;
using Catch::Matchers::WithinAbs;
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;
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);
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];
double initial_true_anomaly = craft->orbit.true_anomaly;
INFO("Initial true anomaly: " << initial_true_anomaly << " rad (" << initial_true_anomaly * 180.0 / M_PI << "°)");
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);
OrbitalElements final_elements = propagate_orbital_elements(craft->orbit, period_seconds, earth->mass);
double final_true_anomaly = final_elements.true_anomaly;
INFO("Final true anomaly: " << final_true_anomaly << " rad (" << final_true_anomaly * 180.0 / M_PI << "°)");
double expected_true_anomaly = fmod(initial_true_anomaly + 2.0 * M_PI, 2.0 * M_PI);
double anomaly_error = fabs(final_true_anomaly - expected_true_anomaly);
INFO("Expected true anomaly: " << expected_true_anomaly << " rad");
INFO("True anomaly error: " << anomaly_error << " rad (" << anomaly_error * 180.0 / M_PI << "°)");
REQUIRE(anomaly_error < 1.0e-6);
destroy_simulation(sim);
}
TEST_CASE("Vis-viva equation holds at multiple points in orbit", "[analytical][propagation][vis_viva]") {
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 true_anomalies[] = {0.0, M_PI / 4.0, M_PI / 2.0, 3.0 * M_PI / 4.0, M_PI};
for (int i = 0; i < 5; i++) {
double nu = true_anomalies[i];
INFO("Testing at true anomaly: " << nu << " rad (" << nu * 180.0 / M_PI << "°)");
craft->orbit.true_anomaly = nu;
Vec3 position;
Vec3 velocity;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &position, &velocity);
double r = vec3_magnitude(position);
double v = vec3_magnitude(velocity);
double expected_v_squared = mu * (2.0 / r - 1.0 / a);
double expected_v = sqrt(expected_v_squared);
double v_error = fabs(v - expected_v);
double relative_error = v_error / expected_v * 100.0;
INFO(" Radius: " << r << " m");
INFO(" Actual velocity: " << v << " m/s");
INFO(" Expected velocity: " << expected_v << " m/s");
INFO(" Error: " << v_error << " m/s (" << relative_error << "%)");
const double mu = G * earth->mass;
const double A1_A = apsides_craft->orbit.semi_major_axis;
const double A1_E = apsides_craft->orbit.eccentricity;
const double A2_A = timestep_craft->orbit.semi_major_axis;
const double A2_E = timestep_craft->orbit.eccentricity;
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);
// Precalculated velocity magnitudes (from scripts/precalc_analytical_propagation.py)
const double A1_V_PERI = 8928.484709064580e+00; // m/s at perigee
const double A1_V_APO = 2232.121177266145e+00; // m/s at apogee
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
const double A2_V_APO = 3374.650017832537e+00; // m/s at apogee
// 100-period propagation accumulates ~1.1e-12 rad anomaly error;
// ANG_TOL (1e-12) is too tight for long-term stability tests
const double LONG_TERM_ANG_TOL = 1e-10;
// === Helper lambdas ===
auto make_elements = [&](double a, double e, double nu) {
OrbitalElements el = {};
el.semi_major_axis = a;
el.eccentricity = e;
el.true_anomaly = nu;
return el;
};
auto get_state = [&](double a, double e, double nu, Vec3& pos, Vec3& vel) {
OrbitalElements el = make_elements(a, e, nu);
orbital_elements_to_cartesian(el, earth->mass, &pos, &vel);
};
auto propagate_and_get_state = [&](double a, double e, double nu, double dt,
Vec3& pos, Vec3& vel) {
OrbitalElements el = make_elements(a, e, nu);
OrbitalElements final_el = propagate_orbital_elements(el, dt, earth->mass);
orbital_elements_to_cartesian(final_el, earth->mass, &pos, &vel);
};
auto check_apsides_radius = [&](double a, double e, double nu,
double expected_r, const char* label) {
Vec3 pos, vel;
get_state(a, e, nu, pos, vel);
const double r = vec3_magnitude(pos);
INFO(label);
INFO(" Expected r: " << expected_r << " m");
INFO(" Calculated r: " << r << " m");
REQUIRE_THAT(r, WithinAbs(expected_r, R_TOL));
};
auto check_period_return = [&](double a, double e, double period,
const char* label) {
OrbitalElements el = make_elements(a, e, 0.0);
Vec3 pos_initial, vel_initial;
orbital_elements_to_cartesian(el, earth->mass, &pos_initial, &vel_initial);
OrbitalElements final_el = propagate_orbital_elements(el, period, earth->mass);
Vec3 pos_final, vel_final;
orbital_elements_to_cartesian(final_el, earth->mass, &pos_final, &vel_final);
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);
}
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};
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");
}
Vec3 pos_initial;
Vec3 vel_initial;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos_initial, &vel_initial);
SECTION("apsides perigee velocity matches precalculated value") {
Vec3 pos_peri, vel_peri, pos_apo, vel_apo;
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++) {
double dt = period_seconds * dt_ratios[i];
INFO("Testing dt = " << dt << " s (" << dt_ratios[i] << "x period)");
SECTION("apsides velocity at pi/4 matches precalculated value") {
Vec3 pos_45, vel_45, pos_peri, vel_peri;
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;
Vec3 vel_final;
orbital_elements_to_cartesian(propagated, earth->mass, &pos_final, &vel_final);
SECTION("timestep apogee radius = a*(1+e)") {
check_apsides_radius(A2_A, A2_E, M_PI, A2_A * (1.0 + A2_E),
"Timestep spacecraft apogee");
}
double pos_error = vec3_distance(pos_initial, pos_final);
double vel_error = vec3_distance(vel_initial, vel_final);
SECTION("timestep perigee velocity matches precalculated value") {
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;
double expected_num_orbits = round(num_periods);
// --- 2. Vis-viva ---
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;
double expected_pos_error = fractional_phase * 2.0 * M_PI * a;
SECTION("vis-viva at nu = pi/4") {
check_vis_viva(A1_A, A1_E, M_PI / 4.0, "vis-viva nu=pi/4");
}
INFO(" Position error: " << pos_error << " m");
INFO(" Expected error (phase): " << expected_pos_error << " m");
INFO(" Number of periods: " << num_periods);
SECTION("vis-viva at nu = pi/2") {
check_vis_viva(A1_A, A1_E, M_PI / 2.0, "vis-viva nu=pi/2");
}
if (expected_num_orbits > 0 && expected_pos_error > 1.0e-6) {
double relative_error = pos_error / expected_pos_error;
SECTION("vis-viva at nu = 3pi/4") {
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);
} else if (expected_num_orbits > 0) {
INFO(" Expected error is zero, skipping relative error check");
REQUIRE(pos_error < 1.0e-3);
}
// --- 3. Period return (both spacecraft) ---
SECTION("apsides position and velocity return after one period") {
check_period_return(A1_A, A1_E, A1_PERIOD,
"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]") {
const double TIME_STEP = 60.0;
SECTION("timestep position and velocity return after one period") {
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];
CelestialBody* earth = &sim->bodies[0];
SECTION("very small timestep (0.1 s) produces expected displacement") {
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;
double mu = G * earth->mass;
double period_seconds = 2.0 * M_PI * sqrt(pow(a, 3.0) / mu);
double mean_motion = sqrt(mu / pow(a, 3.0));
SECTION("accuracy at 1x period") {
Vec3 pos_final, vel_final;
propagate_and_get_state(A2_A, A2_E, 0.0, A2_PERIOD, 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);
double initial_true_anomaly = craft->orbit.true_anomaly;
INFO("Initial true anomaly: " << initial_true_anomaly << " rad");
INFO("dt: " << A2_PERIOD << " s (1x period)");
INFO("pos_error: " << pos_error << " m");
INFO("vel_error: " << vel_error << " m/s");
double propagation_time = period_seconds * 100.0;
INFO("Propagation time: " << propagation_time << " s (" << propagation_time / period_seconds << " periods)");
REQUIRE_THAT(pos_error, WithinAbs(0.0, R_TOL));
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;
INFO("Final true anomaly: " << final_true_anomaly << " rad");
// --- 5. Long-term stability ---
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;
double expected_final_anomaly = fmod(initial_true_anomaly + expected_delta_anomaly, 2.0 * M_PI);
OrbitalElements el = make_elements(A2_A, A2_E, initial_nu);
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);
double anomaly_error = fmin(raw_error, 2.0 * M_PI - raw_error);
const double raw_error = std::abs(final_nu - expected_final_nu);
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);
}

27
tests/test_analytical_propagation.toml

@ -1,6 +1,5 @@
# Test Configuration: Analytical Propagation Tests
# Combined configuration for apsides and timestep testing
# Contains two spacecraft with different orbital parameters
# Two spacecraft with different orbital parameters for propagation testing
[[bodies]]
name = "Earth"
@ -8,34 +7,16 @@ 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
}
orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
[[spacecraft]]
name = "Apsides_Test_Spacecraft"
mass = 1000.0
parent_index = 0
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
}
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 }
[[spacecraft]]
name = "Timestep_Test_Spacecraft"
mass = 1000.0
parent_index = 0
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
}
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 }

227
tests/test_barkers_equation.cpp

@ -2,154 +2,111 @@
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/orbital_mechanics.h"
#include <cmath>
#include <vector>
TEST_CASE("Barker's equation - zero mean anomaly", "[barker][analytical]") {
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));
}
using Catch::Matchers::WithinAbs;
TEST_CASE("Barker's equation - large positive 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 - 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};
SCENARIO("Barker's equation solves parabolic mean anomaly",
"[barker][analytical][parabolic]") {
const double PARENT_MASS = 1.989e30;
const double TIME_STEP = 3600.0;
const int NUM_STEPS = 24;
for (double M_original : test_values) {
auto check_roundtrip = [&](double M_original, double tol) {
double nu = solve_barker_equation(M_original);
double D = tan(nu / 2.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]") {
for (double M = -50.0; M <= 50.0; M += 1.0) {
SECTION("zero mean anomaly yields zero true anomaly") {
double M = 0.0;
double nu = solve_barker_equation(M);
REQUIRE(nu > -M_PI * 0.99);
REQUIRE(nu < M_PI * 0.99);
REQUIRE_THAT(nu, WithinAbs(0.0, 1e-15));
}
}
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));
OrbitalElements current = initial;
double total_time = 0.0;
for (int step = 0; step < NUM_STEPS; step++) {
OrbitalElements next = propagate_orbital_elements(current, TIME_STEP, PARENT_MASS);
current = next;
total_time += TIME_STEP;
SECTION("positive mean anomaly values") {
std::vector<std::pair<double, double>> tests = {
std::make_pair(0.1, 1e-14), std::make_pair(1.0, 1e-14),
std::make_pair(5.0, 1e-14), std::make_pair(20.0, 1e-13)
};
for (const auto& p : tests) {
double nu = solve_barker_equation(p.first);
REQUIRE(nu > 0.0);
REQUIRE(nu < M_PI);
check_roundtrip(p.first, p.second);
}
}
Vec3 pos_final, vel_final;
orbital_elements_to_cartesian(current, PARENT_MASS, &pos_final, &vel_final);
double final_distance = vec3_magnitude(pos_final);
double final_velocity = vec3_magnitude(vel_final);
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");
REQUIRE(final_distance > initial_distance);
SECTION("negative mean anomaly values") {
std::vector<std::pair<double, double>> tests = {
std::make_pair(-0.1, 1e-14), std::make_pair(-1.0, 1e-14),
std::make_pair(-5.0, 1e-14)
};
for (const auto& p : tests) {
double nu = solve_barker_equation(p.first);
REQUIRE(nu < 0.0);
REQUIRE(nu > -M_PI);
check_roundtrip(p.first, p.second);
}
}
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);
INFO("Final escape velocity: " << final_escape_velocity / 1000.0 << " km/s");
SECTION("true anomaly stays within (-pi, pi) for M in [-50, 50]") {
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/matchers/catch_matchers_floating_point.hpp>
#include <cmath>
#include <array>
#include <vector>
#include "../src/orbital_mechanics.h"
#include "../src/orbital_objects.h"
#include "../src/test_utilities.h"
#include "../src/config_loader.h"
#include "../src/simulation.h"
using Catch::Matchers::WithinAbs;
TEST_CASE("Cartesian to Elements - Advanced Tests", "[orbital_mechanics]") {
const double G = 6.67430e-11;
SCENARIO("Cartesian to Elements - Advanced conversion tests",
"[orbital_mechanics][cartesian][elements]") {
const double M_sun = 1.989e30;
const double mu = G * M_sun;
SECTION("Circular orbit conversion preserves exact circular parameters") {
double r = 1.496e11;
double v_circular = sqrt(mu / r);
Vec3 position = {r, 0.0, 0.0};
Vec3 velocity = {0.0, v_circular, 0.0};
OrbitalElements elements = cartesian_to_orbital_elements(position, velocity, M_sun);
REQUIRE_THAT(elements.eccentricity, WithinAbs(0.0, 1e-10));
REQUIRE_THAT(elements.semi_major_axis, WithinAbs(r, 1e3));
Vec3 converted_position, converted_velocity;
orbital_elements_to_cartesian(elements, M_sun, &converted_position, &converted_velocity);
REQUIRE(compare_vec3(position, converted_position, 1e3));
REQUIRE(compare_vec3(velocity, converted_velocity, 1e-3));
}
SECTION("Near-circular orbit (e=0.001) recovers small eccentricity") {
OrbitalElements elements = {
.semi_major_axis = 1.496e11,
.eccentricity = 0.001,
.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.eccentricity, WithinAbs(0.001, 1e-6));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.496e11, 1e3));
}
SECTION("Elliptical orbit (e=0.5) preserves orbital shape") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.5,
.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(0.5, 1e-4));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6));
}
SECTION("Highly elliptical orbit (e=0.95) preserves extreme eccentricity") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.95,
.true_anomaly = 0.1,
.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.95, 1e-3));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6));
}
SECTION("Near-parabolic orbit (e=0.999) recovers near-escape trajectory") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.999,
.true_anomaly = 0.05,
.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.999, 1e-3));
}
SECTION("Parabolic orbit (e=1.0) recovers escape trajectory") {
OrbitalElements elements = {
.semi_latus_rectum = 1.0e11,
.eccentricity = 1.0,
.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.eccentricity, WithinAbs(1.0, 1e-2));
REQUIRE_THAT(recovered.semi_latus_rectum, WithinAbs(1.0e11, 1e3));
}
SECTION("Hyperbolic orbit (e=2.0) preserves unbound trajectory") {
OrbitalElements elements = {
.semi_major_axis = -1.0e11,
.eccentricity = 2.0,
.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.eccentricity, WithinAbs(2.0, 1e-3));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(-1.0e11, 1e6));
}
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));
// NOTE: Semi-major axis tolerance for |a|=1e11 m cases. The vis-viva equation
// a = -mu/(2*epsilon) amplifies floating-point error in specific energy
// (~1e-15 rel) to absolute errors of ~1e-4 m at this scale. Header A_TOL=1e-6
// would fail; 2e-4 provides comfortable margin over observed ~1.4e-4 m error.
const double A_TOL_LARGE = 2e-4;
auto convert_and_recover = [&](const OrbitalElements& elements) {
Vec3 pos, vel;
orbital_elements_to_cartesian(elements, M_sun, &pos, &vel);
return cartesian_to_orbital_elements(pos, vel, M_sun);
};
auto make_elements = [&](double a, double e, double nu, double inc,
double lon_anode, double arg_peri) {
OrbitalElements el = {};
el.semi_major_axis = a;
el.eccentricity = e;
el.true_anomaly = nu;
el.inclination = inc;
el.longitude_of_ascending_node = lon_anode;
el.argument_of_periapsis = arg_peri;
return el;
};
SECTION("eccentricity spectrum: circular to highly hyperbolic") {
const double r = 1.496e11;
const double v_circular = sqrt(G * M_sun / r);
const Vec3 pos_circ = {r, 0.0, 0.0};
const Vec3 vel_circ = {0.0, v_circular, 0.0};
const OrbitalElements circular = make_elements(r, 0.0, 0.0, 0.0, 0.0, 0.0);
Vec3 converted_pos, converted_vel;
orbital_elements_to_cartesian(circular, M_sun, &converted_pos, &converted_vel);
const OrbitalElements recovered_circ =
cartesian_to_orbital_elements(converted_pos, converted_vel, M_sun);
REQUIRE_THAT(recovered_circ.eccentricity, WithinAbs(0.0, E_TOL));
REQUIRE_THAT(recovered_circ.semi_major_axis, WithinAbs(r, A_TOL_LARGE));
REQUIRE(compare_vec3(pos_circ, converted_pos, A_TOL_LARGE));
REQUIRE(compare_vec3(vel_circ, converted_vel, V_TOL));
// Near-circular (e=0.001)
const OrbitalElements near_circ = make_elements(1.496e11, 0.001, 0.5, 0.0, 0.0, 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));
// Elliptical (e=0.5)
const OrbitalElements elliptical = make_elements(1.0e11, 0.5, 0.8, 0.0, 0.0, 0.0);
const OrbitalElements rec_elliptical = convert_and_recover(elliptical);
REQUIRE_THAT(rec_elliptical.eccentricity, WithinAbs(0.5, E_TOL));
REQUIRE_THAT(rec_elliptical.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE));
// Highly elliptical (e=0.95)
const OrbitalElements high_ell = make_elements(1.0e11, 0.95, 0.1, 0.0, 0.0, 0.0);
const OrbitalElements rec_high_ell = convert_and_recover(high_ell);
REQUIRE_THAT(rec_high_ell.eccentricity, WithinAbs(0.95, E_TOL));
REQUIRE_THAT(rec_high_ell.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE));
// Near-parabolic (e=0.999)
const OrbitalElements near_par = make_elements(1.0e11, 0.999, 0.05, 0.0, 0.0, 0.0);
const OrbitalElements rec_near_par = convert_and_recover(near_par);
REQUIRE_THAT(rec_near_par.eccentricity, WithinAbs(0.999, E_TOL));
// Parabolic (e=1.0)
OrbitalElements parabolic = {};
parabolic.semi_latus_rectum = 1.0e11;
parabolic.eccentricity = 1.0;
parabolic.true_anomaly = 0.5;
parabolic.inclination = 0.0;
parabolic.longitude_of_ascending_node = 0.0;
parabolic.argument_of_periapsis = 0.0;
const OrbitalElements rec_parabolic = convert_and_recover(parabolic);
REQUIRE_THAT(rec_parabolic.eccentricity, WithinAbs(1.0, E_TOL));
REQUIRE_THAT(rec_parabolic.semi_latus_rectum, WithinAbs(1.0e11, A_TOL));
// Hyperbolic (e=2.0)
const OrbitalElements hyper = make_elements(-1.0e11, 2.0, 0.5, 0.0, 0.0, 0.0);
const OrbitalElements rec_hyper = convert_and_recover(hyper);
REQUIRE_THAT(rec_hyper.eccentricity, WithinAbs(2.0, E_TOL));
REQUIRE_THAT(rec_hyper.semi_major_axis, WithinAbs(-1.0e11, A_TOL_LARGE));
// Highly hyperbolic (e=10.0)
const OrbitalElements high_hyper = make_elements(-1.0e10, 10.0, 0.8, 0.0, 0.0, 0.0);
const OrbitalElements rec_high_hyper = convert_and_recover(high_hyper);
REQUIRE_THAT(rec_high_hyper.eccentricity, WithinAbs(10.0, E_TOL));
REQUIRE_THAT(rec_high_hyper.semi_major_axis, WithinAbs(-1.0e10, A_TOL));
}
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);
const OrbitalElements rec_eq = convert_and_recover(eq);
REQUIRE_THAT(rec_eq.inclination, WithinAbs(0.0, ANG_TOL));
REQUIRE_THAT(rec_eq.eccentricity, WithinAbs(0.3, E_TOL));
// 90-degree inclination (polar)
const OrbitalElements polar = make_elements(1.0e11, 0.2, 0.6, M_PI / 2.0, 0.5, 0.3);
const OrbitalElements rec_polar = convert_and_recover(polar);
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));
// 180-degree inclination (retrograde)
const OrbitalElements retro = make_elements(1.0e11, 0.2, 0.6, M_PI, 0.5, 0.3);
const OrbitalElements rec_retro = convert_and_recover(retro);
REQUIRE_THAT(rec_retro.inclination, WithinAbs(M_PI, ANG_TOL_COARSE));
}
SECTION("true anomaly at key orbital positions") {
struct nu_test {
double nu;
double expected_nu;
const char* label;
};
std::vector<nu_test> tests = {
{0.0, 0.0, "periapsis"},
{M_PI, M_PI, "apoapsis"},
{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"},
{-3.0 * M_PI / 2.0, M_PI / 2.0, "quadrature -270"},
};
for (const auto& t : tests) {
const OrbitalElements elements = make_elements(1.0e11, 0.5, t.nu, 0.0, 0.0, 0.0);
const OrbitalElements recovered = convert_and_recover(elements);
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("Large 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
SECTION("quadrature at various eccentricities") {
struct e_test {
double e;
};
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(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
std::vector<e_test> tests = {
{0.9},
{0.1},
};
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(1.28318530717958623, 1e-6));
for (const auto& t : tests) {
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);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(t.e, E_TOL));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, ANG_TOL));
}
}
SECTION("Very large true anomaly nu=10.0 rad (approx 573 deg) preserves accuracy") {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.5,
.true_anomaly = 10.0,
.inclination = 0.0,
.longitude_of_ascending_node = 0.0,
.argument_of_periapsis = 0.0
SECTION("large true anomaly values") {
struct large_nu_test {
double nu;
double expected_nu;
const char* label;
};
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, 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
std::vector<large_nu_test> tests = {
{5.0, 5.0, "nu=5.0"},
{-5.0, 1.28318530717958623, "nu=-5.0"},
{10.0, 10.0 - 2.0 * M_PI, "nu=10.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-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));
for (const auto& t : tests) {
const OrbitalElements elements = make_elements(1.0e11, 0.5, t.nu, 0.0, 0.0, 0.0);
const OrbitalElements recovered = convert_and_recover(elements);
INFO("Test: " << t.label);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, E_TOL));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(t.expected_nu, ANG_TOL));
}
}
SECTION("Multiple quadrature points in sequence maintain accuracy") {
double true_anomalies[] = {0.0, M_PI/4.0, M_PI/2.0, 3.0*M_PI/4.0, M_PI};
for (int i = 0; i < 5; i++) {
OrbitalElements elements = {
.semi_major_axis = 1.0e11,
.eccentricity = 0.5,
.true_anomaly = true_anomalies[i],
.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(true_anomalies[i], 1e-6));
SECTION("3D orientation with quadrature point") {
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);
const OrbitalElements recovered = convert_and_recover(elements);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, E_TOL));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, ANG_TOL));
REQUIRE_THAT(recovered.inclination, WithinAbs(M_PI / 3.0, ANG_TOL_COARSE));
REQUIRE_THAT(recovered.longitude_of_ascending_node, WithinAbs(M_PI / 4.0, ANG_TOL_COARSE));
REQUIRE_THAT(recovered.argument_of_periapsis, WithinAbs(M_PI / 6.0, ANG_TOL_COARSE));
}
SECTION("multiple true anomaly points in sequence") {
std::array<double, 5> true_anomalies = {0.0, M_PI / 4.0, M_PI / 2.0,
3.0 * M_PI / 4.0, M_PI};
for (double nu : true_anomalies) {
const OrbitalElements elements = make_elements(1.0e11, 0.5, nu, 0.0, 0.0, 0.0);
const OrbitalElements recovered = convert_and_recover(elements);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, E_TOL));
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") {
OrbitalElements elements = {
.semi_major_axis = -1.0e11,
.eccentricity = 2.0,
.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(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));
SECTION("hyperbolic orbit at quadrature point") {
const OrbitalElements elements = make_elements(-1.0e11, 2.0, M_PI / 2.0, 0.0, 0.0, 0.0);
const OrbitalElements recovered = convert_and_recover(elements);
REQUIRE_THAT(recovered.eccentricity, WithinAbs(2.0, E_TOL));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(-1.0e11, A_TOL_LARGE));
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, ANG_TOL));
}
}

240
tests/test_cartesian_to_elements_basic.cpp

@ -1,190 +1,84 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h"
#include "../src/orbital_mechanics.h"
#include "../src/simulation.h"
#include "../src/config_loader.h"
#include "../src/test_utilities.h"
#include <cmath>
const double POSITION_TOLERANCE = 1.0e6;
const double VELOCITY_TOLERANCE = 10.0;
const double ELEMENT_TOLERANCE = 1.0e-6;
using Catch::Matchers::WithinAbs;
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 parent_mass = 5.972e24;
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"));
Spacecraft* craft = &sim->spacecraft[0];
double expected_a = craft->orbit.semi_major_axis;
Vec3 position;
Vec3 velocity;
orbital_elements_to_cartesian(craft->orbit, sim->bodies[0].mass, &position, &velocity);
OrbitalElements elements = cartesian_to_orbital_elements(position, velocity, sim->bodies[0].mass);
double actual_a = elements.semi_major_axis;
double a_error = fabs(actual_a - expected_a);
double relative_error = a_error / fabs(expected_a);
INFO("Expected semi-major axis: " << expected_a << " m");
INFO("Actual semi-major axis: " << actual_a << " m");
INFO("Absolute error: " << a_error << " m");
INFO("Relative error: " << relative_error * 100.0 << "%");
REQUIRE(relative_error < ELEMENT_TOLERANCE);
destroy_simulation(sim);
}
TEST_CASE("Eccentricity accuracy", "[cartesian][elements][eccentricity]") {
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];
double expected_e = craft->orbit.eccentricity;
Vec3 position;
Vec3 velocity;
orbital_elements_to_cartesian(craft->orbit, sim->bodies[0].mass, &position, &velocity);
OrbitalElements elements = cartesian_to_orbital_elements(position, velocity, sim->bodies[0].mass);
double actual_e = elements.eccentricity;
double e_error = fabs(actual_e - expected_e);
INFO("Expected eccentricity: " << expected_e);
INFO("Actual eccentricity: " << actual_e);
INFO("Absolute error: " << e_error);
REQUIRE(e_error < ELEMENT_TOLERANCE);
const OrbitalElements& orig = craft->orbit;
// Convert elements → state vectors
Vec3 pos, vel;
orbital_elements_to_cartesian(orig, parent_mass, &pos, &vel);
const double expected_r = vec3_magnitude(pos); // 7500000.0
const double expected_v = vec3_magnitude(vel); // 8928.484709...
// Round-trip: state vectors → elements
const OrbitalElements recovered = cartesian_to_orbital_elements(pos, vel, parent_mass);
// Re-convert recovered elements → state vectors
Vec3 pos2, vel2;
orbital_elements_to_cartesian(recovered, parent_mass, &pos2, &vel2);
const double recovered_r = vec3_magnitude(pos2);
const double recovered_v = vec3_magnitude(vel2);
SECTION("elements round-trip: semi-major axis") {
const double da = fabs(recovered.semi_major_axis - orig.semi_major_axis);
INFO("Original a: " << orig.semi_major_axis);
INFO("Recovered a: " << recovered.semi_major_axis);
INFO("Error: " << da << " m");
REQUIRE_THAT(da, WithinAbs(0.0, A_TOL));
}
SECTION("elements round-trip: eccentricity") {
const double de = fabs(recovered.eccentricity - orig.eccentricity);
INFO("Original e: " << orig.eccentricity);
INFO("Recovered e: " << recovered.eccentricity);
INFO("Error: " << de);
REQUIRE_THAT(de, WithinAbs(0.0, E_TOL));
}
SECTION("elements round-trip: true anomaly") {
const double dnu = fabs(recovered.true_anomaly - orig.true_anomaly);
INFO("Original nu: " << orig.true_anomaly);
INFO("Recovered nu: " << recovered.true_anomaly);
INFO("Error: " << dnu);
REQUIRE_THAT(dnu, WithinAbs(0.0, ANG_TOL));
}
SECTION("elements round-trip: inclination") {
const double dinc = fabs(recovered.inclination - orig.inclination);
INFO("Original inc: " << orig.inclination);
INFO("Recovered inc: " << recovered.inclination);
INFO("Error: " << dinc);
REQUIRE_THAT(dinc, WithinAbs(0.0, ANG_TOL));
}
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);
}

20
tests/test_cartesian_to_elements_basic.toml

@ -1,5 +1,4 @@
# Test Configuration: Basic Elliptical Orbit
# Moderate eccentricity, zero inclination for testing Cartesian ↔ orbital elements conversion
# Basic elliptical orbit: a=15000km, e=0.5, zero inclination
[[bodies]]
name = "Earth"
@ -7,21 +6,10 @@ 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
}
orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
[[spacecraft]]
[[spacecraft]]
name = "Test_Spacecraft"
mass = 1000.0
parent_index = 0
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
}
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 }

42
tests/test_energy.cpp

@ -1,34 +1,60 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h"
#include "../src/simulation.h"
#include "../src/config_loader.h"
#include "../src/test_utilities.h"
#include <cmath>
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 DAYS_TO_SIMULATE = 10.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"));
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;
while (sim->time < total_time) {
update_simulation(sim);
}
double final_energy = calculate_system_total_energy(sim);
double energy_drift_percent = fabs((final_energy - initial_energy) / initial_energy) * 100.0;
const double final_energy = calculate_system_total_energy(sim);
const double energy_drift = fabs(final_energy - initial_energy) / fabs(initial_energy);
INFO("Initial energy: " << initial_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);
}

12
tests/test_energy.toml

@ -8,11 +8,7 @@ mass = 1.989e30
radius = 6.96e8
parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = {
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
[[bodies]]
name = "Earth"
@ -20,8 +16,4 @@ mass = 5.972e24
radius = 6.371e6
parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = {
semi_major_axis = 1.496e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
orbit = { 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/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h"
#include "../src/orbital_mechanics.h"
#include "../src/simulation.h"
#include "../src/config_loader.h"
#include "../src/test_utilities.h"
#include <cmath>
#include <array>
const double VELOCITY_TOLERANCE = 1.0e-6;
const double POSITION_TOLERANCE = 1.0e3;
using Catch::Matchers::WithinAbs;
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 parent_mass = 5.972e24;
const double mu = G * parent_mass;
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];
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];
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];
CelestialBody* earth = &sim->bodies[0];
INFO("Testing spacecraft with e=" << hyperbolic->orbit.eccentricity);
Vec3 pos;
Vec3 vel;
orbital_elements_to_cartesian(hyperbolic->orbit, earth->mass, &pos, &vel);
// Precomputed analytical values for spacecraft 0 (a=6.5e8, e=0.99)
const double a0 = high_e->orbit.semi_major_axis;
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);
double v = vec3_magnitude(vel);
SECTION("highly elliptical: apoapsis radius = a*(1+e)") {
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;
double a = hyperbolic->orbit.semi_major_axis;
double escape_velocity = sqrt(2.0 * mu / r);
double circular_velocity = sqrt(mu / r);
INFO("r=" << r << " m, expected=" << expected_r_apo0 << " m");
INFO("v=" << v << " m/s");
INFO("Radius: " << r << " m");
INFO("Velocity: " << v << " m/s");
INFO("Escape velocity: " << escape_velocity << " m/s");
INFO("Circular velocity: " << circular_velocity << " m/s");
INFO("Semi-major axis: " << a << " m");
REQUIRE_THAT(r, WithinAbs(expected_r_apo0, R_TOL));
REQUIRE_THAT(v, WithinAbs(55.511063, V_TOL));
check_visviva(high_e->orbit, r, v);
}
double expected_v_squared = mu * (2.0 / r - 1.0 / a);
double expected_v = sqrt(expected_v_squared);
SECTION("near-parabolic: periapsis radius") {
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);
double relative_error = v_error / expected_v;
INFO("r=" << r << " m, expected=" << expected_r_peri1 << " m");
INFO("v=" << v << " m/s");
INFO("Expected velocity: " << expected_v << " m/s");
INFO("Velocity error: " << v_error << " m/s (" << relative_error * 100.0 << "%)");
REQUIRE_THAT(r, WithinAbs(expected_r_peri1, R_TOL));
REQUIRE_THAT(v, WithinAbs(10644.867979, V_TOL));
check_visviva(near_parabolic->orbit, r, v);
}
REQUIRE(relative_error < VELOCITY_TOLERANCE);
REQUIRE(v > escape_velocity * 0.9);
REQUIRE(a < 0.0);
SECTION("near-parabolic: apoapsis radius") {
convert_at_nu(near_parabolic, M_PI);
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]") {
const double TIME_STEP = 60.0;
REQUIRE_THAT(r, WithinAbs(expected_r_apo1, R_TOL));
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++) {
Spacecraft* craft = &sim->spacecraft[i];
SECTION("hyperbolic: velocity matches vis-viva") {
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++) {
double nu = true_anomalies[j];
SECTION("hyperbolic: true anomaly limits") {
INFO("max_nu=" << max_nu_hyperbolic << " rad (±" << max_nu_hyperbolic * 180.0 / M_PI << "°)");
// For hyperbolic orbits (e > 1), skip invalid true anomalies
// Valid range: |ν| < arccos(-1/e)
if (craft->orbit.eccentricity > 1.0) {
double max_nu = acos(-1.0 / craft->orbit.eccentricity);
if (fabs(nu) >= max_nu) {
INFO(" ν=" << nu << " rad: skipped (exceeds hyperbolic limit ±" << max_nu << " rad)");
continue;
}
}
// pi and 3pi/2 should be outside hyperbolic range
const double pi = M_PI;
const double three_pi_half = 3.0 * M_PI / 2.0;
INFO("pi=" << pi << " rad, exceeds limit: " << (fabs(pi) >= max_nu_hyperbolic));
INFO("3pi/2=" << three_pi_half << " rad, exceeds limit: " << (fabs(three_pi_half) >= max_nu_hyperbolic));
REQUIRE(fabs(pi) >= max_nu_hyperbolic);
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;
Vec3 vel;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos, &vel);
for (int i = 0; i < sim->craft_count; i++) {
Spacecraft* craft = &sim->spacecraft[i];
const double a = craft->orbit.semi_major_axis;
const double e = craft->orbit.eccentricity;
double r = vec3_magnitude(pos);
double v = vec3_magnitude(vel);
INFO("Spacecraft " << i << ": e=" << e << ", a=" << a);
double a = craft->orbit.semi_major_axis;
double mu = G * earth->mass;
for (int j = 0; j < 4; j++) {
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) {
double expected_v = sqrt(expected_v_squared);
double v_error = fabs(v - expected_v);
double relative_error = v_error / expected_v;
craft->orbit.true_anomaly = nu;
Vec3 pos, vel;
orbital_elements_to_cartesian(craft->orbit, parent_mass, &pos, &vel);
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);
}
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
# Tests near-parabolic and hyperbolic orbits
[[bodies]]
name = "Earth"
@ -7,47 +6,22 @@ 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
}
orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
[[spacecraft]]
[[spacecraft]]
name = "Highly_Elliptical"
mass = 1000.0
parent_index = 0
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
}
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 }
[[spacecraft]]
[[spacecraft]]
name = "Near_Parabolic"
mass = 1000.0
parent_index = 0
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
}
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 }
[[spacecraft]]
[[spacecraft]]
name = "Slightly_Hyperbolic"
mass = 1000.0
parent_index = 0
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
}
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 }

586
tests/test_extreme_orientation_mixed.cpp

@ -4,387 +4,319 @@
#include "../src/orbital_mechanics.h"
#include "../src/simulation.h"
#include "../src/config_loader.h"
#include "../src/test_utilities.h"
#include <cmath>
#include <array>
using Catch::Matchers::WithinAbs;
const double POSITION_TOLERANCE_METERS = 1.0e6;
const double VELOCITY_TOLERANCE_MS = 1.0;
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]") {
SCENARIO("Extreme orientation conversion accuracy and rotation matrix properties",
"[extreme][orientation][mixed]") {
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];
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);
}
const double parent_mass = 5.972e24;
const double mu = G * parent_mass;
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);
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));
REQUIRE(vec3_magnitude(angular_momentum) > 0.0);
}
Spacecraft* sc0 = &sim->spacecraft[0];
Spacecraft* sc1 = &sim->spacecraft[1];
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);
}
TEST_CASE("Argument of periapsis (ω) singularity handling", "[extreme][orientation][arg_peri]") {
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];
SECTION("Spacecraft with ω = 0 (periapsis at ascending node)") {
Spacecraft* arg_peri_zero = &sim->spacecraft[4];
SECTION("Rotation matrices handle ω = 0 without numerical instability") {
Vec3 pos, vel;
orbital_elements_to_cartesian(arg_peri_zero->orbit, earth->mass, &pos, &vel);
double r = vec3_magnitude(pos);
double expected_r = arg_peri_zero->orbit.semi_major_axis * (1.0 - arg_peri_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));
}
SECTION("velocity at apsides for extreme orbits") {
// sc0 at periapsis
convert_at_nu(sc0, 0.0);
const double v0p = vec3_magnitude(sc0->local_velocity);
convert_at_nu(sc0, M_PI);
const double v0a = vec3_magnitude(sc0->local_velocity);
INFO("sc0: v_peri=" << v0p << " m/s, v_apo=" << v0a << " m/s");
REQUIRE_THAT(v0p, WithinAbs(9915.577056, V_TOL));
REQUIRE_THAT(v0a, WithinAbs(803.965707, V_TOL));
// sc1 at periapsis
convert_at_nu(sc1, 0.0);
const double v1p = vec3_magnitude(sc1->local_velocity);
convert_at_nu(sc1, M_PI);
const double v1a = vec3_magnitude(sc1->local_velocity);
INFO("sc1: v_peri=" << v1p << " m/s, v_apo=" << v1a << " m/s");
REQUIRE_THAT(v1p, WithinAbs(7732.294575, V_TOL));
REQUIRE_THAT(v1a, WithinAbs(2577.431525, V_TOL));
// sc2 at periapsis
convert_at_nu(sc2, 0.0);
const double v2p = vec3_magnitude(sc2->local_velocity);
convert_at_nu(sc2, M_PI);
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") {
double r = vec3_magnitude(arg_peri_zero->global_position);
double expected_r_perigee = arg_peri_zero->orbit.semi_major_axis * (1.0 - arg_peri_zero->orbit.eccentricity);
SECTION("apoapsis position for extreme orbits") {
convert_at_nu(sc0, M_PI);
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");
INFO("r=" << r << " m, r_perigee=" << expected_r_perigee << " m");
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};
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") {
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];
INFO("Spacecraft " << i << ": e=" << e << ", a=" << a);
for (int j = 0; j < 4; j++) {
double nu = true_anomalies[j];
craft->orbit.true_anomaly = nu;
Vec3 pos, vel;
orbital_elements_to_cartesian(arg_peri_zero->orbit, earth->mass, &pos, &vel);
double r = vec3_magnitude(pos);
double v = vec3_magnitude(vel);
orbital_elements_to_cartesian(craft->orbit, parent_mass, &pos, &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);
REQUIRE(v > 0.0);
const double expected_v_sq = mu * (2.0 / r - 1.0 / a);
// 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);
}
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("apsidal velocity orthogonality") {
const std::array<double, 2> apsides = {0.0, M_PI};
SECTION("Spacecraft " + std::to_string(craft_idx) + ": velocity orientation at apsides") {
double true_anomalies[] = {0.0, M_PI};
for (int i = 0; i < 2; i++) {
craft->orbit.true_anomaly = true_anomalies[i];
for (int i = 0; i < 5; i++) {
Spacecraft* craft = &sim->spacecraft[i];
INFO("Spacecraft " << i << ": e=" << craft->orbit.eccentricity
<< ", i=" << craft->orbit.inclination << " rad");
for (int j = 0; j < 2; j++) {
craft->orbit.true_anomaly = apsides[j];
Vec3 pos, vel;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos, &vel);
double pos_dot_vel = vec3_dot(pos, vel);
Vec3 angular_momentum = vec3_cross(pos, vel);
orbital_elements_to_cartesian(craft->orbit, parent_mass, &pos, &vel);
INFO("Spacecraft " << craft_idx << " (e=" << craft->orbit.eccentricity << ", i=" << craft->orbit.inclination << ")");
INFO("ν=" << true_anomalies[i] << " rad: pos·vel = " << pos_dot_vel);
const double pos_dot_vel = vec3_dot(pos, vel);
const double h = vec3_magnitude(vec3_cross(pos, vel));
REQUIRE_THAT(pos_dot_vel, WithinAbs(0.0, VELOCITY_TOLERANCE_MS * 1000.0));
REQUIRE(vec3_magnitude(angular_momentum) > 0.0);
INFO(" nu=" << apsides[j] << " rad: pos·vel=" << pos_dot_vel << ", |h|=" << h);
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]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 5, 0, TIME_STEP);
Vec3 pos, vel;
orbital_elements_to_cartesian(sc3->orbit, parent_mass, &pos, &vel);
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) + ": 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);
// Rotation matrix at Omega=0 must produce a valid position in the x-y plane
REQUIRE(pos.x > 0.0);
REQUIRE_THAT(r, WithinAbs(r_peri3, R_TOL));
if (expected_v_sq > 0.0) {
double expected_v = sqrt(expected_v_sq);
double relative_error = fabs(v - expected_v) / expected_v;
INFO("ν=" << true_anomalies[i] << " rad: v=" << v << " m/s, expected=" << expected_v << " m/s");
INFO("Relative error: " << relative_error * 100.0 << "%");
const double h = vec3_magnitude(vec3_cross(pos, vel));
INFO(" |h|=" << h);
// Angular momentum must be non-zero for a valid orbit
REQUIRE(h > 0.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);
}
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];
SECTION("round-trip conversion accuracy") {
const std::array<Spacecraft*, 5> crafts = {sc0, sc1, sc2, sc3, sc4};
for (int craft_idx = 0; craft_idx < sim->craft_count; craft_idx++) {
Spacecraft* craft = &sim->spacecraft[craft_idx];
for (int i = 0; i < 5; i++) {
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") {
Vec3 pos, vel;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos, &vel);
INFO("Spacecraft " << i << ": " << craft->name);
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(" Semi-major axis: " << craft->orbit.semi_major_axis << " -> " << recovered.semi_major_axis);
INFO(" Eccentricity: " << craft->orbit.eccentricity << " -> " << recovered.eccentricity);
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);
INFO(" e: " << e << " -> " << recovered.eccentricity);
INFO(" i: " << inc << " -> " << recovered.inclination);
INFO(" a: " << a << " -> " << recovered.semi_major_axis);
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(craft->orbit.semi_major_axis, fabs(craft->orbit.semi_major_axis) * 0.01));
REQUIRE_THAT(recovered.eccentricity, WithinAbs(craft->orbit.eccentricity, ELEMENT_TOLERANCE));
REQUIRE_THAT(recovered.inclination, WithinAbs(craft->orbit.inclination, ANGULAR_TOLERANCE));
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(a, fabs(a) * 0.01));
REQUIRE_THAT(recovered.eccentricity, WithinAbs(e, E_TOL));
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) {
REQUIRE_THAT(recovered.longitude_of_ascending_node, WithinAbs(craft->orbit.longitude_of_ascending_node, ANGULAR_TOLERANCE * 10.0));
if (O > 1e-6 || O < -1e-6) {
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) {
REQUIRE_THAT(recovered.argument_of_periapsis, WithinAbs(craft->orbit.argument_of_periapsis, ANGULAR_TOLERANCE * 10.0));
if (w > 1e-6 || w < -1e-6) {
REQUIRE_THAT(recovered.argument_of_periapsis, WithinAbs(w, ANG_TOL));
}
SECTION("Round-trip preserves position and velocity") {
Vec3 pos2, vel2;
orbital_elements_to_cartesian(recovered, earth->mass, &pos2, &vel2);
double pos_error = vec3_magnitude(vec3_sub(pos, pos2));
double vel_error = vec3_magnitude(vec3_sub(vel, vel2));
// Round-trip preserves position and velocity
Vec3 pos, vel;
orbital_elements_to_cartesian(recovered, parent_mass, &pos, &vel);
INFO("Spacecraft " << craft_idx << ": " << craft->name);
INFO(" Position error: " << pos_error << " m");
INFO(" Velocity error: " << vel_error << " m/s");
Vec3 pos2, vel2;
orbital_elements_to_cartesian(recovered, parent_mass, &pos2, &vel2);
REQUIRE_THAT(pos_error, WithinAbs(0.0, POSITION_TOLERANCE_METERS));
REQUIRE_THAT(vel_error, WithinAbs(0.0, VELOCITY_TOLERANCE_MS * 1000.0));
}
const double pos_err = vec3_magnitude(vec3_sub(pos, pos2));
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);
}
TEST_CASE("Rotation matrix verification for extreme parameters", "[extreme][orientation][matrices]") {
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"));
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) + ": rotation matrix properties") {
double omega = craft->orbit.argument_of_periapsis;
double i = craft->orbit.inclination;
double Omega = craft->orbit.longitude_of_ascending_node;
Mat3 R_orbital = mat3_rotation_orbital(omega, i, Omega);
SECTION("Rotation matrix preserves vector magnitudes (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 mag_x = vec3_magnitude(rot_x);
double mag_y = vec3_magnitude(rot_y);
double mag_z = vec3_magnitude(rot_z);
INFO("Spacecraft " << craft_idx << ": " << craft->name);
INFO(" |R·x| = " << mag_x << " (expected 1.0)");
INFO(" |R·y| = " << mag_y << " (expected 1.0)");
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));
}
SECTION("rotation matrix orthogonality") {
for (int i = 0; i < 5; i++) {
Spacecraft* craft = &sim->spacecraft[i];
const double omega = craft->orbit.argument_of_periapsis;
const double inc = craft->orbit.inclination;
const double Omega = craft->orbit.longitude_of_ascending_node;
Mat3 R = mat3_rotation_orbital(omega, inc, Omega);
const Vec3 unit_x = {1.0, 0.0, 0.0};
const Vec3 unit_y = {0.0, 1.0, 0.0};
const Vec3 unit_z = {0.0, 0.0, 1.0};
Vec3 rot_x = mat3_multiply_vec3(R, unit_x);
Vec3 rot_y = mat3_multiply_vec3(R, unit_y);
Vec3 rot_z = mat3_multiply_vec3(R, unit_z);
const double mag_x = vec3_magnitude(rot_x);
const double mag_y = vec3_magnitude(rot_y);
const double mag_z = vec3_magnitude(rot_z);
const double xy_dot = vec3_dot(rot_x, rot_y);
const double yz_dot = vec3_dot(rot_y, rot_z);
const double xz_dot = vec3_dot(rot_x, rot_z);
INFO("Spacecraft " << i << ": " << craft->name);
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);
REQUIRE_THAT(mag_x, WithinAbs(1.0, MAT_TOL));
REQUIRE_THAT(mag_y, WithinAbs(1.0, MAT_TOL));
REQUIRE_THAT(mag_z, WithinAbs(1.0, MAT_TOL));
REQUIRE_THAT(xy_dot, WithinAbs(0.0, MAT_TOL));
REQUIRE_THAT(yz_dot, WithinAbs(0.0, MAT_TOL));
REQUIRE_THAT(xz_dot, WithinAbs(0.0, MAT_TOL));
}
}

62
tests/test_extreme_orientation_mixed.toml

@ -1,88 +1,38 @@
# Test Configuration: Extreme Orientation Mixed Cases
# Tests combined high inclination + high eccentricity orbital mechanics
# Tests singularity handling at Ω=0 and ω=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
}
orbit = { 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]]
name = "High_Inc_High_Ecc"
mass = 1000.0
parent_index = 0
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
}
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 }
# Test body 2: Very high inclination near polar + moderate eccentricity
# i = 1.4 rad (80°), e = 0.5
[[spacecraft]]
name = "Polar_Moderate_Ecc"
mass = 1000.0
parent_index = 0
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
}
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 }
# Test body 3: High eccentricity near parabolic with moderate inclination
# e = 0.99, i = 0.5 rad (28.6°)
[[spacecraft]]
name = "Near_Parabolic_Mod_Inc"
mass = 1000.0
parent_index = 0
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
}
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 }
# Test body 4: Edge case near Ω singularity (Ω = 0) with high inclination/eccentricity
[[spacecraft]]
name = "Omega_Zero"
mass = 1000.0
parent_index = 0
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
}
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 }
# Test body 5: Edge case near ω singularity (ω = 0) with high inclination/eccentricity
[[spacecraft]]
name = "Arg_Peri_Zero"
mass = 1000.0
parent_index = 0
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
}
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 }

580
tests/test_extreme_timescales.cpp

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

78
tests/test_extreme_timescales.toml

@ -1,5 +1,4 @@
# Test Configuration: Extreme Timescales for Analytical Propagation
# Tests orbital period extremes to validate propagation at different timescales
[[bodies]]
name = "Earth"
@ -7,11 +6,7 @@ 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
}
orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
[[bodies]]
name = "Sun"
@ -19,97 +14,40 @@ mass = 1.989e30
radius = 6.96e8
parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = {
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
orbit = { 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]]
name = "Fast_Orbit_LEO"
mass = 1000.0
parent_index = 0
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
}
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 }
# 2. Mercury-like fast orbit around Sun (period ~88 days)
# Tests moderately fast planetary orbit
[[spacecraft]]
name = "Mercury_Like_Orbit"
mass = 1000.0
parent_index = 1
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
}
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 }
# 3. Very long period orbit - Jupiter-like (period ~11.86 years)
# Tests mean anomaly accumulation over long time intervals
[[spacecraft]]
name = "Long_Period_Orbit"
mass = 1000.0
parent_index = 1
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
}
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 }
# 4. Very low altitude orbit (altitude ~100 km)
# Tests propagation near planetary surface
[[spacecraft]]
name = "Low_Altitude_Orbit"
mass = 1000.0
parent_index = 0
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
}
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 }
# 5. Super-synchronous orbit (period > 24 hours)
[[spacecraft]]
name = "Super_Synchronous_Orbit"
mass = 1000.0
parent_index = 0
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
}
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 }
# 6. Geosynchronous orbit (period = 24 hours exactly)
# Reference for period accuracy verification
[[spacecraft]]
name = "Geosynchronous_Orbit"
mass = 1000.0
parent_index = 0
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
}
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 }

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]]
name = "Sun"
mass = 1.989e30
radius = 6.96e8
parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = {
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
[[bodies]]
name = "Earth"
@ -21,29 +12,13 @@ mass = 5.972e24
radius = 6.371e6
parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = {
semi_major_axis = 1.496e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
# ========== IMPULSE BURN SPACECRAFT ==========
orbit = { semi_major_axis = 1.496e11, eccentricity = 0.0, true_anomaly = 0.0 }
# 1. Hohmann Transfer Spacecraft
# Initial circular LEO orbit (altitude ~400 km)
# Two maneuvers: apogee raise, circularization
[[spacecraft]]
name = "Hohmann_Transfer"
mass = 1000.0
parent_index = 1
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
}
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 }
[[maneuvers]]
name = "hohmann_burn_1"
@ -61,21 +36,11 @@ trigger_value = 5400.0
direction = "prograde"
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]]
name = "Plane_Change"
mass = 1000.0
parent_index = 1
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
}
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 }
[[maneuvers]]
name = "plane_change_burn"
@ -85,21 +50,11 @@ trigger_value = 0.0
direction = "normal"
delta_v = 1400.0
# 3. Periapsis Burn Spacecraft
# Initial elliptical orbit (e = 0.5)
# One maneuver: prograde burn at periapsis to raise apoapsis
[[spacecraft]]
name = "Periapsis_Burn"
mass = 1000.0
parent_index = 1
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
}
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 }
[[maneuvers]]
name = "periapsis_burn"
@ -109,21 +64,11 @@ trigger_value = 0.0
direction = "prograde"
delta_v = 500.0
# 4. Apoapsis Burn Spacecraft
# Initial elliptical orbit (e = 0.5)
# One maneuver: prograde burn at apoapsis to raise periapsis
[[spacecraft]]
name = "Apoapsis_Burn"
mass = 1000.0
parent_index = 1
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
}
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 }
[[maneuvers]]
name = "apoapsis_burn"
@ -133,21 +78,11 @@ trigger_value = 0.0
direction = "prograde"
delta_v = 500.0
# 5. Small Delta-v Burn Spacecraft
# Initial circular orbit
# One maneuver: minimal prograde burn (Δv < 1 m/s)
[[spacecraft]]
name = "Small_Delta_v"
mass = 1000.0
parent_index = 1
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
}
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 }
[[maneuvers]]
name = "small_burn"
@ -157,21 +92,11 @@ trigger_value = 0.0
direction = "prograde"
delta_v = 0.5
# 6. Large Delta-v Burn Spacecraft
# Initial circular orbit
# One maneuver: large prograde burn (Δv > orbital velocity)
[[spacecraft]]
name = "Large_Delta_v"
mass = 1000.0
parent_index = 1
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
}
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 }
[[maneuvers]]
name = "large_burn"
@ -181,74 +106,26 @@ trigger_value = 0.0
direction = "prograde"
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]]
name = "Low_Thrust_Ion"
mass = 1000.0
parent_index = 1
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
}
# 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)
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 }
[[spacecraft]]
name = "Multi_Burn_Sequence"
mass = 1000.0
parent_index = 1
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
}
# 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
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 }
[[spacecraft]]
name = "Mode_Transition"
mass = 1000.0
parent_index = 1
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
}
# 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
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 }
[[spacecraft]]
name = "Energy_Conservation"
mass = 1000.0
parent_index = 1
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
}
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 }

192
tests/test_inclined_orbits.cpp

@ -6,149 +6,96 @@
#include "../src/test_utilities.h"
#include <cmath>
const double POSITION_TOLERANCE_METERS = 10000.0;
const double PERIOD_TOLERANCE_SECONDS = 600.0;
using Catch::Matchers::WithinAbs;
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 SEMI_MAJOR_AXIS = 26540000.0;
const double ECCENTRICITY = 0.74;
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_inclined_orbits.toml"));
Spacecraft* molniya = &sim->spacecraft[0];
CelestialBody* earth = &sim->bodies[0];
SECTION("Position at perigee (true_anomaly = 0)") {
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 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;
auto check_radius_at_nu = [&](double nu, double expected_r) {
molniya->orbit.true_anomaly = nu;
initialize_orbital_objects(sim);
double expected_radius = SEMI_MAJOR_AXIS * (1.0 - ECCENTRICITY * ECCENTRICITY) / (1.0 + ECCENTRICITY * cos(M_PI / 2.0));
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 ν=π/2: " << expected_radius << " m");
INFO("Actual radius: " << actual_radius << " m");
INFO("Error: " << radius_error << " m");
REQUIRE(radius_error < POSITION_TOLERANCE_METERS);
double actual_r = vec3_magnitude(vec3_sub(molniya->global_position, earth->global_position));
INFO("nu: " << nu << " rad, expected r: " << expected_r << " m, actual r: " << actual_r << " m");
REQUIRE_THAT(actual_r, WithinAbs(expected_r, 10000.0));
};
CHECK(molniya->global_position.z != 0.0);
SECTION("Perigee (nu = 0)") {
check_radius_at_nu(0.0, SEMI_MAJOR_AXIS * (1.0 - ECCENTRICITY));
}
SECTION("Position at apogee (true_anomaly = π)") {
molniya->orbit.true_anomaly = M_PI;
initialize_orbital_objects(sim);
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("90 degrees (nu = pi/2)") {
double expected_r = SEMI_MAJOR_AXIS * (1.0 - ECCENTRICITY * ECCENTRICITY) /
(1.0 + ECCENTRICITY * cos(M_PI / 2.0));
check_radius_at_nu(M_PI / 2.0, expected_r);
}
SECTION("Position at true_anomaly = 3π/2 (270°)") {
molniya->orbit.true_anomaly = 3.0 * M_PI / 2.0;
initialize_orbital_objects(sim);
double expected_radius = SEMI_MAJOR_AXIS * (1.0 - ECCENTRICITY * ECCENTRICITY) / (1.0 + ECCENTRICITY * cos(3.0 * M_PI / 2.0));
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 ν=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)");
SECTION("Apogee (nu = pi)") {
check_radius_at_nu(M_PI, SEMI_MAJOR_AXIS * (1.0 + ECCENTRICITY));
}
SECTION("270 degrees (nu = 3pi/2)") {
double expected_r = SEMI_MAJOR_AXIS * (1.0 - ECCENTRICITY * ECCENTRICITY) /
(1.0 + ECCENTRICITY * cos(3.0 * M_PI / 2.0));
check_radius_at_nu(3.0 * M_PI / 2.0, expected_r);
}
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 SECONDS_PER_HOUR = 3600.0;
const double MAX_SIMULATION_HOURS = 15.0;
// Relaxed tolerance for highly elliptical orbit with 60s timestep
const double MOLNIYA_PERIOD_TOLERANCE_SECONDS = 1800.0; // 30 minutes
const double G_CONST = 6.67430e-11;
const double EARTH_MASS = 5.972e24;
const double MU = G_CONST * EARTH_MASS;
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_inclined_orbits.toml"));
Spacecraft* molniya = &sim->spacecraft[0];
CelestialBody* earth = &sim->bodies[0];
double semi_major_axis = molniya->orbit.semi_major_axis;
double mu = G * earth->mass;
double theoretical_period_seconds = 2.0 * M_PI * sqrt(pow(semi_major_axis, 3) / mu);
double theoretical_period_hours = theoretical_period_seconds / SECONDS_PER_HOUR;
INFO("Semi-major axis: " << semi_major_axis << " m");
INFO("Theoretical period from Kepler's 3rd law: " << theoretical_period_hours << " hours");
OrbitTracker* tracker = create_orbit_tracker_3d(0, 0.01,
molniya->orbit.inclination,
molniya->orbit.longitude_of_ascending_node,
molniya->orbit.argument_of_periapsis);
double max_time = MAX_SIMULATION_HOURS * SECONDS_PER_HOUR;
while (sim->time < max_time && !tracker->orbit_completed) {
update_simulation(sim);
update_orbit_tracker(tracker, (CelestialBody*)molniya, earth, sim->time);
const double a = molniya->orbit.semi_major_axis;
const double expected_apogee_r = a * (1.0 + molniya->orbit.eccentricity);
const double theoretical_half_period = M_PI * sqrt(a * a * a / MU);
INFO("Theoretical half period: " << theoretical_half_period << " s");
INFO("Expected apogee radius: " << expected_apogee_r << " m");
auto propagate_to_half_period = [&]() -> double {
double target_time = theoretical_half_period;
while (sim->time < target_time) {
update_simulation(sim);
}
return vec3_magnitude(vec3_sub(molniya->global_position, earth->global_position));
};
SECTION("After half period, craft reaches apogee") {
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);
}
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 SEMI_MAJOR_AXIS = 10000000.0;
const double ECCENTRICITY = 0.5;
const double INCLINATION_DEG = 45.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);
REQUIRE(load_system_config(sim, "tests/test_inclined_orbits.toml"));
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.inclination = INCLINATION_RAD;
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);
SECTION("Z-coordinate is non-zero for inclined orbit") {
double z_position = craft->global_position.z;
INFO("Z-coordinate: " << z_position << " m");
REQUIRE(z_position != 0.0);
}
auto check_z_nonzero = [&]() {
double z = craft->global_position.z;
INFO("Z-coordinate: " << z << " m");
REQUIRE_THAT(z, !WithinAbs(0.0, 0.001));
};
SECTION("Position magnitude matches orbital radius") {
double position_vector_mag = vec3_magnitude(craft->global_position);
auto check_radius = [&]() {
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");
INFO("Orbital radius: " << orbital_radius << " m");
INFO("Error: " << magnitude_error << " m");
REQUIRE(magnitude_error < POSITION_TOLERANCE_METERS);
}
SECTION("Z-coordinate is non-zero for inclined orbit") { check_z_nonzero(); }
SECTION("Position magnitude matches orbital radius") { check_radius(); }
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 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);
REQUIRE(load_system_config(sim, "tests/test_inclined_orbits.toml"));
Spacecraft* molniya = &sim->spacecraft[0];
INFO("Loaded inclination: " << (molniya->orbit.inclination * 180.0 / M_PI) << " degrees");
INFO("Expected inclination: " << EXPECTED_INCLINATION_DEG << " degrees");
REQUIRE_THAT(molniya->orbit.inclination, Catch::Matchers::WithinAbs(EXPECTED_INCLINATION_RAD, 0.01));
REQUIRE_THAT(molniya->orbit.inclination, WithinAbs(1.107, 0.01));
destroy_simulation(sim);
}

24
tests/test_inclined_orbits.toml

@ -1,13 +1,10 @@
# Test Configuration: Molniya Orbit
# Earth as root body with highly elliptical, highly inclined satellite orbit
# Molniya orbit parameters:
# - Period: ~718 minutes (~12 hours)
# - Semi-major axis: 26,540 km
# - Eccentricity: 0.74
# - Inclination: 63.4°
# - Argument of perigee: 270° (apogee at northernmost point)
# - Perigee altitude: ~600 km
# - Apogee altitude: ~39,700 km
# - Semi-major axis: ~26,600 km
# - Inclination: 63.4 deg
# - Argument of perigee: 270 deg (apogee at northernmost point)
[[bodies]]
name = "Earth"
@ -15,21 +12,10 @@ 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
}
orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
[[spacecraft]]
name = "Molniya_Satellite"
mass = 1000.0
parent_index = 0
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
}
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 }

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/maneuver.h"
#include "../src/config_loader.h"
#include "../src/rendezvous.h"
#include "../src/test_utilities.h"
#include <cmath>
#include <cstring>
using Catch::Matchers::WithinAbs;
TEST_CASE("Maneuver loading from config", "[maneuver][config]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuver_planning.toml"));
REQUIRE(sim->maneuver_count == 2);
REQUIRE(std::string(sim->maneuvers[0].name) == "orbit_raise_1");
REQUIRE(std::string(sim->maneuvers[1].name) == "orbit_raise_2");
REQUIRE(sim->maneuvers[0].trigger_type == TRIGGER_TIME);
REQUIRE(sim->maneuvers[1].trigger_type == TRIGGER_TRUE_ANOMALY);
REQUIRE(sim->maneuvers[0].delta_v == 500.0);
destroy_simulation(sim);
}
TEST_CASE("Time-based trigger executes at correct time", "[maneuver][trigger][time]") {
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(!sim->maneuvers[0].executed);
REQUIRE(!sim->maneuvers[1].executed);
double initial_velocity = vec3_magnitude(sim->spacecraft[0].local_velocity);
const double BURN_TIME = 3600.0;
while (sim->time < BURN_TIME + sim->dt) {
update_simulation(sim);
}
REQUIRE(sim->maneuvers[0].executed);
REQUIRE(fabs(sim->maneuvers[0].executed_time - BURN_TIME) < TIME_STEP);
double after_velocity = vec3_magnitude(sim->spacecraft[0].local_velocity);
REQUIRE(after_velocity > initial_velocity);
destroy_simulation(sim);
}
TEST_CASE("True anomaly trigger executes at correct angle", "[maneuver][trigger][true_anomaly]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuver_planning.toml"));
REQUIRE(sim->maneuver_count == 2);
REQUIRE(!sim->maneuvers[1].executed);
double first_burn_velocity = 0.0;
const double FIRST_BURN_TIME = 3600.0;
while (sim->time < FIRST_BURN_TIME) {
update_simulation(sim);
}
first_burn_velocity = vec3_magnitude(sim->spacecraft[0].local_velocity);
const double TARGET_ANOMALY = 3.14159;
(void)TARGET_ANOMALY;
double max_sim_time = FIRST_BURN_TIME + 72000.0;
while (sim->time < max_sim_time) {
update_simulation(sim);
if (sim->maneuvers[1].executed) {
break;
auto run_until = [&](double target_time) {
while (sim->time < target_time) {
update_simulation(sim);
}
};
SECTION("maneuvers load from config with correct properties") {
REQUIRE(sim->maneuver_count == 2);
REQUIRE(std::string(sim->maneuvers[0].name) == "orbit_raise_1");
REQUIRE(std::string(sim->maneuvers[1].name) == "orbit_raise_2");
REQUIRE(sim->maneuvers[0].trigger_type == TRIGGER_TIME);
REQUIRE(sim->maneuvers[1].trigger_type == TRIGGER_TRUE_ANOMALY);
REQUIRE_THAT(sim->maneuvers[0].delta_v, WithinAbs(500.0, D_TOL));
}
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);
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;
run_until(BURN_TIME + TIME_STEP);
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]") {
const double TIME_STEP = 60.0;
SECTION("true anomaly trigger executes after first burn") {
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;
while (sim->time < MAX_TIME) {
update_simulation(sim);
REQUIRE(sim->maneuvers[1].executed);
REQUIRE_THAT(vec3_magnitude(sim->spacecraft[0].local_velocity),
WithinAbs(8.672299586435140e3, V_TOL));
}
REQUIRE(sim->maneuvers[0].executed);
REQUIRE(sim->maneuvers[1].executed);
SECTION("maneuvers execute only once") {
run_until(20000.0);
REQUIRE(sim->maneuvers[0].executed);
REQUIRE(sim->maneuvers[1].executed);
double execution_count = 0.0;
for (int i = 0; i < sim->maneuver_count; i++) {
if (sim->maneuvers[i].executed) {
execution_count += 1.0;
int execution_count = 0;
for (int i = 0; i < sim->maneuver_count; i++) {
if (sim->maneuvers[i].executed) {
execution_count++;
}
}
REQUIRE(execution_count == 2);
}
REQUIRE(execution_count == 2.0);
destroy_simulation(sim);
}
TEST_CASE("Duplicate maneuver names fail config load", "[maneuver][config][error]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
bool result = load_system_config(sim, "tests/test_maneuver_planning.toml");
REQUIRE(result);
REQUIRE(sim->maneuver_count == 2);
Maneuver duplicate_maneuver = sim->maneuvers[0];
sim->maneuvers[sim->maneuver_count] = duplicate_maneuver;
(void)sim->maneuver_count;
sim->maneuver_count++;
bool is_duplicate = (std::string(sim->maneuvers[0].name) ==
std::string(sim->maneuvers[2].name));
REQUIRE(is_duplicate);
destroy_simulation(sim);
}

24
tests/test_maneuver_planning.toml

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

316
tests/test_maneuvers.cpp

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

18
tests/test_maneuvers.toml

@ -8,11 +8,7 @@ mass = 1.989e30
radius = 6.96e8
parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = {
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
[[bodies]]
name = "Earth"
@ -20,19 +16,11 @@ mass = 5.972e24
radius = 6.371e6
parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = {
semi_major_axis = 1.496e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
orbit = { semi_major_axis = 1.496e11, eccentricity = 0.0, true_anomaly = 0.0 }
[[spacecraft]]
name = "LEO_Satellite"
mass = 1000.0
parent_index = 1
orbit = {
semi_major_axis = 6.771e6,
eccentricity = 0.0,
true_anomaly = 0.0
}
orbit = { semi_major_axis = 6.771e6, eccentricity = 0.0, true_anomaly = 0.0 }
# 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/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h"
#include "../src/simulation.h"
#include "../src/config_loader.h"
#include "../src/test_utilities.h"
#include <cmath>
#include <vector>
#include <string>
TEST_CASE("Moon orbital stability around Earth", "[moon][earth]") {
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);
using Catch::Matchers::WithinAbs;
INFO("Expected Moon period: " << EXPECTED_PERIOD_DAYS << " days");
INFO("Measured Moon period: " << measured_period_days << " days");
INFO("Period error: " << period_error_days << " days");
REQUIRE(period_error_days < 3.0);
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);
// === Helper: run sim loop, collect failures, assert once at end ===
static std::string fmt_time(double t) {
char buf[32];
snprintf(buf, sizeof(buf), "t=%.0fs", t);
return std::string(buf);
}
TEST_CASE("Galilean moons orbital stability around Jupiter", "[moon][jupiter]") {
const double TIME_STEP = 60.0;
const double SECONDS_PER_DAY = 86400.0;
const double MAX_SIMULATION_DAYS = 20.0;
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);
static std::string fmt_drift(double d) {
char buf[32];
snprintf(buf, sizeof(buf), "drift=%.4f", d);
return std::string(buf);
}
REQUIRE(sim->bodies[IO_INDEX].parent_index == JUPITER_INDEX);
REQUIRE(sim->bodies[EUROPA_INDEX].parent_index == JUPITER_INDEX);
REQUIRE(sim->bodies[GANYMEDE_INDEX].parent_index == JUPITER_INDEX);
REQUIRE(sim->bodies[CALLISTO_INDEX].parent_index == JUPITER_INDEX);
// === Per-moon period tolerances (0.5% of analytical period, from precalc) ===
static const double MOON_PERIOD_TOL = 11861.4; // Moon
static const double IO_PERIOD_TOL = 764.6; // Io
static const double EUROPA_PERIOD_TOL = 1534.5; // Europa
static const double GANYMEDE_PERIOD_TOL = 3091.1; // Ganymede
static const double CALLISTO_PERIOD_TOL = 7210.6; // Callisto
static const double TITAN_PERIOD_TOL = 6889.9; // Titan
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);
REQUIRE(europa_tracker->orbit_completed);
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);
}
// === Drift tolerance for distance checks (10% bound) ===
static const double DRIFT_REL_TOL = 0.1;
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 EXPECTED_PERIOD_DAYS = 15.95;
const double SECONDS_PER_DAY = 86400.0;
const double MAX_SIMULATION_DAYS = 25.0;
SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml"));
const int SATURN_INDEX = 5;
const int TITAN_INDEX = 13;
OrbitTracker* tracker = create_orbit_tracker_with_min_time(TITAN_INDEX, 10.0);
Vec3 initial_pos_relative_to_saturn = vec3_sub(
sim->bodies[TITAN_INDEX].global_position,
sim->bodies[SATURN_INDEX].global_position
);
double initial_distance = vec3_magnitude(initial_pos_relative_to_saturn);
CelestialBody* earth = &sim->bodies[2];
CelestialBody* moon = &sim->bodies[8];
struct MoonData {
int index;
const char* name;
double expected_seconds;
double max_days;
double min_time_days;
double period_tol;
int parent_index;
};
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) {
update_simulation(sim);
const MoonData all_moons[] = {
{8, "Moon", 2372274.33, 35.0, 5.0, MOON_PERIOD_TOL, 2},
{9, "Io", 152928.62, 5.0, 1.0, IO_PERIOD_TOL, 4},
{10, "Europa", 306909.10, 10.0, 2.0, EUROPA_PERIOD_TOL, 4},
{11, "Ganymede", 618227.12, 15.0, 5.0, GANYMEDE_PERIOD_TOL, 4},
{12, "Callisto", 1442117.30, 25.0, 10.0, CALLISTO_PERIOD_TOL, 4},
{13, "Titan", 1377976.07, 25.0, 10.0, TITAN_PERIOD_TOL, 5},
};
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(
sim->bodies[TITAN_INDEX].global_position,
sim->bodies[SATURN_INDEX].global_position
);
double current_distance = vec3_magnitude(current_pos_relative_to_saturn);
auto measure_period = [&](int body_index, int parent_index,
double max_days, double min_time_days) {
sim->time = 0.0;
OrbitTracker* tracker = create_orbit_tracker_with_min_time(
body_index, min_time_days * SECONDS_PER_DAY);
double max_time = max_days * SECONDS_PER_DAY;
while (sim->time < max_time && !tracker->orbit_completed) {
update_simulation(sim);
update_orbit_tracker(tracker, &sim->bodies[body_index],
&sim->bodies[parent_index], sim->time);
}
double drift_percentage = (fabs(current_distance - initial_distance) / initial_distance) * 100.0;
REQUIRE(drift_percentage < 10.0);
double period = tracker->orbit_completed
? 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 period_error_days = fabs(measured_period_days - EXPECTED_PERIOD_DAYS);
double max_time = 60.0 * SECONDS_PER_DAY;
int completed_count = 0;
while (sim->time < max_time && completed_count < count) {
update_simulation(sim);
for (int i = 0; i < count; i++) {
if (!trackers[i]->orbit_completed) {
update_orbit_tracker(trackers[i], &sim->bodies[all_moons[i].index],
&sim->bodies[all_moons[i].parent_index], sim->time);
if (trackers[i]->orbit_completed) {
completed_count++;
double period = trackers[i]->time_at_completion / SECONDS_PER_DAY;
INFO(all_moons[i].name << " completed orbit at "
<< period << " days");
}
}
}
}
INFO("Expected Titan period: " << EXPECTED_PERIOD_DAYS << " days");
INFO("Measured Titan period: " << measured_period_days << " days");
INFO("Period error: " << period_error_days << " days");
for (int i = 0; i < count; i++) {
REQUIRE(trackers[i]->orbit_completed);
destroy_orbit_tracker(trackers[i]);
}
};
REQUIRE(period_error_days < 3.0);
// === Sections ===
destroy_orbit_tracker(tracker);
destroy_simulation(sim);
}
SECTION("Moon maintains Earth as parent throughout simulation") {
check_parent_stability(moon, 2, 35.0);
}
TEST_CASE("Combined solar system with all moons - parent stability", "[moon][integration]") {
const double TIME_STEP = 60.0;
const double SECONDS_PER_DAY = 86400.0;
const double MAX_SIMULATION_DAYS = 60.0;
SECTION("Moon distance from Earth stays within 10% of initial") {
check_distance_drift(moon, 2, 35.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 {
double time_days;
int body_index;
int old_parent;
int new_parent;
};
SECTION("Galilean moons complete orbits in expected periods") {
for (const auto& m : galilean) {
double period = measure_period(m.index, m.parent_index,
m.max_days, m.min_time_days);
INFO(m.name << " period: " << period / SECONDS_PER_DAY
<< " days (expected: " << m.expected_seconds / SECONDS_PER_DAY
<< ")");
REQUIRE_THAT(period, WithinAbs(m.expected_seconds, m.period_tol));
}
}
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];
for (int i = 0; i < 14; i++) {
initial_parents[i] = sim->bodies[i].parent_index;
SECTION("Titan maintains Saturn as parent") {
check_parent_stability(&sim->bodies[13], 5, 25.0);
}
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) {
update_simulation(sim);
SECTION("Titan distance from Saturn stays within 10%") {
check_distance_drift(&sim->bodies[13], 5, 25.0);
}
for (int i = 0; i < sim->body_count; i++) {
if (sim->bodies[i].parent_index != initial_parents[i]) {
ParentChange change;
change.time_days = sim->time / SECONDS_PER_DAY;
change.body_index = i;
change.old_parent = initial_parents[i];
change.new_parent = sim->bodies[i].parent_index;
parent_changes.push_back(change);
initial_parents[i] = sim->bodies[i].parent_index;
SECTION("All moons maintain correct parents over 60-day simulation") {
double max_time = 60.0 * SECONDS_PER_DAY;
std::vector<std::string> failures;
while (sim->time < max_time) {
update_simulation(sim);
for (int i = 0; i < 6; i++) {
int idx = all_moons[i].index;
int expected = all_moons[i].parent_index;
if (sim->bodies[idx].parent_index != expected) {
failures.push_back(fmt_time(sim->time) + ": " +
std::string(all_moons[i].name) +
" parent=" +
std::to_string(sim->bodies[idx].parent_index) +
" (expected " + std::to_string(expected) + ")");
}
}
}
REQUIRE(failures.empty());
}
INFO("Total parent changes detected: " << parent_changes.size());
for (const auto& change : parent_changes) {
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);
SECTION("All moons complete at least one orbit") {
wait_for_all_orbits(6);
}
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);
}

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]]
name = "Sun"
mass = 1.989e30 # kg
radius = 6.96e8 # m
mass = 1.989e+30
radius = 696000000.0
parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = {
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
orbit.semi_major_axis = 0.0
orbit.eccentricity = 0.0
orbit.true_anomaly = 0.0
[[bodies]]
name = "Venus"
mass = 4.867e24
radius = 6.0518e6
mass = 4.87e+24
radius = 6052000.0
parent_index = 0
color = { r = 0.9, g = 0.7, b = 0.3 }
orbit = {
semi_major_axis = 1.082e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
color = { r = 0.5, g = 0.5, b = 0.5 }
orbit.semi_major_axis = 1.081608e+11
orbit.eccentricity = 0.007
orbit.inclination = 0.059166661642608
orbit.longitude_of_ascending_node = 1.338318470429252
orbit.argument_of_periapsis = 0.958534825195286
orbit.true_anomaly = 0.890141231198106
[[bodies]]
name = "Earth"
mass = 5.972e24
radius = 6.371e6
mass = 5.97e+24
radius = 6378000.0
parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = {
semi_major_axis = 1.496e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
color = { r = 0.5, g = 0.5, b = 0.5 }
orbit.semi_major_axis = 1.496000e+11
orbit.eccentricity = 0.017
orbit.inclination = 0.000000000000000
orbit.longitude_of_ascending_node = 0.000000000000000
orbit.argument_of_periapsis = 1.796641932002963
orbit.true_anomaly = 6.238578647164619
[[bodies]]
name = "Mars"
mass = 6.39e23
radius = 3.3895e6
mass = 6.42e+23
radius = 3396000.0
parent_index = 0
color = { r = 0.8, g = 0.3, b = 0.1 }
orbit = {
semi_major_axis = 2.279e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
color = { r = 0.5, g = 0.5, b = 0.5 }
orbit.semi_major_axis = 2.279904e+11
orbit.eccentricity = 0.093
orbit.inclination = 0.032288591161895
orbit.longitude_of_ascending_node = 0.864985177288390
orbit.argument_of_periapsis = 5.000368306963754
orbit.true_anomaly = 0.407676817724149
[[bodies]]
name = "Jupiter"
mass = 1.898e27
radius = 6.9911e7
mass = 1.898e+27
radius = 71492000.0
parent_index = 0
color = { r = 0.9, g = 0.7, b = 0.5 }
orbit = {
semi_major_axis = 7.785e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
color = { r = 0.5, g = 0.5, b = 0.5 }
orbit.semi_major_axis = 7.783688e+11
orbit.eccentricity = 0.049
orbit.inclination = 0.022863813201126
orbit.longitude_of_ascending_node = 1.753532299478703
orbit.argument_of_periapsis = 4.786565473594449
orbit.true_anomaly = 0.378299755400337
[[bodies]]
name = "Saturn"
mass = 5.683e26
radius = 5.8232e7
mass = 5.683e+26
radius = 60268000.0
parent_index = 0
color = { r = 0.9, g = 0.8, b = 0.6 }
orbit = {
semi_major_axis = 1.434e12,
eccentricity = 0.0,
true_anomaly = 0.0
}
color = { r = 0.5, g = 0.5, b = 0.5 }
orbit.semi_major_axis = 1.426735e+12
orbit.eccentricity = 0.057
orbit.inclination = 0.043458698374659
orbit.longitude_of_ascending_node = 1.983741227816755
orbit.argument_of_periapsis = 5.915618966709580
orbit.true_anomaly = 5.457583789473037
[[bodies]]
name = "Uranus"
mass = 8.681e25
radius = 2.5362e7
mass = 8.68e+25
radius = 25559000.0
parent_index = 0
color = { r = 0.5, g = 0.8, b = 0.9 }
orbit = {
semi_major_axis = 2.871e12,
eccentricity = 0.0,
true_anomaly = 0.0
}
color = { r = 0.5, g = 0.5, b = 0.5 }
orbit.semi_major_axis = 2.870824e+12
orbit.eccentricity = 0.046
orbit.inclination = 0.013439035240356
orbit.longitude_of_ascending_node = 1.291892712326203
orbit.argument_of_periapsis = 1.691922176883303
orbit.true_anomaly = 2.537061863932694
[[bodies]]
name = "Neptune"
mass = 1.024e26
radius = 2.4622e7
mass = 1.02e+26
radius = 24764000.0
parent_index = 0
color = { r = 0.2, g = 0.4, b = 0.9 }
orbit = {
semi_major_axis = 4.495e12,
eccentricity = 0.0,
true_anomaly = 0.0
}
color = { r = 0.5, g = 0.5, b = 0.5 }
orbit.semi_major_axis = 4.498472e+12
orbit.eccentricity = 0.01
orbit.inclination = 0.030892327760300
orbit.longitude_of_ascending_node = 2.299994888278127
orbit.argument_of_periapsis = 4.767890450598109
orbit.true_anomaly = 4.516812758860357
[[bodies]]
name = "Moon"
mass = 7.342e22
radius = 1.737e6
parent_index = 2
color = { r = 0.7, g = 0.7, b = 0.7 }
orbit = {
semi_major_axis = 3.844e8,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[bodies]]
name = "Moon"
mass = 7.35e+22
radius = 1738000.0
parent_index = 2
color = { r = 0.7, g = 0.7, b = 0.7 }
orbit.semi_major_axis = 3.844000e+08
orbit.eccentricity = 0.055
orbit.inclination = 0.090058989402907
orbit.longitude_of_ascending_node = 2.183057828394507
orbit.argument_of_periapsis = 5.552765015219959
orbit.true_anomaly = 2.434643529152418
[[bodies]]
name = "Io"
mass = 8.93e22
radius = 1.822e6
parent_index = 4
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 = "Io"
mass = 8.93e+23
radius = 1822000.0
parent_index = 4
color = { r = 0.7, g = 0.7, b = 0.7 }
orbit.semi_major_axis = 4.218000e+08
orbit.eccentricity = 0.004
orbit.inclination = 0.000000000000000
orbit.longitude_of_ascending_node = 0.000000000000000
orbit.argument_of_periapsis = 0.856956662729216
orbit.true_anomaly = 5.771386752330690
[[bodies]]
name = "Europa"
mass = 4.80e22
radius = 1.561e6
parent_index = 4
color = { r = 0.8, g = 0.8, b = 0.7 }
orbit = {
semi_major_axis = 6.709e8,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[bodies]]
name = "Europa"
mass = 4.8e+23
radius = 1561000.0
parent_index = 4
color = { r = 0.7, g = 0.7, b = 0.7 }
orbit.semi_major_axis = 6.711000e+08
orbit.eccentricity = 0.009
orbit.inclination = 0.008726646259972
orbit.longitude_of_ascending_node = 3.211405823669566
orbit.argument_of_periapsis = 0.785398163397448
orbit.true_anomaly = 6.023780086902404
[[bodies]]
name = "Ganymede"
mass = 1.48e23
radius = 2.634e6
parent_index = 4
color = { r = 0.6, g = 0.6, b = 0.5 }
orbit = {
semi_major_axis = 1.070e9,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[bodies]]
name = "Ganymede"
mass = 1.48e+24
radius = 2631000.0
parent_index = 4
color = { r = 0.7, g = 0.7, b = 0.7 }
orbit.semi_major_axis = 1.070400e+09
orbit.eccentricity = 0.001
orbit.inclination = 0.003490658503989
orbit.longitude_of_ascending_node = 1.021017612416683
orbit.argument_of_periapsis = 3.460987906704756
orbit.true_anomaly = 5.667675367373862
[[bodies]]
name = "Callisto"
mass = 1.08e23
radius = 2.410e6
parent_index = 4
color = { r = 0.5, g = 0.5, b = 0.4 }
orbit = {
semi_major_axis = 1.883e9,
eccentricity = 0.0,
true_anomaly = 0.0
}
[[bodies]]
name = "Callisto"
mass = 1.08e+24
radius = 2410000.0
parent_index = 4
color = { r = 0.7, g = 0.7, b = 0.7 }
orbit.semi_major_axis = 1.882700e+09
orbit.eccentricity = 0.007
orbit.inclination = 0.005235987755983
orbit.longitude_of_ascending_node = 5.394812717914473
orbit.argument_of_periapsis = 0.764454212373516
orbit.true_anomaly = 1.539408451124606
[[bodies]]
name = "Titan"
mass = 1.35e+24
radius = 2575000.0
parent_index = 5
color = { r = 0.7, g = 0.7, b = 0.7 }
orbit.semi_major_axis = 1.221900e+09
orbit.eccentricity = 0.029
orbit.inclination = 0.005235987755983
orbit.longitude_of_ascending_node = 1.371828792067543
orbit.argument_of_periapsis = 1.366592804311560
orbit.true_anomaly = 0.216397080390910
[[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/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h"
#include "../src/simulation.h"
#include "../src/orbital_objects.h"
#include "../src/maneuver.h"
#include "../src/config_loader.h"
#include "../src/orbital_mechanics.h"
#include "../src/test_utilities.h"
#include <cmath>
// Test: Check omega after prograde burn that flips eccentricity vector direction
TEST_CASE("Omega calculation after prograde burn", "[omega][debug]") {
double parent_mass = 5.972e24; // Earth mass
using Catch::Matchers::WithinAbs;
// Initial orbit: zero inclination, omega = 0
// Start at apoapsis where eccentricity vector points opposite to position
OrbitalElements elements = {0};
SCENARIO("Omega reconstruction after prograde burn at apoapsis", "[omega][debug]") {
const double parent_mass = 5.972e24;
const double mu = G * parent_mass;
OrbitalElements elements = {};
elements.semi_major_axis = 1.0e7;
elements.eccentricity = 0.3;
elements.true_anomaly = M_PI; // Start at apoapsis
elements.inclination = 1e-12; // Tiny inclination to trigger atan2 path
elements.true_anomaly = M_PI;
elements.inclination = 1e-12;
elements.longitude_of_ascending_node = 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);
// Get initial eccentricity vector
Vec3 v = vel;
double r = vec3_magnitude(pos);
double mu = G * parent_mass;
Vec3 e_vec_initial = vec3_scale(vec3_sub(vec3_scale(vel, r), vec3_scale(pos, vec3_magnitude(vel) / mu)), 1.0 / mu);
double e_initial = vec3_magnitude(e_vec_initial);
INFO("Initial state:");
INFO(" e = " << e_initial);
INFO(" e_vec = (" << e_vec_initial.x << ", " << e_vec_initial.y << ", " << e_vec_initial.z << ")");
INFO(" pos = (" << pos.x << ", " << pos.y << ", " << pos.z << ")");
INFO(" vel = (" << vel.x << ", " << vel.y << ", " << vel.z << ")");
// Apply a prograde burn at periapsis
Vec3 vel_dir = vec3_normalize(vel);
Vec3 delta_v = vec3_scale(vel_dir, 1000.0); // Large burn to flip e_vec
vel = vec3_add(vel, delta_v);
// Reconstruct orbital elements
OrbitalElements new_elements = cartesian_to_orbital_elements(pos, vel, parent_mass);
INFO("After prograde burn:");
INFO(" new omega = " << new_elements.argument_of_periapsis << " rad (" << new_elements.argument_of_periapsis * 180.0 / M_PI << " deg)");
INFO(" new e = " << new_elements.eccentricity);
// Get new eccentricity vector
Vec3 e_vec_new = vec3_scale(vec3_sub(vec3_scale(vel, r), vec3_scale(pos, vec3_magnitude(vel) / mu)), 1.0 / mu);
INFO(" new e_vec = (" << e_vec_new.x << ", " << e_vec_new.y << ", " << e_vec_new.z << ")");
// For zero-inclination orbit, omega is computed from the eccentricity vector
// (longitude of periapsis since ascending node is undefined)
// The key constraint is that omega should be in [0, 2π)
bool omega_in_range = (new_elements.argument_of_periapsis >= 0.0) &&
(new_elements.argument_of_periapsis < 2.0 * M_PI);
REQUIRE(omega_in_range);
const double r = vec3_magnitude(pos);
const double v = vec3_magnitude(vel);
const double r_dot_v = vec3_dot(pos, vel);
const Vec3 e_vec = {
((v * v - mu / r) * pos.x - r_dot_v * vel.x) / mu,
((v * v - mu / r) * pos.y - r_dot_v * vel.y) / mu,
((v * v - mu / r) * pos.z - r_dot_v * vel.z) / mu,
};
const double e_initial = vec3_magnitude(e_vec);
SECTION("initial apoapsis state is correct") {
REQUIRE_THAT(r, WithinAbs(1.3e7, R_TOL));
REQUIRE_THAT(v, WithinAbs(4632.763232589246, V_TOL));
REQUIRE_THAT(e_initial, WithinAbs(0.3, E_TOL));
REQUIRE_THAT(e_vec.x / e_initial, WithinAbs(1.0, E_TOL));
REQUIRE_THAT(e_vec.y, WithinAbs(0.0, E_TOL));
REQUIRE_THAT(e_vec.z, WithinAbs(0.0, E_TOL));
}
SECTION("prograde burn at apoapsis flips periapsis") {
const Vec3 burn_dir = vec3_normalize(vel);
const Vec3 delta_v = vec3_scale(burn_dir, 1000.0);
const Vec3 vel_new = vec3_add(vel, delta_v);
const double v_new = vec3_magnitude(vel_new);
const OrbitalElements new_elements = cartesian_to_orbital_elements(pos, vel_new, parent_mass);
REQUIRE_THAT(new_elements.semi_major_axis, WithinAbs(1.346885753127762e7, A_TOL));
REQUIRE_THAT(new_elements.eccentricity, WithinAbs(3.481049006486453e-2, E_TOL));
REQUIRE_THAT(new_elements.argument_of_periapsis, WithinAbs(M_PI, ANG_TOL));
REQUIRE_THAT(new_elements.true_anomaly, WithinAbs(0.0, ANG_TOL));
REQUIRE_THAT(new_elements.inclination, WithinAbs(0.0, ANG_TOL));
REQUIRE_THAT(v_new, WithinAbs(5632.763232589246, V_TOL));
}
}

116
tests/test_orbital_period.cpp

@ -1,102 +1,54 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h"
#include "../src/simulation.h"
#include "../src/config_loader.h"
#include "../src/test_utilities.h"
#include <cmath>
TEST_CASE("Orbital period - Earth (RK4)", "[period][rk4]") {
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");
using Catch::Matchers::WithinAbs;
REQUIRE(period_error_days < 5.0);
destroy_orbit_tracker(tracker);
destroy_simulation(sim);
}
TEST_CASE("Orbital period - Mars (RK4)", "[period][rk4]") {
SCENARIO("Orbital period measurement with analytical propagation",
"[period][analytical]") {
const double TIME_STEP = 60.0;
const double EXPECTED_PERIOD_DAYS = 687.0;
const double SECONDS_PER_DAY = 86400.0;
const double MAX_SIMULATION_DAYS = 750.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(2);
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[2], &sim->bodies[0], sim->time);
auto propagate_and_check = [&](OrbitTracker* tracker, CelestialBody* body,
CelestialBody* parent, double max_time,
double expected_days) {
while (sim->time < max_time && !tracker->orbit_completed) {
update_simulation(sim);
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);
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 < 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);
SECTION("Mars completes one orbit in ~671 days") {
CelestialBody* mars = &sim->bodies[2];
CelestialBody* sun = &sim->bodies[0];
OrbitTracker* tracker = create_orbit_tracker(2);
for (int i = 0; i < STEPS; i++) {
update_simulation(sim);
const double max_time = 750.0 * SECONDS_PER_DAY;
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
parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = {
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
[[bodies]]
name = "Earth"
@ -20,11 +16,7 @@ mass = 5.972e24
radius = 6.371e6
parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = {
semi_major_axis = 1.496e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
orbit = { semi_major_axis = 1.496e11, eccentricity = 0.0, true_anomaly = 0.0 }
[[bodies]]
name = "Mars"
@ -32,8 +24,4 @@ mass = 6.39e23
radius = 3.3895e6
parent_index = 0
color = { r = 0.8, g = 0.3, b = 0.1 }
orbit = {
semi_major_axis = 2.244e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
orbit = { 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/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h"
#include "../src/simulation.h"
#include "../src/config_loader.h"
#include "../src/test_utilities.h"
#include <cmath>
#include <vector>
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 DAYS_TO_SIMULATE = 300.0;
const double SECONDS_PER_DAY = 86400.0;
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"));
const int COMET_INDEX = 1;
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;
double initial_distance = vec3_magnitude(initial_position);
double initial_velocity = vec3_magnitude(sim->bodies[COMET_INDEX].global_velocity);
double initial_kinetic = calculate_kinetic_energy(&sim->bodies[COMET_INDEX]);
double initial_potential = calculate_potential_energy_pair(&sim->bodies[COMET_INDEX],
&sim->bodies[SUN_INDEX]);
double initial_total_energy = initial_kinetic + initial_potential;
// Initial state
const double initial_distance = vec3_magnitude(comet->global_position);
const double initial_velocity = vec3_magnitude(comet->global_velocity);
const double initial_kinetic = calculate_kinetic_energy(comet);
const double initial_potential = calculate_potential_energy_pair(comet, sun);
const double initial_total_energy = initial_kinetic + initial_potential;
INFO("Initial distance: " << initial_distance / AU << " AU");
INFO("Initial velocity: " << 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 potential energy: " << initial_potential);
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;
std::vector<double> velocities;
std::vector<double> energies;
INFO("Distance: " << distance / AU << " AU");
INFO("Actual velocity: " << initial_velocity / 1000.0 << " km/s");
INFO("Escape velocity: " << escape_velocity / 1000.0 << " km/s");
INFO("Circular velocity: " << circular_velocity / 1000.0 << " km/s");
double max_time = DAYS_TO_SIMULATE * SECONDS_PER_DAY;
int step_count = 0;
while (sim->time < max_time) {
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);
}
const double velocity_error = fabs(initial_velocity - escape_velocity) / escape_velocity;
INFO("Velocity error from escape velocity: " << velocity_error * 100.0 << "%");
REQUIRE_THAT(velocity_error, WithinAbs(0.0, V_TOL));
}
update_simulation(sim);
step_count++;
SECTION("eccentricity equals 1.0") {
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);
double final_velocity = vec3_magnitude(sim->bodies[COMET_INDEX].global_velocity);
SECTION("initial velocity matches precalculated") {
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]);
double final_potential = calculate_potential_energy_pair(&sim->bodies[COMET_INDEX],
&sim->bodies[SUN_INDEX]);
double final_total_energy = final_kinetic + final_potential;
// Final state
const double final_distance = vec3_magnitude(comet->global_position);
const double final_velocity = vec3_magnitude(comet->global_velocity);
const double final_kinetic = calculate_kinetic_energy(comet);
const double final_potential = calculate_potential_energy_pair(comet, sun);
const double final_total_energy = final_kinetic + final_potential;
INFO("Final distance: " << final_distance / AU << " AU");
INFO("Final velocity: " << final_velocity / 1000.0 << " km/s");
@ -74,64 +95,24 @@ TEST_CASE("Parabolic orbit - energy and escape trajectory", "[parabolic][energy]
INFO("Final potential energy: " << final_potential);
INFO("Final total energy: " << final_total_energy);
REQUIRE(final_distance > initial_distance);
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++;
}
SECTION("final distance matches escape trajectory") {
REQUIRE_THAT(final_distance, WithinAbs(expected_distance, R_TOL));
}
INFO("Velocity decreases: " << velocity_decreases << " / " << (velocities.size() - 1));
REQUIRE(velocity_decreases > static_cast<int>(velocities.size()) / 2);
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);
SECTION("final velocity matches escape trajectory") {
REQUIRE(final_velocity < initial_velocity);
REQUIRE_THAT(final_velocity, WithinAbs(final_expected_velocity, V_TOL));
}
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);
}

16
tests/test_parabolic_orbit.toml

@ -7,21 +7,13 @@ name = "Sun"
mass = 1.989e30
radius = 6.96e8
parent_index = -1
color = {r = 1.0,g = 1.0,b = 0.0 }
orbit = {
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 }
[[bodies]]
name = "ParabolicComet"
mass = 1.0e14
radius = 5.0e3
parent_index = 0
color = {r = 0.7,g = 0.8,b = 0.9 }
orbit = {
semi_latus_rectum = 2.992e11,
eccentricity = 1.0,
true_anomaly = 0.0
}
color = { r = 0.7, g = 0.8, b = 0.9 }
orbit = { 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/maneuver.h"
#include "../src/config_loader.h"
#include "../src/orbital_mechanics.h"
#include "../src/test_utilities.h"
#include <cmath>
#include <tuple>
// Test prograde burn at periapsis (true anomaly = 0)
// 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];
using Catch::Matchers::WithinAbs;
// Record initial state (at 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]") {
SCENARIO("Periapsis-triggered prograde burn behavior", "[maneuver][periapsis]") {
const double TIME_STEP = 60.0;
const int ORBIT_STEPS = 300;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_periapsis_burn.toml"));
Spacecraft* craft = &sim->spacecraft[0];
Spacecraft* craft_cross = &sim->spacecraft[1];
CelestialBody* parent = &sim->bodies[craft->parent_index];
int maneuver_indices[2];
int maneuver_count_for_craft = 0;
for (int i = 0; i < sim->maneuver_count; i++) {
if (sim->maneuvers[i].craft_index == 0) {
maneuver_indices[maneuver_count_for_craft++] = i;
}
// Shared fixture values (from precalc_periapsis_burn.py)
const double initial_periapsis = 7259700.0;
const double burn1_preburn_v = 8448.412303782408344;
const double burn1_expected_sma = 13404876.681005753576756;
// 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:");
INFO(" Periapsis: " << initial_periapsis);
INFO(" Apoapsis: " << initial_apoapsis);
INFO(" Eccentricity: " << craft->orbit.eccentricity);
SECTION("prograde burn at periapsis fires immediately and raises orbit") {
double a_before = craft->orbit.semi_major_axis;
double e_before = craft->orbit.eccentricity;
double peri_before = a_before * (1.0 - e_before);
double burn1_time = -1.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++) {
// Execute one step — burn fires immediately (nu=0, trigger=0)
update_simulation(sim);
if (sim->maneuvers[maneuver_indices[0]].executed && burn1_time < 0) {
burn1_time = sim->time;
burn1_radius = vec3_magnitude(craft->local_position);
burn1_period = 2.0 * M_PI * sqrt(pow(craft->orbit.semi_major_axis, 3.0) / (G * parent->mass));
Vec3 r = craft->local_position;
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);
burn1_true_anomaly = calculate_true_anomaly(r, v, e_vec, e_mag, burn1_radius);
burn1_true_anomaly = normalize_angle(burn1_true_anomaly);
INFO("First burn executed at step " << i);
INFO(" Time: " << burn1_time);
INFO(" Radius: " << burn1_radius);
INFO(" True anomaly: " << burn1_true_anomaly << " rad (" << burn1_true_anomaly * 180.0 / M_PI << "°)");
INFO(" Periapsis: " << initial_periapsis);
INFO(" Apoapsis: " << initial_apoapsis);
INFO(" New period: " << burn1_period << " seconds");
}
if (sim->maneuvers[maneuver_indices[1]].executed && burn2_time < 0) {
burn2_time = sim->time;
burn2_radius = vec3_magnitude(craft->local_position);
Vec3 r = craft->local_position;
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 << "°)");
}
// Verify burn fired at exact periapsis via burn_result
const BurnResult& br = sim->maneuvers[0].burn_result;
REQUIRE(br.valid);
REQUIRE_THAT(br.true_anomaly, WithinAbs(0.0, ANG_TOL));
REQUIRE_THAT(vec3_magnitude(br.position), WithinAbs(initial_periapsis, R_TOL));
// Maneuver executed
REQUIRE(sim->maneuvers[0].executed);
// Periapsis preserved after burn
double final_sma = craft->orbit.semi_major_axis;
double final_ecc = craft->orbit.eccentricity;
double final_periapsis = final_sma * (1.0 - final_ecc);
REQUIRE_THAT(final_periapsis, WithinAbs(initial_periapsis, R_TOL));
// Pre-burn velocity captured at exact burn time (tight tolerance)
REQUIRE_THAT(vec3_magnitude(br.velocity), WithinAbs(burn1_preburn_v, V_TOL));
// Semi-major axis after burn
REQUIRE_THAT(final_sma, WithinAbs(burn1_expected_sma, A_TOL));
INFO("Initial SMA: " << a_before << " m");
INFO("Final SMA: " << final_sma << " m");
INFO("Initial periapsis: " << peri_before << " m");
INFO("Final periapsis: " << final_periapsis << " m");
INFO("Burn time position: " << br.position.x << ", " << br.position.y << ", " << br.position.z);
INFO("Burn time velocity: " << br.velocity.x << ", " << br.velocity.y << ", " << br.velocity.z);
}
REQUIRE(sim->maneuvers[maneuver_indices[0]].executed);
REQUIRE(sim->maneuvers[maneuver_indices[1]].executed);
INFO("Burn comparison:");
INFO(" Burn 1: time=" << burn1_time << ", radius=" << burn1_radius << ", true_anomaly=" << burn1_true_anomaly);
INFO(" Burn 2: time=" << burn2_time << ", radius=" << burn2_radius << ", true_anomaly=" << burn2_true_anomaly);
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;
SECTION("two sequential periapsis burns execute at same location") {
// Find maneuver indices for craft 0
int burn1_idx = -1, burn2_idx = -1;
for (int i = 0; i < sim->maneuver_count; i++) {
if (sim->maneuvers[i].craft_index == 0 && !sim->maneuvers[i].executed) {
if (burn1_idx < 0) burn1_idx = i;
else burn2_idx = i;
}
}
}
REQUIRE(maneuver_index >= 0);
REQUIRE(burn1_idx >= 0);
REQUIRE(burn2_idx >= 0);
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);
double initial_periapsis_val = 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:");
INFO(" Periapsis: " << initial_periapsis);
INFO(" Apoapsis: " << initial_apoapsis);
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;
const int max_steps = 300;
for (int i = 0; i < max_steps; i++) {
update_simulation(sim);
}
int max_steps = 1000;
for (int i = 0; i < max_steps && !sim->maneuvers[maneuver_index].executed; i++) {
update_simulation(sim);
REQUIRE(sim->maneuvers[burn1_idx].executed);
REQUIRE(sim->maneuvers[burn2_idx].executed);
// 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) {
burn_time = sim->time;
burn_radius = vec3_magnitude(craft->local_position);
Vec3 r = craft->local_position;
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);
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);
SECTION("periapsis burn fires when crossing from 90 degrees") {
int cross_maneuver = -1;
for (int i = 0; i < sim->maneuver_count; i++) {
if (sim->maneuvers[i].craft_index == 1) {
cross_maneuver = i;
break;
}
}
}
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]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
// Burn at exact periapsis radius
REQUIRE_THAT(burn_radius, WithinAbs(cross_initial_periapsis, R_TOL));
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);
double initial_radius = vec3_magnitude(craft->local_position);
SECTION("burn location equals new periapsis after prograde burn") {
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);
INFO("Initial periapsis: " << initial_periapsis);
INFO("Final periapsis: " << final_periapsis);
double final_periapsis = craft->orbit.semi_major_axis * (1.0 - craft->orbit.eccentricity);
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);
}

28
tests/test_periapsis_burn.toml

@ -6,24 +6,16 @@ name = "Sun"
mass = 1.989e30
radius = 6.96e8
parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = {
semi_major_axis = 0.0,
eccentricity = 0.0,
true_anomaly = 0.0
}
color = {r=1.0, g=1.0, b=0.0}
orbit = {semi_major_axis=0.0, eccentricity=0.0, true_anomaly=0.0}
[[bodies]]
name = "Earth"
mass = 5.972e24
radius = 6.371e6
parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = {
semi_major_axis = 1.496e11,
eccentricity = 0.0,
true_anomaly = 0.0
}
color = {r=0.0, g=0.5, b=1.0}
orbit = {semi_major_axis=1.496e11, eccentricity=0.0, true_anomaly=0.0}
[[spacecraft]]
name = "TestSatellite"
@ -32,22 +24,14 @@ parent_index = 1
# Start at periapsis of an elliptical orbit
# a = 10,000 km, e = 0.3
# periapsis = 7,000 km, apoapsis = 13,000 km
orbit = {
semi_major_axis = 1.0371e7,
eccentricity = 0.3,
true_anomaly = 0.0
}
orbit = {semi_major_axis=1.0371e7, eccentricity=0.3, true_anomaly=0.0}
[[spacecraft]]
name = "TestSatelliteCrossing"
mass = 1000.0
parent_index = 1
# Start at 90 degrees from periapsis for crossing test
orbit = {
semi_major_axis = 1.0371e7,
eccentricity = 0.3,
true_anomaly = 1.57 # 90 degrees (pi/2)
}
orbit = {semi_major_axis=1.0371e7, eccentricity=0.3, true_anomaly=1.57}
[[maneuvers]]
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 "../src/physics.h"
#include "../src/orbital_mechanics.h"
#include "../src/test_utilities.h"
#include <cmath>
TEST_CASE("True anomaly round-trip conversion at periapsis", "[orbital_elements][true_anomaly]") {
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;
using Catch::Matchers::WithinAbs;
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;
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);
// Precomputed analytical values
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,
Catch::Matchers::WithinAbs(elements.true_anomaly, 0.01));
}
auto check_roundtrip = [&](double expected_nu) {
elements.true_anomaly = expected_nu;
TEST_CASE("True anomaly round-trip conversion at 90 degrees", "[orbital_elements][true_anomaly]") {
double parent_mass = 5.972e24;
orbital_elements_to_cartesian(elements, parent_mass, &pos, &vel);
OrbitalElements reconstructed = cartesian_to_orbital_elements(pos, vel, parent_mass);
OrbitalElements elements = {0};
elements.semi_major_axis = 7000e3;
elements.eccentricity = 0.3;
elements.true_anomaly = M_PI / 2.0;
elements.inclination = 0.0;
elements.longitude_of_ascending_node = 0.0;
elements.argument_of_periapsis = 0.0;
INFO("Expected nu: " << expected_nu);
INFO("Reconstructed: " << reconstructed.true_anomaly);
REQUIRE_THAT(reconstructed.true_anomaly, WithinAbs(expected_nu, ANG_TOL));
};
Vec3 pos, vel;
orbital_elements_to_cartesian(elements, parent_mass, &pos, &vel);
OrbitalElements reconstructed = cartesian_to_orbital_elements(pos, vel, parent_mass);
SECTION("at periapsis (nu = 0)") { check_roundtrip(0.0); }
SECTION("at apoapsis (nu = pi)") { check_roundtrip(M_PI); }
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);
INFO("Reconstructed true_anomaly: " << reconstructed.true_anomaly);
SECTION("periapsis radius = a*(1-e)") {
orbital_elements_to_cartesian(elements, parent_mass, &pos, &vel);
const double r_peri = vec3_magnitude(pos);
REQUIRE_THAT(reconstructed.true_anomaly,
Catch::Matchers::WithinAbs(elements.true_anomaly, 0.01));
}
INFO("Expected r: " << expected_r_peri);
INFO("Calculated r: " << r_peri);
TEST_CASE("True anomaly round-trip conversion at 270 degrees", "[orbital_elements][true_anomaly]") {
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);
REQUIRE_THAT(r_peri, WithinAbs(expected_r_peri, R_TOL));
}
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("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);
SECTION("apoapsis radius = a*(1+e)") {
elements.true_anomaly = M_PI;
INFO("At true_anomaly=pi:");
INFO(" Calculated radius: " << r_apo);
INFO(" Expected: " << expected_apo);
orbital_elements_to_cartesian(elements, parent_mass, &pos, &vel);
const double r_apo = vec3_magnitude(pos);
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