Source code for spindoctor.nav_model.body_shape

"""Per-body shape parameters used by the body NavModel.

The body extractor's covariance and emission gates consult the per-body
shape, albedo, and SPICE-residual quantities returned by
:func:`load_body_shape`.  The lookup pulls operator-curated values from
``config_220_body_shape.yaml`` first, falling back to the hard-coded
``BODY_SHAPE_TABLE`` profiles for bodies the YAML has not populated yet,
and finally to ``DEFAULT_BODY_SHAPE`` for entirely unknown bodies.

Each ``BodyShape`` instance carries the values the navigation pipeline
actually consumes:

- ``ellipsoid_rms_residual_km`` — RMS deviation of the body silhouette
  from the best-fit ellipsoid.  Drives the LIMB_ARC normal-sigma.
- ``crater_scale_km`` — characteristic per-image limb roughness from
  craters and topography.
- ``albedo_variation`` — fractional brightness variation across the
  disc; drives the terminator photometric error budget.
- ``spice_orbital_residual_km`` — SPK ephemeris uncertainty projected
  to the limb plane.
- ``min_blob_diameter_px`` — minimum predicted disc diameter at which
  ``BODY_BLOB`` is preferred over an unresolved limb.
- ``shape_class_hint`` — ``regular`` / ``irregular`` /
  ``highly_irregular`` / ``unknown``; used in human-readable logs and
  by reviewers reading sidecar diagnostics.

Documentation-only fields in the YAML (``radii_km``, ``albedo_mean``,
``_sources``) are intentionally not surfaced on the runtime dataclass:
``radii_km`` would compete with ``oops`` body radii and ``albedo_mean``
has no consumer yet.  Adding them at runtime is a one-line dataclass
extension whenever a consumer materializes.
"""

from __future__ import annotations

from dataclasses import dataclass, replace
from typing import Any

__all__ = [
    'BODY_SHAPE_TABLE',
    'DEFAULT_BODY_SHAPE',
    'BodyShape',
    'load_body_shape',
]


