diff --git a/crates/astrodyn_dynamics/src/body_init.rs b/crates/astrodyn_dynamics/src/body_init.rs index 358eb033..0da3f3cd 100644 --- a/crates/astrodyn_dynamics/src/body_init.rs +++ b/crates/astrodyn_dynamics/src/body_init.rs @@ -78,6 +78,75 @@ pub fn init_from_orbital_elements( TranslationalState { position, velocity } } +/// Initialize translational state from Keplerian orbital elements using the +/// semi-latus rectum (rather than semi-major axis) plus true anomaly. +/// +/// Port of the `SlrEccIncAscnodeArgperTanom` branch of JEOD +/// `DynBodyInitOrbit::apply()` from +/// `models/dynamics/body_action/src/dyn_body_init_orbit.cc:196-200, 285-321`. +/// +/// JEOD selects `shape = ShapeSemiLatusRectum`, which **skips** the +/// `semi_latus_rectum = semi_major_axis * (1 - e²)` derivation (that block +/// runs only for `ShapeSemiMajorAxis`). The deck-supplied semi-latus rectum +/// is therefore used verbatim as `elem.semiparam`. To match JEOD bit-for-bit +/// we set `semiparam = semi_latus_rectum` directly here — routing through +/// `init_from_orbital_elements` (which recomputes `semiparam = a·(1-e²)` from +/// `a = p/(1-e²)`) would introduce a round-trip that JEOD never performs. +/// +/// # Arguments +/// * `semi_latus_rectum` - Semi-latus rectum p (m) +/// * `eccentricity` - Orbital eccentricity +/// * `inclination` - Inclination (rad) +/// * `raan` - Right ascension of ascending node (rad) +/// * `arg_periapsis` - Argument of periapsis (rad) +/// * `true_anomaly` - True anomaly (rad) +/// * `mu` - Gravitational parameter of central body (m^3/s^2) +pub fn init_from_semi_latus_rectum_true_anomaly( + semi_latus_rectum: f64, + eccentricity: f64, + inclination: f64, + raan: f64, + arg_periapsis: f64, + true_anomaly: f64, + mu: f64, +) -> TranslationalState { + // JEOD_INV: BA.05 — orbit initializer requires a valid gravity source (mu > 0) + // JEOD dyn_body_init_orbit.cc:98-111: validate mu before use. + assert!( + mu > 0.0, + "init_from_semi_latus_rectum_true_anomaly: mu must be positive, got {mu}" + ); + assert!( + semi_latus_rectum > 0.0 && semi_latus_rectum.is_finite(), + "init_from_semi_latus_rectum_true_anomaly: semi_latus_rectum must be positive and finite, \ + got {semi_latus_rectum}" + ); + assert!( + (0.0..1.0).contains(&eccentricity), + "init_from_semi_latus_rectum_true_anomaly: eccentricity must be in [0, 1), \ + got {eccentricity}" + ); + + // JEOD dyn_body_init_orbit.cc: ShapeSemiLatusRectum leaves semiparam as + // the deck value, then sets the angles, true_anom, and calls + // nu_to_anomalies() followed by to_cartesian(). + use astrodyn_quantities::frame::SelfPlanet; + let mut oe = OrbitalElements::::default(); + oe.semiparam = semi_latus_rectum; + oe.e_mag = eccentricity; + oe.inclination = inclination; + oe.long_asc_node = raan; + oe.arg_periapsis = arg_periapsis; + oe.true_anom = true_anomaly; + oe.nu_to_anomalies(); + + let (position, velocity) = oe + .to_cartesian(mu) + .expect("init_from_semi_latus_rectum_true_anomaly: to_cartesian failed"); + + TranslationalState { position, velocity } +} + /// Typed sibling of [`init_from_orbital_elements`]. /// /// Returns a [`TranslationalStateTyped`] — Phase 3 callers @@ -569,7 +638,8 @@ mod tests { .time_periapsis .expect("ISS set01 should have time_periapsis"); let state = init_from_time_periapsis( - init.semi_major_axis, + init.semi_major_axis + .expect("ISS set01 should have semi_major_axis"), init.eccentricity, init.inclination, init.ascending_node, @@ -1323,6 +1393,63 @@ mod tests { // assert and leave the others intact. // ======================================================================= + #[test] + fn slr_true_anomaly_matches_orbital_elements_within_roundoff() { + // The slr+true-anomaly converter sets semiparam = p directly (JEOD's + // SlrEccIncAscnodeArgperTanom path), whereas init_from_orbital_elements + // takes sma and recomputes semiparam = a·(1-e²). Feeding the + // algebraically-equivalent sma = p/(1-e²) into the sma path must agree + // to within the round-trip roundoff (a few ULP × radius). + let p = 6_732_889.984_55; + let e = 0.00129073350; + let inc = 51.670450765_f64.to_radians(); + let raan = 49.708417385_f64.to_radians(); + let argp = 100.582445989_f64.to_radians(); + let nu = 299.884499026_f64.to_radians(); + + let slr = init_from_semi_latus_rectum_true_anomaly(p, e, inc, raan, argp, nu, EARTH_MU); + let a = p / (1.0 - e * e); + let sma = init_from_orbital_elements(a, e, inc, raan, argp, nu, EARTH_MU); + + let pos_err = (slr.position - sma.position).length(); + let vel_err = (slr.velocity - sma.velocity).length(); + // ~7e6 m radius × ~1e-15 relative ULP ≈ 1e-8 m; allow generous margin. + assert!( + pos_err < 1e-6, + "slr vs sma position roundoff too large: {pos_err} m" + ); + assert!( + vel_err < 1e-9, + "slr vs sma velocity roundoff too large: {vel_err} m/s" + ); + } + + #[test] + fn slr_true_anomaly_position_magnitude_matches_conic() { + // r = p / (1 + e·cos ν) — verify the converter reproduces the conic + // radius for a non-trivial true anomaly. + let p = 6_700_000.0; + let e = 0.01; + let nu = 1.3_f64; // rad + let state = init_from_semi_latus_rectum_true_anomaly(p, e, 0.5, 0.3, 0.7, nu, EARTH_MU); + let r_expected = p / (1.0 + e * nu.cos()); + let r_actual = state.position.length(); + assert!( + (r_actual - r_expected).abs() < 1e-6, + "conic radius: expected {r_expected}, got {r_actual}" + ); + } + + #[test] + #[should_panic(expected = "mu must be positive")] + fn ba_05_panics_on_zero_mu_in_slr_true_anomaly_init() { + // JEOD_INV: BA.05 — `init_from_semi_latus_rectum_true_anomaly` shares + // the mu>0 guard so the set03 path can't slip a misconfigured gravity + // source past the others. + let _ = + init_from_semi_latus_rectum_true_anomaly(6_700_000.0, 0.01, 0.0, 0.0, 0.0, 0.0, 0.0); + } + #[test] #[should_panic(expected = "mu must be positive")] fn ba_05_panics_on_zero_mu_in_orbital_elements_init() { diff --git a/crates/astrodyn_dynamics/src/lib.rs b/crates/astrodyn_dynamics/src/lib.rs index 3e5d82ef..0e1fc9ba 100644 --- a/crates/astrodyn_dynamics/src/lib.rs +++ b/crates/astrodyn_dynamics/src/lib.rs @@ -83,7 +83,7 @@ pub use abm4::{abm4_translational_step, Abm4State}; pub use attach::{combine_states_at_attach, AttachCombineInputs, AttachCombineOutputs}; pub use body_init::{ compute_ned_rotation, init_from_lvlh, init_from_mean_anomaly, init_from_ned, - init_from_orbital_elements, init_from_time_periapsis, + init_from_orbital_elements, init_from_semi_latus_rectum_true_anomaly, init_from_time_periapsis, }; pub use constraints::{apply_constraint, BaumgarteSolver, HolonomicConstraint, PendulumConstraint}; pub use forces::{ diff --git a/crates/astrodyn_dynamics/tests/tier2_body_init.rs b/crates/astrodyn_dynamics/tests/tier2_body_init.rs index 8e9effd6..80dc284b 100644 --- a/crates/astrodyn_dynamics/tests/tier2_body_init.rs +++ b/crates/astrodyn_dynamics/tests/tier2_body_init.rs @@ -95,7 +95,8 @@ fn iss_set01_time_periapsis() { .expect("ISS set01 must have time_periapsis"); let computed = init_from_time_periapsis( - init.semi_major_axis, + init.semi_major_axis + .expect("ISS set01 must have semi_major_axis"), init.eccentricity, init.inclination, init.ascending_node, @@ -144,7 +145,8 @@ fn iss_set02_mean_anomaly() { let mean_anomaly = init.mean_anomaly.expect("ISS set02 must have mean_anomaly"); let computed = init_from_mean_anomaly( - init.semi_major_axis, + init.semi_major_axis + .expect("ISS set02 must have semi_major_axis"), init.eccentricity, init.inclination, init.ascending_node, @@ -193,7 +195,8 @@ fn iss_set10_true_anomaly() { let true_anomaly = init.true_anomaly.expect("ISS set10 must have true_anomaly"); let computed = init_from_orbital_elements( - init.semi_major_axis, + init.semi_major_axis + .expect("ISS set10 must have semi_major_axis"), init.eccentricity, init.inclination, init.ascending_node, @@ -240,7 +243,9 @@ fn iss_element_sets_cross_consistent() { "trans_Orbit_inertial_body_set01", ); let state01 = init_from_time_periapsis( - init01.semi_major_axis, + init01 + .semi_major_axis + .expect("ISS set01 must have semi_major_axis"), init01.eccentricity, init01.inclination, init01.ascending_node, @@ -255,7 +260,9 @@ fn iss_element_sets_cross_consistent() { "trans_Orbit_inertial_body_set02", ); let state02 = init_from_mean_anomaly( - init02.semi_major_axis, + init02 + .semi_major_axis + .expect("ISS set02 must have semi_major_axis"), init02.eccentricity, init02.inclination, init02.ascending_node, @@ -270,7 +277,9 @@ fn iss_element_sets_cross_consistent() { "trans_Orbit_inertial_body_set10", ); let state10 = init_from_orbital_elements( - init10.semi_major_axis, + init10 + .semi_major_axis + .expect("ISS set10 must have semi_major_axis"), init10.eccentricity, init10.inclination, init10.ascending_node, diff --git a/crates/astrodyn_math/tests/jeod_validation.rs b/crates/astrodyn_math/tests/jeod_validation.rs index 42a747cf..20233592 100644 --- a/crates/astrodyn_math/tests/jeod_validation.rs +++ b/crates/astrodyn_math/tests/jeod_validation.rs @@ -19,12 +19,14 @@ fn validate_iss_orbital_elements_to_cartesian() { // not `$JEOD_HOME` at runtime. let init = orbital_init::load_orbital_init("ISS", "trans_Orbit_inertial_body_set01"); let expected = reference_state::load_reference_state("ISS", "inertial"); + let sma = init + .semi_major_axis + .expect("ISS set01 should have semi_major_axis"); // Verify parsed values are sensible assert!( - init.semi_major_axis > 6_000_000.0 && init.semi_major_axis < 7_000_000.0, - "ISS semi-major axis should be ~6732 km, got {} m", - init.semi_major_axis + sma > 6_000_000.0 && sma < 7_000_000.0, + "ISS semi-major axis should be ~6732 km, got {sma} m", ); assert!( init.eccentricity < 0.01, @@ -45,7 +47,7 @@ fn validate_iss_orbital_elements_to_cartesian() { // mean_anomaly = time_periapsis * sqrt(mu / a) / a // which is algebraically identical to M = n·t_peri with n = sqrt(mu/a^3) // but matches JEOD's arithmetic order for bit-parity with the port. - let a = init.semi_major_axis; + let a = sma; let t_peri = init .time_periapsis .expect("ISS set01 should have time_periapsis"); @@ -53,7 +55,7 @@ fn validate_iss_orbital_elements_to_cartesian() { let n = (MU_EARTH / (a * a * a)).sqrt(); let mut oe = OrbitalElements::::default(); - oe.semi_major_axis = init.semi_major_axis; + oe.semi_major_axis = a; oe.e_mag = init.eccentricity; oe.inclination = init.inclination; oe.long_asc_node = init.ascending_node; @@ -368,10 +370,12 @@ fn validate_orbital_init_parser() { // Cross-check parsed values against known file contents let deg2rad = std::f64::consts::PI / 180.0; + let sma = init + .semi_major_axis + .expect("ISS set01 should have semi_major_axis"); assert!( - (init.semi_major_axis - 6_732_901.201_52).abs() < 0.01, - "semi_major_axis: expected 6732901.20152 m, got {}", - init.semi_major_axis + (sma - 6_732_901.201_52).abs() < 0.01, + "semi_major_axis: expected 6732901.20152 m, got {sma}", ); assert!( (init.eccentricity - 0.00129073350).abs() < 1e-12, diff --git a/crates/astrodyn_verif_jeod/src/bin/extract_body_init.rs b/crates/astrodyn_verif_jeod/src/bin/extract_body_init.rs index 08bf9f38..a770b357 100644 --- a/crates/astrodyn_verif_jeod/src/bin/extract_body_init.rs +++ b/crates/astrodyn_verif_jeod/src/bin/extract_body_init.rs @@ -27,6 +27,7 @@ //! `reference_inertial_trans_state.py` — ECI reference state //! `trans_Orbit_inertial_body_set01.py` — orbit (sma/ecc/inc/raan/argp/t_peri) //! `trans_Orbit_inertial_body_set02.py` — orbit (mean anomaly) +//! `trans_Orbit_inertial_body_set03.py` — orbit (semi-latus rectum + true anomaly) //! `trans_Orbit_inertial_body_set10.py` — orbit (true anomaly) //! `trans_Orbit_pfix_body_set01.py` — pfix orbit (set01 form) //! `trans_TransState_inertial_body.py` — direct Cartesian (STS_114 only) @@ -63,6 +64,7 @@ const SCENARIOS: &[Scenario] = &[ orbit_inits: &[ "trans_Orbit_inertial_body_set01", "trans_Orbit_inertial_body_set02", + "trans_Orbit_inertial_body_set03", "trans_Orbit_inertial_body_set10", "trans_Orbit_pfix_body_set01", ], @@ -74,6 +76,7 @@ const SCENARIOS: &[Scenario] = &[ orbit_inits: &[ "trans_Orbit_inertial_body_set01", "trans_Orbit_inertial_body_set02", + "trans_Orbit_inertial_body_set03", "trans_Orbit_pfix_body_set01", ], trans_states: &["trans_TransState_inertial_body"], @@ -313,7 +316,13 @@ fn write_bundle( writeln!( out, " \"semi_major_axis\": {},", - fmt(init.semi_major_axis) + fmt_opt(init.semi_major_axis) + ) + .unwrap(); + writeln!( + out, + " \"semi_latus_rectum\": {},", + fmt_opt(init.semi_latus_rectum) ) .unwrap(); writeln!(out, " \"eccentricity\": {},", fmt(init.eccentricity)).unwrap(); diff --git a/crates/astrodyn_verif_jeod/src/run_verification/sim_orbinit_docker.rs b/crates/astrodyn_verif_jeod/src/run_verification/sim_orbinit_docker.rs index b2962e05..9bc2cac6 100644 --- a/crates/astrodyn_verif_jeod/src/run_verification/sim_orbinit_docker.rs +++ b/crates/astrodyn_verif_jeod/src/run_verification/sim_orbinit_docker.rs @@ -16,15 +16,20 @@ //! The recipes correspond to JEOD's RUN list: //! * RUN_0001 — ISS orbital elements (set01) in `Earth.inertial`; //! * RUN_0002 — ISS orbital elements (set02, mean anomaly) in `Earth.inertial`; +//! * RUN_0003 — ISS orbital elements (set03, semi-latus rectum + true anomaly) in `Earth.inertial`; //! * RUN_0101 — STS-114 orbital elements (set01) in `Earth.inertial`; //! * RUN_0102 — STS-114 orbital elements (set02, mean anomaly) in `Earth.inertial`; +//! * RUN_0103 — STS-114 orbital elements (set03, semi-latus rectum + true anomaly) in `Earth.inertial`; //! * RUN_0201 — ISS orbital elements (set01) in `Earth.pfix`; //! * RUN_0301 — STS-114 orbital elements (set01) in `Earth.pfix`; //! * RUN_0401 — STS-114 direct Cartesian state in `Earth.inertial`. //! -//! Both parameterizations resolve to [`init_from_mean_anomaly`]; set01 -//! derives `M = t_peri·√(μ/a³)` from the fixture's `time_periapsis`, while -//! set02 reads the fixture's `mean_anomaly` field directly. +//! set01 and set02 resolve to [`init_from_mean_anomaly`]; set01 derives +//! `M = t_peri·√(μ/a³)` from the fixture's `time_periapsis`, while set02 +//! reads the fixture's `mean_anomaly` field directly. set03 +//! (`SlrEccIncAscnodeArgperTanom`) resolves to +//! [`init_from_semi_latus_rectum_true_anomaly`], using the fixture's +//! `semi_latus_rectum` (as `semiparam`) and `true_anomaly` directly. //! //! The orbital-element-to-Cartesian conversion is the substance of this //! test; it runs inside every scenario factory rather than being @@ -47,9 +52,10 @@ use super::fixtures::load_mu_earth; use crate::verification::{CsvReference, InitialConditions, Tolerances, VerificationCase}; use astrodyn::{ calendar_to_tjt, compute_t_parent_this_from_tjt, default_leap_second_table, - init_from_mean_anomaly, ut1_to_gmst_seconds, CalendarDate, GravityControl, GravityControls, - GravityGradient, GravityModel, GravitySource, GravitySourceEntry, RotationModel, - SimulationBuilder, SimulationTime, TranslationalState, VehicleConfig, + init_from_mean_anomaly, init_from_semi_latus_rectum_true_anomaly, ut1_to_gmst_seconds, + CalendarDate, GravityControl, GravityControls, GravityGradient, GravityModel, GravitySource, + GravitySourceEntry, RotationModel, SimulationBuilder, SimulationTime, TranslationalState, + VehicleConfig, }; use astrodyn_verif_jeod_fixtures::orbital_init::{load_orbital_init, load_trans_state}; use glam::{DMat3, DVec3}; @@ -132,12 +138,14 @@ fn orbital_element_state(vehicle: &str, init_name: &str, mu_earth: f64) -> Trans let t_peri = init.time_periapsis.unwrap_or_else(|| { panic!("{vehicle}/{init_name}: set01 expected time_periapsis in the fixture",) }); - let a = init.semi_major_axis; + let a = init.semi_major_axis.unwrap_or_else(|| { + panic!("{vehicle}/{init_name}: set01 expected semi_major_axis in the fixture") + }); let n = (mu_earth / (a * a * a)).sqrt(); let mean_anomaly = n * t_peri; let state_ref = init_from_mean_anomaly( - init.semi_major_axis, + a, init.eccentricity, init.inclination, init.ascending_node, @@ -178,8 +186,11 @@ fn mean_anomaly_element_state(vehicle: &str, init_name: &str, mu_earth: f64) -> got '{}' — add frame handling if a pfix set02 RUN is introduced", init.reference_frame, ); + let a = init.semi_major_axis.unwrap_or_else(|| { + panic!("{vehicle}/{init_name}: set02 expected semi_major_axis in the fixture") + }); init_from_mean_anomaly( - init.semi_major_axis, + a, init.eccentricity, init.inclination, init.ascending_node, @@ -189,6 +200,40 @@ fn mean_anomaly_element_state(vehicle: &str, init_name: &str, mu_earth: f64) -> ) } +/// Materialize a JEOD set03 (`SlrEccIncAscnodeArgperTanom`) fixture into an +/// inertial-frame translational state. set03 parameterizes the orbit by +/// **semi-latus rectum** (`semi_latus_rectum`) + **true anomaly** +/// (`true_anomaly`), both stored in SI by `extract_body_init` (m, rad). JEOD +/// uses the deck's semi-latus rectum verbatim as `elem.semiparam` (the +/// `semi_major_axis * (1 - e²)` derivation runs only for sma-parameterized +/// sets), so this is exactly [`init_from_semi_latus_rectum_true_anomaly`] +/// with no sma round-trip. The set03 decks are `Earth.inertial` only. +fn true_anomaly_element_state(vehicle: &str, init_name: &str, mu_earth: f64) -> TranslationalState { + let init = load_orbital_init(vehicle, init_name); + let p = init.semi_latus_rectum.unwrap_or_else(|| { + panic!("{vehicle}/{init_name}: set03 expected semi_latus_rectum in the fixture") + }); + let true_anomaly = init.true_anomaly.unwrap_or_else(|| { + panic!("{vehicle}/{init_name}: set03 expected true_anomaly in the fixture") + }); + assert_eq!( + init.reference_frame.as_str(), + "Earth.inertial", + "{vehicle}/{init_name}: set03 recipe only supports Earth.inertial frames, \ + got '{}' — add frame handling if a pfix set03 RUN is introduced", + init.reference_frame, + ); + init_from_semi_latus_rectum_true_anomaly( + p, + init.eccentricity, + init.inclination, + init.ascending_node, + init.arg_periapsis, + true_anomaly, + mu_earth, + ) +} + /// Materialize a JEOD direct-Cartesian fixture (RUN_0401 only) into an /// inertial-frame translational state. The fixture is a pass-through: /// `position`/`velocity` arrays are taken verbatim. @@ -297,6 +342,24 @@ fn build_run_0102(_init: &InitialConditions) -> SimulationBuilder { build_orbinit_docker(mu, state) } +/// RUN_0003: ISS set03 (semi-latus rectum + true-anomaly) elements from the +/// committed `iss.json` fixture (`trans_Orbit_inertial_body_set03`), in +/// `Earth.inertial`. +fn build_run_0003(_init: &InitialConditions) -> SimulationBuilder { + let mu = load_mu_earth(); + let state = true_anomaly_element_state("ISS", "trans_Orbit_inertial_body_set03", mu); + build_orbinit_docker(mu, state) +} + +/// RUN_0103: STS-114 set03 (semi-latus rectum + true-anomaly) elements from +/// the committed `sts_114.json` fixture (`trans_Orbit_inertial_body_set03`), +/// in `Earth.inertial`. +fn build_run_0103(_init: &InitialConditions) -> SimulationBuilder { + let mu = load_mu_earth(); + let state = true_anomaly_element_state("STS_114", "trans_Orbit_inertial_body_set03", mu); + build_orbinit_docker(mu, state) +} + /// RUN_0001: ISS orbital elements (set01) in `Earth.inertial`. pub fn run_0001() -> VerificationCase { VerificationCase { @@ -345,6 +408,40 @@ pub fn run_0102() -> VerificationCase { } } +/// RUN_0003: ISS orbital elements (set03, semi-latus rectum + true anomaly) +/// in `Earth.inertial`. +pub fn run_0003() -> VerificationCase { + VerificationCase { + name: "tier3_orbinit_docker_run_0003", + scenario: build_run_0003, + reference: CsvReference::SyntheticTimes { + dt: DT_S, + num_steps: NUM_STEPS, + }, + duration: Time::new::(0.0), + tolerances: synthetic_tolerances(), + extras: None, + pre_step: None, + } +} + +/// RUN_0103: STS-114 orbital elements (set03, semi-latus rectum + true +/// anomaly) in `Earth.inertial`. +pub fn run_0103() -> VerificationCase { + VerificationCase { + name: "tier3_orbinit_docker_run_0103", + scenario: build_run_0103, + reference: CsvReference::SyntheticTimes { + dt: DT_S, + num_steps: NUM_STEPS, + }, + duration: Time::new::(0.0), + tolerances: synthetic_tolerances(), + extras: None, + pre_step: None, + } +} + /// RUN_0101: STS-114 orbital elements (set01) in `Earth.inertial`. pub fn run_0101() -> VerificationCase { VerificationCase { diff --git a/crates/astrodyn_verif_jeod/test_data/body_init/iss.json b/crates/astrodyn_verif_jeod/test_data/body_init/iss.json index a96923e0..b63d53e1 100644 --- a/crates/astrodyn_verif_jeod/test_data/body_init/iss.json +++ b/crates/astrodyn_verif_jeod/test_data/body_init/iss.json @@ -4,13 +4,14 @@ "source": "models/dynamics/body_action/verif/SIM_orbinit/Modified_data/ISS/", "jeod_version": "5.4", "jeod_commit": "27893108bbde8bb162b3213a30d57af89acd1c76", - "generated_utc": "2026-05-25T07:10:54Z", + "generated_utc": "2026-05-25T19:52:50Z", "note": "Body initialization vectors. Regenerate with: cargo run -p astrodyn_verif_jeod --bin extract_body_init -- --jeod-home $JEOD_HOME", "reference_inertial": {"position": [1244540.53, 5655938.85, 3425643.22], "velocity": [-6003.833051, -1469.496044, 4590.511776]}, "orbital_inits": [ { "name": "trans_Orbit_inertial_body_set01", "semi_major_axis": 6732901.20152, + "semi_latus_rectum": null, "eccentricity": 0.0012907335, "inclination": 0.9018194918388728, "ascending_node": 0.8675755493238397, @@ -24,6 +25,7 @@ { "name": "trans_Orbit_inertial_body_set02", "semi_major_axis": 6732901.20152, + "semi_latus_rectum": null, "eccentricity": 0.0012907335, "inclination": 0.9018194918388728, "ascending_node": 0.8675755493238397, @@ -34,9 +36,24 @@ "planet_name": "Earth", "reference_frame": "Earth.inertial" }, + { + "name": "trans_Orbit_inertial_body_set03", + "semi_major_axis": null, + "semi_latus_rectum": 6732889.98455, + "eccentricity": 0.0012907335, + "inclination": 0.9018194918388728, + "ascending_node": 0.8675755493238397, + "arg_periapsis": 1.7554948522174143, + "time_periapsis": null, + "mean_anomaly": null, + "true_anomaly": 5.2339718836974285, + "planet_name": "Earth", + "reference_frame": "Earth.inertial" + }, { "name": "trans_Orbit_inertial_body_set10", "semi_major_axis": 6732901.20152, + "semi_latus_rectum": null, "eccentricity": 0.0012907335, "inclination": 0.9018194918388728, "ascending_node": 0.8675755493238397, @@ -50,6 +67,7 @@ { "name": "trans_Orbit_pfix_body_set01", "semi_major_axis": 6732901.205250001, + "semi_latus_rectum": null, "eccentricity": 0.00129073426, "inclination": 0.90143908767673, "ascending_node": 5.429530680701693, diff --git a/crates/astrodyn_verif_jeod/test_data/body_init/sts_114.json b/crates/astrodyn_verif_jeod/test_data/body_init/sts_114.json index 28f8336a..4061a992 100644 --- a/crates/astrodyn_verif_jeod/test_data/body_init/sts_114.json +++ b/crates/astrodyn_verif_jeod/test_data/body_init/sts_114.json @@ -4,13 +4,14 @@ "source": "models/dynamics/body_action/verif/SIM_orbinit/Modified_data/STS_114/", "jeod_version": "5.4", "jeod_commit": "27893108bbde8bb162b3213a30d57af89acd1c76", - "generated_utc": "2026-05-25T07:10:54Z", + "generated_utc": "2026-05-25T19:52:50Z", "note": "Body initialization vectors. Regenerate with: cargo run -p astrodyn_verif_jeod --bin extract_body_init -- --jeod-home $JEOD_HOME", "reference_inertial": {"position": [1244471.94, 5655811.8, 3425518.88], "velocity": [-6003.553468, -1469.321965, 4590.57723]}, "orbital_inits": [ { "name": "trans_Orbit_inertial_body_set01", "semi_major_axis": 6732163.59764, + "semi_latus_rectum": null, "eccentricity": 0.00122446354, "inclination": 0.9018261209658911, "ascending_node": 0.867596132253508, @@ -24,6 +25,7 @@ { "name": "trans_Orbit_inertial_body_set02", "semi_major_axis": 6732163.59764, + "semi_latus_rectum": null, "eccentricity": 0.00122446354, "inclination": 0.9018261209658911, "ascending_node": 0.867596132253508, @@ -34,9 +36,24 @@ "planet_name": "Earth", "reference_frame": "Earth.inertial" }, + { + "name": "trans_Orbit_inertial_body_set03", + "semi_major_axis": null, + "semi_latus_rectum": 6732153.5040299995, + "eccentricity": 0.00122446354, + "inclination": 0.9018261209658911, + "ascending_node": 0.867596132253508, + "arg_periapsis": 1.801291139680453, + "time_periapsis": null, + "mean_anomaly": null, + "true_anomaly": 5.18816333569782, + "planet_name": "Earth", + "reference_frame": "Earth.inertial" + }, { "name": "trans_Orbit_pfix_body_set01", "semi_major_axis": 6732163.61166, + "semi_latus_rectum": null, "eccentricity": 0.00122446412, "inclination": 0.9014457086530605, "ascending_node": 5.429551274731656, diff --git a/crates/astrodyn_verif_jeod/test_data/orbinit_0003_orbinit.csv b/crates/astrodyn_verif_jeod/test_data/orbinit_0003_orbinit.csv new file mode 100644 index 00000000..5f2d694d --- /dev/null +++ b/crates/astrodyn_verif_jeod/test_data/orbinit_0003_orbinit.csv @@ -0,0 +1,2 @@ +sys.exec.out.time {s},target.dyn_body.composite_body.state.trans.position[0] {m},target.dyn_body.composite_body.state.trans.position[1] {m},target.dyn_body.composite_body.state.trans.position[2] {m},target.dyn_body.composite_body.state.trans.velocity[0] {m/s},target.dyn_body.composite_body.state.trans.velocity[1] {m/s},target.dyn_body.composite_body.state.trans.velocity[2] {m/s} + 0, 1244540.529984511, 5655938.849999859, 3425643.21998937, -6003.833092400742, -1469.496054184508, 4590.511807697765 diff --git a/crates/astrodyn_verif_jeod/test_data/orbinit_0103_orbinit.csv b/crates/astrodyn_verif_jeod/test_data/orbinit_0103_orbinit.csv new file mode 100644 index 00000000..6eebce16 --- /dev/null +++ b/crates/astrodyn_verif_jeod/test_data/orbinit_0103_orbinit.csv @@ -0,0 +1,2 @@ +sys.exec.out.time {s},target.dyn_body.composite_body.state.trans.position[0] {m},target.dyn_body.composite_body.state.trans.position[1] {m},target.dyn_body.composite_body.state.trans.position[2] {m},target.dyn_body.composite_body.state.trans.velocity[0] {m/s},target.dyn_body.composite_body.state.trans.velocity[1] {m/s},target.dyn_body.composite_body.state.trans.velocity[2] {m/s} + 0, 1244471.940073538, 5655811.800012855, 3425518.879963227, -6003.553509412101, -1469.321975031956, 4590.577261703181 diff --git a/crates/astrodyn_verif_jeod/tests/tier3_sim_orbinit_docker.rs b/crates/astrodyn_verif_jeod/tests/tier3_sim_orbinit_docker.rs index 74354186..2210fbed 100644 --- a/crates/astrodyn_verif_jeod/tests/tier3_sim_orbinit_docker.rs +++ b/crates/astrodyn_verif_jeod/tests/tier3_sim_orbinit_docker.rs @@ -34,6 +34,8 @@ //! //! Scenarios: //! RUN_0001: ISS orbital elements in inertial frame (set01, time_periapsis) +//! RUN_0003: ISS orbital elements in inertial frame (set03, slr + true anomaly) +//! RUN_0103: STS-114 orbital elements in inertial frame (set03, slr + true anomaly) //! RUN_0101: STS-114 orbital elements in inertial frame (set01, time_periapsis) //! RUN_0201: ISS orbital elements in planet-fixed (pfix) frame (set01) //! RUN_0301: STS-114 orbital elements in planet-fixed (pfix) frame (set01) @@ -221,6 +223,37 @@ fn tier3_orbinit_docker_run0102_sts_inertial() { ); } +// ─────────────────────────────────────────────────────────────────────────── +// RUN_0003 / RUN_0103: set03 (semi-latus rectum + true-anomaly), inertial +// frame. Exercises `init_from_semi_latus_rectum_true_anomaly` directly — +// JEOD's `SlrEccIncAscnodeArgperTanom` branch uses the deck's semi-latus +// rectum as `semiparam` verbatim (no sma round-trip). Tolerances 1.05× observed. +// ─────────────────────────────────────────────────────────────────────────── + +#[test] +fn tier3_orbinit_docker_run0003_iss_inertial() { + // Observed: pos=5.21e-10 m, vel=2.27e-13 m/s (5% above → listed). + assert_orbinit_match( + sim_orbinit_docker::run_0003(), + "orbinit_0003_orbinit.csv", + "RUN_0003 (ISS inertial set03, slr + true anomaly)", + 5.47e-10, + 2.39e-13, + ); +} + +#[test] +fn tier3_orbinit_docker_run0103_sts_inertial() { + // Observed: pos=1.40e-9 m, vel=9.37e-13 m/s (5% above → listed). + assert_orbinit_match( + sim_orbinit_docker::run_0103(), + "orbinit_0103_orbinit.csv", + "RUN_0103 (STS-114 inertial set03, slr + true anomaly)", + 1.47e-9, + 9.84e-13, + ); +} + // ─────────────────────────────────────────────────────────────────────────── // RUN_0201: ISS orbital elements in planet-fixed frame // ─────────────────────────────────────────────────────────────────────────── diff --git a/crates/astrodyn_verif_jeod_fixtures/src/body_init_fixtures.rs b/crates/astrodyn_verif_jeod_fixtures/src/body_init_fixtures.rs index 432bcf51..eca1f328 100644 --- a/crates/astrodyn_verif_jeod_fixtures/src/body_init_fixtures.rs +++ b/crates/astrodyn_verif_jeod_fixtures/src/body_init_fixtures.rs @@ -28,7 +28,8 @@ //! "reference_inertial": {"position": [...], "velocity": [...]} | null, //! "orbital_inits": [ //! {"name": "trans_Orbit_inertial_body_set01", -//! "semi_major_axis": ..., "eccentricity": ..., "inclination": ..., +//! "semi_major_axis": ... | null, "semi_latus_rectum": ... | null, +//! "eccentricity": ..., "inclination": ..., //! "ascending_node": ..., "arg_periapsis": ..., //! "time_periapsis": ... | null, //! "mean_anomaly": ... | null, @@ -85,8 +86,14 @@ pub struct ReferenceStateRecord { pub struct OrbitalInitRecord { /// Vehicle name as recorded in the JEOD `Modified_data/*.py` source. pub name: String, - /// Semi-major axis in metres. - pub semi_major_axis: f64, + /// Semi-major axis in metres, when the JEOD source provides it + /// (sma-parameterized sets: 01/02/10). `None` for slr-parameterized + /// sets (set03), which carry `semi_latus_rectum` instead. + pub semi_major_axis: Option, + /// Semi-latus rectum in metres, when the JEOD source provides it + /// (set03 `SlrEccIncAscnodeArgperTanom`). `None` for sma-parameterized + /// sets. + pub semi_latus_rectum: Option, /// Orbital eccentricity (dimensionless). pub eccentricity: f64, /// Inclination in radians. @@ -262,8 +269,31 @@ pub(crate) fn parse_bundle_json(s: &str) -> Result { fn parse_orbital_init_entry(entry: &str) -> Result { let name = parse_str_field(entry, "name") .ok_or_else(|| format!("orbital_inits entry missing \"name\": {entry}"))?; - let semi_major_axis = parse_num_field(entry, "semi_major_axis") - .ok_or_else(|| format!("orbital_inits[{name}]: missing semi_major_axis"))?; + // Exactly one of `semi_major_axis` / `semi_latus_rectum` is present per + // JEOD set: sets 01/02/10 carry sma; set03 carries slr. Both are stored + // as nullable so the parser tolerates either shape; the converter that + // consumes the record asserts the field it needs is present. The two are + // mutually exclusive — reject both missing *and* both present. + let semi_major_axis = parse_opt_num_field(entry, "semi_major_axis"); + let semi_latus_rectum = parse_opt_num_field(entry, "semi_latus_rectum"); + match (semi_major_axis, semi_latus_rectum) { + (None, None) => { + return Err(format!( + "orbital_inits[{name}]: missing both semi_major_axis and semi_latus_rectum \ + (exactly one is required). Regenerate with: cargo run -p astrodyn_verif_jeod \ + --bin extract_body_init -- --jeod-home $JEOD_HOME" + )); + } + (Some(_), Some(_)) => { + return Err(format!( + "orbital_inits[{name}]: both semi_major_axis and semi_latus_rectum present \ + (exactly one is required; they are mutually exclusive per JEOD set). \ + Regenerate with: cargo run -p astrodyn_verif_jeod \ + --bin extract_body_init -- --jeod-home $JEOD_HOME" + )); + } + _ => {} + } let eccentricity = parse_num_field(entry, "eccentricity") .ok_or_else(|| format!("orbital_inits[{name}]: missing eccentricity"))?; let inclination = parse_num_field(entry, "inclination") @@ -281,6 +311,7 @@ fn parse_orbital_init_entry(entry: &str) -> Result { Ok(OrbitalInitRecord { name, semi_major_axis, + semi_latus_rectum, eccentricity, inclination, ascending_node, @@ -516,6 +547,20 @@ mod tests { "true_anomaly": null, "planet_name": "Earth", "reference_frame": "Earth.inertial" + }, + { + "name": "set_c", + "semi_major_axis": null, + "semi_latus_rectum": 6.73e6, + "eccentricity": 0.0013, + "inclination": 0.9, + "ascending_node": 0.86, + "arg_periapsis": 1.75, + "time_periapsis": null, + "mean_anomaly": null, + "true_anomaly": 5.23, + "planet_name": "Earth", + "reference_frame": "Earth.inertial" } ], "trans_states": [ @@ -537,15 +582,23 @@ mod tests { assert_eq!(r.position, [1.0, 2.0, 3.0]); assert_eq!(r.velocity, [4.0, 5.0, 6.0]); - assert_eq!(b.orbital_inits.len(), 2); + assert_eq!(b.orbital_inits.len(), 3); let a = &b.orbital_inits[0]; assert_eq!(a.name, "set_a"); + assert_eq!(a.semi_major_axis, Some(6.7e6)); + assert_eq!(a.semi_latus_rectum, None); assert_eq!(a.time_periapsis, Some(4581.96)); assert_eq!(a.mean_anomaly, None); let bb = &b.orbital_inits[1]; assert_eq!(bb.name, "set_b"); assert_eq!(bb.time_periapsis, None); assert_eq!(bb.mean_anomaly, Some(0.5)); + // set03-style: slr-parameterized, sma absent, true anomaly present. + let cc = &b.orbital_inits[2]; + assert_eq!(cc.name, "set_c"); + assert_eq!(cc.semi_major_axis, None); + assert_eq!(cc.semi_latus_rectum, Some(6.73e6)); + assert_eq!(cc.true_anomaly, Some(5.23)); assert_eq!(b.trans_states.len(), 1); let t = &b.trans_states[0]; @@ -579,4 +632,35 @@ mod tests { let err = parse_bundle_json(json).unwrap_err(); assert!(err.contains("unsupported schema_version 999"), "got: {err}"); } + + #[test] + fn rejects_orbital_init_with_both_sma_and_slr() { + // `semi_major_axis` and `semi_latus_rectum` are mutually exclusive per + // JEOD set; a fixture carrying both is malformed. + let json = r#"{ + "schema_version": 1, + "vehicle": "TEST", + "reference_inertial": null, + "orbital_inits": [ + { + "name": "set_bad", + "semi_major_axis": 6.7e6, + "semi_latus_rectum": 6.73e6, + "eccentricity": 0.001, + "inclination": 0.9, + "ascending_node": 0.86, + "arg_periapsis": 1.75, + "time_periapsis": null, + "mean_anomaly": null, + "true_anomaly": 5.23, + "planet_name": "Earth", + "reference_frame": "Earth.inertial" + } + ], + "trans_states": [] +}"#; + let err = parse_bundle_json(json).unwrap_err(); + assert!(err.contains("both"), "got: {err}"); + assert!(err.contains("mutually exclusive"), "got: {err}"); + } } diff --git a/crates/astrodyn_verif_jeod_fixtures/src/orbital_init.rs b/crates/astrodyn_verif_jeod_fixtures/src/orbital_init.rs index d7e41e11..8918de23 100644 --- a/crates/astrodyn_verif_jeod_fixtures/src/orbital_init.rs +++ b/crates/astrodyn_verif_jeod_fixtures/src/orbital_init.rs @@ -37,8 +37,14 @@ use crate::body_init_fixtures::{ /// - `"s"` -> seconds (no conversion) #[derive(Debug, Clone)] pub struct OrbitalInitData { - /// Semi-major axis in metres (converted from km in the source). - pub semi_major_axis: f64, + /// Semi-major axis in metres (converted from km in the source), when the + /// JEOD set provides it (sets 01/02/10). `None` for set03, which carries + /// `semi_latus_rectum` instead. + pub semi_major_axis: Option, + /// Semi-latus rectum in metres (converted from km in the source), when the + /// JEOD set provides it (set03 `SlrEccIncAscnodeArgperTanom`). `None` for + /// sma-parameterized sets. + pub semi_latus_rectum: Option, /// Eccentricity (dimensionless). pub eccentricity: f64, /// Inclination in radians (converted from degrees in the source). @@ -91,6 +97,7 @@ impl From<&OrbitalInitRecord> for OrbitalInitData { fn from(rec: &OrbitalInitRecord) -> Self { OrbitalInitData { semi_major_axis: rec.semi_major_axis, + semi_latus_rectum: rec.semi_latus_rectum, eccentricity: rec.eccentricity, inclination: rec.inclination, ascending_node: rec.ascending_node, @@ -125,6 +132,7 @@ pub fn parse_orbital_init_py(content: &str) -> Result = None; + let mut semi_latus_rectum: Option = None; let mut eccentricity: Option = None; let mut inclination: Option = None; let mut ascending_node: Option = None; @@ -148,6 +156,7 @@ pub fn parse_orbital_init_py(content: &str) -> Result semi_major_axis = Some(val), + "semi_latus_rectum" => semi_latus_rectum = Some(val), "eccentricity" => eccentricity = Some(val), "inclination" => inclination = Some(val), "ascending_node" => ascending_node = Some(val), @@ -169,6 +178,7 @@ pub fn parse_orbital_init_py(content: &str) -> Result semi_major_axis = Some(val * 1000.0), // assume km + "semi_latus_rectum" => semi_latus_rectum = Some(val * 1000.0), // assume km "eccentricity" => eccentricity = Some(val), "inclination" => inclination = Some(val.to_radians()), // assume degrees "ascending_node" => ascending_node = Some(val.to_radians()), @@ -193,11 +203,30 @@ pub fn parse_orbital_init_py(content: &str) -> Result { + return Err(BodyInitFixtureError::malformed( + "missing both semi_major_axis and semi_latus_rectum (exactly one is required)" + .to_string(), + )); + } + (Some(_), Some(_)) => { + return Err(BodyInitFixtureError::malformed( + "both semi_major_axis and semi_latus_rectum present (exactly one is required; \ + they are mutually exclusive per JEOD set)" + .to_string(), + )); + } + _ => {} + } Ok(OrbitalInitRecord { name: String::new(), // filled in by extract_body_init - semi_major_axis: semi_major_axis.ok_or_else(|| { - BodyInitFixtureError::malformed("missing semi_major_axis".to_string()) - })?, + semi_major_axis, + semi_latus_rectum, eccentricity: eccentricity .ok_or_else(|| BodyInitFixtureError::malformed("missing eccentricity".to_string()))?, inclination: inclination @@ -369,7 +398,8 @@ vehicle.set01.subject.planet_name = "Earth" vehicle.set01.subject.orbit_frame_name = "Earth.inertial" "#; let rec = parse_orbital_init_py(py).unwrap(); - assert!((rec.semi_major_axis - 6_732_901.201_52).abs() < 1e-6); + assert!((rec.semi_major_axis.unwrap() - 6_732_901.201_52).abs() < 1e-6); + assert_eq!(rec.semi_latus_rectum, None); assert!((rec.eccentricity - 0.00129073350).abs() < 1e-12); assert!((rec.inclination - 51.670450765_f64.to_radians()).abs() < 1e-12); assert_eq!(rec.planet_name, "Earth"); @@ -377,6 +407,53 @@ vehicle.set01.subject.orbit_frame_name = "Earth.inertial" assert!((rec.time_periapsis.unwrap() - 4581.96167293).abs() < 1e-9); } + #[test] + fn parse_orbital_init_py_set03_slr_true_anomaly() { + // JEOD set03 (`SlrEccIncAscnodeArgperTanom`): semi_latus_rectum + + // true_anomaly, no semi_major_axis. Values are the ISS set03 deck. + let py = r#" + vehicle.orb_init.arg_periapsis = trick.attach_units( "degree",100.582445989) + vehicle.orb_init.eccentricity = 0.00129073350 + vehicle.orb_init.inclination = trick.attach_units( "degree",51.670450765) + vehicle.orb_init.ascending_node = trick.attach_units( "degree",49.708417385) + vehicle.orb_init.semi_latus_rectum = trick.attach_units( "km",6732.88998455) + vehicle.orb_init.true_anomaly = trick.attach_units( "degree",299.884499026) + vehicle.orb_init.planet_name = "Earth" + vehicle.orb_init.orbit_frame_name = "Earth.inertial" +"#; + let rec = parse_orbital_init_py(py).unwrap(); + assert_eq!(rec.semi_major_axis, None); + assert!((rec.semi_latus_rectum.unwrap() - 6_732_889.984_55).abs() < 1e-6); + assert!((rec.eccentricity - 0.00129073350).abs() < 1e-12); + assert!((rec.true_anomaly.unwrap() - 299.884499026_f64.to_radians()).abs() < 1e-12); + assert_eq!(rec.time_periapsis, None); + assert_eq!(rec.mean_anomaly, None); + } + + #[test] + fn parse_orbital_init_py_rejects_both_sma_and_slr() { + // `semi_major_axis` and `semi_latus_rectum` are mutually exclusive per + // JEOD set; a deck carrying both is malformed. + let py = r#" + vehicle.orb_init.arg_periapsis = trick.attach_units( "degree",100.582445989) + vehicle.orb_init.eccentricity = 0.00129073350 + vehicle.orb_init.inclination = trick.attach_units( "degree",51.670450765) + vehicle.orb_init.ascending_node = trick.attach_units( "degree",49.708417385) + vehicle.orb_init.semi_major_axis = trick.attach_units( "km",6732.90120152) + vehicle.orb_init.semi_latus_rectum = trick.attach_units( "km",6732.88998455) + vehicle.orb_init.true_anomaly = trick.attach_units( "degree",299.884499026) + vehicle.orb_init.planet_name = "Earth" + vehicle.orb_init.orbit_frame_name = "Earth.inertial" +"#; + let err = parse_orbital_init_py(py).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("both"), "unexpected error: {msg}"); + assert!( + msg.contains("mutually exclusive"), + "unexpected error: {msg}" + ); + } + #[test] fn parse_trans_state_py_attach_units() { let py = r#" diff --git a/crates/astrodyn_verif_parity/tests/bevy_parity_orbinit_docker.rs b/crates/astrodyn_verif_parity/tests/bevy_parity_orbinit_docker.rs index c663fbab..e9b4b6d7 100644 --- a/crates/astrodyn_verif_parity/tests/bevy_parity_orbinit_docker.rs +++ b/crates/astrodyn_verif_parity/tests/bevy_parity_orbinit_docker.rs @@ -38,6 +38,16 @@ fn bevy_parity_orbinit_docker_run_0102() { sim_orbinit_docker::run_0102().run_and_assert_parity::(); } +#[test] +fn bevy_parity_orbinit_docker_run_0003() { + sim_orbinit_docker::run_0003().run_and_assert_parity::(); +} + +#[test] +fn bevy_parity_orbinit_docker_run_0103() { + sim_orbinit_docker::run_0103().run_and_assert_parity::(); +} + #[test] fn bevy_parity_orbinit_docker_run_0201() { sim_orbinit_docker::run_0201().run_and_assert_parity::(); diff --git a/src/lib.rs b/src/lib.rs index 42c767a2..dce4c324 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -221,7 +221,8 @@ pub use astrodyn_dynamics::{ // orbital-element / LVLH initialization paths used by mission init code // and JEOD parity tests. pub use astrodyn_dynamics::body_init::{ - init_from_mean_anomaly, init_from_orbital_elements_typed, init_rot_from_lvlh, + init_from_mean_anomaly, init_from_orbital_elements_typed, + init_from_semi_latus_rectum_true_anomaly, init_rot_from_lvlh, }; // astrodyn_dynamics::kinematic_propagation: input struct paired with the diff --git a/trick/generate_references.sh b/trick/generate_references.sh index 5616b270..f222a02d 100755 --- a/trick/generate_references.sh +++ b/trick/generate_references.sh @@ -699,6 +699,9 @@ run_orbinit_group() { # set02 mean-anomaly parameterization (ISS + STS, inertial) "SET_test/RUN_0002:orbinit_0002:orbinit_0002_orbinit.csv" "SET_test/RUN_0102:orbinit_0102:orbinit_0102_orbinit.csv" + # set03 semi-latus-rectum + true-anomaly parameterization (ISS + STS, inertial) + "SET_test/RUN_0003:orbinit_0003:orbinit_0003_orbinit.csv" + "SET_test/RUN_0103:orbinit_0103:orbinit_0103_orbinit.csv" ) local needs_build=0 for entry in "${RUNS[@]}"; do