Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions chained_eclipse/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import math
from dataclasses import asdict, dataclass, field
from typing import Any, Callable

Expand All @@ -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 independently constrained bodies and massless control integrations.
"""

semimajor_axis_km: float = 180_000.0
Expand All @@ -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 non-negative value")

def to_dict(self) -> dict[str, Any]:
return asdict(self)

Expand Down Expand Up @@ -136,4 +162,3 @@ def position(self, jd: np.ndarray | float) -> np.ndarray:
if state.ndim == 1:
return state[:3]
return state[..., :3]

51 changes: 49 additions & 2 deletions tests/test_orbital_dynamics.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -17,6 +19,52 @@ 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


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"),
[
(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=-1.0)


def test_element_state_round_trip() -> None:
elements = OrbitalElements(
longitude_ascending_node_deg=119.7,
Expand All @@ -43,4 +91,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