From d61a36086af5951db8845115d45c0a058fb3f5af Mon Sep 17 00:00:00 2001 From: Josh <162485031+wiyth00@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:54:32 -0400 Subject: [PATCH 1/3] Fix configured second-moon mass consistency --- chained_eclipse/models.py | 29 ++++++++++++++++++++-- tests/test_orbital_dynamics.py | 45 ++++++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/chained_eclipse/models.py b/chained_eclipse/models.py index bd07287..7c29ce3 100644 --- a/chained_eclipse/models.py +++ b/chained_eclipse/models.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass, field +import math from typing import Any, Callable import numpy as np @@ -14,11 +15,25 @@ ) +def spherical_mass_kg(radius_km: float, density_kg_m3: float) -> float: + """Return the mass of a spherical body from its radius and bulk density.""" + + if not math.isfinite(radius_km) or radius_km <= 0.0: + raise ValueError("radius_km must be a finite positive value") + if not math.isfinite(density_kg_m3) or density_kg_m3 <= 0.0: + raise ValueError("density_kg_m3 must be a finite positive value") + return 4.0 / 3.0 * math.pi * (radius_km * 1_000.0) ** 3 * density_kg_m3 + + @dataclass(slots=True) class OrbitalElements: """Osculating Earth-centred elements at the configured epoch. - Angular fields are degrees in the mean J2000 ecliptic frame. + Angular fields are degrees in the mean J2000 ecliptic frame. When callers + customize the radius or density but leave ``mass_kg`` at the package + default, the mass is recalculated to keep the physical parameters + internally consistent. An explicitly supplied non-default mass is retained + for callers modelling a body whose mass is independently constrained. """ semimajor_axis_km: float = 180_000.0 @@ -33,6 +48,17 @@ class OrbitalElements: mass_kg: float = SECOND_MOON_MASS_KG frame: str = "mean_ecliptic_J2000" + def __post_init__(self) -> None: + derived_mass = spherical_mass_kg(self.radius_km, self.density_kg_m3) + uses_custom_bulk_properties = ( + self.radius_km != SECOND_MOON_RADIUS_KM + or self.density_kg_m3 != SECOND_MOON_DENSITY_KG_M3 + ) + if uses_custom_bulk_properties and self.mass_kg == SECOND_MOON_MASS_KG: + self.mass_kg = derived_mass + elif not math.isfinite(self.mass_kg) or self.mass_kg <= 0.0: + raise ValueError("mass_kg must be a finite positive value") + def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -136,4 +162,3 @@ def position(self, jd: np.ndarray | float) -> np.ndarray: if state.ndim == 1: return state[:3] return state[..., :3] - diff --git a/tests/test_orbital_dynamics.py b/tests/test_orbital_dynamics.py index 3db0831..a14b883 100644 --- a/tests/test_orbital_dynamics.py +++ b/tests/test_orbital_dynamics.py @@ -1,10 +1,12 @@ """Checks for mass calculation, element conversion, and two-body propagation.""" +import math + import numpy as np import pytest from chained_eclipse.constants import SECOND_MOON_MASS_KG -from chained_eclipse.models import OrbitalElements +from chained_eclipse.models import OrbitalElements, spherical_mass_kg from chained_eclipse.orbital_dynamics import ( elements_to_state, mean_motion_rad_s, @@ -17,6 +19,46 @@ def test_second_moon_mass_matches_radius_density_calculation() -> None: assert SECOND_MOON_MASS_KG == pytest.approx(8.2430310159e21, rel=1e-10) +def test_custom_bulk_properties_recalculate_default_mass() -> None: + elements = OrbitalElements(radius_km=1_000.0, density_kg_m3=2_500.0) + + assert elements.mass_kg == pytest.approx(spherical_mass_kg(1_000.0, 2_500.0)) + assert elements.mass_kg != SECOND_MOON_MASS_KG + + +def test_explicit_independently_constrained_mass_is_retained() -> None: + elements = OrbitalElements( + radius_km=1_000.0, + density_kg_m3=2_500.0, + mass_kg=9.0e21, + ) + + assert elements.mass_kg == 9.0e21 + + +@pytest.mark.parametrize( + ("radius_km", "density_kg_m3"), + [ + (0.0, 2_500.0), + (-1.0, 2_500.0), + (math.inf, 2_500.0), + (1_000.0, 0.0), + (1_000.0, -1.0), + (1_000.0, math.nan), + ], +) +def test_invalid_bulk_properties_are_rejected( + radius_km: float, density_kg_m3: float +) -> None: + with pytest.raises(ValueError): + OrbitalElements(radius_km=radius_km, density_kg_m3=density_kg_m3) + + +def test_invalid_explicit_mass_is_rejected() -> None: + with pytest.raises(ValueError, match="mass_kg"): + OrbitalElements(mass_kg=0.0) + + def test_element_state_round_trip() -> None: elements = OrbitalElements( longitude_ascending_node_deg=119.7, @@ -43,4 +85,3 @@ def test_two_body_state_repeats_after_one_orbital_period() -> None: final = propagate_two_body(elements, period) assert np.linalg.norm(final[:3] - initial[:3]) < 1e-6 assert np.linalg.norm(final[3:] - initial[3:]) < 1e-10 - From ca834007802ea08f3658677a25d758a8f4104c6e Mon Sep 17 00:00:00 2001 From: Josh <162485031+wiyth00@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:58:55 -0400 Subject: [PATCH 2/3] Fix Ruff import ordering --- chained_eclipse/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chained_eclipse/models.py b/chained_eclipse/models.py index 7c29ce3..98c36e8 100644 --- a/chained_eclipse/models.py +++ b/chained_eclipse/models.py @@ -2,8 +2,8 @@ from __future__ import annotations -from dataclasses import asdict, dataclass, field import math +from dataclasses import asdict, dataclass, field from typing import Any, Callable import numpy as np From 8fe52cadaad9c47775201dae7885fa1e98edfb5a Mon Sep 17 00:00:00 2001 From: Josh <162485031+wiyth00@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:02:21 -0400 Subject: [PATCH 3/3] Preserve massless control configurations --- chained_eclipse/models.py | 6 +++--- tests/test_orbital_dynamics.py | 8 +++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/chained_eclipse/models.py b/chained_eclipse/models.py index 98c36e8..0bf30d0 100644 --- a/chained_eclipse/models.py +++ b/chained_eclipse/models.py @@ -33,7 +33,7 @@ class OrbitalElements: customize the radius or density but leave ``mass_kg`` at the package default, the mass is recalculated to keep the physical parameters internally consistent. An explicitly supplied non-default mass is retained - for callers modelling a body whose mass is independently constrained. + for independently constrained bodies and massless control integrations. """ semimajor_axis_km: float = 180_000.0 @@ -56,8 +56,8 @@ def __post_init__(self) -> None: ) if uses_custom_bulk_properties and self.mass_kg == SECOND_MOON_MASS_KG: self.mass_kg = derived_mass - elif not math.isfinite(self.mass_kg) or self.mass_kg <= 0.0: - raise ValueError("mass_kg must be a finite positive value") + elif not math.isfinite(self.mass_kg) or self.mass_kg < 0.0: + raise ValueError("mass_kg must be a finite non-negative value") def to_dict(self) -> dict[str, Any]: return asdict(self) diff --git a/tests/test_orbital_dynamics.py b/tests/test_orbital_dynamics.py index a14b883..a34ddc3 100644 --- a/tests/test_orbital_dynamics.py +++ b/tests/test_orbital_dynamics.py @@ -36,6 +36,12 @@ def test_explicit_independently_constrained_mass_is_retained() -> None: assert elements.mass_kg == 9.0e21 +def test_massless_control_is_retained() -> None: + elements = OrbitalElements(mass_kg=0.0) + + assert elements.mass_kg == 0.0 + + @pytest.mark.parametrize( ("radius_km", "density_kg_m3"), [ @@ -56,7 +62,7 @@ def test_invalid_bulk_properties_are_rejected( def test_invalid_explicit_mass_is_rejected() -> None: with pytest.raises(ValueError, match="mass_kg"): - OrbitalElements(mass_kg=0.0) + OrbitalElements(mass_kg=-1.0) def test_element_state_round_trip() -> None: