Source code for spindoctor.nav_model.nav_model_body_base

"""Shared base class for body navigation models.

``NavModelBodyBase`` carries the limb-mask helper and the body-label
annotation pipeline shared between every concrete body NavModel.  It is
abstract — registered subclasses (``NavModelBodySimulated`` today, plus
the real-scene body model when it lands) inherit the helpers and supply
the per-image rendering.

Anti-aliasing math lives separately in
``spindoctor.nav_model.rings.ring_math.compute_antialiasing``; helpers here are
strictly observation-aware (image shape, font config, label-placement
heuristics).
"""

import math
from statistics import NormalDist
from typing import TYPE_CHECKING

import numpy as np
import scipy.ndimage as ndimage

from spindoctor.annotation import (
    TEXTINFO_BOTTOM_ARROW,
    TEXTINFO_LEFT_ARROW,
    TEXTINFO_RIGHT_ARROW,
    TEXTINFO_TOP_ARROW,
    Annotation,
    Annotations,
    AnnotationTextInfo,
    TextLocInfo,
)
from spindoctor.feature.feature import NavFeature, NavReliabilityBreakdown
from spindoctor.feature.feature_type import NavFeatureType
from spindoctor.feature.flags import BodyBlobFlags
from spindoctor.feature.geometry import BodyBlobGeometry
from spindoctor.nav_model.body_shape import BodyShape
from spindoctor.support.filters import NavFilterKind, NavFilterSpec
from spindoctor.support.image import shift_array
from spindoctor.support.noise_estimate import NOISE_CLIP_SIGMA, estimate_background_and_sky_sigma
from spindoctor.support.types import NDArrayBoolType, NDArrayFloatType

from .nav_model import NavModel

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

__all__ = ['BODY_BLOB_MIN_DIAMETER_PX', 'NavModelBodyBase']


BODY_BLOB_MIN_DIAMETER_PX: float = 5.0
"""Minimum predicted disc diameter (px) at which BODY_BLOB is emitted.

Below this the silhouette covers so few pixels (< ~20) that the
brightness-weighted centroid is dominated by the PSF and per-pixel
noise rather than the body's position.  The floor admits the
operator-curated ``below_resolution_body`` exemplars (a 6 px Enceladus
whose sidecar requires ``BodyBlobNav`` to run); on the sim calibration
campaign the 5-8 px band recovers planted offsets to under 0.15 px
with honest 2-sigma coverage, so precision above the floor is the
covariance's and the confidence formula's job, not the emission
gate's.  The per-body shape table can override this floor upward for
known difficult bodies (gas giants use 20).
"""


_SUB_SOLAR_MIN_OFFSET_PX: float = 0.5
"""Minimum lit-centroid offset (px) for a meaningful sub-solar direction.

Below this the body is near full phase, the lit and geometric centroids
coincide to within pixel noise, and the direction toward the bright limb
is undefined; the BODY_BLOB feature then carries ``(0.0, 0.0)`` and
BodyBlobNav falls back to the filled-disc coarse template.
"""


_BLOB_DETECTION_SNR_MIDPOINT: float = 3.0
"""Detection SNR at which the blob reliability's SNR term crosses 0.5.

Matches ``BodyBlobNav``'s 3-sigma lit-pixel threshold: pixels below
``background + 3 * sky_sigma`` never contribute to the centroid moment,
so a body whose brightest expected pixels sit at that level is at the
technique's own usability boundary.  A detection SNR of 0 (nothing above
the noise order-statistics in the search window) lands the term at
``sigmoid(-3) ~ 0.05``, decisively below the 0.20 reliability gate for
any body size.
"""


_BLOB_EXTENT_MIDPOINT_PX: float = 4.0
"""Predicted diameter at which the blob reliability's extent term crosses 0.5.

Below the :data:`BODY_BLOB_MIN_DIAMETER_PX` emission floor.  The floor
already guarantees no undersized BODY_BLOB exists, so the extent term
must not re-gate at-floor bodies (with the midpoint at the floor itself
an at-floor body could never clear the 0.20 reliability gate: ``0.4 *
0.5 * snr_term < 0.2`` for every finite SNR).  Sitting the midpoint
just under the floor makes the term a mild near-floor discount that
asks a smaller body for a stronger detection (SNR ~4.4 at 5 px, ~3.3
at 8 px, ~3.1 at 20 px) rather than acting as a second size gate.
"""


_BLOB_EXTENT_SCALE_PX: float = 2.0
"""Sigmoid scale (px) of the blob reliability's extent term."""






def _blob_reliability(*, snr: float, diameter_px: float) -> float:
    """Reliability of BODY_BLOB per the design (cap at 0.4).

    ``snr`` is the measured detection SNR from
    ``NavModelBodyBase._blob_detection_snr``.  Its sigmoid is centered
    at :data:`_BLOB_DETECTION_SNR_MIDPOINT` (the technique's own
    lit-pixel threshold) so a window with no signal above the noise
    order-statistics scores ~0.02 (gated for any body size) while a
    clearly detected body saturates the term.  The extent sigmoid
    (midpoint :data:`_BLOB_EXTENT_MIDPOINT_PX`, below the
    :data:`BODY_BLOB_MIN_DIAMETER_PX` emission floor) applies a mild
    near-floor discount; detection is the primary discriminator against
    the reliability gate's 0.20 threshold, which the combination crosses
    at SNR ~3.0 for a 20 px body up to ~4.4 at the 5 px floor.
    """
    snr_term = _sigmoid(snr - _BLOB_DETECTION_SNR_MIDPOINT)
    extent_term = _sigmoid((diameter_px - _BLOB_EXTENT_MIDPOINT_PX) / _BLOB_EXTENT_SCALE_PX)
    return float(snr_term * extent_term * 0.4)


def _sigmoid(x: float) -> float:
    """Logistic sigmoid (numerically safe)."""
    if x >= 0:
        z = math.exp(-x)
        return 1.0 / (1.0 + z)
    z = math.exp(x)
    return z / (1.0 + z)