[docs] @dataclass(frozen=True) class BodyShape: """Per-body shape and SPICE-residual quantities consumed at run time. Parameters: ellipsoid_rms_residual_km: RMS shape residual from the best-fit ellipsoid (km). Primary contribution in the limb-arc normal-sigma quadrature sum. crater_scale_km: Characteristic crater / topographic scale (km), independent of ``ellipsoid_rms_residual_km``. Adds to the limb-arc normal-sigma in quadrature. albedo_variation: Fractional disc brightness variation in ``[0, 1]``; drives terminator-arc reliability. spice_orbital_residual_km: SPK ephemeris uncertainty in km (~0.5 for major moons; up to 5 for irregular satellites). min_blob_diameter_px: Predicted disc diameter (px) at which the extractor stops emitting LIMB_ARC and switches to BODY_BLOB. shape_class_hint: Coarse classification used by the log / reviewer. One of ``regular``, ``irregular``, ``highly_irregular``, ``unknown``. """ ellipsoid_rms_residual_km: float crater_scale_km: float albedo_variation: float spice_orbital_residual_km: float min_blob_diameter_px: float = 5.0 shape_class_hint: str = 'unknown'
DEFAULT_BODY_SHAPE: BodyShape = BodyShape( ellipsoid_rms_residual_km=2.0, crater_scale_km=5.0, albedo_variation=0.15, spice_orbital_residual_km=2.0, min_blob_diameter_px=5.0, shape_class_hint='unknown', ) """Fallback shape used when a body has no specific entry. The numbers reflect a generic small icy moon: ~2 km bulk-shape residual, ~5 km crater scale, modest albedo variation, generous 2 km SPK residual. """ _SATURN_MOON_SHAPE: BodyShape = BodyShape( ellipsoid_rms_residual_km=1.0, crater_scale_km=2.0, albedo_variation=0.10, spice_orbital_residual_km=0.5, min_blob_diameter_px=5.0, shape_class_hint='regular', ) """Profile for the major Saturn moons whose shape is well-measured.""" _IRREGULAR_MOON_SHAPE: BodyShape = BodyShape( ellipsoid_rms_residual_km=10.0, crater_scale_km=5.0, albedo_variation=0.20, spice_orbital_residual_km=2.0, min_blob_diameter_px=5.0, shape_class_hint='highly_irregular', ) """Profile for very irregular bodies (Hyperion, Phoebe, etc.).""" _GAS_GIANT_SHAPE: BodyShape = BodyShape( ellipsoid_rms_residual_km=50.0, crater_scale_km=0.0, albedo_variation=0.30, spice_orbital_residual_km=10.0, min_blob_diameter_px=20.0, shape_class_hint='regular', ) """Profile for gas / ice giants whose limbs are atmospheric scattering.""" BODY_SHAPE_TABLE: dict[str, BodyShape] = { 'SATURN': _GAS_GIANT_SHAPE, 'JUPITER': _GAS_GIANT_SHAPE, 'URANUS': _GAS_GIANT_SHAPE, 'NEPTUNE': _GAS_GIANT_SHAPE, 'MIMAS': _SATURN_MOON_SHAPE, 'ENCELADUS': _SATURN_MOON_SHAPE, 'TETHYS': _SATURN_MOON_SHAPE, 'DIONE': _SATURN_MOON_SHAPE, 'RHEA': _SATURN_MOON_SHAPE, 'IAPETUS': _SATURN_MOON_SHAPE, 'TITAN': _SATURN_MOON_SHAPE, 'HYPERION': _IRREGULAR_MOON_SHAPE, 'PHOEBE': _IRREGULAR_MOON_SHAPE, 'EUROPA': _SATURN_MOON_SHAPE, 'IO': _SATURN_MOON_SHAPE, 'GANYMEDE': _SATURN_MOON_SHAPE, 'CALLISTO': _SATURN_MOON_SHAPE, } """Hard-coded fallback profiles, keyed by upper-case SPICE body name. Used when the corresponding body is missing from ``config_220_body_shape.yaml`` or when individual numeric fields in the YAML entry are ``null`` (the YAML value wins when it is set). Bodies absent from this table fall through to ``DEFAULT_BODY_SHAPE``. """ # YAML field names that map directly onto ``BodyShape`` fields. The # loader walks this list and overwrites the hard-coded baseline only # when the YAML carries a non-null value. Keep ordering aligned with # the ``BodyShape`` field declaration above for grep-ability. _YAML_FIELDS: tuple[str, ...] = ( 'ellipsoid_rms_residual_km', 'crater_scale_km', 'albedo_variation', 'spice_orbital_residual_km', 'min_blob_diameter_px', 'shape_class_hint', )
[docs] def load_body_shape(body_name: str, config: Any = None) -> BodyShape: """Build the runtime ``BodyShape`` for ``body_name`` (case-insensitive). Merges three sources, in priority order: 1. ``config.body_shape[<BODY>]`` — operator-curated YAML entry from ``config_220_body_shape.yaml``. Each non-null field overrides the hard-coded baseline. 2. ``BODY_SHAPE_TABLE[<BODY>]`` — hard-coded per-class profile for bodies the YAML has not populated yet (e.g. minor moons that Phase 10 §B has not reached). 3. ``DEFAULT_BODY_SHAPE`` — final fallback for bodies unknown to both sources. Parameters: body_name: Body name in any case (``'mimas'`` / ``'MIMAS'``). config: Optional ``Config`` override; defaults to ``DEFAULT_CONFIG``. Tests may pass a stub that exposes a ``body_shape`` attribute (mapping or ``AttrDict``). Returns: A ``BodyShape`` populated from the best available source for each field. """ upper = body_name.upper() baseline = BODY_SHAPE_TABLE.get(upper, DEFAULT_BODY_SHAPE) yaml_block = _yaml_entry_for(upper, config) if yaml_block is None: return baseline overrides: dict[str, Any] = {} for field in _YAML_FIELDS: if field not in yaml_block: continue value = yaml_block[field] if value is None: continue overrides[field] = value if not overrides: return baseline return replace(baseline, **overrides)
def _yaml_entry_for(upper_body_name: str, config: Any) -> dict[str, Any] | None: """Return the YAML mapping for ``upper_body_name`` if present. Resolves ``config.body_shape`` against either an explicit ``Config`` instance or the global ``DEFAULT_CONFIG``. Returns ``None`` when the body is absent, the YAML block is empty / not a mapping, or the loader has not been run yet (``config.body_shape`` raises during early bootstrapping). """ cfg = config if cfg is None: # Local import keeps this module import-cycle-free; the global # default is the one nav_model_body.py uses when no per-instance # override is supplied. from spindoctor.config import DEFAULT_CONFIG cfg = DEFAULT_CONFIG try: body_shape_section = cfg.body_shape except AttributeError: # ``cfg`` is not a Config-like object exposing ``body_shape`` (e.g. a # duck-typed test stub, or pre-bootstrap). A genuine config-load / # validation error is a real failure and is left to propagate. return None if not isinstance(body_shape_section, dict): return None entry = body_shape_section.get(upper_body_name) if entry is None or not isinstance(entry, dict): return None return dict(entry)