From 8820eeea94baa6476ea967df15bf162e531256e4 Mon Sep 17 00:00:00 2001 From: Travis Yeager Date: Sat, 19 Sep 2026 00:32:08 -0700 Subject: [PATCH 1/2] Fix valid-epoch RV results and cache ownership Carry valid epochs through terminated numerical RV batches, preserve caller-owned cache inputs, and validate integration steps and tolerances. Verified: 13 patch integration tests passed; Ruff E9/F63/F7/F82, compileall, and git diff --check passed. --- ssapy/compute.py | 37 ++++- ssapy/propagator.py | 137 +++++++++++----- tests/test_patch_integration_regressions.py | 166 ++++++++++++++++++++ 3 files changed, 296 insertions(+), 44 deletions(-) create mode 100644 tests/test_patch_integration_regressions.py diff --git a/ssapy/compute.py b/ssapy/compute.py index bf8fd938..2bde50ae 100644 --- a/ssapy/compute.py +++ b/ssapy/compute.py @@ -126,6 +126,7 @@ def _countOrbit(orbit): def _countTime(time): if isinstance(time, Time): time = time.gps + time = np.asarray(time, dtype=float) squeezeTime = False try: nTime = len(time) @@ -267,17 +268,20 @@ class HashableArrayContainer: """ def __init__(self, arr): - self.arr = arr + self.arr = np.array(arr, copy=True) self.arr.flags.writeable = False def __hash__(self): - return hash(self.arr.data.tobytes()) + return hash((self.arr.dtype.str, self.arr.shape, self.arr.tobytes())) def __eq__(self, rhs): - return np.all(self.arr == rhs.arr) + if not isinstance(rhs, HashableArrayContainer): + return NotImplemented + return (self.arr.dtype == rhs.arr.dtype and self.arr.shape == rhs.arr.shape + and self.arr.tobytes() == rhs.arr.tobytes()) -def rv(orbit, time, propagator=KeplerianPropagator()): +def rv(orbit, time, propagator=KeplerianPropagator(), *, return_times=False): """Calculate positions and velocities on the outer product of all supplied orbits and times. @@ -290,6 +294,9 @@ def rv(orbit, time, propagator=KeplerianPropagator()): since 1980-01-06 00:00:00 UTC propagator : Propagator, optional The propagator instance to use. + return_times : bool, optional + Also return the one-dimensional array of GPS epochs corresponding to + the returned states. Useful when a numerical propagator terminates. Notes ----- @@ -299,19 +306,35 @@ def rv(orbit, time, propagator=KeplerianPropagator()): For Keplerian orbit propagation it is more efficient to use a "vector Orbit" instead of a list of single scalar Orbits. + Numerical termination can remove requested epochs at either end. A batch + retains only epochs valid for every orbit, preserving the requested order + (including duplicates). Use ``return_times=True`` to identify those epochs; + a shortened result is not necessarily a prefix of the requested grid. + Returns ------- r : array_like (n, m, 3) Position in meters. v : array_like (n, m, 3) Velocity in meters per second. + times : ndarray (m,), optional + Retained GPS epochs when ``return_times=True``; always one-dimensional. """ nOrbit, squeezeOrbit, orbit = _countOrbit(orbit) nTime, squeezeTime, time = _countTime(time) # print(nOrbit, squeezeOrbit, orbit, nTime, squeezeTime, time) - outR, outV = _rv(orbit, HashableArrayContainer(time), propagator) - - return _doSqueeze(squeezeOrbit, squeezeTime, outR, outV) + if return_times: + outR, outV, valid = propagator._getRVManyWithMask(orbit, time) + else: + outR, outV = _rv(orbit, HashableArrayContainer(time), propagator) + # A terminated scalar query can have no state to squeeze to a scalar. + out = _doSqueeze(squeezeOrbit, squeezeTime and outR.shape[1] == 1, outR, outV) + # The rv cache owns its arrays. Unit conversion or light-time corrections + # applied by a caller must not alter future results for the same query. + out = tuple(value.copy() for value in out) + if return_times: + return (*out, time[valid].copy()) + return out def __rv(orbit, time, propagator): diff --git a/ssapy/propagator.py b/ssapy/propagator.py index 8e7e7956..574176bf 100644 --- a/ssapy/propagator.py +++ b/ssapy/propagator.py @@ -54,6 +54,18 @@ def _getInterpolantContainer(orbit, propagator): _MOON_CHECK_MARGIN = 50e6 +def _positive_step(h): + h = float(h) + if not np.isfinite(h) or h <= 0: + raise ValueError("h must be finite and positive") + return h + + +def _check_step_progress(t, h): + if not np.isfinite(h) or not np.isfinite(t + h) or t + h == t: + raise RuntimeError("integration step cannot advance the epoch at the requested step size") + + def _burnup_event(t, s): return np.linalg.norm(s[0:3]) - _BURNUP_RADIUS @@ -117,23 +129,28 @@ def _getRVOne(self, orbit, time): # outR[j], outV[j] = self._getRVOne(orb, time) # return outR, outV def _getRVMany(self, orbit, time): - nOrbit = len(orbit) - outR_list = [] - outV_list = [] - min_len = len(time) - - for j, orb in enumerate(orbit): - rj, vj = self._getRVOne(orb, time) - if len(rj) < min_len: - min_len = len(rj) - outR_list.append(rj) - outV_list.append(vj) - - # Truncate all results and time array to minimum length returned - outR = np.array([r[:min_len] for r in outR_list]) - outV = np.array([v[:min_len] for v in outV_list]) + r, v, _ = self._getRVManyWithMask(orbit, time) + return r, v - return outR, outV + def _getRVOneWithMask(self, orbit, time): + """Legacy subclasses return prefixes; truncating subclasses override.""" + r, v = self._getRVOne(orbit, time) + return r, v, np.arange(len(time)) < len(r) + + def _getRVManyWithMask(self, orbit, time): + """Keep only requested epochs valid for every orbit, in query order.""" + results = [self._getRVOneWithMask(orb, time) for orb in orbit] + common = np.ones(len(time), dtype=bool) + for r, v, valid in results: + if valid.shape != common.shape or len(r) != valid.sum() or v.shape != r.shape: + raise ValueError("propagator returned inconsistent state/time coverage") + common &= valid + if not results: + empty = np.empty((0, len(time), 3)) + return empty, empty.copy(), common + r = np.stack([r[common[valid]] for r, _, valid in results]) + v = np.stack([v[common[valid]] for _, v, valid in results]) + return r, v, common class KeplerianPropagator(Propagator): """ A basic Keplerian propagator for finding the position and velocity of an @@ -178,6 +195,10 @@ def _getRVMany(self, orbit, time): def __hash__(self): return hash("KeplerianPropagator") + def _getRVManyWithMask(self, orbit, time): + r, v = self._getRVMany(orbit, time) + return r, v, np.ones(len(time), dtype=bool) + def __eq__(self, rhs): return isinstance(rhs, KeplerianPropagator) @@ -389,7 +410,13 @@ def __init__(self, accel, ode_kwargs=None): self.accel = accel if ode_kwargs is None: ode_kwargs = {'rtol': 1e-7} - self.ode_kwargs = ode_kwargs + self.ode_kwargs = dict(ode_kwargs) + # Component tolerances are documented solve_ivp inputs and must also + # form stable, hashable interpolant-cache keys. Copy caller-owned data. + for name in ('rtol', 'atol'): + if name in self.ode_kwargs: + value = np.asarray(self.ode_kwargs[name], dtype=float) + self.ode_kwargs[name] = float(value) if value.ndim == 0 else tuple(value) def __repr__(self): return "SciPyPropagator({!r}, {!r})".format(self.accel, self.ode_kwargs) @@ -456,14 +483,26 @@ def _solve_piecewise_ivp(self, fp, t_span, sol): 2: "Moon impact", }[event_index] print(f"{label} detected at t = {event_time:.2f} s") + # Retain termination even if rounding puts the final state + # slightly inside the event surface, where restarting would + # not produce another zero crossing. + sol._ssapy_stopped_direction = 1 if t1 > t0 else -1 return sol return sol def _getRVOne(self, orbit, tQuery): + r, v, _ = self._getRVOneWithMask(orbit, tQuery) + return r, v + + def _getRVOneWithMask(self, orbit, tQuery): from scipy.integrate._ivp.base import ConstantDenseOutput from scipy.integrate._ivp.common import OdeSolution - # Pattern for ScipyPropagator interpolant is just: - # OdeSolution + # Cache the dense solution and whether each end is terminal. + tQuery = np.asarray(tQuery, dtype=float) + if tQuery.ndim != 1 or not np.all(np.isfinite(tQuery)): + raise ValueError("query times must be a finite one-dimensional array") + if tQuery.size == 0: + return np.empty((0, 3)), np.empty((0, 3)), np.zeros(0, dtype=bool) container = _InterpolantCache(orbit, self) def fp(t, s): @@ -473,6 +512,7 @@ def fp(t, s): tmin, tmax = np.min(tQuery), np.max(tQuery) update = False + stopped_before = stopped_after = False if len(container) == 0: ts = np.array([orbit.t, orbit.t]) interpolants = [ConstantDenseOutput( @@ -484,21 +524,27 @@ def fp(t, s): update = True else: sol = container[0] - if tmin < sol.ts[0]: + if len(container) == 3: + _, stopped_before, stopped_after = container + if tmin < sol.ts[0] and not stopped_before: sol = self._solve_piecewise_ivp( fp, [sol.ts[0], tmin], sol) + stopped_before = (getattr(sol, '_ssapy_stopped_direction', 0) == -1 + or sol.ts[0] > tmin) update = True - if tmax > sol.ts[-1]: + if tmax > sol.ts[-1] and not stopped_after: sol = self._solve_piecewise_ivp( fp, [sol.ts[-1], tmax], sol) + stopped_after = (getattr(sol, '_ssapy_stopped_direction', 0) == 1 + or sol.ts[-1] < tmax) update = True if update: container.clear() - container.append(sol) + container.extend([sol, stopped_before, stopped_after]) # A terminating event truncates the dense solution at whichever end # the integration was running towards, so both ends must be checked. @@ -507,12 +553,13 @@ def fp(t, s): # reentry that terminates mid-arc otherwise returns extrapolated # garbage (|r| ~ 1e18 km) for the earlier requested times, with no # mask, NaN or exception to mark it. - tQuery = tQuery[(tQuery >= sol.ts[0]) & (tQuery <= sol.ts[-1])] + valid = (tQuery >= sol.ts[0]) & (tQuery <= sol.ts[-1]) + tQuery = tQuery[valid] if len(tQuery) == 0: - return np.empty((0, 3)), np.empty((0, 3)) + return np.empty((0, 3)), np.empty((0, 3)), valid out = sol(tQuery).T - return out[:, 0:3], out[:, 3:6] + return out[:, 0:3], out[:, 3:6], valid def __hash__(self): return hash(( @@ -559,6 +606,10 @@ def _prop(self): ... # Subclasses must override def _getRVOne(self, orbit, tQuery): + r, v, _ = self._getRVOneWithMask(orbit, tQuery) + return r, v + + def _getRVOneWithMask(self, orbit, tQuery): from collections import deque from scipy.interpolate import make_interp_spline # Pattern for RK interpolant is: @@ -590,11 +641,12 @@ def _getRVOne(self, orbit, tQuery): states_arr = np.asarray(states, dtype=np.float64) if times_arr.size == 1: # Only defined exactly at the single cached time - tQuery = tQuery[tQuery == times_arr[0]] + valid = tQuery == times_arr[0] + tQuery = tQuery[valid] if tQuery.size == 0: - return np.empty((0, 3)), np.empty((0, 3)) + return np.empty((0, 3)), np.empty((0, 3)), valid out = np.repeat(states_arr, tQuery.size, axis=0) - return out[:, 0:3], out[:, 3:6] + return out[:, 0:3], out[:, 3:6], valid if remake_spline or spline is None: k = min(3, len(times_arr) - 1) # cubic, stable @@ -604,9 +656,10 @@ def _getRVOne(self, orbit, tQuery): times_arr = np.asarray(times, dtype=np.float64) states_arr = np.asarray(states, dtype=np.float64) - tQuery = tQuery[(tQuery >= times_arr[0]) & (tQuery <= times_arr[-1])] + valid = (tQuery >= times_arr[0]) & (tQuery <= times_arr[-1]) + tQuery = tQuery[valid] if tQuery.size == 0: - return np.empty((0, 3)), np.empty((0, 3)) + return np.empty((0, 3)), np.empty((0, 3)), valid # Exact knot lookup (prevents tiny spline-at-knot drift) idx = np.searchsorted(times_arr, tQuery) @@ -621,7 +674,7 @@ def _getRVOne(self, orbit, tQuery): out[exact] = states_arr[idx[exact]] out[~exact] = spline(tQuery[~exact]) - return out[:, 0:3], out[:, 3:6] + return out[:, 0:3], out[:, 3:6], valid class RK4Propagator(RKPropagator): @@ -640,7 +693,7 @@ class RK4Propagator(RKPropagator): def __init__(self, accel, h): self.accel = accel - self.h = h + self.h = _positive_step(h) def __repr__(self): return "RK4Propagator({!r}, {!r})".format(self.accel, self.h) @@ -682,6 +735,7 @@ def fp(s, t): keepGoing = True while keepGoing: + _check_step_progress(t, h) # test here so we always get 1 extra iteration... if not pred(t) and len(times) >= self._minPoints: keepGoing = False @@ -734,7 +788,7 @@ class RK8Propagator(RKPropagator): def __init__(self, accel, h): self.accel = accel - self.h = h + self.h = _positive_step(h) # Class level variables for Butcher tableau c = np.array([0, 1 / 18, 1 / 12, 1 / 8, 5 / 16, 3 / 8, 59 / 400, 93 / 200, 5490023248 / 9719169821, 13 / 20, 1201146811 / 1299019798, 1, 1], dtype=np.float64) @@ -798,6 +852,7 @@ def fp(s, t): pred = lambda t: t >= tthresh keepGoing = True while keepGoing: + _check_step_progress(t, h) # test here so we always get 1 extra iteration, which seems to # interpolate better if not pred(t) and len(times) >= self._minPoints: @@ -854,8 +909,11 @@ class RK78Propagator(RK8Propagator): def __init__(self, accel, h, tol=(1e-6,) * 3 + (1e-9,) * 3): self.accel = accel - self.h = h - self.tol = tol + self.h = _positive_step(h) + tolerance = np.broadcast_to(np.asarray(tol, dtype=float), (6,)) + if not np.all(np.isfinite(tolerance)) or np.any(tolerance <= 0): + raise ValueError("tol must contain finite positive tolerances") + self.tol = tuple(float(value) for value in tolerance) # Inherit most class vars from RK8Propagator, but need b7 coefficients b7 = np.array([13451932 / 455176623, 0, 0, 0, 0, -808719846 / 976000145, 1757004468 / 5645159321, 656045339 / 265891186, -3867574721 / 1518517206, 465885868 / 322736535, 53011238 / 667516719, 2 / 45, 0]) @@ -898,12 +956,15 @@ def fp(s, t): def step(h, t, state): while True: + _check_step_progress(t, h) k = np.zeros((13, 6), dtype=np.float64) for i in range(13): k[i] = h * fp(state + np.dot(a[i], k), t + c[i] * h) result7 = state + np.dot(b7, k) result8 = state + np.dot(b8, k) errmax = np.max(np.abs(result7 - result8) / self.tol) + if not np.isfinite(errmax) or not np.all(np.isfinite(result8)): + raise RuntimeError("RK78 encountered a non-finite state or error estimate") if errmax > (1.0): h *= max(0.1, 0.9 * errmax**(-1 / 7)) continue @@ -983,7 +1044,7 @@ class LeapfrogPropagator(RKPropagator): def __init__(self, accel, h): self.accel = accel - self.h = h + self.h = _positive_step(h) def __repr__(self): return "LeapfrogPropagator({!r}, {!r})".format( @@ -1005,6 +1066,7 @@ def accel_fn(r, v, t): keepGoing = True while keepGoing: + _check_step_progress(t, h) if not pred(t) and len(times) >= self._minPoints: keepGoing = False @@ -1071,7 +1133,7 @@ class Leapfrog4Propagator(RKPropagator): def __init__(self, accel, h): self.accel = accel - self.h = h + self.h = _positive_step(h) def __repr__(self): return "Leapfrog4Propagator({!r}, {!r})".format( @@ -1102,6 +1164,7 @@ def _prop(self, times, states, h, tthresh, propkw): keepGoing = True while keepGoing: + _check_step_progress(t, h) if not pred(t) and len(times) >= self._minPoints: keepGoing = False diff --git a/tests/test_patch_integration_regressions.py b/tests/test_patch_integration_regressions.py new file mode 100644 index 00000000..b413c1d3 --- /dev/null +++ b/tests/test_patch_integration_regressions.py @@ -0,0 +1,166 @@ +from collections import deque +from dataclasses import dataclass, field +import unittest +from unittest.mock import patch + +import numpy as np + +from ssapy.constants import EARTH_RADIUS +from ssapy.propagator import (Propagator, SciPyPropagator, RK4Propagator, RK8Propagator, + RK78Propagator, LeapfrogPropagator, Leapfrog4Propagator) +from ssapy.compute import rv, HashableArrayContainer, _countTime + + +@dataclass(eq=False) +class LinearState: + r: np.ndarray + v: np.ndarray + t: float = 0. + propkw: dict = field(default_factory=dict) + + +class ZeroAcceleration: + time_breakpoints = np.array([-np.inf, np.inf]) + def __call__(self, r, v, t, **kwargs): + return np.zeros(3) + + +class EpochCoverageTests(unittest.TestCase): + def states(self): + radius = EARTH_RADIUS + 100e3 + return [LinearState(np.array([radius + 1000, 0, 0]), np.array([100., 0, 0])), + LinearState(np.array([radius + 1500, 0, 0]), np.array([-100., 0, 0]))] + + def test_forward_backward_batch_matches_retained_epochs(self): + # One arc ends backward at -10 s, the other forward at +15 s. + # Include out-of-order and repeated query epochs to forbid prefix heuristics. + times = np.array([20., -20., 0., 5., -5., 0.]) + for prop in (SciPyPropagator(ZeroAcceleration(), {'max_step': 1.}), + RK4Propagator(ZeroAcceleration(), 1.)): + with self.subTest(propagator=type(prop).__name__): + states = self.states() + r, v, valid = prop._getRVManyWithMask(states, times) + np.testing.assert_array_equal(valid, [False, False, True, True, True, True]) + for index, state in enumerate(states): + expected = state.r + times[valid, None] * state.v + np.testing.assert_allclose(r[index], expected, atol=1e-6, rtol=0) + np.testing.assert_allclose(v[index], np.broadcast_to(state.v, expected.shape), atol=1e-9) + + def test_public_rv_returns_matching_epochs(self): + states = self.states() + times = np.array([-20., -5., 0., 5., 20.]) + with patch('ssapy.compute._countOrbit', return_value=(2, False, states)): + r, v, retained = rv(states, times, SciPyPropagator(ZeroAcceleration()), return_times=True) + np.testing.assert_array_equal(retained, [-5., 0., 5.]) + np.testing.assert_allclose(r[0], states[0].r + retained[:, None] * states[0].v, atol=1e-6) + + def test_legacy_prefix_subclasses_remain_compatible(self): + class Prefix(Propagator): + def _getRVOne(self, orbit, time): + return np.ones((orbit, 3)), np.zeros((orbit, 3)) + self.assertEqual(Prefix()._getRVMany([3, 1], np.arange(3.))[0].shape, (2, 1, 3)) + + def test_cached_termination_is_not_restarted_from_rounded_event_state(self): + for epoch in (1.4e9, 3.8e9): + for direction in (-1, 1): + with self.subTest(epoch=epoch, direction=direction): + state = LinearState(np.array([EARTH_RADIUS + 101e3, 0, 0]), + np.array([-direction * 123.456, 0, 0]), epoch) + prop = SciPyPropagator(ZeroAcceleration(), {'max_step': 3.1}) + query = epoch + direction * np.arange(0., 25., 2.) + first_r, first_v = prop._getRVOne(state, query) + self.assertEqual(len(first_r), 5) + with patch.object(prop, '_solve_piecewise_ivp', side_effect=AssertionError('restarted terminated arc')): + second_r, second_v = prop._getRVOne(state, query) + np.testing.assert_array_equal(second_r, first_r) + np.testing.assert_array_equal(second_v, first_v) + + def test_termination_at_one_end_does_not_block_other_end(self): + state = self.states()[0] + prop = SciPyPropagator(ZeroAcceleration()) + prop._getRVOne(state, np.array([-20., 0.])) + r, v = prop._getRVOne(state, np.array([0., 20.])) + np.testing.assert_allclose(r[-1], state.r + 20 * state.v, atol=1e-6) + + def test_empty_scalar_after_termination_is_not_squeezed_to_nonexistent_state(self): + states = self.states()[:1] + with patch('ssapy.compute._countOrbit', return_value=(1, True, states)): + r, v, retained = rv(states[0], -20., SciPyPropagator(ZeroAcceleration()), return_times=True) + self.assertEqual(r.shape, (0, 3)) + self.assertEqual(retained.shape, (0,)) + + +class ToleranceTests(unittest.TestCase): + def test_invalid_fixed_steps_are_rejected_before_entering_step_loops(self): + for cls in (RK4Propagator, RK8Propagator, RK78Propagator, + LeapfrogPropagator, Leapfrog4Propagator): + for h in (0., -1., np.nan, np.inf): + with self.subTest(propagator=cls.__name__, h=h), self.assertRaises(ValueError): + cls(ZeroAcceleration(), h) + + def test_rk78_accepts_documented_array_tolerance_without_cache_failure(self): + tol = np.array([1e-6] * 3 + [1e-9] * 3) + prop = RK78Propagator(ZeroAcceleration(), 1., tol) + same = RK78Propagator(prop.accel, 1., list(tol)) + self.assertEqual(hash(prop), hash(same)) + self.assertEqual(prop, same) + tol[:] = 100 + self.assertEqual(prop.tol[0], 1e-6) + state = LinearState(np.array([7e6, 0, 0]), np.array([0., 7500., 0.])) + r, v = prop._getRVOne(state, np.array([0., 1., 2.])) + np.testing.assert_allclose(r, state.r + np.arange(3.)[:, None] * state.v, atol=1e-6) + + def test_scipy_component_atol_is_hashable_and_owned(self): + tolerance = np.ones(6) * 1e-9 + options = {'atol': tolerance, 'rtol': 1e-8} + prop = SciPyPropagator(ZeroAcceleration(), options) + key = hash(prop) + tolerance[:] = 1 + options['rtol'] = 0.1 + self.assertEqual(hash(prop), key) + self.assertEqual(prop.ode_kwargs['atol'][0], 1e-9) + + def test_invalid_rk78_tolerance_and_nonadvancing_step_fail(self): + for tol in (0., -1., np.nan, np.inf): + with self.subTest(tol=tol), self.assertRaises(ValueError): + RK78Propagator(ZeroAcceleration(), 1, tol) + for cls in (RK4Propagator, RK8Propagator, RK78Propagator, + LeapfrogPropagator, Leapfrog4Propagator): + prop = cls(ZeroAcceleration(), 1e-12) + times = deque([1.4e9]) + states = deque([np.array([7e6, 0, 0, 0, 7500, 0.])]) + with self.subTest(propagator=cls.__name__), self.assertRaisesRegex(RuntimeError, 'cannot advance'): + prop._prop(times, states, 1e-12, 1.4e9 + 1, {}) + + +class CacheInputTests(unittest.TestCase): + def test_mutating_returned_states_does_not_corrupt_the_rv_cache(self): + state = LinearState(np.array([7e6, 0, 0]), np.array([0, 7500., 0])) + prop = SciPyPropagator(ZeroAcceleration()) + times = np.array([0., 1.]) + with patch('ssapy.compute._countOrbit', return_value=(1, True, (state,))): + first_r, first_v = rv(state, times, prop) + expected_r, expected_v = first_r.copy(), first_v.copy() + first_r /= 1000. + first_v[:] = 0. + second_r, second_v = rv(state, times, prop) + np.testing.assert_array_equal(second_r, expected_r) + np.testing.assert_array_equal(second_v, expected_v) + self.assertTrue(times.flags.writeable) + + def test_time_lists_are_converted_and_cache_keys_do_not_freeze_callers(self): + _, _, values = _countTime([0., 1., 2.]) + key = HashableArrayContainer(values) + old_hash = hash(key) + values[:] = 7 + self.assertEqual(hash(key), old_hash) + np.testing.assert_array_equal(key.arr, [0., 1., 2.]) + + def test_cache_equality_respects_shape_dtype_and_hash(self): + key = HashableArrayContainer(np.array([0., 1.])) + same = HashableArrayContainer(np.array([0., 1.])) + self.assertEqual(key, same) + self.assertEqual(hash(key), hash(same)) + self.assertNotEqual(key, HashableArrayContainer(np.array([[0., 1.]]))) + self.assertNotEqual(key, HashableArrayContainer(np.array([0, 1]))) + self.assertNotEqual(key, object()) From 18e750562ff8604fcc54f55ae03f73df5a89c5cd Mon Sep 17 00:00:00 2001 From: Travis Yeager Date: Sat, 19 Sep 2026 01:03:39 -0700 Subject: [PATCH 2/2] Allow ThreeAngle solver test more CI time --- tests/test_orbit_solver.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_orbit_solver.py b/tests/test_orbit_solver.py index 513b4540..4420b874 100644 --- a/tests/test_orbit_solver.py +++ b/tests/test_orbit_solver.py @@ -156,6 +156,7 @@ def testKappaSignPlane(): np.testing.assert_allclose(solver1.i, np.pi-solver2.i, rtol=0, atol=1e-9) +@pytest.mark.timeout(90) @timer def testThreeAngles(): np.random.seed(57721)