Source code for empyrean.od.ades_observations

"""ADES optical observation types."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Protocol

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

if TYPE_CHECKING:

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

        pyarrow generates its compute kernels (``is_in`` among them) into the
        module namespace at import time, so they are invisible to static
        analysis even though the module itself resolves. This Protocol gives
        mypy the true signature; the runtime binding below is the real
        ``pc.is_in`` function, so behavior is unchanged.
        """

        def __call__(
            self,
            values: pa.ChunkedArray | pa.Array,
            /,
            value_set: pa.Array,
            *,
            skip_nulls: bool = ...,
            options: Any = ...,
            memory_pool: pa.MemoryPool | None = ...,
        ) -> pa.BooleanArray: ...


# Typed handle onto the dynamically-generated kernel (see _IsIn above). The
# attribute is injected into pyarrow.compute's namespace at import time, so it
# is fetched from the module dict and re-typed through the Protocol for static
# callers.
_is_in: _IsIn = pc.__dict__["is_in"]


[docs] class ADESObservations(qv.Table): """ADES optical observations — full schema. Every named PSV column round-trips losslessly across the Python ↔ wrapper ↔ C ABI boundary. The class name signals that the schema is the MPC ADES astrometric data exchange standard, not a generic observations record. """ # ── Identification ──────────────────────────────── perm_id = qv.LargeStringColumn(nullable=True) prov_id = qv.LargeStringColumn(nullable=True) trk_sub = qv.LargeStringColumn(nullable=True) obs_id = qv.LargeStringColumn(nullable=True) obs_sub_id = qv.LargeStringColumn(nullable=True) trk_id = qv.LargeStringColumn(nullable=True) # ── Observer ────────────────────────────────────── stn = qv.LargeStringColumn() # MPC obs code mode = qv.LargeStringColumn(nullable=True) prog = qv.LargeStringColumn(nullable=True) # ── Observer location (roving / spacecraft) ────── sys = qv.LargeStringColumn(nullable=True) ctr = qv.Float64Column(nullable=True) pos1 = qv.Float64Column(nullable=True) pos2 = qv.Float64Column(nullable=True) pos3 = qv.Float64Column(nullable=True) # ── Astrometry ──────────────────────────────────── obs_time = qv.LargeStringColumn() # ISO 8601 UTC ra = qv.Float64Column() # degrees dec = qv.Float64Column() # degrees # ── Uncertainties ──────────────────────────────── rms_ra = qv.Float64Column(nullable=True) # arcsec rms_dec = qv.Float64Column(nullable=True) # arcsec rms_corr = qv.Float64Column(nullable=True) # correlation [-1, 1] # ── Astrometric catalog ────────────────────────── ast_cat = qv.LargeStringColumn(nullable=True) # ── Photometry ─────────────────────────────────── mag = qv.Float64Column(nullable=True) rms_mag = qv.Float64Column(nullable=True) band = qv.LargeStringColumn(nullable=True) phot_cat = qv.LargeStringColumn(nullable=True) phot_ap = qv.Float64Column(nullable=True) # arcsec # ── Supplementary diagnostics ──────────────────── log_snr = qv.Float64Column(nullable=True) seeing = qv.Float64Column(nullable=True) # arcsec FWHM exp = qv.Float64Column(nullable=True) # seconds rms_fit = qv.Float64Column(nullable=True) # arcsec n_stars = qv.Int32Column(nullable=True) notes = qv.LargeStringColumn(nullable=True) remarks = qv.LargeStringColumn(nullable=True) # ── Selection helpers ────────────────────────────────
[docs] def select_station(self, codes: str | list[str]) -> ADESObservations: """Rows from one or more MPC observatory codes (the ``stn`` column).""" wanted = [codes] if isinstance(codes, str) else list(codes) mask = _is_in(self.column("stn"), value_set=pa.array(wanted)) return self.apply_mask(mask)
# ── Arc statistics ─────────────────────────────────── @staticmethod def _obs_times_to_days(times: list[str]) -> np.ndarray: """Parse ISO-8601 UTC strings to a 1-D float64 array of MJD- agnostic days-since-epoch. ADES `obs_time` carries the trailing ``Z`` UTC marker; numpy's `datetime64` rejects timezone-aware strings (raises a deprecation warning), so strip the suffix before parsing. The downstream consumers care only about deltas / gaps, not the absolute epoch, so the days-since-Unix-epoch normalization is fine. """ cleaned = [t.rstrip("Z") if isinstance(t, str) else t for t in times] return np.array(cleaned, dtype="datetime64[ns]").astype("int64") / 1e9 / 86400.0 @property def time_span_days(self) -> float: """Arc length in days, max(obs_time) − min(obs_time). Empty / single-row tables return 0.0.""" times = self.obs_time.to_pylist() if len(times) < 2: return 0.0 days = self._obs_times_to_days(times) return float(days.max() - days.min())
[docs] def n_oppositions(self, gap_days: float = 90.0) -> int: """Count of distinct apparitions, defined as ``1 + (number of consecutive-observation gaps exceeding ``gap_days``)``. 90-day default matches scott's :attr:`ODConfig.opposition_gap_days` and the conventional planetary-science threshold for Sun-synodic visibility windows. Empty tables return 0; single-row tables return 1. """ times = self.obs_time.to_pylist() if len(times) == 0: return 0 if len(times) == 1: return 1 days = np.sort(self._obs_times_to_days(times)) gaps = np.diff(days) return int(1 + np.sum(gaps > gap_days))