Skip to content

Commit 5ab85b2

Browse files
jobovyclaude
andcommitted
StreamTrack: NaN for tp outside the track range, not silent extrapolation
Previously _cart_eval/_eval_cart clipped tp to the spline's data support; out-of-range queries silently returned the boundary value rather than flagging an error. cov() did the same via numpy.interp's clamp behavior. That made it easy to miss when a caller was probing outside the fit (e.g. using -tdisrupt as a tp on a leading-arm track, where tp >= 0). Now: out-of-range tps return NaN; for an array tp, only the offending entries are NaN and the in-range entries are unaffected. cov() honors the same convention and skips the per-tp Jacobian step on NaN entries. Several existing tests were inadvertently passing -10/-20/-tdisrupt to leading-arm tracks (where the silent clamp masked it); switched them to query tp_grid()-derived in-range values. Added a dedicated test that asserts the NaN behavior for both scalar and array tps and across cov(basis=galcenrect|sky). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6a5e628 commit 5ab85b2

2 files changed

Lines changed: 111 additions & 39 deletions

File tree

galpy/df/streamTrack.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ class StreamTrack:
160160
the percentile-trimmed range of the closest-point assignments, bounded
161161
by ``track_time_range`` (typically much smaller than ``tdisrupt``).
162162
163+
Accessors and :meth:`cov` return ``NaN`` for ``tp`` values outside
164+
the track's valid range (rather than silent cubic-spline
165+
extrapolation). When ``tp`` is an array, only the offending entries
166+
are NaN.
167+
163168
Notes
164169
-----
165170
This class is intentionally light-weight and generic: while currently
@@ -471,10 +476,17 @@ def tp_grid(self):
471476
"""Return the fine tp grid on which the track is stored."""
472477
return self._tp_grid.copy()
473478

479+
def _in_range(self, tp_arr):
480+
"""Boolean mask over ``tp_arr`` for entries inside the track's
481+
valid ``tp`` range. Out-of-range tps get NaN accessor / cov
482+
outputs rather than silent cubic-spline extrapolation."""
483+
return (tp_arr >= self._tp_grid[0]) & (tp_arr <= self._tp_grid[-1])
484+
474485
def _eval_cart(self, tp):
475-
tp = numpy.clip(numpy.atleast_1d(tp), self._tp_grid[0], self._tp_grid[-1])
476-
out = numpy.array([spl(tp) for spl in self._cart_splines]) # (6, len)
477-
return out
486+
tp_arr = numpy.atleast_1d(tp)
487+
in_range = self._in_range(tp_arr)
488+
out = numpy.array([spl(tp_arr) for spl in self._cart_splines]) # (6, len)
489+
return numpy.where(in_range[None, :], out, numpy.nan)
478490

479491
def _maybe_scalar(self, tp, arr):
480492
if numpy.isscalar(tp) or (hasattr(tp, "ndim") and tp.ndim == 0):
@@ -485,10 +497,11 @@ def _parse_tp(self, tp):
485497
return conversion.parse_time(tp, ro=self._ro, vo=self._vo)
486498

487499
def _cart_eval(self, idx, tp):
488-
# Clip tp to the track's valid range to prevent unbounded cubic-spline
489-
# extrapolation outside the data support.
490-
tp_arr = numpy.clip(numpy.atleast_1d(tp), self._tp_grid[0], self._tp_grid[-1])
491-
val = self._cart_splines[idx](tp_arr)
500+
# Out-of-range tps return NaN (not silent cubic-spline extrapolation).
501+
# Array tps get NaNs only at the offending entries.
502+
tp_arr = numpy.atleast_1d(tp)
503+
in_range = self._in_range(tp_arr)
504+
val = numpy.where(in_range, self._cart_splines[idx](tp_arr), numpy.nan)
492505
return self._maybe_scalar(tp, val)
493506

494507
@physical_conversion("position", pop=True)
@@ -869,15 +882,15 @@ def cov(
869882

870883
tp = self._parse_tp(tp)
871884
tp_arr = numpy.atleast_1d(tp)
885+
in_range = self._in_range(tp_arr)
872886
out = numpy.empty((len(tp_arr), 6, 6))
873887
for a in range(6):
874888
for b in range(6):
875-
out[:, a, b] = numpy.interp(
876-
tp_arr, self._tp_grid, self._cov_xyz[:, a, b]
877-
)
889+
vals = numpy.interp(tp_arr, self._tp_grid, self._cov_xyz[:, a, b])
890+
out[:, a, b] = numpy.where(in_range, vals, numpy.nan)
878891
if use_phys:
879892
scale = numpy.array([ro_use, ro_use, ro_use, vo_use, vo_use, vo_use])
880-
out = out * numpy.outer(scale, scale)
893+
out = out * numpy.outer(scale, scale) # NaN · scale = NaN
881894

882895
if basis != "galcenrect":
883896
# When use_phys=True we thread the resolved ro/vo through the
@@ -890,6 +903,8 @@ def cov(
890903
jac_vo = vo_use if use_phys else None
891904
jac_use_phys = True if use_phys else None
892905
for k, tp_k in enumerate(tp_arr):
906+
if not in_range[k]:
907+
continue # out[k] already NaN; skip the Jacobian
893908
J = self._analytical_jacobian(
894909
tp_k,
895910
basis,

tests/test_streamspraydf.py

Lines changed: 85 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -906,7 +906,8 @@ def test_streamTrack_sample_consistency(_simple_spdf):
906906
def test_streamTrack_interface(_simple_spdf):
907907
numpy.random.seed(2)
908908
track = _simple_spdf.streamTrack(n=2000, ntp=41, tail="leading")
909-
tps = numpy.linspace(-_simple_spdf._tdisrupt, 0.0, 5)
909+
g = track.tp_grid()
910+
tps = numpy.linspace(g[0], g[-1], 5)
910911
for meth in (
911912
"x",
912913
"y",
@@ -938,7 +939,8 @@ def test_streamTrack_interface(_simple_spdf):
938939
def test_streamTrack_covariance_psd(_simple_spdf):
939940
numpy.random.seed(3)
940941
track = _simple_spdf.streamTrack(n=2000, ntp=41, tail="leading")
941-
tps = numpy.linspace(-_simple_spdf._tdisrupt, 0.0, 7)
942+
g = track.tp_grid()
943+
tps = numpy.linspace(g[0], g[-1], 7)
942944
covs = track.cov(tps)
943945
assert covs.shape == (len(tps), 6, 6)
944946
for k in range(len(tps)):
@@ -956,10 +958,13 @@ def test_streamTrack_both_tails(_simple_spdf):
956958
px, py = prog.x(0.0), prog.y(0.0)
957959
assert abs(pair.leading.x(0.0) - px) < 0.1
958960
assert abs(pair.trailing.x(0.0) - px) < 0.1
959-
# Deep into the stream, the two arms should diverge
960-
tp_deep = -0.7 * _simple_spdf._tdisrupt
961-
d_lead = (pair.leading.x(tp_deep) - pair.trailing.x(tp_deep)) ** 2 + (
962-
pair.leading.y(tp_deep) - pair.trailing.y(tp_deep)
961+
# Deep into the stream, the two arms diverge — pick the deepest in-range
962+
# point for each arm (leading arm: largest positive tp; trailing arm:
963+
# most negative tp).
964+
tp_lead = pair.leading.tp_grid()[-1]
965+
tp_trail = pair.trailing.tp_grid()[0]
966+
d_lead = (pair.leading.x(tp_lead) - pair.trailing.x(tp_trail)) ** 2 + (
967+
pair.leading.y(tp_lead) - pair.trailing.y(tp_trail)
963968
) ** 2
964969
assert d_lead > 0.01, "Leading and trailing arms do not diverge at large |tp|"
965970

@@ -971,7 +976,10 @@ def test_streamTrack_iteration_changes_track(_simple_spdf):
971976
tr0 = _simple_spdf.streamTrack(n=2000, ntp=41, niter=0, tail="leading")
972977
numpy.random.seed(5)
973978
tr1 = _simple_spdf.streamTrack(n=2000, ntp=41, niter=1, tail="leading")
974-
tps = numpy.linspace(-_simple_spdf._tdisrupt, 0.0, 101)
979+
# Compare on the in-range grid common to both tracks.
980+
lo = max(tr0.tp_grid()[0], tr1.tp_grid()[0])
981+
hi = min(tr0.tp_grid()[-1], tr1.tp_grid()[-1])
982+
tps = numpy.linspace(lo, hi, 101)
975983
# Track-to-track difference should be small compared to the stream size
976984
ampl = numpy.ptp(tr0.x(tps))
977985
dmax = numpy.max(numpy.abs(tr0.x(tps) - tr1.x(tps)))
@@ -994,9 +1002,16 @@ def test_streamTrack_chen24_works():
9941002
)
9951003
numpy.random.seed(6)
9961004
track = spdf.streamTrack(n=1500, ntp=41, tail="both")
997-
for tp in [-spdf._tdisrupt / 2, 0.0]:
998-
assert numpy.isfinite(track.leading.x(tp))
999-
assert numpy.isfinite(track.trailing.x(tp))
1005+
# Sample at midpoint of each arm's tp grid (guaranteed in-range).
1006+
tp_lead = track.leading.tp_grid()[len(track.leading.tp_grid()) // 2]
1007+
tp_trail = track.trailing.tp_grid()[len(track.trailing.tp_grid()) // 2]
1008+
for tp, arm in [
1009+
(tp_lead, track.leading),
1010+
(tp_trail, track.trailing),
1011+
(0.0, track.leading),
1012+
(0.0, track.trailing),
1013+
]:
1014+
assert numpy.isfinite(arm.x(tp))
10001015

10011016

10021017
def test_streamTrack_with_center():
@@ -1015,22 +1030,24 @@ def test_streamTrack_with_center():
10151030
)
10161031
numpy.random.seed(7)
10171032
track = spdf.streamTrack(n=1500, ntp=31, tail="leading")
1018-
tps = numpy.linspace(-spdf._tdisrupt, 0.0, 5)
1033+
g = track.tp_grid()
1034+
tps = numpy.linspace(g[0], g[-1], 5)
10191035
vals = track.x(tps)
10201036
assert numpy.all(numpy.isfinite(vals))
10211037

10221038

10231039
def test_streamTrack_physical_units(_simple_spdf):
10241040
numpy.random.seed(8)
10251041
track = _simple_spdf.streamTrack(n=1500, ntp=31, tail="leading")
1026-
x0 = track.x(-10.0)
1042+
tp = track.tp_grid()[len(track.tp_grid()) // 2]
1043+
x0 = track.x(tp)
10271044
track.turn_physical_on(ro=8.0, vo=220.0)
1028-
x0_phys = track.x(-10.0)
1045+
x0_phys = track.x(tp)
10291046
# physical x should be ~ ro * internal
10301047
val = x0_phys.value if hasattr(x0_phys, "value") else x0_phys
10311048
assert abs(val - 8.0 * x0) < 1e-6
10321049
track.turn_physical_off()
1033-
assert abs(track.x(-10.0) - x0) < 1e-10
1050+
assert abs(track.x(tp) - x0) < 1e-10
10341051

10351052

10361053
def test_streamTrack_cov_physical_units(_simple_spdf):
@@ -1039,10 +1056,11 @@ def test_streamTrack_cov_physical_units(_simple_spdf):
10391056
# by vo^2, cross terms by ro*vo.
10401057
numpy.random.seed(18)
10411058
track = _simple_spdf.streamTrack(n=1500, ntp=31, tail="leading")
1059+
tp = track.tp_grid()[len(track.tp_grid()) // 2]
10421060
track.turn_physical_off()
1043-
C_int = track.cov(-10.0)
1061+
C_int = track.cov(tp)
10441062
track.turn_physical_on(ro=8.0, vo=220.0)
1045-
C_phys = track.cov(-10.0)
1063+
C_phys = track.cov(tp)
10461064
ro, vo = 8.0, 220.0
10471065
scale = numpy.array([ro, ro, ro, vo, vo, vo])
10481066
expected = C_int * numpy.outer(scale, scale)
@@ -1078,7 +1096,7 @@ def test_streamTrack_physical_accessors_all(_simple_spdf):
10781096
numpy.random.seed(11)
10791097
track = _simple_spdf.streamTrack(n=1500, ntp=31, tail="leading")
10801098
track.turn_physical_on(ro=8.0, vo=220.0)
1081-
tp = -10.0
1099+
tp = track.tp_grid()[len(track.tp_grid()) // 2]
10821100
for meth in (
10831101
"x",
10841102
"y",
@@ -1117,11 +1135,12 @@ def test_streamTrack_pair_physical_toggles(_simple_spdf):
11171135
numpy.random.seed(12)
11181136
pair = _simple_spdf.streamTrack(n=1500, ntp=31, tail="both")
11191137
pair.turn_physical_on(ro=8.0, vo=220.0)
1120-
v = pair.leading.x(-5.0)
1138+
tp_lead = pair.leading.tp_grid()[len(pair.leading.tp_grid()) // 2]
1139+
v = pair.leading.x(tp_lead)
11211140
val = getattr(v, "value", v)
11221141
assert val > 0.0
11231142
pair.turn_physical_off()
1124-
v2 = pair.leading.x(-5.0)
1143+
v2 = pair.leading.x(tp_lead)
11251144
assert not hasattr(v2, "unit")
11261145
import matplotlib
11271146

@@ -1149,6 +1168,40 @@ def test_streamTrack_tp_grid(_simple_spdf):
11491168
assert g[-1] > 0.0
11501169

11511170

1171+
def test_streamTrack_out_of_range_returns_nan(_simple_spdf):
1172+
# Out-of-range tp must return NaN — never silent cubic-spline
1173+
# extrapolation. For an array tp, only the offending entries are NaN.
1174+
numpy.random.seed(20)
1175+
track = _simple_spdf.streamTrack(n=800, ntp=31, tail="leading")
1176+
g = track.tp_grid()
1177+
tp_lo, tp_hi = g[0], g[-1]
1178+
# Scalar out-of-range: NaN
1179+
assert numpy.isnan(track.x(tp_hi + 5.0))
1180+
assert numpy.isnan(track.R(tp_hi + 5.0))
1181+
assert numpy.isnan(track.ra(tp_hi + 5.0))
1182+
# Negative side (leading arm has tp_lo == 0)
1183+
assert numpy.isnan(track.x(tp_lo - 1.0))
1184+
# Scalar in-range: finite
1185+
tp_mid = 0.5 * (tp_lo + tp_hi)
1186+
assert numpy.isfinite(track.x(tp_mid))
1187+
# Array tp with mixed in/out: NaN only at out-of-range entries
1188+
tps = numpy.array([tp_lo - 1.0, tp_mid, tp_hi + 5.0])
1189+
xs = numpy.asarray(track.x(tps))
1190+
assert numpy.isnan(xs[0])
1191+
assert numpy.isfinite(xs[1])
1192+
assert numpy.isnan(xs[2])
1193+
# cov() honors the same convention; out-of-range entries are NaN
1194+
Cs = track.cov(tps)
1195+
assert numpy.all(numpy.isnan(Cs[0]))
1196+
assert numpy.all(numpy.isfinite(Cs[1]))
1197+
assert numpy.all(numpy.isnan(Cs[2]))
1198+
# cov(basis=...) too — NaN entries skip the Jacobian path safely
1199+
Cs_sky = track.cov(tps, basis="sky")
1200+
assert numpy.all(numpy.isnan(Cs_sky[0]))
1201+
assert numpy.all(numpy.isfinite(Cs_sky[1]))
1202+
assert numpy.all(numpy.isnan(Cs_sky[2]))
1203+
1204+
11521205
def test_streamTrack_order1_no_cov(_simple_spdf):
11531206
numpy.random.seed(15)
11541207
track = _simple_spdf.streamTrack(n=800, ntp=31, tail="leading", order=1)
@@ -1176,36 +1229,39 @@ def test_streamTrack_particles_reuse_both(_simple_spdf):
11761229
def test_streamTrack_scalar_cov(_simple_spdf):
11771230
numpy.random.seed(17)
11781231
track = _simple_spdf.streamTrack(n=800, ntp=31, tail="leading")
1179-
C = track.cov(-20.0)
1232+
tp = track.tp_grid()[len(track.tp_grid()) // 2]
1233+
C = track.cov(tp)
11801234
assert C.shape == (6, 6)
1235+
assert numpy.all(numpy.isfinite(C))
11811236

11821237

11831238
def test_streamTrack_cov_per_call_unit_overrides(_simple_spdf):
11841239
# cov() honors per-call ro=, vo=, use_physical= (the same way the mean
11851240
# accessors do) so callers don't need to flip the track-wide toggle.
11861241
numpy.random.seed(18)
11871242
track = _simple_spdf.streamTrack(n=1500, ntp=31, tail="leading")
1243+
tp = track.tp_grid()[len(track.tp_grid()) // 2]
11881244
track.turn_physical_off()
1189-
C_int = track.cov(-10.0)
1245+
C_int = track.cov(tp)
11901246
# ro=, vo= scale the entries even when the track is in internal mode
11911247
ro, vo = 8.0, 220.0
1192-
C_phys_via_kw = track.cov(-10.0, ro=ro, vo=vo, use_physical=True)
1248+
C_phys_via_kw = track.cov(tp, ro=ro, vo=vo, use_physical=True)
11931249
scale = numpy.array([ro, ro, ro, vo, vo, vo])
11941250
assert numpy.allclose(C_phys_via_kw, C_int * numpy.outer(scale, scale), rtol=1e-10)
11951251
# use_physical=False on a physical-mode track gives back internal cov
11961252
track.turn_physical_on(ro=ro, vo=vo)
1197-
C_back_to_int = track.cov(-10.0, use_physical=False)
1253+
C_back_to_int = track.cov(tp, use_physical=False)
11981254
assert numpy.allclose(C_back_to_int, C_int, rtol=1e-10)
11991255
# quantity=True is explicitly not supported (heterogeneous units)
12001256
with pytest.raises(NotImplementedError):
1201-
track.cov(-10.0, quantity=True)
1257+
track.cov(tp, quantity=True)
12021258
# Per-call overrides also have to thread through the analytical
12031259
# Jacobian for non-galcenrect bases — exercise the sky path with
12041260
# explicit ro/vo so the override branches in _cart_mean_at and
12051261
# _analytical_jacobian (where ``ro``/``vo``/``use_physical`` are
12061262
# forwarded to the accessors and used as Xsun) are taken.
12071263
track.turn_physical_off()
1208-
C_sky_with_kw = track.cov(-10.0, basis="sky", ro=ro, vo=vo, use_physical=True)
1264+
C_sky_with_kw = track.cov(tp, basis="sky", ro=ro, vo=vo, use_physical=True)
12091265
assert C_sky_with_kw.shape == (6, 6)
12101266
assert numpy.allclose(C_sky_with_kw, C_sky_with_kw.T, atol=1e-8)
12111267

@@ -1228,7 +1284,8 @@ def test_streamTrack_degenerate_few_particles(_simple_spdf):
12281284
# exercising the degenerate paths in _bin_offsets and _smooth_series.
12291285
numpy.random.seed(19)
12301286
track = _simple_spdf.streamTrack(n=10, ntp=51, tail="leading")
1231-
assert numpy.isfinite(track.x(-10.0))
1287+
tp = track.tp_grid()[len(track.tp_grid()) // 2]
1288+
assert numpy.isfinite(track.x(tp))
12321289

12331290

12341291
def test_streamTrack_custom_track_time_range(_simple_spdf):
@@ -1250,8 +1307,8 @@ def test_streamTrack_smoothing_variants(_simple_spdf):
12501307
tail="leading",
12511308
smoothing={"x": 20.0, "y": 20.0},
12521309
)
1253-
assert numpy.isfinite(tr_f.x(-10.0))
1254-
assert numpy.isfinite(tr_d.x(-10.0))
1310+
assert numpy.isfinite(tr_f.x(tr_f.tp_grid()[len(tr_f.tp_grid()) // 2]))
1311+
assert numpy.isfinite(tr_d.x(tr_d.tp_grid()[len(tr_d.tp_grid()) // 2]))
12551312
# Array-like smoothing: reuse smoothing_s from a previous fit
12561313
numpy.random.seed(18)
12571314
tr_gcv = _simple_spdf.streamTrack(n=800, tail="leading", order=2)

0 commit comments

Comments
 (0)