Source code for nav.nav_model.nav_model_rings

"""Navigation model for planetary rings.

This module implements the orchestrator for the planetary ring navigation model.
It is a thin coordinator that:

1. Reads the planet block from merged config (``rings.ring_features.<PLANET>``),
   including required planet-level parameters (``epoch``, fade settings, etc.).
2. Requires a non-empty ``features`` dict under that block (each entry is
   validated when passed to ``RingFeature.from_config()``).
3. Checks ring visibility via the ``ring_radius`` backplane **before** calling
   ``RingFeature.from_config()``, so feature parsing is skipped when no ring
   radii appear in the FOV.
4. Constructs typed ``RingFeature`` objects via ``RingFeature.from_config()``,
   raising ``ValueError`` on malformed feature entries.
5. Validates no two features have overlapping date ranges over the same radial
   region (``validate_no_date_overlaps``).
6. Performs a fast extent check: if the minimum radius in the FOV exceeds the
   maximum ``a + ae`` across all features, no ring can be visible and the model
   returns before the expensive resolutions backplane and filter pipeline.
7. Filters through the four-pass ``RingFeatureFilter`` pipeline.
8. Renders each surviving feature via ``feature.render(context)``.
9. Optionally removes planet-shadow pixels from each rendered result when
   ``rings.remove_planet_shadow`` is ``True`` (shadow computed once via
   ``obs.ext_bp.where_inside_shadow``).
10. Wraps each result in a ``NavModelResult`` with annotations.

**Design principle**: This module contains no physics, no math, and no rendering
logic. All of that lives in the ``rings`` subpackage (``ring_feature``,
``ring_math``, ``ring_filter``). The orchestrator's only job is to wire together
configuration retrieval, backplane access, and ``NavModelResult`` construction.

**Top-level rings keys** (under ``rings``):

- ``remove_planet_shadow``: Boolean (default ``False``). When ``True``, pixels
  that lie inside the nearest planet's own shadow are zeroed out of each
  rendered feature's model image and excluded from its model mask before the
  ``NavModelResult`` is constructed.  A warning is logged and shadow removal is
  skipped if the backplane call fails.

**Planet ring block keys** (under ``rings.ring_features.<PLANET>``):

- ``general.log_level_model_rings``: Log level for the ``CREATE RINGS MODEL``
  ``PdsLogger.open()`` block. Ring filtering, feature rendering, and fade math log
  through the same ``PdsLogger`` as this model (``debug`` for internals,
  ``info`` for summaries on the orchestrator).
- ``epoch``: UTC epoch string for radial mode calculations (required).
- ``fade_width_pix``: Desired fade extent in pixels for single-edge features
  (required; no default).
- ``min_allowed_fade_width_pix``: Minimum allowed fade after conflict reduction
  (required; no default).
- ``min_feature_pixels``: Minimum resolvable feature width in pixels (required;
  no default).
- ``features``: Dict of feature key -> feature dict (parsed via
  ``RingFeature.from_config``).
"""

import math
from typing import Any

import numpy as np
import oops

from nav.config import Config
from nav.support.time import now_dt, utc_to_et
from nav.support.types import NDArrayBoolType, NDArrayFloatType

from .nav_model_result import NavModelResult
from .nav_model_rings_base import NavModelRingsBase
from .rings import (
    RingFeature,
    RingFeatureFilter,
    RingsRenderContext,
    validate_no_date_overlaps,
)


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]``.

    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