In skyfield/jpllib.py, the Stack class is responsible for selecting the correct segment when a body is stored across multiple date ranges — for example DE441, which stores each body across two epochs. The scalar path in Stack._at() uses a for/break pattern to find the covering segment:
for segment in reversed(self.segments):
spk = segment.spk_segment
if spk.start_jd <= t.tdb <= spk.end_jd:
break
return segment._at(t)
If no segment covers t.tdb — for example, a date that falls between segments or outside the kernel's full range — the loop exhausts without breaking and segment is left bound to the last segment iterated. segment._at(t) is then called on the wrong segment, returning a silently incorrect position with no error raised.
Expected behaviour
When no segment covers the requested date, Stack._at() should raise EphemerisRangeError — consistent with the behaviour of ChebyshevPosition._at() and consistent with the array path in Stack._at() itself, which correctly fills with nan for uncovered indices.
Actual behaviour
A wrong position is returned silently. No exception is raised. The caller has no way to detect that the result is incorrect.
Reproducer
Load DE441 and request a position at a date that falls outside any single segment's coverage but inside the kernel's nominal range. The scalar path will silently return the position from whichever segment happened to be last in the iteration, rather than raising.
Root cause
Python's for/break pattern does not raise on loop exhaustion — the loop variable retains its last value. The correct pattern is an explicit return inside the loop body and a raise after the loop:
for segment in reversed(self.segments):
spk = segment.spk_segment
if spk.start_jd <= t.tdb <= spk.end_jd:
return segment._at(t)
start_time, end_time = self.segments[0].time_range(t.ts), self.segments[-1].time_range(t.ts)
raise EphemerisRangeError(
'no segment in Stack covers the requested date',
start_time[0], end_time[1], t, None
)
Asymmetry with the array path
The array path already handles this correctly — it fills with nan for unmatched indices and leaves the caller to detect gaps. The scalar path should at minimum raise rather than silently corrupt the result.
Note on ordering assumption
The current fix iterates reversed(self.segments), which implicitly assumes that the covering segment is more likely to be near the end of the list (i.e. modern-era segments appear last). This assumption holds for DE441's two-segment layout but is not guaranteed by the SPK format. A forward iteration with early return makes no ordering assumption and is equally efficient for the two-segment case.
Discovery context
This bug was discovered during the development and validation of Moira, a pure-Python astronomical engine that uses jplephem directly for DE441 kernel reads. Moira's SpkReader implements explicit epoch-aware segment selection — iterating all segments for a body pair and selecting the one whose start_jd ≤ jd ≤ end_jd, raising KeyError if no segment covers the date. Comparing Moira's behaviour against Skyfield's during validation revealed that the Stack scalar path could return without finding a covering segment.
A related segment-selection bug in jplephem itself — where kernel[center, target] naively returns the last matching segment regardless of date, giving wrong positions for historical epochs — was reported separately to the jplephem repository.
Environment
Skyfield version: current master
Ephemeris: DE441 (single-file, two segments per body)
Python: 3.11+
In skyfield/jpllib.py, the Stack class is responsible for selecting the correct segment when a body is stored across multiple date ranges — for example DE441, which stores each body across two epochs. The scalar path in Stack._at() uses a for/break pattern to find the covering segment:
If no segment covers t.tdb — for example, a date that falls between segments or outside the kernel's full range — the loop exhausts without breaking and segment is left bound to the last segment iterated. segment._at(t) is then called on the wrong segment, returning a silently incorrect position with no error raised.
Expected behaviour
When no segment covers the requested date, Stack._at() should raise EphemerisRangeError — consistent with the behaviour of ChebyshevPosition._at() and consistent with the array path in Stack._at() itself, which correctly fills with nan for uncovered indices.
Actual behaviour
A wrong position is returned silently. No exception is raised. The caller has no way to detect that the result is incorrect.
Reproducer
Load DE441 and request a position at a date that falls outside any single segment's coverage but inside the kernel's nominal range. The scalar path will silently return the position from whichever segment happened to be last in the iteration, rather than raising.
Root cause
Python's for/break pattern does not raise on loop exhaustion — the loop variable retains its last value. The correct pattern is an explicit return inside the loop body and a raise after the loop:
Asymmetry with the array path
The array path already handles this correctly — it fills with nan for unmatched indices and leaves the caller to detect gaps. The scalar path should at minimum raise rather than silently corrupt the result.
Note on ordering assumption
The current fix iterates reversed(self.segments), which implicitly assumes that the covering segment is more likely to be near the end of the list (i.e. modern-era segments appear last). This assumption holds for DE441's two-segment layout but is not guaranteed by the SPK format. A forward iteration with early return makes no ordering assumption and is equally efficient for the two-segment case.
Discovery context
This bug was discovered during the development and validation of Moira, a pure-Python astronomical engine that uses jplephem directly for DE441 kernel reads. Moira's SpkReader implements explicit epoch-aware segment selection — iterating all segments for a body pair and selecting the one whose start_jd ≤ jd ≤ end_jd, raising KeyError if no segment covers the date. Comparing Moira's behaviour against Skyfield's during validation revealed that the Stack scalar path could return without finding a covering segment.
A related segment-selection bug in jplephem itself — where kernel[center, target] naively returns the last matching segment regardless of date, giving wrong positions for historical epochs — was reported separately to the jplephem repository.
Environment
Skyfield version: current master
Ephemeris: DE441 (single-file, two segments per body)
Python: 3.11+