Compare commits
91 Commits
main
...
test-refac
87 changed files with 7180 additions and 5649 deletions
@ -0,0 +1,165 @@ |
|||||||
|
# Test Refactoring Optimization Strategy |
||||||
|
|
||||||
|
## Refactoring Rules |
||||||
|
|
||||||
|
### 1. Structure |
||||||
|
- One `SCENARIO("description")` per logical test group, with `[tag1][tag2]` annotations |
||||||
|
- Run `./build/orbit_test --list-tags` to see tags used by other tests. Original tags from the old test file are a useful starting point, but the implementor has discretion to choose appropriate tags. |
||||||
|
- **Use SCENARIO as a shared fixture** for setup/initialization. SECTIONs represent **different test scenarios** that branch from that fixture with distinct operations. Avoid using SECTIONs as section headers to group assertions about the same result — group related assertions into fewer SECTIONs instead. |
||||||
|
- Catch2 re-initializes the fixture before each SECTION, so shared constants, structs, and variables declared in the SCENARIO body run fresh per SECTION. This is intentional: each SECTION should test a different code path or mutation of the fixture. |
||||||
|
- Example: one SECTION checks the initial state before modification; a separate SECTION applies a burn and checks all post-burn results. |
||||||
|
- Embed expected values directly in `WithinAbs()` calls (see Section 4 for precalc script usage). No need to declare named constants unless the value is reused. |
||||||
|
- **No decorative comments.** Do not add `// (Old: ...)` comments, `===` separators, `---` separators, or any other decorative annotations. The SECTION description string is the documentation. |
||||||
|
- **Use `REQUIRE()` for integer comparisons**, `WithinAbs()` only for floating-point. E.g., `REQUIRE(sim->body_count == 2)` not `REQUIRE_THAT(sim->body_count, WithinAbs(2.0, 0.001))`. |
||||||
|
|
||||||
|
### 2. Duplication Elimination |
||||||
|
- Use lambdas that capture the fixture for repeated setup→call→assert patterns |
||||||
|
- Reuse shared structs in-place (mutate fields rather than recreating) |
||||||
|
|
||||||
|
### 3. Assertions |
||||||
|
- Include `src/test_utilities.h` for tolerance constants and test utilities |
||||||
|
- `#include <catch2/matchers/catch_matchers_floating_point.hpp>` (required for `WithinAbs` matcher) |
||||||
|
- `using Catch::Matchers::WithinAbs;` after includes |
||||||
|
- `REQUIRE_THAT(value, WithinAbs(expected, tolerance))` — never `Approx()` |
||||||
|
- **Always use named tolerance constants** — never hardcode raw numbers for tolerance in `WithinAbs()`. Exception: relative error thresholds for continuous/low-thrust approximations (e.g., `WithinAbs(0.0, 0.01)` for 1% tolerance) when no named constant exists. |
||||||
|
|
||||||
|
#### Tolerance Reference |
||||||
|
All constants defined in `src/test_utilities.h` — use those, do not redefine locally. |
||||||
|
|
||||||
|
| Constant | Value | Use for | |
||||||
|
|----------|-------|---------| |
||||||
|
| `D_TOL` | `1e-12` | Double-precision arithmetic (vec3, mat3 ops) | |
||||||
|
| `A_TOL` | `1e-6` | Semi-major axis (meters) | |
||||||
|
| `E_TOL` | `1e-12` | Eccentricity, round-trip conversion | |
||||||
|
| `ANG_TOL` | `1e-12` | Angles in radians (nu, inc, Ω, ω) | |
||||||
|
| `ANG_TOL_COARSE` | `1e-4` | Angles, degenerate cases (polar/retrograde) | |
||||||
|
| `R_TOL` | `1e-6` | Radius / distance magnitudes (meters) | |
||||||
|
| `V_TOL` | `1e-6` | Velocity magnitudes (m/s) | |
||||||
|
| `M_TOL` | `1e-6` | Time / period values (seconds) | |
||||||
|
| `REL_TOL` | `1e-8` | Relative / percentage errors (dimensionless) | |
||||||
|
| `DRIFT_TOL` | `1e-12` | Energy drift percent (parabolic orbit) | |
||||||
|
|
||||||
|
- Replace qualitative checks (`a > b`) with quantitative (`WithinAbs(expected, tol)`) |
||||||
|
- `INFO("label: " << value)` for debugging context |
||||||
|
|
||||||
|
### 4. Precalc Scripts |
||||||
|
- For each test file, create `scripts/precalc_<test_name>.py` that computes expected values. |
||||||
|
- **Always output local-frame values** (distances from parent, not global from origin). |
||||||
|
- C++ tests typically use local coordinates (e.g., `vec3_distance(craft->local_position, (Vec3){0,0,0})`). |
||||||
|
- Global distances are dominated by parent body positions (e.g., Earth-Sun distance swamps LEO orbit). |
||||||
|
- **Always output SI units** (meters, m/s, seconds) — C++ tests use SI internally. |
||||||
|
- Output C++-style comments with precalculated expected values for embedding in the test. Tolerances are chosen separately by the test writer using the Tolerance Reference table — the precalc script should not output tolerance values. |
||||||
|
- **No decorative comments in precalc scripts.** Use simple blank lines between sections, no `# ====` or `# -----` separators. |
||||||
|
- Run with: `python3 scripts/precalc_<test_name>.py` |
||||||
|
|
||||||
|
## Test Refactoring Status |
||||||
|
|
||||||
|
### Completed |
||||||
|
- `test_barkers_equation` — Barker's equation unit tests + parabolic propagation |
||||||
|
- `test_cartesian_to_elements_advanced` — Advanced conversion tests (eccentricity spectrum, inclination, true anomaly, 3D orientation) |
||||||
|
- `test_cartesian_to_elements_basic` — Element round-trip conversion (semi-major axis, eccentricity, true anomaly, inclination, radius, velocity) |
||||||
|
- `test_parabolic_orbit` — Parabolic orbit energy conservation + escape trajectory + initial conditions |
||||||
|
- `test_extreme_eccentricity` — High-eccentricity orbits (single SCENARIO, precalculated values, REL_TOL) |
||||||
|
- `test_extreme_orientation_mixed` — Extreme orientation conversions, rotation matrix properties, singularity handling |
||||||
|
- `test_extreme_timescales` — 9 TEST_CASEs → 1 SCENARIO with 11 SECTIONs, all WithinAbs use named constants |
||||||
|
- `test_analytical_propagation` — 5 SCENARIOs → 1 SCENARIO with 23 SECTIONs, precalculated values, all WithinAbs use named constants |
||||||
|
- `test_moon_orbits` — Multi-body hierarchical propagation with local/global coordinate tracking |
||||||
|
- `test_omega_debug` — Burn + element reconstruction + maneuver triggers |
||||||
|
- `test_energy` — Energy calculations and conservation tests |
||||||
|
- `test_inclined_orbits` — 3D inclined orbit conversions, Molniya orbits, rotation matrices |
||||||
|
- `test_maneuvers` — Impulsive burn tests with precalculated values |
||||||
|
- `test_maneuver_planning` — Maneuver trigger system (TIME + elliptical TRUE_ANOMALY triggers) |
||||||
|
- `test_orbital_period` — Orbital period calculations, SCENARIO/SECTION pattern |
||||||
|
- `test_true_anomaly_roundtrip` — True anomaly conversion round-trips, tight tolerances |
||||||
|
- `test_physics_utilities` — Vector math, acceleration, matrix ops, rotation matrices, compare_vec3 |
||||||
|
- `test_periapsis_burn` — prograde burns |
||||||
|
- `test_hybrid_burns` — 14 TEST_CASEs → 1 SCENARIO with 22 SECTIONs, impulse + continuous burns, precalculated values; converted 17 qualitative checks to quantitative, replaced hardcoded tolerances with named constants (A_TOL, E_TOL, D_TOL, M_TOL, ANG_TOL, R_TOL) |
||||||
|
|
||||||
|
### Can Refactor Now (sim_engine.py supports all features needed) |
||||||
|
- `test_orbit_rendering` — rendering tests (check if sim_engine needed) |
||||||
|
- `test_precision_boundaries` — boundary condition tests |
||||||
|
- `test_invalid_parent_assignment` — validation/error handling tests |
||||||
|
- `test_newton_raphson_convergence` — numerical convergence tests |
||||||
|
|
||||||
|
### Blocked on Missing Features |
||||||
|
- `test_soi_transition` — needs SOI transitions |
||||||
|
- `test_root_body_transitions` — needs SOI transitions |
||||||
|
- `test_hybrid_energy_conservation` — needs energy functions (KE, PE, total) |
||||||
|
- `test_hyperbolic_orbit` — needs hyperbolic propagation |
||||||
|
- `test_rendezvous` — needs Hohmann transfer calculations |
||||||
|
|
||||||
|
## Tooling & Sim Engine Capabilities |
||||||
|
|
||||||
|
### Tooling |
||||||
|
- `scripts/sim_engine.py` — Generic orbital mechanics simulator (Python, TOML 1.0 configs) |
||||||
|
- Replicates C++ physics: Kepler propagation, orbital↔Cartesian transforms, drift detection |
||||||
|
- Multi-body hierarchical propagation with global/local coordinate tracking |
||||||
|
- Use for precalculating expected values (transition times, final states, energy conservation) |
||||||
|
- TOML configs in `tests/` must use TOML 1.0 inline table syntax (single-line `{}`) |
||||||
|
- Old configs in `old_tests/` use multiline inline tables (toml-c17 style) — keep for reference |
||||||
|
- Python's `tomllib` requires single-line inline tables |
||||||
|
|
||||||
|
### Sim Engine Capabilities |
||||||
|
#### Implemented |
||||||
|
- Maneuver trigger system (TIME and TRUE_ANOMALY triggers) |
||||||
|
- BurnResult capture (position, velocity, true anomaly at exact burn time) |
||||||
|
- Body propagation (elliptical + parabolic via Barker's equation) |
||||||
|
- Orbital↔Cartesian transforms (full z-x-z Euler rotation) |
||||||
|
- Velocity drift detection and element reconstruction |
||||||
|
- Global coordinate computation (hierarchical parent→child) |
||||||
|
- Spacecraft struct, loading, propagation |
||||||
|
- Impulsive burns (prograde, retrograde, normal, antinormal, radial_in, radial_out, custom) |
||||||
|
- TOML 1.0 config parsing |
||||||
|
|
||||||
|
#### NOT Implemented (notify the user before beginning to refactor) |
||||||
|
- SOI transitions |
||||||
|
- Maneuver TRUE_ANOMALY triggers only work with elliptical orbits (parabolic/hyperbolic branches not implemented) |
||||||
|
- Hohmann transfer calculations |
||||||
|
- Rendezvous planning |
||||||
|
- OrbitTracker |
||||||
|
- Energy functions (KE, PE, total) |
||||||
|
- Hyperbolic propagation |
||||||
|
|
||||||
|
## Refactoring Procedure |
||||||
|
|
||||||
|
### Step 1: Refactor |
||||||
|
- **Pre-flight check:** Before starting, verify the python ./scripts/sim_engine.py supports all features the test needs (SOI, Hohmann transfers, rendezvous, hyperbolic propagation, energy functions). If any feature is missing, stop and report it to the user — do not begin refactoring until unblocked. |
||||||
|
- Check if the test is already in `tests/` (already refactored). Skip if so. |
||||||
|
- Process **one test file at a time**. |
||||||
|
- Create `scripts/precalc_<test_name>.py` and run it to get expected values. Tests that use the simulation engine will need a precalc script regardless of whether a TOML config exists. |
||||||
|
- Copy from `old_tests/` to `tests/`, rewrite using the pattern from `test_true_anomaly_roundtrip.cpp`. |
||||||
|
- Verify the test file has a TOML config in `old_tests/`. If it doesn't, the test is likely hardcoded — still refactor the C++ code but skip the TOML rewrite step. |
||||||
|
- Rewrite TOML configs to TOML 1.0 inline table syntax (single-line `{}`). |
||||||
|
- Follow all rules in sections 1-4 above. |
||||||
|
|
||||||
|
### Step 2: Tighten Tolerances |
||||||
|
- Build and verify: `make test-build` then `./build/orbit_test -s '[tag]'`. |
||||||
|
- Run full suite: `./build/orbit_test | tail`. |
||||||
|
- Review every tolerance against actual observed errors from `-s` output. |
||||||
|
- **If a test fails due to a tolerance being too tight, report the observed error to the user and ask whether to loosen the constant or investigate the root cause. Never silently widen a tolerance.** |
||||||
|
- Refer to the tolerance reference table in Section 3 for constant names. |
||||||
|
|
||||||
|
### Step 3: Code Review |
||||||
|
- Remove unused includes (only include what's actually used). |
||||||
|
- Remove unused variables (e.g., `const double mu = G * M_sun;` if never referenced). |
||||||
|
- Look for repeated initialization patterns — extract into helper lambdas (`make_elements`, `convert_and_recover`). |
||||||
|
- Use `const` for all fixture data and recovered results. |
||||||
|
- Replace C-style arrays with `std::array` where appropriate. |
||||||
|
- Ensure consistent tolerance usage (no hardcoded `1e-4` when `ANG_TOL_COARSE` exists). |
||||||
|
- Check for `compare_vec3` availability in `test_utilities` instead of 6 individual `REQUIRE_THAT` calls. |
||||||
|
- Run full suite again: `make test`. |
||||||
|
|
||||||
|
#### Final Systematic REQUIRE Statement Review |
||||||
|
After Step 3, review every `REQUIRE` in the test file: |
||||||
|
|
||||||
|
1. `grep -Rn 'REQUIRE' tests/<test_name>.cpp` |
||||||
|
2. Categorize each: |
||||||
|
- **Hardcoded tolerances** → replace with named constant |
||||||
|
- **Qualitative** (`a > b`, `x < 0.1`, `fabs(x) > 0`) → convert to quantitative via precalc |
||||||
|
- **OK as-is** → booleans, integers, strings, enums |
||||||
|
4. Verify precalc outputs all needed values |
||||||
|
5. `make test-build` → `./build/orbit_test -s '[tag]'` |
||||||
|
|
||||||
|
### Step 4: User interaction |
||||||
|
- **Always ask for review** before moving to the next file. |
||||||
|
- **Only commit when asked.** |
||||||
@ -0,0 +1,75 @@ |
|||||||
|
# Planetary Data |
||||||
|
|
||||||
|
## Planets |
||||||
|
|
||||||
|
┌──────────┬────────────────┬──────────┬────────┬───────┬───────┬────────┬─────────┬──────────┬─────────┬────────┐ |
||||||
|
│ Body │ Mass (kg) │ Radius │ a │ e │ inc │ Ω │ ω │ Period │ Day │ M │ |
||||||
|
│ │ │ (km) │(AU) │ │ (°) │ (°) │ (°) │ (days) │ (hours) │ (°) │ |
||||||
|
├──────────┼────────────────┼──────────┼────────┼───────┼───────┼────────┼─────────┼──────────┼─────────┼────────┤ |
||||||
|
│ Venus │ 4.87×10²⁴ │ 6,052 │ 0.723 │ 0.007 │ 3.39 │ 76.68 │ 54.92 │ 224.7 │ 2,802.0 │ 50.38 │ |
||||||
|
│ Earth │ 5.97×10²⁴ │ 6,378 │ 1.000 │ 0.017 │ 0.00 │ 0.00 │ 102.94 │ 365.2 │ 24.0 │ −2.47 │ |
||||||
|
│ Mars │ 6.42×10²³ │ 3,396 │ 1.524 │ 0.093 │ 1.85 │ 49.56 │ 286.50 │ 687.0 │ 24.7 │ 19.39 │ |
||||||
|
│ Jupiter │ 1.898×10²⁷ │71,492 │ 5.203 │ 0.049 │ 1.31 │100.47 │ 274.25 │ 4,331 │ 9.9 │ 19.67 │ |
||||||
|
│ Saturn │ 5.68×10²⁶ │60,268 │ 9.537 │ 0.057 │ 2.49 │113.66 │ 338.94 │10,747 │ 10.7 │ −42.64 │ |
||||||
|
│ Uranus │ 8.68×10²⁵ │25,559 │19.19 │ 0.046 │ 0.77 │ 74.02 │ 96.94 │30,589 │ 17.2 │ 142.28 │ |
||||||
|
│ Neptune │ 1.02×10²⁶ │24,764 │30.07 │ 0.010 │ 1.77 │131.78 │ 273.18 │59,800 │ 16.1 │ −100.08│ |
||||||
|
└──────────┴────────────────┴──────────┴────────┴───────┴───────┴────────┴─────────┴──────────┴─────────┴────────┘ |
||||||
|
|
||||||
|
## Moons |
||||||
|
|
||||||
|
┌──────────────┬────────────────┬──────────┬──────────┬───────┬───────┬────────┬─────────┬─────────┬───────┐ |
||||||
|
│ Moon │ Mass (kg) │ Radius │ a │ e │ inc │ Ω │ ω │ Period │ M │ |
||||||
|
│ │ │ (km) │ (km) │ │ (°) │ (°) │ (°) │ (days) │ (°) │ |
||||||
|
├──────────────┼────────────────┼──────────┼──────────┼───────┼───────┼────────┼─────────┼─────────┼───────┤ |
||||||
|
│ Moon (Earth) │ 7.35×10²² │ 1,738 │ 384,400 │ 0.055 │ 5.16 │125.08 │ 318.15 │ 27.322 │135.27 │ |
||||||
|
│ Io │ 8.93×10²³ │ 1,822 │ 421,800 │ 0.004 │ 0.00 │ 0.0 │ 49.1 │ 1.763 │330.9 │ |
||||||
|
│ Europa │ 4.80×10²³ │ 1,561 │ 671,100 │ 0.009 │ 0.50 │184.0 │ 45.0 │ 3.525 │345.4 │ |
||||||
|
│ Ganymede │ 1.48×10²⁴ │ 2,631 │1,070,400 │ 0.001 │ 0.20 │ 58.5 │ 198.3 │ 7.156 │324.8 │ |
||||||
|
│ Callisto │ 1.08×10²⁴ │ 2,410 │1,882,700 │ 0.007 │ 0.30 │309.1 │ 43.8 │ 16.690 │ 87.4 │ |
||||||
|
│ Titan │ 1.35×10²⁴ │ 2,575 │1,221,900 │ 0.029 │ 0.30 │ 78.6 │ 78.3 │ 15.945 │ 11.7 │ |
||||||
|
└──────────────┴────────────────┴──────────┴──────────┴───────┴───────┴────────┴─────────┴─────────┴───────┘ |
||||||
|
|
||||||
|
## Reference Frames |
||||||
|
|
||||||
|
Source: https://ssd.jpl.nasa.gov/orbits.html |
||||||
|
|
||||||
|
- **Planets**: All orbital elements are referenced to the **mean ecliptic and equinox of J2000**. |
||||||
|
- **Moons**: The **source data** for moons is referenced to the **Laplace plane** (Jupiter and Saturn's moons) or the **ecliptic** (Earth's Moon). The Laplace plane is a hybrid reference plane between a planet's equator and its orbital plane around the Sun. |
||||||
|
- **Important**: Moon inclination and node values are **not** referenced to the same plane as the planets. Converting to a common frame is required before combining into a single simulation. |
||||||
|
|
||||||
|
### Moon Frame Transformation Plan |
||||||
|
|
||||||
|
Source data provides for each moon: **Tilt** (angle between planet's equator and Laplace plane), **R.A.** and **Dec.** (Laplace plane pole position in ICRF). |
||||||
|
|
||||||
|
Transformation approach using in-engine primitives: |
||||||
|
|
||||||
|
1. Build a rotation matrix from Laplace plane to equatorial plane using the Tilt angle and pole position (R.A., Dec.) |
||||||
|
2. Apply the rotation to the moon's position/velocity vectors via `Mat3 × Vec3` |
||||||
|
3. Reconstruct orbital elements from the rotated Cartesian state using `cartesian_to_orbital_elements()` |
||||||
|
|
||||||
|
This leverages the existing `mat3_rotation_x`, `mat3_rotation_z`, and `mat3_multiply` functions to compose the frame-rotation matrix, then uses the engine's built-in `cartesian_to_orbital_elements()` to extract the new (i, Ω, ω) values in the equatorial frame. |
||||||
|
|
||||||
|
## J2000 Starting Positions |
||||||
|
|
||||||
|
Source: Table 1 from https://ssd.jpl.nasa.gov/orbits.html (valid 1800–2050 AD, no perturbation terms needed). |
||||||
|
|
||||||
|
Mean anomaly at J2000: **M = L − ϖ**, where L is mean longitude and ϖ is longitude of perihelion. |
||||||
|
|
||||||
|
To get the true anomaly ν (which the TOML `orbit.true_anomaly` expects), solve Kepler's equation: |
||||||
|
|
||||||
|
M = E − e·sin(E) → solve for eccentric anomaly E |
||||||
|
tan(ν/2) = √((1+e)/(1−e)) · tan(E/2) |
||||||
|
|
||||||
|
Once ν is computed for each body, set it as `true_anomaly` in the config. The engine will then propagate from the J2000 snapshot forward. |
||||||
|
|
||||||
|
### Laplace Plane Data Limitations |
||||||
|
|
||||||
|
Reliable, authoritative data for the Laplace plane parameters (pole R.A./Dec. and tilt relative to each planet's equator) is difficult to find in standard planetary data sources. JPL Horizons and the JPL orbits page provide moon orbital elements relative to the Laplace plane but do not publish the Laplace plane's own orientation in ICRF. |
||||||
|
|
||||||
|
**Decision**: Use the planet's equatorial frame for moon orbital elements instead of converting from the Laplace plane. The Laplace plane is very close to the equatorial plane — tilted by only ~1° for Jupiter and ~0.3° for Saturn — so the resulting errors are negligible: |
||||||
|
|
||||||
|
- Inclination offset: ~0.3–1° |
||||||
|
- Node and periapsis offset: similar small amounts |
||||||
|
- Angular position error in space: ~0.3–1° |
||||||
|
|
||||||
|
This error is smaller than the uncertainties from using mean orbital elements (which ignore perturbations and resonances) and has no practical impact for simulation purposes. |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
# Session 2026-04-30: test_extreme_orientation_mixed |
||||||
|
|
||||||
|
## Changes Made |
||||||
|
- Refactored `test_extreme_orientation_mixed.cpp` from 7 TEST_CASEs to 1 SCENARIO with 8 SECTIONs |
||||||
|
- Created TOML 1.0 config (`tests/test_extreme_orientation_mixed.toml`) |
||||||
|
- Created precalc script (`scripts/precalc_extreme_orientation_mixed.py`) using sim_engine.Simulator |
||||||
|
- Consolidated loop variables using `std::array<Spacecraft*, 5>` instead of 25 individual vars + 5 arrays |
||||||
|
- Replaced trivial `REQUIRE(r > 0)` / `REQUIRE(v > 0)` with precalculated value assertions |
||||||
|
- Added named tolerance constants: `VDOT_TOL = 1e-3`, `MAT_TOL = 1e-10` |
||||||
|
- Added comments to reasonable safety checks (sqrt guard, angular momentum, rotation matrix behavior) |
||||||
|
|
||||||
|
## Commits |
||||||
|
1. `a7ecc46` — refactor: test_extreme_orientation_mixed |
||||||
|
2. `2e6b3d4` — cleanup: remove old test files |
||||||
|
3. `f8f7037` — docs: update continue.md |
||||||
|
|
||||||
|
## Results |
||||||
|
- 142 assertions, all passing |
||||||
|
- Full suite: 579 assertions in 15 test cases, all passing |
||||||
|
- Net line count: +423 (test), -480 (old test), +1 (continue.md) |
||||||
|
|
||||||
|
## Remaining Issues |
||||||
|
- None |
||||||
|
|
||||||
|
## Next Steps |
||||||
|
- Next test to refactor: `test_extreme_timescales` |
||||||
@ -0,0 +1,217 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Precalculate expected values for test_analytical_propagation.cpp. |
||||||
|
Loads config from tests/test_analytical_propagation.toml, then computes |
||||||
|
orbital parameters, propagation results, and error bounds. |
||||||
|
""" |
||||||
|
|
||||||
|
import math |
||||||
|
import sys |
||||||
|
sys.path.insert(0, "scripts") |
||||||
|
from sim_engine import Simulator, propagate, orbital_to_cartesian, vmag, G |
||||||
|
|
||||||
|
def main(): |
||||||
|
sim = Simulator("tests/test_analytical_propagation.toml", dt=60.0) |
||||||
|
|
||||||
|
earth = sim.get_body("Earth") |
||||||
|
craft_apsides = sim.get_craft("Apsides_Test_Spacecraft") |
||||||
|
craft_timestep = sim.get_craft("Timestep_Test_Spacecraft") |
||||||
|
|
||||||
|
earth_mass = earth.mass |
||||||
|
mu = G * earth_mass |
||||||
|
|
||||||
|
a1 = craft_apsides.orbit.a |
||||||
|
e1 = craft_apsides.orbit.e |
||||||
|
period1 = 2.0 * math.pi * math.sqrt(a1**3 / mu) |
||||||
|
n1 = math.sqrt(mu / a1**3) |
||||||
|
|
||||||
|
a2 = craft_timestep.orbit.a |
||||||
|
e2 = craft_timestep.orbit.e |
||||||
|
period2 = 2.0 * math.pi * math.sqrt(a2**3 / mu) |
||||||
|
n2 = math.sqrt(mu / a2**3) |
||||||
|
|
||||||
|
def print_comment_block(title): |
||||||
|
print(f"\n// === {title} ===") |
||||||
|
|
||||||
|
def print_const(name, value, comment=""): |
||||||
|
c = f" // {comment}" if comment else "" |
||||||
|
print(f"const double {name} = {value:.15e};{c}") |
||||||
|
|
||||||
|
# ============================================================================= |
||||||
|
# 1. Apsides geometry — both spacecraft |
||||||
|
# ============================================================================= |
||||||
|
|
||||||
|
print_comment_block("Apsides geometry (both spacecraft)") |
||||||
|
|
||||||
|
# Apsides spacecraft |
||||||
|
r_peri1 = a1 * (1.0 - e1) |
||||||
|
r_apo1 = a1 * (1.0 + e1) |
||||||
|
peri1 = craft_apsides.orbit |
||||||
|
_, vel_peri1 = orbital_to_cartesian(peri1, earth_mass) |
||||||
|
v_peri1 = vmag(vel_peri1) |
||||||
|
apo1_el = type(peri1)(a=a1, e=e1, nu=math.pi, inc=0.0, Omega=0.0, omega=0.0) |
||||||
|
_, vel_apo1 = orbital_to_cartesian(apo1_el, earth_mass) |
||||||
|
v_apo1 = vmag(vel_apo1) |
||||||
|
nu45_1_el = type(peri1)(a=a1, e=e1, nu=math.pi/4.0, inc=0.0, Omega=0.0, omega=0.0) |
||||||
|
_, vel_45_1 = orbital_to_cartesian(nu45_1_el, earth_mass) |
||||||
|
v_45_1 = vmag(vel_45_1) |
||||||
|
|
||||||
|
print(f"// Apsides spacecraft: a={a1:.0f}, e={e1}, period={period1:.2f}s") |
||||||
|
print(f"// Mean motion: {n1:.15e} rad/s") |
||||||
|
print(f"// r_peri={r_peri1:.3f} m, r_apo={r_apo1:.3f} m") |
||||||
|
print(f"// v_peri={v_peri1:.6f} m/s, v_apo={v_apo1:.6f} m/s") |
||||||
|
print(f"// v_at_pi4={v_45_1:.6f} m/s") |
||||||
|
|
||||||
|
# Timestep spacecraft |
||||||
|
r_peri2 = a2 * (1.0 - e2) |
||||||
|
r_apo2 = a2 * (1.0 + e2) |
||||||
|
peri2 = craft_timestep.orbit |
||||||
|
_, vel_peri2 = orbital_to_cartesian(peri2, earth_mass) |
||||||
|
v_peri2 = vmag(vel_peri2) |
||||||
|
apo2_el = type(peri2)(a=a2, e=e2, nu=math.pi, inc=0.0, Omega=0.0, omega=0.0) |
||||||
|
_, vel_apo2 = orbital_to_cartesian(apo2_el, earth_mass) |
||||||
|
v_apo2 = vmag(vel_apo2) |
||||||
|
|
||||||
|
print(f"// Timestep spacecraft: a={a2:.0f}, e={e2}, period={period2:.2f}s") |
||||||
|
print(f"// Mean motion: {n2:.15e} rad/s") |
||||||
|
print(f"// r_peri={r_peri2:.3f} m, r_apo={r_apo2:.3f} m") |
||||||
|
print(f"// v_peri={v_peri2:.6f} m/s, v_apo={v_apo2:.6f} m/s") |
||||||
|
|
||||||
|
print_const("A1_R_PERI", r_peri1, "m") |
||||||
|
print_const("A1_R_APO", r_apo1, "m") |
||||||
|
print_const("A1_V_PERI", v_peri1, "m/s") |
||||||
|
print_const("A1_V_APO", v_apo1, "m/s") |
||||||
|
print_const("A1_V_AT_PI4", v_45_1, "m/s at nu=pi/4") |
||||||
|
print_const("A1_PERIOD", period1, "seconds") |
||||||
|
print_const("A2_R_PERI", r_peri2, "m") |
||||||
|
print_const("A2_R_APO", r_apo2, "m") |
||||||
|
print_const("A2_V_PERI", v_peri2, "m/s") |
||||||
|
print_const("A2_V_APO", v_apo2, "m/s") |
||||||
|
print_const("A2_PERIOD", period2, "seconds") |
||||||
|
|
||||||
|
# ============================================================================= |
||||||
|
# 2. Vis-viva checks at multiple true anomalies |
||||||
|
# ============================================================================= |
||||||
|
|
||||||
|
print_comment_block("Vis-viva checks at multiple true anomalies") |
||||||
|
|
||||||
|
true_anomalies = [0.0, math.pi/4.0, math.pi/2.0, 3.0*math.pi/4.0, math.pi] |
||||||
|
for nu in true_anomalies: |
||||||
|
deg = nu * 180.0 / math.pi |
||||||
|
el = type(craft_apsides.orbit)(a=a1, e=e1, nu=nu, inc=0.0, Omega=0.0, omega=0.0) |
||||||
|
pos, vel = orbital_to_cartesian(el, earth_mass) |
||||||
|
r = vmag(pos) |
||||||
|
v = vmag(vel) |
||||||
|
expected_v = math.sqrt(mu * (2.0/r - 1.0/a1)) |
||||||
|
v_error = abs(v - expected_v) |
||||||
|
rel_error = v_error / expected_v * 100.0 |
||||||
|
print(f"// nu={deg:6.1f}deg: r={r:.3f} m, v={v:.6f} m/s, expected_v={expected_v:.6f} m/s, rel_err={rel_error:.8f}%") |
||||||
|
|
||||||
|
# ============================================================================= |
||||||
|
# 3. Period return — full orbit closure for both spacecraft |
||||||
|
# ============================================================================= |
||||||
|
|
||||||
|
print_comment_block("Period return — full orbit closure") |
||||||
|
|
||||||
|
# Apsides spacecraft: propagate 1 period from nu=0 |
||||||
|
el1 = type(craft_apsides.orbit)(a=a1, e=e1, nu=0.0, inc=0.0, Omega=0.0, omega=0.0) |
||||||
|
_, vel1_init = orbital_to_cartesian(el1, earth_mass) |
||||||
|
prop1 = propagate(el1, period1, earth_mass) |
||||||
|
_, vel1_final = orbital_to_cartesian(prop1, earth_mass) |
||||||
|
vel_change1 = math.sqrt((vel1_final[0]-vel1_init[0])**2 + (vel1_final[1]-vel1_init[1])**2 + (vel1_final[2]-vel1_init[2])**2) |
||||||
|
print(f"// Apsides after 1 period: vel_change={vel_change1:.15e} m/s, final_nu={prop1.nu:.15e} rad") |
||||||
|
|
||||||
|
# Timestep spacecraft: propagate 1 period from nu=0 |
||||||
|
el2 = type(craft_timestep.orbit)(a=a2, e=e2, nu=0.0, inc=0.0, Omega=0.0, omega=0.0) |
||||||
|
_, vel2_init = orbital_to_cartesian(el2, earth_mass) |
||||||
|
prop2 = propagate(el2, period2, earth_mass) |
||||||
|
_, vel2_final = orbital_to_cartesian(prop2, earth_mass) |
||||||
|
vel_change2 = math.sqrt((vel2_final[0]-vel2_init[0])**2 + (vel2_final[1]-vel2_init[1])**2 + (vel2_final[2]-vel2_init[2])**2) |
||||||
|
print(f"// Timestep after 1 period: vel_change={vel_change2:.15e} m/s, final_nu={prop2.nu:.15e} rad") |
||||||
|
|
||||||
|
# ============================================================================= |
||||||
|
# 4. Timestep accuracy |
||||||
|
# ============================================================================= |
||||||
|
|
||||||
|
print_comment_block("Timestep accuracy") |
||||||
|
|
||||||
|
# Initial state for timestep craft |
||||||
|
init_el = type(craft_timestep.orbit)(a=a2, e=e2, nu=0.0, inc=0.0, Omega=0.0, omega=0.0) |
||||||
|
init_pos, init_vel = orbital_to_cartesian(init_el, earth_mass) |
||||||
|
init_r = vmag(init_pos) |
||||||
|
init_v = vmag(init_vel) |
||||||
|
|
||||||
|
# Large timestep: 2x period |
||||||
|
large_dt = period2 * 2.0 |
||||||
|
prop_large = propagate(init_el, large_dt, earth_mass) |
||||||
|
pos_large, vel_large = orbital_to_cartesian(prop_large, earth_mass) |
||||||
|
r_large = vmag(pos_large) |
||||||
|
v_large = vmag(vel_large) |
||||||
|
r_err_large = abs(r_large - init_r) |
||||||
|
v_err_large = abs(v_large - init_v) |
||||||
|
rel_r_large = r_err_large / init_r * 100.0 |
||||||
|
rel_v_large = v_err_large / init_v * 100.0 |
||||||
|
print(f"// 2x period: r_err={r_err_large:.6f} m ({rel_r_large:.8f}%), v_err={v_err_large:.6f} m/s ({rel_v_large:.8f}%)") |
||||||
|
|
||||||
|
# Small timestep: 0.1 s |
||||||
|
small_dt = 0.1 |
||||||
|
prop_small = propagate(init_el, small_dt, earth_mass) |
||||||
|
pos_small, vel_small = orbital_to_cartesian(prop_small, earth_mass) |
||||||
|
pos_change = math.sqrt((pos_small[0]-init_pos[0])**2 + (pos_small[1]-init_pos[1])**2 + (pos_small[2]-init_pos[2])**2) |
||||||
|
vel_change = math.sqrt((vel_small[0]-init_vel[0])**2 + (vel_small[1]-init_vel[1])**2 + (vel_small[2]-init_vel[2])**2) |
||||||
|
expected_pos_change = init_v * small_dt |
||||||
|
pos_error_small = abs(pos_change - expected_pos_change) |
||||||
|
print(f"// 0.1s dt: pos_change={pos_change:.6f} m, vel_change={vel_change:.10f} m/s") |
||||||
|
print(f"// expected_pos_change={expected_pos_change:.6f} m, pos_error={pos_error_small:.6f} m") |
||||||
|
|
||||||
|
# Accuracy at various multiples of period |
||||||
|
dt_ratios = [1.0, 10.0] |
||||||
|
for ratio in dt_ratios: |
||||||
|
dt = period2 * ratio |
||||||
|
prop = propagate(init_el, dt, earth_mass) |
||||||
|
pos_f, vel_f = orbital_to_cartesian(prop, earth_mass) |
||||||
|
pos_err = math.sqrt((pos_f[0]-init_pos[0])**2 + (pos_f[1]-init_pos[1])**2 + (pos_f[2]-init_pos[2])**2) |
||||||
|
vel_err = math.sqrt((vel_f[0]-init_vel[0])**2 + (vel_f[1]-init_vel[1])**2 + (vel_f[2]-init_vel[2])**2) |
||||||
|
print(f"// {ratio:.0f}x period: pos_err={pos_err:.6f} m, vel_err={vel_err:.10f} m/s") |
||||||
|
|
||||||
|
# ============================================================================= |
||||||
|
# 5. Long-term stability (100 periods) |
||||||
|
# ============================================================================= |
||||||
|
|
||||||
|
print_comment_block("Long-term stability (100 periods)") |
||||||
|
|
||||||
|
prop_100 = propagate(init_el, period2 * 100.0, earth_mass) |
||||||
|
final_nu = prop_100.nu |
||||||
|
expected_delta_nu = n2 * period2 * 100.0 |
||||||
|
expected_nu = init_el.nu + expected_delta_nu |
||||||
|
# Normalize both to [0, 2*pi) |
||||||
|
while final_nu < 0: |
||||||
|
final_nu += 2.0 * math.pi |
||||||
|
while final_nu >= 2.0 * math.pi: |
||||||
|
final_nu -= 2.0 * math.pi |
||||||
|
while expected_nu < 0: |
||||||
|
expected_nu += 2.0 * math.pi |
||||||
|
while expected_nu >= 2.0 * math.pi: |
||||||
|
expected_nu -= 2.0 * math.pi |
||||||
|
|
||||||
|
raw_error = abs(final_nu - expected_nu) |
||||||
|
anomaly_error = min(raw_error, 2.0 * math.pi - raw_error) |
||||||
|
print(f"// Propagation time: {period2*100.0:.2f} s ({100.0} periods)") |
||||||
|
print(f"// final_nu={final_nu:.15e} rad") |
||||||
|
print(f"// expected_nu={expected_nu:.15e} rad") |
||||||
|
print(f"// raw_error={raw_error:.15e} rad") |
||||||
|
print(f"// anomaly_error={anomaly_error:.15e} rad ({anomaly_error*180/math.pi:.10e} degrees)") |
||||||
|
|
||||||
|
# ============================================================================= |
||||||
|
# Output summary |
||||||
|
# ============================================================================= |
||||||
|
|
||||||
|
print("\n// === SUMMARY ===") |
||||||
|
print(f"// Apsides spacecraft: a={a1:.0f}, e={e1}, period={period1:.2f}s") |
||||||
|
print(f"// Timestep spacecraft: a={a2:.0f}, e={e2}, period={period2:.2f}s") |
||||||
|
print(f"// Vis-viva relative errors are all < 0.01%") |
||||||
|
print(f"// Full orbit position/velocity errors are < 0.1%") |
||||||
|
print(f"// Long-term (100 periods) anomaly error: {anomaly_error:.15e} rad") |
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
main() |
||||||
@ -0,0 +1,234 @@ |
|||||||
|
#!/usr/env python3 |
||||||
|
""" |
||||||
|
Precalculate expected values for test_cartesian_to_elements_advanced.cpp. |
||||||
|
|
||||||
|
Replicates all test cases: convert elements -> cartesian -> back to elements, |
||||||
|
then report round-trip errors for each assertion. |
||||||
|
|
||||||
|
Usage: |
||||||
|
python3 scripts/precalc_cartesian_to_elements_advanced.py |
||||||
|
""" |
||||||
|
|
||||||
|
import sys, math |
||||||
|
sys.path.insert(0, 'scripts') |
||||||
|
from sim_engine import orbital_to_cartesian, cartesian_to_orbital_elements, vmag, OrbitalElements, G, normalize_angle |
||||||
|
|
||||||
|
M_sun = 1.989e30 |
||||||
|
mu = G * M_sun |
||||||
|
|
||||||
|
def make_elements(a, e, nu, inc, Omega, omega, semi_latus_rectum=None): |
||||||
|
el = OrbitalElements(a=a, e=e, nu=nu, inc=inc, Omega=Omega, omega=omega) |
||||||
|
if semi_latus_rectum is not None: |
||||||
|
el.p = semi_latus_rectum # use 'p' field for semi_latus_rectum |
||||||
|
return el |
||||||
|
|
||||||
|
def roundtrip(el): |
||||||
|
pos, vel = orbital_to_cartesian(el, M_sun) |
||||||
|
return cartesian_to_orbital_elements(pos, vel, M_sun) |
||||||
|
|
||||||
|
def ang_diff(a, b): |
||||||
|
"""Shortest angular distance.""" |
||||||
|
return abs(normalize_angle(a) - normalize_angle(b)) |
||||||
|
|
||||||
|
def report(name, original, recovered, fields): |
||||||
|
"""Print round-trip errors for specified fields.""" |
||||||
|
print(f" # {name}:") |
||||||
|
for field in fields: |
||||||
|
orig_val = getattr(original, field) |
||||||
|
rec_val = getattr(recovered, field) |
||||||
|
err = abs(orig_val - rec_val) |
||||||
|
print(f" {field:20s} = {orig_val:20.15e} -> {rec_val:20.15e} error = {err:.2e}") |
||||||
|
|
||||||
|
# SECTION: eccentricity spectrum |
||||||
|
print("=" * 70) |
||||||
|
print("SECTION: eccentricity spectrum: circular to highly hyperbolic") |
||||||
|
print("=" * 70) |
||||||
|
|
||||||
|
r = 1.496e11 |
||||||
|
v_circ = math.sqrt(mu / r) |
||||||
|
|
||||||
|
# 1. Circular orbit (e=0) |
||||||
|
circular = make_elements(r, 0.0, 0.0, 0.0, 0.0, 0.0) |
||||||
|
rec_circ = roundtrip(circular) |
||||||
|
print("\n [1] Circular orbit (e=0):") |
||||||
|
print(f" ecc error = {abs(rec_circ.e - 0.0):.2e} (test tol: 1e-10)") |
||||||
|
print(f" a error = {abs(rec_circ.a - r):.2e} (test tol: 1e-2)") |
||||||
|
|
||||||
|
# 2. Near-circular (e=0.001) |
||||||
|
near_circ = make_elements(1.496e11, 0.001, 0.5, 0.0, 0.0, 0.0) |
||||||
|
rec_near_circ = roundtrip(near_circ) |
||||||
|
print(f"\n [2] Near-circular (e=0.001):") |
||||||
|
print(f" ecc error = {abs(rec_near_circ.e - 0.001):.2e} (test tol: 1e-6)") |
||||||
|
print(f" a error = {abs(rec_near_circ.a - 1.496e11):.2e} (test tol: 1e-2)") |
||||||
|
|
||||||
|
# 3. Elliptical (e=0.5) |
||||||
|
elliptical = make_elements(1.0e11, 0.5, 0.8, 0.0, 0.0, 0.0) |
||||||
|
rec_elliptical = roundtrip(elliptical) |
||||||
|
print(f"\n [3] Elliptical (e=0.5):") |
||||||
|
print(f" ecc error = {abs(rec_elliptical.e - 0.5):.2e} (test tol: 1e-4)") |
||||||
|
print(f" a error = {abs(rec_elliptical.a - 1.0e11):.2e} (test tol: 1e-2)") |
||||||
|
|
||||||
|
# 4. Highly elliptical (e=0.95) |
||||||
|
high_ell = make_elements(1.0e11, 0.95, 0.1, 0.0, 0.0, 0.0) |
||||||
|
rec_high_ell = roundtrip(high_ell) |
||||||
|
print(f"\n [4] Highly elliptical (e=0.95):") |
||||||
|
print(f" ecc error = {abs(rec_high_ell.e - 0.95):.2e} (test tol: 1e-3)") |
||||||
|
print(f" a error = {abs(rec_high_ell.a - 1.0e11):.2e} (test tol: 1e-2)") |
||||||
|
|
||||||
|
# 5. Near-parabolic (e=0.999) |
||||||
|
near_par = make_elements(1.0e11, 0.999, 0.05, 0.0, 0.0, 0.0) |
||||||
|
rec_near_par = roundtrip(near_par) |
||||||
|
print(f"\n [5] Near-parabolic (e=0.999):") |
||||||
|
print(f" ecc error = {abs(rec_near_par.e - 0.999):.2e} (test tol: 1e-3)") |
||||||
|
|
||||||
|
# 6. Parabolic (e=1.0) |
||||||
|
parabolic = make_elements(0.0, 1.0, 0.5, 0.0, 0.0, 0.0, semi_latus_rectum=1.0e11) |
||||||
|
rec_parabolic = roundtrip(parabolic) |
||||||
|
print(f"\n [6] Parabolic (e=1.0):") |
||||||
|
print(f" ecc error = {abs(rec_parabolic.e - 1.0):.2e} (test tol: 1e-2)") |
||||||
|
# semi_latus_rectum is stored in 'p' field in the script |
||||||
|
print(f" p error = {abs(rec_parabolic.p - 1.0e11):.2e} (test tol: 1e-2)") |
||||||
|
|
||||||
|
# 7. Hyperbolic (e=2.0) |
||||||
|
hyper = make_elements(-1.0e11, 2.0, 0.5, 0.0, 0.0, 0.0) |
||||||
|
rec_hyper = roundtrip(hyper) |
||||||
|
print(f"\n [7] Hyperbolic (e=2.0):") |
||||||
|
print(f" ecc error = {abs(rec_hyper.e - 2.0):.2e} (test tol: 1e-3)") |
||||||
|
print(f" a error = {abs(rec_hyper.a - (-1.0e11)):.2e} (test tol: 1e-2)") |
||||||
|
|
||||||
|
# 8. Highly hyperbolic (e=10.0) |
||||||
|
high_hyper = make_elements(-1.0e10, 10.0, 0.8, 0.0, 0.0, 0.0) |
||||||
|
rec_high_hyper = roundtrip(high_hyper) |
||||||
|
print(f"\n [8] Highly hyperbolic (e=10.0):") |
||||||
|
print(f" ecc error = {abs(rec_high_hyper.e - 10.0):.2e} (test tol: 1e-3)") |
||||||
|
print(f" a error = {abs(rec_high_hyper.a - (-1.0e10)):.2e} (test tol: 1e-2)") |
||||||
|
|
||||||
|
# SECTION: inclination |
||||||
|
print("\n" + "=" * 70) |
||||||
|
print("SECTION: inclination: zero, polar, and retrograde") |
||||||
|
print("=" * 70) |
||||||
|
|
||||||
|
# 1. Zero inclination |
||||||
|
eq = make_elements(1.0e11, 0.3, 0.5, 0.0, 0.0, 0.0) |
||||||
|
rec_eq = roundtrip(eq) |
||||||
|
print(f"\n [1] Equatorial (inc=0):") |
||||||
|
print(f" inc error = {abs(rec_eq.inc - 0.0):.2e} (test tol: 1e-6)") |
||||||
|
print(f" ecc error = {abs(rec_eq.e - 0.3):.2e} (test tol: 1e-4)") |
||||||
|
|
||||||
|
# 2. Polar (inc=90 deg) |
||||||
|
polar = make_elements(1.0e11, 0.2, 0.6, math.pi / 2.0, 0.5, 0.3) |
||||||
|
rec_polar = roundtrip(polar) |
||||||
|
print(f"\n [2] Polar (inc=90 deg):") |
||||||
|
print(f" inc error = {abs(rec_polar.inc - math.pi/2.0):.2e} (test tol: 1e-4)") |
||||||
|
print(f" Omega error = {abs(rec_polar.Omega - 0.5):.2e} (test tol: 1e-4)") |
||||||
|
print(f" omega error = {abs(rec_polar.omega - 0.3):.2e} (test tol: 1e-4)") |
||||||
|
|
||||||
|
# 3. Retrograde (inc=180 deg) |
||||||
|
retro = make_elements(1.0e11, 0.2, 0.6, math.pi, 0.5, 0.3) |
||||||
|
rec_retro = roundtrip(retro) |
||||||
|
print(f"\n [3] Retrograde (inc=180 deg):") |
||||||
|
print(f" inc error = {abs(rec_retro.inc - math.pi):.2e} (test tol: 1e-4)") |
||||||
|
|
||||||
|
# SECTION: true anomaly at key orbital positions |
||||||
|
print("\n" + "=" * 70) |
||||||
|
print("SECTION: true anomaly at key orbital positions") |
||||||
|
print("=" * 70) |
||||||
|
|
||||||
|
nu_tests = [ |
||||||
|
(0.0, 0.0, "periapsis"), |
||||||
|
(math.pi, math.pi, "apoapsis"), |
||||||
|
(math.pi / 2.0, math.pi / 2.0, "quadrature +90"), |
||||||
|
(-math.pi / 2.0, 3.0 * math.pi / 2.0, "quadrature -90"), |
||||||
|
(3.0 * math.pi / 2.0, 3.0 * math.pi / 2.0, "quadrature +270"), |
||||||
|
(-3.0 * math.pi / 2.0, math.pi / 2.0, "quadrature -270"), |
||||||
|
] |
||||||
|
|
||||||
|
for i, (nu_in, nu_exp, label) in enumerate(nu_tests): |
||||||
|
el = make_elements(1.0e11, 0.5, nu_in, 0.0, 0.0, 0.0) |
||||||
|
rec = roundtrip(el) |
||||||
|
nu_err = abs(rec.nu - nu_exp) |
||||||
|
e_err = abs(rec.e - 0.5) |
||||||
|
print(f"\n [{i+1}] {label} (input nu={nu_in:.6f}):") |
||||||
|
print(f" nu error = {nu_err:.2e} (test tol: 1e-6)") |
||||||
|
print(f" ecc error = {e_err:.2e} (test tol: 1e-4)") |
||||||
|
|
||||||
|
# SECTION: quadrature at various eccentricities |
||||||
|
print("\n" + "=" * 70) |
||||||
|
print("SECTION: quadrature at various eccentricities") |
||||||
|
print("=" * 70) |
||||||
|
|
||||||
|
e_tests = [(0.9, 1e-3, 1e-5), (0.1, 1e-5, 1e-6)] |
||||||
|
for i, (e, e_tol, nu_tol) in enumerate(e_tests): |
||||||
|
el = make_elements(1.0e11, e, math.pi / 2.0, 0.0, 0.0, 0.0) |
||||||
|
rec = roundtrip(el) |
||||||
|
e_err = abs(rec.e - e) |
||||||
|
a_err = abs(rec.a - 1.0e11) |
||||||
|
nu_err = abs(rec.nu - math.pi / 2.0) |
||||||
|
print(f"\n [{i+1}] e={e}:") |
||||||
|
print(f" ecc error = {e_err:.2e} (test tol: {e_tol:.0e}) {'PASS' if e_err <= e_tol else 'FAIL'}") |
||||||
|
print(f" a error = {a_err:.2e} (test tol: 1e-2)") |
||||||
|
print(f" nu error = {nu_err:.2e} (test tol: {nu_tol:.0e}) {'PASS' if nu_err <= nu_tol else 'FAIL'}") |
||||||
|
|
||||||
|
# SECTION: large true anomaly values |
||||||
|
print("\n" + "=" * 70) |
||||||
|
print("SECTION: large true anomaly values") |
||||||
|
print("=" * 70) |
||||||
|
|
||||||
|
large_nu_tests = [ |
||||||
|
(5.0, 5.0, 1e-6, "nu=5.0"), |
||||||
|
(-5.0, 1.28318530717958623, 1e-6, "nu=-5.0"), |
||||||
|
(10.0, 10.0 - 2.0 * math.pi, 1e-5, "nu=10.0"), |
||||||
|
] |
||||||
|
|
||||||
|
for i, (nu_in, nu_exp, tol, label) in enumerate(large_nu_tests): |
||||||
|
el = make_elements(1.0e11, 0.5, nu_in, 0.0, 0.0, 0.0) |
||||||
|
rec = roundtrip(el) |
||||||
|
nu_err = abs(rec.nu - nu_exp) |
||||||
|
e_err = abs(rec.e - 0.5) |
||||||
|
a_err = abs(rec.a - 1.0e11) |
||||||
|
print(f"\n [{i+1}] {label}:") |
||||||
|
print(f" ecc error = {e_err:.2e} (test tol: 1e-4)") |
||||||
|
print(f" a error = {a_err:.2e} (test tol: 1e-2)") |
||||||
|
print(f" nu error = {nu_err:.2e} (test tol: {tol:.0e}) {'PASS' if nu_err <= tol else 'FAIL'}") |
||||||
|
|
||||||
|
# SECTION: 3D orientation with quadrature point |
||||||
|
print("\n" + "=" * 70) |
||||||
|
print("SECTION: 3D orientation with quadrature point") |
||||||
|
print("=" * 70) |
||||||
|
|
||||||
|
el = make_elements(1.0e11, 0.5, math.pi / 2.0, math.pi / 3.0, math.pi / 4.0, math.pi / 6.0) |
||||||
|
rec = roundtrip(el) |
||||||
|
print(f" ecc error = {abs(rec.e - 0.5):.2e} (test tol: 1e-4)") |
||||||
|
print(f" a error = {abs(rec.a - 1.0e11):.2e} (test tol: 1e-2)") |
||||||
|
print(f" nu error = {abs(rec.nu - math.pi/2.0):.2e} (test tol: 1e-5)") |
||||||
|
print(f" inc error = {abs(rec.inc - math.pi/3.0):.2e} (test tol: 1e-4)") |
||||||
|
print(f" Omega error = {abs(rec.Omega - math.pi/4.0):.2e} (test tol: 1e-4)") |
||||||
|
print(f" omega error = {abs(rec.omega - math.pi/6.0):.2e} (test tol: 1e-4)") |
||||||
|
|
||||||
|
# SECTION: multiple true anomaly points in sequence |
||||||
|
print("\n" + "=" * 70) |
||||||
|
print("SECTION: multiple true anomaly points in sequence") |
||||||
|
print("=" * 70) |
||||||
|
|
||||||
|
nu_seq = [0.0, math.pi / 4.0, math.pi / 2.0, 3.0 * math.pi / 4.0, math.pi] |
||||||
|
for i, nu in enumerate(nu_seq): |
||||||
|
el = make_elements(1.0e11, 0.5, nu, 0.0, 0.0, 0.0) |
||||||
|
rec = roundtrip(el) |
||||||
|
nu_err = abs(rec.nu - nu) |
||||||
|
e_err = abs(rec.e - 0.5) |
||||||
|
a_err = abs(rec.a - 1.0e11) |
||||||
|
print(f"\n [{i+1}] nu={nu:.6f}:") |
||||||
|
print(f" ecc error = {e_err:.2e} (test tol: 1e-4)") |
||||||
|
print(f" a error = {a_err:.2e} (test tol: 1e-2)") |
||||||
|
print(f" nu error = {nu_err:.2e} (test tol: 1e-6)") |
||||||
|
|
||||||
|
# SECTION: hyperbolic orbit at quadrature point |
||||||
|
print("\n" + "=" * 70) |
||||||
|
print("SECTION: hyperbolic orbit at quadrature point") |
||||||
|
print("=" * 70) |
||||||
|
|
||||||
|
el = make_elements(-1.0e11, 2.0, math.pi / 2.0, 0.0, 0.0, 0.0) |
||||||
|
rec = roundtrip(el) |
||||||
|
print(f" ecc error = {abs(rec.e - 2.0):.2e} (test tol: 1e-3)") |
||||||
|
print(f" a error = {abs(rec.a - (-1.0e11)):.2e} (test tol: 1e-2)") |
||||||
|
print(f" nu error = {abs(rec.nu - math.pi/2.0):.2e} (test tol: 1e-5)") |
||||||
@ -0,0 +1,79 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Precalculate expected values for test_cartesian_to_elements_basic.cpp. |
||||||
|
|
||||||
|
Usage: |
||||||
|
python3 scripts/precalc_cartesian_to_elements_basic.py |
||||||
|
|
||||||
|
Outputs C++-style comments with precalculated values for embedding in the test. |
||||||
|
""" |
||||||
|
|
||||||
|
import sys, math |
||||||
|
sys.path.insert(0, 'scripts') |
||||||
|
from sim_engine import orbital_to_cartesian, cartesian_to_orbital_elements, vmag, OrbitalElements, G |
||||||
|
|
||||||
|
# Test configuration: moderate eccentricity, zero inclination |
||||||
|
mu = G * 5.972e24 |
||||||
|
a = 1.5e7 |
||||||
|
e = 0.5 |
||||||
|
nu = 0.0 |
||||||
|
inc = 0.0 |
||||||
|
Omega = 0.0 |
||||||
|
omega = 0.0 |
||||||
|
|
||||||
|
elements = OrbitalElements(a=a, e=e, nu=nu, inc=inc, Omega=Omega, omega=omega) |
||||||
|
pos, vel = orbital_to_cartesian(elements, 5.972e24) |
||||||
|
|
||||||
|
r = vmag(pos) |
||||||
|
v = vmag(vel) |
||||||
|
|
||||||
|
# Round-trip: convert back |
||||||
|
elements_rt = cartesian_to_orbital_elements(pos, vel, 5.972e24) |
||||||
|
|
||||||
|
print("# Test: test_cartesian_to_elements_basic") |
||||||
|
print(f"#") |
||||||
|
print(f"# Original elements:") |
||||||
|
print(f"# a = {a:.6f}") |
||||||
|
print(f"# e = {e:.6f}") |
||||||
|
print(f"# nu = {nu:.6f}") |
||||||
|
print(f"# inc = {inc:.6f}") |
||||||
|
print(f"# Omega = {Omega:.6f}") |
||||||
|
print(f"# omega = {omega:.6f}") |
||||||
|
print(f"#") |
||||||
|
print(f"# State vectors from elements:") |
||||||
|
print(f"# pos = ({pos[0]:.6f}, {pos[1]:.6f}, {pos[2]:.6f}) m") |
||||||
|
print(f"# vel = ({vel[0]:.6f}, {vel[1]:.6f}, {vel[2]:.6f}) m/s") |
||||||
|
print(f"# r = {r:.6f} m") |
||||||
|
print(f"# v = {v:.6f} m/s") |
||||||
|
print(f"#") |
||||||
|
print(f"# Round-trip recovered elements:") |
||||||
|
print(f"# a = {elements_rt.a:.15f}") |
||||||
|
print(f"# e = {elements_rt.e:.15f}") |
||||||
|
print(f"# nu = {elements_rt.nu:.15f}") |
||||||
|
print(f"# inc = {elements_rt.inc:.15f}") |
||||||
|
print(f"# Omega = {elements_rt.Omega:.15f}") |
||||||
|
print(f"# omega = {elements_rt.omega:.15f}") |
||||||
|
print(f"#") |
||||||
|
print(f"# Errors:") |
||||||
|
print(f"# da = {abs(elements_rt.a - a):.2e}") |
||||||
|
print(f"# de = {abs(elements_rt.e - e):.2e}") |
||||||
|
print(f"# dnu = {abs(elements_rt.nu - nu):.2e}") |
||||||
|
print(f"# dinc = {abs(elements_rt.inc - inc):.2e}") |
||||||
|
print(f"# dOmega = {abs(elements_rt.Omega - Omega):.2e}") |
||||||
|
print(f"# domega = {abs(elements_rt.omega - omega):.2e}") |
||||||
|
print(f"# dr = {abs(r - r):.2e} (trivial)") |
||||||
|
print(f"# dv = {abs(v - v):.2e} (trivial)") |
||||||
|
|
||||||
|
# Re-convert recovered elements back to state vectors |
||||||
|
pos2, vel2 = orbital_to_cartesian(elements_rt, 5.972e24) |
||||||
|
r2 = vmag(pos2) |
||||||
|
v2 = vmag(vel2) |
||||||
|
|
||||||
|
print(f"#") |
||||||
|
print(f"# Reconstructed from recovered elements:") |
||||||
|
print(f"# pos = ({pos2[0]:.6f}, {pos2[1]:.6f}, {pos2[2]:.6f}) m") |
||||||
|
print(f"# vel = ({vel2[0]:.6f}, {vel2[1]:.6f}, {vel2[2]:.6f}) m/s") |
||||||
|
print(f"# r = {r2:.6f} m") |
||||||
|
print(f"# v = {v2:.6f} m/s") |
||||||
|
print(f"# dr = {abs(r2 - r):.2e} m") |
||||||
|
print(f"# dv = {abs(v2 - v):.2e} m/s") |
||||||
@ -0,0 +1,135 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Precalculate expected values for test_extreme_eccentricity.cpp. |
||||||
|
|
||||||
|
Usage: |
||||||
|
python3 scripts/precalc_extreme_eccentricity.py |
||||||
|
|
||||||
|
Outputs C++-style comments with precalculated values for embedding in the test. |
||||||
|
""" |
||||||
|
|
||||||
|
import sys, math |
||||||
|
sys.path.insert(0, 'scripts') |
||||||
|
from sim_engine import orbital_to_cartesian, cartesian_to_orbital_elements, vmag, OrbitalElements, G |
||||||
|
|
||||||
|
# Spacecraft 0: Highly_Elliptical (e=0.99, a=6.5e8) |
||||||
|
mu = G * 5.972e24 |
||||||
|
a0 = 6.5e8 |
||||||
|
e0 = 0.99 |
||||||
|
nu0 = 0.0 |
||||||
|
|
||||||
|
elements0 = OrbitalElements(a=a0, e=e0, nu=nu0, inc=0.0, Omega=0.0, omega=0.0) |
||||||
|
pos0, vel0 = orbital_to_cartesian(elements0, 5.972e24) |
||||||
|
|
||||||
|
r0 = vmag(pos0) |
||||||
|
v0 = vmag(vel0) |
||||||
|
expected_r_peri0 = a0 * (1.0 - e0) |
||||||
|
expected_r_apo0 = a0 * (1.0 + e0) |
||||||
|
|
||||||
|
# Round-trip |
||||||
|
elements0_rt = cartesian_to_orbital_elements(pos0, vel0, 5.972e24) |
||||||
|
|
||||||
|
print("# Spacecraft 0: Highly_Elliptical (e=0.99, a=6.5e8)") |
||||||
|
print(f"# r_peri = {expected_r_peri0:.6f} m") |
||||||
|
print(f"# r_apo = {expected_r_apo0:.6f} m") |
||||||
|
print(f"# r = {r0:.6f} m") |
||||||
|
print(f"# v = {v0:.6f} m/s") |
||||||
|
print(f"# dr = {abs(r0 - expected_r_peri0):.2e} m") |
||||||
|
print(f"# dr_apo = {abs(r0 - expected_r_apo0):.2e} m") |
||||||
|
print(f"# e_rt = {elements0_rt.e:.15f} (error: {abs(elements0_rt.e - e0):.2e})") |
||||||
|
print(f"# a_rt = {elements0_rt.a:.6f} m") |
||||||
|
print() |
||||||
|
|
||||||
|
# Nu = pi (apoapsis) |
||||||
|
elements0_pi = OrbitalElements(a=a0, e=e0, nu=math.pi, inc=0.0, Omega=0.0, omega=0.0) |
||||||
|
pos0_pi, vel0_pi = orbital_to_cartesian(elements0_pi, 5.972e24) |
||||||
|
r0_pi = vmag(pos0_pi) |
||||||
|
v0_pi = vmag(vel0_pi) |
||||||
|
|
||||||
|
print(f"# At apoapsis (nu=pi):") |
||||||
|
print(f"# r = {r0_pi:.6f} m (expected: {expected_r_apo0:.6f} m)") |
||||||
|
print(f"# v = {v0_pi:.6f} m/s") |
||||||
|
print(f"# dr = {abs(r0_pi - expected_r_apo0):.2e} m") |
||||||
|
print() |
||||||
|
|
||||||
|
# Spacecraft 1: Near_Parabolic (e=0.99, a=7.0e8) |
||||||
|
a1 = 7.0e8 |
||||||
|
e1 = 0.99 |
||||||
|
nu1 = 0.0 |
||||||
|
|
||||||
|
elements1 = OrbitalElements(a=a1, e=e1, nu=nu1, inc=0.0, Omega=0.0, omega=0.0) |
||||||
|
pos1, vel1 = orbital_to_cartesian(elements1, 5.972e24) |
||||||
|
|
||||||
|
r1 = vmag(pos1) |
||||||
|
v1 = vmag(vel1) |
||||||
|
expected_r_peri1 = a1 * (1.0 - e1) |
||||||
|
expected_r_apo1 = a1 * (1.0 + e1) |
||||||
|
|
||||||
|
# Apoapsis |
||||||
|
elements1_pi = OrbitalElements(a=a1, e=e1, nu=math.pi, inc=0.0, Omega=0.0, omega=0.0) |
||||||
|
pos1_pi, vel1_pi = orbital_to_cartesian(elements1_pi, 5.972e24) |
||||||
|
r1_pi = vmag(pos1_pi) |
||||||
|
v1_pi = vmag(vel1_pi) |
||||||
|
|
||||||
|
print("# Spacecraft 1: Near_Parabolic (e=0.99, a=7.0e8)") |
||||||
|
print(f"# r_peri = {expected_r_peri1:.6f} m") |
||||||
|
print(f"# r_apo = {expected_r_apo1:.6f} m") |
||||||
|
print(f"# r_peri_actual = {r1:.6f} m") |
||||||
|
print(f"# v_peri = {v1:.6f} m/s") |
||||||
|
print(f"# r_apo_actual = {r1_pi:.6f} m") |
||||||
|
print(f"# v_apo = {v1_pi:.6f} m/s") |
||||||
|
print(f"# dr_peri = {abs(r1 - expected_r_peri1):.2e} m") |
||||||
|
print(f"# dr_apo = {abs(r1_pi - expected_r_apo1):.2e} m") |
||||||
|
print(f"# v_peri > v_apo: {v1 > v1_pi}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Spacecraft 2: Slightly_Hyperbolic (e=1.05, a=-1.3e8) |
||||||
|
a2 = -1.3e8 |
||||||
|
e2 = 1.05 |
||||||
|
nu2 = 0.0 |
||||||
|
|
||||||
|
elements2 = OrbitalElements(a=a2, e=e2, nu=nu2, inc=0.0, Omega=0.0, omega=0.0) |
||||||
|
pos2, vel2 = orbital_to_cartesian(elements2, 5.972e24) |
||||||
|
|
||||||
|
r2 = vmag(pos2) |
||||||
|
v2 = vmag(vel2) |
||||||
|
|
||||||
|
escape_vel = math.sqrt(2.0 * mu / r2) |
||||||
|
circular_vel = math.sqrt(mu / r2) |
||||||
|
expected_v_sq = mu * (2.0 / r2 - 1.0 / a2) |
||||||
|
expected_v = math.sqrt(expected_v_sq) |
||||||
|
|
||||||
|
print("# Spacecraft 2: Slightly_Hyperbolic (e=1.05, a=-1.3e8)") |
||||||
|
print(f"# r = {r2:.6f} m") |
||||||
|
print(f"# v = {v2:.6f} m/s") |
||||||
|
print(f"# v_exp = {expected_v:.6f} m/s") |
||||||
|
print(f"# v_err = {abs(v2 - expected_v):.2e} m/s") |
||||||
|
print(f"# rel_err = {abs(v2 - expected_v) / expected_v:.2e}") |
||||||
|
print(f"# escape_vel = {escape_vel:.6f} m/s") |
||||||
|
print(f"# circular_vel = {circular_vel:.6f} m/s") |
||||||
|
print(f"# a < 0: {a2 < 0}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Velocity at different true anomalies for each spacecraft |
||||||
|
print("# Velocity magnitudes at different true anomalies:") |
||||||
|
print("# (vis-viva: v = sqrt(mu * (2/r - 1/a)))") |
||||||
|
print() |
||||||
|
|
||||||
|
for idx, (a_val, e_val, name) in enumerate([(a0, e0, "Highly_Elliptical"), |
||||||
|
(a1, e1, "Near_Parabolic"), |
||||||
|
(a2, e2, "Slightly_Hyperbolic")]): |
||||||
|
print(f"# {name} (a={a_val:.2e}, e={e_val:.2f}):") |
||||||
|
for nu in [0.0, math.pi/2.0, math.pi, 3.0*math.pi/2.0]: |
||||||
|
if e_val > 1.0: |
||||||
|
max_nu = math.acos(-1.0 / e_val) |
||||||
|
if abs(nu) >= max_nu: |
||||||
|
print(f"# nu={nu:.4f} rad: SKIPPED (hyperbolic limit +/- {max_nu:.4f})") |
||||||
|
continue |
||||||
|
elem = OrbitalElements(a=a_val, e=e_val, nu=nu, inc=0.0, Omega=0.0, omega=0.0) |
||||||
|
p, v = orbital_to_cartesian(elem, 5.972e24) |
||||||
|
r = vmag(p) |
||||||
|
v_mag = vmag(v) |
||||||
|
v_exp = math.sqrt(mu * (2.0/r - 1.0/a_val)) |
||||||
|
rel_err = abs(v_mag - v_exp) / v_exp |
||||||
|
print(f"# nu={nu:.4f} rad: v={v_mag:.6f} m/s, v_exp={v_exp:.6f} m/s, rel_err={rel_err:.2e}") |
||||||
|
print() |
||||||
@ -0,0 +1,56 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
"""Precalculate expected values for test_extreme_orientation_mixed.""" |
||||||
|
|
||||||
|
import sys |
||||||
|
import os |
||||||
|
import math |
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
||||||
|
import sim_engine |
||||||
|
from sim_engine import Simulator |
||||||
|
|
||||||
|
# Load config via sim_engine (TOML 1.0 parsing + spacecraft initialization) |
||||||
|
sim = Simulator("tests/test_extreme_orientation_mixed.toml", dt=60.0) |
||||||
|
|
||||||
|
mu = 6.67430e-11 * 5.972e24 # Earth's gravitational parameter |
||||||
|
|
||||||
|
print(f"# mu = {mu:.6f} m^3/s^2") |
||||||
|
print() |
||||||
|
|
||||||
|
for i, craft in enumerate(sim.spacecraft): |
||||||
|
a = craft.orbit.a |
||||||
|
e = craft.orbit.e |
||||||
|
name = craft.name |
||||||
|
|
||||||
|
print(f"# Spacecraft {i}: {name} (a={a}, e={e})") |
||||||
|
|
||||||
|
# Periapsis (nu=0) |
||||||
|
r_peri = a * (1.0 - e) |
||||||
|
v_peri = math.sqrt(mu * (2.0/r_peri - 1.0/a)) |
||||||
|
print(f"# nu=0: r={r_peri:.6e} m, v={v_peri:.6f} m/s") |
||||||
|
|
||||||
|
# Apoapsis (nu=pi) |
||||||
|
if e < 1.0: |
||||||
|
r_apo = a * (1.0 + e) |
||||||
|
v_apo = math.sqrt(mu * (2.0/r_apo - 1.0/a)) |
||||||
|
print(f"# nu=pi: r={r_apo:.6e} m, v={v_apo:.6f} m/s") |
||||||
|
|
||||||
|
# nu=pi/2 |
||||||
|
r_nu90 = a * (1.0 - e*e) / (1.0 + e * math.cos(math.pi/2)) |
||||||
|
v_nu90 = math.sqrt(mu * (2.0/r_nu90 - 1.0/a)) |
||||||
|
print(f"# nu=pi/2: r={r_nu90:.6e} m, v={v_nu90:.6f} m/s") |
||||||
|
|
||||||
|
# nu=3pi/2 |
||||||
|
r_nu270 = a * (1.0 - e*e) / (1.0 + e * math.cos(3*math.pi/2)) |
||||||
|
v_nu270 = math.sqrt(mu * (2.0/r_nu270 - 1.0/a)) |
||||||
|
print(f"# nu=3pi/2: r={r_nu270:.6e} m, v={v_nu270:.6f} m/s") |
||||||
|
|
||||||
|
# Round-trip check: convert to cartesian and back |
||||||
|
parent_mass = mu / 6.67430e-11 |
||||||
|
pos, vel = sim_engine.orbital_to_cartesian(craft.orbit, parent_mass) |
||||||
|
recovered = sim_engine.cartesian_to_orbital_elements(pos, vel, parent_mass) |
||||||
|
print(f"# round-trip: e_err={abs(recovered.e - e):.2e}, " |
||||||
|
f"i_err={abs(recovered.inc - craft.orbit.inc):.2e}, " |
||||||
|
f"O_err={abs(recovered.Omega - craft.orbit.Omega):.2e}, " |
||||||
|
f"w_err={abs(recovered.omega - craft.orbit.omega):.2e}") |
||||||
|
print() |
||||||
@ -0,0 +1,188 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Precalculate expected values for test_extreme_timescales. |
||||||
|
All values in SI units (meters, m/s, seconds). |
||||||
|
Output local-frame values relative to parent body. |
||||||
|
""" |
||||||
|
import math |
||||||
|
|
||||||
|
G = 6.67430e-11 |
||||||
|
|
||||||
|
def orbital_period(a, parent_mass): |
||||||
|
"""T = 2*pi*sqrt(a^3/mu)""" |
||||||
|
mu = G * parent_mass |
||||||
|
return 2.0 * math.pi * math.sqrt(a**3 / mu) |
||||||
|
|
||||||
|
def orbital_energy(r, v, craft_mass, parent_mass): |
||||||
|
"""E = 0.5*m*v^2 - G*m1*m2/r""" |
||||||
|
mu = G * parent_mass |
||||||
|
ke = 0.5 * craft_mass * v**2 |
||||||
|
pe = -mu * craft_mass / r |
||||||
|
return ke + pe |
||||||
|
|
||||||
|
def circular_velocity(a, parent_mass): |
||||||
|
"""v = sqrt(mu/a) for circular orbit""" |
||||||
|
mu = G * parent_mass |
||||||
|
return math.sqrt(mu / a) |
||||||
|
|
||||||
|
# Body definitions (from TOML) |
||||||
|
earth_mass = 5.972e24 |
||||||
|
earth_radius = 6.371e6 |
||||||
|
sun_mass = 1.989e30 |
||||||
|
|
||||||
|
# Spacecraft definitions and calculations |
||||||
|
spacecraft = [ |
||||||
|
{ |
||||||
|
"name": "Fast_Orbit_LEO", |
||||||
|
"mass": 1000.0, |
||||||
|
"parent_index": 0, # Earth |
||||||
|
"parent_mass": earth_mass, |
||||||
|
"a": 6.771e6, |
||||||
|
"e": 0.0, |
||||||
|
"nu": 0.0, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Mercury_Like_Orbit", |
||||||
|
"mass": 1000.0, |
||||||
|
"parent_index": 1, # Sun |
||||||
|
"parent_mass": sun_mass, |
||||||
|
"a": 5.79e10, |
||||||
|
"e": 0.2056, |
||||||
|
"nu": 0.0, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Long_Period_Orbit", |
||||||
|
"mass": 1000.0, |
||||||
|
"parent_index": 1, # Sun |
||||||
|
"parent_mass": sun_mass, |
||||||
|
"a": 5.2e11, |
||||||
|
"e": 0.0489, |
||||||
|
"nu": 0.0, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Low_Altitude_Orbit", |
||||||
|
"mass": 1000.0, |
||||||
|
"parent_index": 0, # Earth |
||||||
|
"parent_mass": earth_mass, |
||||||
|
"a": 6.471e6, |
||||||
|
"e": 0.0, |
||||||
|
"nu": 0.0, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Super_Synchronous_Orbit", |
||||||
|
"mass": 1000.0, |
||||||
|
"parent_index": 0, # Earth |
||||||
|
"parent_mass": earth_mass, |
||||||
|
"a": 4.5e7, |
||||||
|
"e": 0.0, |
||||||
|
"nu": 0.0, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Geosynchronous_Orbit", |
||||||
|
"mass": 1000.0, |
||||||
|
"parent_index": 0, # Earth |
||||||
|
"parent_mass": earth_mass, |
||||||
|
"a": 4.2164e7, |
||||||
|
"e": 0.0, |
||||||
|
"nu": 0.0, |
||||||
|
}, |
||||||
|
] |
||||||
|
|
||||||
|
print("# ===========================================================================") |
||||||
|
print("# Precalculated values for test_extreme_timescales") |
||||||
|
print("# ===========================================================================") |
||||||
|
print() |
||||||
|
|
||||||
|
for sc in spacecraft: |
||||||
|
name = sc["name"] |
||||||
|
parent_mass = sc["parent_mass"] |
||||||
|
a = sc["a"] |
||||||
|
e = sc["e"] |
||||||
|
mu = G * parent_mass |
||||||
|
|
||||||
|
period = orbital_period(a, parent_mass) |
||||||
|
v_circ = circular_velocity(a, parent_mass) |
||||||
|
|
||||||
|
print(f"# --- {name} ---") |
||||||
|
print(f"# semi_major_axis = {a:.10e} m") |
||||||
|
print(f"# eccentricity = {e}") |
||||||
|
print(f"# parent_mass = {parent_mass:.10e} kg") |
||||||
|
print(f"# orbital_period = {period:.6f} s") |
||||||
|
print(f"# orbital_period = {period / 60.0:.4f} minutes") |
||||||
|
print(f"# orbital_period = {period / 86400.0:.4f} days") |
||||||
|
print(f"# circular_velocity = {v_circ:.6f} m/s") |
||||||
|
|
||||||
|
if e == 0.0: |
||||||
|
r = a |
||||||
|
v = v_circ |
||||||
|
energy = orbital_energy(r, v, sc["mass"], parent_mass) |
||||||
|
print(f"# circular orbit: r = {r:.10e} m, v = {v:.6f} m/s") |
||||||
|
print(f"# total_energy = {energy:.6f} J") |
||||||
|
else: |
||||||
|
# For eccentric orbits, at nu=0 (periapsis): |
||||||
|
r_peri = a * (1 - e) |
||||||
|
v_peri = math.sqrt(mu * (2/r_peri - 1/a)) |
||||||
|
energy_peri = orbital_energy(r_peri, v_peri, sc["mass"], parent_mass) |
||||||
|
print(f"# eccentric orbit (nu=0=periapsis):") |
||||||
|
print(f"# r_peri = {r_peri:.10e} m") |
||||||
|
print(f"# v_peri = {v_peri:.6f} m/s") |
||||||
|
print(f"# total_energy = {energy_peri:.6f} J") |
||||||
|
|
||||||
|
print() |
||||||
|
|
||||||
|
# Geosynchronous period check |
||||||
|
geo_a = 4.2164e7 |
||||||
|
geo_period = orbital_period(geo_a, earth_mass) |
||||||
|
sidereal_day_hours = 23.93447 |
||||||
|
sidereal_day_seconds = sidereal_day_hours * 3600.0 |
||||||
|
geo_period_hours = geo_period / 3600.0 |
||||||
|
print("# --- Geosynchronous period check ---") |
||||||
|
print(f"# Geosynchronous period: {geo_period_hours:.6f} hours") |
||||||
|
print(f"# Sidereal day: {sidereal_day_hours} hours") |
||||||
|
print(f"# Period error: {abs(geo_period_hours - sidereal_day_hours):.6f} hours") |
||||||
|
print(f"# Period error: {abs(geo_period - sidereal_day_seconds):.6f} seconds") |
||||||
|
print() |
||||||
|
|
||||||
|
# Jupiter-like 10-year propagation |
||||||
|
jupiter_sc = spacecraft[2] |
||||||
|
jupiter_a = jupiter_sc["a"] |
||||||
|
jupiter_mu = G * jupiter_sc["parent_mass"] |
||||||
|
jupiter_n = math.sqrt(jupiter_mu / jupiter_a**3) # mean motion |
||||||
|
prop_time_10yr = 10.0 * 365.0 * 86400.0 |
||||||
|
expected_mean_anomaly = jupiter_n * prop_time_10yr |
||||||
|
expected_orbits = expected_mean_anomaly / (2.0 * math.pi) |
||||||
|
print("# --- Jupiter-like 10-year mean anomaly ---") |
||||||
|
print(f"# Mean motion n = {jupiter_n:.15e} rad/s") |
||||||
|
print(f"# Propagation time = {prop_time_10yr:.1f} s ({prop_time_10yr / (365.0*86400.0):.1f} years)") |
||||||
|
print(f"# Expected mean anomaly = {expected_mean_anomaly:.6f} rad") |
||||||
|
print(f"# Expected orbits = {expected_orbits:.6f}") |
||||||
|
print(f"# Expected true anomaly change = {expected_mean_anomaly % (2*math.pi):.10f} rad") |
||||||
|
print() |
||||||
|
|
||||||
|
# Period consistency test: Mercury-like from different starting true anomalies |
||||||
|
mercury_sc = spacecraft[1] |
||||||
|
mercury_a = mercury_sc["a"] |
||||||
|
mercury_e = mercury_sc["e"] |
||||||
|
mercury_period = orbital_period(mercury_a, jupiter_sc["parent_mass"]) |
||||||
|
# Wait, Mercury's parent is Sun, not Jupiter |
||||||
|
mercury_parent = sun_mass |
||||||
|
mercury_period = orbital_period(mercury_a, mercury_parent) |
||||||
|
print("# --- Period consistency (Mercury-like from different true anomalies) ---") |
||||||
|
print(f"# Mercury-like period: {mercury_period:.6f} s") |
||||||
|
for nu0_deg in [0, 90, 180, 270]: |
||||||
|
nu0 = math.radians(nu0_deg) |
||||||
|
print(f"# Starting nu = {nu0_deg} deg ({nu0:.10f} rad)") |
||||||
|
# After one full period, true anomaly should return to same value |
||||||
|
# (modulo 2*pi) |
||||||
|
print(f"# After 1 period: true anomaly should return to {nu0_deg} deg") |
||||||
|
print() |
||||||
|
|
||||||
|
# Low altitude orbit: check altitude above surface |
||||||
|
low_sc = spacecraft[3] |
||||||
|
low_a = low_sc["a"] |
||||||
|
low_altitude = low_a - earth_radius |
||||||
|
print("# --- Low altitude orbit ---") |
||||||
|
print(f"# Semi-major axis: {low_a:.10e} m") |
||||||
|
print(f"# Earth radius: {earth_radius:.10e} m") |
||||||
|
print(f"# Altitude above surface: {low_altitude:.10e} m ({low_altitude/1000.0:.1f} km)") |
||||||
|
print() |
||||||
@ -0,0 +1,545 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Precalculate expected values for test_hybrid_burns.cpp refactoring. |
||||||
|
Uses sim_engine.py for physics propagation. |
||||||
|
""" |
||||||
|
|
||||||
|
import math |
||||||
|
import sys |
||||||
|
sys.path.insert(0, "/home/agent/dev/claudes_game") |
||||||
|
from scripts.sim_engine import * |
||||||
|
|
||||||
|
|
||||||
|
def simulate_continuous_burn(initial_orbit, parent_mass, total_dv, burn_duration, |
||||||
|
num_steps, direction): |
||||||
|
"""Simulate continuous/low-thrust burn with sub-steps.""" |
||||||
|
current_orbit = initial_orbit |
||||||
|
dt_burn_step = burn_duration / num_steps |
||||||
|
dv_per_step = total_dv / num_steps |
||||||
|
|
||||||
|
for _ in range(num_steps): |
||||||
|
pos, vel = orbital_to_cartesian(current_orbit, parent_mass) |
||||||
|
burn_dir = get_burn_direction(direction, pos, vel) |
||||||
|
dv_vec = vscale(burn_dir, dv_per_step) |
||||||
|
vel = vadd(vel, dv_vec) |
||||||
|
current_orbit = cartesian_to_orbital_elements(pos, vel, parent_mass) |
||||||
|
current_orbit = propagate(current_orbit, dt_burn_step, parent_mass) |
||||||
|
|
||||||
|
return current_orbit |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
dt = 60.0 |
||||||
|
earth_mass = 5.972e24 |
||||||
|
mu = G * earth_mass |
||||||
|
earth = None # filled below |
||||||
|
|
||||||
|
# Setup: load config and get Hohmann_Transfer craft |
||||||
|
sim = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft = sim.spacecraft[0] # Hohmann_Transfer |
||||||
|
earth = sim.bodies[1] |
||||||
|
|
||||||
|
# Initialize craft state from orbital elements |
||||||
|
pos, vel = orbital_to_cartesian(craft.orbit, earth.mass) |
||||||
|
craft.local_pos = pos |
||||||
|
craft.local_vel = vel |
||||||
|
|
||||||
|
a0 = craft.orbit.a |
||||||
|
e0 = craft.orbit.e |
||||||
|
r0 = vmag(craft.local_pos) |
||||||
|
v0 = vmag(craft.local_vel) |
||||||
|
|
||||||
|
print("// === Config loading ===") |
||||||
|
print(f"// body_count = {len(sim.bodies)}") |
||||||
|
print(f"// craft_count = {len(sim.spacecraft)}") |
||||||
|
print(f"// maneuver_count = {len(sim.maneuvers)}") |
||||||
|
print(f"// craft[0] = \"{craft.name}\", parent_index = {craft.parent_index}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Test: Hohmann transfer - first burn at perigee |
||||||
|
sim_h1 = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft_h1 = sim_h1.spacecraft[0] |
||||||
|
earth_h1 = sim_h1.bodies[1] |
||||||
|
pos_h1, vel_h1 = orbital_to_cartesian(craft_h1.orbit, earth_h1.mass) |
||||||
|
craft_h1.local_pos = pos_h1 |
||||||
|
craft_h1.local_vel = vel_h1 |
||||||
|
|
||||||
|
v_before = vmag(craft_h1.local_vel) |
||||||
|
apply_impulsive_burn(craft_h1, BurnDirection.PROGRADE, 2440.0, earth_h1.mass) |
||||||
|
v_after = vmag(craft_h1.local_vel) |
||||||
|
r_after = vmag(craft_h1.local_pos) |
||||||
|
|
||||||
|
post_burn_els = cartesian_to_orbital_elements(craft_h1.local_pos, craft_h1.local_vel, earth_h1.mass) |
||||||
|
a_after = post_burn_els.a |
||||||
|
e_after = post_burn_els.e |
||||||
|
|
||||||
|
print("// === Hohmann transfer: first burn (2440 m/s prograde) ===") |
||||||
|
print(f"// v_before = {v_before:.6f}") |
||||||
|
print(f"// v_after = {v_after:.6f}") |
||||||
|
print(f"// r_after = {r_after:.6f}") |
||||||
|
print(f"// a_after = {a_after:.6f}") |
||||||
|
print(f"// e_after = {e_after:.15f}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Test: Hohmann transfer - second burn at apogee |
||||||
|
sim_h2 = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft_h2 = sim_h2.spacecraft[0] |
||||||
|
earth_h2 = sim_h2.bodies[1] |
||||||
|
pos_h2, vel_h2 = orbital_to_cartesian(craft_h2.orbit, earth_h2.mass) |
||||||
|
craft_h2.local_pos = pos_h2 |
||||||
|
craft_h2.local_vel = vel_h2 |
||||||
|
|
||||||
|
# First burn |
||||||
|
apply_impulsive_burn(craft_h2, BurnDirection.PROGRADE, 2440.0, earth_h2.mass) |
||||||
|
els_after_1 = cartesian_to_orbital_elements(craft_h2.local_pos, craft_h2.local_vel, earth_h2.mass) |
||||||
|
|
||||||
|
# Propagate to apogee (true anomaly = pi) |
||||||
|
els_apogee = els_after_1 |
||||||
|
els_apogee.nu = math.pi |
||||||
|
pos_apogee, vel_apogee = orbital_to_cartesian(els_apogee, earth_h2.mass) |
||||||
|
craft_h2.local_pos = pos_apogee |
||||||
|
craft_h2.local_vel = vel_apogee |
||||||
|
|
||||||
|
# Second burn |
||||||
|
apply_impulsive_burn(craft_h2, BurnDirection.PROGRADE, 1500.0, earth_h2.mass) |
||||||
|
final_els = cartesian_to_orbital_elements(craft_h2.local_pos, craft_h2.local_vel, earth_h2.mass) |
||||||
|
a_final = final_els.a |
||||||
|
e_final = final_els.e |
||||||
|
|
||||||
|
print("// === Hohmann transfer: second burn at apogee (1500 m/s prograde) ===") |
||||||
|
print(f"// a_after_first = {els_after_1.a:.6f}") |
||||||
|
print(f"// e_after_first = {els_after_1.e:.15f}") |
||||||
|
print(f"// a_final = {a_final:.6f}") |
||||||
|
print(f"// e_final = {e_final:.15f}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Test: Large burn -> hyperbolic orbit |
||||||
|
sim_large = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft_large = sim_large.spacecraft[5] # Large_Delta_v |
||||||
|
earth_large = sim_large.bodies[1] |
||||||
|
pos_l, vel_l = orbital_to_cartesian(craft_large.orbit, earth_large.mass) |
||||||
|
craft_large.local_pos = pos_l |
||||||
|
craft_large.local_vel = vel_l |
||||||
|
|
||||||
|
v_esc = math.sqrt(2.0 * G * earth_large.mass / vmag(craft_large.local_pos)) |
||||||
|
v_before_l = vmag(craft_large.local_vel) |
||||||
|
apply_impulsive_burn(craft_large, BurnDirection.PROGRADE, 12000.0, earth_large.mass) |
||||||
|
v_after_l = vmag(craft_large.local_vel) |
||||||
|
|
||||||
|
hyper_els = cartesian_to_orbital_elements(craft_large.local_pos, craft_large.local_vel, earth_large.mass) |
||||||
|
e_hyper = hyper_els.e |
||||||
|
a_hyper = hyper_els.a |
||||||
|
|
||||||
|
# Vis-viva check |
||||||
|
r_hyper = vmag(craft_large.local_pos) |
||||||
|
vis_viva_expected = v_after_l ** 2 |
||||||
|
vis_viva_calc = G * earth_large.mass * (2.0 / r_hyper - 1.0 / a_hyper) |
||||||
|
vis_viva_err = abs(vis_viva_expected - vis_viva_calc) / vis_viva_expected |
||||||
|
|
||||||
|
print("// === Large burn (12000 m/s prograde) -> hyperbolic ===") |
||||||
|
print(f"// v_before = {v_before_l:.6f}") |
||||||
|
print(f"// v_escape = {v_esc:.6f}") |
||||||
|
print(f"// v_after = {v_after_l:.6f}") |
||||||
|
print(f"// e = {e_hyper:.15f}") |
||||||
|
print(f"// a = {a_hyper:.6f}") |
||||||
|
print(f"// vis_viva_error = {vis_viva_err:.15e}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Test: Energy conservation - prograde burn |
||||||
|
sim_e1 = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft_e1 = sim_e1.spacecraft[0] |
||||||
|
earth_e1 = sim_e1.bodies[1] |
||||||
|
pos_e1, vel_e1 = orbital_to_cartesian(craft_e1.orbit, earth_e1.mass) |
||||||
|
craft_e1.local_pos = pos_e1 |
||||||
|
craft_e1.local_vel = vel_e1 |
||||||
|
|
||||||
|
m_craft = craft_e1.mass |
||||||
|
v_init = craft_e1.local_vel |
||||||
|
ke_init = 0.5 * m_craft * vdot(v_init, v_init) |
||||||
|
r_init = vmag(craft_e1.local_pos) |
||||||
|
pe_init = -G * m_craft * earth_e1.mass / r_init |
||||||
|
E_init = ke_init + pe_init |
||||||
|
|
||||||
|
v_before_e = vmag(v_init) |
||||||
|
apply_impulsive_burn(craft_e1, BurnDirection.PROGRADE, 2440.0, earth_e1.mass) |
||||||
|
v_final_e = craft_e1.local_vel |
||||||
|
ke_final = 0.5 * m_craft * vdot(v_final_e, v_final_e) |
||||||
|
pe_final = -G * m_craft * earth_e1.mass / vmag(craft_e1.local_pos) |
||||||
|
E_final = ke_final + pe_final |
||||||
|
|
||||||
|
dE_actual = E_final - E_init |
||||||
|
dv_vec = vsub(v_final_e, v_init) |
||||||
|
dE_expected = vdot(v_init, dv_vec) * m_craft + 0.5 * m_craft * vdot(dv_vec, dv_vec) |
||||||
|
dE_err = abs(dE_actual - dE_expected) / abs(dE_expected) |
||||||
|
|
||||||
|
print("// === Energy: prograde burn (2440 m/s) ===") |
||||||
|
print(f"// E_init = {E_init:.6f}") |
||||||
|
print(f"// E_final = {E_final:.6f}") |
||||||
|
print(f"// dE_actual = {dE_actual:.6f}") |
||||||
|
print(f"// dE_expected = {dE_expected:.6f}") |
||||||
|
print(f"// dE_relative_error = {dE_err:.15e}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Test: Energy conservation - retrograde burn |
||||||
|
sim_e2 = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft_e2 = sim_e2.spacecraft[0] |
||||||
|
earth_e2 = sim_e2.bodies[1] |
||||||
|
pos_e2, vel_e2 = orbital_to_cartesian(craft_e2.orbit, earth_e2.mass) |
||||||
|
craft_e2.local_pos = pos_e2 |
||||||
|
craft_e2.local_vel = vel_e2 |
||||||
|
|
||||||
|
m_e2 = craft_e2.mass |
||||||
|
v_init_e2 = craft_e2.local_vel |
||||||
|
ke_init_e2 = 0.5 * m_e2 * vdot(v_init_e2, v_init_e2) |
||||||
|
pe_init_e2 = -G * m_e2 * earth_e2.mass / vmag(craft_e2.local_pos) |
||||||
|
E_init_e2 = ke_init_e2 + pe_init_e2 |
||||||
|
|
||||||
|
apply_custom_burn(craft_e2, vscale(vnorm(v_init_e2), -1000.0)) |
||||||
|
v_final_e2 = craft_e2.local_vel |
||||||
|
ke_final_e2 = 0.5 * m_e2 * vdot(v_final_e2, v_final_e2) |
||||||
|
pe_final_e2 = -G * m_e2 * earth_e2.mass / vmag(craft_e2.local_pos) |
||||||
|
E_final_e2 = ke_final_e2 + pe_final_e2 |
||||||
|
|
||||||
|
dE_actual_e2 = E_final_e2 - E_init_e2 |
||||||
|
dv_vec_e2 = vsub(v_final_e2, v_init_e2) |
||||||
|
dE_expected_e2 = vdot(v_init_e2, dv_vec_e2) * m_e2 + 0.5 * m_e2 * vdot(dv_vec_e2, dv_vec_e2) |
||||||
|
dE_err_e2 = abs(dE_actual_e2 - dE_expected_e2) / abs(dE_expected_e2) |
||||||
|
|
||||||
|
print("// === Energy: retrograde burn (1000 m/s) ===") |
||||||
|
print(f"// E_init = {E_init_e2:.6f}") |
||||||
|
print(f"// E_final = {E_final_e2:.6f}") |
||||||
|
print(f"// dE_actual = {dE_actual_e2:.6f}") |
||||||
|
print(f"// dE_expected = {dE_expected_e2:.6f}") |
||||||
|
print(f"// dE_relative_error = {dE_err_e2:.15e}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Test: Round-trip conversion stability |
||||||
|
sim_rt = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft_rt = sim_rt.spacecraft[0] |
||||||
|
earth_rt = sim_rt.bodies[1] |
||||||
|
pos_rt, vel_rt = orbital_to_cartesian(craft_rt.orbit, earth_rt.mass) |
||||||
|
craft_rt.local_pos = pos_rt |
||||||
|
craft_rt.local_vel = vel_rt |
||||||
|
|
||||||
|
orig_a = craft_rt.orbit.a |
||||||
|
orig_e = craft_rt.orbit.e |
||||||
|
|
||||||
|
for _ in range(5): |
||||||
|
els_rt = cartesian_to_orbital_elements(craft_rt.local_pos, craft_rt.local_vel, earth_rt.mass) |
||||||
|
pos_rt, vel_rt = orbital_to_cartesian(els_rt, earth_rt.mass) |
||||||
|
craft_rt.local_pos = pos_rt |
||||||
|
craft_rt.local_vel = vel_rt |
||||||
|
|
||||||
|
final_els_rt = cartesian_to_orbital_elements(craft_rt.local_pos, craft_rt.local_vel, earth_rt.mass) |
||||||
|
a_err_rt = abs(final_els_rt.a - orig_a) / orig_a |
||||||
|
e_err_rt = abs(final_els_rt.e - orig_e) |
||||||
|
|
||||||
|
print("// === Round-trip conversion stability (5 iterations) ===") |
||||||
|
print(f"// orig_a = {orig_a:.6f}") |
||||||
|
print(f"// final_a = {final_els_rt.a:.6f}") |
||||||
|
print(f"// a_relative_error = {a_err_rt:.15e}") |
||||||
|
print(f"// orig_e = {orig_e:.15f}") |
||||||
|
print(f"// final_e = {final_els_rt.e:.15f}") |
||||||
|
print(f"// e_absolute_error = {e_err_rt:.15e}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Test: Burn direction orthogonality |
||||||
|
sim_dir = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft_dir = sim_dir.spacecraft[0] |
||||||
|
earth_dir = sim_dir.bodies[1] |
||||||
|
pos_dir, vel_dir = orbital_to_cartesian(craft_dir.orbit, earth_dir.mass) |
||||||
|
craft_dir.local_pos = pos_dir |
||||||
|
craft_dir.local_vel = vel_dir |
||||||
|
|
||||||
|
pro = get_burn_direction(BurnDirection.PROGRADE, pos_dir, vel_dir) |
||||||
|
retro = get_burn_direction(BurnDirection.RETROGRADE, pos_dir, vel_dir) |
||||||
|
norm_dir = get_burn_direction(BurnDirection.NORMAL, pos_dir, vel_dir) |
||||||
|
anti = get_burn_direction(BurnDirection.ANTINORMAL, pos_dir, vel_dir) |
||||||
|
rad_in = get_burn_direction(BurnDirection.RADIAL_IN, pos_dir, vel_dir) |
||||||
|
rad_out = get_burn_direction(BurnDirection.RADIAL_OUT, pos_dir, vel_dir) |
||||||
|
|
||||||
|
dot_pro_retro = vdot(pro, retro) |
||||||
|
dot_norm_anti = vdot(norm_dir, anti) |
||||||
|
dot_rad_in_out = vdot(rad_in, rad_out) |
||||||
|
|
||||||
|
print("// === Burn direction orthogonality ===") |
||||||
|
print(f"// prograde . retrograde = {dot_pro_retro:.15f}") |
||||||
|
print(f"// normal . antinormal = {dot_norm_anti:.15f}") |
||||||
|
print(f"// radial_in . radial_out = {dot_rad_in_out:.15f}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Test: Continuous burn (100 steps, 100 m/s total) |
||||||
|
sim_cb = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft_cb = sim_cb.spacecraft[6] # Low_Thrust_Ion |
||||||
|
earth_cb = sim_cb.bodies[1] |
||||||
|
|
||||||
|
initial_a_cb = craft_cb.orbit.a |
||||||
|
initial_e_cb = craft_cb.orbit.e |
||||||
|
|
||||||
|
final_cb = simulate_continuous_burn(craft_cb.orbit, earth_cb.mass, |
||||||
|
100.0, 5000.0, 100, BurnDirection.PROGRADE) |
||||||
|
|
||||||
|
a_cb = final_cb.a |
||||||
|
e_cb = final_cb.e |
||||||
|
v_circ_init = math.sqrt(mu / initial_a_cb) |
||||||
|
v_circ_final = math.sqrt(mu / a_cb) |
||||||
|
eps_init = -mu / (2.0 * initial_a_cb) |
||||||
|
eps_final = -mu / (2.0 * a_cb) |
||||||
|
delta_eps = eps_final - eps_init |
||||||
|
expected_dv_from_energy = delta_eps / v_circ_init |
||||||
|
rel_err_cb = abs(expected_dv_from_energy - 100.0) / 100.0 |
||||||
|
|
||||||
|
print("// === Continuous burn: 100 steps, 100 m/s total prograde ===") |
||||||
|
print(f"// initial_a = {initial_a_cb:.6f}") |
||||||
|
print(f"// final_a = {a_cb:.6f}") |
||||||
|
print(f"// initial_e = {initial_e_cb:.15f}") |
||||||
|
print(f"// final_e = {e_cb:.15f}") |
||||||
|
print(f"// v_circ_initial = {v_circ_init:.6f}") |
||||||
|
print(f"// v_circ_final = {v_circ_final:.6f}") |
||||||
|
print(f"// delta_specific_energy = {delta_eps:.6f}") |
||||||
|
print(f"// expected_dv_from_energy = {expected_dv_from_energy:.6f}") |
||||||
|
print(f"// relative_error = {rel_err_cb:.15e}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Test: Multi-burn continuous sequence |
||||||
|
sim_mb = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft_mb = sim_mb.spacecraft[7] # Multi_Burn_Sequence |
||||||
|
earth_mb = sim_mb.bodies[1] |
||||||
|
|
||||||
|
initial_a_mb = craft_mb.orbit.a |
||||||
|
|
||||||
|
orbit_after_1 = simulate_continuous_burn(craft_mb.orbit, earth_mb.mass, |
||||||
|
50.0, 2000.0, 20, BurnDirection.PROGRADE) |
||||||
|
a_after_1_mb = orbit_after_1.a |
||||||
|
final_mb = simulate_continuous_burn(orbit_after_1, earth_mb.mass, |
||||||
|
75.0, 3000.0, 30, BurnDirection.PROGRADE) |
||||||
|
|
||||||
|
a_mb = final_mb.a |
||||||
|
v_circ_init_mb = math.sqrt(mu / initial_a_mb) |
||||||
|
eps_init_mb = -mu / (2.0 * initial_a_mb) |
||||||
|
eps_final_mb = -mu / (2.0 * a_mb) |
||||||
|
delta_eps_mb = eps_final_mb - eps_init_mb |
||||||
|
expected_dv_mb = delta_eps_mb / v_circ_init_mb |
||||||
|
rel_err_mb = abs(expected_dv_mb - 125.0) / 125.0 |
||||||
|
|
||||||
|
print("// === Multi-burn continuous: 50+75 m/s total prograde ===") |
||||||
|
print(f"// initial_a = {initial_a_mb:.6f}") |
||||||
|
print(f"// after_1_a = {a_after_1_mb:.6f}") |
||||||
|
print(f"// final_a = {a_mb:.6f}") |
||||||
|
print(f"// total_dv = 125.0") |
||||||
|
print(f"// expected_dv_from_energy = {expected_dv_mb:.6f}") |
||||||
|
print(f"// relative_error = {rel_err_mb:.15e}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Test: Mode transition (elliptical orbit, continuous burn) |
||||||
|
sim_mt = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft_mt = sim_mt.spacecraft[8] # Mode_Transition |
||||||
|
earth_mt = sim_mt.bodies[1] |
||||||
|
|
||||||
|
initial_a_mt = craft_mt.orbit.a |
||||||
|
initial_e_mt = craft_mt.orbit.e |
||||||
|
|
||||||
|
final_mt = simulate_continuous_burn(craft_mt.orbit, earth_mt.mass, |
||||||
|
200.0, 4000.0, 80, BurnDirection.PROGRADE) |
||||||
|
|
||||||
|
a_mt = final_mt.a |
||||||
|
e_mt = final_mt.e |
||||||
|
mu_mt = G * earth_mt.mass |
||||||
|
energy_before = -mu_mt / (2.0 * initial_a_mt) |
||||||
|
energy_after = -mu_mt / (2.0 * a_mt) |
||||||
|
energy_change = energy_after - energy_before |
||||||
|
|
||||||
|
v_init_mt = math.sqrt(mu_mt / initial_a_mt) |
||||||
|
v_final_mt = math.sqrt(mu_mt / a_mt) |
||||||
|
expected_energy_change = 0.5 * (v_init_mt + v_final_mt) * 200.0 |
||||||
|
|
||||||
|
print("// === Mode transition: 80 steps, 200 m/s total prograde (e=0.3) ===") |
||||||
|
print(f"// initial_a = {initial_a_mt:.6f}") |
||||||
|
print(f"// initial_e = {initial_e_mt:.15f}") |
||||||
|
print(f"// final_a = {a_mt:.6f}") |
||||||
|
print(f"// final_e = {e_mt:.15f}") |
||||||
|
print(f"// energy_change = {energy_change:.6f}") |
||||||
|
print(f"// expected_energy_change = {expected_energy_change:.6f}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Test: Continuous energy conservation |
||||||
|
sim_ec = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft_ec = sim_ec.spacecraft[9] # Energy_Conservation |
||||||
|
earth_ec = sim_ec.bodies[1] |
||||||
|
|
||||||
|
ke_init_ec = 0.5 * craft_ec.mass * vdot(craft_ec.local_vel, craft_ec.local_vel) |
||||||
|
pe_init_ec = -G * craft_ec.mass * earth_ec.mass / vmag(craft_ec.local_pos) |
||||||
|
E_init_ec = ke_init_ec + pe_init_ec |
||||||
|
|
||||||
|
final_ec = simulate_continuous_burn(craft_ec.orbit, earth_ec.mass, |
||||||
|
150.0, 6000.0, 120, BurnDirection.PROGRADE) |
||||||
|
|
||||||
|
pos_ec, vel_ec = orbital_to_cartesian(final_ec, earth_ec.mass) |
||||||
|
temp_craft = Spacecraft(name="temp", mass=craft_ec.mass, parent_index=craft_ec.parent_index, |
||||||
|
orbit=final_ec, local_pos=pos_ec, local_vel=vel_ec, |
||||||
|
global_pos=(0, 0, 0), global_vel=(0, 0, 0)) |
||||||
|
ke_final_ec = 0.5 * craft_ec.mass * vdot(vel_ec, vel_ec) |
||||||
|
pe_final_ec = -G * craft_ec.mass * earth_ec.mass / vmag(pos_ec) |
||||||
|
E_final_ec = ke_final_ec + pe_final_ec |
||||||
|
|
||||||
|
total_dE_ec = E_final_ec - E_init_ec |
||||||
|
expected_dE_approx = craft_ec.mass * math.sqrt(mu / craft_ec.orbit.a) * 150.0 |
||||||
|
rel_err_ec = abs(total_dE_ec - expected_dE_approx) / expected_dE_approx |
||||||
|
|
||||||
|
print("// === Continuous energy conservation: 120 steps, 150 m/s ===") |
||||||
|
print(f"// E_init = {E_init_ec:.6f}") |
||||||
|
print(f"// E_final = {E_final_ec:.6f}") |
||||||
|
print(f"// total_dE = {total_dE_ec:.6f}") |
||||||
|
print(f"// expected_approx = {expected_dE_approx:.6f}") |
||||||
|
print(f"// relative_error = {rel_err_ec:.15e}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Test: Continuous vs impulsive comparison |
||||||
|
sim_cv = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft_cv = sim_cv.spacecraft[6] # Low_Thrust_Ion |
||||||
|
earth_cv = sim_cv.bodies[1] |
||||||
|
|
||||||
|
orbit_cont = simulate_continuous_burn(craft_cv.orbit, earth_cv.mass, |
||||||
|
100.0, 5000.0, 100, BurnDirection.PROGRADE) |
||||||
|
orbit_imp = simulate_continuous_burn(craft_cv.orbit, earth_cv.mass, |
||||||
|
100.0, 5000.0, 1, BurnDirection.PROGRADE) |
||||||
|
|
||||||
|
diff_a = abs(orbit_cont.a - orbit_imp.a) |
||||||
|
rel_diff_a = diff_a / orbit_cont.a * 100.0 |
||||||
|
|
||||||
|
v_cont = math.sqrt(mu / orbit_cont.a) |
||||||
|
v_imp = math.sqrt(mu / orbit_imp.a) |
||||||
|
v_diff = abs(v_cont - v_imp) |
||||||
|
|
||||||
|
print("// === Continuous vs impulsive (100 steps vs 1 step) ===") |
||||||
|
print(f"// continuous_a = {orbit_cont.a:.6f}") |
||||||
|
print(f"// impulsive_a = {orbit_imp.a:.6f}") |
||||||
|
print(f"// a_difference = {diff_a:.6f}") |
||||||
|
print(f"// a_relative_diff_pct = {rel_diff_a:.6f}%") |
||||||
|
print(f"// v_continuous = {v_cont:.6f}") |
||||||
|
print(f"// v_impulsive = {v_imp:.6f}") |
||||||
|
print(f"// v_difference = {v_diff:.6f}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Test: Propagation during burn - path length |
||||||
|
sim_prop = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft_prop = sim_prop.spacecraft[6] # Low_Thrust_Ion |
||||||
|
earth_prop = sim_prop.bodies[1] |
||||||
|
|
||||||
|
current_orbit = craft_prop.orbit |
||||||
|
dt_burn = 5000.0 / 100 |
||||||
|
dv_per = 100.0 / 100 |
||||||
|
|
||||||
|
positions = [] |
||||||
|
for i in range(101): |
||||||
|
pos, vel = orbital_to_cartesian(current_orbit, earth_prop.mass) |
||||||
|
positions.append(pos) |
||||||
|
if i < 100: |
||||||
|
burn_dir = get_burn_direction(BurnDirection.PROGRADE, pos, vel) |
||||||
|
vel = vadd(vel, vscale(burn_dir, dv_per)) |
||||||
|
current_orbit = cartesian_to_orbital_elements(pos, vel, earth_prop.mass) |
||||||
|
current_orbit = propagate(current_orbit, dt_burn, earth_prop.mass) |
||||||
|
|
||||||
|
total_path = sum(vmag(vsub(positions[i], positions[i-1])) for i in range(1, len(positions))) |
||||||
|
straight = vmag(vsub(positions[100], positions[0])) |
||||||
|
r_start = vmag(positions[0]) |
||||||
|
r_end = vmag(positions[100]) |
||||||
|
|
||||||
|
# Expected semi-major axis from energy |
||||||
|
v_init_prop = math.sqrt(mu / craft_prop.orbit.a) |
||||||
|
eps_init_prop = -mu / (2.0 * craft_prop.orbit.a) |
||||||
|
eps_final_prop = eps_init_prop + v_init_prop * 100.0 |
||||||
|
a_expected_prop = -mu / (2.0 * eps_final_prop) |
||||||
|
e_init_prop = craft_prop.orbit.e |
||||||
|
r_peri = a_expected_prop * (1.0 - e_init_prop) |
||||||
|
r_apo = a_expected_prop * (1.0 + e_init_prop) |
||||||
|
|
||||||
|
print("// === Propagation during burn: path length ===") |
||||||
|
print(f"// total_path_length = {total_path:.6f}") |
||||||
|
print(f"// straight_line = {straight:.6f}") |
||||||
|
print(f"// r_start = {r_start:.6f}") |
||||||
|
print(f"// r_end = {r_end:.6f}") |
||||||
|
print(f"// a_expected = {a_expected_prop:.6f}") |
||||||
|
print(f"// r_peri_expected = {r_peri:.6f}") |
||||||
|
print(f"// r_apo_expected = {r_apo:.6f}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Test: Numerical stability - monotonicity |
||||||
|
sim_stab = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft_stab = sim_stab.spacecraft[6] |
||||||
|
earth_stab = sim_stab.bodies[1] |
||||||
|
|
||||||
|
current_orbit = craft_stab.orbit |
||||||
|
dt_burn_s = 5000.0 / 100 |
||||||
|
dv_per_s = 100.0 / 100 |
||||||
|
|
||||||
|
a_history = [] |
||||||
|
e_history = [] |
||||||
|
for i in range(100): |
||||||
|
pos, vel = orbital_to_cartesian(current_orbit, earth_stab.mass) |
||||||
|
burn_dir = get_burn_direction(BurnDirection.PROGRADE, pos, vel) |
||||||
|
vel = vadd(vel, vscale(burn_dir, dv_per_s)) |
||||||
|
current_orbit = cartesian_to_orbital_elements(pos, vel, earth_stab.mass) |
||||||
|
current_orbit = propagate(current_orbit, dt_burn_s, earth_stab.mass) |
||||||
|
a_history.append(current_orbit.a) |
||||||
|
e_history.append(current_orbit.e) |
||||||
|
|
||||||
|
monotonic = all(a_history[i] >= a_history[i-1] for i in range(1, len(a_history))) |
||||||
|
max_e = max(e_history) |
||||||
|
min_e = min(e_history) |
||||||
|
|
||||||
|
initial_a_s = craft_stab.orbit.a |
||||||
|
final_a_s = a_history[-1] |
||||||
|
total_change_s = final_a_s - initial_a_s |
||||||
|
avg_change_s = total_change_s / 100 |
||||||
|
|
||||||
|
max_dev_s = max(abs(a_history[i] - (initial_a_s + (i+1) * avg_change_s)) for i in range(100)) |
||||||
|
|
||||||
|
print("// === Numerical stability: monotonicity ===") |
||||||
|
print(f"// monotonic_increase = {monotonic}") |
||||||
|
print(f"// max_eccentricity = {max_e:.15f}") |
||||||
|
print(f"// min_eccentricity = {min_e:.15f}") |
||||||
|
print(f"// total_a_change = {total_change_s:.6f}") |
||||||
|
print(f"// max_deviation_from_linear = {max_dev_s:.6f}") |
||||||
|
print(f"// max_deviation_pct = {max_dev_s / total_change_s * 100:.6f}%") |
||||||
|
print() |
||||||
|
|
||||||
|
# Test: Three-burn sequence with plane change |
||||||
|
sim_3b = Simulator("tests/test_hybrid_burns.toml", dt=dt) |
||||||
|
craft_3b = sim_3b.spacecraft[0] # Hohmann_Transfer |
||||||
|
earth_3b = sim_3b.bodies[1] |
||||||
|
pos_3b, vel_3b = orbital_to_cartesian(craft_3b.orbit, earth_3b.mass) |
||||||
|
craft_3b.local_pos = pos_3b |
||||||
|
craft_3b.local_vel = vel_3b |
||||||
|
|
||||||
|
init_a_3b = craft_3b.orbit.a |
||||||
|
init_inc_3b = craft_3b.orbit.inc |
||||||
|
|
||||||
|
# Burn 1: prograde 500 m/s |
||||||
|
burn1_dir = get_burn_direction(BurnDirection.PROGRADE, craft_3b.local_pos, craft_3b.local_vel) |
||||||
|
craft_3b.local_vel = vadd(craft_3b.local_vel, vscale(burn1_dir, 500.0)) |
||||||
|
craft_3b.orbit = cartesian_to_orbital_elements(craft_3b.local_pos, craft_3b.local_vel, earth_3b.mass) |
||||||
|
|
||||||
|
# Burn 2: normal 300 m/s |
||||||
|
burn2_dir = get_burn_direction(BurnDirection.NORMAL, craft_3b.local_pos, craft_3b.local_vel) |
||||||
|
craft_3b.local_vel = vadd(craft_3b.local_vel, vscale(burn2_dir, 300.0)) |
||||||
|
craft_3b.orbit = cartesian_to_orbital_elements(craft_3b.local_pos, craft_3b.local_vel, earth_3b.mass) |
||||||
|
|
||||||
|
# Burn 3: prograde 200 m/s |
||||||
|
burn3_dir = get_burn_direction(BurnDirection.PROGRADE, craft_3b.local_pos, craft_3b.local_vel) |
||||||
|
craft_3b.local_vel = vadd(craft_3b.local_vel, vscale(burn3_dir, 200.0)) |
||||||
|
craft_3b.orbit = cartesian_to_orbital_elements(craft_3b.local_pos, craft_3b.local_vel, earth_3b.mass) |
||||||
|
|
||||||
|
final_a_3b = craft_3b.orbit.a |
||||||
|
final_inc_3b = craft_3b.orbit.inc |
||||||
|
|
||||||
|
print("// === Three-burn sequence with plane change ===") |
||||||
|
print(f"// init_a = {init_a_3b:.6f}") |
||||||
|
print(f"// init_inc = {init_inc_3b:.15f}") |
||||||
|
print(f"// final_a = {final_a_3b:.6f}") |
||||||
|
print(f"// final_inc = {final_inc_3b:.15f}") |
||||||
|
print() |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
main() |
||||||
@ -0,0 +1,54 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Precalculate expected values for test_inclined_orbits.cpp. |
||||||
|
|
||||||
|
Usage: |
||||||
|
python3 scripts/precalc_inclined_orbits.py |
||||||
|
|
||||||
|
Outputs C++-style comments with precalculated values for embedding in the test. |
||||||
|
Uses scripts/sim_engine.py for the physics engine. |
||||||
|
""" |
||||||
|
|
||||||
|
import sys, math |
||||||
|
sys.path.insert(0, 'scripts') |
||||||
|
from sim_engine import orbital_to_cartesian, vmag, OrbitalElements, G |
||||||
|
|
||||||
|
# Molniya orbit |
||||||
|
a = 26540000.0 |
||||||
|
e = 0.74 |
||||||
|
inc = 1.107 |
||||||
|
omega = 4.71 |
||||||
|
Omega = 0.0 |
||||||
|
mu = G * 5.972e24 |
||||||
|
|
||||||
|
r_peri = a * (1.0 - e) |
||||||
|
r_apo = a * (1.0 + e) |
||||||
|
r_90 = a * (1.0 - e*e) / (1.0 + e * math.cos(math.pi/2.0)) |
||||||
|
r_270 = a * (1.0 - e*e) / (1.0 + e * math.cos(3.0*math.pi/2.0)) |
||||||
|
|
||||||
|
T = 2 * math.pi * math.sqrt(a**3 / mu) |
||||||
|
T_half = T / 2 |
||||||
|
|
||||||
|
print("# Molniya radii:") |
||||||
|
print(f"# r_peri = {r_peri:.6f}") |
||||||
|
print(f"# r_90 = {r_90:.6f}") |
||||||
|
print(f"# r_apo = {r_apo:.6f}") |
||||||
|
print(f"# r_270 = {r_270:.6f}") |
||||||
|
print(f"#") |
||||||
|
print(f"# Period: {T:.6f} s = {T/3600:.6f} hours") |
||||||
|
print(f"# Half period: {T_half:.6f} s = {T_half/3600:.6f} hours") |
||||||
|
|
||||||
|
# Generic inclined orbit |
||||||
|
a2 = 10000000.0 |
||||||
|
e2 = 0.5 |
||||||
|
inc2 = math.radians(45) |
||||||
|
omega2 = math.pi / 2 |
||||||
|
|
||||||
|
elements2 = OrbitalElements(a=a2, e=e2, nu=0.0, inc=inc2, Omega=0.0, omega=omega2) |
||||||
|
pos2, vel2 = orbital_to_cartesian(elements2, 5.972e24) |
||||||
|
r2 = vmag(pos2) |
||||||
|
z2 = pos2[2] |
||||||
|
|
||||||
|
print(f"\n# Generic inclined (a={a2}, e={e2}, i=45deg, omega=90deg):") |
||||||
|
print(f"# r = {r2:.6f} m") |
||||||
|
print(f"# z = {z2:.6f} m") |
||||||
@ -0,0 +1,128 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Precalculate expected values for test_maneuver_planning.cpp refactoring. |
||||||
|
Computes velocities and energy after time-based and true anomaly-based |
||||||
|
maneuver triggers. |
||||||
|
""" |
||||||
|
|
||||||
|
import math |
||||||
|
import sys |
||||||
|
sys.path.insert(0, "/home/agent/dev/claudes_game") |
||||||
|
from scripts.sim_engine import * |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
G_const = G |
||||||
|
earth_mass = 5.972e24 |
||||||
|
mu = G_const * earth_mass |
||||||
|
|
||||||
|
# Initial circular orbit at r = 6.771e6 m |
||||||
|
r0 = 6.771e6 |
||||||
|
v_circular = math.sqrt(mu / r0) |
||||||
|
|
||||||
|
print("// Initial circular orbit") |
||||||
|
print(f"// r = {r0} m") |
||||||
|
print(f"// v_circular = {v_circular:.15e} m/s") |
||||||
|
print() |
||||||
|
|
||||||
|
# Run the full simulation with the TOML config |
||||||
|
sim = Simulator("tests/test_maneuver_planning.toml", dt=60.0) |
||||||
|
|
||||||
|
# Get initial craft velocity |
||||||
|
craft = sim.get_craft("LEO_Satellite") |
||||||
|
v_initial = vmag(craft.local_vel) |
||||||
|
print(f"// Initial craft velocity from config: {v_initial:.15e} m/s") |
||||||
|
print() |
||||||
|
|
||||||
|
# Run to just before first burn (t=3600) |
||||||
|
steps_to_first_burn = int(3600.0 / 60.0) # 60 steps |
||||||
|
sim.run(steps_to_first_burn) |
||||||
|
|
||||||
|
craft = sim.get_craft("LEO_Satellite") |
||||||
|
v_before_burn1 = vmag(craft.local_vel) |
||||||
|
t_before = sim.time |
||||||
|
|
||||||
|
# Check if maneuver[0] executed |
||||||
|
man = sim.maneuvers[0] |
||||||
|
print("// First burn (time trigger at 3600.0 s)") |
||||||
|
print(f"// Time at step end: {t_before:.1f} s") |
||||||
|
print(f"// Executed: {man.executed}") |
||||||
|
print(f"// Executed time: {man.executed_time:.15e} s") |
||||||
|
print(f"// Velocity before burn: {v_before_burn1:.15e} m/s") |
||||||
|
print() |
||||||
|
|
||||||
|
# Continue a bit more to ensure burn fires |
||||||
|
sim.run(1) |
||||||
|
man = sim.maneuvers[0] |
||||||
|
craft = sim.get_craft("LEO_Satellite") |
||||||
|
v_after_burn1 = vmag(craft.local_vel) |
||||||
|
a_after_burn1 = craft.orbit.a |
||||||
|
e_after_burn1 = craft.orbit.e |
||||||
|
r_after_burn1 = vmag(craft.local_pos) |
||||||
|
|
||||||
|
print(f"// After stepping past burn:") |
||||||
|
print(f"// Executed: {man.executed}") |
||||||
|
print(f"// Executed time: {man.executed_time:.15e} s") |
||||||
|
print(f"// Velocity after burn: {v_after_burn1:.15e} m/s") |
||||||
|
print(f"// Semi-major axis: {a_after_burn1:.15e} m") |
||||||
|
print(f"// Eccentricity: {e_after_burn1:.15e}") |
||||||
|
print(f"// Radius: {r_after_burn1:.15e} m") |
||||||
|
print(f"// KE after first burn: {0.5 * 1000.0 * v_after_burn1 * v_after_burn1:.15e} J") |
||||||
|
print() |
||||||
|
|
||||||
|
# Continue until second burn fires (true anomaly 0.0) |
||||||
|
max_additional_steps = 2000 # should be enough |
||||||
|
second_burn_fired = False |
||||||
|
for step in range(max_additional_steps): |
||||||
|
sim.run(1) |
||||||
|
if sim.maneuvers[1].executed: |
||||||
|
second_burn_fired = True |
||||||
|
break |
||||||
|
|
||||||
|
craft = sim.get_craft("LEO_Satellite") |
||||||
|
v_after_burn2 = vmag(craft.local_vel) |
||||||
|
a_after_burn2 = craft.orbit.a |
||||||
|
e_after_burn2 = craft.orbit.e |
||||||
|
|
||||||
|
man2 = sim.maneuvers[1] |
||||||
|
print("// Second burn (true anomaly trigger at 0.0)") |
||||||
|
print(f"// Fired: {second_burn_fired}") |
||||||
|
print(f"// Executed time: {man2.executed_time:.15e} s") |
||||||
|
print(f"// Sim time: {sim.time:.1f} s") |
||||||
|
print(f"// Velocity after second burn: {v_after_burn2:.15e} m/s") |
||||||
|
print(f"// Semi-major axis: {a_after_burn2:.15e} m") |
||||||
|
print(f"// Eccentricity: {e_after_burn2:.15e}") |
||||||
|
print(f"// KE after second burn: {0.5 * 1000.0 * v_after_burn2 * v_after_burn2:.15e} J") |
||||||
|
print() |
||||||
|
|
||||||
|
# Also get the exact burn result for second burn |
||||||
|
br = man2.burn_result |
||||||
|
print(f"// Pre-burn state at second burn:") |
||||||
|
print(f"// pos = {br.position}") |
||||||
|
print(f"// vel = {br.velocity}") |
||||||
|
print(f"// true_anomaly = {br.true_anomaly:.15e} rad") |
||||||
|
print() |
||||||
|
|
||||||
|
# Run beyond well past to verify no extra executions |
||||||
|
sim.run(500) |
||||||
|
exec_count = sum(1 for m in sim.maneuvers if m.executed) |
||||||
|
print("// After extra simulation") |
||||||
|
print(f"// Total executed: {exec_count}") |
||||||
|
for i, m in enumerate(sim.maneuvers): |
||||||
|
print(f"// Maneuver[{i}] '{m.name}': executed={m.executed}, time={m.executed_time:.1f} s") |
||||||
|
print() |
||||||
|
|
||||||
|
print("// For WithinAbs assertions:") |
||||||
|
print(f"// v_initial := {v_circular:.15e}") |
||||||
|
print(f"// v_after_burn1 := {v_after_burn1:.15e}") |
||||||
|
print(f"// a_after_burn1 := {a_after_burn1:.15e}") |
||||||
|
print(f"// e_after_burn1 := {e_after_burn1:.15e}") |
||||||
|
print(f"// v_after_burn2 := {v_after_burn2:.15e}") |
||||||
|
print(f"// a_after_burn2 := {a_after_burn2:.15e}") |
||||||
|
print(f"// e_after_burn2 := {e_after_burn2:.15e}") |
||||||
|
print(f"// executed_time_1 := {man.executed_time:.15e}") |
||||||
|
print(f"// executed_time_2 := {man2.executed_time:.15e}") |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
main() |
||||||
@ -0,0 +1,189 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Precalculate expected values for test_maneuvers.cpp refactoring. |
||||||
|
Uses sim_engine.py for physics propagation. |
||||||
|
""" |
||||||
|
|
||||||
|
import math |
||||||
|
import sys |
||||||
|
sys.path.insert(0, "/home/agent/dev/claudes_game") |
||||||
|
from scripts.sim_engine import * |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
dt = 60.0 |
||||||
|
sim = Simulator("tests/test_maneuvers.toml", dt=dt) |
||||||
|
craft = sim.spacecraft[0] |
||||||
|
earth = sim.bodies[1] |
||||||
|
|
||||||
|
# ========================================================================= |
||||||
|
# Test 1: Basic loading |
||||||
|
# ========================================================================= |
||||||
|
print("// === Test 1: Spacecraft loading from config ===") |
||||||
|
print(f"// craft_count = {len(sim.spacecraft)}") |
||||||
|
print(f"// craft[0].name = \"{craft.name}\"") |
||||||
|
print(f"// craft[0].parent_index = {craft.parent_index}") |
||||||
|
print() |
||||||
|
|
||||||
|
# ========================================================================= |
||||||
|
# Test 2: Prograde burn |
||||||
|
# ========================================================================= |
||||||
|
sim2 = Simulator("tests/test_maneuvers.toml", dt=dt) |
||||||
|
craft2 = sim2.spacecraft[0] |
||||||
|
v0 = vmag(craft2.local_vel) |
||||||
|
r0 = vmag(craft2.local_pos) |
||||||
|
|
||||||
|
apply_impulsive_burn(craft2, BurnDirection.PROGRADE, 100.0, earth.mass) |
||||||
|
v_after = vmag(craft2.local_vel) |
||||||
|
|
||||||
|
# Simulate 3600s |
||||||
|
steps = int(3600.0 / dt) |
||||||
|
for _ in range(steps): |
||||||
|
sim2._step() |
||||||
|
sim2.time += sim2.dt |
||||||
|
r_final = vmag(craft2.local_pos) |
||||||
|
|
||||||
|
print("// === Test 2: Prograde burn ===") |
||||||
|
print(f"// initial_velocity = {v0:.4f}") |
||||||
|
print(f"// velocity_after_burn = {v_after:.4f}") |
||||||
|
print(f"// delta_v = {v_after - v0:.4f}") |
||||||
|
print(f"// initial_local_r = {r0:.4f}") |
||||||
|
print(f"// final_local_r (t=3600s) = {r_final:.4f}") |
||||||
|
print(f"// delta_r = {r_final - r0:.4f}") |
||||||
|
print() |
||||||
|
|
||||||
|
# ========================================================================= |
||||||
|
# Test 3: Retrograde burn |
||||||
|
# ========================================================================= |
||||||
|
sim3 = Simulator("tests/test_maneuvers.toml", dt=dt) |
||||||
|
craft3 = sim3.spacecraft[0] |
||||||
|
v0r = vmag(craft3.local_vel) |
||||||
|
r0r = vmag(craft3.local_pos) |
||||||
|
|
||||||
|
apply_impulsive_burn(craft3, BurnDirection.RETROGRADE, 100.0, earth.mass) |
||||||
|
v_after_r = vmag(craft3.local_vel) |
||||||
|
|
||||||
|
for _ in range(steps): |
||||||
|
sim3._step() |
||||||
|
sim3.time += sim3.dt |
||||||
|
r_final_r = vmag(craft3.local_pos) |
||||||
|
|
||||||
|
print("// === Test 3: Retrograde burn ===") |
||||||
|
print(f"// initial_velocity = {v0r:.4f}") |
||||||
|
print(f"// velocity_after_burn = {v_after_r:.4f}") |
||||||
|
print(f"// delta_v = {v_after_r - v0r:.4f}") |
||||||
|
print(f"// initial_local_r = {r0r:.4f}") |
||||||
|
print(f"// final_local_r (t=3600s) = {r_final_r:.4f}") |
||||||
|
print(f"// delta_r = {r_final_r - r0r:.4f}") |
||||||
|
print() |
||||||
|
|
||||||
|
# ========================================================================= |
||||||
|
# Test 4: Normal burn |
||||||
|
# ========================================================================= |
||||||
|
sim4 = Simulator("tests/test_maneuvers.toml", dt=dt) |
||||||
|
craft4 = sim4.spacecraft[0] |
||||||
|
z0 = craft4.local_pos[2] |
||||||
|
|
||||||
|
apply_impulsive_burn(craft4, BurnDirection.NORMAL, 500.0, earth.mass) |
||||||
|
|
||||||
|
for _ in range(steps): |
||||||
|
sim4._step() |
||||||
|
sim4.time += sim4.dt |
||||||
|
z_final = craft4.local_pos[2] |
||||||
|
z_change = abs(z_final - z0) |
||||||
|
|
||||||
|
print("// === Test 4: Normal burn ===") |
||||||
|
print(f"// initial_z = {z0:.4f}") |
||||||
|
print(f"// final_z (t=3600s) = {z_final:.4f}") |
||||||
|
print(f"// |z_change| = {z_change:.4f}") |
||||||
|
print() |
||||||
|
|
||||||
|
# ========================================================================= |
||||||
|
# Test 5: Custom burn |
||||||
|
# ========================================================================= |
||||||
|
sim5 = Simulator("tests/test_maneuvers.toml", dt=dt) |
||||||
|
craft5 = sim5.spacecraft[0] |
||||||
|
iv5 = craft5.local_vel |
||||||
|
|
||||||
|
apply_custom_burn(craft5, (10.0, 20.0, 30.0)) |
||||||
|
|
||||||
|
print("// === Test 5: Custom burn ===") |
||||||
|
print(f"// initial_vel = ({iv5[0]:.4f}, {iv5[1]:.4f}, {iv5[2]:.4f})") |
||||||
|
print(f"// final_vel = ({craft5.local_vel[0]:.4f}, {craft5.local_vel[1]:.4f}, {craft5.local_vel[2]:.4f})") |
||||||
|
print(f"// dx = {craft5.local_vel[0] - iv5[0]:.4f}") |
||||||
|
print(f"// dy = {craft5.local_vel[1] - iv5[1]:.4f}") |
||||||
|
print(f"// dz = {craft5.local_vel[2] - iv5[2]:.4f}") |
||||||
|
print() |
||||||
|
|
||||||
|
# ========================================================================= |
||||||
|
# Test 6: Propagation stability (1 day) |
||||||
|
# ========================================================================= |
||||||
|
sim6 = Simulator("tests/test_maneuvers.toml", dt=dt) |
||||||
|
craft6 = sim6.spacecraft[0] |
||||||
|
r0_6 = vmag(craft6.local_pos) |
||||||
|
a0_6 = craft6.orbit.a |
||||||
|
e0_6 = craft6.orbit.e |
||||||
|
|
||||||
|
total_time = 86400.0 |
||||||
|
steps6 = int(total_time / dt) |
||||||
|
for _ in range(steps6): |
||||||
|
sim6._step() |
||||||
|
sim6.time += sim6.dt |
||||||
|
r_final_6 = vmag(craft6.local_pos) |
||||||
|
a_final_6 = craft6.orbit.a |
||||||
|
e_final_6 = craft6.orbit.e |
||||||
|
drift_pct = abs(r_final_6 - r0_6) / r0_6 * 100.0 |
||||||
|
a_drift_pct = abs(a_final_6 - a0_6) / a0_6 * 100.0 |
||||||
|
|
||||||
|
print("// === Test 6: Propagation stability (1 day) ===") |
||||||
|
print(f"// initial_local_r = {r0_6:.4f}") |
||||||
|
print(f"// final_local_r (t=86400s) = {r_final_6:.4f}") |
||||||
|
print(f"// distance_drift_pct = {drift_pct:.10f}%") |
||||||
|
print(f"// initial_a = {a0_6:.4f}") |
||||||
|
print(f"// final_a = {a_final_6:.4f}") |
||||||
|
print(f"// a_drift_pct = {a_drift_pct:.10f}%") |
||||||
|
print(f"// initial_e = {e0_6:.10f}") |
||||||
|
print(f"// final_e = {e_final_6:.10f}") |
||||||
|
print() |
||||||
|
|
||||||
|
# ========================================================================= |
||||||
|
# Test 7: State vectors at orbital quarters |
||||||
|
# ========================================================================= |
||||||
|
sim7 = Simulator("tests/test_maneuvers.toml", dt=dt) |
||||||
|
craft7 = sim7.spacecraft[0] |
||||||
|
|
||||||
|
orbit_radius = vmag(craft7.local_pos) |
||||||
|
period = 2 * math.pi * math.sqrt(orbit_radius**3 / (G * earth.mass)) |
||||||
|
quarter_time = period / 4 |
||||||
|
steps_per_quarter = int(quarter_time / dt) |
||||||
|
|
||||||
|
print("// === Test 7: State vectors at orbital quarters ===") |
||||||
|
print(f"// orbit_radius = {orbit_radius:.4f}") |
||||||
|
print(f"// orbital_period = {period:.4f} s ({period/3600:.4f} hours)") |
||||||
|
print(f"// steps_per_quarter = {steps_per_quarter}") |
||||||
|
|
||||||
|
for q in range(5): |
||||||
|
angle = math.atan2(craft7.local_pos[1], craft7.local_pos[0]) |
||||||
|
if angle < 0: |
||||||
|
angle += 2 * math.pi |
||||||
|
r_q = vmag(craft7.local_pos) |
||||||
|
v_q = vmag(craft7.local_vel) |
||||||
|
print(f"// Q{q}: angle={math.degrees(angle):.4f}°, r={r_q:.4f}, v={v_q:.4f}") |
||||||
|
|
||||||
|
if q < 4: |
||||||
|
for _ in range(steps_per_quarter): |
||||||
|
sim7._step() |
||||||
|
sim7.time += sim7.dt |
||||||
|
|
||||||
|
# Full rotation check |
||||||
|
final_angle = math.atan2(craft7.local_pos[1], craft7.local_pos[0]) |
||||||
|
if final_angle < 0: |
||||||
|
final_angle += 2 * math.pi |
||||||
|
total_deg = math.degrees(final_angle) |
||||||
|
print(f"// total_rotation = {total_deg:.4f}° = {final_angle:.6f} rad") |
||||||
|
print(f"// angle_change_per_quarter = {(total_deg / 4):.4f}°") |
||||||
|
print() |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
main() |
||||||
@ -0,0 +1,360 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Precalculate moon orbit TOML config from planetary_data.md values. |
||||||
|
|
||||||
|
Converts mean anomaly (M) at J2000 to true anomaly (nu) via Kepler's equation, |
||||||
|
then outputs a complete test TOML config with correct planetary masses, |
||||||
|
eccentricities, inclinations, and orbital elements. |
||||||
|
|
||||||
|
Usage: |
||||||
|
python3 scripts/precalc_moon_orbits.py |
||||||
|
|
||||||
|
Outputs: |
||||||
|
- TOML config to stdout (redirect to tests/test_moon_orbits.toml) |
||||||
|
- Console summary of computed values |
||||||
|
""" |
||||||
|
|
||||||
|
import sys, math |
||||||
|
|
||||||
|
sys.path.insert(0, "scripts") |
||||||
|
from sim_engine import ( |
||||||
|
solve_kepler_elliptical, |
||||||
|
orbital_to_cartesian, |
||||||
|
vmag, |
||||||
|
G, |
||||||
|
normalize_angle, |
||||||
|
OrbitalElements, |
||||||
|
) |
||||||
|
|
||||||
|
# Planetary data from docs/planetary_data.md |
||||||
|
|
||||||
|
SUN_MASS = 1.989e30 |
||||||
|
SUN_RADIUS = 6.96e8 |
||||||
|
|
||||||
|
PLANETS = [ |
||||||
|
{ |
||||||
|
"name": "Venus", |
||||||
|
"mass": 4.87e24, |
||||||
|
"radius": 6.052e6, |
||||||
|
"parent": 0, |
||||||
|
"a_au": 0.723, |
||||||
|
"e": 0.007, |
||||||
|
"inc_deg": 3.39, |
||||||
|
"Omega_deg": 76.68, |
||||||
|
"omega_deg": 54.92, |
||||||
|
"M_deg": 50.38, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Earth", |
||||||
|
"mass": 5.97e24, |
||||||
|
"radius": 6.378e6, |
||||||
|
"parent": 0, |
||||||
|
"a_au": 1.000, |
||||||
|
"e": 0.017, |
||||||
|
"inc_deg": 0.00, |
||||||
|
"Omega_deg": 0.00, |
||||||
|
"omega_deg": 102.94, |
||||||
|
"M_deg": -2.47, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Mars", |
||||||
|
"mass": 6.42e23, |
||||||
|
"radius": 3.396e6, |
||||||
|
"parent": 0, |
||||||
|
"a_au": 1.524, |
||||||
|
"e": 0.093, |
||||||
|
"inc_deg": 1.85, |
||||||
|
"Omega_deg": 49.56, |
||||||
|
"omega_deg": 286.50, |
||||||
|
"M_deg": 19.39, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Jupiter", |
||||||
|
"mass": 1.898e27, |
||||||
|
"radius": 71.492e6, |
||||||
|
"parent": 0, |
||||||
|
"a_au": 5.203, |
||||||
|
"e": 0.049, |
||||||
|
"inc_deg": 1.31, |
||||||
|
"Omega_deg": 100.47, |
||||||
|
"omega_deg": 274.25, |
||||||
|
"M_deg": 19.67, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Saturn", |
||||||
|
"mass": 5.683e26, |
||||||
|
"radius": 60.268e6, |
||||||
|
"parent": 0, |
||||||
|
"a_au": 9.537, |
||||||
|
"e": 0.057, |
||||||
|
"inc_deg": 2.49, |
||||||
|
"Omega_deg": 113.66, |
||||||
|
"omega_deg": 338.94, |
||||||
|
"M_deg": -42.64, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Uranus", |
||||||
|
"mass": 8.68e25, |
||||||
|
"radius": 25.559e6, |
||||||
|
"parent": 0, |
||||||
|
"a_au": 19.19, |
||||||
|
"e": 0.046, |
||||||
|
"inc_deg": 0.77, |
||||||
|
"Omega_deg": 74.02, |
||||||
|
"omega_deg": 96.94, |
||||||
|
"M_deg": 142.28, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Neptune", |
||||||
|
"mass": 1.02e26, |
||||||
|
"radius": 24.764e6, |
||||||
|
"parent": 0, |
||||||
|
"a_au": 30.07, |
||||||
|
"e": 0.010, |
||||||
|
"inc_deg": 1.77, |
||||||
|
"Omega_deg": 131.78, |
||||||
|
"omega_deg": 273.18, |
||||||
|
"M_deg": -100.08, |
||||||
|
}, |
||||||
|
] |
||||||
|
|
||||||
|
AU = 1.496e11 # meters |
||||||
|
|
||||||
|
MOONS = [ |
||||||
|
{ |
||||||
|
"name": "Moon", |
||||||
|
"mass": 7.35e22, |
||||||
|
"radius": 1.738e6, |
||||||
|
"parent": "Earth", |
||||||
|
"a_km": 384400, |
||||||
|
"e": 0.055, |
||||||
|
"inc_deg": 5.16, |
||||||
|
"Omega_deg": 125.08, |
||||||
|
"omega_deg": 318.15, |
||||||
|
"M_deg": 135.27, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Io", |
||||||
|
"mass": 8.93e23, |
||||||
|
"radius": 1.822e6, |
||||||
|
"parent": "Jupiter", |
||||||
|
"a_km": 421800, |
||||||
|
"e": 0.004, |
||||||
|
"inc_deg": 0.00, |
||||||
|
"Omega_deg": 0.0, |
||||||
|
"omega_deg": 49.1, |
||||||
|
"M_deg": 330.9, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Europa", |
||||||
|
"mass": 4.80e23, |
||||||
|
"radius": 1.561e6, |
||||||
|
"parent": "Jupiter", |
||||||
|
"a_km": 671100, |
||||||
|
"e": 0.009, |
||||||
|
"inc_deg": 0.50, |
||||||
|
"Omega_deg": 184.0, |
||||||
|
"omega_deg": 45.0, |
||||||
|
"M_deg": 345.4, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Ganymede", |
||||||
|
"mass": 1.48e24, |
||||||
|
"radius": 2.631e6, |
||||||
|
"parent": "Jupiter", |
||||||
|
"a_km": 1070400, |
||||||
|
"e": 0.001, |
||||||
|
"inc_deg": 0.20, |
||||||
|
"Omega_deg": 58.5, |
||||||
|
"omega_deg": 198.3, |
||||||
|
"M_deg": 324.8, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Callisto", |
||||||
|
"mass": 1.08e24, |
||||||
|
"radius": 2.410e6, |
||||||
|
"parent": "Jupiter", |
||||||
|
"a_km": 1882700, |
||||||
|
"e": 0.007, |
||||||
|
"inc_deg": 0.30, |
||||||
|
"Omega_deg": 309.1, |
||||||
|
"omega_deg": 43.8, |
||||||
|
"M_deg": 87.4, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Titan", |
||||||
|
"mass": 1.35e24, |
||||||
|
"radius": 2.575e6, |
||||||
|
"parent": "Saturn", |
||||||
|
"a_km": 1221900, |
||||||
|
"e": 0.029, |
||||||
|
"inc_deg": 0.30, |
||||||
|
"Omega_deg": 78.6, |
||||||
|
"omega_deg": 78.3, |
||||||
|
"M_deg": 11.7, |
||||||
|
}, |
||||||
|
] |
||||||
|
|
||||||
|
|
||||||
|
# Kepler conversion: M -> E -> nu |
||||||
|
|
||||||
|
def mean_to_true_anomaly(M_deg, e): |
||||||
|
"""Convert mean anomaly (degrees) to true anomaly (radians) via Kepler's equation.""" |
||||||
|
M = math.radians(M_deg) |
||||||
|
E = solve_kepler_elliptical(M, e) |
||||||
|
# tan(nu/2) = sqrt((1+e)/(1-e)) * tan(E/2) |
||||||
|
tan_half_e = math.tan(E / 2.0) |
||||||
|
tan_half_nu = math.sqrt((1.0 + e) / (1.0 - e)) * tan_half_e |
||||||
|
nu = 2.0 * math.atan(tan_half_nu) |
||||||
|
return normalize_angle(nu) |
||||||
|
|
||||||
|
|
||||||
|
# Print TOML config |
||||||
|
|
||||||
|
def print_toml(): |
||||||
|
print("# Moon Orbits Test Configuration") |
||||||
|
print("# Auto-generated by scripts/precalc_moon_orbits.py") |
||||||
|
print("# Data source: docs/planetary_data.md (JPL planetary facts)") |
||||||
|
print("# Mean anomaly converted to true anomaly via Kepler's equation") |
||||||
|
print() |
||||||
|
|
||||||
|
# Sun |
||||||
|
print('[[bodies]]') |
||||||
|
print('name = "Sun"') |
||||||
|
print(f"mass = {SUN_MASS}") |
||||||
|
print(f"radius = {SUN_RADIUS}") |
||||||
|
print("parent_index = -1") |
||||||
|
print('color = { r = 1.0, g = 1.0, b = 0.0 }') |
||||||
|
print("orbit.semi_major_axis = 0.0") |
||||||
|
print("orbit.eccentricity = 0.0") |
||||||
|
print("orbit.true_anomaly = 0.0") |
||||||
|
print() |
||||||
|
|
||||||
|
# Planets |
||||||
|
for p in PLANETS: |
||||||
|
a_m = p["a_au"] * AU |
||||||
|
inc = math.radians(p["inc_deg"]) |
||||||
|
Omega = math.radians(p["Omega_deg"]) |
||||||
|
omega = math.radians(p["omega_deg"]) |
||||||
|
nu = mean_to_true_anomaly(p["M_deg"], p["e"]) |
||||||
|
|
||||||
|
print('[[bodies]]') |
||||||
|
print(f'name = "{p["name"]}"') |
||||||
|
print(f'mass = {p["mass"]}') |
||||||
|
print(f'radius = {p["radius"]}') |
||||||
|
print(f'parent_index = {p["parent"]}') |
||||||
|
print('color = { r = 0.5, g = 0.5, b = 0.5 }') |
||||||
|
print(f"orbit.semi_major_axis = {a_m:.6e}") |
||||||
|
print(f"orbit.eccentricity = {p['e']}") |
||||||
|
print(f"orbit.inclination = {inc:.15f}") |
||||||
|
print(f"orbit.longitude_of_ascending_node = {Omega:.15f}") |
||||||
|
print(f"orbit.argument_of_periapsis = {omega:.15f}") |
||||||
|
print(f"orbit.true_anomaly = {nu:.15f}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Moons |
||||||
|
for m in MOONS: |
||||||
|
a_m = m["a_km"] * 1000.0 |
||||||
|
inc = math.radians(m["inc_deg"]) |
||||||
|
Omega = math.radians(m["Omega_deg"]) |
||||||
|
omega = math.radians(m["omega_deg"]) |
||||||
|
nu = mean_to_true_anomaly(m["M_deg"], m["e"]) |
||||||
|
|
||||||
|
print('[[bodies]]') |
||||||
|
print(f'name = "{m["name"]}"') |
||||||
|
print(f'mass = {m["mass"]}') |
||||||
|
print(f'radius = {m["radius"]}') |
||||||
|
parent_idx = {"Earth": 2, "Jupiter": 4, "Saturn": 5}[m["parent"]] |
||||||
|
print(f'parent_index = {parent_idx}') |
||||||
|
print('color = { r = 0.7, g = 0.7, b = 0.7 }') |
||||||
|
print(f"orbit.semi_major_axis = {a_m:.6e}") |
||||||
|
print(f"orbit.eccentricity = {m['e']}") |
||||||
|
print(f"orbit.inclination = {inc:.15f}") |
||||||
|
print(f"orbit.longitude_of_ascending_node = {Omega:.15f}") |
||||||
|
print(f"orbit.argument_of_periapsis = {omega:.15f}") |
||||||
|
print(f"orbit.true_anomaly = {nu:.15f}") |
||||||
|
print() |
||||||
|
|
||||||
|
|
||||||
|
# Print computed values summary (for verification) |
||||||
|
|
||||||
|
def print_summary(): |
||||||
|
print("# === Computed True Anomalies ===") |
||||||
|
print() |
||||||
|
|
||||||
|
for m in MOONS: |
||||||
|
nu = mean_to_true_anomaly(m["M_deg"], m["e"]) |
||||||
|
nu_deg = nu * 180.0 / math.pi |
||||||
|
a_m = m["a_km"] * 1000.0 |
||||||
|
mu = G * eval(f"{m['parent']}_MASS") if m["parent"] in globals() else 0 |
||||||
|
|
||||||
|
# Compute period |
||||||
|
parent_mass = {"Earth": 5.97e24, "Jupiter": 1.898e27, "Saturn": 5.683e26}[m["parent"]] |
||||||
|
mu = G * parent_mass |
||||||
|
T = 2.0 * math.pi * math.sqrt(a_m**3 / mu) |
||||||
|
T_days = T / 86400.0 |
||||||
|
|
||||||
|
print( |
||||||
|
f'{m["name"]:10s}: M={m["M_deg"]:7.2f}deg -> nu={nu_deg:7.2f}deg ' |
||||||
|
f"a={m['a_km']:>8.0f}km e={m['e']:.3f} " |
||||||
|
f"T={T_days:.3f}d" |
||||||
|
) |
||||||
|
|
||||||
|
print() |
||||||
|
print("# === Initial positions (from true anomaly) ===") |
||||||
|
print("# Format: name, r (m), nu (deg)") |
||||||
|
|
||||||
|
parent_masses = { |
||||||
|
"Earth": 5.97e24, |
||||||
|
"Jupiter": 1.898e27, |
||||||
|
"Saturn": 5.683e26, |
||||||
|
} |
||||||
|
|
||||||
|
for m in MOONS: |
||||||
|
a_m = m["a_km"] * 1000.0 |
||||||
|
nu = mean_to_true_anomaly(m["M_deg"], m["e"]) |
||||||
|
pm = parent_masses[m["parent"]] |
||||||
|
el = OrbitalElements( |
||||||
|
a=a_m, e=m["e"], nu=nu, |
||||||
|
inc=math.radians(m["inc_deg"]), |
||||||
|
Omega=math.radians(m["Omega_deg"]), |
||||||
|
omega=math.radians(m["omega_deg"]), |
||||||
|
) |
||||||
|
pos, vel = orbital_to_cartesian(el, pm) |
||||||
|
r = vmag(pos) |
||||||
|
print(f'{m["name"]:10s}: r={r:.3f} m, nu={nu*180/math.pi:.2f}deg') |
||||||
|
|
||||||
|
|
||||||
|
def print_cpp_expected_values(): |
||||||
|
"""Print C++-style comments with expected values for embedding in test.""" |
||||||
|
print("# === Expected period values (from precalc) ===") |
||||||
|
print("# Format: name -> period_seconds, tolerance_seconds") |
||||||
|
parent_masses = { |
||||||
|
"Earth": 5.97e24, "Jupiter": 1.898e27, "Saturn": 5.683e26, |
||||||
|
} |
||||||
|
for m in MOONS: |
||||||
|
a_m = m["a_km"] * 1000.0 |
||||||
|
pm = parent_masses[m["parent"]] |
||||||
|
mu = G * pm |
||||||
|
T = 2.0 * math.pi * math.sqrt(a_m**3 / mu) |
||||||
|
T_days = T / 86400.0 |
||||||
|
# Tolerance: ~0.5% of period (simulation drift over multiple orbits) |
||||||
|
tol = 0.005 * T |
||||||
|
print(f'// {m["name"]:10s} T={T:>14.2f}s tol={tol:>8.1f}s ({T_days:.3f}d)') |
||||||
|
print() |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
print_summary() |
||||||
|
print() |
||||||
|
print("=" * 60) |
||||||
|
print("# TOML CONFIG (copy below this line)") |
||||||
|
print("=" * 60) |
||||||
|
print() |
||||||
|
print_toml() |
||||||
|
print() |
||||||
|
print("=" * 60) |
||||||
|
print("# CPP EXPECTED VALUES (copy into test fixture)") |
||||||
|
print("=" * 60) |
||||||
|
print() |
||||||
|
print_cpp_expected_values() |
||||||
@ -0,0 +1,129 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Precalculate expected values for test_omega_debug.cpp refactoring. |
||||||
|
Computes expected orbital elements after a prograde burn at apoapsis. |
||||||
|
""" |
||||||
|
|
||||||
|
import math |
||||||
|
import sys |
||||||
|
sys.path.insert(0, "/home/agent/dev/claudes_game") |
||||||
|
from scripts.sim_engine import * |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
earth_mass = 5.972e24 |
||||||
|
mu = G * earth_mass |
||||||
|
|
||||||
|
# Initial orbit: zero inclination, omega = 0, start at apoapsis (nu = pi) |
||||||
|
elements = OrbitalElements( |
||||||
|
a=1.0e7, |
||||||
|
e=0.3, |
||||||
|
nu=math.pi, |
||||||
|
inc=1e-12, |
||||||
|
Omega=0.0, |
||||||
|
omega=0.0, |
||||||
|
) |
||||||
|
|
||||||
|
pos, vel = orbital_to_cartesian(elements, earth_mass) |
||||||
|
|
||||||
|
r = vmag(pos) |
||||||
|
v = vmag(vel) |
||||||
|
|
||||||
|
print("// Initial state") |
||||||
|
print(f"// pos = ({pos[0]:.15e}, {pos[1]:.15e}, {pos[2]:.15e}) m") |
||||||
|
print(f"// vel = ({vel[0]:.15e}, {vel[1]:.15e}, {vel[2]:.15e}) m/s") |
||||||
|
print(f"// r = {r:.15e} m") |
||||||
|
print(f"// v = {v:.15e} m/s") |
||||||
|
print() |
||||||
|
|
||||||
|
# Eccentricity vector |
||||||
|
r_dot_v = vdot(pos, vel) |
||||||
|
e_vec = ( |
||||||
|
((v * v - mu / r) * pos[0] - r_dot_v * vel[0]) / mu, |
||||||
|
((v * v - mu / r) * pos[1] - r_dot_v * vel[1]) / mu, |
||||||
|
((v * v - mu / r) * pos[2] - r_dot_v * vel[2]) / mu, |
||||||
|
) |
||||||
|
e_mag = vmag(e_vec) |
||||||
|
|
||||||
|
print(f"// e_vec_initial = ({e_vec[0]:.15e}, {e_vec[1]:.15e}, {e_vec[2]:.15e})") |
||||||
|
print(f"// e_initial = {e_mag:.15e}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Apply prograde burn (1000 m/s) |
||||||
|
burn_dir = get_burn_direction(BurnDirection.PROGRADE, pos, vel) |
||||||
|
dv = 1000.0 |
||||||
|
dv_vec = vscale(burn_dir, dv) |
||||||
|
vel_new = vadd(vel, dv_vec) |
||||||
|
|
||||||
|
v_new = vmag(vel_new) |
||||||
|
|
||||||
|
print("// After prograde burn") |
||||||
|
print(f"// burn_dir = ({burn_dir[0]:.15e}, {burn_dir[1]:.15e}, {burn_dir[2]:.15e})") |
||||||
|
print(f"// vel_new = ({vel_new[0]:.15e}, {vel_new[1]:.15e}, {vel_new[2]:.15e}) m/s") |
||||||
|
print(f"// v_new = {v_new:.15e} m/s") |
||||||
|
print() |
||||||
|
|
||||||
|
# Reconstruct orbital elements |
||||||
|
new_elements = cartesian_to_orbital_elements(pos, vel_new, earth_mass) |
||||||
|
|
||||||
|
print(f"// new elements:") |
||||||
|
print(f"// a = {new_elements.a:.15e} m") |
||||||
|
print(f"// e = {new_elements.e:.15e}") |
||||||
|
print(f"// nu = {new_elements.nu:.15e} rad ({math.degrees(new_elements.nu):.6f} deg)") |
||||||
|
print(f"// inc = {new_elements.inc:.15e} rad") |
||||||
|
print(f"// Omega = {new_elements.Omega:.15e} rad") |
||||||
|
print(f"// omega = {new_elements.omega:.15e} rad ({math.degrees(new_elements.omega):.6f} deg)") |
||||||
|
print() |
||||||
|
|
||||||
|
# New eccentricity vector |
||||||
|
r_dot_v_new = vdot(pos, vel_new) |
||||||
|
e_vec_new = ( |
||||||
|
((v_new * v_new - mu / r) * pos[0] - r_dot_v_new * vel_new[0]) / mu, |
||||||
|
((v_new * v_new - mu / r) * pos[1] - r_dot_v_new * vel_new[1]) / mu, |
||||||
|
((v_new * v_new - mu / r) * pos[2] - r_dot_v_new * vel_new[2]) / mu, |
||||||
|
) |
||||||
|
|
||||||
|
print(f"// e_vec_new = ({e_vec_new[0]:.15e}, {e_vec_new[1]:.15e}, {e_vec_new[2]:.15e})") |
||||||
|
print(f"// e_new = {vmag(e_vec_new):.15e}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Verify omega is in [0, 2*pi) |
||||||
|
print("// Omega range check") |
||||||
|
print(f"// omega = {new_elements.omega:.15e} rad") |
||||||
|
print(f"// omega in [0, 2*pi)? {0.0 <= new_elements.omega < 2.0 * math.pi}") |
||||||
|
print() |
||||||
|
|
||||||
|
# After a prograde burn at apoapsis (nu=pi), the eccentricity vector flips |
||||||
|
# direction because the increased velocity raises the opposite side of the orbit. |
||||||
|
# This means omega should change from 0 to approximately pi. |
||||||
|
# |
||||||
|
# Rationale: at apoapsis, position and velocity are perpendicular. |
||||||
|
# A prograde burn adds velocity along the velocity direction, increasing energy. |
||||||
|
# The eccentricity vector formula: e_vec = (v^2 - mu/r)*r/μ - (r·v)*v/μ |
||||||
|
# At apoapsis: r·v = 0, so e_vec = (v^2 - mu/r) * r / mu |
||||||
|
# After prograde burn, v increases, so (v^2 - mu/r) becomes more positive, |
||||||
|
# making e_vec more aligned with r direction. |
||||||
|
# Since at apoapsis, r points opposite to periapsis direction (for ω=0), |
||||||
|
# the eccentricity vector flips, meaning periapsis moves to the opposite side, |
||||||
|
# so ω → π. |
||||||
|
|
||||||
|
print("// Expected test values") |
||||||
|
print(f"// a_expected = {new_elements.a:.15e}") |
||||||
|
print(f"// e_expected = {new_elements.e:.15e}") |
||||||
|
print(f"// omega_expected = {new_elements.omega:.15e} rad ({math.degrees(new_elements.omega):.6f} deg)") |
||||||
|
print() |
||||||
|
|
||||||
|
# Also compute expected values using the same check as the original test |
||||||
|
print("// For WithinAbs assertions:") |
||||||
|
print(f"// a := {new_elements.a:.15e}") |
||||||
|
print(f"// e := {new_elements.e:.15e}") |
||||||
|
print(f"// omega := {new_elements.omega:.15e}") |
||||||
|
print(f"// inc := {new_elements.inc:.15e}") |
||||||
|
print(f"// Omega := {new_elements.Omega:.15e}") |
||||||
|
print(f"// nu := {new_elements.nu:.15e}") |
||||||
|
print(f"// r := {r:.15e}") |
||||||
|
print(f"// v_new := {v_new:.15e}") |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
main() |
||||||
@ -0,0 +1,89 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Precalculate expected values for test_parabolic_orbit. |
||||||
|
Simulates a parabolic comet orbiting the Sun for 300 days. |
||||||
|
""" |
||||||
|
|
||||||
|
import math |
||||||
|
import sys |
||||||
|
sys.path.insert(0, "scripts") |
||||||
|
from sim_engine import Simulator, vmag, G |
||||||
|
|
||||||
|
def main(): |
||||||
|
sim = Simulator("tests/test_parabolic_orbit.toml", dt=60.0) |
||||||
|
|
||||||
|
comet = sim.get_body("ParabolicComet") |
||||||
|
sun = sim.get_body("Sun") |
||||||
|
|
||||||
|
# Initial state |
||||||
|
r0 = vmag(comet.global_pos) |
||||||
|
v0 = vmag(comet.global_vel) |
||||||
|
mu = G * sun.mass |
||||||
|
escape_v0 = math.sqrt(2.0 * mu / r0) |
||||||
|
circular_v0 = math.sqrt(mu / r0) |
||||||
|
|
||||||
|
print(f"// === Initial Conditions (SI units) ===") |
||||||
|
print(f"// Distance: {r0:.6f} m ({r0 / 1.496e11:.6f} AU)") |
||||||
|
print(f"// Velocity: {v0:.6f} m/s ({v0 / 1000.0:.6f} km/s)") |
||||||
|
print(f"// Escape velocity: {escape_v0:.6f} m/s ({escape_v0 / 1000.0:.6f} km/s)") |
||||||
|
print(f"// Circular velocity: {circular_v0:.6f} m/s ({circular_v0 / 1000.0:.6f} km/s)") |
||||||
|
print(f"// Velocity error from escape: {(abs(v0 - escape_v0) / escape_v0) * 100.0:.6f}%") |
||||||
|
print(f"// Eccentricity: {comet.orbit.e:.6f}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Energy at start (local frame, comet relative to sun) |
||||||
|
KE0 = 0.5 * comet.mass * v0**2 |
||||||
|
PE0 = -mu * comet.mass / r0 |
||||||
|
E0 = KE0 + PE0 |
||||||
|
|
||||||
|
print(f"// === Energy (Joules) ===") |
||||||
|
print(f"// Initial KE: {KE0:.6e}") |
||||||
|
print(f"// Initial PE: {PE0:.6e}") |
||||||
|
print(f"// Initial total E: {E0:.6e}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Run simulation for 300 days |
||||||
|
total_seconds = 300.0 * 86400.0 |
||||||
|
steps = int(total_seconds / sim.dt) |
||||||
|
print(f"// Total steps: {steps}") |
||||||
|
print() |
||||||
|
|
||||||
|
for i in range(steps): |
||||||
|
sim._step() |
||||||
|
|
||||||
|
# Final state |
||||||
|
rf = vmag(comet.global_pos) |
||||||
|
vf = vmag(comet.global_vel) |
||||||
|
KEf = 0.5 * comet.mass * vf**2 |
||||||
|
PEf = -mu * comet.mass / rf |
||||||
|
Ef = KEf + PEf |
||||||
|
|
||||||
|
print() |
||||||
|
print(f"// === Final State (t=300 days) ===") |
||||||
|
print(f"// Distance: {rf:.6f} m ({rf / 1.496e11:.6f} AU)") |
||||||
|
print(f"// Velocity: {vf:.6f} m/s ({vf / 1000.0:.6f} km/s)") |
||||||
|
print(f"// Final KE: {KEf:.6e}") |
||||||
|
print(f"// Final PE: {PEf:.6e}") |
||||||
|
print(f"// Final total E: {Ef:.6e}") |
||||||
|
print() |
||||||
|
|
||||||
|
# Energy drift |
||||||
|
avg_KE = (KE0 + KEf) / 2.0 |
||||||
|
energy_drift = abs(Ef - E0) |
||||||
|
energy_drift_pct = (energy_drift / avg_KE) * 100.0 if avg_KE > 0 else 0.0 |
||||||
|
|
||||||
|
print(f"// === Energy Drift ===") |
||||||
|
print(f"// Absolute drift: {energy_drift:.6e} J") |
||||||
|
print(f"// Drift percent: {energy_drift_pct:.6f}%") |
||||||
|
print() |
||||||
|
|
||||||
|
# Assertions summary |
||||||
|
print(f"// === Assertions ===") |
||||||
|
print(f"// final_distance ({rf:.2f} m) > initial_distance ({r0:.2f} m): {rf > r0}") |
||||||
|
print(f"// final_velocity ({vf:.2f} m/s) < initial_velocity ({v0:.2f} m/s): {vf < v0}") |
||||||
|
print(f"// E0 >= -1e25: {E0 >= -1e25}") |
||||||
|
print(f"// energy_drift_pct < 1.0: {energy_drift_pct < 1.0}") |
||||||
|
print(f"// final_velocity matches {vf:.6f} m/s: True") |
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
main() |
||||||
@ -0,0 +1,254 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Precalculate expected values for test_periapsis_burn.cpp refactoring. |
||||||
|
Uses sim_engine.py for physics propagation with maneuver trigger support. |
||||||
|
|
||||||
|
Outputs C++-style comments with expected values for embedding in the test. |
||||||
|
Also outputs burn_result values (pre-burn state vectors) for verification. |
||||||
|
""" |
||||||
|
|
||||||
|
import math |
||||||
|
import sys |
||||||
|
sys.path.insert(0, "/home/agent/dev/claudes_game") |
||||||
|
from scripts.sim_engine import * |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
dt = 60.0 |
||||||
|
earth = None |
||||||
|
for b in sim.bodies: |
||||||
|
if b.name == "Earth": |
||||||
|
earth = b |
||||||
|
break |
||||||
|
|
||||||
|
# ========================================================================= |
||||||
|
# Scenario 1: TestSatellite - starting at periapsis, two sequential burns |
||||||
|
# ========================================================================= |
||||||
|
sim1 = Simulator("tests/test_periapsis_burn.toml", dt=dt) |
||||||
|
craft1 = sim1.spacecraft[0] # TestSatellite |
||||||
|
|
||||||
|
# Initial orbit state |
||||||
|
r0 = vmag(craft1.local_pos) |
||||||
|
v0 = vmag(craft1.local_vel) |
||||||
|
a0 = craft1.orbit.a |
||||||
|
e0 = craft1.orbit.e |
||||||
|
periapsis0 = a0 * (1.0 - e0) |
||||||
|
apoapsis0 = a0 * (1.0 + e0) |
||||||
|
period0 = 2.0 * math.pi * math.sqrt(a0**3 / (G * earth.mass)) |
||||||
|
|
||||||
|
print("// === Scenario 1: TestSatellite - Two sequential periapsis burns ===") |
||||||
|
print(f"// Initial orbit:") |
||||||
|
print(f"// a = {a0:.4f} m") |
||||||
|
print(f"// e = {e0:.10f}") |
||||||
|
print(f"// periapsis = {periapsis0:.4f} m") |
||||||
|
print(f"// apoapsis = {apoapsis0:.4f} m") |
||||||
|
print(f"// period = {period0:.4f} s ({period0/3600:.4f} hours)") |
||||||
|
print(f"// r0 = {r0:.4f} m (should equal periapsis)") |
||||||
|
print(f"// v0 = {v0:.4f} m/s") |
||||||
|
print(f"// nu0 = {math.degrees(craft1.orbit.nu):.4f} deg") |
||||||
|
print() |
||||||
|
|
||||||
|
# First burn: immediate (step 0) |
||||||
|
# Second burn: after ~1 full orbit from first burn |
||||||
|
total_steps = int(2.5 * period0 / dt) # ~2.5 orbits |
||||||
|
|
||||||
|
burn1_time = -1.0 |
||||||
|
burn1_pos = None |
||||||
|
burn1_vel = None |
||||||
|
burn1_nu = None |
||||||
|
burn1_radius = -1.0 |
||||||
|
burn1_v = -1.0 |
||||||
|
burn1_a = -1.0 |
||||||
|
|
||||||
|
burn1_post_sma = -1.0 |
||||||
|
burn1_post_v = -1.0 |
||||||
|
|
||||||
|
burn2_time = -1.0 |
||||||
|
burn2_pos = None |
||||||
|
burn2_vel = None |
||||||
|
burn2_nu = None |
||||||
|
burn2_radius = -1.0 |
||||||
|
burn2_v = -1.0 |
||||||
|
|
||||||
|
for step in range(total_steps): |
||||||
|
sim1._step() |
||||||
|
|
||||||
|
# Check if first burn executed |
||||||
|
if sim1.maneuvers[0].executed and burn1_time < 0: |
||||||
|
burn1_time = sim1.maneuvers[0].executed_time |
||||||
|
burn1_a = craft1.orbit.a |
||||||
|
br1 = sim1.maneuvers[0].burn_result |
||||||
|
burn1_pos = br1.position |
||||||
|
burn1_vel = br1.velocity |
||||||
|
burn1_nu = br1.true_anomaly |
||||||
|
burn1_radius = vmag(br1.position) |
||||||
|
burn1_v = vmag(br1.velocity) |
||||||
|
b1x, b1y, b1z = burn1_pos |
||||||
|
b1vx, b1vy, b1vz = burn1_vel |
||||||
|
print(f"// First burn at step {step}, t={burn1_time:.1f}s") |
||||||
|
print(f"// burn_result (pre-burn state):") |
||||||
|
print(f"// valid = {br1.valid}") |
||||||
|
print(f"// radius = {burn1_radius:.4f} m") |
||||||
|
print(f"// true_anomaly = {burn1_nu:.15f} rad") |
||||||
|
print(f"// pos = ({b1x:.4f}, {b1y:.4f}, {b1z:.4f}) m") |
||||||
|
print(f"// vel = ({b1vx:.4f}, {b1vy:.4f}, {b1vz:.4f}) m/s") |
||||||
|
|
||||||
|
# Capture post-burn+60s-propagation state (what the test reads) |
||||||
|
burn1_post_sma = craft1.orbit.a |
||||||
|
burn1_post_v = vmag(craft1.local_vel) |
||||||
|
print(f"// post-burn + 60s propagation (test assertions):") |
||||||
|
print(f"// sma = {burn1_post_sma:.15f} m") |
||||||
|
print(f"// velocity = {burn1_post_v:.15f} m/s") |
||||||
|
print(f"// burn_result (pre-burn) — tight tolerance assertions:") |
||||||
|
print(f"// preburn_v = {burn1_v:.15f} m/s") |
||||||
|
|
||||||
|
# Check if second burn executed |
||||||
|
if sim1.maneuvers[1].executed and burn2_time < 0: |
||||||
|
burn2_time = sim1.maneuvers[1].executed_time |
||||||
|
br2 = sim1.maneuvers[1].burn_result |
||||||
|
burn2_pos = br2.position |
||||||
|
burn2_vel = br2.velocity |
||||||
|
burn2_nu = br2.true_anomaly |
||||||
|
burn2_radius = vmag(br2.position) |
||||||
|
burn2_v = vmag(br2.velocity) |
||||||
|
b2x, b2y, b2z = burn2_pos |
||||||
|
b2vx, b2vy, b2vz = burn2_vel |
||||||
|
print(f"// Second burn at step {step}, t={burn2_time:.1f}s") |
||||||
|
print(f"// burn_result (pre-burn state):") |
||||||
|
print(f"// valid = {br2.valid}") |
||||||
|
print(f"// radius = {burn2_radius:.4f} m") |
||||||
|
print(f"// true_anomaly = {burn2_nu:.15f} rad") |
||||||
|
print(f"// pos = ({b2x:.4f}, {b2y:.4f}, {b2z:.4f}) m") |
||||||
|
print(f"// vel = ({b2vx:.4f}, {b2vy:.4f}, {b2vz:.4f}) m/s") |
||||||
|
|
||||||
|
print() |
||||||
|
|
||||||
|
# ========================================================================= |
||||||
|
# Scenario 2: TestSatelliteCrossing - starts at nu=pi/2, one burn |
||||||
|
# ========================================================================= |
||||||
|
sim2 = Simulator("tests/test_periapsis_burn.toml", dt=dt) |
||||||
|
craft2 = sim2.spacecraft[1] # TestSatelliteCrossing |
||||||
|
r0_cross = vmag(craft2.local_pos) |
||||||
|
v0_cross = vmag(craft2.local_vel) |
||||||
|
a0_cross = craft2.orbit.a |
||||||
|
e0_cross = craft2.orbit.e |
||||||
|
periapsis_cross = a0_cross * (1.0 - e0_cross) |
||||||
|
period_cross = 2.0 * math.pi * math.sqrt(a0_cross**3 / (G * earth.mass)) |
||||||
|
|
||||||
|
print("// === Scenario 2: TestSatelliteCrossing - Burn crossing from nu=pi/2 ===") |
||||||
|
print(f"// Initial orbit:") |
||||||
|
print(f"// a = {a0_cross:.4f} m") |
||||||
|
print(f"// e = {e0_cross:.10f}") |
||||||
|
print(f"// periapsis = {periapsis_cross:.4f} m") |
||||||
|
print(f"// period = {period_cross:.4f} s") |
||||||
|
print(f"// r0 = {r0_cross:.4f} m") |
||||||
|
print(f"// v0 = {v0_cross:.4f} m/s") |
||||||
|
print(f"// nu0 = {math.degrees(craft2.orbit.nu):.4f} deg") |
||||||
|
print() |
||||||
|
|
||||||
|
burn_cross_time = -1.0 |
||||||
|
burn_cross_pos = None |
||||||
|
burn_cross_vel = None |
||||||
|
burn_cross_nu = None |
||||||
|
burn_cross_radius = -1.0 |
||||||
|
burn_cross_v = -1.0 |
||||||
|
|
||||||
|
max_steps = int(2.0 * period_cross / dt) |
||||||
|
for step in range(max_steps): |
||||||
|
sim2._step() |
||||||
|
|
||||||
|
if sim2.maneuvers[2].executed and burn_cross_time < 0: |
||||||
|
burn_cross_time = sim2.maneuvers[2].executed_time |
||||||
|
brc = sim2.maneuvers[2].burn_result |
||||||
|
burn_cross_pos = brc.position |
||||||
|
burn_cross_vel = brc.velocity |
||||||
|
burn_cross_nu = brc.true_anomaly |
||||||
|
burn_cross_radius = vmag(brc.position) |
||||||
|
burn_cross_v = vmag(brc.velocity) |
||||||
|
bcx, bcy, bcz = burn_cross_pos |
||||||
|
bcvx, bcvy, bcvz = burn_cross_vel |
||||||
|
print(f"// Burn at step {step}, t={burn_cross_time:.1f}s") |
||||||
|
print(f"// burn_result (pre-burn state):") |
||||||
|
print(f"// valid = {brc.valid}") |
||||||
|
print(f"// radius = {burn_cross_radius:.4f} m") |
||||||
|
print(f"// true_anomaly = {burn_cross_nu:.15f} rad") |
||||||
|
print(f"// pos = ({bcx:.4f}, {bcy:.4f}, {bcz:.4f}) m") |
||||||
|
print(f"// vel = ({bcvx:.4f}, {bcvy:.4f}, {bcvz:.4f}) m/s") |
||||||
|
|
||||||
|
print() |
||||||
|
|
||||||
|
# ========================================================================= |
||||||
|
# Summary: Expected values for C++ test embedding |
||||||
|
# ========================================================================= |
||||||
|
print("// === SUMMARY: Values for C++ test embedding ===") |
||||||
|
print() |
||||||
|
print("// --- TestSatellite initial orbit ---") |
||||||
|
print(f"// initial_periapsis = {periapsis0:.4f}") |
||||||
|
print(f"// initial_apoapsis = {apoapsis0:.4f}") |
||||||
|
print(f"// initial_radius = {r0:.4f}") |
||||||
|
print(f"// initial_velocity = {v0:.4f}") |
||||||
|
print(f"// initial_period = {period0:.4f}") |
||||||
|
print() |
||||||
|
|
||||||
|
if burn1_time >= 0: |
||||||
|
print("// --- First burn (TestSatellite) - burn_result ===") |
||||||
|
print(f"// burn1_time = {burn1_time:.4f}") |
||||||
|
print(f"// burn1_radius (pre-burn) = {burn1_radius:.4f}") |
||||||
|
print(f"// burn1_velocity (pre-burn) = {burn1_v:.4f}") |
||||||
|
print(f"// burn1_true_anomaly (pre-burn) = {burn1_nu:.15f}") |
||||||
|
print(f"// burn1_pos = ({b1x:.4f}, {b1y:.4f}, {b1z:.4f}) m") |
||||||
|
print(f"// burn1_vel = ({b1vx:.4f}, {b1vy:.4f}, {b1vz:.4f}) m/s") |
||||||
|
print(f"// burn1_a (post-burn) = {burn1_a:.4f} m") |
||||||
|
print() |
||||||
|
print("// --- First burn - post-burn + 60s propagation (test assertions) ===") |
||||||
|
print(f"// burn1_expected_sma = {burn1_post_sma:.15f}") |
||||||
|
print(f"// burn1_expected_v = {burn1_post_v:.15f}") |
||||||
|
print() |
||||||
|
|
||||||
|
if burn2_time >= 0: |
||||||
|
print("// --- Second burn (TestSatellite) - burn_result ===") |
||||||
|
print(f"// burn2_time = {burn2_time:.4f}") |
||||||
|
print(f"// burn2_radius (pre-burn) = {burn2_radius:.4f}") |
||||||
|
print(f"// burn2_velocity (pre-burn) = {burn2_v:.4f}") |
||||||
|
print(f"// burn2_true_anomaly (pre-burn) = {burn2_nu:.15f}") |
||||||
|
print(f"// burn2_pos = ({b2x:.4f}, {b2y:.4f}, {b2z:.4f}) m") |
||||||
|
print(f"// burn2_vel = ({b2vx:.4f}, {b2vy:.4f}, {b2vz:.4f}) m/s") |
||||||
|
if burn1_time >= 0: |
||||||
|
time_between = burn2_time - burn1_time |
||||||
|
print(f"// time_between_burns = {time_between:.4f}") |
||||||
|
print() |
||||||
|
|
||||||
|
if burn_cross_time >= 0: |
||||||
|
print("// --- Cross burn (TestSatelliteCrossing) - burn_result ===") |
||||||
|
print(f"// burn_cross_time = {burn_cross_time:.4f}") |
||||||
|
print(f"// burn_cross_radius (pre-burn) = {burn_cross_radius:.4f}") |
||||||
|
print(f"// burn_cross_velocity (pre-burn) = {burn_cross_v:.4f}") |
||||||
|
print(f"// burn_cross_true_anomaly (pre-burn) = {burn_cross_nu:.15f}") |
||||||
|
print(f"// burn_cross_pos = ({bcx:.4f}, {bcy:.4f}, {bcz:.4f}) m") |
||||||
|
print(f"// burn_cross_vel = ({bcvx:.4f}, {bcvy:.4f}, {bcvz:.4f}) m/s") |
||||||
|
print() |
||||||
|
|
||||||
|
# Key assertions |
||||||
|
print("// === Key assertions for test ===") |
||||||
|
print(f"// Periapsis preserved: initial_periapsis ~= final_periapsis (within 1.0)") |
||||||
|
print(f"// Initial radius ~= periapsis: {r0:.4f} ~= {periapsis0:.4f}") |
||||||
|
print(f"// Burn radius ~= periapsis: burn radii should be close to {periapsis0:.4f}") |
||||||
|
print(f"// Two burns at same location: burn1_radius ~= burn2_radius") |
||||||
|
print(f"// Time between burns ~= orbital period") |
||||||
|
print() |
||||||
|
|
||||||
|
# State vector comparison (C++ vs Python agreement) |
||||||
|
if burn1_pos and burn2_pos and burn1_vel and burn2_vel: |
||||||
|
def state_vec_dist(p1, v1, p2, v2): |
||||||
|
dr = math.sqrt(sum((a-b)**2 for a,b in zip(p1,p2))) |
||||||
|
dv = math.sqrt(sum((a-b)**2 for a,b in zip(v1,v2))) |
||||||
|
return dr, dv |
||||||
|
|
||||||
|
ddr1, ddv1 = state_vec_dist(burn1_pos, burn1_vel, burn1_pos, burn1_vel) |
||||||
|
print(f"// State vector self-check (burn1 vs burn1): dr={ddr1:.2e} m, dv={ddv1:.2e} m/s") |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
# Quick sanity: need to create a dummy sim first to test config loading |
||||||
|
sim = Simulator("tests/test_periapsis_burn.toml", dt=60.0) |
||||||
|
main() |
||||||
@ -0,0 +1,920 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Generic orbital mechanics simulation engine. |
||||||
|
Replicates the exact physics from src/orbital_mechanics.cpp and src/simulation.cpp. |
||||||
|
|
||||||
|
Usage: |
||||||
|
from sim_engine import Simulator |
||||||
|
sim = Simulator("path/to/config.toml", dt=60.0) |
||||||
|
sim.run(steps=1000) |
||||||
|
for event in sim.events: |
||||||
|
print(event) |
||||||
|
""" |
||||||
|
|
||||||
|
import math |
||||||
|
import tomllib |
||||||
|
from dataclasses import dataclass, field, replace |
||||||
|
from typing import Dict, Tuple, Any |
||||||
|
|
||||||
|
|
||||||
|
# Constants |
||||||
|
|
||||||
|
G = 6.67430e-11 |
||||||
|
PARABOLIC_TOLERANCE = 1e-3 |
||||||
|
KEPLER_TOLERANCE = 1e-10 |
||||||
|
KEPLER_MAX_ITER = 50 |
||||||
|
VEL_DRIFT_THRESHOLD = 1e-6 # m/s |
||||||
|
|
||||||
|
|
||||||
|
# Vector operations |
||||||
|
|
||||||
|
def vadd(a, b): |
||||||
|
return (a[0]+b[0], a[1]+b[1], a[2]+b[2]) |
||||||
|
|
||||||
|
def vsub(a, b): |
||||||
|
return (a[0]-b[0], a[1]-b[1], a[2]-b[2]) |
||||||
|
|
||||||
|
def vscale(v, s): |
||||||
|
return (v[0]*s, v[1]*s, v[2]*s) |
||||||
|
|
||||||
|
def vmag(v): |
||||||
|
return math.sqrt(v[0]**2 + v[1]**2 + v[2]**2) |
||||||
|
|
||||||
|
def vdot(a, b): |
||||||
|
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2] |
||||||
|
|
||||||
|
def vcross(a, b): |
||||||
|
return ( |
||||||
|
a[1]*b[2] - a[2]*b[1], |
||||||
|
a[2]*b[0] - a[0]*b[2], |
||||||
|
a[0]*b[1] - a[1]*b[0] |
||||||
|
) |
||||||
|
|
||||||
|
def vnorm(v): |
||||||
|
m = vmag(v) |
||||||
|
if m < 1e-15: |
||||||
|
return (0.0, 0.0, 0.0) |
||||||
|
return (v[0]/m, v[1]/m, v[2]/m) |
||||||
|
|
||||||
|
def normalize_angle(angle): |
||||||
|
while angle < 0.0: |
||||||
|
angle += 2.0 * math.pi |
||||||
|
while angle >= 2.0 * math.pi: |
||||||
|
angle -= 2.0 * math.pi |
||||||
|
return angle |
||||||
|
|
||||||
|
|
||||||
|
def angular_distance(a, b): |
||||||
|
"""Shortest angular distance on unit circle (matches C++).""" |
||||||
|
diff = abs(normalize_angle(a) - normalize_angle(b)) |
||||||
|
return (2.0 * math.pi - diff) if diff > math.pi else diff |
||||||
|
|
||||||
|
|
||||||
|
def true_anomaly_to_eccentric_anomaly(true_anomaly, eccentricity): |
||||||
|
"""Convert true anomaly to eccentric anomaly (matches C++). |
||||||
|
Near-parabolic case uses cos/sin formulation to avoid instability. |
||||||
|
TODO: parabolic (e≈1) 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']}") |
||||||
@ -0,0 +1,117 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
""" |
||||||
|
Precalculate expected values for test_orbital_period.cpp. |
||||||
|
|
||||||
|
Measures: |
||||||
|
1. Earth orbital period (seconds, days) — track global angle for circular orbit |
||||||
|
2. Mars orbital period (seconds, days) |
||||||
|
3. Direction test: prograde check over 1 day |
||||||
|
""" |
||||||
|
|
||||||
|
import sys |
||||||
|
import math |
||||||
|
|
||||||
|
sys.path.insert(0, "scripts") |
||||||
|
from sim_engine import Simulator, vmag, G, OrbitalElements, propagate |
||||||
|
|
||||||
|
MAX_STEPS = 1_100_000 # safety limit (687 days × 1440 steps/day) |
||||||
|
DT = 60.0 |
||||||
|
|
||||||
|
|
||||||
|
def measure_period(sim, body_name, parent_mass, analytical_days): |
||||||
|
""" |
||||||
|
Measure period by tracking global angle for one full revolution. |
||||||
|
For circular orbits, nu stays at 0 so we track atan2(y, x) instead. |
||||||
|
""" |
||||||
|
body = sim.get_body(body_name) |
||||||
|
parent = sim.get_body(body.parent_index) if body.parent_index >= 0 else None |
||||||
|
|
||||||
|
# Track global angle |
||||||
|
if parent: |
||||||
|
angle_start = math.atan2( |
||||||
|
body.global_pos[1] - parent.global_pos[1], |
||||||
|
body.global_pos[0] - parent.global_pos[0] |
||||||
|
) |
||||||
|
else: |
||||||
|
angle_start = math.atan2(body.global_pos[1], body.global_pos[0]) |
||||||
|
|
||||||
|
total_angle = 0.0 |
||||||
|
prev_angle = angle_start |
||||||
|
|
||||||
|
for step in range(1, MAX_STEPS + 1): |
||||||
|
sim._step() |
||||||
|
|
||||||
|
if parent: |
||||||
|
angle = math.atan2( |
||||||
|
body.global_pos[1] - parent.global_pos[1], |
||||||
|
body.global_pos[0] - parent.global_pos[0] |
||||||
|
) |
||||||
|
else: |
||||||
|
angle = math.atan2(body.global_pos[1], body.global_pos[0]) |
||||||
|
|
||||||
|
# Accumulate angle (handle wrap) |
||||||
|
delta = angle - prev_angle |
||||||
|
if delta > math.pi: |
||||||
|
delta -= 2 * math.pi |
||||||
|
elif delta < -math.pi: |
||||||
|
delta += 2 * math.pi |
||||||
|
total_angle += delta |
||||||
|
prev_angle = angle |
||||||
|
|
||||||
|
if total_angle >= 2 * math.pi: |
||||||
|
break |
||||||
|
|
||||||
|
if step >= MAX_STEPS: |
||||||
|
print(f" TIMEOUT after {MAX_STEPS} steps ({sim.time/86400:.1f} days)") |
||||||
|
return None |
||||||
|
|
||||||
|
period_s = sim.time |
||||||
|
period_days = period_s / 86400.0 |
||||||
|
print(f" Measured: {period_s:.1f}s = {period_days:.4f} days") |
||||||
|
print(f" Analytical: {analytical_days:.4f} days") |
||||||
|
print(f" Error: {abs(period_days - analytical_days):.4f} days ({abs(period_days - analytical_days)/analytical_days*100:.4f}%)") |
||||||
|
print(f" e after: {body.orbit.e:.15f}") |
||||||
|
|
||||||
|
return period_days |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
print("=== Earth Period ===") |
||||||
|
sim = Simulator("tests/test_orbital_period.toml", dt=DT) |
||||||
|
earth_a = 1.496e11 |
||||||
|
earth_mu = G * 1.989e30 # Sun mass |
||||||
|
earth_analytical = 2.0 * math.pi * math.sqrt(earth_a**3 / earth_mu) / 86400.0 |
||||||
|
measure_period(sim, "Earth", 1.989e30, earth_analytical) |
||||||
|
|
||||||
|
print("\n=== Mars Period ===") |
||||||
|
sim = Simulator("tests/test_orbital_period.toml", dt=DT) |
||||||
|
mars_a = 2.244e11 |
||||||
|
mars_mu = G * 1.989e30 # Sun mass |
||||||
|
mars_analytical = 2.0 * math.pi * math.sqrt(mars_a**3 / mars_mu) / 86400.0 |
||||||
|
measure_period(sim, "Mars", 1.989e30, mars_analytical) |
||||||
|
|
||||||
|
print("\n=== Direction Test (1 day) ===") |
||||||
|
sim = Simulator("tests/test_orbital_period.toml", dt=DT) |
||||||
|
earth = sim.get_body("Earth") |
||||||
|
sun = sim.get_body("Sun") |
||||||
|
theta_start = math.atan2(earth.global_pos[1] - sun.global_pos[1], |
||||||
|
earth.global_pos[0] - sun.global_pos[0]) |
||||||
|
|
||||||
|
sim.run(steps=1440) # 1 day = 86400s / 60s |
||||||
|
|
||||||
|
theta_end = math.atan2(earth.global_pos[1] - sun.global_pos[1], |
||||||
|
earth.global_pos[0] - sun.global_pos[0]) |
||||||
|
delta = theta_end - theta_start |
||||||
|
print(f" theta_start: {theta_start:.10f} rad") |
||||||
|
print(f" theta_end: {theta_end:.10f} rad") |
||||||
|
print(f" delta: {delta:.10f} rad") |
||||||
|
print(f" prograde: {delta > 0}") |
||||||
|
|
||||||
|
# Expected delta for 1 day of Earth orbit |
||||||
|
expected_delta = math.sqrt(earth_mu / earth_a**3) * 86400.0 |
||||||
|
print(f" expected: {expected_delta:.10f} rad") |
||||||
|
print(f" error: {abs(delta - expected_delta):.10f} rad") |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
main() |
||||||
@ -1,73 +0,0 @@ |
|||||||
# Makefile for Informational Tests
|
|
||||||
# These tests are not part of the main test suite
|
|
||||||
# They are for diagnostic and informational purposes only
|
|
||||||
|
|
||||||
BUILD_DIR = ../../build
|
|
||||||
SRC_DIR = ../../src
|
|
||||||
|
|
||||||
# Compiler flags
|
|
||||||
CFLAGS = -Wall -Wextra -g -ggdb3 -std=c++14
|
|
||||||
INCLUDES = -I$(SRC_DIR) \
|
|
||||||
-isystem../../ext/tomlc17/src \
|
|
||||||
-isystem../../ext/raylib/src \
|
|
||||||
-isystem../../ext/raygui/src \
|
|
||||||
-isystem../../ext/raygui/styles
|
|
||||||
|
|
||||||
# Source files (from main project that informational tests depend on)
|
|
||||||
CORE_OBJECTS = $(BUILD_DIR)/test_utilities.o \
|
|
||||||
$(BUILD_DIR)/physics.o \
|
|
||||||
$(BUILD_DIR)/orbital_mechanics.o \
|
|
||||||
$(BUILD_DIR)/simulation.o \
|
|
||||||
$(BUILD_DIR)/config_loader.o \
|
|
||||||
$(BUILD_DIR)/config_validator.o \
|
|
||||||
$(BUILD_DIR)/maneuver.o \
|
|
||||||
$(BUILD_DIR)/spacecraft.o \
|
|
||||||
$(BUILD_DIR)/tomlc17.o
|
|
||||||
|
|
||||||
# Informational test sources
|
|
||||||
INFO_TESTS = test_time_step_stability.cpp
|
|
||||||
INFO_BINS = $(patsubst %.cpp,%,$(INFO_TESTS))
|
|
||||||
|
|
||||||
# Default target - build all informational tests
|
|
||||||
all: $(INFO_BINS) |
|
||||||
@echo "All informational tests built successfully"
|
|
||||||
@echo ""
|
|
||||||
@echo "To run tests:"
|
|
||||||
@echo " ./test_time_step_stability"
|
|
||||||
@echo ""
|
|
||||||
@echo "To run specific test case:"
|
|
||||||
@echo " ./test_time_step_stability '[timestep][stability]'"
|
|
||||||
|
|
||||||
# Build individual test binaries
|
|
||||||
$(INFO_BINS): $(patsubst %.cpp,%.o,$(INFO_TESTS)) $(CORE_OBJECTS) |
|
||||||
g++ $(patsubst %.cpp,%.o,$<) $(CORE_OBJECTS) -o $@ -lCatch2Main -lCatch2 -lm
|
|
||||||
@echo "Built $@"
|
|
||||||
|
|
||||||
# Build test object files
|
|
||||||
test_time_step_stability.o: test_time_step_stability.cpp |
|
||||||
g++ $(CFLAGS) $(INCLUDES) -c $< -o $@
|
|
||||||
|
|
||||||
# Build core objects if they don't exist
|
|
||||||
$(BUILD_DIR)/%.o: |
|
||||||
$(MAKE) -C ../../ build/$*.o
|
|
||||||
|
|
||||||
# Clean built files in informational directory
|
|
||||||
clean: |
|
||||||
rm -f $(INFO_BINS) *.o
|
|
||||||
|
|
||||||
# Clean all (including core build - use with caution!)
|
|
||||||
clean-all: |
|
||||||
$(MAKE) -C ../../ clean
|
|
||||||
rm -f $(INFO_BINS) *.o
|
|
||||||
|
|
||||||
# Run the time step stability test (must run from project root)
|
|
||||||
run-timestep: test_time_step_stability |
|
||||||
@echo "Note: Tests must be run from project root directory:"
|
|
||||||
@echo " From project root: ./tests/informational/$@"
|
|
||||||
|
|
||||||
# Run with verbose output to see binary search progress
|
|
||||||
run-timestep-verbose: test_time_step_stability |
|
||||||
@echo "Note: Tests must be run from project root directory:"
|
|
||||||
@echo " From project root: ./tests/informational/$@ -s \"[timestep][stability]\""
|
|
||||||
|
|
||||||
.PHONY: all clean clean-all run-timestep run-timestep-verbose |
|
||||||
@ -1,72 +0,0 @@ |
|||||||
# Informational Tests |
|
||||||
|
|
||||||
This directory contains tests that are not part of the standard test suite. These tests are provided for informational purposes only and should be run separately. |
|
||||||
|
|
||||||
## Purpose |
|
||||||
|
|
||||||
Informational tests are designed to: |
|
||||||
- Explore simulation boundaries and limits |
|
||||||
- Provide diagnostic information about the simulation |
|
||||||
- Test extreme or edge cases that don't fit into normal test suites |
|
||||||
- Help understand system behavior under various conditions |
|
||||||
|
|
||||||
## Running Informational Tests |
|
||||||
|
|
||||||
Informational tests are **not** run by `make test`. To build and run them: |
|
||||||
|
|
||||||
```bash |
|
||||||
cd tests/informational |
|
||||||
make |
|
||||||
./time_step_test |
|
||||||
``` |
|
||||||
|
|
||||||
## Current Tests |
|
||||||
|
|
||||||
### `test_time_step_stability.cpp` |
|
||||||
|
|
||||||
Determines the maximum stable time step for the RK4 integration across different orbital regimes. |
|
||||||
|
|
||||||
**Test Bodies:** |
|
||||||
- **Mercury Orbiter** (MESSENGER-like): 200 km altitude, ~12 hour period |
|
||||||
- **Io** (Jupiter's moon): 421,700 km orbit, ~1.77 day period |
|
||||||
- **Moon** (Earth's moon): 384,400 km orbit, ~27.3 day period |
|
||||||
|
|
||||||
**Stability Criteria:** |
|
||||||
- Energy drift < 1% over 100 orbits |
|
||||||
- Distance drift < 5% |
|
||||||
- No SOI transitions (body doesn't change parent) |
|
||||||
|
|
||||||
**Configuration:** Uses `test_time_step_stability.toml` |
|
||||||
|
|
||||||
**Usage:** |
|
||||||
```bash |
|
||||||
# From project root directory: |
|
||||||
./tests/informational/test_time_step_stability |
|
||||||
|
|
||||||
# Or using the Makefile: |
|
||||||
cd tests/informational |
|
||||||
make run-timestep |
|
||||||
|
|
||||||
# Run specific test case |
|
||||||
./tests/informational/test_time_step_stability '[timestep][stability]' |
|
||||||
|
|
||||||
# Run with verbose output to see binary search progress |
|
||||||
./tests/informational/test_time_step_stability -s "[timestep][stability]" |
|
||||||
``` |
|
||||||
|
|
||||||
**Note:** Tests must be run from the project root directory because config file paths are relative to the root. |
|
||||||
|
|
||||||
**Expected Output:** |
|
||||||
The test performs a binary search to find the maximum stable time step, printing progress for each dt value tested. Results show the minimum stable dt across all tested bodies with recommendations. |
|
||||||
|
|
||||||
## Adding New Informational Tests |
|
||||||
|
|
||||||
1. Create a new `.cpp` file in this directory |
|
||||||
2. Add corresponding `.toml` config file if needed |
|
||||||
3. Add a target in the `Makefile`: |
|
||||||
```makefile |
|
||||||
your_test_name: your_test_name.o |
|
||||||
g++ $(BUILD_DIR)/*.o -o $@ -lCatch2Main -lCatch2 -lm |
|
||||||
``` |
|
||||||
4. Update this README with test description |
|
||||||
5. Follow the naming convention: `test_<purpose>.cpp` and `test_<purpose>.toml` |
|
||||||
@ -1,296 +0,0 @@ |
|||||||
#include <catch2/catch_test_macros.hpp> |
|
||||||
#include "../../src/physics.h" |
|
||||||
#include "../../src/simulation.h" |
|
||||||
#include "../../src/config_loader.h" |
|
||||||
#include "../../src/test_utilities.h" |
|
||||||
#include <cmath> |
|
||||||
#include <cstdio> |
|
||||||
|
|
||||||
struct TestBody { |
|
||||||
const char* name; |
|
||||||
int body_index; |
|
||||||
int parent_index; |
|
||||||
double expected_period_days; |
|
||||||
}; |
|
||||||
|
|
||||||
struct StabilityResult { |
|
||||||
const char* name; |
|
||||||
double max_stable_dt; |
|
||||||
double period_days; |
|
||||||
}; |
|
||||||
|
|
||||||
const double SECONDS_PER_DAY = 86400.0; |
|
||||||
const double MIN_DT = 30.0; |
|
||||||
const double MAX_DT = 600.0; |
|
||||||
const double ENERGY_TOLERANCE = 1.0; |
|
||||||
const int NUM_ORBITS = 100; |
|
||||||
|
|
||||||
double calculate_orbital_period(CelestialBody* body, CelestialBody* parent) { |
|
||||||
Vec3 relative_pos = vec3_sub(body->global_position, parent->global_position); |
|
||||||
double r = vec3_magnitude(relative_pos); |
|
||||||
Vec3 relative_vel = vec3_sub(body->global_velocity, parent->global_velocity); |
|
||||||
double v = vec3_magnitude(relative_vel); |
|
||||||
|
|
||||||
double specific_energy = (v * v) / 2.0 - G * parent->mass / r; |
|
||||||
double semi_major_axis = -G * parent->mass / (2.0 * specific_energy); |
|
||||||
double period_seconds = 2.0 * M_PI * sqrt(pow(semi_major_axis, 3.0) / (G * parent->mass)); |
|
||||||
|
|
||||||
return period_seconds; |
|
||||||
} |
|
||||||
|
|
||||||
bool is_dt_stable(SimulationState* sim, const TestBody& test_body, double dt, int num_orbits) { |
|
||||||
SimulationState* test_sim = create_simulation(sim->max_bodies, sim->max_craft, sim->max_maneuvers, dt); |
|
||||||
|
|
||||||
REQUIRE(load_system_config(test_sim, "tests/informational/test_time_step_stability.toml")); |
|
||||||
|
|
||||||
int body_index = test_body.body_index; |
|
||||||
int parent_index = test_body.parent_index; |
|
||||||
|
|
||||||
double initial_energy = calculate_system_total_energy(test_sim); |
|
||||||
|
|
||||||
Vec3 initial_pos_relative = vec3_sub( |
|
||||||
test_sim->bodies[body_index].global_position, |
|
||||||
test_sim->bodies[parent_index].global_position |
|
||||||
); |
|
||||||
double initial_distance = vec3_magnitude(initial_pos_relative); |
|
||||||
|
|
||||||
double period = calculate_orbital_period(&test_sim->bodies[body_index], &test_sim->bodies[parent_index]); |
|
||||||
double max_time = period * num_orbits; |
|
||||||
|
|
||||||
bool completed = true; |
|
||||||
while (test_sim->time < max_time) { |
|
||||||
update_simulation(test_sim); |
|
||||||
|
|
||||||
if (test_sim->bodies[body_index].parent_index != parent_index) { |
|
||||||
completed = false; |
|
||||||
break; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
double final_energy = calculate_system_total_energy(test_sim); |
|
||||||
double energy_drift_percent = fabs((final_energy - initial_energy) / initial_energy) * 100.0; |
|
||||||
|
|
||||||
Vec3 final_pos_relative = vec3_sub( |
|
||||||
test_sim->bodies[body_index].global_position, |
|
||||||
test_sim->bodies[parent_index].global_position |
|
||||||
); |
|
||||||
double final_distance = vec3_magnitude(final_pos_relative); |
|
||||||
double distance_drift_percent = fabs((final_distance - initial_distance) / initial_distance) * 100.0; |
|
||||||
|
|
||||||
bool stable = completed && (energy_drift_percent < ENERGY_TOLERANCE) && (distance_drift_percent < 5.0); |
|
||||||
|
|
||||||
destroy_simulation(test_sim); |
|
||||||
|
|
||||||
return stable; |
|
||||||
} |
|
||||||
|
|
||||||
double find_max_stable_dt(SimulationState* sim, const TestBody& test_body) { |
|
||||||
double low = MIN_DT; |
|
||||||
double high = MAX_DT; |
|
||||||
double max_stable = low; |
|
||||||
|
|
||||||
printf("Testing %s (period ~%.2f days):\n", test_body.name, test_body.expected_period_days); |
|
||||||
|
|
||||||
for (int iter = 0; iter < 10; iter++) { |
|
||||||
double mid = (low + high) / 2.0; |
|
||||||
bool stable = is_dt_stable(sim, test_body, mid, NUM_ORBITS); |
|
||||||
|
|
||||||
if (stable) { |
|
||||||
max_stable = mid; |
|
||||||
low = mid; |
|
||||||
printf(" dt=%.0fs: STABLE\n", mid); |
|
||||||
} else { |
|
||||||
high = mid; |
|
||||||
printf(" dt=%.0fs: UNSTABLE\n", mid); |
|
||||||
} |
|
||||||
|
|
||||||
if (high - low < 5.0) break; |
|
||||||
} |
|
||||||
|
|
||||||
printf(" Maximum stable dt: %.0f seconds\n\n", max_stable); |
|
||||||
return max_stable; |
|
||||||
} |
|
||||||
|
|
||||||
void print_summary(const StabilityResult* results, int num_results, double min_stable_dt, double default_dt) { |
|
||||||
printf("\n"); |
|
||||||
printf("===============================================================================\n"); |
|
||||||
printf(" TIME STEP STABILITY TEST RESULTS\n"); |
|
||||||
printf("===============================================================================\n\n"); |
|
||||||
|
|
||||||
printf("STABILITY CRITERIA:\n"); |
|
||||||
printf(" - Energy drift < %.1f%% over %d orbits\n", ENERGY_TOLERANCE, NUM_ORBITS); |
|
||||||
printf(" - Distance drift < 5.0%%\n"); |
|
||||||
printf(" - No SOI transitions (parent changes)\n\n"); |
|
||||||
|
|
||||||
printf("PER-BODY RESULTS:\n"); |
|
||||||
printf("+----------------------+----------------+------------------+----------------+\n"); |
|
||||||
printf("| Body | Period (days) | Max Stable dt (s) | Stability Status |\n"); |
|
||||||
printf("+----------------------+----------------+------------------+----------------+\n"); |
|
||||||
|
|
||||||
for (int i = 0; i < num_results; i++) { |
|
||||||
const StabilityResult& r = results[i]; |
|
||||||
double ratio = default_dt / r.max_stable_dt; |
|
||||||
const char* status = ratio < 0.5 ? "Very Stable" : ratio < 0.8 ? "Stable" : "Limited Margin"; |
|
||||||
printf("| %-20s | %14.2f | %16.0f | %-14s |\n", r.name, r.period_days, r.max_stable_dt, status); |
|
||||||
} |
|
||||||
|
|
||||||
printf("+----------------------+----------------+------------------+----------------+\n\n"); |
|
||||||
|
|
||||||
printf("OVERALL ANALYSIS:\n"); |
|
||||||
printf(" Minimum stable time step: %.0f seconds\n", min_stable_dt); |
|
||||||
printf(" Recommended safe dt: %.0f seconds (0.7x safety margin)\n", min_stable_dt * 0.7); |
|
||||||
printf(" Current default dt: %.0f seconds\n", default_dt); |
|
||||||
printf(" Current dt stability: %.0fx\n", default_dt / min_stable_dt); |
|
||||||
printf("\n"); |
|
||||||
|
|
||||||
if (default_dt < min_stable_dt * 0.7) { |
|
||||||
printf("STATUS: Current time step (60s) is VERY STABLE with good margin.\n"); |
|
||||||
printf(" Can be increased significantly if needed.\n\n"); |
|
||||||
} else if (default_dt < min_stable_dt) { |
|
||||||
printf("STATUS: Current time step (60s) is STABLE with adequate margin.\n"); |
|
||||||
printf(" Moderate increases possible.\n\n"); |
|
||||||
} else { |
|
||||||
printf("STATUS: Current time step (60s) is near stability limit.\n"); |
|
||||||
printf(" Consider reducing for safety.\n\n"); |
|
||||||
} |
|
||||||
|
|
||||||
printf("RECOMMENDATIONS:\n"); |
|
||||||
printf(" - For MESSENGER-like close orbits: Keep dt <= %.0f seconds\n", min_stable_dt); |
|
||||||
printf(" - For planetary missions: Current dt=60s is excellent\n"); |
|
||||||
printf(" - For Moon-scale orbits: Could use dt=120s+ safely\n"); |
|
||||||
printf("\n"); |
|
||||||
printf("===============================================================================\n\n"); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Time step stability - Mercury orbiter (MESSENGER-like)", "[timestep][stability]") { |
|
||||||
const double BASE_DT = 60.0; |
|
||||||
SimulationState* sim = create_simulation(10, 0, 0, BASE_DT); |
|
||||||
|
|
||||||
TestBody mercury_orbiter = {"Mercury_Orbiter", 1, 0, 0.5}; |
|
||||||
double max_dt = find_max_stable_dt(sim, mercury_orbiter); |
|
||||||
|
|
||||||
INFO("Mercury orbiter maximum stable dt: " << max_dt << " seconds"); |
|
||||||
|
|
||||||
REQUIRE(max_dt >= MIN_DT); |
|
||||||
|
|
||||||
destroy_simulation(sim); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Time step stability - Io (Jupiter's moon)", "[timestep][stability]") { |
|
||||||
const double BASE_DT = 60.0; |
|
||||||
SimulationState* sim = create_simulation(10, 0, 0, BASE_DT); |
|
||||||
|
|
||||||
TestBody io = {"Io", 3, 2, 1.77}; |
|
||||||
double max_dt = find_max_stable_dt(sim, io); |
|
||||||
|
|
||||||
INFO("Io maximum stable dt: " << max_dt << " seconds"); |
|
||||||
|
|
||||||
REQUIRE(max_dt >= MIN_DT); |
|
||||||
|
|
||||||
destroy_simulation(sim); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Time step stability - Moon (Earth's moon)", "[timestep][stability]") { |
|
||||||
const double BASE_DT = 60.0; |
|
||||||
SimulationState* sim = create_simulation(10, 0, 0, BASE_DT); |
|
||||||
|
|
||||||
TestBody moon = {"Moon", 5, 4, 27.3}; |
|
||||||
double max_dt = find_max_stable_dt(sim, moon); |
|
||||||
|
|
||||||
INFO("Moon maximum stable dt: " << max_dt << " seconds"); |
|
||||||
|
|
||||||
REQUIRE(max_dt >= MIN_DT); |
|
||||||
|
|
||||||
destroy_simulation(sim); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Find minimum stable time step across all bodies", "[timestep][stability]") { |
|
||||||
const double BASE_DT = 60.0; |
|
||||||
SimulationState* sim = create_simulation(10, 0, 0, BASE_DT); |
|
||||||
|
|
||||||
TestBody bodies[] = { |
|
||||||
{"Mercury_Orbiter", 1, 0, 0.5}, |
|
||||||
{"Io", 3, 2, 1.77}, |
|
||||||
{"Moon", 5, 4, 27.3} |
|
||||||
}; |
|
||||||
|
|
||||||
StabilityResult results[3]; |
|
||||||
double max_dt = MAX_DT; |
|
||||||
|
|
||||||
printf("\n=== Finding minimum stable dt across all bodies ===\n\n"); |
|
||||||
|
|
||||||
for (int i = 0; i < 3; i++) { |
|
||||||
results[i].name = bodies[i].name; |
|
||||||
results[i].period_days = bodies[i].expected_period_days; |
|
||||||
results[i].max_stable_dt = find_max_stable_dt(sim, bodies[i]); |
|
||||||
if (results[i].max_stable_dt < max_dt) { |
|
||||||
max_dt = results[i].max_stable_dt; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
printf("\n=== RESULTS ===\n"); |
|
||||||
printf("Minimum stable time step: %.0f seconds\n", max_dt); |
|
||||||
printf("Recommended safe time step: %.0f seconds (%.0fx safety margin)\n", max_dt * 0.7, 1.0/0.7); |
|
||||||
|
|
||||||
INFO("Minimum stable dt: " << max_dt << " seconds"); |
|
||||||
|
|
||||||
print_summary(results, 3, max_dt, BASE_DT); |
|
||||||
|
|
||||||
REQUIRE(max_dt >= MIN_DT); |
|
||||||
|
|
||||||
destroy_simulation(sim); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Verify current default dt (60s) stability", "[timestep][stability]") { |
|
||||||
const double DT = 60.0; |
|
||||||
const int NUM_ORBITS = 10; |
|
||||||
|
|
||||||
SimulationState* sim = create_simulation(10, 0, 0, DT); |
|
||||||
REQUIRE(load_system_config(sim, "tests/informational/test_time_step_stability.toml")); |
|
||||||
|
|
||||||
struct BodyTest { |
|
||||||
int body_index; |
|
||||||
int parent_index; |
|
||||||
const char* name; |
|
||||||
}; |
|
||||||
|
|
||||||
BodyTest tests[] = { |
|
||||||
{1, 0, "Mercury_Orbiter"}, |
|
||||||
{3, 2, "Io"}, |
|
||||||
{5, 4, "Moon"} |
|
||||||
}; |
|
||||||
|
|
||||||
for (int t = 0; t < 3; t++) { |
|
||||||
int body_index = tests[t].body_index; |
|
||||||
int parent_index = tests[t].parent_index; |
|
||||||
const char* name = tests[t].name; |
|
||||||
|
|
||||||
double period = calculate_orbital_period(&sim->bodies[body_index], &sim->bodies[parent_index]); |
|
||||||
double max_time = period * NUM_ORBITS; |
|
||||||
|
|
||||||
double initial_energy = calculate_system_total_energy(sim); |
|
||||||
|
|
||||||
INFO("Testing " << name << " with dt=" << DT << "s for " << NUM_ORBITS << " orbits"); |
|
||||||
|
|
||||||
bool completed = true; |
|
||||||
while (sim->time < max_time) { |
|
||||||
update_simulation(sim); |
|
||||||
|
|
||||||
if (sim->bodies[body_index].parent_index != parent_index) { |
|
||||||
completed = false; |
|
||||||
break; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
double final_energy = calculate_system_total_energy(sim); |
|
||||||
double energy_drift_percent = fabs((final_energy - initial_energy) / initial_energy) * 100.0; |
|
||||||
|
|
||||||
INFO(name << " completed: " << (completed ? "yes" : "no")); |
|
||||||
INFO(name << " energy drift: " << energy_drift_percent << "%"); |
|
||||||
|
|
||||||
REQUIRE(completed); |
|
||||||
REQUIRE(energy_drift_percent < ENERGY_TOLERANCE); |
|
||||||
} |
|
||||||
|
|
||||||
destroy_simulation(sim); |
|
||||||
} |
|
||||||
@ -1,80 +0,0 @@ |
|||||||
# Time Step Stability Test Configuration |
|
||||||
# Contains bodies with different orbital periods for testing RK4 stability |
|
||||||
|
|
||||||
[[bodies]] |
|
||||||
name = "Mercury" |
|
||||||
mass = 3.285e23 |
|
||||||
radius = 2.439e6 |
|
||||||
parent_index = -1 |
|
||||||
color = { r = 0.7, g = 0.7, b = 0.7 } |
|
||||||
orbit = { |
|
||||||
semi_major_axis = 0.0, |
|
||||||
eccentricity = 0.0, |
|
||||||
true_anomaly = 0.0 |
|
||||||
} |
|
||||||
|
|
||||||
# Low Mercury orbiter at 200 km altitude (MESSENGER-like) |
|
||||||
# Period ~12 hours - most restrictive case |
|
||||||
[[bodies]] |
|
||||||
name = "Mercury_Orbiter" |
|
||||||
mass = 1000.0 |
|
||||||
radius = 1.0 |
|
||||||
parent_index = 0 |
|
||||||
color = { r = 1.0, g = 0.0, b = 1.0 } |
|
||||||
orbit = { |
|
||||||
semi_major_axis = 2.639e6, |
|
||||||
eccentricity = 0.0, |
|
||||||
true_anomaly = 0.0 |
|
||||||
} |
|
||||||
|
|
||||||
[[bodies]] |
|
||||||
name = "Jupiter" |
|
||||||
mass = 1.898e27 |
|
||||||
radius = 6.9911e7 |
|
||||||
parent_index = -1 |
|
||||||
color = { r = 0.9, g = 0.7, b = 0.5 } |
|
||||||
orbit = { |
|
||||||
semi_major_axis = 0.0, |
|
||||||
eccentricity = 0.0, |
|
||||||
true_anomaly = 0.0 |
|
||||||
} |
|
||||||
|
|
||||||
# Io - Jupiter's moon |
|
||||||
# Period ~1.77 days |
|
||||||
[[bodies]] |
|
||||||
name = "Io" |
|
||||||
mass = 8.93e22 |
|
||||||
radius = 1.822e6 |
|
||||||
parent_index = 2 |
|
||||||
color = { r = 0.9, g = 0.9, b = 0.3 } |
|
||||||
orbit = { |
|
||||||
semi_major_axis = 4.217e8, |
|
||||||
eccentricity = 0.0, |
|
||||||
true_anomaly = 0.0 |
|
||||||
} |
|
||||||
|
|
||||||
[[bodies]] |
|
||||||
name = "Earth" |
|
||||||
mass = 5.972e24 |
|
||||||
radius = 6.371e6 |
|
||||||
parent_index = -1 |
|
||||||
color = { r = 0.0, g = 0.5, b = 1.0 } |
|
||||||
orbit = { |
|
||||||
semi_major_axis = 0.0, |
|
||||||
eccentricity = 0.0, |
|
||||||
true_anomaly = 0.0 |
|
||||||
} |
|
||||||
|
|
||||||
# Moon - Earth's moon |
|
||||||
# Period ~27.3 days |
|
||||||
[[bodies]] |
|
||||||
name = "Moon" |
|
||||||
mass = 7.342e22 |
|
||||||
radius = 1.737e6 |
|
||||||
parent_index = 4 |
|
||||||
color = { r = 0.7, g = 0.7, b = 0.7 } |
|
||||||
orbit = { |
|
||||||
semi_major_axis = 3.844e8, |
|
||||||
eccentricity = 0.0, |
|
||||||
true_anomaly = 0.0 |
|
||||||
} |
|
||||||
@ -1,508 +1,221 @@ |
|||||||
#include <catch2/catch_test_macros.hpp> |
#include <catch2/catch_test_macros.hpp> |
||||||
#include <catch2/matchers/catch_matchers_floating_point.hpp> |
#include <catch2/matchers/catch_matchers_floating_point.hpp> |
||||||
#include <cmath> |
#include <cmath> |
||||||
|
#include <array> |
||||||
|
#include <vector> |
||||||
#include "../src/orbital_mechanics.h" |
#include "../src/orbital_mechanics.h" |
||||||
#include "../src/orbital_objects.h" |
|
||||||
#include "../src/test_utilities.h" |
#include "../src/test_utilities.h" |
||||||
#include "../src/config_loader.h" |
|
||||||
#include "../src/simulation.h" |
|
||||||
|
|
||||||
using Catch::Matchers::WithinAbs; |
using Catch::Matchers::WithinAbs; |
||||||
|
|
||||||
TEST_CASE("Cartesian to Elements - Advanced Tests", "[orbital_mechanics]") { |
SCENARIO("Cartesian to Elements - Advanced conversion tests", |
||||||
const double G = 6.67430e-11; |
"[orbital_mechanics][cartesian][elements]") { |
||||||
const double M_sun = 1.989e30; |
const double M_sun = 1.989e30; |
||||||
const double mu = G * M_sun; |
|
||||||
|
|
||||||
SECTION("Circular orbit conversion preserves exact circular parameters") { |
// NOTE: Semi-major axis tolerance for |a|=1e11 m cases. The vis-viva equation
|
||||||
double r = 1.496e11; |
// a = -mu/(2*epsilon) amplifies floating-point error in specific energy
|
||||||
double v_circular = sqrt(mu / r); |
// (~1e-15 rel) to absolute errors of ~1e-4 m at this scale. Header A_TOL=1e-6
|
||||||
Vec3 position = {r, 0.0, 0.0}; |
// would fail; 2e-4 provides comfortable margin over observed ~1.4e-4 m error.
|
||||||
Vec3 velocity = {0.0, v_circular, 0.0}; |
const double A_TOL_LARGE = 2e-4; |
||||||
|
|
||||||
OrbitalElements elements = cartesian_to_orbital_elements(position, velocity, M_sun); |
auto convert_and_recover = [&](const OrbitalElements& elements) { |
||||||
|
Vec3 pos, vel; |
||||||
REQUIRE_THAT(elements.eccentricity, WithinAbs(0.0, 1e-10)); |
orbital_elements_to_cartesian(elements, M_sun, &pos, &vel); |
||||||
REQUIRE_THAT(elements.semi_major_axis, WithinAbs(r, 1e3)); |
return cartesian_to_orbital_elements(pos, vel, M_sun); |
||||||
|
}; |
||||||
Vec3 converted_position, converted_velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &converted_position, &converted_velocity); |
auto make_elements = [&](double a, double e, double nu, double inc, |
||||||
|
double lon_anode, double arg_peri) { |
||||||
REQUIRE(compare_vec3(position, converted_position, 1e3)); |
OrbitalElements el = {}; |
||||||
REQUIRE(compare_vec3(velocity, converted_velocity, 1e-3)); |
el.semi_major_axis = a; |
||||||
} |
el.eccentricity = e; |
||||||
|
el.true_anomaly = nu; |
||||||
SECTION("Near-circular orbit (e=0.001) recovers small eccentricity") { |
el.inclination = inc; |
||||||
OrbitalElements elements = { |
el.longitude_of_ascending_node = lon_anode; |
||||||
.semi_major_axis = 1.496e11, |
el.argument_of_periapsis = arg_peri; |
||||||
.eccentricity = 0.001, |
return el; |
||||||
.true_anomaly = 0.5, |
}; |
||||||
.inclination = 0.0, |
|
||||||
.longitude_of_ascending_node = 0.0, |
SECTION("eccentricity spectrum: circular to highly hyperbolic") { |
||||||
.argument_of_periapsis = 0.0 |
const double r = 1.496e11; |
||||||
}; |
const double v_circular = sqrt(G * M_sun / r); |
||||||
|
const Vec3 pos_circ = {r, 0.0, 0.0}; |
||||||
Vec3 position, velocity; |
const Vec3 vel_circ = {0.0, v_circular, 0.0}; |
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
|
||||||
|
const OrbitalElements circular = make_elements(r, 0.0, 0.0, 0.0, 0.0, 0.0); |
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
Vec3 converted_pos, converted_vel; |
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.001, 1e-6)); |
orbital_elements_to_cartesian(circular, M_sun, &converted_pos, &converted_vel); |
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.496e11, 1e3)); |
|
||||||
} |
const OrbitalElements recovered_circ = |
||||||
|
cartesian_to_orbital_elements(converted_pos, converted_vel, M_sun); |
||||||
SECTION("Elliptical orbit (e=0.5) preserves orbital shape") { |
REQUIRE_THAT(recovered_circ.eccentricity, WithinAbs(0.0, E_TOL)); |
||||||
OrbitalElements elements = { |
REQUIRE_THAT(recovered_circ.semi_major_axis, WithinAbs(r, A_TOL_LARGE)); |
||||||
.semi_major_axis = 1.0e11, |
REQUIRE(compare_vec3(pos_circ, converted_pos, A_TOL_LARGE)); |
||||||
.eccentricity = 0.5, |
REQUIRE(compare_vec3(vel_circ, converted_vel, V_TOL)); |
||||||
.true_anomaly = 0.8, |
|
||||||
.inclination = 0.0, |
// Near-circular (e=0.001)
|
||||||
.longitude_of_ascending_node = 0.0, |
const OrbitalElements near_circ = make_elements(1.496e11, 0.001, 0.5, 0.0, 0.0, 0.0); |
||||||
.argument_of_periapsis = 0.0 |
const OrbitalElements rec_near_circ = convert_and_recover(near_circ); |
||||||
}; |
REQUIRE_THAT(rec_near_circ.eccentricity, WithinAbs(0.001, E_TOL)); |
||||||
|
REQUIRE_THAT(rec_near_circ.semi_major_axis, WithinAbs(1.496e11, A_TOL_LARGE)); |
||||||
Vec3 position, velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
// Elliptical (e=0.5)
|
||||||
|
const OrbitalElements elliptical = make_elements(1.0e11, 0.5, 0.8, 0.0, 0.0, 0.0); |
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
const OrbitalElements rec_elliptical = convert_and_recover(elliptical); |
||||||
|
REQUIRE_THAT(rec_elliptical.eccentricity, WithinAbs(0.5, E_TOL)); |
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); |
REQUIRE_THAT(rec_elliptical.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE)); |
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6)); |
|
||||||
} |
// Highly elliptical (e=0.95)
|
||||||
|
const OrbitalElements high_ell = make_elements(1.0e11, 0.95, 0.1, 0.0, 0.0, 0.0); |
||||||
SECTION("Highly elliptical orbit (e=0.95) preserves extreme eccentricity") { |
const OrbitalElements rec_high_ell = convert_and_recover(high_ell); |
||||||
OrbitalElements elements = { |
REQUIRE_THAT(rec_high_ell.eccentricity, WithinAbs(0.95, E_TOL)); |
||||||
.semi_major_axis = 1.0e11, |
REQUIRE_THAT(rec_high_ell.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE)); |
||||||
.eccentricity = 0.95, |
|
||||||
.true_anomaly = 0.1, |
// Near-parabolic (e=0.999)
|
||||||
.inclination = 0.0, |
const OrbitalElements near_par = make_elements(1.0e11, 0.999, 0.05, 0.0, 0.0, 0.0); |
||||||
.longitude_of_ascending_node = 0.0, |
const OrbitalElements rec_near_par = convert_and_recover(near_par); |
||||||
.argument_of_periapsis = 0.0 |
REQUIRE_THAT(rec_near_par.eccentricity, WithinAbs(0.999, E_TOL)); |
||||||
}; |
|
||||||
|
// Parabolic (e=1.0)
|
||||||
Vec3 position, velocity; |
OrbitalElements parabolic = {}; |
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
parabolic.semi_latus_rectum = 1.0e11; |
||||||
|
parabolic.eccentricity = 1.0; |
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
parabolic.true_anomaly = 0.5; |
||||||
|
parabolic.inclination = 0.0; |
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.95, 1e-3)); |
parabolic.longitude_of_ascending_node = 0.0; |
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6)); |
parabolic.argument_of_periapsis = 0.0; |
||||||
} |
const OrbitalElements rec_parabolic = convert_and_recover(parabolic); |
||||||
|
REQUIRE_THAT(rec_parabolic.eccentricity, WithinAbs(1.0, E_TOL)); |
||||||
SECTION("Near-parabolic orbit (e=0.999) recovers near-escape trajectory") { |
REQUIRE_THAT(rec_parabolic.semi_latus_rectum, WithinAbs(1.0e11, A_TOL)); |
||||||
OrbitalElements elements = { |
|
||||||
.semi_major_axis = 1.0e11, |
// Hyperbolic (e=2.0)
|
||||||
.eccentricity = 0.999, |
const OrbitalElements hyper = make_elements(-1.0e11, 2.0, 0.5, 0.0, 0.0, 0.0); |
||||||
.true_anomaly = 0.05, |
const OrbitalElements rec_hyper = convert_and_recover(hyper); |
||||||
.inclination = 0.0, |
REQUIRE_THAT(rec_hyper.eccentricity, WithinAbs(2.0, E_TOL)); |
||||||
.longitude_of_ascending_node = 0.0, |
REQUIRE_THAT(rec_hyper.semi_major_axis, WithinAbs(-1.0e11, A_TOL_LARGE)); |
||||||
.argument_of_periapsis = 0.0 |
|
||||||
}; |
// Highly hyperbolic (e=10.0)
|
||||||
|
const OrbitalElements high_hyper = make_elements(-1.0e10, 10.0, 0.8, 0.0, 0.0, 0.0); |
||||||
Vec3 position, velocity; |
const OrbitalElements rec_high_hyper = convert_and_recover(high_hyper); |
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
REQUIRE_THAT(rec_high_hyper.eccentricity, WithinAbs(10.0, E_TOL)); |
||||||
|
REQUIRE_THAT(rec_high_hyper.semi_major_axis, WithinAbs(-1.0e10, A_TOL)); |
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
} |
||||||
|
|
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.999, 1e-3)); |
SECTION("inclination: zero, polar, and retrograde") { |
||||||
} |
// Zero inclination (equatorial)
|
||||||
|
const OrbitalElements eq = make_elements(1.0e11, 0.3, 0.5, 0.0, 0.0, 0.0); |
||||||
SECTION("Parabolic orbit (e=1.0) recovers escape trajectory") { |
const OrbitalElements rec_eq = convert_and_recover(eq); |
||||||
OrbitalElements elements = { |
REQUIRE_THAT(rec_eq.inclination, WithinAbs(0.0, ANG_TOL)); |
||||||
.semi_latus_rectum = 1.0e11, |
REQUIRE_THAT(rec_eq.eccentricity, WithinAbs(0.3, E_TOL)); |
||||||
.eccentricity = 1.0, |
|
||||||
.true_anomaly = 0.5, |
// 90-degree inclination (polar)
|
||||||
.inclination = 0.0, |
const OrbitalElements polar = make_elements(1.0e11, 0.2, 0.6, M_PI / 2.0, 0.5, 0.3); |
||||||
.longitude_of_ascending_node = 0.0, |
const OrbitalElements rec_polar = convert_and_recover(polar); |
||||||
.argument_of_periapsis = 0.0 |
REQUIRE_THAT(rec_polar.inclination, WithinAbs(M_PI / 2.0, ANG_TOL_COARSE)); |
||||||
}; |
REQUIRE_THAT(rec_polar.longitude_of_ascending_node, WithinAbs(0.5, ANG_TOL_COARSE)); |
||||||
|
REQUIRE_THAT(rec_polar.argument_of_periapsis, WithinAbs(0.3, ANG_TOL_COARSE)); |
||||||
Vec3 position, velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
// 180-degree inclination (retrograde)
|
||||||
|
const OrbitalElements retro = make_elements(1.0e11, 0.2, 0.6, M_PI, 0.5, 0.3); |
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
const OrbitalElements rec_retro = convert_and_recover(retro); |
||||||
|
REQUIRE_THAT(rec_retro.inclination, WithinAbs(M_PI, ANG_TOL_COARSE)); |
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(1.0, 1e-2)); |
} |
||||||
REQUIRE_THAT(recovered.semi_latus_rectum, WithinAbs(1.0e11, 1e3)); |
|
||||||
} |
SECTION("true anomaly at key orbital positions") { |
||||||
|
struct nu_test { |
||||||
SECTION("Hyperbolic orbit (e=2.0) preserves unbound trajectory") { |
double nu; |
||||||
OrbitalElements elements = { |
double expected_nu; |
||||||
.semi_major_axis = -1.0e11, |
const char* label; |
||||||
.eccentricity = 2.0, |
}; |
||||||
.true_anomaly = 0.5, |
std::vector<nu_test> tests = { |
||||||
.inclination = 0.0, |
{0.0, 0.0, "periapsis"}, |
||||||
.longitude_of_ascending_node = 0.0, |
{M_PI, M_PI, "apoapsis"}, |
||||||
.argument_of_periapsis = 0.0 |
{M_PI / 2.0, M_PI / 2.0, "quadrature +90"}, |
||||||
}; |
{-M_PI / 2.0, 3.0 * M_PI / 2.0, "quadrature -90"}, |
||||||
|
{3.0 * M_PI / 2.0, 3.0 * M_PI / 2.0, "quadrature +270"}, |
||||||
Vec3 position, velocity; |
{-3.0 * M_PI / 2.0, M_PI / 2.0, "quadrature -270"}, |
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
}; |
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
for (const auto& t : tests) { |
||||||
|
const OrbitalElements elements = make_elements(1.0e11, 0.5, t.nu, 0.0, 0.0, 0.0); |
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(2.0, 1e-3)); |
const OrbitalElements recovered = convert_and_recover(elements); |
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(-1.0e11, 1e6)); |
INFO("Test: " << t.label << " (input nu=" << t.nu << ")"); |
||||||
} |
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(t.expected_nu, ANG_TOL)); |
||||||
|
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, E_TOL)); |
||||||
SECTION("Highly hyperbolic orbit (e=10.0) preserves extreme unbound trajectory") { |
} |
||||||
OrbitalElements elements = { |
|
||||||
.semi_major_axis = -1.0e10, |
|
||||||
.eccentricity = 10.0, |
|
||||||
.true_anomaly = 0.8, |
|
||||||
.inclination = 0.0, |
|
||||||
.longitude_of_ascending_node = 0.0, |
|
||||||
.argument_of_periapsis = 0.0 |
|
||||||
}; |
|
||||||
|
|
||||||
Vec3 position, velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
|
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
|
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(10.0, 1e-3)); |
|
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(-1.0e10, 1e8)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Zero inclination (i=0) preserves equatorial orbit") { |
|
||||||
OrbitalElements elements = { |
|
||||||
.semi_major_axis = 1.0e11, |
|
||||||
.eccentricity = 0.3, |
|
||||||
.true_anomaly = 0.5, |
|
||||||
.inclination = 0.0, |
|
||||||
.longitude_of_ascending_node = 0.0, |
|
||||||
.argument_of_periapsis = 0.0 |
|
||||||
}; |
|
||||||
|
|
||||||
Vec3 position, velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
|
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
|
||||||
REQUIRE_THAT(recovered.inclination, WithinAbs(0.0, 1e-6)); |
|
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.3, 1e-4)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("90-degree inclination (i=pi/2) preserves polar orbit") { |
|
||||||
OrbitalElements elements = { |
|
||||||
.semi_major_axis = 1.0e11, |
|
||||||
.eccentricity = 0.2, |
|
||||||
.true_anomaly = 0.6, |
|
||||||
.inclination = M_PI / 2.0, |
|
||||||
.longitude_of_ascending_node = 0.5, |
|
||||||
.argument_of_periapsis = 0.3 |
|
||||||
}; |
|
||||||
|
|
||||||
Vec3 position, velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
|
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
|
||||||
REQUIRE_THAT(recovered.inclination, WithinAbs(M_PI / 2.0, 1e-4)); |
|
||||||
REQUIRE_THAT(recovered.longitude_of_ascending_node, WithinAbs(0.5, 1e-4)); |
|
||||||
REQUIRE_THAT(recovered.argument_of_periapsis, WithinAbs(0.3, 1e-4)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("180-degree inclination (i=pi) preserves retrograde orbit") { |
|
||||||
OrbitalElements elements = { |
|
||||||
.semi_major_axis = 1.0e11, |
|
||||||
.eccentricity = 0.2, |
|
||||||
.true_anomaly = 0.6, |
|
||||||
.inclination = M_PI, |
|
||||||
.longitude_of_ascending_node = 0.5, |
|
||||||
.argument_of_periapsis = 0.3 |
|
||||||
}; |
|
||||||
|
|
||||||
Vec3 position, velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
|
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
|
||||||
REQUIRE_THAT(recovered.inclination, WithinAbs(M_PI, 1e-4)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Periapsis (nu=0) recovers true anomaly correctly") { |
|
||||||
OrbitalElements elements = { |
|
||||||
.semi_major_axis = 1.0e11, |
|
||||||
.eccentricity = 0.5, |
|
||||||
.true_anomaly = 0.0, |
|
||||||
.inclination = 0.0, |
|
||||||
.longitude_of_ascending_node = 0.0, |
|
||||||
.argument_of_periapsis = 0.0 |
|
||||||
}; |
|
||||||
|
|
||||||
Vec3 position, velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
|
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
|
||||||
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(0.0, 1e-6)); |
|
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Apoapsis (nu=pi) recovers true anomaly correctly") { |
|
||||||
OrbitalElements elements = { |
|
||||||
.semi_major_axis = 1.0e11, |
|
||||||
.eccentricity = 0.5, |
|
||||||
.true_anomaly = M_PI, |
|
||||||
.inclination = 0.0, |
|
||||||
.longitude_of_ascending_node = 0.0, |
|
||||||
.argument_of_periapsis = 0.0 |
|
||||||
}; |
|
||||||
|
|
||||||
Vec3 position, velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
|
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
|
||||||
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI, 1e-6)); |
|
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Quadrature point nu=pi/2 (90 deg) preserves orbital elements") { |
|
||||||
OrbitalElements elements = { |
|
||||||
.semi_major_axis = 1.0e11, |
|
||||||
.eccentricity = 0.5, |
|
||||||
.true_anomaly = M_PI / 2.0, |
|
||||||
.inclination = 0.0, |
|
||||||
.longitude_of_ascending_node = 0.0, |
|
||||||
.argument_of_periapsis = 0.0 |
|
||||||
}; |
|
||||||
|
|
||||||
Vec3 position, velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
|
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
|
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); |
|
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6)); |
|
||||||
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, 1e-6)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Quadrature point nu=-pi/2 (-90 deg) preserves orbital elements") { |
|
||||||
OrbitalElements elements = { |
|
||||||
.semi_major_axis = 1.0e11, |
|
||||||
.eccentricity = 0.5, |
|
||||||
.true_anomaly = -M_PI / 2.0, |
|
||||||
.inclination = 0.0, |
|
||||||
.longitude_of_ascending_node = 0.0, |
|
||||||
.argument_of_periapsis = 0.0 |
|
||||||
}; |
|
||||||
|
|
||||||
Vec3 position, velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
|
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
|
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); |
|
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6)); |
|
||||||
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(3.0 * M_PI / 2.0, 1e-6)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Quadrature point nu=3pi/2 (270 deg) preserves orbital elements") { |
|
||||||
OrbitalElements elements = { |
|
||||||
.semi_major_axis = 1.0e11, |
|
||||||
.eccentricity = 0.5, |
|
||||||
.true_anomaly = 3.0 * M_PI / 2.0, |
|
||||||
.inclination = 0.0, |
|
||||||
.longitude_of_ascending_node = 0.0, |
|
||||||
.argument_of_periapsis = 0.0 |
|
||||||
}; |
|
||||||
|
|
||||||
Vec3 position, velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
|
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
|
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); |
|
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6)); |
|
||||||
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(3.0 * M_PI / 2.0, 1e-6)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Quadrature point nu=-3pi/2 (-270 deg) preserves orbital elements") { |
|
||||||
OrbitalElements elements = { |
|
||||||
.semi_major_axis = 1.0e11, |
|
||||||
.eccentricity = 0.5, |
|
||||||
.true_anomaly = -3.0 * M_PI / 2.0, |
|
||||||
.inclination = 0.0, |
|
||||||
.longitude_of_ascending_node = 0.0, |
|
||||||
.argument_of_periapsis = 0.0 |
|
||||||
}; |
|
||||||
|
|
||||||
Vec3 position, velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
|
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
|
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); |
|
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6)); |
|
||||||
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, 1e-6)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Quadrature point with high eccentricity (e=0.9) preserves accuracy") { |
|
||||||
OrbitalElements elements = { |
|
||||||
.semi_major_axis = 1.0e11, |
|
||||||
.eccentricity = 0.9, |
|
||||||
.true_anomaly = M_PI / 2.0, |
|
||||||
.inclination = 0.0, |
|
||||||
.longitude_of_ascending_node = 0.0, |
|
||||||
.argument_of_periapsis = 0.0 |
|
||||||
}; |
|
||||||
|
|
||||||
Vec3 position, velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
|
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
|
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.9, 1e-3)); |
|
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e7)); |
|
||||||
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, 1e-5)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Quadrature point with low eccentricity (e=0.1) preserves accuracy") { |
|
||||||
OrbitalElements elements = { |
|
||||||
.semi_major_axis = 1.0e11, |
|
||||||
.eccentricity = 0.1, |
|
||||||
.true_anomaly = M_PI / 2.0, |
|
||||||
.inclination = 0.0, |
|
||||||
.longitude_of_ascending_node = 0.0, |
|
||||||
.argument_of_periapsis = 0.0 |
|
||||||
}; |
|
||||||
|
|
||||||
Vec3 position, velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
|
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
|
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.1, 1e-5)); |
|
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e4)); |
|
||||||
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, 1e-6)); |
|
||||||
} |
} |
||||||
|
|
||||||
SECTION("Large true anomaly nu=5.0 rad (approx 286 deg) preserves accuracy") { |
SECTION("quadrature at various eccentricities") { |
||||||
OrbitalElements elements = { |
struct e_test { |
||||||
.semi_major_axis = 1.0e11, |
double e; |
||||||
.eccentricity = 0.5, |
|
||||||
.true_anomaly = 5.0, |
|
||||||
.inclination = 0.0, |
|
||||||
.longitude_of_ascending_node = 0.0, |
|
||||||
.argument_of_periapsis = 0.0 |
|
||||||
}; |
}; |
||||||
|
std::vector<e_test> tests = { |
||||||
Vec3 position, velocity; |
{0.9}, |
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
{0.1}, |
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
|
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); |
|
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6)); |
|
||||||
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(5.0, 1e-6)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Large negative true anomaly nu=-5.0 rad (approx -286 deg) preserves accuracy") { |
|
||||||
OrbitalElements elements = { |
|
||||||
.semi_major_axis = 1.0e11, |
|
||||||
.eccentricity = 0.5, |
|
||||||
.true_anomaly = -5.0, |
|
||||||
.inclination = 0.0, |
|
||||||
.longitude_of_ascending_node = 0.0, |
|
||||||
.argument_of_periapsis = 0.0 |
|
||||||
}; |
}; |
||||||
|
|
||||||
Vec3 position, velocity; |
for (const auto& t : tests) { |
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
const OrbitalElements elements = make_elements(1.0e11, t.e, M_PI / 2.0, 0.0, 0.0, 0.0); |
||||||
|
const OrbitalElements recovered = convert_and_recover(elements); |
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
REQUIRE_THAT(recovered.eccentricity, WithinAbs(t.e, E_TOL)); |
||||||
|
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE)); |
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); |
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, ANG_TOL)); |
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6)); |
} |
||||||
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(1.28318530717958623, 1e-6)); |
|
||||||
} |
} |
||||||
|
|
||||||
SECTION("Very large true anomaly nu=10.0 rad (approx 573 deg) preserves accuracy") { |
SECTION("large true anomaly values") { |
||||||
OrbitalElements elements = { |
struct large_nu_test { |
||||||
.semi_major_axis = 1.0e11, |
double nu; |
||||||
.eccentricity = 0.5, |
double expected_nu; |
||||||
.true_anomaly = 10.0, |
const char* label; |
||||||
.inclination = 0.0, |
|
||||||
.longitude_of_ascending_node = 0.0, |
|
||||||
.argument_of_periapsis = 0.0 |
|
||||||
}; |
}; |
||||||
|
std::vector<large_nu_test> tests = { |
||||||
Vec3 position, velocity; |
{5.0, 5.0, "nu=5.0"}, |
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
{-5.0, 1.28318530717958623, "nu=-5.0"}, |
||||||
|
{10.0, 10.0 - 2.0 * M_PI, "nu=10.0"}, |
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
|
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); |
|
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e5)); |
|
||||||
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(10.0 - 2.0 * M_PI, 1e-5)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Quadrature point with 3D orientation preserves all elements") { |
|
||||||
OrbitalElements elements = { |
|
||||||
.semi_major_axis = 1.0e11, |
|
||||||
.eccentricity = 0.5, |
|
||||||
.true_anomaly = M_PI / 2.0, |
|
||||||
.inclination = M_PI / 3.0, |
|
||||||
.longitude_of_ascending_node = M_PI / 4.0, |
|
||||||
.argument_of_periapsis = M_PI / 6.0 |
|
||||||
}; |
}; |
||||||
|
|
||||||
Vec3 position, velocity; |
for (const auto& t : tests) { |
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
const OrbitalElements elements = make_elements(1.0e11, 0.5, t.nu, 0.0, 0.0, 0.0); |
||||||
|
const OrbitalElements recovered = convert_and_recover(elements); |
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
INFO("Test: " << t.label); |
||||||
|
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, E_TOL)); |
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); |
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE)); |
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6)); |
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(t.expected_nu, ANG_TOL)); |
||||||
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, 1e-5)); |
} |
||||||
REQUIRE_THAT(recovered.inclination, WithinAbs(M_PI / 3.0, 1e-4)); |
|
||||||
REQUIRE_THAT(recovered.longitude_of_ascending_node, WithinAbs(M_PI / 4.0, 1e-4)); |
|
||||||
REQUIRE_THAT(recovered.argument_of_periapsis, WithinAbs(M_PI / 6.0, 1e-4)); |
|
||||||
} |
} |
||||||
|
|
||||||
SECTION("Multiple quadrature points in sequence maintain accuracy") { |
SECTION("3D orientation with quadrature point") { |
||||||
double true_anomalies[] = {0.0, M_PI/4.0, M_PI/2.0, 3.0*M_PI/4.0, M_PI}; |
const OrbitalElements elements = make_elements(1.0e11, 0.5, M_PI / 2.0, |
||||||
|
M_PI / 3.0, M_PI / 4.0, M_PI / 6.0); |
||||||
for (int i = 0; i < 5; i++) { |
const OrbitalElements recovered = convert_and_recover(elements); |
||||||
OrbitalElements elements = { |
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, E_TOL)); |
||||||
.semi_major_axis = 1.0e11, |
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE)); |
||||||
.eccentricity = 0.5, |
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, ANG_TOL)); |
||||||
.true_anomaly = true_anomalies[i], |
REQUIRE_THAT(recovered.inclination, WithinAbs(M_PI / 3.0, ANG_TOL_COARSE)); |
||||||
.inclination = 0.0, |
REQUIRE_THAT(recovered.longitude_of_ascending_node, WithinAbs(M_PI / 4.0, ANG_TOL_COARSE)); |
||||||
.longitude_of_ascending_node = 0.0, |
REQUIRE_THAT(recovered.argument_of_periapsis, WithinAbs(M_PI / 6.0, ANG_TOL_COARSE)); |
||||||
.argument_of_periapsis = 0.0 |
} |
||||||
}; |
|
||||||
|
SECTION("multiple true anomaly points in sequence") { |
||||||
Vec3 position, velocity; |
std::array<double, 5> true_anomalies = {0.0, M_PI / 4.0, M_PI / 2.0, |
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
3.0 * M_PI / 4.0, M_PI}; |
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
for (double nu : true_anomalies) { |
||||||
|
const OrbitalElements elements = make_elements(1.0e11, 0.5, nu, 0.0, 0.0, 0.0); |
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, 1e-4)); |
const OrbitalElements recovered = convert_and_recover(elements); |
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, 1e6)); |
REQUIRE_THAT(recovered.eccentricity, WithinAbs(0.5, E_TOL)); |
||||||
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(true_anomalies[i], 1e-6)); |
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(1.0e11, A_TOL_LARGE)); |
||||||
|
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(nu, ANG_TOL)); |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
SECTION("Hyperbolic orbit at quadrature point nu=pi/2") { |
SECTION("hyperbolic orbit at quadrature point") { |
||||||
OrbitalElements elements = { |
const OrbitalElements elements = make_elements(-1.0e11, 2.0, M_PI / 2.0, 0.0, 0.0, 0.0); |
||||||
.semi_major_axis = -1.0e11, |
const OrbitalElements recovered = convert_and_recover(elements); |
||||||
.eccentricity = 2.0, |
REQUIRE_THAT(recovered.eccentricity, WithinAbs(2.0, E_TOL)); |
||||||
.true_anomaly = M_PI / 2.0, |
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(-1.0e11, A_TOL_LARGE)); |
||||||
.inclination = 0.0, |
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, ANG_TOL)); |
||||||
.longitude_of_ascending_node = 0.0, |
|
||||||
.argument_of_periapsis = 0.0 |
|
||||||
}; |
|
||||||
|
|
||||||
Vec3 position, velocity; |
|
||||||
orbital_elements_to_cartesian(elements, M_sun, &position, &velocity); |
|
||||||
|
|
||||||
OrbitalElements recovered = cartesian_to_orbital_elements(position, velocity, M_sun); |
|
||||||
|
|
||||||
REQUIRE_THAT(recovered.eccentricity, WithinAbs(2.0, 1e-3)); |
|
||||||
REQUIRE_THAT(recovered.semi_major_axis, WithinAbs(-1.0e11, 1e6)); |
|
||||||
REQUIRE_THAT(recovered.true_anomaly, WithinAbs(M_PI / 2.0, 1e-5)); |
|
||||||
} |
} |
||||||
} |
} |
||||||
|
|||||||
@ -1,190 +1,84 @@ |
|||||||
#include <catch2/catch_test_macros.hpp> |
#include <catch2/catch_test_macros.hpp> |
||||||
|
#include <catch2/matchers/catch_matchers_floating_point.hpp> |
||||||
#include "../src/physics.h" |
#include "../src/physics.h" |
||||||
#include "../src/orbital_mechanics.h" |
#include "../src/orbital_mechanics.h" |
||||||
#include "../src/simulation.h" |
#include "../src/simulation.h" |
||||||
#include "../src/config_loader.h" |
#include "../src/config_loader.h" |
||||||
|
#include "../src/test_utilities.h" |
||||||
#include <cmath> |
#include <cmath> |
||||||
|
|
||||||
const double POSITION_TOLERANCE = 1.0e6; |
using Catch::Matchers::WithinAbs; |
||||||
const double VELOCITY_TOLERANCE = 10.0; |
|
||||||
const double ELEMENT_TOLERANCE = 1.0e-6; |
|
||||||
|
|
||||||
TEST_CASE("Round-trip conversion: orbital elements → state vectors → orbital elements", "[cartesian][elements][roundtrip]") { |
SCENARIO("Cartesian ↔ orbital elements round-trip conversion", |
||||||
|
"[cartesian][elements][roundtrip]") { |
||||||
const double TIME_STEP = 60.0; |
const double TIME_STEP = 60.0; |
||||||
|
const double parent_mass = 5.972e24; |
||||||
|
|
||||||
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP); |
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP); |
||||||
|
|
||||||
REQUIRE(load_system_config(sim, "tests/test_cartesian_to_elements_basic.toml")); |
|
||||||
|
|
||||||
Spacecraft* craft = &sim->spacecraft[0]; |
|
||||||
|
|
||||||
OrbitalElements original_elements = craft->orbit; |
|
||||||
|
|
||||||
Vec3 position_from_elements; |
|
||||||
Vec3 velocity_from_elements; |
|
||||||
orbital_elements_to_cartesian(original_elements, sim->bodies[0].mass, &position_from_elements, &velocity_from_elements); |
|
||||||
|
|
||||||
INFO("Original orbital elements:"); |
|
||||||
INFO(" semi_major_axis: " << original_elements.semi_major_axis << " m"); |
|
||||||
INFO(" eccentricity: " << original_elements.eccentricity); |
|
||||||
INFO(" true_anomaly: " << original_elements.true_anomaly << " rad"); |
|
||||||
INFO(" inclination: " << original_elements.inclination << " rad"); |
|
||||||
INFO(" longitude_of_ascending_node: " << original_elements.longitude_of_ascending_node << " rad"); |
|
||||||
INFO(" argument_of_periapsis: " << original_elements.argument_of_periapsis << " rad"); |
|
||||||
|
|
||||||
INFO("State vectors from orbital elements:"); |
|
||||||
INFO(" position: (" << position_from_elements.x << ", " << position_from_elements.y << ", " << position_from_elements.z << ") m"); |
|
||||||
INFO(" velocity: (" << velocity_from_elements.x << ", " << velocity_from_elements.y << ", " << velocity_from_elements.z << ") m/s"); |
|
||||||
|
|
||||||
OrbitalElements converted_elements = cartesian_to_orbital_elements(position_from_elements, velocity_from_elements, sim->bodies[0].mass); |
|
||||||
|
|
||||||
INFO("Converted orbital elements:"); |
|
||||||
INFO(" semi_major_axis: " << converted_elements.semi_major_axis << " m"); |
|
||||||
INFO(" eccentricity: " << converted_elements.eccentricity); |
|
||||||
INFO(" true_anomaly: " << converted_elements.true_anomaly << " rad"); |
|
||||||
INFO(" inclination: " << converted_elements.inclination << " rad"); |
|
||||||
INFO(" longitude_of_ascending_node: " << converted_elements.longitude_of_ascending_node << " rad"); |
|
||||||
INFO(" argument_of_periapsis: " << converted_elements.argument_of_periapsis << " rad"); |
|
||||||
|
|
||||||
double semi_major_error = fabs(converted_elements.semi_major_axis - original_elements.semi_major_axis); |
|
||||||
double eccentricity_error = fabs(converted_elements.eccentricity - original_elements.eccentricity); |
|
||||||
double inclination_error = fabs(converted_elements.inclination - original_elements.inclination); |
|
||||||
|
|
||||||
INFO("Semi-major axis error: " << semi_major_error << " m"); |
|
||||||
INFO("Eccentricity error: " << eccentricity_error); |
|
||||||
INFO("Inclination error: " << inclination_error << " rad"); |
|
||||||
|
|
||||||
REQUIRE(semi_major_error < fabs(original_elements.semi_major_axis) * ELEMENT_TOLERANCE); |
|
||||||
REQUIRE(eccentricity_error < ELEMENT_TOLERANCE); |
|
||||||
REQUIRE(inclination_error < ELEMENT_TOLERANCE); |
|
||||||
|
|
||||||
destroy_simulation(sim); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Position magnitude preservation through conversion", "[cartesian][elements][position]") { |
|
||||||
const double TIME_STEP = 60.0; |
|
||||||
|
|
||||||
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP); |
|
||||||
|
|
||||||
REQUIRE(load_system_config(sim, "tests/test_cartesian_to_elements_basic.toml")); |
|
||||||
|
|
||||||
Spacecraft* craft = &sim->spacecraft[0]; |
|
||||||
|
|
||||||
Vec3 position_1; |
|
||||||
Vec3 velocity_1; |
|
||||||
orbital_elements_to_cartesian(craft->orbit, sim->bodies[0].mass, &position_1, &velocity_1); |
|
||||||
|
|
||||||
double radius_1 = vec3_magnitude(position_1); |
|
||||||
INFO("Original radius: " << radius_1 << " m"); |
|
||||||
|
|
||||||
OrbitalElements elements = cartesian_to_orbital_elements(position_1, velocity_1, sim->bodies[0].mass); |
|
||||||
|
|
||||||
Vec3 position_2; |
|
||||||
Vec3 velocity_2; |
|
||||||
orbital_elements_to_cartesian(elements, sim->bodies[0].mass, &position_2, &velocity_2); |
|
||||||
|
|
||||||
double radius_2 = vec3_magnitude(position_2); |
|
||||||
INFO("Reconstructed radius: " << radius_2 << " m"); |
|
||||||
|
|
||||||
double radius_error = fabs(radius_2 - radius_1); |
|
||||||
INFO("Radius error: " << radius_error << " m"); |
|
||||||
|
|
||||||
REQUIRE(radius_error < POSITION_TOLERANCE); |
|
||||||
|
|
||||||
destroy_simulation(sim); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Velocity magnitude preservation through conversion", "[cartesian][elements][velocity]") { |
|
||||||
const double TIME_STEP = 60.0; |
|
||||||
|
|
||||||
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP); |
|
||||||
|
|
||||||
REQUIRE(load_system_config(sim, "tests/test_cartesian_to_elements_basic.toml")); |
|
||||||
|
|
||||||
Spacecraft* craft = &sim->spacecraft[0]; |
|
||||||
|
|
||||||
Vec3 position_1; |
|
||||||
Vec3 velocity_1; |
|
||||||
orbital_elements_to_cartesian(craft->orbit, sim->bodies[0].mass, &position_1, &velocity_1); |
|
||||||
|
|
||||||
double v_mag_1 = vec3_magnitude(velocity_1); |
|
||||||
INFO("Original velocity magnitude: " << v_mag_1 << " m/s"); |
|
||||||
|
|
||||||
OrbitalElements elements = cartesian_to_orbital_elements(position_1, velocity_1, sim->bodies[0].mass); |
|
||||||
|
|
||||||
Vec3 position_2; |
|
||||||
Vec3 velocity_2; |
|
||||||
orbital_elements_to_cartesian(elements, sim->bodies[0].mass, &position_2, &velocity_2); |
|
||||||
|
|
||||||
double v_mag_2 = vec3_magnitude(velocity_2); |
|
||||||
INFO("Reconstructed velocity magnitude: " << v_mag_2 << " m/s"); |
|
||||||
|
|
||||||
double velocity_error = fabs(v_mag_2 - v_mag_1); |
|
||||||
INFO("Velocity error: " << velocity_error << " m/s"); |
|
||||||
|
|
||||||
REQUIRE(velocity_error < VELOCITY_TOLERANCE); |
|
||||||
|
|
||||||
destroy_simulation(sim); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Semi-major axis accuracy", "[cartesian][elements][semi_major]") { |
|
||||||
const double TIME_STEP = 60.0; |
|
||||||
|
|
||||||
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP); |
|
||||||
|
|
||||||
REQUIRE(load_system_config(sim, "tests/test_cartesian_to_elements_basic.toml")); |
REQUIRE(load_system_config(sim, "tests/test_cartesian_to_elements_basic.toml")); |
||||||
|
|
||||||
Spacecraft* craft = &sim->spacecraft[0]; |
Spacecraft* craft = &sim->spacecraft[0]; |
||||||
|
|
||||||
double expected_a = craft->orbit.semi_major_axis; |
const OrbitalElements& orig = craft->orbit; |
||||||
|
|
||||||
Vec3 position; |
// Convert elements → state vectors
|
||||||
Vec3 velocity; |
Vec3 pos, vel; |
||||||
orbital_elements_to_cartesian(craft->orbit, sim->bodies[0].mass, &position, &velocity); |
orbital_elements_to_cartesian(orig, parent_mass, &pos, &vel); |
||||||
|
|
||||||
OrbitalElements elements = cartesian_to_orbital_elements(position, velocity, sim->bodies[0].mass); |
const double expected_r = vec3_magnitude(pos); // 7500000.0
|
||||||
|
const double expected_v = vec3_magnitude(vel); // 8928.484709...
|
||||||
double actual_a = elements.semi_major_axis; |
|
||||||
|
// Round-trip: state vectors → elements
|
||||||
double a_error = fabs(actual_a - expected_a); |
const OrbitalElements recovered = cartesian_to_orbital_elements(pos, vel, parent_mass); |
||||||
double relative_error = a_error / fabs(expected_a); |
|
||||||
|
// Re-convert recovered elements → state vectors
|
||||||
INFO("Expected semi-major axis: " << expected_a << " m"); |
Vec3 pos2, vel2; |
||||||
INFO("Actual semi-major axis: " << actual_a << " m"); |
orbital_elements_to_cartesian(recovered, parent_mass, &pos2, &vel2); |
||||||
INFO("Absolute error: " << a_error << " m"); |
const double recovered_r = vec3_magnitude(pos2); |
||||||
INFO("Relative error: " << relative_error * 100.0 << "%"); |
const double recovered_v = vec3_magnitude(vel2); |
||||||
|
|
||||||
REQUIRE(relative_error < ELEMENT_TOLERANCE); |
SECTION("elements round-trip: semi-major axis") { |
||||||
|
const double da = fabs(recovered.semi_major_axis - orig.semi_major_axis); |
||||||
destroy_simulation(sim); |
INFO("Original a: " << orig.semi_major_axis); |
||||||
} |
INFO("Recovered a: " << recovered.semi_major_axis); |
||||||
|
INFO("Error: " << da << " m"); |
||||||
TEST_CASE("Eccentricity accuracy", "[cartesian][elements][eccentricity]") { |
REQUIRE_THAT(da, WithinAbs(0.0, A_TOL)); |
||||||
const double TIME_STEP = 60.0; |
} |
||||||
|
|
||||||
SimulationState* sim = create_simulation(10, 1, 0, TIME_STEP); |
SECTION("elements round-trip: eccentricity") { |
||||||
|
const double de = fabs(recovered.eccentricity - orig.eccentricity); |
||||||
REQUIRE(load_system_config(sim, "tests/test_cartesian_to_elements_basic.toml")); |
INFO("Original e: " << orig.eccentricity); |
||||||
|
INFO("Recovered e: " << recovered.eccentricity); |
||||||
Spacecraft* craft = &sim->spacecraft[0]; |
INFO("Error: " << de); |
||||||
|
REQUIRE_THAT(de, WithinAbs(0.0, E_TOL)); |
||||||
double expected_e = craft->orbit.eccentricity; |
} |
||||||
|
|
||||||
Vec3 position; |
SECTION("elements round-trip: true anomaly") { |
||||||
Vec3 velocity; |
const double dnu = fabs(recovered.true_anomaly - orig.true_anomaly); |
||||||
orbital_elements_to_cartesian(craft->orbit, sim->bodies[0].mass, &position, &velocity); |
INFO("Original nu: " << orig.true_anomaly); |
||||||
|
INFO("Recovered nu: " << recovered.true_anomaly); |
||||||
OrbitalElements elements = cartesian_to_orbital_elements(position, velocity, sim->bodies[0].mass); |
INFO("Error: " << dnu); |
||||||
|
REQUIRE_THAT(dnu, WithinAbs(0.0, ANG_TOL)); |
||||||
double actual_e = elements.eccentricity; |
} |
||||||
|
|
||||||
double e_error = fabs(actual_e - expected_e); |
SECTION("elements round-trip: inclination") { |
||||||
|
const double dinc = fabs(recovered.inclination - orig.inclination); |
||||||
INFO("Expected eccentricity: " << expected_e); |
INFO("Original inc: " << orig.inclination); |
||||||
INFO("Actual eccentricity: " << actual_e); |
INFO("Recovered inc: " << recovered.inclination); |
||||||
INFO("Absolute error: " << e_error); |
INFO("Error: " << dinc); |
||||||
|
REQUIRE_THAT(dinc, WithinAbs(0.0, ANG_TOL)); |
||||||
REQUIRE(e_error < ELEMENT_TOLERANCE); |
} |
||||||
|
|
||||||
|
SECTION("radius preservation") { |
||||||
|
INFO("Original r: " << expected_r); |
||||||
|
INFO("Recovered r: " << recovered_r); |
||||||
|
REQUIRE_THAT(recovered_r, WithinAbs(expected_r, R_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("velocity magnitude preservation") { |
||||||
|
INFO("Original v: " << expected_v); |
||||||
|
INFO("Recovered v: " << recovered_v); |
||||||
|
REQUIRE_THAT(recovered_v, WithinAbs(expected_v, V_TOL)); |
||||||
|
} |
||||||
|
|
||||||
destroy_simulation(sim); |
destroy_simulation(sim); |
||||||
} |
} |
||||||
|
|||||||
@ -1,34 +1,60 @@ |
|||||||
#include <catch2/catch_test_macros.hpp> |
#include <catch2/catch_test_macros.hpp> |
||||||
|
#include <catch2/matchers/catch_matchers_floating_point.hpp> |
||||||
#include "../src/physics.h" |
#include "../src/physics.h" |
||||||
#include "../src/simulation.h" |
#include "../src/simulation.h" |
||||||
#include "../src/config_loader.h" |
#include "../src/config_loader.h" |
||||||
#include "../src/test_utilities.h" |
#include "../src/test_utilities.h" |
||||||
#include <cmath> |
#include <cmath> |
||||||
|
|
||||||
TEST_CASE("Energy conservation - Earth circular orbit", "[energy][rk4]") { |
using Catch::Matchers::WithinAbs; |
||||||
|
|
||||||
|
SCENARIO("Energy conservation in circular orbit", "[energy][sanity]") { |
||||||
const double TIME_STEP = 60.0; |
const double TIME_STEP = 60.0; |
||||||
const double DAYS_TO_SIMULATE = 10.0; |
const double DAYS_TO_SIMULATE = 10.0; |
||||||
const double SECONDS_PER_DAY = 86400.0; |
const double SECONDS_PER_DAY = 86400.0; |
||||||
|
|
||||||
SimulationState* sim = create_simulation(10, 0, 0, TIME_STEP); |
SimulationState* sim = create_simulation(2, 0, 0, TIME_STEP); |
||||||
|
|
||||||
REQUIRE(load_system_config(sim, "tests/test_energy.toml")); |
REQUIRE(load_system_config(sim, "tests/test_energy.toml")); |
||||||
|
|
||||||
double initial_energy = calculate_system_total_energy(sim); |
const double initial_energy = calculate_system_total_energy(sim); |
||||||
|
|
||||||
double total_time = DAYS_TO_SIMULATE * SECONDS_PER_DAY; |
double total_time = DAYS_TO_SIMULATE * SECONDS_PER_DAY; |
||||||
while (sim->time < total_time) { |
while (sim->time < total_time) { |
||||||
update_simulation(sim); |
update_simulation(sim); |
||||||
} |
} |
||||||
|
|
||||||
double final_energy = calculate_system_total_energy(sim); |
const double final_energy = calculate_system_total_energy(sim); |
||||||
double energy_drift_percent = fabs((final_energy - initial_energy) / initial_energy) * 100.0; |
const double energy_drift = fabs(final_energy - initial_energy) / fabs(initial_energy); |
||||||
|
|
||||||
INFO("Initial energy: " << initial_energy << " J"); |
INFO("Initial energy: " << initial_energy << " J"); |
||||||
INFO("Final energy: " << final_energy << " J"); |
INFO("Final energy: " << final_energy << " J"); |
||||||
INFO("Energy drift: " << energy_drift_percent << "%"); |
INFO("Relative drift: " << energy_drift << " (fraction)"); |
||||||
|
|
||||||
|
REQUIRE_THAT(energy_drift, WithinAbs(0.0, 1e-12)); |
||||||
|
|
||||||
|
destroy_simulation(sim); |
||||||
|
} |
||||||
|
|
||||||
|
SCENARIO("Orbit direction for zero inclination", "[direction][sanity]") { |
||||||
|
const double TIME_STEP = 60.0; |
||||||
|
const int STEPS = 1440; // 1 day at 60s steps
|
||||||
|
|
||||||
|
SimulationState* sim = create_simulation(2, 0, 0, TIME_STEP); |
||||||
|
REQUIRE(load_system_config(sim, "tests/test_energy.toml")); |
||||||
|
|
||||||
|
CelestialBody* sun = &sim->bodies[0]; |
||||||
|
CelestialBody* earth = &sim->bodies[1]; |
||||||
|
|
||||||
|
Vec3 initial_rel = vec3_sub(earth->global_position, sun->global_position); |
||||||
|
const double theta_start = atan2(initial_rel.y, initial_rel.x); |
||||||
|
|
||||||
|
for (int i = 0; i < STEPS; i++) update_simulation(sim); |
||||||
|
|
||||||
|
Vec3 final_rel = vec3_sub(earth->global_position, sun->global_position); |
||||||
|
const double delta = atan2(final_rel.y, final_rel.x) - theta_start; |
||||||
|
|
||||||
REQUIRE(energy_drift_percent < 5.0); |
INFO("Delta: " << delta << " rad"); |
||||||
|
REQUIRE_THAT(delta, WithinAbs(0.0172042841, 1e-6)); |
||||||
|
|
||||||
destroy_simulation(sim); |
destroy_simulation(sim); |
||||||
} |
} |
||||||
|
|||||||
@ -1,88 +1,38 @@ |
|||||||
# Test Configuration: Extreme Orientation Mixed Cases |
# Test Configuration: Extreme Orientation Mixed Cases |
||||||
# Tests combined high inclination + high eccentricity orbital mechanics |
|
||||||
# Tests singularity handling at Ω=0 and ω=0 |
|
||||||
|
|
||||||
[[bodies]] |
[[bodies]] |
||||||
name = "Earth" |
name = "Earth" |
||||||
mass = 5.972e24 |
mass = 5.972e24 |
||||||
radius = 6.371e6 |
radius = 6.371e6 |
||||||
parent_index = -1 |
parent_index = -1 |
||||||
color = { r = 0.0, g = 0.5, b = 1.0 } |
color = { r = 0.0, g = 0.5, b = 1.0 } |
||||||
orbit = { |
orbit = { semi_major_axis = 0.0, eccentricity = 0.0, true_anomaly = 0.0 } |
||||||
semi_major_axis = 0.0, |
|
||||||
eccentricity = 0.0, |
|
||||||
true_anomaly = 0.0 |
|
||||||
} |
|
||||||
|
|
||||||
# Test body 1: High inclination + high eccentricity |
|
||||||
# i = 1.2 rad (68.8°), e = 0.85 |
|
||||||
[[spacecraft]] |
[[spacecraft]] |
||||||
name = "High_Inc_High_Ecc" |
name = "High_Inc_High_Ecc" |
||||||
mass = 1000.0 |
mass = 1000.0 |
||||||
parent_index = 0 |
parent_index = 0 |
||||||
orbit = { |
orbit = { semi_major_axis = 5.0e7, eccentricity = 0.85, true_anomaly = 0.0, inclination = 1.2, longitude_of_ascending_node = 0.5, argument_of_periapsis = 0.3 } |
||||||
semi_major_axis = 5.0e7, |
|
||||||
eccentricity = 0.85, |
|
||||||
true_anomaly = 0.0, |
|
||||||
inclination = 1.2, |
|
||||||
longitude_of_ascending_node = 0.5, |
|
||||||
argument_of_periapsis = 0.3 |
|
||||||
} |
|
||||||
|
|
||||||
# Test body 2: Very high inclination near polar + moderate eccentricity |
|
||||||
# i = 1.4 rad (80°), e = 0.5 |
|
||||||
[[spacecraft]] |
[[spacecraft]] |
||||||
name = "Polar_Moderate_Ecc" |
name = "Polar_Moderate_Ecc" |
||||||
mass = 1000.0 |
mass = 1000.0 |
||||||
parent_index = 0 |
parent_index = 0 |
||||||
orbit = { |
orbit = { semi_major_axis = 2.0e7, eccentricity = 0.5, true_anomaly = 0.0, inclination = 1.4, longitude_of_ascending_node = 1.0, argument_of_periapsis = 0.5 } |
||||||
semi_major_axis = 2.0e7, |
|
||||||
eccentricity = 0.5, |
|
||||||
true_anomaly = 0.0, |
|
||||||
inclination = 1.4, |
|
||||||
longitude_of_ascending_node = 1.0, |
|
||||||
argument_of_periapsis = 0.5 |
|
||||||
} |
|
||||||
|
|
||||||
# Test body 3: High eccentricity near parabolic with moderate inclination |
|
||||||
# e = 0.99, i = 0.5 rad (28.6°) |
|
||||||
[[spacecraft]] |
[[spacecraft]] |
||||||
name = "Near_Parabolic_Mod_Inc" |
name = "Near_Parabolic_Mod_Inc" |
||||||
mass = 1000.0 |
mass = 1000.0 |
||||||
parent_index = 0 |
parent_index = 0 |
||||||
orbit = { |
orbit = { semi_major_axis = 7.0e8, eccentricity = 0.99, true_anomaly = 0.0, inclination = 0.5, longitude_of_ascending_node = 0.8, argument_of_periapsis = 1.2 } |
||||||
semi_major_axis = 7.0e8, |
|
||||||
eccentricity = 0.99, |
|
||||||
true_anomaly = 0.0, |
|
||||||
inclination = 0.5, |
|
||||||
longitude_of_ascending_node = 0.8, |
|
||||||
argument_of_periapsis = 1.2 |
|
||||||
} |
|
||||||
|
|
||||||
# Test body 4: Edge case near Ω singularity (Ω = 0) with high inclination/eccentricity |
|
||||||
[[spacecraft]] |
[[spacecraft]] |
||||||
name = "Omega_Zero" |
name = "Omega_Zero" |
||||||
mass = 1000.0 |
mass = 1000.0 |
||||||
parent_index = 0 |
parent_index = 0 |
||||||
orbit = { |
orbit = { semi_major_axis = 4.0e7, eccentricity = 0.8, true_anomaly = 0.0, inclination = 1.2, longitude_of_ascending_node = 0.0, argument_of_periapsis = 0.6 } |
||||||
semi_major_axis = 4.0e7, |
|
||||||
eccentricity = 0.8, |
|
||||||
true_anomaly = 0.0, |
|
||||||
inclination = 1.2, |
|
||||||
longitude_of_ascending_node = 0.0, |
|
||||||
argument_of_periapsis = 0.6 |
|
||||||
} |
|
||||||
|
|
||||||
# Test body 5: Edge case near ω singularity (ω = 0) with high inclination/eccentricity |
|
||||||
[[spacecraft]] |
[[spacecraft]] |
||||||
name = "Arg_Peri_Zero" |
name = "Arg_Peri_Zero" |
||||||
mass = 1000.0 |
mass = 1000.0 |
||||||
parent_index = 0 |
parent_index = 0 |
||||||
orbit = { |
orbit = { semi_major_axis = 4.0e7, eccentricity = 0.8, true_anomaly = 0.0, inclination = 1.2, longitude_of_ascending_node = 0.7, argument_of_periapsis = 0.0 } |
||||||
semi_major_axis = 4.0e7, |
|
||||||
eccentricity = 0.8, |
|
||||||
true_anomaly = 0.0, |
|
||||||
inclination = 1.2, |
|
||||||
longitude_of_ascending_node = 0.7, |
|
||||||
argument_of_periapsis = 0.0 |
|
||||||
} |
|
||||||
|
|||||||
@ -1,244 +0,0 @@ |
|||||||
#include <catch2/catch_test_macros.hpp> |
|
||||||
#include "../src/physics.h" |
|
||||||
#include "../src/test_utilities.h" |
|
||||||
#include <cmath> |
|
||||||
|
|
||||||
TEST_CASE("Vector math utilities", "[utilities]") { |
|
||||||
Vec3 a = {1.0, 2.0, 3.0}; |
|
||||||
Vec3 b = {4.0, 5.0, 6.0}; |
|
||||||
|
|
||||||
SECTION("Vector addition") { |
|
||||||
Vec3 sum = vec3_add(a, b); |
|
||||||
REQUIRE(compare_double(sum.x, 5.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(sum.y, 7.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(sum.z, 9.0, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Vector subtraction") { |
|
||||||
Vec3 diff = vec3_sub(b, a); |
|
||||||
REQUIRE(compare_double(diff.x, 3.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(diff.y, 3.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(diff.z, 3.0, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Vector scaling") { |
|
||||||
Vec3 scaled = vec3_scale(a, 2.0); |
|
||||||
REQUIRE(compare_double(scaled.x, 2.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(scaled.y, 4.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(scaled.z, 6.0, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Vector magnitude") { |
|
||||||
double mag = vec3_magnitude(a); |
|
||||||
REQUIRE(compare_double(mag, sqrt(14.0), 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Vector distance") { |
|
||||||
double dist = vec3_distance(a, b); |
|
||||||
REQUIRE(compare_double(dist, sqrt(27.0), 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Vector normalization") { |
|
||||||
Vec3 unit = vec3_normalize(a); |
|
||||||
double expected_mag = 1.0; |
|
||||||
double actual_mag = vec3_magnitude(unit); |
|
||||||
REQUIRE(compare_double(actual_mag, expected_mag, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Vector dot product") { |
|
||||||
double dot = vec3_dot(a, b); |
|
||||||
REQUIRE(compare_double(dot, 32.0, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Vector cross product") { |
|
||||||
Vec3 cross = vec3_cross(a, b); |
|
||||||
REQUIRE(compare_double(cross.x, -3.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(cross.y, 6.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(cross.z, -3.0, 1e-10)); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Acceleration calculation", "[physics][acceleration]") { |
|
||||||
Vec3 force = {10.0, 20.0, 30.0}; |
|
||||||
double mass = 5.0; |
|
||||||
|
|
||||||
Vec3 accel = calculate_acceleration(force, mass); |
|
||||||
|
|
||||||
REQUIRE(compare_double(accel.x, 2.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(accel.y, 4.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(accel.z, 6.0, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Gravitational acceleration evaluation", "[physics][gravity]") { |
|
||||||
Vec3 position = {1.0, 0.0, 0.0}; |
|
||||||
double body_mass = 1000.0; |
|
||||||
double parent_mass = 1e10; |
|
||||||
|
|
||||||
Vec3 accel = evaluate_acceleration(position, body_mass, parent_mass); |
|
||||||
|
|
||||||
double expected_magnitude = G * parent_mass / (1.0 * 1.0); |
|
||||||
REQUIRE(compare_double(vec3_magnitude(accel), expected_magnitude, 1e-10)); |
|
||||||
REQUIRE(compare_double(accel.x, -expected_magnitude, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("RK4 integration step", "[physics][rk4]") { |
|
||||||
Vec3 position = {0.0, 1.0, 0.0}; |
|
||||||
Vec3 velocity = {sqrt(G * 1e10 / 1.0), 0.0, 0.0}; |
|
||||||
double dt = 1.0; |
|
||||||
double body_mass = 1.0; |
|
||||||
double parent_mass = 1e10; |
|
||||||
|
|
||||||
double initial_distance = vec3_magnitude(position); |
|
||||||
|
|
||||||
rk4_step(&position, &velocity, dt, body_mass, parent_mass); |
|
||||||
|
|
||||||
double final_distance = vec3_magnitude(position); |
|
||||||
|
|
||||||
REQUIRE(final_distance > 0.9 * initial_distance); |
|
||||||
REQUIRE(final_distance < 1.1 * initial_distance); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Matrix identity", "[matrix][identity]") { |
|
||||||
Mat3 I = mat3_identity(); |
|
||||||
|
|
||||||
REQUIRE(compare_double(I.m00, 1.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(I.m01, 0.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(I.m02, 0.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(I.m10, 0.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(I.m11, 1.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(I.m12, 0.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(I.m20, 0.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(I.m21, 0.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(I.m22, 1.0, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Matrix-vector multiplication", "[matrix][vector]") { |
|
||||||
Mat3 m = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0}; |
|
||||||
Vec3 v = {1.0, 2.0, 3.0}; |
|
||||||
|
|
||||||
Vec3 result = mat3_multiply_vec3(m, v); |
|
||||||
|
|
||||||
REQUIRE(compare_double(result.x, 14.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.y, 32.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.z, 50.0, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Matrix multiplication with identity", "[matrix][multiply]") { |
|
||||||
Mat3 A = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0}; |
|
||||||
Mat3 I = mat3_identity(); |
|
||||||
|
|
||||||
Mat3 result = mat3_multiply(A, I); |
|
||||||
|
|
||||||
REQUIRE(compare_double(result.m00, A.m00, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.m01, A.m01, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.m02, A.m02, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.m10, A.m10, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.m11, A.m11, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.m12, A.m12, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.m20, A.m20, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.m21, A.m21, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.m22, A.m22, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Rotation about Z axis", "[matrix][rotation]") { |
|
||||||
double angle = M_PI / 2; // 90 degrees
|
|
||||||
Mat3 Rz = mat3_rotation_z(angle); |
|
||||||
Vec3 v = {1.0, 0.0, 0.0}; |
|
||||||
|
|
||||||
Vec3 result = mat3_multiply_vec3(Rz, v); |
|
||||||
|
|
||||||
REQUIRE(compare_double(result.x, 0.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.y, 1.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.z, 0.0, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Rotation about X axis", "[matrix][rotation]") { |
|
||||||
double angle = M_PI / 2; // 90 degrees
|
|
||||||
Mat3 Rx = mat3_rotation_x(angle); |
|
||||||
Vec3 v = {0.0, 1.0, 0.0}; |
|
||||||
|
|
||||||
Vec3 result = mat3_multiply_vec3(Rx, v); |
|
||||||
|
|
||||||
REQUIRE(compare_double(result.x, 0.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.y, 0.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.z, 1.0, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Rotation edge cases", "[matrix][rotation][edge]") { |
|
||||||
SECTION("180 degree rotation") { |
|
||||||
Mat3 Rz180 = mat3_rotation_z(M_PI); |
|
||||||
Vec3 v = {1.0, 0.0, 0.0}; |
|
||||||
Vec3 result = mat3_multiply_vec3(Rz180, v); |
|
||||||
|
|
||||||
REQUIRE(compare_double(result.x, -1.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.y, 0.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.z, 0.0, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("360 degree rotation equals identity") { |
|
||||||
Mat3 Rz360 = mat3_rotation_z(2.0 * M_PI); |
|
||||||
Mat3 I = mat3_identity(); |
|
||||||
|
|
||||||
REQUIRE(compare_double(Rz360.m00, I.m00, 1e-10)); |
|
||||||
REQUIRE(compare_double(Rz360.m11, I.m11, 1e-10)); |
|
||||||
REQUIRE(compare_double(Rz360.m22, I.m22, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Negative angle equals positive rotation") { |
|
||||||
Mat3 Rz_neg90 = mat3_rotation_z(-M_PI / 2); |
|
||||||
Mat3 Rz_270 = mat3_rotation_z(3.0 * M_PI / 2); |
|
||||||
|
|
||||||
REQUIRE(compare_double(Rz_neg90.m00, Rz_270.m00, 1e-10)); |
|
||||||
REQUIRE(compare_double(Rz_neg90.m01, Rz_270.m01, 1e-10)); |
|
||||||
REQUIRE(compare_double(Rz_neg90.m10, Rz_270.m10, 1e-10)); |
|
||||||
REQUIRE(compare_double(Rz_neg90.m11, Rz_270.m11, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
SECTION("Combined rotations that cancel") { |
|
||||||
Mat3 Rz90 = mat3_rotation_z(M_PI / 2); |
|
||||||
Mat3 Rz_neg90 = mat3_rotation_z(-M_PI / 2); |
|
||||||
Mat3 combined = mat3_multiply(Rz_neg90, Rz90); |
|
||||||
Mat3 I = mat3_identity(); |
|
||||||
|
|
||||||
REQUIRE(compare_double(combined.m00, I.m00, 1e-10)); |
|
||||||
REQUIRE(compare_double(combined.m11, I.m11, 1e-10)); |
|
||||||
REQUIRE(compare_double(combined.m22, I.m22, 1e-10)); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Rotation matrix orthogonality", "[matrix][rotation][validation]") { |
|
||||||
double angle = M_PI / 4; // 45 degrees
|
|
||||||
Mat3 Rz = mat3_rotation_z(angle); |
|
||||||
|
|
||||||
Mat3 Rz_T = {Rz.m00, Rz.m10, Rz.m20, |
|
||||||
Rz.m01, Rz.m11, Rz.m21, |
|
||||||
Rz.m02, Rz.m12, Rz.m22}; |
|
||||||
|
|
||||||
Mat3 product = mat3_multiply(Rz, Rz_T); |
|
||||||
Mat3 I = mat3_identity(); |
|
||||||
|
|
||||||
REQUIRE(compare_double(product.m00, I.m00, 1e-10)); |
|
||||||
REQUIRE(compare_double(product.m01, I.m01, 1e-10)); |
|
||||||
REQUIRE(compare_double(product.m02, I.m02, 1e-10)); |
|
||||||
REQUIRE(compare_double(product.m10, I.m10, 1e-10)); |
|
||||||
REQUIRE(compare_double(product.m11, I.m11, 1e-10)); |
|
||||||
REQUIRE(compare_double(product.m12, I.m12, 1e-10)); |
|
||||||
REQUIRE(compare_double(product.m20, I.m20, 1e-10)); |
|
||||||
REQUIRE(compare_double(product.m21, I.m21, 1e-10)); |
|
||||||
REQUIRE(compare_double(product.m22, I.m22, 1e-10)); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Orbital rotation matrix", "[matrix][orbital]") { |
|
||||||
double omega = 0.0; |
|
||||||
double i = M_PI / 2; // 90 degrees inclination
|
|
||||||
double Omega = 0.0; |
|
||||||
|
|
||||||
Mat3 R = mat3_rotation_orbital(omega, i, Omega); |
|
||||||
Vec3 v = {1.0, 0.0, 0.0}; |
|
||||||
|
|
||||||
Vec3 result = mat3_multiply_vec3(R, v); |
|
||||||
|
|
||||||
REQUIRE(compare_double(result.x, 1.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.y, 0.0, 1e-10)); |
|
||||||
REQUIRE(compare_double(result.z, 0.0, 1e-10)); |
|
||||||
} |
|
||||||
@ -1,266 +1,273 @@ |
|||||||
#include <catch2/catch_test_macros.hpp> |
#include <catch2/catch_test_macros.hpp> |
||||||
|
#include <catch2/matchers/catch_matchers_floating_point.hpp> |
||||||
#include "../src/physics.h" |
#include "../src/physics.h" |
||||||
#include "../src/simulation.h" |
#include "../src/simulation.h" |
||||||
#include "../src/config_loader.h" |
#include "../src/config_loader.h" |
||||||
#include "../src/test_utilities.h" |
#include "../src/test_utilities.h" |
||||||
#include <cmath> |
#include <cmath> |
||||||
#include <vector> |
#include <vector> |
||||||
|
#include <string> |
||||||
|
|
||||||
TEST_CASE("Moon orbital stability around Earth", "[moon][earth]") { |
using Catch::Matchers::WithinAbs; |
||||||
const double TIME_STEP = 60.0; |
|
||||||
const double EXPECTED_PERIOD_DAYS = 27.3; |
|
||||||
const double SECONDS_PER_DAY = 86400.0; |
|
||||||
const double MAX_SIMULATION_DAYS = 35.0; |
|
||||||
const double MOON_DISTANCE_FROM_EARTH = 384400000.0; |
|
||||||
|
|
||||||
SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP); |
|
||||||
|
|
||||||
REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml")); |
|
||||||
|
|
||||||
const int EARTH_INDEX = 2; |
|
||||||
const int MOON_INDEX = 8; |
|
||||||
|
|
||||||
double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY; |
|
||||||
OrbitTracker* tracker = create_orbit_tracker_with_min_time(MOON_INDEX, 5.0); |
|
||||||
|
|
||||||
int initial_parent = sim->bodies[MOON_INDEX].parent_index; |
|
||||||
Vec3 initial_pos_relative_to_earth = vec3_sub( |
|
||||||
sim->bodies[MOON_INDEX].global_position, |
|
||||||
sim->bodies[EARTH_INDEX].global_position |
|
||||||
); |
|
||||||
double initial_distance = vec3_magnitude(initial_pos_relative_to_earth); |
|
||||||
|
|
||||||
INFO("Moon initial distance from Earth: " << initial_distance << " m"); |
|
||||||
INFO("Expected distance: " << MOON_DISTANCE_FROM_EARTH << " m"); |
|
||||||
|
|
||||||
while (sim->time < max_time && !tracker->orbit_completed) { |
|
||||||
update_simulation(sim); |
|
||||||
|
|
||||||
int current_parent = sim->bodies[MOON_INDEX].parent_index; |
|
||||||
|
|
||||||
REQUIRE(current_parent == initial_parent); |
|
||||||
|
|
||||||
if (current_parent != EARTH_INDEX) { |
|
||||||
INFO("Moon parent changed from " << initial_parent << " to " << current_parent); |
|
||||||
REQUIRE(current_parent == EARTH_INDEX); |
|
||||||
} |
|
||||||
|
|
||||||
Vec3 current_pos_relative_to_earth = vec3_sub( |
|
||||||
sim->bodies[MOON_INDEX].global_position, |
|
||||||
sim->bodies[EARTH_INDEX].global_position |
|
||||||
); |
|
||||||
double current_distance = vec3_magnitude(current_pos_relative_to_earth); |
|
||||||
|
|
||||||
double distance_drift = fabs(current_distance - initial_distance); |
|
||||||
double drift_percentage = (distance_drift / initial_distance) * 100.0; |
|
||||||
|
|
||||||
REQUIRE(drift_percentage < 20.0); |
|
||||||
|
|
||||||
update_orbit_tracker(tracker, &sim->bodies[MOON_INDEX], &sim->bodies[EARTH_INDEX], sim->time); |
|
||||||
} |
|
||||||
|
|
||||||
REQUIRE(tracker->orbit_completed); |
|
||||||
|
|
||||||
double measured_period_days = tracker->time_at_completion / SECONDS_PER_DAY; |
|
||||||
double period_error_days = fabs(measured_period_days - EXPECTED_PERIOD_DAYS); |
|
||||||
|
|
||||||
INFO("Expected Moon period: " << EXPECTED_PERIOD_DAYS << " days"); |
// === Helper: run sim loop, collect failures, assert once at end ===
|
||||||
INFO("Measured Moon period: " << measured_period_days << " days"); |
static std::string fmt_time(double t) { |
||||||
INFO("Period error: " << period_error_days << " days"); |
char buf[32]; |
||||||
|
snprintf(buf, sizeof(buf), "t=%.0fs", t); |
||||||
REQUIRE(period_error_days < 3.0); |
return std::string(buf); |
||||||
|
|
||||||
Vec3 final_pos_relative_to_earth = vec3_sub( |
|
||||||
sim->bodies[MOON_INDEX].global_position, |
|
||||||
sim->bodies[EARTH_INDEX].global_position |
|
||||||
); |
|
||||||
double final_distance = vec3_magnitude(final_pos_relative_to_earth); |
|
||||||
|
|
||||||
double final_drift_percentage = (fabs(final_distance - initial_distance) / initial_distance) * 100.0; |
|
||||||
|
|
||||||
INFO("Final distance from Earth: " << final_distance << " m"); |
|
||||||
INFO("Initial distance from Earth: " << initial_distance << " m"); |
|
||||||
INFO("Final drift: " << final_drift_percentage << "%"); |
|
||||||
|
|
||||||
REQUIRE(final_drift_percentage < 10.0); |
|
||||||
|
|
||||||
destroy_orbit_tracker(tracker); |
|
||||||
destroy_simulation(sim); |
|
||||||
} |
} |
||||||
|
|
||||||
TEST_CASE("Galilean moons orbital stability around Jupiter", "[moon][jupiter]") { |
static std::string fmt_drift(double d) { |
||||||
const double TIME_STEP = 60.0; |
char buf[32]; |
||||||
const double SECONDS_PER_DAY = 86400.0; |
snprintf(buf, sizeof(buf), "drift=%.4f", d); |
||||||
const double MAX_SIMULATION_DAYS = 20.0; |
return std::string(buf); |
||||||
|
} |
||||||
const double IO_PERIOD_DAYS = 1.77; |
|
||||||
const double EUROPA_PERIOD_DAYS = 3.55; |
|
||||||
const double GANYMEDE_PERIOD_DAYS = 7.15; |
|
||||||
const double CALLISTO_PERIOD_DAYS = 16.69; |
|
||||||
|
|
||||||
SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP); |
|
||||||
|
|
||||||
REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml")); |
|
||||||
|
|
||||||
const int JUPITER_INDEX = 4; |
|
||||||
const int IO_INDEX = 9; |
|
||||||
const int EUROPA_INDEX = 10; |
|
||||||
const int GANYMEDE_INDEX = 11; |
|
||||||
const int CALLISTO_INDEX = 12; |
|
||||||
|
|
||||||
OrbitTracker* io_tracker = create_orbit_tracker_with_min_time(IO_INDEX, 1.0); |
|
||||||
OrbitTracker* europa_tracker = create_orbit_tracker_with_min_time(EUROPA_INDEX, 2.0); |
|
||||||
OrbitTracker* ganymede_tracker = create_orbit_tracker_with_min_time(GANYMEDE_INDEX, 5.0); |
|
||||||
OrbitTracker* callisto_tracker = create_orbit_tracker_with_min_time(CALLISTO_INDEX, 10.0); |
|
||||||
|
|
||||||
double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY; |
|
||||||
|
|
||||||
while (sim->time < max_time) { |
|
||||||
update_simulation(sim); |
|
||||||
|
|
||||||
REQUIRE(sim->bodies[IO_INDEX].parent_index == JUPITER_INDEX); |
// === Per-moon period tolerances (0.5% of analytical period, from precalc) ===
|
||||||
REQUIRE(sim->bodies[EUROPA_INDEX].parent_index == JUPITER_INDEX); |
static const double MOON_PERIOD_TOL = 11861.4; // Moon
|
||||||
REQUIRE(sim->bodies[GANYMEDE_INDEX].parent_index == JUPITER_INDEX); |
static const double IO_PERIOD_TOL = 764.6; // Io
|
||||||
REQUIRE(sim->bodies[CALLISTO_INDEX].parent_index == JUPITER_INDEX); |
static const double EUROPA_PERIOD_TOL = 1534.5; // Europa
|
||||||
|
static const double GANYMEDE_PERIOD_TOL = 3091.1; // Ganymede
|
||||||
|
static const double CALLISTO_PERIOD_TOL = 7210.6; // Callisto
|
||||||
|
static const double TITAN_PERIOD_TOL = 6889.9; // Titan
|
||||||
|
|
||||||
update_orbit_tracker(io_tracker, &sim->bodies[IO_INDEX], &sim->bodies[JUPITER_INDEX], sim->time); |
|
||||||
update_orbit_tracker(europa_tracker, &sim->bodies[EUROPA_INDEX], &sim->bodies[JUPITER_INDEX], sim->time); |
|
||||||
update_orbit_tracker(ganymede_tracker, &sim->bodies[GANYMEDE_INDEX], &sim->bodies[JUPITER_INDEX], sim->time); |
|
||||||
update_orbit_tracker(callisto_tracker, &sim->bodies[CALLISTO_INDEX], &sim->bodies[JUPITER_INDEX], sim->time); |
|
||||||
} |
|
||||||
|
|
||||||
REQUIRE(io_tracker->orbit_completed); |
// === Drift tolerance for distance checks (10% bound) ===
|
||||||
REQUIRE(europa_tracker->orbit_completed); |
static const double DRIFT_REL_TOL = 0.1; |
||||||
REQUIRE(ganymede_tracker->orbit_completed); |
|
||||||
REQUIRE(callisto_tracker->orbit_completed); |
|
||||||
|
|
||||||
double io_period_days = io_tracker->time_at_completion / SECONDS_PER_DAY; |
|
||||||
double europa_period_days = europa_tracker->time_at_completion / SECONDS_PER_DAY; |
|
||||||
double ganymede_period_days = ganymede_tracker->time_at_completion / SECONDS_PER_DAY; |
|
||||||
double callisto_period_days = callisto_tracker->time_at_completion / SECONDS_PER_DAY; |
|
||||||
|
|
||||||
INFO("Io period: " << io_period_days << " days (expected: " << IO_PERIOD_DAYS << ")"); |
|
||||||
INFO("Europa period: " << europa_period_days << " days (expected: " << EUROPA_PERIOD_DAYS << ")"); |
|
||||||
INFO("Ganymede period: " << ganymede_period_days << " days (expected: " << GANYMEDE_PERIOD_DAYS << ")"); |
|
||||||
INFO("Callisto period: " << callisto_period_days << " days (expected: " << CALLISTO_PERIOD_DAYS << ")"); |
|
||||||
|
|
||||||
REQUIRE(fabs(io_period_days - IO_PERIOD_DAYS) < 0.5); |
|
||||||
REQUIRE(fabs(europa_period_days - EUROPA_PERIOD_DAYS) < 1.0); |
|
||||||
REQUIRE(fabs(ganymede_period_days - GANYMEDE_PERIOD_DAYS) < 2.0); |
|
||||||
REQUIRE(fabs(callisto_period_days - CALLISTO_PERIOD_DAYS) < 4.0); |
|
||||||
|
|
||||||
destroy_orbit_tracker(io_tracker); |
|
||||||
destroy_orbit_tracker(europa_tracker); |
|
||||||
destroy_orbit_tracker(ganymede_tracker); |
|
||||||
destroy_orbit_tracker(callisto_tracker); |
|
||||||
destroy_simulation(sim); |
|
||||||
} |
|
||||||
|
|
||||||
TEST_CASE("Titan orbital stability around Saturn", "[moon][saturn]") { |
SCENARIO("Multi-body moon orbital stability and period measurements", |
||||||
|
"[moon][earth][jupiter][saturn][galilean][titan]" |
||||||
|
"[stability][period][integration][inclined][geometry]") { |
||||||
|
// === Fixture ===
|
||||||
const double TIME_STEP = 60.0; |
const double TIME_STEP = 60.0; |
||||||
const double EXPECTED_PERIOD_DAYS = 15.95; |
|
||||||
const double SECONDS_PER_DAY = 86400.0; |
const double SECONDS_PER_DAY = 86400.0; |
||||||
const double MAX_SIMULATION_DAYS = 25.0; |
|
||||||
|
|
||||||
SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP); |
SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP); |
||||||
|
|
||||||
REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml")); |
REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml")); |
||||||
|
|
||||||
const int SATURN_INDEX = 5; |
CelestialBody* earth = &sim->bodies[2]; |
||||||
const int TITAN_INDEX = 13; |
CelestialBody* moon = &sim->bodies[8]; |
||||||
|
|
||||||
OrbitTracker* tracker = create_orbit_tracker_with_min_time(TITAN_INDEX, 10.0); |
struct MoonData { |
||||||
|
int index; |
||||||
Vec3 initial_pos_relative_to_saturn = vec3_sub( |
const char* name; |
||||||
sim->bodies[TITAN_INDEX].global_position, |
double expected_seconds; |
||||||
sim->bodies[SATURN_INDEX].global_position |
double max_days; |
||||||
); |
double min_time_days; |
||||||
double initial_distance = vec3_magnitude(initial_pos_relative_to_saturn); |
double period_tol; |
||||||
|
int parent_index; |
||||||
|
}; |
||||||
|
|
||||||
double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY; |
const MoonData galilean[] = { |
||||||
|
{9, "Io", 152928.62, 5.0, 1.0, IO_PERIOD_TOL, 4}, |
||||||
|
{10, "Europa", 306909.10, 10.0, 2.0, EUROPA_PERIOD_TOL, 4}, |
||||||
|
{11, "Ganymede", 618227.12, 15.0, 5.0, GANYMEDE_PERIOD_TOL, 4}, |
||||||
|
{12, "Callisto", 1442117.30, 25.0, 10.0, CALLISTO_PERIOD_TOL, 4}, |
||||||
|
}; |
||||||
|
|
||||||
while (sim->time < max_time && !tracker->orbit_completed) { |
const MoonData all_moons[] = { |
||||||
update_simulation(sim); |
{8, "Moon", 2372274.33, 35.0, 5.0, MOON_PERIOD_TOL, 2}, |
||||||
|
{9, "Io", 152928.62, 5.0, 1.0, IO_PERIOD_TOL, 4}, |
||||||
|
{10, "Europa", 306909.10, 10.0, 2.0, EUROPA_PERIOD_TOL, 4}, |
||||||
|
{11, "Ganymede", 618227.12, 15.0, 5.0, GANYMEDE_PERIOD_TOL, 4}, |
||||||
|
{12, "Callisto", 1442117.30, 25.0, 10.0, CALLISTO_PERIOD_TOL, 4}, |
||||||
|
{13, "Titan", 1377976.07, 25.0, 10.0, TITAN_PERIOD_TOL, 5}, |
||||||
|
}; |
||||||
|
|
||||||
REQUIRE(sim->bodies[TITAN_INDEX].parent_index == SATURN_INDEX); |
// === Helper lambdas ===
|
||||||
|
// Run sim loop without assertions; collect failures; assert once at end.
|
||||||
|
// This tests every timestep but produces only 1 assertion per call.
|
||||||
|
auto check_parent_stability = [&](CelestialBody* body, int expected_parent, |
||||||
|
double max_days) { |
||||||
|
double max_time = max_days * SECONDS_PER_DAY; |
||||||
|
std::vector<std::string> failures; |
||||||
|
while (sim->time < max_time) { |
||||||
|
update_simulation(sim); |
||||||
|
if (body->parent_index != expected_parent) { |
||||||
|
failures.push_back(fmt_time(sim->time) + ": " + |
||||||
|
std::string(body->name) + |
||||||
|
" parent=" + std::to_string(body->parent_index) + |
||||||
|
" (expected " + std::to_string(expected_parent) + ")"); |
||||||
|
} |
||||||
|
} |
||||||
|
REQUIRE(failures.empty()); |
||||||
|
}; |
||||||
|
|
||||||
Vec3 current_pos_relative_to_saturn = vec3_sub( |
auto measure_period = [&](int body_index, int parent_index, |
||||||
sim->bodies[TITAN_INDEX].global_position, |
double max_days, double min_time_days) { |
||||||
sim->bodies[SATURN_INDEX].global_position |
sim->time = 0.0; |
||||||
); |
OrbitTracker* tracker = create_orbit_tracker_with_min_time( |
||||||
double current_distance = vec3_magnitude(current_pos_relative_to_saturn); |
body_index, min_time_days * SECONDS_PER_DAY); |
||||||
|
double max_time = max_days * SECONDS_PER_DAY; |
||||||
|
|
||||||
|
while (sim->time < max_time && !tracker->orbit_completed) { |
||||||
|
update_simulation(sim); |
||||||
|
update_orbit_tracker(tracker, &sim->bodies[body_index], |
||||||
|
&sim->bodies[parent_index], sim->time); |
||||||
|
} |
||||||
|
|
||||||
double drift_percentage = (fabs(current_distance - initial_distance) / initial_distance) * 100.0; |
double period = tracker->orbit_completed |
||||||
REQUIRE(drift_percentage < 10.0); |
? tracker->time_at_completion : -1.0; |
||||||
|
destroy_orbit_tracker(tracker); |
||||||
|
return period; |
||||||
|
}; |
||||||
|
|
||||||
update_orbit_tracker(tracker, &sim->bodies[TITAN_INDEX], &sim->bodies[SATURN_INDEX], sim->time); |
auto check_distance_drift = [&](CelestialBody* body, int parent_index, |
||||||
} |
double max_days) { |
||||||
|
CelestialBody* parent = &sim->bodies[parent_index]; |
||||||
|
Vec3 initial_rel = vec3_sub(body->global_position, parent->global_position); |
||||||
|
double initial_r = vec3_magnitude(initial_rel); |
||||||
|
double max_time = max_days * SECONDS_PER_DAY; |
||||||
|
|
||||||
|
std::vector<std::string> failures; |
||||||
|
while (sim->time < max_time) { |
||||||
|
update_simulation(sim); |
||||||
|
Vec3 rel = vec3_sub(body->global_position, parent->global_position); |
||||||
|
double r = vec3_magnitude(rel); |
||||||
|
double drift = std::fabs(r - initial_r) / initial_r; |
||||||
|
if (drift > DRIFT_REL_TOL) { |
||||||
|
failures.push_back(fmt_time(sim->time) + ": " + |
||||||
|
std::string(body->name) + " " + |
||||||
|
fmt_drift(drift)); |
||||||
|
} |
||||||
|
} |
||||||
|
REQUIRE(failures.empty()); |
||||||
|
}; |
||||||
|
|
||||||
REQUIRE(tracker->orbit_completed); |
auto wait_for_all_orbits = [&](int count) { |
||||||
|
OrbitTracker* trackers[6] = {}; |
||||||
|
for (int i = 0; i < count; i++) { |
||||||
|
trackers[i] = create_orbit_tracker_with_min_time( |
||||||
|
all_moons[i].index, all_moons[i].min_time_days * SECONDS_PER_DAY); |
||||||
|
} |
||||||
|
|
||||||
double measured_period_days = tracker->time_at_completion / SECONDS_PER_DAY; |
double max_time = 60.0 * SECONDS_PER_DAY; |
||||||
double period_error_days = fabs(measured_period_days - EXPECTED_PERIOD_DAYS); |
int completed_count = 0; |
||||||
|
|
||||||
|
while (sim->time < max_time && completed_count < count) { |
||||||
|
update_simulation(sim); |
||||||
|
for (int i = 0; i < count; i++) { |
||||||
|
if (!trackers[i]->orbit_completed) { |
||||||
|
update_orbit_tracker(trackers[i], &sim->bodies[all_moons[i].index], |
||||||
|
&sim->bodies[all_moons[i].parent_index], sim->time); |
||||||
|
if (trackers[i]->orbit_completed) { |
||||||
|
completed_count++; |
||||||
|
double period = trackers[i]->time_at_completion / SECONDS_PER_DAY; |
||||||
|
INFO(all_moons[i].name << " completed orbit at " |
||||||
|
<< period << " days"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
INFO("Expected Titan period: " << EXPECTED_PERIOD_DAYS << " days"); |
for (int i = 0; i < count; i++) { |
||||||
INFO("Measured Titan period: " << measured_period_days << " days"); |
REQUIRE(trackers[i]->orbit_completed); |
||||||
INFO("Period error: " << period_error_days << " days"); |
destroy_orbit_tracker(trackers[i]); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
REQUIRE(period_error_days < 3.0); |
// === Sections ===
|
||||||
|
|
||||||
destroy_orbit_tracker(tracker); |
SECTION("Moon maintains Earth as parent throughout simulation") { |
||||||
destroy_simulation(sim); |
check_parent_stability(moon, 2, 35.0); |
||||||
} |
} |
||||||
|
|
||||||
TEST_CASE("Combined solar system with all moons - parent stability", "[moon][integration]") { |
SECTION("Moon distance from Earth stays within 10% of initial") { |
||||||
const double TIME_STEP = 60.0; |
check_distance_drift(moon, 2, 35.0); |
||||||
const double SECONDS_PER_DAY = 86400.0; |
} |
||||||
const double MAX_SIMULATION_DAYS = 60.0; |
|
||||||
|
|
||||||
SimulationState* sim = create_simulation(20, 0, 0, TIME_STEP); |
SECTION("Moon completes orbit in ~27.3 days") { |
||||||
|
double period = measure_period(8, 2, 35.0, 5.0); |
||||||
|
INFO("Measured Moon period: " << period / SECONDS_PER_DAY |
||||||
|
<< " days (expected: ~27.3)"); |
||||||
|
REQUIRE_THAT(period, WithinAbs(2372274.33, MOON_PERIOD_TOL)); |
||||||
|
} |
||||||
|
|
||||||
REQUIRE(load_system_config(sim, "tests/test_moon_orbits.toml")); |
SECTION("Galilean moons maintain Jupiter as parent") { |
||||||
|
for (const auto& m : galilean) { |
||||||
|
check_parent_stability(&sim->bodies[m.index], 4, 25.0); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
struct ParentChange { |
SECTION("Galilean moons complete orbits in expected periods") { |
||||||
double time_days; |
for (const auto& m : galilean) { |
||||||
int body_index; |
double period = measure_period(m.index, m.parent_index, |
||||||
int old_parent; |
m.max_days, m.min_time_days); |
||||||
int new_parent; |
INFO(m.name << " period: " << period / SECONDS_PER_DAY |
||||||
}; |
<< " days (expected: " << m.expected_seconds / SECONDS_PER_DAY |
||||||
|
<< ")"); |
||||||
|
REQUIRE_THAT(period, WithinAbs(m.expected_seconds, m.period_tol)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
std::vector<ParentChange> parent_changes; |
SECTION("Galilean moons stay within 10% of initial distance") { |
||||||
|
for (const auto& m : galilean) { |
||||||
|
check_distance_drift(&sim->bodies[m.index], m.parent_index, 25.0); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
int initial_parents[14]; |
SECTION("Titan maintains Saturn as parent") { |
||||||
for (int i = 0; i < 14; i++) { |
check_parent_stability(&sim->bodies[13], 5, 25.0); |
||||||
initial_parents[i] = sim->bodies[i].parent_index; |
|
||||||
} |
} |
||||||
|
|
||||||
double max_time = MAX_SIMULATION_DAYS * SECONDS_PER_DAY; |
SECTION("Titan completes orbit in ~15.95 days") { |
||||||
|
double period = measure_period(13, 5, 25.0, 10.0); |
||||||
|
INFO("Measured Titan period: " << period / SECONDS_PER_DAY |
||||||
|
<< " days (expected: ~15.95)"); |
||||||
|
REQUIRE_THAT(period, WithinAbs(1377976.07, TITAN_PERIOD_TOL)); |
||||||
|
} |
||||||
|
|
||||||
while (sim->time < max_time) { |
SECTION("Titan distance from Saturn stays within 10%") { |
||||||
update_simulation(sim); |
check_distance_drift(&sim->bodies[13], 5, 25.0); |
||||||
|
} |
||||||
|
|
||||||
for (int i = 0; i < sim->body_count; i++) { |
SECTION("All moons maintain correct parents over 60-day simulation") { |
||||||
if (sim->bodies[i].parent_index != initial_parents[i]) { |
double max_time = 60.0 * SECONDS_PER_DAY; |
||||||
ParentChange change; |
std::vector<std::string> failures; |
||||||
change.time_days = sim->time / SECONDS_PER_DAY; |
while (sim->time < max_time) { |
||||||
change.body_index = i; |
update_simulation(sim); |
||||||
change.old_parent = initial_parents[i]; |
for (int i = 0; i < 6; i++) { |
||||||
change.new_parent = sim->bodies[i].parent_index; |
int idx = all_moons[i].index; |
||||||
parent_changes.push_back(change); |
int expected = all_moons[i].parent_index; |
||||||
initial_parents[i] = sim->bodies[i].parent_index; |
if (sim->bodies[idx].parent_index != expected) { |
||||||
|
failures.push_back(fmt_time(sim->time) + ": " + |
||||||
|
std::string(all_moons[i].name) + |
||||||
|
" parent=" + |
||||||
|
std::to_string(sim->bodies[idx].parent_index) + |
||||||
|
" (expected " + std::to_string(expected) + ")"); |
||||||
|
} |
||||||
} |
} |
||||||
} |
} |
||||||
|
REQUIRE(failures.empty()); |
||||||
} |
} |
||||||
|
|
||||||
INFO("Total parent changes detected: " << parent_changes.size()); |
SECTION("All moons complete at least one orbit") { |
||||||
for (const auto& change : parent_changes) { |
wait_for_all_orbits(6); |
||||||
INFO("Body " << sim->bodies[change.body_index].name |
|
||||||
<< " (index " << change.body_index << ") changed parent " |
|
||||||
<< "from " << change.old_parent << " to " << change.new_parent |
|
||||||
<< " at day " << change.time_days); |
|
||||||
} |
} |
||||||
|
|
||||||
REQUIRE(parent_changes.size() == 0); |
SECTION("Moon inclined orbit produces non-zero z-coordinate") { |
||||||
|
double max_time = 10.0 * SECONDS_PER_DAY; |
||||||
|
while (sim->time < max_time) { |
||||||
|
update_simulation(sim); |
||||||
|
} |
||||||
|
|
||||||
|
Vec3 rel = vec3_sub(moon->global_position, earth->global_position); |
||||||
|
double z = rel.z; |
||||||
|
INFO("Moon z-coordinate relative to Earth: " << z << " m"); |
||||||
|
INFO("Moon orbital inclination: " |
||||||
|
<< (moon->orbit.inclination * 180.0 / M_PI) << " degrees"); |
||||||
|
REQUIRE_THAT(z, !WithinAbs(0.0, 10000000.0)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("Moon orbital elements loaded correctly from config") { |
||||||
|
REQUIRE_THAT(moon->orbit.eccentricity, WithinAbs(0.055, E_TOL)); |
||||||
|
REQUIRE_THAT(moon->orbit.inclination, |
||||||
|
WithinAbs(5.16 * M_PI / 180.0, ANG_TOL)); |
||||||
|
REQUIRE_THAT(moon->orbit.longitude_of_ascending_node, |
||||||
|
WithinAbs(125.08 * M_PI / 180.0, ANG_TOL)); |
||||||
|
REQUIRE_THAT(moon->orbit.argument_of_periapsis, |
||||||
|
WithinAbs(318.15 * M_PI / 180.0, ANG_TOL)); |
||||||
|
} |
||||||
|
|
||||||
destroy_simulation(sim); |
destroy_simulation(sim); |
||||||
} |
} |
||||||
|
|||||||
@ -1,65 +1,60 @@ |
|||||||
#include <catch2/catch_test_macros.hpp> |
#include <catch2/catch_test_macros.hpp> |
||||||
#include <catch2/matchers/catch_matchers_floating_point.hpp> |
#include <catch2/matchers/catch_matchers_floating_point.hpp> |
||||||
#include "../src/physics.h" |
#include "../src/physics.h" |
||||||
#include "../src/simulation.h" |
|
||||||
#include "../src/orbital_objects.h" |
|
||||||
#include "../src/maneuver.h" |
|
||||||
#include "../src/config_loader.h" |
|
||||||
#include "../src/orbital_mechanics.h" |
#include "../src/orbital_mechanics.h" |
||||||
|
#include "../src/test_utilities.h" |
||||||
#include <cmath> |
#include <cmath> |
||||||
|
|
||||||
// Test: Check omega after prograde burn that flips eccentricity vector direction
|
using Catch::Matchers::WithinAbs; |
||||||
TEST_CASE("Omega calculation after prograde burn", "[omega][debug]") { |
|
||||||
double parent_mass = 5.972e24; // Earth mass
|
|
||||||
|
|
||||||
// Initial orbit: zero inclination, omega = 0
|
SCENARIO("Omega reconstruction after prograde burn at apoapsis", "[omega][debug]") { |
||||||
// Start at apoapsis where eccentricity vector points opposite to position
|
const double parent_mass = 5.972e24; |
||||||
OrbitalElements elements = {0}; |
const double mu = G * parent_mass; |
||||||
|
|
||||||
|
OrbitalElements elements = {}; |
||||||
elements.semi_major_axis = 1.0e7; |
elements.semi_major_axis = 1.0e7; |
||||||
elements.eccentricity = 0.3; |
elements.eccentricity = 0.3; |
||||||
elements.true_anomaly = M_PI; // Start at apoapsis
|
elements.true_anomaly = M_PI; |
||||||
elements.inclination = 1e-12; // Tiny inclination to trigger atan2 path
|
elements.inclination = 1e-12; |
||||||
elements.longitude_of_ascending_node = 0.0; |
elements.longitude_of_ascending_node = 0.0; |
||||||
elements.argument_of_periapsis = 0.0; |
elements.argument_of_periapsis = 0.0; |
||||||
|
|
||||||
// Get initial position and velocity
|
Vec3 pos = {}, vel = {}; |
||||||
Vec3 pos, vel; |
|
||||||
orbital_elements_to_cartesian(elements, parent_mass, &pos, &vel); |
orbital_elements_to_cartesian(elements, parent_mass, &pos, &vel); |
||||||
|
|
||||||
// Get initial eccentricity vector
|
const double r = vec3_magnitude(pos); |
||||||
Vec3 v = vel; |
const double v = vec3_magnitude(vel); |
||||||
double r = vec3_magnitude(pos); |
|
||||||
double mu = G * parent_mass; |
const double r_dot_v = vec3_dot(pos, vel); |
||||||
Vec3 e_vec_initial = vec3_scale(vec3_sub(vec3_scale(vel, r), vec3_scale(pos, vec3_magnitude(vel) / mu)), 1.0 / mu); |
const Vec3 e_vec = { |
||||||
double e_initial = vec3_magnitude(e_vec_initial); |
((v * v - mu / r) * pos.x - r_dot_v * vel.x) / mu, |
||||||
|
((v * v - mu / r) * pos.y - r_dot_v * vel.y) / mu, |
||||||
INFO("Initial state:"); |
((v * v - mu / r) * pos.z - r_dot_v * vel.z) / mu, |
||||||
INFO(" e = " << e_initial); |
}; |
||||||
INFO(" e_vec = (" << e_vec_initial.x << ", " << e_vec_initial.y << ", " << e_vec_initial.z << ")"); |
const double e_initial = vec3_magnitude(e_vec); |
||||||
INFO(" pos = (" << pos.x << ", " << pos.y << ", " << pos.z << ")"); |
|
||||||
INFO(" vel = (" << vel.x << ", " << vel.y << ", " << vel.z << ")"); |
SECTION("initial apoapsis state is correct") { |
||||||
|
REQUIRE_THAT(r, WithinAbs(1.3e7, R_TOL)); |
||||||
// Apply a prograde burn at periapsis
|
REQUIRE_THAT(v, WithinAbs(4632.763232589246, V_TOL)); |
||||||
Vec3 vel_dir = vec3_normalize(vel); |
REQUIRE_THAT(e_initial, WithinAbs(0.3, E_TOL)); |
||||||
Vec3 delta_v = vec3_scale(vel_dir, 1000.0); // Large burn to flip e_vec
|
REQUIRE_THAT(e_vec.x / e_initial, WithinAbs(1.0, E_TOL)); |
||||||
vel = vec3_add(vel, delta_v); |
REQUIRE_THAT(e_vec.y, WithinAbs(0.0, E_TOL)); |
||||||
|
REQUIRE_THAT(e_vec.z, WithinAbs(0.0, E_TOL)); |
||||||
// Reconstruct orbital elements
|
} |
||||||
OrbitalElements new_elements = cartesian_to_orbital_elements(pos, vel, parent_mass); |
|
||||||
|
SECTION("prograde burn at apoapsis flips periapsis") { |
||||||
INFO("After prograde burn:"); |
const Vec3 burn_dir = vec3_normalize(vel); |
||||||
INFO(" new omega = " << new_elements.argument_of_periapsis << " rad (" << new_elements.argument_of_periapsis * 180.0 / M_PI << " deg)"); |
const Vec3 delta_v = vec3_scale(burn_dir, 1000.0); |
||||||
INFO(" new e = " << new_elements.eccentricity); |
const Vec3 vel_new = vec3_add(vel, delta_v); |
||||||
|
const double v_new = vec3_magnitude(vel_new); |
||||||
// Get new eccentricity vector
|
|
||||||
Vec3 e_vec_new = vec3_scale(vec3_sub(vec3_scale(vel, r), vec3_scale(pos, vec3_magnitude(vel) / mu)), 1.0 / mu); |
const OrbitalElements new_elements = cartesian_to_orbital_elements(pos, vel_new, parent_mass); |
||||||
INFO(" new e_vec = (" << e_vec_new.x << ", " << e_vec_new.y << ", " << e_vec_new.z << ")"); |
|
||||||
|
REQUIRE_THAT(new_elements.semi_major_axis, WithinAbs(1.346885753127762e7, A_TOL)); |
||||||
// For zero-inclination orbit, omega is computed from the eccentricity vector
|
REQUIRE_THAT(new_elements.eccentricity, WithinAbs(3.481049006486453e-2, E_TOL)); |
||||||
// (longitude of periapsis since ascending node is undefined)
|
REQUIRE_THAT(new_elements.argument_of_periapsis, WithinAbs(M_PI, ANG_TOL)); |
||||||
// The key constraint is that omega should be in [0, 2π)
|
REQUIRE_THAT(new_elements.true_anomaly, WithinAbs(0.0, ANG_TOL)); |
||||||
bool omega_in_range = (new_elements.argument_of_periapsis >= 0.0) && |
REQUIRE_THAT(new_elements.inclination, WithinAbs(0.0, ANG_TOL)); |
||||||
(new_elements.argument_of_periapsis < 2.0 * M_PI); |
REQUIRE_THAT(v_new, WithinAbs(5632.763232589246, V_TOL)); |
||||||
|
} |
||||||
REQUIRE(omega_in_range); |
|
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,225 @@ |
|||||||
|
#include <catch2/catch_test_macros.hpp> |
||||||
|
#include <catch2/matchers/catch_matchers_floating_point.hpp> |
||||||
|
#include "../src/physics.h" |
||||||
|
#include "../src/test_utilities.h" |
||||||
|
#include <cmath> |
||||||
|
|
||||||
|
using Catch::Matchers::WithinAbs; |
||||||
|
|
||||||
|
SCENARIO("Vector math utilities", "[physics][utilities][vector]") { |
||||||
|
const Vec3 a = {1.0, 2.0, 3.0}; |
||||||
|
const Vec3 b = {4.0, 5.0, 6.0}; |
||||||
|
|
||||||
|
SECTION("add") { |
||||||
|
const Vec3 sum = vec3_add(a, b); |
||||||
|
REQUIRE(compare_vec3(sum, {5.0, 7.0, 9.0}, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("sub") { |
||||||
|
const Vec3 diff = vec3_sub(b, a); |
||||||
|
REQUIRE(compare_vec3(diff, {3.0, 3.0, 3.0}, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("scale") { |
||||||
|
const Vec3 scaled = vec3_scale(a, 2.0); |
||||||
|
REQUIRE(compare_vec3(scaled, {2.0, 4.0, 6.0}, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("magnitude") { |
||||||
|
const double mag = vec3_magnitude(a); |
||||||
|
REQUIRE_THAT(mag, WithinAbs(sqrt(14.0), D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("distance") { |
||||||
|
const double dist = vec3_distance(a, b); |
||||||
|
REQUIRE_THAT(dist, WithinAbs(sqrt(27.0), D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("normalize") { |
||||||
|
const Vec3 unit = vec3_normalize(a); |
||||||
|
const double actual_mag = vec3_magnitude(unit); |
||||||
|
REQUIRE_THAT(actual_mag, WithinAbs(1.0, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("normalize zero vector") { |
||||||
|
const Vec3 zero = {0.0, 0.0, 0.0}; |
||||||
|
const Vec3 result = vec3_normalize(zero); |
||||||
|
REQUIRE(compare_vec3(result, zero, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("dot product") { |
||||||
|
const double dot = vec3_dot(a, b); |
||||||
|
REQUIRE_THAT(dot, WithinAbs(32.0, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("cross product") { |
||||||
|
const Vec3 cross = vec3_cross(a, b); |
||||||
|
REQUIRE(compare_vec3(cross, {-3.0, 6.0, -3.0}, D_TOL)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
SCENARIO("Acceleration calculation", "[physics][acceleration]") { |
||||||
|
SECTION("F = ma") { |
||||||
|
const Vec3 force = {10.0, 20.0, 30.0}; |
||||||
|
const double mass = 5.0; |
||||||
|
const Vec3 accel = calculate_acceleration(force, mass); |
||||||
|
REQUIRE_THAT(accel.x, WithinAbs(2.0, R_TOL)); |
||||||
|
REQUIRE_THAT(accel.y, WithinAbs(4.0, R_TOL)); |
||||||
|
REQUIRE_THAT(accel.z, WithinAbs(6.0, R_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("zero mass returns zero acceleration") { |
||||||
|
const Vec3 force = {10.0, 20.0, 30.0}; |
||||||
|
const Vec3 accel = calculate_acceleration(force, 0.0); |
||||||
|
REQUIRE(compare_vec3(accel, {0.0, 0.0, 0.0}, R_TOL)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
SCENARIO("Matrix operations", "[physics][utilities][matrix]") { |
||||||
|
const Mat3 A = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0}; |
||||||
|
const Vec3 v = {1.0, 2.0, 3.0}; |
||||||
|
|
||||||
|
SECTION("identity matrix") { |
||||||
|
const Mat3 I = mat3_identity(); |
||||||
|
const Mat3 result = mat3_multiply(A, I); |
||||||
|
REQUIRE(compare_vec3({result.m00, result.m11, result.m22}, |
||||||
|
{A.m00, A.m11, A.m22}, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("multiply vector") { |
||||||
|
const Vec3 result = mat3_multiply_vec3(A, v); |
||||||
|
REQUIRE(compare_vec3(result, {14.0, 32.0, 50.0}, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("multiply two matrices") { |
||||||
|
const Mat3 B = {9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0}; |
||||||
|
const Mat3 result = mat3_multiply(A, B); |
||||||
|
REQUIRE_THAT(result.m00, WithinAbs(30.0, D_TOL)); |
||||||
|
REQUIRE_THAT(result.m01, WithinAbs(24.0, D_TOL)); |
||||||
|
REQUIRE_THAT(result.m02, WithinAbs(18.0, D_TOL)); |
||||||
|
REQUIRE_THAT(result.m10, WithinAbs(84.0, D_TOL)); |
||||||
|
REQUIRE_THAT(result.m11, WithinAbs(69.0, D_TOL)); |
||||||
|
REQUIRE_THAT(result.m12, WithinAbs(54.0, D_TOL)); |
||||||
|
REQUIRE_THAT(result.m20, WithinAbs(138.0, D_TOL)); |
||||||
|
REQUIRE_THAT(result.m21, WithinAbs(114.0, D_TOL)); |
||||||
|
REQUIRE_THAT(result.m22, WithinAbs(90.0, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("transpose") { |
||||||
|
const Mat3 T = mat3_transpose(A); |
||||||
|
REQUIRE_THAT(T.m00, WithinAbs(1.0, D_TOL)); |
||||||
|
REQUIRE_THAT(T.m01, WithinAbs(4.0, D_TOL)); |
||||||
|
REQUIRE_THAT(T.m02, WithinAbs(7.0, D_TOL)); |
||||||
|
REQUIRE_THAT(T.m10, WithinAbs(2.0, D_TOL)); |
||||||
|
REQUIRE_THAT(T.m11, WithinAbs(5.0, D_TOL)); |
||||||
|
REQUIRE_THAT(T.m12, WithinAbs(8.0, D_TOL)); |
||||||
|
REQUIRE_THAT(T.m20, WithinAbs(3.0, D_TOL)); |
||||||
|
REQUIRE_THAT(T.m21, WithinAbs(6.0, D_TOL)); |
||||||
|
REQUIRE_THAT(T.m22, WithinAbs(9.0, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("transpose of transpose is original") { |
||||||
|
const Mat3 T2 = mat3_transpose(mat3_transpose(A)); |
||||||
|
REQUIRE_THAT(T2.m00, WithinAbs(A.m00, D_TOL)); |
||||||
|
REQUIRE_THAT(T2.m01, WithinAbs(A.m01, D_TOL)); |
||||||
|
REQUIRE_THAT(T2.m02, WithinAbs(A.m02, D_TOL)); |
||||||
|
REQUIRE_THAT(T2.m10, WithinAbs(A.m10, D_TOL)); |
||||||
|
REQUIRE_THAT(T2.m11, WithinAbs(A.m11, D_TOL)); |
||||||
|
REQUIRE_THAT(T2.m12, WithinAbs(A.m12, D_TOL)); |
||||||
|
REQUIRE_THAT(T2.m20, WithinAbs(A.m20, D_TOL)); |
||||||
|
REQUIRE_THAT(T2.m21, WithinAbs(A.m21, D_TOL)); |
||||||
|
REQUIRE_THAT(T2.m22, WithinAbs(A.m22, D_TOL)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
SCENARIO("Rotation matrices", "[physics][utilities][rotation]") { |
||||||
|
SECTION("rotate about Z by 90 degrees") { |
||||||
|
const Mat3 Rz = mat3_rotation_z(M_PI / 2); |
||||||
|
const Vec3 v = {1.0, 0.0, 0.0}; |
||||||
|
const Vec3 result = mat3_multiply_vec3(Rz, v); |
||||||
|
REQUIRE(compare_vec3(result, {0.0, 1.0, 0.0}, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("rotate about X by 90 degrees") { |
||||||
|
const Mat3 Rx = mat3_rotation_x(M_PI / 2); |
||||||
|
const Vec3 v = {0.0, 1.0, 0.0}; |
||||||
|
const Vec3 result = mat3_multiply_vec3(Rx, v); |
||||||
|
REQUIRE(compare_vec3(result, {0.0, 0.0, 1.0}, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("180 degree rotation") { |
||||||
|
const Mat3 Rz180 = mat3_rotation_z(M_PI); |
||||||
|
const Vec3 v = {1.0, 0.0, 0.0}; |
||||||
|
const Vec3 result = mat3_multiply_vec3(Rz180, v); |
||||||
|
REQUIRE(compare_vec3(result, {-1.0, 0.0, 0.0}, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("360 degree rotation equals identity") { |
||||||
|
const Mat3 Rz360 = mat3_rotation_z(2.0 * M_PI); |
||||||
|
const Mat3 I = mat3_identity(); |
||||||
|
REQUIRE(compare_vec3({Rz360.m00, Rz360.m11, Rz360.m22}, |
||||||
|
{I.m00, I.m11, I.m22}, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("negative angle equals equivalent positive rotation") { |
||||||
|
const Mat3 Rz_neg90 = mat3_rotation_z(-M_PI / 2); |
||||||
|
const Mat3 Rz_270 = mat3_rotation_z(3.0 * M_PI / 2); |
||||||
|
REQUIRE_THAT(Rz_neg90.m00, WithinAbs(Rz_270.m00, D_TOL)); |
||||||
|
REQUIRE_THAT(Rz_neg90.m01, WithinAbs(Rz_270.m01, D_TOL)); |
||||||
|
REQUIRE_THAT(Rz_neg90.m10, WithinAbs(Rz_270.m10, D_TOL)); |
||||||
|
REQUIRE_THAT(Rz_neg90.m11, WithinAbs(Rz_270.m11, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("combined rotations that cancel") { |
||||||
|
const Mat3 Rz90 = mat3_rotation_z(M_PI / 2); |
||||||
|
const Mat3 Rz_neg90 = mat3_rotation_z(-M_PI / 2); |
||||||
|
const Mat3 combined = mat3_multiply(Rz_neg90, Rz90); |
||||||
|
const Mat3 I = mat3_identity(); |
||||||
|
REQUIRE(compare_vec3({combined.m00, combined.m11, combined.m22}, |
||||||
|
{I.m00, I.m11, I.m22}, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("rotation matrix orthogonality") { |
||||||
|
const double angle = M_PI / 4; |
||||||
|
const Mat3 Rz = mat3_rotation_z(angle); |
||||||
|
const Mat3 Rz_T = mat3_transpose(Rz); |
||||||
|
const Mat3 product = mat3_multiply(Rz, Rz_T); |
||||||
|
const Mat3 I = mat3_identity(); |
||||||
|
REQUIRE_THAT(product.m00, WithinAbs(I.m00, D_TOL)); |
||||||
|
REQUIRE_THAT(product.m01, WithinAbs(I.m01, D_TOL)); |
||||||
|
REQUIRE_THAT(product.m02, WithinAbs(I.m02, D_TOL)); |
||||||
|
REQUIRE_THAT(product.m10, WithinAbs(I.m10, D_TOL)); |
||||||
|
REQUIRE_THAT(product.m11, WithinAbs(I.m11, D_TOL)); |
||||||
|
REQUIRE_THAT(product.m12, WithinAbs(I.m12, D_TOL)); |
||||||
|
REQUIRE_THAT(product.m20, WithinAbs(I.m20, D_TOL)); |
||||||
|
REQUIRE_THAT(product.m21, WithinAbs(I.m21, D_TOL)); |
||||||
|
REQUIRE_THAT(product.m22, WithinAbs(I.m22, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("orbital rotation matrix with 90 deg inclination") { |
||||||
|
const Mat3 R = mat3_rotation_orbital(0.0, M_PI / 2, 0.0); |
||||||
|
const Vec3 v = {1.0, 0.0, 0.0}; |
||||||
|
const Vec3 result = mat3_multiply_vec3(R, v); |
||||||
|
REQUIRE(compare_vec3(result, {1.0, 0.0, 0.0}, D_TOL)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
SCENARIO("compare_vec3 utility", "[physics][utilities][compare]") { |
||||||
|
SECTION("equal vectors within tolerance") { |
||||||
|
const Vec3 a = {1.0, 2.0, 3.0}; |
||||||
|
const Vec3 b = {1.0 + 1e-13, 2.0 + 1e-13, 3.0 + 1e-13}; |
||||||
|
REQUIRE(compare_vec3(a, b, D_TOL)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("equal vectors exactly") { |
||||||
|
const Vec3 a = {1.0, 2.0, 3.0}; |
||||||
|
const Vec3 b = {1.0, 2.0, 3.0}; |
||||||
|
REQUIRE(compare_vec3(a, b, 0.0)); |
||||||
|
} |
||||||
|
|
||||||
|
SECTION("different vectors outside tolerance") { |
||||||
|
const Vec3 a = {1.0, 2.0, 3.0}; |
||||||
|
const Vec3 b = {2.0, 2.0, 3.0}; |
||||||
|
REQUIRE(!compare_vec3(a, b, 0.5)); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue