Source code for empyrean.propagation.events

"""Event types from orbit propagation.

Each event type is a quivr Table with ``orbit_id`` (primary key) and
``object_id`` (optional metadata) columns. Tables are grouped inside
the :class:`Events` dataclass returned by propagation.
"""

from __future__ import annotations

from collections.abc import Iterable
from dataclasses import dataclass, fields
from typing import TYPE_CHECKING, TypeVar

import pyarrow as pa
import pyarrow.compute as pc
import quivr as qv

from empyrean.coordinates.enums import Origin

if TYPE_CHECKING:
    from typing import Protocol

    class _IsIn(Protocol):
        """Static signature for ``pyarrow.compute.is_in``.

        pyarrow ships ``py.typed`` but generates every ``pyarrow.compute``
        function dynamically at import, so it declares no static signature for
        ``is_in``. Bind it to this precisely-typed callable so the call site
        type-checks without resorting to ``Any`` or ``# type: ignore``.
        """

        def __call__(
            self, values: pa.ChunkedArray, /, *, value_set: pa.Array
        ) -> pa.BooleanArray: ...

    _is_in: _IsIn
else:
    _is_in = pc.is_in

_T = TypeVar("_T", bound=qv.Table)


def _select_event_table_by_orbit(table: _T, orbit_ids: list[str]) -> _T:
    """Filter a per-event-type quivr Table to rows matching the given orbit_ids."""
    if len(table) == 0:
        return table
    mask = _is_in(table.column("orbit_id"), value_set=pa.array(orbit_ids))
    return table.apply_mask(mask)


# ── Summary ──────────────────────────────────────────────────


