Source code for adam_core.orbits.trajectory

"""Piecewise, validity-bounded trajectory of orbit states.

A :class:`Trajectory` is an ordered set of *segments*. Each segment is one
anchor :class:`~adam_core.orbits.orbits.Orbits` state (position, velocity and
6x6 covariance at a single epoch) plus a *coverage window* -- the time interval
over which that anchor state is the authoritative source for the object. This
mirrors the CCSDS OEM / SPICE notion of an ephemeris segment's useable
interval (see :mod:`adam_core.orbits.oem_io`).

This is the in-memory type a search consumes when an object's state is only
valid piecewise (e.g. spacecraft trajectories reconstructed from an SPK, where
solar-radiation-pressure and maneuvers make a single propagated state diverge
over time). For a given search span the caller binds the one segment whose
coverage window contains the span's reference time via :meth:`segment_for`, and
fails closed on coverage gaps and overlaps.

A conventional single-state orbit is simply a one-segment trajectory whose
coverage window spans the whole search, so callers that never build a
:class:`Trajectory` are unaffected.
"""

from typing import Optional

import numpy as np
import numpy.typing as npt
import quivr as qv

from ..time import Timestamp
from .orbits import Orbits


[docs] class Trajectory(qv.Table): """A set of validity-bounded orbit segments (rows are segments). Segments belonging to the same ``object_id`` must have non-overlapping coverage windows; gaps between them are allowed and are treated as "no valid state" (fail closed) rather than silently extrapolated. """ object_id = qv.LargeStringColumn() segment_id = qv.LargeStringColumn() # Coverage window [coverage_start, coverage_end) over which ``orbit`` is the # authoritative state for the object (OEM USEABLE_START/STOP semantics). coverage_start = Timestamp.as_column() coverage_end = Timestamp.as_column() # Anchor state + 6x6 covariance + epoch + frame + origin for this segment. orbit = Orbits.as_column() # Provenance / constraints. source = qv.LargeStringColumn(nullable=True) source_version = qv.LargeStringColumn(nullable=True) max_propagation_days = qv.Float64Column(nullable=True) is_maneuver_boundary = qv.BooleanColumn(nullable=True)
[docs] def coverage_start_mjd(self) -> npt.NDArray[np.float64]: """Coverage-window start times as TDB MJD.""" return np.asarray( self.coverage_start.rescale("tdb").mjd().to_numpy(zero_copy_only=False), dtype=np.float64, )
[docs] def coverage_end_mjd(self) -> npt.NDArray[np.float64]: """Coverage-window end times as TDB MJD.""" return np.asarray( self.coverage_end.rescale("tdb").mjd().to_numpy(zero_copy_only=False), dtype=np.float64, )
[docs] def epoch_mjd(self) -> npt.NDArray[np.float64]: """Anchor-state epochs as TDB MJD.""" return np.asarray( self.orbit.coordinates.time.rescale("tdb") .mjd() .to_numpy(zero_copy_only=False), dtype=np.float64, )
[docs] def object_ids(self) -> list[str]: """Distinct object ids, in first-seen order.""" seen: dict[str, None] = {} for value in self.object_id.to_pylist(): if value is not None: seen.setdefault(str(value), None) return list(seen)
[docs] def validate_coverage(self) -> "Trajectory": """Check structural invariants; raise ``ValueError`` on violation. Enforces, per object: ``coverage_end`` strictly after ``coverage_start``, each anchor epoch inside its own coverage window, and non-overlapping coverage windows (touching endpoints are allowed because windows are half-open ``[start, end)``). """ starts = self.coverage_start_mjd() ends = self.coverage_end_mjd() epochs = self.epoch_mjd() if bool(np.any(ends <= starts)): raise ValueError( "Trajectory coverage_end must be strictly after coverage_start" ) if bool(np.any((epochs < starts) | (epochs > ends))): raise ValueError( "Trajectory segment orbit epoch must lie within its coverage window" ) objects: npt.NDArray[np.object_] = np.asarray( self.object_id.to_pylist(), dtype=object ) for obj in self.object_ids(): mask = objects == obj order = np.argsort(starts[mask], kind="stable") s = starts[mask][order] e = ends[mask][order] if s.size > 1 and bool(np.any(s[1:] < e[:-1])): raise ValueError( f"Trajectory has overlapping coverage windows for object {obj!r}" ) return self
[docs] def segment_for( self, time_mjd_tdb: float, object_id: Optional[str] = None ) -> Optional["Trajectory"]: """Return the single segment whose coverage window contains ``time``. Coverage windows are half-open ``[coverage_start, coverage_end)``. Returns ``None`` when no segment covers the time (a gap; fail closed). Raises ``ValueError`` when more than one segment matches (an invalid, overlapping trajectory) or when the trajectory holds multiple objects and ``object_id`` was not supplied. """ traj: "Trajectory" = self if object_id is not None: objects: npt.NDArray[np.object_] = np.asarray( self.object_id.to_pylist(), dtype=object ) traj = self.take(np.nonzero(objects == object_id)[0]) elif len(traj.object_ids()) > 1: raise ValueError( "segment_for requires object_id when the trajectory holds " "multiple objects" ) starts = traj.coverage_start_mjd() ends = traj.coverage_end_mjd() covers = (time_mjd_tdb >= starts) & (time_mjd_tdb < ends) idx = np.nonzero(covers)[0] if idx.size == 0: return None if idx.size > 1: raise ValueError( f"Overlapping coverage windows match t={time_mjd_tdb}; " "trajectory is invalid" ) return traj.take(idx)