Compare commits

..

No commits in common. 'test-refactor' and 'main' have entirely different histories.

  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. 217
      scripts/precalc_analytical_propagation.py
  10. 234
      scripts/precalc_cartesian_to_elements_advanced.py
  11. 79
      scripts/precalc_cartesian_to_elements_basic.py
  12. 135
      scripts/precalc_extreme_eccentricity.py
  13. 56
      scripts/precalc_extreme_orientation_mixed.py
  14. 188
      scripts/precalc_extreme_timescales.py
  15. 545
      scripts/precalc_hybrid_burns.py
  16. 54
      scripts/precalc_inclined_orbits.py
  17. 128
      scripts/precalc_maneuver_planning.py
  18. 189
      scripts/precalc_maneuvers.py
  19. 360
      scripts/precalc_moon_orbits.py
  20. 129
      scripts/precalc_omega_debug.py
  21. 89
      scripts/precalc_parabolic_orbit.py
  22. 254
      scripts/precalc_periapsis_burn.py
  23. 920
      scripts/sim_engine.py
  24. 117
      scripts/test_orbital_period.py
  25. 9
      src/maneuver.h
  26. 49
      src/orbital_mechanics.cpp
  27. 3
      src/orbital_mechanics.h
  28. 64
      src/physics.cpp
  29. 11
      src/physics.h
  30. 7
      src/simulation.cpp
  31. 102
      src/test_utilities.cpp
  32. 27
      src/test_utilities.h
  33. 73
      tests/informational/Makefile
  34. 72
      tests/informational/README.md
  35. 296
      tests/informational/test_time_step_stability.cpp
  36. 80
      tests/informational/test_time_step_stability.toml
  37. 698
      tests/test_analytical_propagation.cpp
  38. 27
      tests/test_analytical_propagation.toml
  39. 153
      tests/test_barkers_equation.cpp
  40. 693
      tests/test_cartesian_to_elements_advanced.cpp
  41. 240
      tests/test_cartesian_to_elements_basic.cpp
  42. 20
      tests/test_cartesian_to_elements_basic.toml
  43. 42
      tests/test_energy.cpp
  44. 12
      tests/test_energy.toml
  45. 322
      tests/test_extreme_eccentricity.cpp
  46. 40
      tests/test_extreme_eccentricity.toml
  47. 578
      tests/test_extreme_orientation_mixed.cpp
  48. 62
      tests/test_extreme_orientation_mixed.toml
  49. 536
      tests/test_extreme_timescales.cpp
  50. 78
      tests/test_extreme_timescales.toml
  51. 1311
      tests/test_hybrid_burns.cpp
  52. 153
      tests/test_hybrid_burns.toml
  53. 0
      tests/test_hybrid_energy_conservation.cpp
  54. 0
      tests/test_hybrid_energy_conservation.toml
  55. 0
      tests/test_hyperbolic_orbit.cpp
  56. 0
      tests/test_hyperbolic_orbit.toml
  57. 184
      tests/test_inclined_orbits.cpp
  58. 24
      tests/test_inclined_orbits.toml
  59. 244
      tests/test_integration.cpp
  60. 0
      tests/test_invalid_parent_assignment.cpp
  61. 0
      tests/test_invalid_parent_assignment.toml
  62. 131
      tests/test_maneuver_planning.cpp
  63. 24
      tests/test_maneuver_planning.toml
  64. 220
      tests/test_maneuvers.cpp
  65. 18
      tests/test_maneuvers.toml
  66. 419
      tests/test_moon_orbits.cpp
  67. 275
      tests/test_moon_orbits.toml
  68. 0
      tests/test_newton_raphson_convergence.cpp
  69. 95
      tests/test_omega_debug.cpp
  70. 0
      tests/test_orbit_rendering.cpp
  71. 0
      tests/test_orbit_rendering.toml
  72. 98
      tests/test_orbital_period.cpp
  73. 18
      tests/test_orbital_period.toml
  74. 161
      tests/test_parabolic_orbit.cpp
  75. 16
      tests/test_parabolic_orbit.toml
  76. 333
      tests/test_periapsis_burn.cpp
  77. 28
      tests/test_periapsis_burn.toml
  78. 225
      tests/test_physics_utilities.cpp
  79. 0
      tests/test_precision_boundaries.cpp
  80. 0
      tests/test_precision_boundaries.toml
  81. 0
      tests/test_rendezvous.cpp
  82. 0
      tests/test_rendezvous.toml
  83. 0
      tests/test_root_body_transitions.cpp
  84. 0
      tests/test_root_body_transitions.toml
  85. 0
      tests/test_soi_transition.cpp
  86. 0
      tests/test_soi_transition.toml
  87. 145
      tests/test_true_anomaly_roundtrip.cpp

7
.gitignore vendored

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

2
AGENTS.md

@ -21,7 +21,7 @@
- No trailing whitespace in any files (including markdown) - No trailing whitespace in any files (including markdown)
- Pre-commit hook automatically strips it - Pre-commit hook automatically strips it
- For markdown line breaks, use <br> tag instead of two trailing spaces - For markdown line breaks, use <br> tag instead of two trailing spaces
- ZII (Zero Is Initialization) pattern: Initialize structs using `type_name s = {};` - 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};`
## File Reading Policy ## File Reading Policy
- Ask before reading files unless immediately necessary for current task - Ask before reading files unless immediately necessary for current task

4
Makefile

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

165
continue.md

@ -1,165 +0,0 @@
# 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,30 +2,18 @@
DO NOT read, write, edit, restore, or commit this file under any circumstances. DO NOT read, write, edit, restore, or commit this file under any circumstances.
This is a manually maintained file - all changes must be made by humans only. This is a manually maintained file - all changes must be made by humans only.
If you see modifications to this file in git status, IGNORE them and do not commit. If you see modifications to this file in git status, IGNORE them and do not commit.
**Do not revert unstaged changes to this file**
======================== ========================
=== next steps === === next steps ===
- refactor test cases:
- use strict values in 'REQUIRE' tests
- ensure we're using 'SECTION' macros for setup/teardown
- we're working on a generic python simulator to precalculate, but we should
also use some actual values from real-world missions or textbooks
- there's also the option of using a 3rd party simulator:
- https://github.com/poliastro/poliastro/blob/main/src/poliastro/core/propagation/farnocchia.py
- the (archived) poliastro project has two body propagators that are
similar enough to our Newton-Raphson implementation
- test_utilities:create_orbit_tracker functions could return copies instead of pointers
- functions using pointers could be pass by reference
- min_time should have a default value
- 3d should be always, and use existing OrbitalElements struct
- remove RK4 integration implementation?
- interplanetary transfers - interplanetary transfers
- SOI boundary testing - SOI boundary testing
- draw SOI boundry in graphical sim - draw SOI boundry in graphical sim
- remove tests/informational/*
- remove RK4 integration implementation?
- refactor tests and check logic for edge cases
- add reset/load new config UI control - add reset/load new config UI control
- UI fixes - UI fixes
@ -37,6 +25,7 @@ If you see modifications to this file in git status, IGNORE them and do not comm
- remember to periodically check the reference docs against new changes - remember to periodically check the reference docs against new changes
=== code style === === code style ===
- document 'ZII' zero is initialization
- arena memory management? - arena memory management?
=== Simulation/Physics === === Simulation/Physics ===

75
docs/planetary_data.md

@ -1,75 +0,0 @@
# 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

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

11
docs/technical_reference.md

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

217
scripts/precalc_analytical_propagation.py

@ -1,217 +0,0 @@
#!/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

@ -1,234 +0,0 @@
#!/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

@ -1,79 +0,0 @@
#!/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

@ -1,135 +0,0 @@
#!/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

@ -1,56 +0,0 @@
#!/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

@ -1,188 +0,0 @@
#!/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

@ -1,545 +0,0 @@
#!/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

@ -1,54 +0,0 @@
#!/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

@ -1,128 +0,0 @@
#!/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

@ -1,189 +0,0 @@
#!/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

@ -1,360 +0,0 @@
#!/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

@ -1,129 +0,0 @@
#!/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

@ -1,89 +0,0 @@
#!/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

@ -1,254 +0,0 @@
#!/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

@ -1,920 +0,0 @@
#!/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

@ -1,117 +0,0 @@
#!/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,14 +21,6 @@ enum TriggerType {
TRIGGER_TRUE_ANOMALY TRIGGER_TRUE_ANOMALY
}; };
// State vectors captured at the exact moment a burn fires
struct BurnResult {
bool valid;
Vec3 position;
Vec3 velocity;
double true_anomaly;
};
struct Maneuver { struct Maneuver {
char name[64]; char name[64];
int craft_index; int craft_index;
@ -39,7 +31,6 @@ struct Maneuver {
double scheduled_dt; double scheduled_dt;
bool executed; bool executed;
double executed_time; double executed_time;
BurnResult burn_result;
}; };
struct HohmannTransfer { struct HohmannTransfer {

49
src/orbital_mechanics.cpp

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

3
src/orbital_mechanics.h

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

64
src/physics.cpp

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

11
src/physics.h

@ -29,7 +29,6 @@ double vec3_dot(Vec3 a, Vec3 b);
// Matrix functions // Matrix functions
Mat3 mat3_identity(); Mat3 mat3_identity();
Mat3 mat3_multiply(Mat3 a, Mat3 b); Mat3 mat3_multiply(Mat3 a, Mat3 b);
Mat3 mat3_transpose(Mat3 m);
Vec3 mat3_multiply_vec3(Mat3 m, Vec3 v); Vec3 mat3_multiply_vec3(Mat3 m, Vec3 v);
Mat3 mat3_rotation_x(double angle); Mat3 mat3_rotation_x(double angle);
Mat3 mat3_rotation_z(double angle); Mat3 mat3_rotation_z(double angle);
@ -38,7 +37,13 @@ Mat3 mat3_rotation_orbital(double omega, double i, double Omega);
// Physics functions // Physics functions
Vec3 calculate_acceleration(Vec3 force, double mass); Vec3 calculate_acceleration(Vec3 force, double mass);
// Comparison utility // Physics integration functions
bool compare_vec3(Vec3 a, Vec3 b, double tolerance); // 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);
#endif #endif

7
src/simulation.cpp

@ -324,13 +324,6 @@ void update_spacecraft_physics(SimulationState* sim) {
craft->orbit = propagate_orbital_elements(craft->orbit, burn_dt, parent->mass); craft->orbit = propagate_orbital_elements(craft->orbit, burn_dt, parent->mass);
orbital_elements_to_cartesian(craft->orbit, parent->mass, &craft->local_position, &craft->local_velocity); orbital_elements_to_cartesian(craft->orbit, parent->mass, &craft->local_position, &craft->local_velocity);
// Capture exact pre-burn state for test assertions
fired_maneuver->burn_result = {};
fired_maneuver->burn_result.valid = true;
fired_maneuver->burn_result.position = craft->local_position;
fired_maneuver->burn_result.velocity = craft->local_velocity;
fired_maneuver->burn_result.true_anomaly = craft->orbit.true_anomaly;
double burn_time = sim->time + burn_dt; double burn_time = sim->time + burn_dt;
execute_maneuver(fired_maneuver, craft, sim, burn_time); execute_maneuver(fired_maneuver, craft, sim, burn_time);

102
src/test_utilities.cpp

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

27
src/test_utilities.h

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

73
tests/informational/Makefile

@ -0,0 +1,73 @@
# 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

@ -0,0 +1,72 @@
# 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

@ -0,0 +1,296 @@
#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

@ -0,0 +1,80 @@
# 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
}

698
tests/test_analytical_propagation.cpp

@ -1,5 +1,4 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h" #include "../src/physics.h"
#include "../src/orbital_mechanics.h" #include "../src/orbital_mechanics.h"
#include "../src/simulation.h" #include "../src/simulation.h"
@ -7,343 +6,442 @@
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include <cmath> #include <cmath>
using Catch::Matchers::WithinAbs; 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;
SCENARIO("Analytical propagation: apsides, periods, vis-viva, timestep accuracy, long-term stability", TEST_CASE("Propagation through perigee (velocity maximum)", "[analytical][propagation][perigee]") {
"[analytical][propagation][apsides][period][vis_viva][timestep][accuracy][long_term]") {
// === Fixture ===
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 2, 0, TIME_STEP); SimulationState* sim = create_simulation(10, 2, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_analytical_propagation.toml")); REQUIRE(load_system_config(sim, "tests/test_analytical_propagation.toml"));
Spacecraft* apsides_craft = &sim->spacecraft[0]; Spacecraft* craft = &sim->spacecraft[0];
Spacecraft* timestep_craft = &sim->spacecraft[1];
CelestialBody* earth = &sim->bodies[0]; CelestialBody* earth = &sim->bodies[0];
const double mu = G * earth->mass; Vec3 pos_before;
const double A1_A = apsides_craft->orbit.semi_major_axis; Vec3 vel_before;
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;
}
INFO(label); craft->orbit.true_anomaly = M_PI / 4.0;
INFO(" Initial nu: " << initial_nu << " rad"); orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos_before, &vel_before);
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");
}
SECTION("apsides apogee radius = a*(1+e)") { double v_before = vec3_magnitude(vel_before);
check_apsides_radius(A1_A, A1_E, M_PI, A1_A * (1.0 + A1_E), double r_before = vec3_magnitude(pos_before);
"Apsides spacecraft apogee");
}
SECTION("apsides perigee velocity matches precalculated value") { INFO("Before perigee:");
Vec3 pos_peri, vel_peri, pos_apo, vel_apo; INFO(" Position: (" << pos_before.x << ", " << pos_before.y << ", " << pos_before.z << ") m");
get_state(A1_A, A1_E, 0.0, pos_peri, vel_peri); INFO(" Velocity: (" << vel_before.x << ", " << vel_before.y << ", " << vel_before.z << ") m/s");
get_state(A1_A, A1_E, M_PI, pos_apo, vel_apo); INFO(" Velocity magnitude: " << v_before << " m/s");
const double v_peri = vec3_magnitude(vel_peri); INFO(" Radius: " << r_before << " m");
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));
}
SECTION("apsides velocity at pi/4 matches precalculated value") { Vec3 pos_perigee;
Vec3 pos_45, vel_45, pos_peri, vel_peri; Vec3 vel_perigee;
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));
}
SECTION("timestep perigee radius = a*(1-e)") { craft->orbit.true_anomaly = 0.0;
check_apsides_radius(A2_A, A2_E, 0.0, A2_A * (1.0 - A2_E), orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos_perigee, &vel_perigee);
"Timestep spacecraft perigee");
}
SECTION("timestep apogee radius = a*(1+e)") { double v_perigee = vec3_magnitude(vel_perigee);
check_apsides_radius(A2_A, A2_E, M_PI, A2_A * (1.0 + A2_E), double r_perigee = vec3_magnitude(pos_perigee);
"Timestep spacecraft apogee");
}
SECTION("timestep perigee velocity matches precalculated value") { INFO("At perigee (v=0):");
Vec3 pos_peri, vel_peri, pos_apo, vel_apo; INFO(" Position: (" << pos_perigee.x << ", " << pos_perigee.y << ", " << pos_perigee.z << ") m");
get_state(A2_A, A2_E, 0.0, pos_peri, vel_peri); INFO(" Velocity: (" << vel_perigee.x << ", " << vel_perigee.y << ", " << vel_perigee.z << ") m/s");
get_state(A2_A, A2_E, M_PI, pos_apo, vel_apo); INFO(" Velocity magnitude: " << v_perigee << " m/s");
const double v_peri = vec3_magnitude(vel_peri); INFO(" Radius: " << r_perigee << " m");
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));
}
// --- 2. Vis-viva --- double expected_r_perigee = craft->orbit.semi_major_axis * (1.0 - craft->orbit.eccentricity);
SECTION("vis-viva at nu = 0") { INFO("Expected radius at perigee: " << expected_r_perigee << " m");
check_vis_viva(A1_A, A1_E, 0.0, "vis-viva nu=0");
}
SECTION("vis-viva at nu = pi/4") { double r_error = fabs(r_perigee - expected_r_perigee);
check_vis_viva(A1_A, A1_E, M_PI / 4.0, "vis-viva nu=pi/4"); INFO("Radius error: " << r_error << " m");
}
SECTION("vis-viva at nu = pi/2") { REQUIRE(r_error < POSITION_TOLERANCE_APSIDES);
check_vis_viva(A1_A, A1_E, M_PI / 2.0, "vis-viva nu=pi/2"); REQUIRE(v_perigee > v_before);
}
SECTION("vis-viva at nu = 3pi/4") { destroy_simulation(sim);
check_vis_viva(A1_A, A1_E, 3.0 * M_PI / 4.0, "vis-viva nu=3pi/4"); }
}
SECTION("vis-viva at nu = pi") { TEST_CASE("Propagation through apogee (velocity minimum)", "[analytical][propagation][apogee]") {
check_vis_viva(A1_A, A1_E, M_PI, "vis-viva nu=pi"); const double TIME_STEP = 60.0;
}
// --- 3. Period return (both spacecraft) --- SimulationState* sim = create_simulation(10, 2, 0, TIME_STEP);
SECTION("apsides position and velocity return after one period") {
check_period_return(A1_A, A1_E, A1_PERIOD,
"Apsides spacecraft");
}
SECTION("apsides true anomaly returns after one period") { REQUIRE(load_system_config(sim, "tests/test_analytical_propagation.toml"));
check_anomaly_return(A1_A, A1_E, A1_PERIOD, 0.0,
"Apsides spacecraft nu");
}
SECTION("timestep position and velocity return after one period") { Spacecraft* craft = &sim->spacecraft[0];
check_period_return(A2_A, A2_E, A2_PERIOD, CelestialBody* earth = &sim->bodies[0];
"Timestep spacecraft");
}
SECTION("timestep true anomaly returns after one period") { Vec3 pos_perigee;
check_anomaly_return(A2_A, A2_E, A2_PERIOD, 0.0, Vec3 vel_perigee;
"Timestep spacecraft nu"); craft->orbit.true_anomaly = 0.0;
} orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos_perigee, &vel_perigee);
// --- 4. Timestep accuracy --- double v_perigee = vec3_magnitude(vel_perigee);
SECTION("large timestep (2x period) preserves state") { double r_perigee = vec3_magnitude(pos_perigee);
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));
}
SECTION("very small timestep (0.1 s) produces expected displacement") { INFO("At perigee:");
const double dt = 0.1; INFO(" Velocity magnitude: " << v_perigee << " m/s");
Vec3 pos_final, vel_final; INFO(" Radius: " << r_perigee << " m");
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));
}
SECTION("accuracy at 1x period") { Vec3 pos_apogee;
Vec3 pos_final, vel_final; Vec3 vel_apogee;
propagate_and_get_state(A2_A, A2_E, 0.0, A2_PERIOD, pos_final, vel_final); craft->orbit.true_anomaly = M_PI;
Vec3 pos_init, vel_init; orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos_apogee, &vel_apogee);
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 << " s (1x period)"); double v_apogee = vec3_magnitude(vel_apogee);
INFO("pos_error: " << pos_error << " m"); double r_apogee = vec3_magnitude(pos_apogee);
INFO("vel_error: " << vel_error << " m/s");
REQUIRE_THAT(pos_error, WithinAbs(0.0, R_TOL)); INFO("At apogee (v=π):");
REQUIRE_THAT(vel_error, WithinAbs(0.0, V_TOL)); 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];
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 << "°)");
SECTION("accuracy at 10x period") { craft->orbit.true_anomaly = nu;
Vec3 pos_final, vel_final;
propagate_and_get_state(A2_A, A2_E, 0.0, A2_PERIOD * 10.0, Vec3 position;
pos_final, vel_final); Vec3 velocity;
Vec3 pos_init, vel_init; orbital_elements_to_cartesian(craft->orbit, earth->mass, &position, &velocity);
get_state(A2_A, A2_E, 0.0, pos_init, vel_init);
const double pos_error = vec3_distance(pos_init, pos_final); double r = vec3_magnitude(position);
const double vel_error = vec3_distance(vel_init, vel_final); double v = vec3_magnitude(velocity);
INFO("dt: " << A2_PERIOD * 10.0 << " s (10x period)"); double expected_v_squared = mu * (2.0 / r - 1.0 / a);
INFO("pos_error: " << pos_error << " m"); double expected_v = sqrt(expected_v_squared);
INFO("vel_error: " << vel_error << " m/s");
double v_error = fabs(v - expected_v);
REQUIRE_THAT(pos_error, WithinAbs(0.0, R_TOL)); double relative_error = v_error / expected_v * 100.0;
REQUIRE_THAT(vel_error, WithinAbs(0.0, V_TOL));
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 << "%)");
REQUIRE(relative_error < 0.01);
} }
// --- 5. Long-term stability --- destroy_simulation(sim);
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)); TEST_CASE("Large timestep - dt greater than orbital period", "[analytical][timestep][large]") {
const double initial_nu = 0.0; const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 2, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_analytical_propagation.toml"));
Spacecraft* craft = &sim->spacecraft[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");
OrbitalElements el = make_elements(A2_A, A2_E, initial_nu); double v_before_mag = vec3_magnitude(vel_before);
OrbitalElements propagated = propagate_orbital_elements(el, propagation_time, earth->mass); double expected_pos_change = v_before_mag * small_dt;
const double final_nu = propagated.true_anomaly; double pos_error = fabs(pos_change - expected_pos_change);
const double expected_delta_anomaly = mean_motion * propagation_time; INFO("Expected position change (v·dt): " << expected_pos_change << " m");
double expected_final_nu = initial_nu + expected_delta_anomaly; INFO("Position error: " << pos_error << " m");
while (expected_final_nu < 0.0) { INFO("Relative position error: " << (pos_error / expected_pos_change * 100.0) << "%");
expected_final_nu += 2.0 * M_PI;
REQUIRE(pos_error < VELOCITY_TOLERANCE_TIMESTEP * small_dt * 10.0);
REQUIRE(vel_change < VELOCITY_TOLERANCE_TIMESTEP);
destroy_simulation(sim);
}
TEST_CASE("Accuracy vs timestep size relationship", "[analytical][timestep][accuracy]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 2, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_analytical_propagation.toml"));
Spacecraft* craft = &sim->spacecraft[1];
CelestialBody* earth = &sim->bodies[0];
double a = craft->orbit.semi_major_axis;
double mu = G * earth->mass;
double period_seconds = 2.0 * M_PI * sqrt(pow(a, 3.0) / mu);
double dt_ratios[] = {0.01, 0.1, 1.0, 10.0};
Vec3 pos_initial;
Vec3 vel_initial;
orbital_elements_to_cartesian(craft->orbit, earth->mass, &pos_initial, &vel_initial);
for (int i = 0; i < 4; i++) {
double dt = period_seconds * dt_ratios[i];
INFO("Testing dt = " << dt << " s (" << dt_ratios[i] << "x period)");
OrbitalElements propagated = propagate_orbital_elements(craft->orbit, dt, earth->mass);
Vec3 pos_final;
Vec3 vel_final;
orbital_elements_to_cartesian(propagated, earth->mass, &pos_final, &vel_final);
double pos_error = vec3_distance(pos_initial, pos_final);
double vel_error = vec3_distance(vel_initial, vel_final);
double num_periods = dt / period_seconds;
double expected_num_orbits = round(num_periods);
double fractional_phase = num_periods - expected_num_orbits;
double expected_pos_error = fractional_phase * 2.0 * M_PI * a;
INFO(" Position error: " << pos_error << " m");
INFO(" Expected error (phase): " << expected_pos_error << " m");
INFO(" Number of periods: " << num_periods);
if (expected_num_orbits > 0 && expected_pos_error > 1.0e-6) {
double relative_error = pos_error / expected_pos_error;
INFO(" Relative error: " << relative_error);
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);
} }
while (expected_final_nu >= 2.0 * M_PI) {
expected_final_nu -= 2.0 * M_PI;
} }
const double raw_error = std::abs(final_nu - expected_final_nu); destroy_simulation(sim);
const double anomaly_error = std::fmin(raw_error, 2.0 * M_PI - raw_error); }
INFO("Propagation time: " << propagation_time << " s (" TEST_CASE("Mean anomaly accumulation over long propagation", "[analytical][timestep][accumulation]") {
<< propagation_time / A2_PERIOD << " periods)"); const double TIME_STEP = 60.0;
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_THAT(anomaly_error, WithinAbs(0.0, LONG_TERM_ANG_TOL)); 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 mean_motion = sqrt(mu / pow(a, 3.0));
double initial_true_anomaly = craft->orbit.true_anomaly;
INFO("Initial true anomaly: " << initial_true_anomaly << " rad");
double propagation_time = period_seconds * 100.0;
INFO("Propagation time: " << propagation_time << " s (" << propagation_time / period_seconds << " periods)");
OrbitalElements propagated = propagate_orbital_elements(craft->orbit, propagation_time, earth->mass);
double final_true_anomaly = propagated.true_anomaly;
INFO("Final true anomaly: " << final_true_anomaly << " rad");
double expected_delta_anomaly = mean_motion * propagation_time;
double expected_final_anomaly = fmod(initial_true_anomaly + expected_delta_anomaly, 2.0 * M_PI);
INFO("Expected final anomaly: " << expected_final_anomaly << " rad");
double raw_error = fabs(final_true_anomaly - expected_final_anomaly);
double anomaly_error = fmin(raw_error, 2.0 * M_PI - raw_error);
INFO("True anomaly error: " << anomaly_error << " rad (" << anomaly_error * 180.0 / M_PI << "°)");
REQUIRE(anomaly_error < 1.0e-3);
destroy_simulation(sim); destroy_simulation(sim);
} }

27
tests/test_analytical_propagation.toml

@ -1,5 +1,6 @@
# Test Configuration: Analytical Propagation Tests # Test Configuration: Analytical Propagation Tests
# Two spacecraft with different orbital parameters for propagation testing # Combined configuration for apsides and timestep testing
# Contains two spacecraft with different orbital parameters
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
@ -7,16 +8,34 @@ mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = -1 parent_index = -1
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { 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 = "Apsides_Test_Spacecraft" name = "Apsides_Test_Spacecraft"
mass = 1000.0 mass = 1000.0
parent_index = 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]] [[spacecraft]]
name = "Timestep_Test_Spacecraft" name = "Timestep_Test_Spacecraft"
mass = 1000.0 mass = 1000.0
parent_index = 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
}

153
tests/test_barkers_equation.cpp

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

693
tests/test_cartesian_to_elements_advanced.cpp

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

240
tests/test_cartesian_to_elements_basic.cpp

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

20
tests/test_cartesian_to_elements_basic.toml

@ -1,4 +1,5 @@
# Basic elliptical orbit: a=15000km, e=0.5, zero inclination # Test Configuration: Basic Elliptical Orbit
# Moderate eccentricity, zero inclination for testing Cartesian ↔ orbital elements conversion
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
@ -6,10 +7,21 @@ mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = -1 parent_index = -1
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { 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" name = "Test_Spacecraft"
mass = 1000.0 mass = 1000.0
parent_index = 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,60 +1,34 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h" #include "../src/physics.h"
#include "../src/simulation.h" #include "../src/simulation.h"
#include "../src/config_loader.h" #include "../src/config_loader.h"
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include <cmath> #include <cmath>
using Catch::Matchers::WithinAbs; TEST_CASE("Energy conservation - Earth circular orbit", "[energy][rk4]") {
SCENARIO("Energy conservation in circular orbit", "[energy][sanity]") {
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
const double DAYS_TO_SIMULATE = 10.0; const double DAYS_TO_SIMULATE = 10.0;
const double SECONDS_PER_DAY = 86400.0; const double SECONDS_PER_DAY = 86400.0;
SimulationState* sim = create_simulation(2, 0, 0, TIME_STEP); SimulationState* sim = create_simulation(10, 0, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_energy.toml")); REQUIRE(load_system_config(sim, "tests/test_energy.toml"));
const double initial_energy = calculate_system_total_energy(sim); double initial_energy = calculate_system_total_energy(sim);
double total_time = DAYS_TO_SIMULATE * SECONDS_PER_DAY; double total_time = DAYS_TO_SIMULATE * SECONDS_PER_DAY;
while (sim->time < total_time) { while (sim->time < total_time) {
update_simulation(sim); update_simulation(sim);
} }
const double final_energy = calculate_system_total_energy(sim); double final_energy = calculate_system_total_energy(sim);
const double energy_drift = fabs(final_energy - initial_energy) / fabs(initial_energy); double energy_drift_percent = fabs((final_energy - initial_energy) / initial_energy) * 100.0;
INFO("Initial energy: " << initial_energy << " J"); INFO("Initial energy: " << initial_energy << " J");
INFO("Final energy: " << final_energy << " J"); INFO("Final energy: " << final_energy << " J");
INFO("Relative drift: " << energy_drift << " (fraction)"); INFO("Energy drift: " << energy_drift_percent << "%");
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;
INFO("Delta: " << delta << " rad"); REQUIRE(energy_drift_percent < 5.0);
REQUIRE_THAT(delta, WithinAbs(0.0172042841, 1e-6));
destroy_simulation(sim); destroy_simulation(sim);
} }

12
tests/test_energy.toml

@ -8,7 +8,11 @@ mass = 1.989e30
radius = 6.96e8 radius = 6.96e8
parent_index = -1 parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 } color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = { 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]] [[bodies]]
name = "Earth" name = "Earth"
@ -16,4 +20,8 @@ mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = 0 parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { 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
}

322
tests/test_extreme_eccentricity.cpp

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

40
tests/test_extreme_eccentricity.toml

@ -1,4 +1,5 @@
# Test Configuration: Extreme Eccentricity Orbits # Test Configuration: Extreme Eccentricity Orbits
# Tests near-parabolic and hyperbolic orbits
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
@ -6,22 +7,47 @@ mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = -1 parent_index = -1
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { 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" name = "Highly_Elliptical"
mass = 1000.0 mass = 1000.0
parent_index = 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" name = "Near_Parabolic"
mass = 1000.0 mass = 1000.0
parent_index = 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" name = "Slightly_Hyperbolic"
mass = 1000.0 mass = 1000.0
parent_index = 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
}

578
tests/test_extreme_orientation_mixed.cpp

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

62
tests/test_extreme_orientation_mixed.toml

@ -1,38 +1,88 @@
# Test Configuration: Extreme Orientation Mixed Cases # Test Configuration: Extreme Orientation Mixed Cases
# Tests combined high inclination + high eccentricity orbital mechanics
# Tests singularity handling at Ω=0 and ω=0
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
mass = 5.972e24 mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = -1 parent_index = -1
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { 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]] [[spacecraft]]
name = "High_Inc_High_Ecc" name = "High_Inc_High_Ecc"
mass = 1000.0 mass = 1000.0
parent_index = 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]] [[spacecraft]]
name = "Polar_Moderate_Ecc" name = "Polar_Moderate_Ecc"
mass = 1000.0 mass = 1000.0
parent_index = 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]] [[spacecraft]]
name = "Near_Parabolic_Mod_Inc" name = "Near_Parabolic_Mod_Inc"
mass = 1000.0 mass = 1000.0
parent_index = 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]] [[spacecraft]]
name = "Omega_Zero" name = "Omega_Zero"
mass = 1000.0 mass = 1000.0
parent_index = 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]] [[spacecraft]]
name = "Arg_Peri_Zero" name = "Arg_Peri_Zero"
mass = 1000.0 mass = 1000.0
parent_index = 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
}

536
tests/test_extreme_timescales.cpp

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

78
tests/test_extreme_timescales.toml

@ -1,4 +1,5 @@
# Test Configuration: Extreme Timescales for Analytical Propagation # Test Configuration: Extreme Timescales for Analytical Propagation
# Tests orbital period extremes to validate propagation at different timescales
[[bodies]] [[bodies]]
name = "Earth" name = "Earth"
@ -6,7 +7,11 @@ mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = -1 parent_index = -1
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { 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]] [[bodies]]
name = "Sun" name = "Sun"
@ -14,40 +19,97 @@ mass = 1.989e30
radius = 6.96e8 radius = 6.96e8
parent_index = -1 parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 } color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = { 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]] [[spacecraft]]
name = "Fast_Orbit_LEO" name = "Fast_Orbit_LEO"
mass = 1000.0 mass = 1000.0
parent_index = 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]] [[spacecraft]]
name = "Mercury_Like_Orbit" name = "Mercury_Like_Orbit"
mass = 1000.0 mass = 1000.0
parent_index = 1 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]] [[spacecraft]]
name = "Long_Period_Orbit" name = "Long_Period_Orbit"
mass = 1000.0 mass = 1000.0
parent_index = 1 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]] [[spacecraft]]
name = "Low_Altitude_Orbit" name = "Low_Altitude_Orbit"
mass = 1000.0 mass = 1000.0
parent_index = 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]] [[spacecraft]]
name = "Super_Synchronous_Orbit" name = "Super_Synchronous_Orbit"
mass = 1000.0 mass = 1000.0
parent_index = 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]] [[spacecraft]]
name = "Geosynchronous_Orbit" name = "Geosynchronous_Orbit"
mass = 1000.0 mass = 1000.0
parent_index = 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
}

1311
tests/test_hybrid_burns.cpp

File diff suppressed because it is too large Load Diff

153
tests/test_hybrid_burns.toml

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

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

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

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

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

184
tests/test_inclined_orbits.cpp

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

24
tests/test_inclined_orbits.toml

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

@ -0,0 +1,244 @@
#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));
}

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

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

131
tests/test_maneuver_planning.cpp

@ -5,51 +5,80 @@
#include "../src/orbital_objects.h" #include "../src/orbital_objects.h"
#include "../src/maneuver.h" #include "../src/maneuver.h"
#include "../src/config_loader.h" #include "../src/config_loader.h"
#include "../src/rendezvous.h"
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include <cmath>
#include <cstring>
using Catch::Matchers::WithinAbs; using Catch::Matchers::WithinAbs;
SCENARIO("Maneuver planning and execution", "[maneuver][planning][trigger][config]") { TEST_CASE("Maneuver loading from config", "[maneuver][config]") {
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
const double BURN_TIME = 3600.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP); SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuver_planning.toml")); REQUIRE(load_system_config(sim, "tests/test_maneuver_planning.toml"));
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(sim->maneuver_count == 2);
REQUIRE(std::string(sim->maneuvers[0].name) == "orbit_raise_1"); REQUIRE(std::string(sim->maneuvers[0].name) == "orbit_raise_1");
REQUIRE(std::string(sim->maneuvers[1].name) == "orbit_raise_2"); REQUIRE(std::string(sim->maneuvers[1].name) == "orbit_raise_2");
REQUIRE(sim->maneuvers[0].trigger_type == TRIGGER_TIME); REQUIRE(sim->maneuvers[0].trigger_type == TRIGGER_TIME);
REQUIRE(sim->maneuvers[1].trigger_type == TRIGGER_TRUE_ANOMALY); REQUIRE(sim->maneuvers[1].trigger_type == TRIGGER_TRUE_ANOMALY);
REQUIRE_THAT(sim->maneuvers[0].delta_v, WithinAbs(500.0, D_TOL)); REQUIRE(sim->maneuvers[0].delta_v == 500.0);
}
destroy_simulation(sim);
}
TEST_CASE("Time-based trigger executes at correct time", "[maneuver][trigger][time]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
SECTION("time-based trigger executes at 3600 s") { REQUIRE(load_system_config(sim, "tests/test_maneuver_planning.toml"));
REQUIRE(sim->maneuver_count == 2);
REQUIRE(!sim->maneuvers[0].executed); REQUIRE(!sim->maneuvers[0].executed);
REQUIRE(!sim->maneuvers[1].executed); REQUIRE(!sim->maneuvers[1].executed);
run_until(BURN_TIME + TIME_STEP); 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(sim->maneuvers[0].executed);
REQUIRE_THAT(sim->maneuvers[0].executed_time, WithinAbs(BURN_TIME, TIME_STEP)); REQUIRE(fabs(sim->maneuvers[0].executed_time - BURN_TIME) < TIME_STEP);
const double after_velocity = vec3_magnitude(sim->spacecraft[0].local_velocity); double after_velocity = vec3_magnitude(sim->spacecraft[0].local_velocity);
REQUIRE_THAT(after_velocity, WithinAbs(8.170251503359999e3, V_TOL)); 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;
SECTION("true anomaly trigger executes after first burn") { 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); REQUIRE(!sim->maneuvers[1].executed);
run_until(BURN_TIME + TIME_STEP); double first_burn_velocity = 0.0;
REQUIRE(sim->maneuvers[0].executed);
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;
const double max_sim_time = BURN_TIME + 72000.0;
while (sim->time < max_sim_time) { while (sim->time < max_sim_time) {
update_simulation(sim); update_simulation(sim);
if (sim->maneuvers[1].executed) { if (sim->maneuvers[1].executed) {
@ -58,24 +87,66 @@ SCENARIO("Maneuver planning and execution", "[maneuver][planning][trigger][confi
} }
REQUIRE(sim->maneuvers[1].executed); REQUIRE(sim->maneuvers[1].executed);
REQUIRE_THAT(vec3_magnitude(sim->spacecraft[0].local_velocity),
WithinAbs(8.672299586435140e3, V_TOL));
}
SECTION("maneuvers execute only once") { double second_burn_velocity = vec3_magnitude(sim->spacecraft[0].local_velocity);
run_until(20000.0); double second_burn_kinetic_energy = 0.5 * sim->spacecraft[0].mass *
second_burn_velocity * second_burn_velocity;
double first_burn_kinetic_energy = 0.5 * sim->spacecraft[0].mass *
first_burn_velocity * first_burn_velocity;
REQUIRE(second_burn_kinetic_energy > first_burn_kinetic_energy);
destroy_simulation(sim);
}
TEST_CASE("Maneuvers only execute once", "[maneuver][execution]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_maneuver_planning.toml"));
const double MAX_TIME = 20000.0;
while (sim->time < MAX_TIME) {
update_simulation(sim);
}
REQUIRE(sim->maneuvers[0].executed); REQUIRE(sim->maneuvers[0].executed);
REQUIRE(sim->maneuvers[1].executed); REQUIRE(sim->maneuvers[1].executed);
int execution_count = 0; double execution_count = 0.0;
for (int i = 0; i < sim->maneuver_count; i++) { for (int i = 0; i < sim->maneuver_count; i++) {
if (sim->maneuvers[i].executed) { if (sim->maneuvers[i].executed) {
execution_count++; execution_count += 1.0;
} }
} }
REQUIRE(execution_count == 2);
} REQUIRE(execution_count == 2.0);
destroy_simulation(sim); destroy_simulation(sim);
} }
TEST_CASE("Duplicate maneuver names fail config load", "[maneuver][config][error]") {
const double TIME_STEP = 60.0;
SimulationState* sim = create_simulation(10, 10, 100, TIME_STEP);
bool result = load_system_config(sim, "tests/test_maneuver_planning.toml");
REQUIRE(result);
REQUIRE(sim->maneuver_count == 2);
Maneuver duplicate_maneuver = sim->maneuvers[0];
sim->maneuvers[sim->maneuver_count] = duplicate_maneuver;
(void)sim->maneuver_count;
sim->maneuver_count++;
bool is_duplicate = (std::string(sim->maneuvers[0].name) ==
std::string(sim->maneuvers[2].name));
REQUIRE(is_duplicate);
destroy_simulation(sim);
}

24
tests/test_maneuver_planning.toml

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

220
tests/test_maneuvers.cpp

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

18
tests/test_maneuvers.toml

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

419
tests/test_moon_orbits.cpp

@ -1,273 +1,266 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h" #include "../src/physics.h"
#include "../src/simulation.h" #include "../src/simulation.h"
#include "../src/config_loader.h" #include "../src/config_loader.h"
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include <cmath> #include <cmath>
#include <vector> #include <vector>
#include <string>
using Catch::Matchers::WithinAbs; 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;
// === Helper: run sim loop, collect failures, assert once at end === SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP);
static std::string fmt_time(double t) {
char buf[32];
snprintf(buf, sizeof(buf), "t=%.0fs", t);
return std::string(buf);
}
static std::string fmt_drift(double d) { REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml"));
char buf[32];
snprintf(buf, sizeof(buf), "drift=%.4f", d); const int EARTH_INDEX = 2;
return std::string(buf); const int MOON_INDEX = 8;
}
double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY;
OrbitTracker* tracker = create_orbit_tracker_with_min_time(MOON_INDEX, 5.0);
int initial_parent = sim->bodies[MOON_INDEX].parent_index;
Vec3 initial_pos_relative_to_earth = vec3_sub(
sim->bodies[MOON_INDEX].global_position,
sim->bodies[EARTH_INDEX].global_position
);
double initial_distance = vec3_magnitude(initial_pos_relative_to_earth);
INFO("Moon initial distance from Earth: " << initial_distance << " m");
INFO("Expected distance: " << MOON_DISTANCE_FROM_EARTH << " m");
while (sim->time < max_time && !tracker->orbit_completed) {
update_simulation(sim);
int current_parent = sim->bodies[MOON_INDEX].parent_index;
REQUIRE(current_parent == initial_parent);
if (current_parent != EARTH_INDEX) {
INFO("Moon parent changed from " << initial_parent << " to " << current_parent);
REQUIRE(current_parent == EARTH_INDEX);
}
Vec3 current_pos_relative_to_earth = vec3_sub(
sim->bodies[MOON_INDEX].global_position,
sim->bodies[EARTH_INDEX].global_position
);
double current_distance = vec3_magnitude(current_pos_relative_to_earth);
double distance_drift = fabs(current_distance - initial_distance);
double drift_percentage = (distance_drift / initial_distance) * 100.0;
REQUIRE(drift_percentage < 20.0);
update_orbit_tracker(tracker, &sim->bodies[MOON_INDEX], &sim->bodies[EARTH_INDEX], sim->time);
}
REQUIRE(tracker->orbit_completed);
double measured_period_days = tracker->time_at_completion / SECONDS_PER_DAY;
double period_error_days = fabs(measured_period_days - EXPECTED_PERIOD_DAYS);
INFO("Expected Moon period: " << EXPECTED_PERIOD_DAYS << " days");
INFO("Measured Moon period: " << measured_period_days << " days");
INFO("Period error: " << period_error_days << " days");
// === Per-moon period tolerances (0.5% of analytical period, from precalc) === REQUIRE(period_error_days < 3.0);
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
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);
// === Drift tolerance for distance checks (10% bound) === double final_drift_percentage = (fabs(final_distance - initial_distance) / initial_distance) * 100.0;
static const double DRIFT_REL_TOL = 0.1;
SCENARIO("Multi-body moon orbital stability and period measurements", INFO("Final distance from Earth: " << final_distance << " m");
"[moon][earth][jupiter][saturn][galilean][titan]" INFO("Initial distance from Earth: " << initial_distance << " m");
"[stability][period][integration][inclined][geometry]") { INFO("Final drift: " << final_drift_percentage << "%");
// === Fixture ===
REQUIRE(final_drift_percentage < 10.0);
destroy_orbit_tracker(tracker);
destroy_simulation(sim);
}
TEST_CASE("Galilean moons orbital stability around Jupiter", "[moon][jupiter]") {
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
const double SECONDS_PER_DAY = 86400.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); SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml")); REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml"));
CelestialBody* earth = &sim->bodies[2]; const int JUPITER_INDEX = 4;
CelestialBody* moon = &sim->bodies[8]; const int IO_INDEX = 9;
const int EUROPA_INDEX = 10;
struct MoonData { const int GANYMEDE_INDEX = 11;
int index; const int CALLISTO_INDEX = 12;
const char* name;
double expected_seconds;
double max_days;
double min_time_days;
double period_tol;
int parent_index;
};
const MoonData galilean[] = { OrbitTracker* io_tracker = create_orbit_tracker_with_min_time(IO_INDEX, 1.0);
{9, "Io", 152928.62, 5.0, 1.0, IO_PERIOD_TOL, 4}, OrbitTracker* europa_tracker = create_orbit_tracker_with_min_time(EUROPA_INDEX, 2.0);
{10, "Europa", 306909.10, 10.0, 2.0, EUROPA_PERIOD_TOL, 4}, OrbitTracker* ganymede_tracker = create_orbit_tracker_with_min_time(GANYMEDE_INDEX, 5.0);
{11, "Ganymede", 618227.12, 15.0, 5.0, GANYMEDE_PERIOD_TOL, 4}, OrbitTracker* callisto_tracker = create_orbit_tracker_with_min_time(CALLISTO_INDEX, 10.0);
{12, "Callisto", 1442117.30, 25.0, 10.0, CALLISTO_PERIOD_TOL, 4},
};
const MoonData all_moons[] = { double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY;
{8, "Moon", 2372274.33, 35.0, 5.0, MOON_PERIOD_TOL, 2},
{9, "Io", 152928.62, 5.0, 1.0, IO_PERIOD_TOL, 4},
{10, "Europa", 306909.10, 10.0, 2.0, EUROPA_PERIOD_TOL, 4},
{11, "Ganymede", 618227.12, 15.0, 5.0, GANYMEDE_PERIOD_TOL, 4},
{12, "Callisto", 1442117.30, 25.0, 10.0, CALLISTO_PERIOD_TOL, 4},
{13, "Titan", 1377976.07, 25.0, 10.0, TITAN_PERIOD_TOL, 5},
};
// === Helper lambdas ===
// Run sim loop without assertions; collect failures; assert once at end.
// This tests every timestep but produces only 1 assertion per call.
auto check_parent_stability = [&](CelestialBody* body, int expected_parent,
double max_days) {
double max_time = max_days * SECONDS_PER_DAY;
std::vector<std::string> failures;
while (sim->time < max_time) { while (sim->time < max_time) {
update_simulation(sim); update_simulation(sim);
if (body->parent_index != expected_parent) {
failures.push_back(fmt_time(sim->time) + ": " +
std::string(body->name) +
" parent=" + std::to_string(body->parent_index) +
" (expected " + std::to_string(expected_parent) + ")");
}
}
REQUIRE(failures.empty());
};
auto measure_period = [&](int body_index, int parent_index, REQUIRE(sim->bodies[IO_INDEX].parent_index == JUPITER_INDEX);
double max_days, double min_time_days) { REQUIRE(sim->bodies[EUROPA_INDEX].parent_index == JUPITER_INDEX);
sim->time = 0.0; REQUIRE(sim->bodies[GANYMEDE_INDEX].parent_index == JUPITER_INDEX);
OrbitTracker* tracker = create_orbit_tracker_with_min_time( REQUIRE(sim->bodies[CALLISTO_INDEX].parent_index == JUPITER_INDEX);
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_orbit_tracker(io_tracker, &sim->bodies[IO_INDEX], &sim->bodies[JUPITER_INDEX], sim->time);
update_simulation(sim); update_orbit_tracker(europa_tracker, &sim->bodies[EUROPA_INDEX], &sim->bodies[JUPITER_INDEX], sim->time);
update_orbit_tracker(tracker, &sim->bodies[body_index], update_orbit_tracker(ganymede_tracker, &sim->bodies[GANYMEDE_INDEX], &sim->bodies[JUPITER_INDEX], sim->time);
&sim->bodies[parent_index], sim->time); update_orbit_tracker(callisto_tracker, &sim->bodies[CALLISTO_INDEX], &sim->bodies[JUPITER_INDEX], sim->time);
} }
double period = tracker->orbit_completed REQUIRE(io_tracker->orbit_completed);
? tracker->time_at_completion : -1.0; REQUIRE(europa_tracker->orbit_completed);
destroy_orbit_tracker(tracker); REQUIRE(ganymede_tracker->orbit_completed);
return period; 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);
}
auto check_distance_drift = [&](CelestialBody* body, int parent_index, TEST_CASE("Titan orbital stability around Saturn", "[moon][saturn]") {
double max_days) { const double TIME_STEP = 60.0;
CelestialBody* parent = &sim->bodies[parent_index]; const double EXPECTED_PERIOD_DAYS = 15.95;
Vec3 initial_rel = vec3_sub(body->global_position, parent->global_position); const double SECONDS_PER_DAY = 86400.0;
double initial_r = vec3_magnitude(initial_rel); const double MAX_SIMULATION_DAYS = 25.0;
double max_time = max_days * SECONDS_PER_DAY;
std::vector<std::string> failures; SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP);
while (sim->time < max_time) {
update_simulation(sim);
Vec3 rel = vec3_sub(body->global_position, parent->global_position);
double r = vec3_magnitude(rel);
double drift = std::fabs(r - initial_r) / initial_r;
if (drift > DRIFT_REL_TOL) {
failures.push_back(fmt_time(sim->time) + ": " +
std::string(body->name) + " " +
fmt_drift(drift));
}
}
REQUIRE(failures.empty());
};
auto wait_for_all_orbits = [&](int count) { REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml"));
OrbitTracker* trackers[6] = {};
for (int i = 0; i < count; i++) {
trackers[i] = create_orbit_tracker_with_min_time(
all_moons[i].index, all_moons[i].min_time_days * SECONDS_PER_DAY);
}
double max_time = 60.0 * SECONDS_PER_DAY; const int SATURN_INDEX = 5;
int completed_count = 0; const int TITAN_INDEX = 13;
while (sim->time < max_time && completed_count < count) { 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);
double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY;
while (sim->time < max_time && !tracker->orbit_completed) {
update_simulation(sim); update_simulation(sim);
for (int i = 0; i < count; i++) {
if (!trackers[i]->orbit_completed) {
update_orbit_tracker(trackers[i], &sim->bodies[all_moons[i].index],
&sim->bodies[all_moons[i].parent_index], sim->time);
if (trackers[i]->orbit_completed) {
completed_count++;
double period = trackers[i]->time_at_completion / SECONDS_PER_DAY;
INFO(all_moons[i].name << " completed orbit at "
<< period << " days");
}
}
}
}
for (int i = 0; i < count; i++) { REQUIRE(sim->bodies[TITAN_INDEX].parent_index == SATURN_INDEX);
REQUIRE(trackers[i]->orbit_completed);
destroy_orbit_tracker(trackers[i]);
}
};
// === Sections === 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);
SECTION("Moon maintains Earth as parent throughout simulation") { double drift_percentage = (fabs(current_distance - initial_distance) / initial_distance) * 100.0;
check_parent_stability(moon, 2, 35.0); REQUIRE(drift_percentage < 10.0);
}
SECTION("Moon distance from Earth stays within 10% of initial") { update_orbit_tracker(tracker, &sim->bodies[TITAN_INDEX], &sim->bodies[SATURN_INDEX], sim->time);
check_distance_drift(moon, 2, 35.0);
} }
SECTION("Moon completes orbit in ~27.3 days") { REQUIRE(tracker->orbit_completed);
double period = measure_period(8, 2, 35.0, 5.0);
INFO("Measured Moon period: " << period / SECONDS_PER_DAY
<< " days (expected: ~27.3)");
REQUIRE_THAT(period, WithinAbs(2372274.33, MOON_PERIOD_TOL));
}
SECTION("Galilean moons maintain Jupiter as parent") { double measured_period_days = tracker->time_at_completion / SECONDS_PER_DAY;
for (const auto& m : galilean) { double period_error_days = fabs(measured_period_days - EXPECTED_PERIOD_DAYS);
check_parent_stability(&sim->bodies[m.index], 4, 25.0);
}
}
SECTION("Galilean moons complete orbits in expected periods") { INFO("Expected Titan period: " << EXPECTED_PERIOD_DAYS << " days");
for (const auto& m : galilean) { INFO("Measured Titan period: " << measured_period_days << " days");
double period = measure_period(m.index, m.parent_index, INFO("Period error: " << period_error_days << " days");
m.max_days, m.min_time_days);
INFO(m.name << " period: " << period / SECONDS_PER_DAY
<< " days (expected: " << m.expected_seconds / SECONDS_PER_DAY
<< ")");
REQUIRE_THAT(period, WithinAbs(m.expected_seconds, m.period_tol));
}
}
SECTION("Galilean moons stay within 10% of initial distance") { REQUIRE(period_error_days < 3.0);
for (const auto& m : galilean) {
check_distance_drift(&sim->bodies[m.index], m.parent_index, 25.0);
}
}
SECTION("Titan maintains Saturn as parent") { destroy_orbit_tracker(tracker);
check_parent_stability(&sim->bodies[13], 5, 25.0); destroy_simulation(sim);
} }
SECTION("Titan completes orbit in ~15.95 days") { TEST_CASE("Combined solar system with all moons - parent stability", "[moon][integration]") {
double period = measure_period(13, 5, 25.0, 10.0); const double TIME_STEP = 60.0;
INFO("Measured Titan period: " << period / SECONDS_PER_DAY const double SECONDS_PER_DAY = 86400.0;
<< " days (expected: ~15.95)"); const double MAX_SIMULATION_DAYS = 60.0;
REQUIRE_THAT(period, WithinAbs(1377976.07, TITAN_PERIOD_TOL));
}
SECTION("Titan distance from Saturn stays within 10%") { SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP);
check_distance_drift(&sim->bodies[13], 5, 25.0);
}
SECTION("All moons maintain correct parents over 60-day simulation") { REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml"));
double max_time = 60.0 * SECONDS_PER_DAY;
std::vector<std::string> failures; struct ParentChange {
while (sim->time < max_time) { double time_days;
update_simulation(sim); int body_index;
for (int i = 0; i < 6; i++) { int old_parent;
int idx = all_moons[i].index; int new_parent;
int expected = all_moons[i].parent_index; };
if (sim->bodies[idx].parent_index != expected) {
failures.push_back(fmt_time(sim->time) + ": " +
std::string(all_moons[i].name) +
" parent=" +
std::to_string(sim->bodies[idx].parent_index) +
" (expected " + std::to_string(expected) + ")");
}
}
}
REQUIRE(failures.empty());
}
SECTION("All moons complete at least one orbit") { std::vector<ParentChange> parent_changes;
wait_for_all_orbits(6);
int initial_parents[14];
for (int i = 0; i < 14; i++) {
initial_parents[i] = sim->bodies[i].parent_index;
} }
SECTION("Moon inclined orbit produces non-zero z-coordinate") { double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY;
double max_time = 10.0 * SECONDS_PER_DAY;
while (sim->time < max_time) { while (sim->time < max_time) {
update_simulation(sim); update_simulation(sim);
}
Vec3 rel = vec3_sub(moon->global_position, earth->global_position); for (int i = 0; i < sim->body_count; i++) {
double z = rel.z; if (sim->bodies[i].parent_index != initial_parents[i]) {
INFO("Moon z-coordinate relative to Earth: " << z << " m"); ParentChange change;
INFO("Moon orbital inclination: " change.time_days = sim->time / SECONDS_PER_DAY;
<< (moon->orbit.inclination * 180.0 / M_PI) << " degrees"); change.body_index = i;
REQUIRE_THAT(z, !WithinAbs(0.0, 10000000.0)); 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("Moon orbital elements loaded correctly from config") { INFO("Total parent changes detected: " << parent_changes.size());
REQUIRE_THAT(moon->orbit.eccentricity, WithinAbs(0.055, E_TOL)); for (const auto& change : parent_changes) {
REQUIRE_THAT(moon->orbit.inclination, INFO("Body " << sim->bodies[change.body_index].name
WithinAbs(5.16 * M_PI / 180.0, ANG_TOL)); << " (index " << change.body_index << ") changed parent "
REQUIRE_THAT(moon->orbit.longitude_of_ascending_node, << "from " << change.old_parent << " to " << change.new_parent
WithinAbs(125.08 * M_PI / 180.0, ANG_TOL)); << " at day " << change.time_days);
REQUIRE_THAT(moon->orbit.argument_of_periapsis,
WithinAbs(318.15 * M_PI / 180.0, ANG_TOL));
} }
REQUIRE(parent_changes.size() == 0);
destroy_simulation(sim); destroy_simulation(sim);
} }

275
tests/test_moon_orbits.toml

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

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

95
tests/test_omega_debug.cpp

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

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

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

98
tests/test_orbital_period.cpp

@ -1,54 +1,102 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h" #include "../src/physics.h"
#include "../src/simulation.h" #include "../src/simulation.h"
#include "../src/config_loader.h" #include "../src/config_loader.h"
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include <cmath> #include <cmath>
using Catch::Matchers::WithinAbs; TEST_CASE("Orbital period - Earth (RK4)", "[period][rk4]") {
SCENARIO("Orbital period measurement with analytical propagation",
"[period][analytical]") {
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
const double EXPECTED_PERIOD_DAYS = 365.0;
const double SECONDS_PER_DAY = 86400.0; const double SECONDS_PER_DAY = 86400.0;
const double MAX_SIMULATION_DAYS = 400.0;
SimulationState* sim = create_simulation(10, 0, 0, TIME_STEP); SimulationState* sim = create_simulation(10, 0, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_orbital_period.toml")); REQUIRE(load_system_config(sim, "tests/test_orbital_period.toml"));
auto propagate_and_check = [&](OrbitTracker* tracker, CelestialBody* body, OrbitTracker* tracker = create_orbit_tracker(1);
CelestialBody* parent, double max_time,
double expected_days) { double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY;
while (sim->time < max_time && !tracker->orbit_completed) { while (sim->time < max_time && !tracker->orbit_completed) {
update_simulation(sim); update_simulation(sim);
update_orbit_tracker(tracker, body, parent, sim->time); update_orbit_tracker(tracker, &sim->bodies[1], &sim->bodies[0], sim->time);
} }
REQUIRE(tracker->orbit_completed); REQUIRE(tracker->orbit_completed);
const double measured_days = tracker->time_at_completion / SECONDS_PER_DAY; double measured_period_days = tracker->time_at_completion / SECONDS_PER_DAY;
INFO("Measured period: " << measured_days << " days"); double period_error_days = fabs(measured_period_days - EXPECTED_PERIOD_DAYS);
REQUIRE_THAT(measured_days, WithinAbs(expected_days, 0.1));
INFO("Expected period: " << EXPECTED_PERIOD_DAYS << " days");
INFO("Measured period: " << measured_period_days << " days");
INFO("Error: " << period_error_days << " days");
REQUIRE(period_error_days < 5.0);
destroy_orbit_tracker(tracker); destroy_orbit_tracker(tracker);
}; destroy_simulation(sim);
}
SECTION("Earth completes one orbit in ~365 days") { TEST_CASE("Orbital period - Mars (RK4)", "[period][rk4]") {
CelestialBody* earth = &sim->bodies[1]; const double TIME_STEP = 60.0;
CelestialBody* sun = &sim->bodies[0]; const double EXPECTED_PERIOD_DAYS = 687.0;
OrbitTracker* tracker = create_orbit_tracker(1); const double SECONDS_PER_DAY = 86400.0;
const double MAX_SIMULATION_DAYS = 750.0;
const double max_time = 400.0 * SECONDS_PER_DAY; SimulationState* sim = create_simulation(10, 0, 0, TIME_STEP);
propagate_and_check(tracker, earth, sun, max_time, 365.2105);
} REQUIRE(load_system_config(sim, "tests/test_orbital_period.toml"));
SECTION("Mars completes one orbit in ~671 days") {
CelestialBody* mars = &sim->bodies[2];
CelestialBody* sun = &sim->bodies[0];
OrbitTracker* tracker = create_orbit_tracker(2); OrbitTracker* tracker = create_orbit_tracker(2);
const double max_time = 750.0 * SECONDS_PER_DAY; double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY;
propagate_and_check(tracker, mars, sun, max_time, 670.9345); while (sim->time < max_time && !tracker->orbit_completed) {
update_simulation(sim);
update_orbit_tracker(tracker, &sim->bodies[2], &sim->bodies[0], sim->time);
} }
REQUIRE(tracker->orbit_completed);
double measured_period_days = tracker->time_at_completion / SECONDS_PER_DAY;
double period_error_days = fabs(measured_period_days - EXPECTED_PERIOD_DAYS);
INFO("Expected period: " << EXPECTED_PERIOD_DAYS << " days");
INFO("Measured period: " << measured_period_days << " days");
INFO("Error: " << period_error_days << " days");
REQUIRE(period_error_days < 25.0);
destroy_orbit_tracker(tracker);
destroy_simulation(sim);
} }
TEST_CASE("Orbit direction - prograde for zero inclination", "[direction]") {
const double TIME_STEP = 60.0;
const double TEST_DURATION_DAYS = 1.0;
const double SECONDS_PER_DAY = 86400.0;
const int STEPS = (int)(TEST_DURATION_DAYS * SECONDS_PER_DAY / TIME_STEP);
SimulationState* sim = create_simulation(2, 0, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_energy.toml"));
CelestialBody* sun = &sim->bodies[0];
CelestialBody* earth = &sim->bodies[1];
Vec3 initial_rel_pos = vec3_sub(earth->global_position, sun->global_position);
double theta_start = atan2(initial_rel_pos.y, initial_rel_pos.x);
for (int i = 0; i < STEPS; i++) {
update_simulation(sim);
}
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,7 +8,11 @@ mass = 1.989e30
radius = 6.96e8 radius = 6.96e8
parent_index = -1 parent_index = -1
color = { r = 1.0, g = 1.0, b = 0.0 } color = { r = 1.0, g = 1.0, b = 0.0 }
orbit = { 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]] [[bodies]]
name = "Earth" name = "Earth"
@ -16,7 +20,11 @@ mass = 5.972e24
radius = 6.371e6 radius = 6.371e6
parent_index = 0 parent_index = 0
color = { r = 0.0, g = 0.5, b = 1.0 } color = { r = 0.0, g = 0.5, b = 1.0 }
orbit = { 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]] [[bodies]]
name = "Mars" name = "Mars"
@ -24,4 +32,8 @@ mass = 6.39e23
radius = 3.3895e6 radius = 3.3895e6
parent_index = 0 parent_index = 0
color = { r = 0.8, g = 0.3, b = 0.1 } color = { r = 0.8, g = 0.3, b = 0.1 }
orbit = { 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
}

161
tests/test_parabolic_orbit.cpp

@ -1,93 +1,72 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../src/physics.h" #include "../src/physics.h"
#include "../src/simulation.h" #include "../src/simulation.h"
#include "../src/config_loader.h" #include "../src/config_loader.h"
#include "../src/test_utilities.h" #include "../src/test_utilities.h"
#include <cmath> #include <cmath>
#include <vector>
using Catch::Matchers::WithinAbs; TEST_CASE("Parabolic orbit - energy and escape trajectory", "[parabolic][energy][escape]") {
SCENARIO("Parabolic orbit - escape trajectory and initial conditions",
"[parabolic][energy][escape][initial]") {
// Fixture constants
const double TIME_STEP = 60.0; const double TIME_STEP = 60.0;
const double DAYS_TO_SIMULATE = 300.0; const double DAYS_TO_SIMULATE = 300.0;
const double SECONDS_PER_DAY = 86400.0; const double SECONDS_PER_DAY = 86400.0;
const double AU = 1.496e11; const double AU = 1.496e11;
// 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); SimulationState* sim = create_simulation(10, 0, 0, TIME_STEP);
REQUIRE(load_system_config(sim, "tests/test_parabolic_orbit.toml")); REQUIRE(load_system_config(sim, "tests/test_parabolic_orbit.toml"));
const int COMET_INDEX = 1; const int COMET_INDEX = 1;
const int SUN_INDEX = 0; const int SUN_INDEX = 0;
CelestialBody* comet = &sim->bodies[COMET_INDEX];
CelestialBody* sun = &sim->bodies[SUN_INDEX];
// Initial state Vec3 initial_position = sim->bodies[COMET_INDEX].global_position;
const double initial_distance = vec3_magnitude(comet->global_position); double initial_distance = vec3_magnitude(initial_position);
const double initial_velocity = vec3_magnitude(comet->global_velocity); double initial_velocity = vec3_magnitude(sim->bodies[COMET_INDEX].global_velocity);
const double initial_kinetic = calculate_kinetic_energy(comet);
const double initial_potential = calculate_potential_energy_pair(comet, sun); double initial_kinetic = calculate_kinetic_energy(&sim->bodies[COMET_INDEX]);
const double initial_total_energy = initial_kinetic + initial_potential; double initial_potential = calculate_potential_energy_pair(&sim->bodies[COMET_INDEX],
&sim->bodies[SUN_INDEX]);
double initial_total_energy = initial_kinetic + initial_potential;
INFO("Initial distance: " << initial_distance / AU << " AU"); INFO("Initial distance: " << initial_distance / AU << " AU");
INFO("Initial velocity: " << initial_velocity / 1000.0 << " km/s"); INFO("Initial velocity: " << vec3_magnitude(sim->bodies[COMET_INDEX].global_velocity) / 1000.0 << " km/s");
INFO("Initial kinetic energy: " << initial_kinetic); INFO("Initial kinetic energy: " << initial_kinetic);
INFO("Initial potential energy: " << initial_potential); INFO("Initial potential energy: " << initial_potential);
INFO("Initial total energy: " << initial_total_energy); INFO("Initial total energy: " << initial_total_energy);
SECTION("velocity matches escape velocity") { REQUIRE(initial_total_energy >= -1e25);
const double distance = vec3_distance(comet->global_position, sun->global_position);
const double escape_velocity = sqrt(2.0 * G * sun->mass / distance);
const double circular_velocity = sqrt(G * sun->mass / distance);
INFO("Distance: " << distance / AU << " AU");
INFO("Actual velocity: " << initial_velocity / 1000.0 << " km/s");
INFO("Escape velocity: " << escape_velocity / 1000.0 << " km/s");
INFO("Circular velocity: " << circular_velocity / 1000.0 << " km/s");
const double velocity_error = fabs(initial_velocity - escape_velocity) / escape_velocity;
INFO("Velocity error from escape velocity: " << velocity_error * 100.0 << "%");
REQUIRE_THAT(velocity_error, WithinAbs(0.0, V_TOL));
}
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)") { std::vector<double> distances;
// For a parabolic orbit, total energy should be zero. Due to std::vector<double> velocities;
// floating-point cancellation of two large terms (~8.87e22), the std::vector<double> energies;
// 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));
}
SECTION("initial velocity matches precalculated") { double max_time = DAYS_TO_SIMULATE * SECONDS_PER_DAY;
INFO("Initial velocity: " << initial_velocity << " m/s"); int step_count = 0;
REQUIRE_THAT(initial_velocity, WithinAbs(initial_expected_velocity, V_TOL)); 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 max_time = DAYS_TO_SIMULATE * SECONDS_PER_DAY;
while (sim->time < max_time) {
update_simulation(sim); update_simulation(sim);
step_count++;
} }
// Final state double final_distance = vec3_magnitude(sim->bodies[COMET_INDEX].global_position);
const double final_distance = vec3_magnitude(comet->global_position); double final_velocity = vec3_magnitude(sim->bodies[COMET_INDEX].global_velocity);
const double final_velocity = vec3_magnitude(comet->global_velocity);
const double final_kinetic = calculate_kinetic_energy(comet); double final_kinetic = calculate_kinetic_energy(&sim->bodies[COMET_INDEX]);
const double final_potential = calculate_potential_energy_pair(comet, sun); double final_potential = calculate_potential_energy_pair(&sim->bodies[COMET_INDEX],
const double final_total_energy = final_kinetic + final_potential; &sim->bodies[SUN_INDEX]);
double final_total_energy = final_kinetic + final_potential;
INFO("Final distance: " << final_distance / AU << " AU"); INFO("Final distance: " << final_distance / AU << " AU");
INFO("Final velocity: " << final_velocity / 1000.0 << " km/s"); INFO("Final velocity: " << final_velocity / 1000.0 << " km/s");
@ -95,24 +74,64 @@ SCENARIO("Parabolic orbit - escape trajectory and initial conditions",
INFO("Final potential energy: " << final_potential); INFO("Final potential energy: " << final_potential);
INFO("Final total energy: " << final_total_energy); INFO("Final total energy: " << final_total_energy);
SECTION("final distance matches escape trajectory") { REQUIRE(final_distance > initial_distance);
REQUIRE_THAT(final_distance, WithinAbs(expected_distance, R_TOL));
}
SECTION("final velocity matches escape trajectory") {
REQUIRE(final_velocity < initial_velocity); REQUIRE(final_velocity < initial_velocity);
REQUIRE_THAT(final_velocity, WithinAbs(final_expected_velocity, V_TOL));
}
SECTION("energy drift near zero") { double energy_drift = fabs(final_total_energy - initial_total_energy);
const double energy_drift = fabs(final_total_energy - initial_total_energy); double avg_kinetic_energy = (initial_kinetic + final_kinetic) / 2.0;
const double avg_kinetic = (initial_kinetic + final_kinetic) / 2.0; double energy_drift_percent = (energy_drift / avg_kinetic_energy) * 100.0;
const double drift_pct = (energy_drift / avg_kinetic) * 100.0;
INFO("Energy drift: " << energy_drift << " J"); INFO("Energy drift: " << energy_drift << " J");
INFO("Energy drift percent: " << drift_pct << "%"); INFO("Energy drift percent: " << energy_drift_percent << "%");
REQUIRE_THAT(drift_pct, WithinAbs(0.0, DRIFT_TOL));
REQUIRE(energy_drift_percent < 1.0);
int velocity_decreases = 0;
for (size_t i = 1; i < velocities.size(); i++) {
if (velocities[i] < velocities[i-1]) {
velocity_decreases++;
} }
}
INFO("Velocity decreases: " << velocity_decreases << " / " << (velocities.size() - 1));
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);
INFO("Eccentricity: " << comet->orbit.eccentricity);
REQUIRE(fabs(comet->orbit.eccentricity - 1.0) < 0.0001);
destroy_simulation(sim); destroy_simulation(sim);
} }

16
tests/test_parabolic_orbit.toml

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

333
tests/test_periapsis_burn.cpp

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

28
tests/test_periapsis_burn.toml

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

225
tests/test_physics_utilities.cpp

@ -1,225 +0,0 @@
#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));
}
}

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

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

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

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

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

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

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

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

145
tests/test_true_anomaly_roundtrip.cpp

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

Loading…
Cancel
Save