[docs] class EventSummary(qv.Table): """One-row-per-event summary across all event types.""" orbit_id = qv.LargeStringColumn() object_id = qv.LargeStringColumn(nullable=True) event_type = qv.LargeStringColumn() body = qv.LargeStringColumn() epoch = qv.Float64Column() # MJD TDB
# ── Close approaches ────────────────────────────────────────
[docs] class CloseApproachStarts(qv.Table): """Close approach zone entry events.""" orbit_id = qv.LargeStringColumn() object_id = qv.LargeStringColumn(nullable=True) body = qv.LargeStringColumn() epoch = qv.Float64Column() distance_au = qv.Float64Column() distance_km = qv.Float64Column()
[docs] class CloseApproachEnds(qv.Table): """Close approach zone exit events.""" orbit_id = qv.LargeStringColumn() object_id = qv.LargeStringColumn(nullable=True) body = qv.LargeStringColumn() epoch = qv.Float64Column() distance_au = qv.Float64Column() distance_km = qv.Float64Column()
# ── Periapses ────────────────────────────────────────────────
[docs] class Periapses(qv.Table): """Periapsis (closest approach) events within a CA zone.""" orbit_id = qv.LargeStringColumn() object_id = qv.LargeStringColumn(nullable=True) body = qv.LargeStringColumn() epoch = qv.Float64Column() distance_au = qv.Float64Column() distance_km = qv.Float64Column() relative_velocity_au_day = qv.Float64Column() relative_x = qv.Float64Column() relative_y = qv.Float64Column() relative_z = qv.Float64Column() relative_vx = qv.Float64Column() relative_vy = qv.Float64Column() relative_vz = qv.Float64Column()
# ── Impacts ──────────────────────────────────────────────────
[docs] class Impacts(qv.Table): """Nominal impact events (body surface intersection).""" orbit_id = qv.LargeStringColumn() object_id = qv.LargeStringColumn(nullable=True) body = qv.LargeStringColumn() epoch = qv.Float64Column() latitude_deg = qv.Float64Column(nullable=True) longitude_deg = qv.Float64Column(nullable=True) altitude_km = qv.Float64Column(nullable=True)
# ── Possible impacts ────────────────────────────────────────
[docs] class PossibleImpacts(qv.Table): """Probabilistic impact assessments from covariance analysis.""" orbit_id = qv.LargeStringColumn() object_id = qv.LargeStringColumn(nullable=True) body = qv.LargeStringColumn() epoch = qv.Float64Column() miss_distance_au = qv.Float64Column() miss_distance_km = qv.Float64Column() effective_radius_au = qv.Float64Column() effective_radius_km = qv.Float64Column() sigma_distance_au = qv.Float64Column() ip_linear = qv.Float64Column() relative_velocity_au_day = qv.Float64Column() ip_second_order = qv.Float64Column(nullable=True) nonlinearity = qv.Float64Column(nullable=True) ip_agm = qv.Float64Column(nullable=True) ip_mc = qv.Float64Column(nullable=True)
# ── Atmospheric ──────────────────────────────────────────────
[docs] class AtmosphericEntries(qv.Table): """Atmospheric entry events (Karman line inbound crossing). ``distance_au`` is the body-CENTER crossing distance (the Karman radius). ``altitude_km`` is the true altitude above the reference ellipsoid from the planetodetic ground track, and ``latitude_deg`` / ``longitude_deg`` the sub-point — all three null when the ground track is unresolved (they are NOT ``distance_au`` relabelled). ``relative_velocity_au_day`` is the body-relative speed at entry. """ orbit_id = qv.LargeStringColumn() object_id = qv.LargeStringColumn(nullable=True) body = qv.LargeStringColumn() epoch = qv.Float64Column() distance_au = qv.Float64Column() altitude_km = qv.Float64Column(nullable=True) relative_velocity_au_day = qv.Float64Column(nullable=True) latitude_deg = qv.Float64Column(nullable=True) longitude_deg = qv.Float64Column(nullable=True)
[docs] class AtmosphericExits(qv.Table): """Atmospheric exit events.""" orbit_id = qv.LargeStringColumn() object_id = qv.LargeStringColumn(nullable=True) body = qv.LargeStringColumn() epoch = qv.Float64Column() distance_au = qv.Float64Column()
# ── Capture ──────────────────────────────────────────────────
[docs] class CaptureStarts(qv.Table): """Temporary gravitational capture start events.""" orbit_id = qv.LargeStringColumn() object_id = qv.LargeStringColumn(nullable=True) body = qv.LargeStringColumn() epoch = qv.Float64Column() distance_au = qv.Float64Column() distance_km = qv.Float64Column() relative_velocity_au_day = qv.Float64Column() two_body_energy = qv.Float64Column() jacobi_constant = qv.Float64Column(nullable=True) jacobi_constant_sigma = qv.Float64Column(nullable=True) jacobi_constant_l1 = qv.Float64Column(nullable=True) jacobi_constant_l2 = qv.Float64Column(nullable=True)
[docs] class CaptureEnds(qv.Table): """Temporary gravitational capture end (escape) events.""" orbit_id = qv.LargeStringColumn() object_id = qv.LargeStringColumn(nullable=True) body = qv.LargeStringColumn() epoch = qv.Float64Column() distance_au = qv.Float64Column() distance_km = qv.Float64Column() relative_velocity_au_day = qv.Float64Column() two_body_energy = qv.Float64Column() jacobi_constant = qv.Float64Column(nullable=True) jacobi_constant_sigma = qv.Float64Column(nullable=True) jacobi_constant_l1 = qv.Float64Column(nullable=True) jacobi_constant_l2 = qv.Float64Column(nullable=True) n_periapses = qv.Int32Column()
# ── Shadow ───────────────────────────────────────────────────
[docs] class ShadowEntries(qv.Table): """Shadow zone entry events (Sun occluded by a body).""" orbit_id = qv.LargeStringColumn() object_id = qv.LargeStringColumn(nullable=True) body = qv.LargeStringColumn() epoch = qv.Float64Column() shadow_fraction = qv.Float64Column() illumination = qv.Float64Column()
[docs] class ShadowExits(qv.Table): """Shadow zone exit events (Sun no longer occluded).""" orbit_id = qv.LargeStringColumn() object_id = qv.LargeStringColumn(nullable=True) body = qv.LargeStringColumn() epoch = qv.Float64Column() shadow_fraction = qv.Float64Column() illumination = qv.Float64Column()
# ── Covariance regime change ───────────────────────────────── class CovarianceRegimeChanges(qv.Table): """Covariance-regime transitions from ``UncertaintyMethod.AUTO``. Each row records a close-approach-window boundary where the resolved covariance kind changed (e.g. ``linear`` -> ``second_order``) because the local nonlinearity ``kappa`` crossed a band edge. This is the audit trail behind Auto's per-window covariance-kind decisions; the ``previous_kind`` / ``resolved_kind`` strings are :class:`~empyrean.propagation.tagged_covariance.CovarianceKind` values. """ orbit_id = qv.LargeStringColumn() object_id = qv.LargeStringColumn(nullable=True) body = qv.LargeStringColumn(nullable=True) epoch = qv.Float64Column() previous_kind = qv.LargeStringColumn(nullable=True) resolved_kind = qv.LargeStringColumn(nullable=True) kappa = qv.Float64Column(nullable=True) threshold_below = qv.Float64Column(nullable=True) threshold_above = qv.Float64Column(nullable=True) # ── Container ────────────────────────────────────────────────
[docs] @dataclass class Events: """All events detected during propagation, grouped by type.""" summary: EventSummary close_approach_starts: CloseApproachStarts close_approach_ends: CloseApproachEnds periapses: Periapses impacts: Impacts possible_impacts: PossibleImpacts atmospheric_entries: AtmosphericEntries atmospheric_exits: AtmosphericExits capture_starts: CaptureStarts capture_ends: CaptureEnds shadow_entries: ShadowEntries shadow_exits: ShadowExits covariance_regime_changes: CovarianceRegimeChanges
[docs] def count_by_type(self) -> dict[str, int]: """Per-event-type row count, including ``summary``. Useful as a quick triage view — `events.count_by_type()` answers "did the propagation produce any close approaches / impacts / captures?" without a multi-line group-by. """ return {f.name: len(getattr(self, f.name)) for f in fields(self)}
[docs] def select_orbit(self, orbit_ids: str | Iterable[str]) -> Events: """Return a new :class:`Events` containing only rows whose ``orbit_id`` matches one of the requested IDs. Filters every per-event-type table at once, including the ``summary``, so callers don't have to remember which 13 tables to thread the filter through. """ if isinstance(orbit_ids, str): ids: list[str] = [orbit_ids] else: ids = list(orbit_ids) return Events( **{ f.name: _select_event_table_by_orbit(getattr(self, f.name), ids) for f in fields(self) } )
# ── Configuration ────────────────────────────────────────────
[docs] @dataclass class EventConfig: """Configuration for event detection during propagation. Parameters ---------- close_approaches : bool Detect close approach periapses. Default True. impacts : bool Detect nominal impacts. Default True. atmospheric : bool Detect atmospheric entry/exit. Default True. possible_impacts : bool Compute impact probabilities. Default True. shadow_events : bool Detect shadow entry/exit. Default True. body_filter : list[Origin | str], optional Restrict monitoring to specific bodies. Pass a list of :class:`Origin` (e.g. ``[Origin.EARTH, Origin.MOON]``) or the canonical names (e.g. ``["Earth", "Moon"]``). ``None`` means all bodies. dense_output : bool Insert dense-state points around close approaches via the integrator's per-step interpolant. Auto-enables :attr:`AdvancedIntegratorConfig.cache_integrator_steps`. Default False. dense_output_cadence_days : float Cadence (days) of dense output points around close approaches. Default 5 minutes (= ``5.0 / 1440.0``). """ close_approaches: bool = True impacts: bool = True atmospheric: bool = True possible_impacts: bool = True shadow_events: bool = True body_filter: list[Origin | str] | None = None dense_output: bool = False dense_output_cadence_days: float = 5.0 / 1440.0