From 5b4f564aace707fed1110abd9a0812a0f2443f97 Mon Sep 17 00:00:00 2001 From: Steven Michael Date: Fri, 3 Jul 2026 22:05:10 -0400 Subject: [PATCH 1/5] ci: add mypy.stubtest to verify type stubs match PyO3 bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-written .pyi stubs had never been type-checked and had drifted from the compiled bindings — e.g. `quaternion.rotz(theta=0.5)` (what the stub advertised) raised TypeError at runtime because PyO3 named the arg `theta_rad`. Add a stubtest gate to the `python bindings test` CI job and bring the stubs back into agreement with the runtime. CI check: - New stubtest step + `mypy` in the `[test]` extra. Run with `--ignore-positional-only --ignore-disjoint-bases` plus python/stubtest_allowlist.txt, which suppresses only systematic PyO3 idioms (constructors, pickle dunders, native submodules, final classes, stub-only type aliases, __all__). No real drift is allowlisted. Stub build fixes (stubs now type-check): - Removed 22 illegal overload-implementation blocks across 5 .pyi files. - Reunited the `time.__add__` overload group split by other methods. - Added `_Time` alias to dodge the `time`-property annotation shadowing in satstate/propresult. Drift fixes (stub-only, no runtime change): - rotx/y/z theta->theta_rad, from_gps_week_and_second sec->seconds, sgp4 tm->time, satstate.propagate time->timedur, from_rotation_matrix mat->dcm; removed phantom as_gregorian(scale=); fixed sgp4_opsmode.improved; added itrfcoord.height, time.add_utc_days, weekday.Invalid. Keyword renames (public API — positional calls unaffected): - duration.from_hours/from_minutes/from_seconds: d -> hours/minutes/seconds - time.from_string s->string, from_unixtime t->unixtime, from_rfc3339 s->rfc3339, from_datetime tm->dt, strftime fmt->format, strptime (s,fmt)->(date_string,format) - kepler.from_pv (r,v)->(pos,vel) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 10 ++ CHANGELOG.md | 32 ++++ pyproject.toml | 2 +- python/satkit/density.pyi | 30 ---- python/satkit/frametransform.pyi | 261 ------------------------------- python/satkit/moon.pyi | 48 ------ python/satkit/satkit.pyi | 152 ++++++++---------- python/satkit/sun.pyi | 45 ------ python/src/pyduration.rs | 18 +-- python/src/pyinstant.rs | 34 ++-- python/src/pykepler.rs | 8 +- python/stubtest_allowlist.txt | 71 +++++++++ 12 files changed, 213 insertions(+), 498 deletions(-) create mode 100644 python/stubtest_allowlist.txt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a103a1e..21d5e6a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -231,3 +231,13 @@ jobs: - name: Run Python tests run: pytest python/test/ + + # Verify the hand-written .pyi stubs still match the compiled bindings. + # --ignore-positional-only / --ignore-disjoint-bases switch off two whole + # PyO3-idiom categories; the rest is handled by the allowlist. + - name: Check type stubs match bindings (stubtest) + run: | + python -m mypy.stubtest satkit \ + --allowlist python/stubtest_allowlist.txt \ + --ignore-positional-only \ + --ignore-disjoint-bases diff --git a/CHANGELOG.md b/CHANGELOG.md index a80ce06..a91adba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,38 @@ # Changelog +## Unreleased + +### Added + +- **`mypy.stubtest` in CI.** The `python bindings test` job now verifies that + the hand-written `.pyi` type stubs match the compiled PyO3 bindings. Two CLI + flags (`--ignore-positional-only`, `--ignore-disjoint-bases`) plus + `python/stubtest_allowlist.txt` suppress systematic PyO3 idioms (constructors, + final classes, native submodules); everything else must agree. + +### Fixed + +- **Type stubs now type-check and match the runtime.** The `.pyi` files had + never been checked and contained illegal overload-implementation blocks, a + `time.__add__` overload group split by other methods, and `time`-as-annotation + shadowing. Filled in stub gaps (`itrfcoord.height`, `time.add_utc_days`, + `weekday.Invalid`) and removed/fixed a phantom `time.as_gregorian(scale=)` + parameter and a mis-declared `sgp4_opsmode.improved` property. + +### Changed + +- **Renamed keyword arguments on several bindings** so the accepted Python + keyword matches its documentation (the stubs previously advertised names the + runtime rejected). Positional calls are unaffected; only callers passing these + by the *old* keyword must update: + - `duration.from_hours` / `from_minutes` / `from_seconds`: `d` → `hours` / `minutes` / `seconds` + - `time.from_string`: `s` → `string`; `time.from_unixtime`: `t` → `unixtime` + - `time.from_rfc3339`: `s` → `rfc3339`; `time.from_datetime`: `tm` → `dt` + - `time.strftime`: `fmt` → `format`; `time.strptime`: `(s, fmt)` → `(date_string, format)` + - `kepler.from_pv`: `(r, v)` → `(pos, vel)` + + ## 0.20.2 - 2026-07-03 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 0e693b3..4b3bfe3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] [project.optional-dependencies] -test = ["pytest", "xmltodict"] +test = ["pytest", "xmltodict", "mypy"] [tool.setuptools] license-files = ["LICENSE-MIT", "LICENSE-APACHE"] diff --git a/python/satkit/density.pyi b/python/satkit/density.pyi index 7e319df..d1d7d5c 100644 --- a/python/satkit/density.pyi +++ b/python/satkit/density.pyi @@ -70,33 +70,3 @@ def nrlmsise( """ ... -def nrlmsise(*args, **kwargs): - """ - NRL MSISE-00 Atmosphere Density Model - - - - or for more detail: - - - Args: - - itrf (satkit.itrfcoord): position at which to compute density & temperature - time (satkit.time|numpy.ndarray|list): Optional instant(s) at which to compute density & temperature. - "Space weather" data at this time will be used in model - computation. Note: at satellite altitudes, density can - change by > 10 X depending on solar cycle - - Returns: - (rho, T) where rho is mass density in kg/m^3 and T is temperature in Kelvin - - Example: - ```python - t = satkit.time(2024, 1, 1) - coord = satkit.itrfcoord(latitude_deg=0, longitude_deg=0, altitude=400e3) - rho, temp = satkit.density.nrlmsise(coord, t) - print(f"Density: {rho:.2e} kg/m^3") - print(f"Temperature: {temp:.1f} K") - ``` - """ - ... diff --git a/python/satkit/frametransform.pyi b/python/satkit/frametransform.pyi index 472e868..049c254 100644 --- a/python/satkit/frametransform.pyi +++ b/python/satkit/frametransform.pyi @@ -61,30 +61,6 @@ def gmst( """ ... -def gmst(*args, **kwargs): - """Greenwich Mean Sidereal Time - - Notes: - - GMST is the angle between the vernal equinox and the Greenwich meridian - - Vallado algorithm 15 - - GMST = 67310.5481 + (876600h + 8640184.812866) * tᵤₜ₁ * (0.983104 + tᵤₜ₁ * −6.2e−6) - - Args: - tm (satkit.time | datetime.datetime): scalar time at which to calculate output - - Returns: - Greenwich Mean Sidereal Time, radians, at input time - - Example: - ```python - import math - t = satkit.time(2024, 1, 1) - theta = satkit.frametransform.gmst(t) - print(f"GMST: {math.degrees(theta):.4f} deg") - ``` - """ - ... - @typing.overload def eqeq(tm: TimeScalar) -> float: """Equation of the Equinoxes @@ -114,20 +90,6 @@ def eqeq( """ ... -def eqeq(*args, **kwargs): - """Equation of the Equinoxes - - The equation of the equinoxes is the difference between apparent and mean - sidereal time (GAST - GMST), arising from nutation of the Earth's axis. - - Args: - tm (satkit.time | datetime.datetime): scalar or array of times - - Returns: - Equation of the equinoxes, radians, at input time(s) - """ - ... - @typing.overload def gast( tm: TimeScalar, @@ -162,23 +124,6 @@ def gast( """ ... -def gast(*args, **kwargs): - """Greenwich Apparent Sidereal Time - - Args: - tm (satkit.time): scalar, list, or numpy array of astro.time or datetime.datetime representing time at which to calculate output - - Returns: - Greenwich apparent sidereal time, radians, at input time(s) - - Example: - ```python - t = satkit.time(2024, 1, 1) - theta = satkit.frametransform.gast(t) - ``` - """ - ... - @typing.overload def earth_rotation_angle( tm: TimeScalar, @@ -227,30 +172,6 @@ def earth_rotation_angle( """ ... -def earth_rotation_angle(*args, **kwargs): - """Earth Rotation Angle - - Notes: - - See: IERS Technical Note 36, Chapter 5, Equation 5.15 - - Calculation Details: - - Let t be UT1 Julian date - - let f be fractional component of t (fraction of day) - - ERA = 2𝜋 ((0.7790572732640 + f + 0.00273781191135448 * (t - 2451545.0)) - - Args: - tm (satkit.time|datetime.datetime: Time[s] at which to calculate Earth Rotation Angle - - Returns: - Earth Rotation Angle at input time[s] in radians - - Example: - ```python - t = satkit.time(2024, 1, 1) - era = satkit.frametransform.earth_rotation_angle(t) - ``` - """ - ... - @typing.overload def qitrf2tirs( tm: TimeScalar, @@ -279,17 +200,6 @@ def qitrf2tirs( """ ... -def qitrf2tirs(*args, **kwargs): - """Rotation from Terrestrial Intermediate Reference System to Celestial Intermediate Reference Systems - - Args: - tm (satkit.time | npt.ArrayLike[satkit.time] | datetime.datetime | npt.ArrayLike[datetime.datetime]): Time[s] at which to calculate the quaternion - - Returns: - Quaternion representing rotation from ITRF to TIRS at input time(s) - """ - ... - @typing.overload def qteme2gcrf( tm: TimeScalar, @@ -324,23 +234,6 @@ def qteme2gcrf( """ ... -def qteme2gcrf(*args, **kwargs): - """Rotation from True Equator Mean Equinox (TEME) to Geocentric Celestial Reference Frame (GCRF) - - Args: - tm (satkit.time| datetime.datetime ): Time[s] at which to calculate the quaternion - - Returns: - Quaternion representing rotation from TEME to GCRF at input time(s) - - Example: - ```python - t = satkit.time(2024, 1, 1) - q = satkit.frametransform.qteme2gcrf(t) - ``` - """ - ... - @typing.overload def qcirs2gcrf( tm: TimeScalar, @@ -369,17 +262,6 @@ def qcirs2gcrf( """ ... -def qcirs2gcrf(*args, **kwargs): - """Rotation from Celestial Intermediate Reference System to Geocentric Celestial Reference Frame - - Args: - tm (satkit.time | npt.ArrayLike[satkit.time] | datetime.datetime | npt.ArrayLike[datetime.datetime]): Time[s] at which to calculate the quaternion - - Returns: - Quaternion representing rotation from CIRS to GCRF at input time(s) - """ - ... - @typing.overload def qtirs2cirs( tm: TimeScalar, @@ -408,17 +290,6 @@ def qtirs2cirs( """ ... -def qtirs2cirs(*args, **kwargs): - """Rotation from Terrestrial Intermediate Reference System (TIRS) to the Celestial Intermediate Reference System (CIRS) - - Args: - tm (satkit.time | datetime.datetime): Time[s] at which to calculate the quaternion - - Returns: - Quaternion representing rotation from TIRS to CIRS at input time(s) - """ - ... - @typing.overload def qgcrf2itrf_approx( tm: TimeScalar, @@ -459,20 +330,6 @@ def qgcrf2itrf_approx( """ ... -def qgcrf2itrf_approx(*args, **kwargs): - """Quaternion representing approximate rotation from the Geocentric Celestial Reference Frame (GCRF) to the International Terrestrial Reference Frame (ITRF) - - Notes: - - Accurate to approx. 1 arcsec - - Args: - tm (satkit.time | datetime.datetime): Time[s] at which to calculate the quaternion - - Returns: - Quaternion representing rotation from GCRF to ITRF at input time(s) - """ - ... - @typing.overload def qitrf2gcrf_approx( tm: TimeScalar, @@ -513,20 +370,6 @@ def qitrf2gcrf_approx( """ ... -def qitrf2gcrf_approx(*args, **kwargs): - """Quaternion representing approximate rotation from the International Terrestrial Reference Frame (ITRF) to the Geocentric Celestial Reference Frame (GCRF) - - Notes: - - Accurate to approx. 1 arcsec - - Args: - tm (satkit.time | datetime.datetime): Time[s] at which to calculate the quaternion - - Returns: - Quaternion representing rotation from ITRF to GCRF at input time(s) - """ - ... - @typing.overload def qgcrf2itrf( tm: TimeScalar, @@ -591,41 +434,6 @@ def qgcrf2itrf( """ ... -def qgcrf2itrf(*args, **kwargs): - """Quaternion representing rotation from the Geocentric Celestial Reference Frame (GCRF) to the International Terrestrial Reference Frame (ITRF) - - Notes: - - Uses full IERS 2010 Conventions reduction (IAU 2006/2000A precession-nutation) - - See IERS Technical Note 36, Chapter 5 - - Does not include solid tides, ocean tides - - Very computationally expensive - - **Velocity transforms**: this quaternion rotates *position* vectors - between ITRF and GCRF but **is not sufficient for velocity** on - its own. ITRF is a rotating frame, so the velocity transform - picks up an extra ``omega_earth x r`` term (~470 m/s at LEO). - Use :func:`itrf_to_gcrf_state` / :func:`gcrf_to_itrf_state` for - full state (position + velocity) transforms. - - Args: - tm (satkit.time | datetime.datetime): Time[s] at which to calculate the quaternion - - Returns: - Quaternion representing rotation from GCRF to ITRF at input time(s) - - Example: - ```python - import numpy as np - - t = satkit.time(2024, 1, 1) - q = satkit.frametransform.qgcrf2itrf(t) - - # Rotate a GCRF position vector to ITRF - pos_gcrf = np.array([6.781e6, 0, 0]) - pos_itrf = q * pos_gcrf - ``` - """ - ... - @typing.overload def qitrf2gcrf( tm: TimeScalar, @@ -683,34 +491,6 @@ def qitrf2gcrf( """ ... -def qitrf2gcrf(*args, **kwargs): - """Quaternion representing rotation from the International Terrestrial Reference Frame (ITRF) to the Geocentric Celestial Reference Frame (GCRF) - - Notes: - - Uses full IERS 2010 Conventions reduction (IAU 2006/2000A precession-nutation) - - See IERS Technical Note 36, Chapter 5 - - Does not include solid tides, ocean tides - - Very computationally expensive - - **Velocity transforms**: this quaternion rotates *position* vectors - between ITRF and GCRF but **is not sufficient for velocity** on - its own. ITRF is a rotating frame, so the velocity transform - picks up an extra ``omega_earth x r`` term (~470 m/s at LEO). - Use :func:`itrf_to_gcrf_state` / :func:`gcrf_to_itrf_state` for - full state (position + velocity) transforms. - - Args: - tm (satkit.time datetime.datetime): Time[s] at which to calculate the quaternion - Returns: - Quaternion representing rotation from ITRF to GCRF at input time(s) - - Example: - ```python - t = satkit.time(2024, 1, 1) - q = satkit.frametransform.qitrf2gcrf(t) - ``` - """ - ... - @typing.overload def qteme2itrf( tm: TimeScalar, @@ -759,33 +539,6 @@ def qteme2itrf( """ ... -def qteme2itrf(*args, **kwargs): - """Quaternion representing rotation from the True Equator Mean Equinox (TEME) frame to the International Terrestrial Reference Frame (ITRF) - - Notes: - - This is equation 3-90 in Vallado - - TEME is the output frame of the SGP4 propagator used to compute position from two-line element sets. - - Args: - tm (satkit.time | datetime.datetime): Time[s] at which to calculate the quaternion - - Returns: - Quaternion representing rotation from TEME to ITRF at input time(s) - - Example: - ```python - import numpy as np - - t = satkit.time(2024, 1, 1) - q = satkit.frametransform.qteme2itrf(t) - - # Convert SGP4 TEME output to ITRF - pos_teme = np.array([6.781e6, 0, 0]) - pos_itrf = q * pos_teme - ``` - """ - ... - def earth_orientation_params( time: time, ) -> tuple[float, float, float, float, float, float]: @@ -836,13 +589,6 @@ def qmod2gcrf( """Quaternion rotating Mean-of-Date (MOD) → GCRF at the given times.""" ... -def qmod2gcrf(*args, **kwargs): - """Quaternion rotating Mean-of-Date (MOD) → GCRF at the given time(s). - - Mean-of-Date accounts for precession but not nutation. - """ - ... - @typing.overload def qtod2mod_approx(tm: TimeScalar) -> quaternion: """Approximate True-of-Date (TOD) → Mean-of-Date (MOD) rotation at the @@ -859,13 +605,6 @@ def qtod2mod_approx( """ ... -def qtod2mod_approx(*args, **kwargs): - """Approximate True-of-Date (TOD) → Mean-of-Date (MOD) rotation. - - Accounts for nutation only. - """ - ... - def to_gcrf( frame: frame, pos: npt.ArrayLike, diff --git a/python/satkit/moon.pyi b/python/satkit/moon.pyi index 93cf94e..e31ceda 100644 --- a/python/satkit/moon.pyi +++ b/python/satkit/moon.pyi @@ -91,33 +91,6 @@ def pos_gcrf( """ ... -def pos_gcrf(*args, **kwargs): - """ - Approximate Moon position in the GCRF Frame - - From Vallado Algorithm 31 - - Args: - time (satkit.time): time at which to compute position - - Returns: - 3-element numpy array representing moon position in GCRF frame - at given time. Units are meters - - Notes: - Accurate to 0.3 degree in ecliptic longitude, 0.2 degree in ecliptic latitude, - and 1275 km in range - - Example: - ```python - import numpy as np - t = satkit.time(2024, 1, 1) - moon = satkit.moon.pos_gcrf(t) - print(f"Moon distance: {np.linalg.norm(moon)/1e3:.0f} km") - ``` - """ - ... - @typing.overload def illumination(time: TimeScalar) -> float: """ @@ -151,10 +124,6 @@ def illumination(time: TimeArrayLike) -> list[float]: """ ... -def illumination(*args, **kwargs): - """Fractional illumination of moon (scalar or array of times).""" - ... - @typing.overload def phase(time: TimeScalar) -> float: """ @@ -181,10 +150,6 @@ def phase(time: TimeArrayLike) -> list[float]: """ ... -def phase(*args, **kwargs): - """Phase of moon in radians (scalar or array of times).""" - ... - @typing.overload def phase_name(time: TimeScalar) -> moonphase: """ @@ -211,16 +176,3 @@ def phase_name(time: TimeArrayLike) -> list[moonphase]: """ ... -def phase_name(*args, **kwargs): - """ - Phase name of moon (scalar or array of times). - - Example: - ```python - t = satkit.time(2024, 1, 1) - p = satkit.moon.phase_name(t) - print(p) - # e.g., moonphase.WaningGibbous - ``` - """ - ... diff --git a/python/satkit/satkit.pyi b/python/satkit/satkit.pyi index f0c6693..ce9120f 100644 --- a/python/satkit/satkit.pyi +++ b/python/satkit/satkit.pyi @@ -77,8 +77,6 @@ class TLE: def from_lines(lines: tuple[str, str, str]) -> TLE: ... @overload @staticmethod - def from_lines(lines: Sequence[str]) -> list[TLE] | TLE: ... - @staticmethod def from_lines(lines: Sequence[str]) -> list[TLE] | TLE: """Return a list of TLES loaded from input list of lines @@ -401,7 +399,7 @@ class TLE: def sgp4( tle: TLE | list[TLE] | dict, - tm: TimeInput, + time: TimeInput, **kwargs, ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: """SGP-4 propagator for TLE @@ -501,10 +499,8 @@ class sgp4_opsmode: afspc: ClassVar[int] """afspc (Air Force Space Command), the default""" - @property - def improved(self) -> int: - """Improved""" - ... + improved: ClassVar[int] + """improved""" class gravmodel: """ @@ -711,6 +707,9 @@ class weekday: Saturday: ClassVar[weekday] """Saturday""" + Invalid: ClassVar[weekday] + """Invalid weekday""" + class tlefitstatus: """ Termination status of the TLE non-linear least-squares fit performed by @@ -1005,7 +1004,7 @@ class time: ... @staticmethod - def from_string(str: str) -> time: + def from_string(string: str) -> time: """ Create a "time" object from input string @@ -1027,7 +1026,7 @@ class time: ... @staticmethod - def from_rfc3339(rfc: str) -> time: + def from_rfc3339(rfc3339: str) -> time: """Create a "time" object from input RFC 3339 string Args: @@ -1049,7 +1048,7 @@ class time: ... @staticmethod - def strptime(str: str, format: str) -> time: + def strptime(date_string: str, format: str) -> time: """ Create a "time" object from input string with given formatting @@ -1123,7 +1122,7 @@ class time: ... @staticmethod - def from_unixtime(ut: float) -> time: + def from_unixtime(unixtime: float) -> time: """Return a time object representing input unixtime Args: @@ -1143,7 +1142,7 @@ class time: ... @staticmethod - def from_gps_week_and_second(week: int, sec: float) -> time: + def from_gps_week_and_second(week: int, seconds: float) -> time: """Return a time object representing input GPS week and second Args: @@ -1204,13 +1203,10 @@ class time: ... def as_gregorian( - self, scale=timescale.UTC + self, ) -> tuple[int, int, int, int, int, float]: """Return tuple representing as UTC Gegorian date and time of the time object. - Args: - scale (timescale, optional): Time scale. Default is satkit.timescale.UTC - Returns: Tuple with 6 elements representing the Gregorian year, month, day, hour, minute, and second of the time object. Month is in range [1,12]. @@ -1306,6 +1302,20 @@ class time: """ ... + def add_utc_days(self, days: float) -> time: + """Return a new time offset by a whole number of UTC days. + + A UTC day is defined as exactly 86400 seconds, avoiding the ambiguity of + adding a "day" across a leap second. + + Args: + days (float): Number of UTC days to add + + Returns: + satkit.time: Time object offset by the given number of UTC days + """ + ... + def as_mjd(self, scale: timescale = timescale.UTC) -> float: """ Represent time instance as a Modified Julian Date @@ -1426,6 +1436,20 @@ class time: """ ... + @typing.overload + def __add__(self, other: npt.NDArray[Any]) -> npt.NDArray[Any]: + """ + Return a numpy array of time objects, with each object representing an element-wise addition of duration to the "self" time object + + Args: + other (npt.ArrayLike[Any]): array-like structure containing durations to add to the current time + + Returns: + Array of time objects representing the element-wise addition of durations to the current time + + """ + ... + def __le__(self, other: time) -> bool: """ Compare two time objects for less than or equal to @@ -1498,20 +1522,6 @@ class time: """ ... - @typing.overload - def __add__(self, other: npt.NDArray[Any]) -> npt.NDArray[Any]: - """ - Return a numpy array of time objects, with each object representing an element-wise addition of duration to the "self" time object - - Args: - other (npt.ArrayLike[Any]): array-like structure containing durations to add to the current time - - Returns: - Array of time objects representing the element-wise addition of durations to the current time - - """ - ... - @typing.overload def __sub__(self, other: duration) -> time: """ @@ -1649,7 +1659,7 @@ class duration: ... @staticmethod - def from_seconds(s: float) -> duration: + def from_seconds(seconds: float) -> duration: """Create duration object representing input number of seconds Args: @@ -1668,7 +1678,7 @@ class duration: ... @staticmethod - def from_minutes(m: float) -> duration: + def from_minutes(minutes: float) -> duration: """Create duration object representing input number of minutes Args: @@ -1680,7 +1690,7 @@ class duration: ... @staticmethod - def from_hours(h: float) -> duration: + def from_hours(hours: float) -> duration: """Create duration object representing input number of hours Args: @@ -1995,7 +2005,7 @@ class quaternion: @staticmethod def from_rotation_matrix( - mat: npt.NDArray[np.float64], + dcm: npt.NDArray[np.float64], ) -> quaternion: """Return quaternion representing identical rotation to input 3x3 rotation matrix @@ -2008,7 +2018,7 @@ class quaternion: ... @staticmethod - def rotx(theta) -> quaternion: + def rotx(theta_rad: float) -> quaternion: """Quaternion representing right-handed rotation of vector by "theta" radians about the xhat unit vector Args: @@ -2026,7 +2036,7 @@ class quaternion: ... @staticmethod - def roty(theta) -> quaternion: + def roty(theta_rad: float) -> quaternion: """Quaternion representing right-handed rotation of vector by "theta" radians about the yhat unit vector Args: @@ -2045,7 +2055,7 @@ class quaternion: ... @staticmethod - def rotz(theta) -> quaternion: + def rotz(theta_rad: float) -> quaternion: """Quaternion representing right-handed rotation of vector by "theta" radians about the zhat unit vector Args: @@ -2571,6 +2581,11 @@ class itrfcoord: """Altitude above ellipsoid, in meters""" ... + @property + def height(self) -> float: + """Height above ellipsoid, in meters (alias of altitude)""" + ... + @property def geodetic(self) -> geodetic: """Geodetic coordinates as a named struct @@ -2779,6 +2794,9 @@ class consts: jgm3_j2: ClassVar[float] """ "J2" gravity due oblateness of Earth from JGM3 gravity model, unitless""" +# Alias so `time` resolves to the class inside bodies that define a `time` member +_Time = time + class satstate: """Satellite state: position, velocity, optional covariance, and maneuvers @@ -2820,7 +2838,7 @@ class satstate: def __init__( self, - time: time, + time: _Time, pos: npt.NDArray[np.float64], vel: npt.NDArray[np.float64], cov: npt.NDArray[np.float64] | None = None, @@ -2893,7 +2911,7 @@ class satstate: ... @property - def time(self) -> time: + def time(self) -> _Time: """Epoch of this satellite state""" ... @@ -2959,7 +2977,7 @@ class satstate: def add_maneuver( self, - time: time, + time: _Time, delta_v: npt.ArrayLike, frame: frame, ) -> None: @@ -3001,7 +3019,7 @@ class satstate: """ ... - def add_prograde(self, time: time, dv_mps: float) -> None: + def add_prograde(self, time: _Time, dv_mps: float) -> None: """Add a prograde impulsive burn (NTW +T, along velocity). A positive ``dv_mps`` adds energy (raises semi-major axis). The burn @@ -3018,7 +3036,7 @@ class satstate: """ ... - def add_retrograde(self, time: time, dv_mps: float) -> None: + def add_retrograde(self, time: _Time, dv_mps: float) -> None: """Add a retrograde impulsive burn (NTW -T, opposite velocity). Equivalent to ``add_prograde`` with a negated magnitude. ``dv_mps`` @@ -3030,7 +3048,7 @@ class satstate: """ ... - def add_radial(self, time: time, dv_mps: float) -> None: + def add_radial(self, time: _Time, dv_mps: float) -> None: """Add a radial-outward impulsive burn (NTW +N axis). For circular orbits this is the outward radial direction. For @@ -3043,7 +3061,7 @@ class satstate: """ ... - def add_normal(self, time: time, dv_mps: float) -> None: + def add_normal(self, time: _Time, dv_mps: float) -> None: """Add a cross-track ("normal") impulsive burn (NTW +W axis). Positive values push in the +angular-momentum direction. Changes @@ -3073,7 +3091,7 @@ class satstate: def propagate( self, - time: time | duration, + timedur: _Time | duration, *, propsettings: propsettings | None = None, satproperties: satproperties | None = None, @@ -3086,7 +3104,7 @@ class satstate: Maneuvers are preserved on the returned state. Args: - time (satkit.time|satkit.duration): Target time, or duration from current time + timedur (satkit.time|satkit.duration): Target time, or duration from current time propsettings (satkit.propsettings, optional): Propagation settings satproperties (satkit.satproperties, optional): Satellite properties (drag, SRP, thrust) @@ -3181,7 +3199,7 @@ class propresult: ... @property - def time(self) -> time: + def time(self) -> _Time: """Time at which state is valid Returns: @@ -3190,7 +3208,7 @@ class propresult: ... @property - def time_end(self) -> time: + def time_end(self) -> _Time: """Time at which state is valid Notes: @@ -3202,7 +3220,7 @@ class propresult: ... @property - def time_begin(self) -> time: + def time_begin(self) -> _Time: """Time at which state_begin is valid @@ -3276,7 +3294,7 @@ class propresult: @typing.overload def interp( self, - time: list[time | datetime.datetime], + time: list[_Time | datetime.datetime], output_phi: typing.Literal[False] = False, ) -> list[npt.NDArray[np.float64]]: """Interpolate state at multiple times @@ -3293,7 +3311,7 @@ class propresult: @typing.overload def interp( self, - time: list[time | datetime.datetime], + time: list[_Time | datetime.datetime], output_phi: typing.Literal[True] = ..., ) -> list[tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]]: """Interpolate state and state transition matrix at multiple times @@ -3307,38 +3325,6 @@ class propresult: """ ... - def interp( - self, - time: time | datetime.datetime | list[time | datetime.datetime], - output_phi: bool = False, - ): - """Interpolate state at given time(s) - - Requires ``enable_interp=True`` in propagation settings. - - Args: - time (time | datetime.datetime | list): Time or list of times at which to interpolate state. - datetime.datetime objects are interpreted as UTC. - output_phi (bool): If True, also return the 6x6 state transition matrix. Default is False. - - Returns: - For a single time: a 6-element state vector, or a ``(state, phi)`` tuple if ``output_phi=True``. For a list of times: a list of state vectors, or a list of ``(state, phi)`` tuples. - - Example: - ```python - # After propagation with enable_interp=True - result = satkit.propagate(state, t0, duration_days=1.0) - t_mid = t0 + satkit.duration(hours=12) - mid_state = result.interp(t_mid) - print(f"Position at 12h: {mid_state[0:3]} m") - - # Interpolate at multiple times - times = [t0 + satkit.duration(hours=h) for h in range(25)] - states = result.interp(times) - ``` - """ - ... - class thrust: """Continuous thrust acceleration for orbit maneuvers diff --git a/python/satkit/sun.pyi b/python/satkit/sun.pyi index bede9a9..12941d8 100644 --- a/python/satkit/sun.pyi +++ b/python/satkit/sun.pyi @@ -61,33 +61,6 @@ def pos_gcrf( """ ... -def pos_gcrf(*args, **kwargs): - """ - Sun position in the Geocentric Celestial Reference Frame (GCRF) - - Algorithm 29 from Vallado for sun in Mean of Date (MOD), then rotated - from MOD to GCRF via Equations 3-88 and 3-89 in Vallado - - Args: - time (satkit.time): time at which to compute position - - Returns: - 3-element numpy array representing sun position in GCRF frame - at given time. Units are meters - - Notes: - From Vallado: Valid with accuracy of .01 degrees from 1950 to 2050 - - Example: - ```python - import numpy as np - t = satkit.time(2024, 6, 21) - sun = satkit.sun.pos_gcrf(t) - print(f"Sun distance: {np.linalg.norm(sun)/1e9:.3f} million km") - ``` - """ - ... - @typing.overload def pos_mod(time: TimeScalar) -> npt.NDArray[np.float64]: """ @@ -129,24 +102,6 @@ def pos_mod( """ ... -def pos_mod(*args, **kwargs): - """ - Sun position in the Mean-of-Date Frame - - Algorithm 29 from Vallado for sun in Mean of Date (MOD) - - Args: - time (satkit.time): time at which to compute position - - Returns: - 3-element numpy array representing sun position in MOD frame - at given time. Units are meters - - Notes: - From Vallado: Valid with accuracy of .01 degrees from 1950 to 2050 - """ - ... - def rise_set( time: satkit.time, coord: satkit.itrfcoord, sigma: float = 90.0 + 50.0 / 60.0 ) -> tuple[satkit.time, satkit.time]: diff --git a/python/src/pyduration.rs b/python/src/pyduration.rs index 538c228..7918a3d 100644 --- a/python/src/pyduration.rs +++ b/python/src/pyduration.rs @@ -101,37 +101,37 @@ impl PyDuration { /// Create new duration object from the number of seconds /// /// Args: - /// d (float): The number of seconds + /// seconds (float): The number of seconds /// /// Returns: /// duration: New duration object #[staticmethod] - fn from_seconds(d: f64) -> Self { - Self(Duration::from_seconds(d)) + fn from_seconds(seconds: f64) -> Self { + Self(Duration::from_seconds(seconds)) } /// Create new duration object from the number of minutes /// /// Args: - /// d (float): The number of minutes + /// minutes (float): The number of minutes /// /// Returns: /// duration: New duration object #[staticmethod] - fn from_minutes(d: f64) -> Self { - Self(Duration::from_minutes(d)) + fn from_minutes(minutes: f64) -> Self { + Self(Duration::from_minutes(minutes)) } /// Create new duration object from number of hours /// /// Args: - /// d (float): The number of hours + /// hours (float): The number of hours /// /// Returns: /// duration: New duration object #[staticmethod] - fn from_hours(d: f64) -> Self { - Self(Duration::from_hours(d)) + fn from_hours(hours: f64) -> Self { + Self(Duration::from_hours(hours)) } /// Create new duration object from the number of milliseconds diff --git a/python/src/pyinstant.rs b/python/src/pyinstant.rs index 3d603a6..868d13c 100644 --- a/python/src/pyinstant.rs +++ b/python/src/pyinstant.rs @@ -248,7 +248,7 @@ impl PyInstant { /// Create satkit.time object from string /// /// Args: - /// s (str): String representing time + /// string (str): String representing time /// /// Returns: /// satkit.time: Time object representing input time @@ -257,15 +257,15 @@ impl PyInstant { /// ValueError: If input string cannot be parsed /// #[staticmethod] - fn from_string(s: &str) -> Result { - Ok(Instant::from_string(s).map(Self)?) + fn from_string(string: &str) -> Result { + Ok(Instant::from_string(string).map(Self)?) } /// Create satkit.time object from string with given format /// /// Args: - /// s (str): String representing time - /// fmt (str): Format string + /// date_string (str): String representing time + /// format (str): Format string /// /// Returns: /// satkit.time: Time object representing input time @@ -285,14 +285,14 @@ impl PyInstant { /// %b: Month as locale’s abbreviated name /// %B: Month as locale’s full name #[staticmethod] - fn strptime(s: &str, fmt: &str) -> Result { - Ok(Instant::strptime(s, fmt).map(Self)?) + fn strptime(date_string: &str, format: &str) -> Result { + Ok(Instant::strptime(date_string, format).map(Self)?) } /// Format time object as string /// /// Args: - /// fmt (str): Format string + /// format (str): Format string /// /// Returns: /// str: String representing time in given format @@ -314,9 +314,9 @@ impl PyInstant { /// %B: Month as locale’s full name /// %w: Weekday as a decimal number, where 0 is Sunday and 6 is Saturday /// - fn strftime(&self, fmt: &str) -> Result { + fn strftime(&self, format: &str) -> Result { self.0 - .strftime(fmt) + .strftime(format) .map_err(|e| anyhow::anyhow!("Could not format time string: {}", e)) } @@ -328,7 +328,7 @@ impl PyInstant { /// This overlaps with ISO 8601 /// /// Args: - /// s (str): String representing time + /// rfc3339 (str): String representing time /// /// Returns: /// satkit.time: Time object representing input time @@ -337,8 +337,8 @@ impl PyInstant { /// ValueError: If input string cannot be parsed /// #[staticmethod] - fn from_rfc3339(s: &str) -> PyResult { - Instant::from_rfc3339(s).map_or_else( + fn from_rfc3339(rfc3339: &str) -> PyResult { + Instant::from_rfc3339(rfc3339).map_or_else( |_| { Err(pyo3::exceptions::PyValueError::new_err( "Could not parse time string", @@ -418,8 +418,8 @@ impl PyInstant { /// Returns: /// satkit.time: Time object representing instant of input unixtime #[staticmethod] - fn from_unixtime(t: f64) -> Self { - Self(Instant::from_unixtime(t)) + fn from_unixtime(unixtime: f64) -> Self { + Self(Instant::from_unixtime(unixtime)) } /// Return time object representing input Julian date and time scale @@ -499,8 +499,8 @@ impl PyInstant { /// satkit.time: satkit.time object that matches input datetime /// SatKit Time object representing input datetime #[staticmethod] - fn from_datetime(tm: &Bound<'_, PyDateTime>) -> PyResult { - Ok(Self(datetime_to_instant(tm)?)) + fn from_datetime(dt: &Bound<'_, PyDateTime>) -> PyResult { + Ok(Self(datetime_to_instant(dt)?)) } /// Convert to Python datetime object diff --git a/python/src/pykepler.rs b/python/src/pykepler.rs index fe0f478..cff9536 100644 --- a/python/src/pykepler.rs +++ b/python/src/pykepler.rs @@ -152,10 +152,10 @@ impl PyKepler { /// Convert Cartesian elements to kepler #[staticmethod] - fn from_pv(r: &Bound, v: &Bound) -> PyResult { - let r = py_to_smatrix(r)?; - let v = py_to_smatrix(v)?; - match Kepler::from_pv(r, v) { + fn from_pv(pos: &Bound, vel: &Bound) -> PyResult { + let pos = py_to_smatrix(pos)?; + let vel = py_to_smatrix(vel)?; + match Kepler::from_pv(pos, vel) { Ok(k) => Ok(Self(k)), Err(e) => Err(pyo3::exceptions::PyRuntimeError::new_err(e.to_string())), } diff --git a/python/stubtest_allowlist.txt b/python/stubtest_allowlist.txt new file mode 100644 index 0000000..9a46d95 --- /dev/null +++ b/python/stubtest_allowlist.txt @@ -0,0 +1,71 @@ +# stubtest allowlist for satkit. +# +# Entries are regexes, fullmatched against each error's OBJECT PATH. This file +# is used together with two CLI flags that switch off whole PyO3-idiom check +# categories globally (cleaner than listing every object path): +# +# python -m mypy.stubtest satkit \ +# --allowlist python/stubtest_allowlist.txt \ +# --ignore-positional-only \ # operator dunders are positional-only at runtime +# --ignore-disjoint-bases # every pyclass is a PEP 800 disjoint base +# +# What remains here suppresses systematic PyO3 idioms that have no CLI flag. +# It deliberately contains NO real stub/runtime drift — those are fixed in the +# .pyi and Rust bindings. stubtest errors on unused entries, so if you fix an +# idiom (e.g. mark a class @final) its entry must be deleted here. + +# --- PyO3 constructors ------------------------------------------------------- +# #[new] parses (*args, **kwargs) manually, so runtime __init__/__new__ carry no +# named params. We keep keyword-rich __init__ stubs for IDE autocomplete. +.*\.__(init|new)__ + +# --- PyO3 pickle support ----------------------------------------------------- +# Emitted for pickling; not user-facing API. +.*\.__getnewargs_ex__ + +# --- Native submodules ------------------------------------------------------- +# Importable only as attributes of the compiled extension; each is documented in +# its own top-level .pyi, so it appears both as an un-importable module and as an +# attribute of satkit.satkit that satkit.pyi doesn't redeclare. +satkit\.(density|frametransform|jplephem|moon|planets|spaceweather|sun|utils) +satkit\.satkit\.(density|frametransform|jplephem|moon|planets|spaceweather|sun|utils) + +# --- Final classes ----------------------------------------------------------- +# Every pyclass is final at runtime; we never subclass in stubs. (Alternative: +# mark each class @final in the stub and drop this block.) +satkit\.satkit\.TLE +satkit\.satkit\.consts +satkit\.satkit\.duration +satkit\.satkit\.frame +satkit\.satkit\.geodetic +satkit\.satkit\.gravmodel +satkit\.satkit\.integrator +satkit\.satkit\.itrfcoord +satkit\.satkit\.kepler +satkit\.satkit\.propresult +satkit\.satkit\.propsettings +satkit\.satkit\.propstats +satkit\.satkit\.quaternion +satkit\.satkit\.satproperties +satkit\.satkit\.satstate +satkit\.satkit\.sgp4_error +satkit\.satkit\.sgp4_gravconst +satkit\.satkit\.sgp4_opsmode +satkit\.satkit\.solarsystem +satkit\.satkit\.thrust +satkit\.satkit\.tidemodel +satkit\.satkit\.time +satkit\.satkit\.timescale +satkit\.satkit\.tlefitstatus +satkit\.satkit\.weekday + +# --- Stub-only type aliases -------------------------------------------------- +# TypeAliases used across the stubs; intentionally have no runtime object. +satkit\.satkit\.TimeScalar +satkit\.satkit\.TimeInput +satkit\.satkit\.TimeArrayLike + +# --- __all__ ----------------------------------------------------------------- +satkit\.__all__ +satkit\.satkit\.__all__ + From f46f93f3c3cc3562f81811fb6679a8080218ac41 Mon Sep 17 00:00:00 2001 From: Steven Michael Date: Sat, 4 Jul 2026 15:27:38 -0400 Subject: [PATCH 2/5] docs: fix API doc nits and remove dead duration.__div__ - frametransform GMST doc: 0.983104 -> 0.093104 (matches code) - itrfcoord latitude_deg doc range: [-180,180] -> [-90,90] - move orphaned EOP-warning doc block off to_gcrf; give disable_eop_time_warning its own docstring - delete dead Python 2 duration.__div__ alias Co-Authored-By: Claude Opus 4.8 (1M context) --- python/src/pyduration.rs | 5 ----- python/src/pyframetransform.rs | 20 ++++++++------------ src/frametransform/mod.rs | 2 +- src/itrfcoord.rs | 2 +- 4 files changed, 10 insertions(+), 19 deletions(-) diff --git a/python/src/pyduration.rs b/python/src/pyduration.rs index 7918a3d..aa875c0 100644 --- a/python/src/pyduration.rs +++ b/python/src/pyduration.rs @@ -213,11 +213,6 @@ impl PyDuration { } } - // Python 2 name kept as an alias for compatibility. - fn __div__(&self, other: &Bound<'_, PyAny>) -> PyResult> { - self.__truediv__(other) - } - // Comparison methods for duration objects fn __eq__(&self, other: &Self) -> bool { self.0 == other.0 diff --git a/python/src/pyframetransform.rs b/python/src/pyframetransform.rs index 88d1941..0b2e1b1 100644 --- a/python/src/pyframetransform.rs +++ b/python/src/pyframetransform.rs @@ -224,18 +224,6 @@ pub fn pyeop(time: &PyInstant) -> Option<(f64, f64, f64, f64, f64, f64)> { satkit::earth_orientation_params::get(&time.0).map(|r| (r[0], r[1], r[2], r[3], r[4], r[5])) } -/// -/// Disable warning about out-of-range Earth Orientation Parameters (EOP) -/// -/// Warning is shown only once, but to prevent it from being shown, -/// run this function. -/// -/// # Example -/// ```python -/// import satkit -/// satkit.frametransform.disable_eop_warning() -/// ``` -/// /// Return the DCM that transforms a 3-vector from the given satellite- /// local frame into GCRF at the current state. /// @@ -721,6 +709,14 @@ pub fn rotation_with_state( }) } +/// Disable the warning about out-of-range Earth Orientation Parameters (EOP). +/// +/// The warning is shown only once by default; call this function to suppress +/// it entirely. +/// +/// Example: +/// >>> import satkit +/// >>> satkit.frametransform.disable_eop_time_warning() #[pyfunction(name = "disable_eop_time_warning")] pub fn disable_eop_time_warning() { satkit::earth_orientation_params::disable_eop_time_warning(); diff --git a/src/frametransform/mod.rs b/src/frametransform/mod.rs index a8b8108..40e471f 100644 --- a/src/frametransform/mod.rs +++ b/src/frametransform/mod.rs @@ -57,7 +57,7 @@ pub use qcirs2gcrs::qcirs2gcrs_dxdy; /// /// Vallado algorithm 15: /// -/// GMST = 67310.5481 + (876600h + 8640184.812866) * tᵤₜ₁ * (0.983104 + tᵤₜ₁ * −6.2e−6) +/// GMST = 67310.5481 + (876600h + 8640184.812866) * tᵤₜ₁ * (0.093104 + tᵤₜ₁ * −6.2e−6) /// /// /// # Arguments diff --git a/src/itrfcoord.rs b/src/itrfcoord.rs index 854fbf7..89885f8 100644 --- a/src/itrfcoord.rs +++ b/src/itrfcoord.rs @@ -342,7 +342,7 @@ impl ITRFCoord { hae } - /// Return geodetic latitude in degrees, [-180, 180] + /// Return geodetic latitude in degrees, [-90, 90] #[inline] pub fn latitude_deg(&self) -> f64 { self.latitude_rad().to_degrees() From b90a14fdac21af6bab93a9bba06f81193a7d8dbd Mon Sep 17 00:00:00 2001 From: Steven Michael Date: Sat, 4 Jul 2026 15:32:58 -0400 Subject: [PATCH 3/5] feat(python): dunder sweep on value types - time, duration: add __hash__ (were unhashable due to __eq__ without __hash__); hash the exact microsecond count so it agrees with __eq__ - TLE, kepler, itrfcoord: add __eq__/__ne__ (float-backed, so left unhashable rather than risk silent float-keyed lookup misses) - TLE, kepler, satstate, propsettings: add __repr__ delegating to __str__ satstate.__eq__ intentionally deferred (no PartialEq; equality across float pv + covariance + maneuvers is a design decision, not a sweep). 114 Python tests pass; stubtest clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 +++++++++ python/src/pyduration.rs | 6 ++++++ python/src/pyinstant.rs | 6 ++++++ python/src/pyitrfcoord.rs | 8 ++++++++ python/src/pykepler.rs | 19 +++++++++++++++++++ python/src/pypropsettings.rs | 4 ++++ python/src/pysatstate.rs | 4 ++++ python/src/pytle.rs | 12 ++++++++++++ 8 files changed, 68 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a91adba..f531db0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ ### Added +- **`time` and `duration` are now hashable.** Both define `__eq__` but + previously lacked `__hash__`, which made them unhashable (unusable as `dict` + keys or `set` members). The hash is derived from the underlying microsecond + count, so it is consistent with equality. +- **Equality and `repr` on more value types.** `TLE`, `kepler`, and `itrfcoord` + now implement `__eq__`; `TLE`, `kepler`, `satstate`, and `propsettings` now + implement `__repr__` (delegating to their `str` form). The three float-backed + types that gained `__eq__` are intentionally left unhashable — a failed + `hash()` is clearer than silently-wrong float-keyed lookups. - **`mypy.stubtest` in CI.** The `python bindings test` job now verifies that the hand-written `.pyi` type stubs match the compiled PyO3 bindings. Two CLI flags (`--ignore-positional-only`, `--ignore-disjoint-bases`) plus diff --git a/python/src/pyduration.rs b/python/src/pyduration.rs index aa875c0..bbcd990 100644 --- a/python/src/pyduration.rs +++ b/python/src/pyduration.rs @@ -213,6 +213,12 @@ impl PyDuration { } } + // Backed by an exact microsecond count, so hashing the raw integer is + // consistent with __eq__ (defining __eq__ alone would make duration unhashable). + fn __hash__(&self) -> isize { + self.0.usec as isize + } + // Comparison methods for duration objects fn __eq__(&self, other: &Self) -> bool { self.0 == other.0 diff --git a/python/src/pyinstant.rs b/python/src/pyinstant.rs index 868d13c..8f647db 100644 --- a/python/src/pyinstant.rs +++ b/python/src/pyinstant.rs @@ -760,6 +760,12 @@ impl PyInstant { self.0 != other.0 } + // Backed by an exact microsecond count, so hashing the raw integer is + // consistent with __eq__ (defining __eq__ alone would make time unhashable). + fn __hash__(&self) -> isize { + self.0.raw as isize + } + /// /// Add given number of UTC days to a time object, and return the result /// diff --git a/python/src/pyitrfcoord.rs b/python/src/pyitrfcoord.rs index 061b67f..2b8ae98 100644 --- a/python/src/pyitrfcoord.rs +++ b/python/src/pyitrfcoord.rs @@ -268,6 +268,14 @@ impl PyITRFCoord { self.__str__() } + fn __eq__(&self, other: &Self) -> bool { + self.0 == other.0 + } + + fn __ne__(&self, other: &Self) -> bool { + self.0 != other.0 + } + /// Quaternion representing rotation from North-East-Down (NED) coordinate frame to International Terrestrial Reference Frame /// (ITRF) at this coordinate /// diff --git a/python/src/pykepler.rs b/python/src/pykepler.rs index cff9536..d69e346 100644 --- a/python/src/pykepler.rs +++ b/python/src/pykepler.rs @@ -263,6 +263,25 @@ impl PyKepler { format!("{}", self.0) } + fn __repr__(&self) -> String { + self.__str__() + } + + fn __eq__(&self, other: &Self) -> bool { + let a = &self.0; + let b = &other.0; + a.a == b.a + && a.eccen == b.eccen + && a.incl == b.incl + && a.raan == b.raan + && a.w == b.w + && a.nu == b.nu + } + + fn __ne__(&self, other: &Self) -> bool { + !self.__eq__(other) + } + fn __getstate__(&mut self, py: Python) -> PyResult> { crate::pyutils::pack_f64s( py, diff --git a/python/src/pypropsettings.rs b/python/src/pypropsettings.rs index 81c9b31..1a08dcc 100644 --- a/python/src/pypropsettings.rs +++ b/python/src/pypropsettings.rs @@ -356,6 +356,10 @@ impl PyPropSettings { self.0.to_string() } + fn __repr__(&self) -> String { + self.__str__() + } + fn __getnewargs_ex__<'a>(&self, py: Python<'a>) -> (Bound<'a, PyTuple>, Bound<'a, PyDict>) { (PyTuple::empty(py), PyDict::new(py)) } diff --git a/python/src/pysatstate.rs b/python/src/pysatstate.rs index 16e957b..4633ef2 100644 --- a/python/src/pysatstate.rs +++ b/python/src/pysatstate.rs @@ -550,4 +550,8 @@ impl PySatState { fn __str__(&self) -> String { self.0.to_string() } + + fn __repr__(&self) -> String { + self.__str__() + } } diff --git a/python/src/pytle.rs b/python/src/pytle.rs index 07cef3a..3ebbbb0 100644 --- a/python/src/pytle.rs +++ b/python/src/pytle.rs @@ -320,6 +320,18 @@ impl PyTLE { self.0.to_pretty_string() } + fn __repr__(&self) -> String { + self.__str__() + } + + fn __eq__(&self, other: &Self) -> bool { + self.0 == other.0 + } + + fn __ne__(&self, other: &Self) -> bool { + self.0 != other.0 + } + /// Output as 2 canonical TLE Lines fn to_2line(&self) -> Result<[String; 2]> { Ok(self.0.to_2line()?) From 2c16e67fec490ce13cfb52b9f8b366b5bd7f01c9 Mon Sep 17 00:00:00 2001 From: Steven Michael Date: Sat, 4 Jul 2026 15:35:15 -0400 Subject: [PATCH 4/5] fix(python): reject unknown kwargs in itrfcoord/sgp4/propagate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typo'd keywords were silently dropped — e.g. itrfcoord(alttiude=100) left the ground station at 0 m. These three sites now call reject_unused_kwargs after consuming all documented keywords, matching the pattern already used elsewhere in the bindings. 114 Python tests pass; stubtest clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 +++++ python/src/pyitrfcoord.rs | 6 ++++++ python/src/pysatstate.rs | 4 ++++ python/src/pysgp4.rs | 6 ++++++ 4 files changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f531db0..87ffb8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,11 @@ ### Changed +- **`itrfcoord(...)`, `sgp4(...)`, and `satstate.propagate(...)` now reject + unknown keyword arguments** instead of silently ignoring them. Previously a + typo such as `itrfcoord(..., alttiude=100)` was dropped, leaving the ground + station at 0 m altitude; it now raises `ValueError`. Callers passing only + documented keywords are unaffected. - **Renamed keyword arguments on several bindings** so the accepted Python keyword matches its documentation (the stubs previously advertised names the runtime rejected). Positional calls are unaffected; only callers passing these diff --git a/python/src/pyitrfcoord.rs b/python/src/pyitrfcoord.rs index 2b8ae98..2bc197f 100644 --- a/python/src/pyitrfcoord.rs +++ b/python/src/pyitrfcoord.rs @@ -129,6 +129,12 @@ impl PyITRFCoord { let mut altitude: f64 = kwargs_or_default(&mut kwargs, "altitude", 0.0)?; altitude = (kwargs_or_none(&mut kwargs, "height")?).map_or(altitude, |v| v); + // Reject typos like `alttiude=` that would otherwise be silently + // ignored and leave the ground-station altitude at its 0.0 default. + if let Some(kw) = kwargs { + crate::pyutils::reject_unused_kwargs(kw)?; + } + if latitude_deg.is_none() || longitude_deg.is_none() { return Err(pyo3::exceptions::PyTypeError::new_err( "Must set latitude, longitude", diff --git a/python/src/pysatstate.rs b/python/src/pysatstate.rs index 4633ef2..94f4a6c 100644 --- a/python/src/pysatstate.rs +++ b/python/src/pysatstate.rs @@ -385,12 +385,16 @@ impl PySatState { })? .0, ); + kw.del_item("propsettings")?; } if let Some(v) = kw.get_item("satproperties")? { satprops_obj = Some(v.extract::().map_err(|e| { pyo3::exceptions::PyValueError::new_err(format!("Invalid satproperties: {}", e)) })?); + kw.del_item("satproperties")?; } + // Reject typo'd keywords rather than silently ignoring them. + crate::pyutils::reject_unused_kwargs(kw)?; } let satprops_ref = satprops_obj diff --git a/python/src/pysgp4.rs b/python/src/pysgp4.rs index c98c8e0..0947a4f 100644 --- a/python/src/pysgp4.rs +++ b/python/src/pysgp4.rs @@ -337,17 +337,23 @@ pub fn sgp4( if let Some(kw) = kwds { if let Some(v) = kw.get_item("errflag")? { output_err = v.extract::()?; + kw.del_item("errflag")?; } if let Some(v) = kw.get_item("opsmode")? { opsmode = v.extract::().map_err(|e| { pyo3::exceptions::PyValueError::new_err(format!("Invalid opsmode: {}", e)) })?; + kw.del_item("opsmode")?; } if let Some(v) = kw.get_item("gravconst")? { gravconst = v.extract::().map_err(|e| { pyo3::exceptions::PyValueError::new_err(format!("Invalid gravconst: {}", e)) })?; + kw.del_item("gravconst")?; } + // Any keyword left over is a typo (e.g. `gravconstt=`) — reject it + // rather than silently ignore it. + crate::pyutils::reject_unused_kwargs(kw)?; } // Handle input as TLE From dbf0574bf36f1bb7636e74c52528983c4f5aac0c Mon Sep 17 00:00:00 2001 From: Steven Michael Date: Sat, 4 Jul 2026 15:37:12 -0400 Subject: [PATCH 5/5] refactor(sgp4): dedupe mean-element conversion via from_mean_elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rev/day->rad/min and degree->radian conversion was copy-pasted in TLE::sgp4_init_args and OMM::sgp4_init_args. Extract it into a single SGP4InitArgs::from_mean_elements constructor; both sources now marshal their fields through it. Behavior-preserving — 206 lib tests + Vallado SGP4 vectors unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++++ src/omm/mod.rs | 29 ++++++++++++----------------- src/sgp4/mod.rs | 40 ++++++++++++++++++++++++++++++++++++++++ src/tle/mod.rs | 29 ++++++++++++----------------- 4 files changed, 68 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87ffb8e..7b29816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Added +- **`SGP4InitArgs::from_mean_elements`** — a constructor that performs the + rev/day → rad/min and degree → radian conversions from catalog units. Both + the `TLE` and CCSDS `OMM` SGP4 sources now build their init args through it, + so the conversion factors are defined in one place. - **`time` and `duration` are now hashable.** Both define `__eq__` but previously lacked `__hash__`, which made them unhashable (unusable as `dict` keys or `set` members). The hash is derived from the underlying microsecond diff --git a/src/omm/mod.rs b/src/omm/mod.rs index 85182bb..9ad5aed 100644 --- a/src/omm/mod.rs +++ b/src/omm/mod.rs @@ -424,10 +424,6 @@ impl SGP4Source for OMM { } fn sgp4_init_args(&self) -> crate::sgp4::Result { - use std::f64::consts::PI; - - const TWOPI: f64 = PI * 2.0; - if let Some(theory) = &self.mean_element_theory { if !theory.trim().eq_ignore_ascii_case("SGP4") { return Err(crate::sgp4::Error::source( @@ -446,19 +442,18 @@ impl SGP4Source for OMM { let epoch = self.epoch_instant().map_err(crate::sgp4::Error::source)?; - Ok(SGP4InitArgs { - jdsatepoch: epoch.as_jd_with_scale(TimeScale::UTC), - bstar: self.bstar.unwrap_or(0.0), - // Convert rev/day(+derivatives) to rad/min(+derivatives), matching TLE. - no: self.mean_motion / (1440.0 / TWOPI), - ndot: self.mean_motion_dot.unwrap_or(0.0) / (1440.0 * 1440.0 / TWOPI), - nddot: self.mean_motion_ddot.unwrap_or(0.0) / (1440.0 * 1440.0 * 1440.0 / TWOPI), - ecco: self.eccentricity, - inclo: self.inclination.to_radians(), - nodeo: self.raan.to_radians(), - argpo: self.arg_of_pericenter.to_radians(), - mo: self.mean_anomaly.to_radians(), - }) + Ok(SGP4InitArgs::from_mean_elements( + epoch.as_jd_with_scale(TimeScale::UTC), + self.bstar.unwrap_or(0.0), + self.mean_motion, + self.mean_motion_dot.unwrap_or(0.0), + self.mean_motion_ddot.unwrap_or(0.0), + self.eccentricity, + self.inclination, + self.raan, + self.arg_of_pericenter, + self.mean_anomaly, + )) } } diff --git a/src/sgp4/mod.rs b/src/sgp4/mod.rs index 131c5a3..41565d6 100644 --- a/src/sgp4/mod.rs +++ b/src/sgp4/mod.rs @@ -68,6 +68,46 @@ pub struct SGP4InitArgs { pub nodeo: f64, } +impl SGP4InitArgs { + /// Build init args from mean elements in their natural catalog units: + /// mean motion and its derivatives in rev/day (+ per day, per day²), and + /// the four angles in degrees. + /// + /// This performs the rev/day → rad/min and degree → radian conversions + /// shared by every SGP4 source (TLE, CCSDS OMM), so the conversion factors + /// live in exactly one place. + #[allow(clippy::too_many_arguments)] + pub fn from_mean_elements( + jdsatepoch: f64, + bstar: f64, + mean_motion: f64, + mean_motion_dot: f64, + mean_motion_ddot: f64, + eccen: f64, + inclination_deg: f64, + raan_deg: f64, + arg_of_perigee_deg: f64, + mean_anomaly_deg: f64, + ) -> Self { + use std::f64::consts::PI; + + const TWOPI: f64 = PI * 2.0; + + Self { + jdsatepoch, + bstar, + no: mean_motion / (1440.0 / TWOPI), + ndot: mean_motion_dot / (1440.0 * 1440.0 / TWOPI), + nddot: mean_motion_ddot / (1440.0 * 1440.0 * 1440.0 / TWOPI), + ecco: eccen, + inclo: inclination_deg.to_radians(), + nodeo: raan_deg.to_radians(), + argpo: arg_of_perigee_deg.to_radians(), + mo: mean_anomaly_deg.to_radians(), + } + } +} + /// Source of SGP4 mean elements (e.g., TLE, CCSDS OMM) that can be propagated. /// /// Implementations are responsible for any unit/time-system conversions needed diff --git a/src/tle/mod.rs b/src/tle/mod.rs index 6ba9ba3..8020640 100644 --- a/src/tle/mod.rs +++ b/src/tle/mod.rs @@ -126,24 +126,19 @@ impl SGP4Source for TLE { } fn sgp4_init_args(&self) -> crate::sgp4::Result { - use std::f64::consts::PI; - - const TWOPI: f64 = PI * 2.0; - - Ok(SGP4InitArgs { + Ok(SGP4InitArgs::from_mean_elements( // Vallado expects JD UTC and then subtracts 2433281.5 inside the legacy interface. - jdsatepoch: self.epoch.as_jd_with_scale(TimeScale::UTC), - bstar: self.bstar, - // Convert rev/day(+derivatives) to rad/min(+derivatives), matching sgp4_impl. - no: self.mean_motion / (1440.0 / TWOPI), - ndot: self.mean_motion_dot / (1440.0 * 1440.0 / TWOPI), - nddot: self.mean_motion_dot_dot / (1440.0 * 1440.0 * 1440.0 / TWOPI), - ecco: self.eccen, - inclo: self.inclination.to_radians(), - nodeo: self.raan.to_radians(), - argpo: self.arg_of_perigee.to_radians(), - mo: self.mean_anomaly.to_radians(), - }) + self.epoch.as_jd_with_scale(TimeScale::UTC), + self.bstar, + self.mean_motion, + self.mean_motion_dot, + self.mean_motion_dot_dot, + self.eccen, + self.inclination, + self.raan, + self.arg_of_perigee, + self.mean_anomaly, + )) } }