Source code for spindoctor.nav_model.nav_model_rings

"""Catalog-driven ring NavModel.

The orchestrator iterates one ``NavModelRings`` per planet whose ring
system has any radius inside the extfov.  Per ring feature surviving
the four-pass ``RingFeatureFilter``, the model emits either:

- one ``RING_EDGE`` ``NavFeature`` carrying a per-vertex ``RingEdgePolyline``
  with its own sigma_radial / sigma_along_edge derived from the catalog ``rms``,
  or
- one ``RING_ANNULUS`` ``NavFeature`` carrying the rendered annulus
  template when the surviving edge polyline compresses radially below
  the resolvability threshold.

The per-edge polylines come from sampling the rendered model + edge
mask: each ``True`` pixel in the edge mask contributes one polyline
vertex, and the gradient direction across the edge gives the radial
normal.  Curvature is measured by the maximum deviation of the polyline
from its best-fit straight line.
"""

from __future__ import annotations

import math
from typing import TYPE_CHECKING, Any

import numpy as np
import oops
from oops import Meshgrid
from oops.backplane import Backplane

from spindoctor.annotation import Annotations
from spindoctor.config import DEFAULT_CONFIG, Config
from spindoctor.feature.feature import NavFeature
from spindoctor.nav_model.nav_model import NavModel
from spindoctor.nav_model.nav_model_rings_base import NavModelRingsBase
from spindoctor.nav_model.ring_emission import (
    RING_EDGE_DEFAULT_RELIABILITY,
    RING_EDGE_SIGMA_ALONG_PX,
    _build_annulus_feature,
    _build_edge_feature,
)
from spindoctor.nav_model.ring_polyline import (
    FLAT_CURVATURE_THRESHOLD_PX,
    _composite_ring_renderings,
    _is_straight_line,
    _mask_bbox,
    _median_radial_scale,
    _polyline_from_edge_mask,
    _radial_extent_px,
)
from spindoctor.nav_model.rings import (
    RingFeature,
    RingFeatureFilter,
    RingsRenderContext,
    validate_no_date_overlaps,
)
from spindoctor.support.time import now_dt, utc_to_et
from spindoctor.support.types import NDArrayBoolType, NDArrayFloatType

if TYPE_CHECKING:  # pragma: no cover - typing-only import
    from spindoctor.nav_orchestrator.nav_context import NavContext


__all__ = [
    'FLAT_CURVATURE_THRESHOLD_PX',
    'RING_EDGE_DEFAULT_RELIABILITY',
    'RING_EDGE_SIGMA_ALONG_PX',
    'NavModelRings',
]


SPARSE_VISIBILITY_GRID_SIZE: int = 16
"""Side length of the sparse-grid backplane used by the cheap ring pre-check.

A 16x16 ring-radius backplane evaluates ~256 SPICE ray casts (vs ~1M+
for a 1024x1024 ext-FOV) and runs in well under 10 ms; it is sufficient
to detect the two common skip cases — no ring-plane intersection in
the FOV at all, and a visible radial range entirely beyond the
catalog's outermost feature.
"""


# All ring-annulus emission tunables live in
# ``config_files/config_510_techniques.yaml`` under
# ``feature_emission.ring_annulus`` with per-planet overrides.  No
# Python-level fallback; missing-key access in
# ``_ring_annulus_emission_params`` is a KeyError so a config typo
# fails fast at process startup.


def _ring_annulus_emission_params(config: Config, planet: str) -> tuple[float, float]:
    """Return ``(max_radial_px, kmpp_threshold)`` for ``planet``.

    Reads
    ``config.feature_emission.ring_annulus.planets[planet]`` first,
    then falls back to ``config.feature_emission.ring_annulus.default``.
    Both fields (``max_radial_px`` and ``kmpp_threshold``) are
    independently looked up, so a planet block may set only one and
    inherit the other from default.  A missing default-block field is
    a ``KeyError`` so a config typo fails fast at process startup.

    Parameters:
        config: Active :class:`~spindoctor.config.Config`.
        planet: SPICE planet name (uppercase), e.g. ``'SATURN'``.

    Returns:
        ``(max_radial_px, kmpp_threshold)`` in pixels and km/px.

    Raises:
        KeyError: If neither the per-planet block nor the default
            block defines a required field.
    """
    block = dict(config.category('feature_emission').get('ring_annulus', {}))
    default_block = block.get('default', {}) or {}
    planets_block = block.get('planets', {}) or {}
    planet_block = planets_block.get(planet, {}) or {}

    def _lookup(field: str) -> float:
        """Read one emission field, preferring the planet block over the default.

        Parameters:
            field: Field name to read.

        Returns:
            The field's value as a float.

        Raises:
            KeyError: If neither the planet block nor the default block
                defines the field, so a config typo fails at startup.
        """
        if field in planet_block:
            return float(planet_block[field])
        if field in default_block:
            return float(default_block[field])
        raise KeyError(
            f'feature_emission.ring_annulus: missing field {field!r} for planet '
            f'{planet!r} (no per-planet override and no default block entry)'
        )

    return _lookup('max_radial_px'), _lookup('kmpp_threshold')


def _require_positive_finite_planet_scalar(
    planet: str, key: str, planet_config: dict[str, Any]
) -> float:
    """Return a positive finite float from ``planet_config[key]``.

    Performs explicit type and finiteness checks so misconfigured YAML
    fails at config-read, not in the math.

    Parameters:
        planet: Planet name (used in error messages).
        key: Config key to read.
        planet_config: The per-planet config dict.

    Returns:
        A positive finite float.

    Raises:
        ValueError: If ``key`` is missing or the value is not a positive
            finite float.
    """
    if key not in planet_config:
        raise ValueError(f'Missing required ring configuration key {key!r} for planet {planet}')
    raw: Any = planet_config[key]
    if isinstance(raw, bool):
        raise ValueError(
            f'Invalid {key} for planet {planet}: expected a finite numeric value, got bool'
        )
    try:
        v = float(raw)
    except (TypeError, ValueError) as exc:
        raise ValueError(
            f'Invalid {key} for planet {planet}: expected a finite numeric value, got {raw!r}'
        ) from exc
    if not math.isfinite(v):
        raise ValueError(f'Invalid {key} {v} for planet {planet} (must be finite)')
    if v <= 0.0:
        raise ValueError(f'Invalid {key} {v} for planet {planet}')
    return v