spindoctor.nav_model

NavModel — predicted-scene generators consumed by the orchestrator.

A NavModel is one of: stars, a body, or a planet’s rings. Each model renders the predicted scene from SPICE prediction (or operator-supplied simulation parameters) and emits NavFeature instances ready for technique consumption plus Annotations for the summary PNG.

Modules:

nav_model

NavModel ABC. Concrete subclasses implement create_model, to_features, and to_annotations.

nav_model_body

NavModelBody — catalog-driven body NavModel.

nav_model_body_base

NavModelBodyBase — shared annotation helpers for body models.

nav_model_body_simulated

NavModelBodySimulated — body model rendered from operator simulation parameters.

nav_model_rings

NavModelRings — catalog-driven ring NavModel.

nav_model_rings_base

NavModelRingsBase — shared annotation helpers for ring models.

nav_model_rings_simulated

NavModelRingsSimulated — ring model rendered from operator simulation parameters.

nav_model_titan

NavModelTitan — Titan placeholder; opaque haze, records a no-result.

stars

Catalog-driven star NavModel and supporting helpers.

NavModel — the base class for predicted-scene generators.

Each NavModel renders the predicted appearance of one part of the scene (stars, a body, or rings) given the observation’s SPICE prediction. The orchestrator iterates registered NavModel instances and asks each to contribute features and annotations to the navigation pipeline:

  • create_model populates the model’s internal state and metadata.

  • to_features(context) returns NavFeature instances ready for technique consumption.

  • to_annotations(context) returns an Annotations collection for the summary PNG.

Concrete NavModel subclasses self-register via __init_subclass__. The module-level build_models_for_obs function iterates the registry and asks each subclass to construct whatever instances apply to the observation; this is what the top-level navigation driver uses so it does not need to know which scene categories exist.

Every NavModel inherits from NavBase so it gets the project logger and config plumbing automatically.

class NavModel(name: str, obs: ObsSnapshot, *, config: Config | None = None)[source]

Bases: NavBase, ABC

Base class for predicted-scene generators.

Class attributes:
_registry: List of every concrete subclass discovered at import

time. Iterated by build_models_for_obs to construct the per-image NavModel set.

Parameters:
  • name – The model’s name (e.g. 'stars', 'body:MIMAS', 'rings:SATURN'). Used as the lookup key in the orchestrator’s model_metadata dict.

  • obs – Observation snapshot the model is built against.

  • config – Optional Config override (defaults to DEFAULT_CONFIG).

classmethod __init_subclass__(**kwargs: Any) None[source]

Auto-register concrete subclasses.

Subclasses that exist only as shared bases (annotation helpers, for example) opt out by setting _abstract: ClassVar[bool] = True in the class body.

abstractmethod create_model() None[source]

Populate the model’s internal state and metadata.

Called once per image before to_features or to_annotations. Implementations evaluate the SPICE prediction, render any required templates, and populate self._metadata with diagnostic information.

classmethod instances_for_obs(obs: ObsSnapshot, *, config: Config | None = None) list[NavModel][source]

Return concrete NavModel instances applicable to obs.

Default returns []. Subclasses that auto-instantiate from an observation (stars, body, rings) override this to construct the appropriate set of instances — typically one per body in FOV, one per planet with visible rings, one stars model.

Subclasses that require operator-supplied parameters (simulated models populated from GUI JSON) inherit the default empty list; the caller that uses them constructs them directly.

Parameters:
  • obs – Observation snapshot to inspect.

  • config – Configuration used both to decide which instances apply and to construct them. None uses DEFAULT_CONFIG.

Returns:

Zero or more NavModel instances.

property metadata: dict[str, Any]

Per-model diagnostic dict; populated during create_model.

property name: str

The model’s name.

property obs: ObsSnapshot

The observation snapshot this model is built against.

abstractmethod to_annotations(context: NavContext) Annotations[source]

Return a collection of annotations describing the predicted scene.

Called after create_model. Annotations summarize what the model predicts is visible in the image; they end up on the summary PNG regardless of whether the features they describe end up gated out or unused by techniques.

Parameters:

context – Per-image NavContext.

Returns:

Annotations collection (possibly empty).

abstractmethod to_features(context: NavContext) list[NavFeature][source]

Return navigation-ready features for this model.

Called after create_model. Implementations build per-element NavFeature instances (stars, body limbs, ring edges) and assign each its preferred filter, position covariance, and reliability.

Parameters:

context – Per-image NavContext carrying global statistics.

Returns:

List of NavFeature instances; may be empty if no usable features are present in this scene.

build_models_for_obs(obs: ObsSnapshot, *, config: Config | None = None) list[NavModel][source]

Construct every NavModel applicable to obs.

Iterates NavModel._registry and lets each registered concrete subclass build whatever instances apply. The top-level navigation driver uses this so the choice of which scene categories are represented (stars, body, rings, …) lives entirely inside the NavModel subclasses, not in the driver.

Parameters:
  • obs – Observation snapshot.

  • config – Configuration used both to decide which instances apply and to construct them, so a per-run override changes model selection the same way it changes model behavior. None uses DEFAULT_CONFIG.

Returns:

Flat list of NavModel instances ready for the orchestrator.

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 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_hintregular / 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.

BODY_SHAPE_TABLE: dict[str, BodyShape] = {'CALLISTO': BodyShape(ellipsoid_rms_residual_km=1.0, crater_scale_km=2.0, albedo_variation=0.1, spice_orbital_residual_km=0.5, min_blob_diameter_px=5.0, shape_class_hint='regular'), 'DIONE': BodyShape(ellipsoid_rms_residual_km=1.0, crater_scale_km=2.0, albedo_variation=0.1, spice_orbital_residual_km=0.5, min_blob_diameter_px=5.0, shape_class_hint='regular'), 'ENCELADUS': BodyShape(ellipsoid_rms_residual_km=1.0, crater_scale_km=2.0, albedo_variation=0.1, spice_orbital_residual_km=0.5, min_blob_diameter_px=5.0, shape_class_hint='regular'), 'EUROPA': BodyShape(ellipsoid_rms_residual_km=1.0, crater_scale_km=2.0, albedo_variation=0.1, spice_orbital_residual_km=0.5, min_blob_diameter_px=5.0, shape_class_hint='regular'), 'GANYMEDE': BodyShape(ellipsoid_rms_residual_km=1.0, crater_scale_km=2.0, albedo_variation=0.1, spice_orbital_residual_km=0.5, min_blob_diameter_px=5.0, shape_class_hint='regular'), 'HYPERION': BodyShape(ellipsoid_rms_residual_km=10.0, crater_scale_km=5.0, albedo_variation=0.2, spice_orbital_residual_km=2.0, min_blob_diameter_px=5.0, shape_class_hint='highly_irregular'), 'IAPETUS': BodyShape(ellipsoid_rms_residual_km=1.0, crater_scale_km=2.0, albedo_variation=0.1, spice_orbital_residual_km=0.5, min_blob_diameter_px=5.0, shape_class_hint='regular'), 'IO': BodyShape(ellipsoid_rms_residual_km=1.0, crater_scale_km=2.0, albedo_variation=0.1, spice_orbital_residual_km=0.5, min_blob_diameter_px=5.0, shape_class_hint='regular'), 'JUPITER': BodyShape(ellipsoid_rms_residual_km=50.0, crater_scale_km=0.0, albedo_variation=0.3, spice_orbital_residual_km=10.0, min_blob_diameter_px=20.0, shape_class_hint='regular'), 'MIMAS': BodyShape(ellipsoid_rms_residual_km=1.0, crater_scale_km=2.0, albedo_variation=0.1, spice_orbital_residual_km=0.5, min_blob_diameter_px=5.0, shape_class_hint='regular'), 'NEPTUNE': BodyShape(ellipsoid_rms_residual_km=50.0, crater_scale_km=0.0, albedo_variation=0.3, spice_orbital_residual_km=10.0, min_blob_diameter_px=20.0, shape_class_hint='regular'), 'PHOEBE': BodyShape(ellipsoid_rms_residual_km=10.0, crater_scale_km=5.0, albedo_variation=0.2, spice_orbital_residual_km=2.0, min_blob_diameter_px=5.0, shape_class_hint='highly_irregular'), 'RHEA': BodyShape(ellipsoid_rms_residual_km=1.0, crater_scale_km=2.0, albedo_variation=0.1, spice_orbital_residual_km=0.5, min_blob_diameter_px=5.0, shape_class_hint='regular'), 'SATURN': BodyShape(ellipsoid_rms_residual_km=50.0, crater_scale_km=0.0, albedo_variation=0.3, spice_orbital_residual_km=10.0, min_blob_diameter_px=20.0, shape_class_hint='regular'), 'TETHYS': BodyShape(ellipsoid_rms_residual_km=1.0, crater_scale_km=2.0, albedo_variation=0.1, spice_orbital_residual_km=0.5, min_blob_diameter_px=5.0, shape_class_hint='regular'), 'TITAN': BodyShape(ellipsoid_rms_residual_km=1.0, crater_scale_km=2.0, albedo_variation=0.1, spice_orbital_residual_km=0.5, min_blob_diameter_px=5.0, shape_class_hint='regular'), 'URANUS': BodyShape(ellipsoid_rms_residual_km=50.0, crater_scale_km=0.0, albedo_variation=0.3, spice_orbital_residual_km=10.0, min_blob_diameter_px=20.0, shape_class_hint='regular')}

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.

class BodyShape(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')[source]

Bases: object

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.

albedo_variation: float
crater_scale_km: float
ellipsoid_rms_residual_km: float
min_blob_diameter_px: float = 5.0
shape_class_hint: str = 'unknown'
spice_orbital_residual_km: float
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.

load_body_shape(body_name: str, config: Any = None) BodyShape[source]

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.

Catalog-driven body NavModel.

Renders one body’s predicted appearance from SPICE, classifies it against the design’s emission rules (limb arc vs. body disc vs. blob vs. terminator), and emits one NavFeature per surviving feature type.

The pipeline:

  1. Builds an oversampled meshgrid around the predicted body bounding box so the limb silhouette is anti-aliased.

  2. Extracts the limb and terminator polylines from the discrete silhouette masks.

  3. Looks up the per-body shape parameters via spindoctor.nav_model.body_shape.load_body_shape(), which merges the operator-curated config_220_body_shape.yaml over the hard-coded BODY_SHAPE_TABLE profiles.

  4. Decides which features to emit by computing limb_uncertainty_px and the visible_lit_fraction / overflow_fraction quantities the design specifies.

The feature-by-feature emission rules:

  • LIMB_ARC is emitted when limb_uncertainty_px <= LIMB_ARC_MAX_UNCERTAINTY_PX and there are surviving limb vertices.

  • BODY_BLOB is emitted when the predicted disc diameter is at least max(BODY_BLOB_MIN_DIAMETER_PX, shape.min_blob_diameter_px) and the limb uncertainty is too high for LIMB_ARC and the rendered silhouette contains at least one lit pixel. A body whose silhouette is entirely in shadow has zero photometric signal to centroid, so it emits no blob (only the geometric features, per the dev guide’s Restrictions). The per-body shape floor can override the global default upward but not downward.

  • BODY_DISC is emitted alongside LIMB_ARC when the body fits inside the FOV with at least BODY_DISC_MIN_VISIBLE_LIT_FRACTION of its lit side visible and overflow_fraction below BODY_DISC_MAX_OVERFLOW_FRACTION.

  • TERMINATOR_ARC is emitted when the terminator polyline has at least TERMINATOR_MIN_VERTICES surviving vertices and the phase-angle factor (sin(phase_angle)) is above TERMINATOR_MIN_PHASE_FACTOR.

BODY_DISC_MAX_OVERFLOW_FRACTION: float = 0.3

Maximum overflow fraction for BODY_DISC emission.

A body whose disc is more than 30% off-frame loses too much template support for the correlation peak to be sharp.

BODY_DISC_MIN_VISIBLE_LIT_FRACTION: float = 0.4

Minimum lit-and-in-FOV fraction for BODY_DISC emission.

Below 40% of the lit hemisphere visible, the disc match is too asymmetric to be useful; BODY_BLOB or LIMB_ARC carries the load.

BODY_POSITION_SLOP_FRAC: float = 0.05

Inflation factor for the body bbox before clipping.

The oops.inventory bounding box is sometimes a half-pixel too small. Inflating it by 5% before clipping into the extfov keeps anti-aliased limb pixels from being lost on the boundary.

LIMB_ARC_MAX_UNCERTAINTY_PX: float = 3.0

Cap on the limb normal-sigma at which LIMB_ARC remains useful.

Above this value the per-vertex normal uncertainty is too large for the DT-based limb fit; the extractor switches to BODY_BLOB so the brightness-weighted-centroid technique still has something to work with. The numeric value is a config default pending calibration against the operator-curated image library.

class NavModelBody(name: str, obs: Observation, body_name: str, *, inventory: dict[str, Any] | None = None, config: Config | None = None)[source]

Bases: NavModelBodyBase

Catalog-driven body NavModel.

Parameters:
  • name – Model instance name (e.g. 'body:MIMAS').

  • obs – Observation snapshot.

  • body_name – SPICE body name.

  • inventory – Optional pre-computed inventory entry; pulled from obs.inventory on demand otherwise.

  • config – Optional Config override.

create_model() None[source]

Render the silhouette, masks, and polylines used by to_features.

classmethod instances_for_obs(obs: Observation, *, config: Config | None = None) list[NavModel][source]

Return one NavModelBody per body whose bbox lies inside extfov.

Selects every in-FOV body via bodies_in_extfov() and constructs a NavModel for each. Titan is excluded: its opaque haze hides the surface, so ellipsoid-shape navigation is systematically wrong, and NavModelTitan handles it instead.

Parameters:
  • obs – Observation snapshot.

  • config – Configuration whose satellite catalog decides which bodies are considered; also passed to the constructed instances. None uses DEFAULT_CONFIG.

Returns:

One NavModelBody per non-Titan body present in the extfov.

to_annotations(context: NavContext) Annotations[source]

Reuse the shared body annotation helper.

to_features(context: NavContext) list[NavFeature][source]

Emit the body’s NavFeatures per the design’s gate rules.

TERMINATOR_MIN_PHASE_FACTOR: float = 0.05

Minimum sin(phase_angle) for TERMINATOR_ARC emission.

Below sin(phase) ~= 0.05 (phase < 3 deg) the terminator is too close to the limb to be photometrically distinguishable.

TERMINATOR_MIN_VERTICES: int = 8

Minimum surviving vertices for TERMINATOR_ARC emission.

TITAN_BODY_NAME: str = 'TITAN'

SPICE name of the one body handled as a special opaque-atmosphere case.

Titan’s thick haze hides the surface and its visible limb is the haze top, wavelength-dependent and hundreds of km above the ground, so ellipsoid limb / terminator / disc navigation is systematically wrong rather than merely noisy; at high phase Titan is not even a circle. Titan builds no shape-based NavModelBodyNavModelTitan records a no-result instead. Titan’s atmosphere is unique (transparent in some wavelengths), so it is handled as a deliberate special case; its handling does not generalize to other thick-atmosphere bodies such as Venus.

bodies_in_extfov(obs: Observation, *, config: Config | None = None) list[tuple[str, dict[str, Any]]][source]

Return (body_name, inventory_entry) for each body inside the extfov.

Queries obs.inventory once with the planet plus its configured satellites and keeps every body whose inventory_body_in_extfov predicate fires. Shared by the shape-based body model and the Titan model so both select from the same in-FOV body set.

Parameters:
  • obs – Observation snapshot.

  • config – Configuration whose satellite catalog decides which bodies are considered; None uses DEFAULT_CONFIG.

Returns:

List of (body_name, inventory_entry) pairs in planet-then-satellite order; empty when the observation exposes no usable inventory.

limb_reliability(*, visible_arc_fraction: float, visible_arc_px: float) float[source]

Sigmoid-of-sum reliability for LIMB_ARC features.

Shared emission policy: the SPICE-backed model scores its limb sampler with this, and the simulated body model feeds it the same quantities computed from its own render (arc fraction net of frame clipping and body-body occlusion), so the two models cannot desync on how a limb’s reliability responds to arc visibility and length.

The score answers a feature-existence question: is this limb arc a target a downstream technique should bother running on? Per-vertex geometric softness (high incidence at the terminator-adjacent end of the limb) lives in _sigma_normal_per_vertex(), where the LM fit weights individual vertices by their normal sigma; folding it into the reliability scalar as well would double-count the same physics and, because incidence_factor saturates near the cap on every fully-lit body, would penalize the cleanest possible geometries the hardest.

shape_features_suppressed(shape: BodyShape, predicted_diameter_px: float, *, config: Config) bool[source]

Whether a body’s shape features (limb / terminator / disc) are suppressed.

Highly-irregular bodies (chaotic rotators, small potato moons) have no usable ellipsoid. Once such a body is resolved beyond a few pixels the rendered limb / terminator / disc silhouette does not match the real body, so those shape features are suppressed; a point-like BODY_BLOB still navigates the centroid. The ‘resolved’ threshold reuses the bodies-config min_bounding_box_area – its square root is the equivalent linear pixel extent (default 9 px^2 -> 3 px). Bodies tagged merely ‘irregular’ are left untouched: the continuous ellipsoid-residual widening in the sigma budget already degrades their limb reliability without dropping the feature.

This is the shared emission policy: the SPICE-backed model applies it to all three shape features, and the simulated body model applies it to its TERMINATOR_ARC so the two models cannot desync on which bodies terminate.

Parameters:
  • shape – The body’s catalog shape profile.

  • predicted_diameter_px – Predicted silhouette diameter in pixels.

  • config – Configuration supplying bodies.min_bounding_box_area.

Returns:

True when the body’s shape features must not be emitted.

terminator_reliability(*, visible_arc_fraction: float, albedo_variation: float, phase_factor: float) float[source]

Reliability of TERMINATOR_ARC mirroring the design’s formula.

Shared emission policy: the SPICE-backed model scores its terminator sampler with this, and the simulated body model feeds it the same quantities computed from its own render, so the two models cannot desync on how a terminator’s reliability responds to arc visibility, surface albedo variation, and phase.

Parameters:
  • visible_arc_fraction – Fraction of the predicted terminator ridge that is visible / usable for the fit.

  • albedo_variation – The body’s catalog albedo-variation figure.

  • phase_factorsin(phase_angle); capped at 1.0.

Returns:

The [0, 1] reliability score.

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).

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).

class NavModelBodyBase(name: str, obs: ObsSnapshot, *, config: Config | None = None)[source]

Bases: NavModel

Base class for body navigation models.

Provides shared helpers to compute a limb mask, build the BODY_BLOB feature, and create annotations consistent across every concrete body model.

The BODY_BLOB construction (_build_blob_feature and its _phase_irregularity_factor / _lit_weighted_centroid_vu helpers) lives here so the SPICE-backed NavModelBody and the simulated NavModelBodySimulated share one implementation rather than two copies of the same phase-and-irregularity calibration math. Subclasses populate the attributes the helpers read: _model_img, _body_mask, _predicted_center_vu, _predicted_diameter_px, _km_per_pixel_at_limb, _subject_range_km, _bbox_extfov_vu, and _metadata['phase_angle_deg'].

Simulated-body NavModel.

Renders a body from operator-supplied geometric parameters (centre, axes, rotation, lighting) rather than from SPICE. Used by the simulated-image GUI to compose synthetic test scenes; the rendered body becomes a BODY_DISC NavFeature that the standard pipeline can navigate against. A well-resolved low-phase body also emits a LIMB_ARC, and a body at appreciable phase emits a TERMINATOR_ARC – both matching the SPICE-backed NavModelBody’s geometry and gating semantics (polyline conventions, emission gates, reliability inputs), so a sim scene exercises the same techniques a real frame would. The per-vertex sigma model is the deliberate exception: the real model derives per-vertex sigmas from the PSF, limb softness, and albedo terms, while the noise-free sim render carries fixed per-vertex values (see _TERMINATOR_SIGMA_NORMAL_PX).

class NavModelBodySimulated(name: str, obs: Observation, body_name: str, sim_params: dict[str, Any], *, config: Config | None = None, sibling_bodies: list[dict[str, Any]] | None = None)[source]

Bases: NavModelBodyBase

Body NavModel rendered from operator-supplied simulation parameters.

apply_limb_emission_gates

When True (the default) the LIMB_ARC is emitted only for a well-resolved, low-phase body (the navigation-policy gates below). Measurement callers (the realism match) set this to False: the silhouette geometry exists regardless of whether a limb fit would be reliable, and the gates-off path emits the lit geometric limb – the phase-0 silhouette boundary restricted to lit vertices – matching the real body model’s LIMB_ARC definition instead of the lit-region boundary (which mixes limb and terminator at nonzero phase).

Parameters:
  • name – Name of this model instance.

  • obs – Observation containing image geometry (used for output shapes and extfov margins).

  • body_name – Logical body name used in metadata and labels.

  • sim_params

    Dictionary of simulation parameters. Expected keys:

    • name

    • center_v, center_u (pixel coordinates of the centre)

    • range_km (km; subject distance, defaults to inf)

    • axis1, axis2, axis3 (pixels; full widths of the ellipsoid axes)

    • rotation_z (deg; rotation about the line of sight)

    • rotation_tilt (deg; tilt of the body)

    • illumination_angle (deg)

    • phase_angle (deg)

    Crater and anti-aliasing keys are accepted but ignored; anti-aliasing is always maximal here.

  • config – Optional Config override.

  • sibling_bodies – Idealized parameter dicts of the OTHER bodies in the same scene (from the same filtered nav_params['bodies'] list this body came from). A sibling with an explicitly nearer range_km occludes this body’s predicted limb / terminator arcs: occluded vertices are dropped from the emitted polylines and the visible-arc fractions report the loss, mirroring how the SPICE-backed model’s per-vertex drops feed its arc fraction.

create_model() None[source]

Render the simulated body and populate masks, annotations, metadata.

classmethod instances_for_obs(obs: Observation, *, config: Config | None = None) list[NavModel][source]

Build one simulated body model per body of a simulated obs.

Reads the per-body entries of the filtered idealized view (obs.nav_params['bodies']) – never the full scene, whose truth keys stay behind the information boundary. Returns an empty list for a real obs, so the SPICE-backed NavModelBody handles those instead.

A scene body may carry a nav_override mapping: the renderer draws the true shape, while the boundary filter overlays the override onto the idealized view this model consumes – the channel that makes the navigation geometry diverge from the render geometry. A scene renders an irregular mesh at the true pose yet predicts an ellipsoid (shape mismatch, B7 scenario 2), or the same mesh at a different pose (chaotic-rotator pose disagreement, B7 scenario 3), without touching the rendered image. The override never changes the centre, so the predicted body stays at the unshifted position the planted offset is measured from.

Parameters:
  • obs – Observation snapshot.

  • config – Configuration passed to the constructed instances. None uses DEFAULT_CONFIG.

Returns:

One NavModelBodySimulated per body in the sim scene.

to_annotations(context: NavContext) Annotations[source]

Emit body silhouette + label annotations for the summary PNG.

to_features(context: NavContext) list[NavFeature][source]

Emit the body’s NavFeatures.

Always emits a BODY_DISC carrying the rendered template (for the correlation technique). Also emits a BODY_BLOB whenever the predicted silhouette is large enough – the lit-weighted centroid is orientation-independent, so it is the technique that navigates small, high-phase, or irregular bodies that the disc correlation cannot. A well-resolved low-phase body adds a LIMB_ARC; a body at appreciable phase adds a TERMINATOR_ARC (the lit/unlit boundary interior to the disc), each matching the SPICE-backed body model’s gate rules.

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.

class NavModelRings(name: str, obs: Observation, *, config: Config | None = None)[source]

Bases: NavModelRingsBase

Catalog-driven ring NavModel for one planet.

Parameters:
  • name – Model instance name (e.g. 'rings:SATURN').

  • obs – Observation snapshot.

  • config – Optional Config override.

create_model() None[source]

Run the four-pass filter, render surviving features, populate metadata.

classmethod instances_for_obs(obs: Observation, *, config: Config | None = None) list[NavModel][source]

Return one NavModelRings per planet whose ring catalog touches the FOV.

Parameters:
  • obs – Observation snapshot.

  • config – Configuration whose ring catalog decides which planets have navigable rings; also passed to the constructed instances. None uses DEFAULT_CONFIG.

Returns:

[NavModelRings] for the closest planet when its ring catalog is configured and the obs exposes the extfov surface, else [].

to_annotations(context: NavContext) Annotations[source]

Render per-edge polyline overlays + ring labels.

to_features(context: NavContext) list[NavFeature][source]

Emit RING_EDGE / RING_ANNULUS features per surviving render result.

Base class for ring navigation models.

This module provides the annotation creation helper shared by the real ring model (NavModelRings) and the simulated ring model (NavModelRingsSimulated).

Anti-aliasing is implemented in spindoctor.nav_model.rings.ring_math.compute_antialiasing and is invoked from RingFeature._render_full_ringlet(). Annotation helpers live here because they need observation metadata (image shape, config font settings) that belongs with NavModel, unlike the pure math in ring_math.

class NavModelRingsBase(name: str, obs: ObsSnapshot, *, config: Config | None = None)[source]

Bases: NavModel

Base class for ring navigation models.

Provides the _create_edge_annotations helper for creating annotations consistent between the real and simulated ring model implementations. Anti-aliasing math is in spindoctor.nav_model.rings.ring_math.

Simulated-rings NavModel.

Predicts ring features for a simulated observation from the idealized ring_system view the information boundary exposes (obs.nav_params['ring_system']): the shared projection geometry and the navigable features’ catalog orbits, shapes, and declared orbit uncertainties. Non-navigable features never reach this model (the boundary filter drops them), and the planted per-feature orbit_error is a truth key this model cannot see – the navigator predicts catalog positions and must absorb the planted misplacement honestly.

One model instance per navigable feature. Each banded feature (ringlet) emits a RING_ANNULUS template for the correlation path; every feature emits one RING_EDGE polyline per predicted catalog boundary for the distance-transform fit. Rendering is delegated to spindoctor.nav_model.sim_ring, whose projection and orbit math are shared with the image-side renderer by design.

class NavModelRingsSimulated(name: str, obs: Observation, feature_name: str, feature_params: dict[str, Any], ring_system: dict[str, Any], *, config: Config | None = None)[source]

Bases: NavModelRingsBase

Ring NavModel predicted from a scene’s idealized ring_system view.

Parameters:
  • name – Name of this model instance.

  • obs – Observation containing image geometry.

  • feature_name – The feature’s name, used in metadata and labels.

  • feature_params – The feature’s idealized mapping from nav_params['ring_system']['features'] (kind, shape keys, catalog orbit, tau, declared_orbit_sigma).

  • ring_system – The idealized ring_system block (shared geometry, range_km, km_per_pixel, phase_deg).

  • config – Optional Config override.

create_model() None[source]

Render the predicted feature and populate masks, annotations, metadata.

classmethod instances_for_obs(obs: Observation, *, config: Config | None = None) list[NavModel][source]

Build one simulated ring model per navigable ring_system feature.

Reads the filtered idealized view (obs.nav_params['ring_system']), whose feature list carries exactly the navigable subset; returns an empty list for a real obs so the SPICE-backed NavModelRings handles those instead.

Parameters:
  • obs – Observation snapshot.

  • config – Configuration passed to the constructed instances. None uses DEFAULT_CONFIG.

Returns:

One NavModelRingsSimulated per navigable feature.

to_annotations(context: NavContext) Annotations[source]

Emit ring-edge polyline + label annotations.

to_features(context: NavContext) list[NavFeature][source]

Emit the ring features.

Emits a RING_ANNULUS carrying the predicted coverage template (for the correlation path) whenever the feature is a banded kind with coverage on the ext-FOV. The template payload convention (compose_template_features) is a postage stamp local to bbox_extfov_vu, so the ext-FOV-sized prediction is cropped to the mask’s tight bbox before emission. Also emits one RING_EDGE per predicted catalog boundary – a per-vertex polyline with outward radial normals that RingEdgeNav fits against the image-edge distance transform, so a curved ring arc recovers the planted offset in both axes.

The navigator’s predicted-body renderer for simulated scenes.

Renders the smooth Lambert ellipsoid NavModelBodySimulated predicts from a scene’s idealized body geometry. This is deliberately the navigator’s best model, not the image: surface texture (craters, relief) is truth-side information rendered only by the image-side twin (spindoctor.sim.forward.body), and the difference between the two is exactly the model error a scene plants. Shading conventions are shared with the image side through spindoctor.sim.ellipsoid_geometry, so that planted difference is the only difference.

create_simulated_body(size: tuple[int, int], center: tuple[float, float], axis1: float, *, axis2: float, axis3: float, rotation_z: float = 0.0, rotation_tilt: float = 0.0, illumination_angle: float = 0.0, phase_angle: float = 0.0, anti_aliasing: float = 0.0) NDArray[floating[Any]][source]

Render the predicted body: a smooth ellipsoid with Lambertian shading.

The body is modeled as a 3D ellipsoid projected onto 2D and illuminated using Lambertian shading (cos(incidence)) based on the illumination direction and phase angle.

Parameters:
  • size – Tuple of (size_v, size_u) giving the image dimensions in pixels.

  • center – Tuple of (v, u) giving the center position in floating-point pixels. (0.0, 0.0) is the top-left corner of pixel (0,0), (0.5, 0.5) is the center of pixel (0,0).

  • axis1 – The full width of axis 1 (a) of the ellipsoid in pixels.

  • axis2 – The full width of axis 2 (b) of the ellipsoid in pixels.

  • axis3 – The full width of axis 3 (c) of the ellipsoid in pixels (depth).

  • rotation_z – Rotation angle around the viewing axis (z-axis) in radians (0 to 2pi).

  • rotation_tilt – Tilt angle of the ellipsoid in radians (0 to pi/2). Controls how much the ellipsoid is tilted toward/away from the viewer.

  • illumination_angle – Direction of illumination in the image plane in radians (0 to 2pi). 0 radians is at the top of the image, pi/2 is to the right.

  • phase_angle – Phase angle in radians (0 to pi). 0 = head-on illumination (fully illuminated), pi/2 = side illumination (half illuminated), pi = back illumination (no visible illumination).

  • anti_aliasing – Float between 0 and 1 controlling anti-aliasing amount at the limb. 0 = no anti-aliasing, 1 = maximum anti-aliasing. Only affects the edge.

Returns:

A 2D numpy array of shape (size_v, size_u) with float values from 0.0 to 1.0, where 0.0 is black and 1.0 is full white.

The navigator’s predicted-ring renderer for simulated scenes.

Predicts a ring_system feature’s appearance from its idealized catalog view (the obs.nav_params['ring_system'] block): the shared projection geometry, the feature’s kind/shape keys, and its catalog orbit – never the planted orbit_error or the photometric truth, which the boundary filter strips before this code can see them. Projection and orbit math are shared with the image-side renderer through spindoctor.sim.ring_geometry, so a predicted edge lands where the rendered edge would land if the scene planted no error: the planted pointing offset and the planted orbit error are the only discrepancies, by construction.

The prediction is geometric, not photometric: a banded feature yields a solid anti-aliased coverage template (the navigator’s opaque-annulus convention; the tau photometry is truth) plus one border polyline per catalog edge with outward radial normals for the distance-transform fit.

class PredictedRingEdge(edge_type: str, mask: NDArray[bool], vertices_vu: NDArray[floating[Any]], normals_vu: NDArray[floating[Any]])[source]

Bases: object

One predicted catalog edge of a ring feature.

Parameters:
  • edge_type – ‘inner’ or ‘outer’ (the banded kinds), or ‘edge’ (the single-boundary kinds).

  • mask – Boolean border mask on the prediction grid (1-pixel polyline).

  • vertices_vu(N, 2) border-pixel positions.

  • normals_vu(N, 2) unit normals pointing radially outward in the ring plane (the direction of increasing ring radius on the sky).

edge_type: str
mask: NDArray[bool]
normals_vu: NDArray[floating[Any]]
vertices_vu: NDArray[floating[Any]]
class PredictedRingFeature(template: NDArray[floating[Any]] | None, mask: NDArray[bool], edges: list[PredictedRingEdge])[source]

Bases: object

The navigator’s rendered prediction of one ring feature.

Parameters:
  • template – Solid anti-aliased coverage in [0, 1] for the correlation path, or None for kinds with no bounded band (edge / ramp / wave, and gaps, which reveal rather than emit).

  • mask – Pixels the feature’s band covers (or its border for the single-boundary kinds); the annotation avoid mask.

  • edges – The predicted catalog edges.

edges: list[PredictedRingEdge]
mask: NDArray[bool]
template: NDArray[floating[Any]] | None
predict_ring_feature(shape: tuple[int, int], feature: dict[str, Any], *, center_v: float, center_u: float, opening_deg_obs: float, node_deg: float, time: float = 0.0, epoch: float = 0.0) PredictedRingFeature[source]

Predict one ring_system feature on the given (extfov) grid.

Parameters:
  • shape – The prediction-grid shape (detector resolution).

  • feature – The feature’s idealized mapping from nav_params.

  • center_v – Projected ring center v on the prediction grid.

  • center_u – Projected ring center u on the prediction grid.

  • opening_deg_obs – Observer ring opening angle B in degrees.

  • node_deg – Sky position angle of the ascending node in degrees.

  • time – Scene time in TDB seconds.

  • epoch – Ring epoch in TDB seconds.

Returns:

The rendered PredictedRingFeature. Empty (no edges, no coverage) for an exactly edge-on geometry, which renders nothing.

Titan NavModel – records a no-result for Titan’s opaque haze.

Titan needs a different algorithm than ellipsoid-limb fitting: its visible “limb” is the haze top, varies with wavelength, and the surface inside is invisible. At high phase Titan is not even a circle, so disc / limb / terminator navigation is systematically wrong rather than merely noisy.

Titan is handled as a deliberate special case: its atmosphere is unique (transparent at some wavelengths), so its handling does not generalize to other thick-atmosphere bodies such as Venus. This model is built and active whenever Titan is in the field of view (the shape-based NavModelBody skips Titan). It emits no features, so no technique navigates it; instead it records, per image, why a Titan scene cannot be navigated. The orchestrator reads the marker it exposes and fails such a frame with TITAN_UNSUPPORTED rather than a silent empty failure.

class NavModelTitan(name: str, obs: Observation, *, config: Config | None = None)[source]

Bases: NavModel

Titan NavModel that declines to navigate.

Concrete Titan navigation requires a haze-aware limb-fit technique with per-filter haze profiles; that algorithm is out of scope for this pipeline today. The model exists so Titan in the FOV is recorded as an explicit no-result rather than vanishing into a generic empty-scene failure.

Parameters:
  • name – Model name ('titan:TITAN').

  • obs – Observation snapshot.

  • config – Optional Config override.

create_model() None[source]

Record Titan and log why it cannot be navigated.

classmethod instances_for_obs(obs: Observation, *, config: Config | None = None) list[NavModel][source]

Return one instance when Titan is inside the extfov, else none.

Parameters:
  • obs – Observation snapshot.

  • config – Configuration whose satellite catalog decides whether Titan is in the mission set. None uses DEFAULT_CONFIG.

Returns:

A single NavModelTitan when Titan is present in the extfov, otherwise an empty list.

property titan_in_fov: bool

Whether Titan is in the field of view.

The orchestrator reads this to attribute an otherwise-empty frame to Titan non-support.

Returns:

the model is only built when Titan is in the field of view.

Return type:

Always True

to_annotations(context: NavContext) Annotations[source]

Return an empty annotation collection.

Parameters:

context – Per-image navigation context; unused because the model renders no overlay.

Returns:

An empty Annotations collection, always.

to_features(context: NavContext) list[NavFeature][source]

Return an empty feature list – Titan navigation is unsupported.

Parameters:

context – Per-image navigation context; unused because the model runs no fit.

Returns:

Titan emits no navigable features.

Return type:

An empty list, always

spindoctor.nav_model.rings

Ring feature domain model for planetary navigation.

This subpackage defines typed domain objects for ring feature data, rendering, and filtering: immutable dataclasses with validation at construction time and rendering behavior on RingFeature.

Architecture overview:

  • ring_types: Pure frozen dataclasses for orbital parameters. No rendering dependencies; safe for lightweight import.

  • ring_render_context: Immutable bundle of rendering dependencies passed to RingFeature.render().

  • ring_render_result: Lightweight result object returned by rendering.

  • ring_feature: Core domain object. Owns backplane-based rendering and cross-feature date-overlap validation.

  • ring_filter: Four-pass filter pipeline deciding which features to render.

  • ring_math: Pure mathematical functions for fade and anti-aliasing.

Immutable data types for ring feature orbital parameters.

This module defines the core value objects that represent ring edge orbital data: RingFeatureType, RingBaseOrbitMode, RingPerturbationMode, and RingEdgeData. These are frozen dataclasses (immutable) because orbital parameters from YAML config are physical constants that should never be modified after loading. Validation occurs at construction time so that downstream rendering code can trust the data without defensive checks.

Separating these types into their own module keeps them free of rendering dependencies (oops, numpy), enabling lightweight import for testing and for the simulated model which uses the types for data validation but does not use backplane rendering.

Design notes:

  • RingBaseOrbitMode and RingPerturbationMode are distinct types to resolve the ambiguity in the YAML config where mode: 1 can appear with either base-orbit fields (a, ae, …) or perturbation fields (amplitude, phase, …). Having separate types makes the dispatch explicit in RingFeature.from_config().

  • Inclination modes (mode_num > 90) are stored in RingEdgeData.perturbations because they are valid YAML data. However, RingEdgeData.radial_perturbations() and parsed_modes_for_backplane() exclude them, because the oops backplane radial_mode() function only handles radial (in-plane) perturbations. Making the limitation visible here rather than silently skipping them in rendering code surfaces the issue for future implementors.

class RingBaseOrbitMode(a: float, ae: float, long_peri: float, rate_peri: float, rms: float)[source]

Bases: object

Mode-1 orbital parameters defining the base orbit of a ring edge.

This represents the fundamental circular (or nearly circular) orbit of a ring edge before any higher-order perturbation modes are applied. It is always present in the data; higher modes are optional perturbations.

Parameters:
  • a – Semi-major axis in km. Must be > 0.

  • ae – Eccentricity amplitude in km. Zero means circular.

  • long_peri – Longitude of pericenter in degrees.

  • rate_peri – Precession rate of pericenter in degrees/day.

  • rms – RMS residual of the orbit fit in km. Must be >= 0. Used as the uncertainty measure for navigation.

Raises:
  • TypeError – If any numeric field is not a finite real number (bool is rejected because it is a subclass of int).

  • ValueError – If a <= 0, ae < 0, or rms < 0.

__post_init__() None[source]

Validate numeric types, finiteness, and field ranges at construction time.

a: float
ae: float
long_peri: float
rate_peri: float
rms: float
class RingEdgeData(base_orbit: RingBaseOrbitMode, perturbations: tuple[RingPerturbationMode, ...])[source]

Bases: object

All orbital mode data for one edge of a ring feature.

Combines the base orbit (RingBaseOrbitMode) with zero or more higher-order perturbations (RingPerturbationMode). This is the complete description needed to compute the radius of one ring edge at any point in the image backplane.

Parameters:
  • base_orbit – The mode-1 base orbit parameters.

  • perturbations – Tuple of higher-order perturbation modes. May be empty. Inclination modes (mode_num > 90) are accepted here but excluded from backplane computation – see radial_perturbations(). A mutable sequence (e.g. list) is accepted at construction and stored as an immutable tuple.

Raises:
  • ValueError – If base_orbit or perturbations is None.

  • TypeError – If base_orbit is not a RingBaseOrbitMode, if perturbations is not a non-string sequence, or if any sequence element is not a RingPerturbationMode.

__post_init__() None[source]

Validate types and freeze perturbations as a tuple.

base_orbit: RingBaseOrbitMode
property base_radius: float

Semi-major axis of the base orbit in km.

This is the nominal radius used for spatial filtering and conflict detection. It is the mean radius of the edge, not the instantaneous (perturbed) radius at any given longitude.

parsed_modes_for_backplane() list[tuple[Any, ...]][source]

Convert edge data to tuples for oops radial_mode computation.

Returns a list of tuples in the format expected by oops.ext_bp.radial_mode():

  • Mode 1 (base orbit): (1, a, ae, long_peri_rad, rate_peri_rad_per_sec)

  • Other modes: (mode_num, amplitude, phase_rad, speed_rad_per_sec)

Inclination modes (mode_num > 90) are excluded because oops.ext_bp.radial_mode only supports in-plane perturbations. The base orbit always comes first, followed by perturbations in their original order.

Returns:

List of mode tuples. Always contains at least the base orbit tuple.

perturbations: tuple[RingPerturbationMode, ...]
radial_perturbations() tuple[RingPerturbationMode, ...][source]

Return only radial (non-inclination) perturbation modes.

Inclination modes (mode_num > 90) require out-of-plane backplane support that is not yet implemented in oops. This method filters them out so callers do not need to check individually.

Returns:

Tuple of perturbation modes with mode_num <= 90.

property rms: float

RMS residual of the orbit fit in km.

Propagated to RingFeature.uncertainty (max of inner and outer edge RMS values).

class RingFeatureType(*values)[source]

Bases: Enum

Classification of a ring feature as a gap or ringlet.

Determines the rendering polarity: - RINGLET: image is brightened between the two edges (fill between). - GAP: image is darkened between the two edges (clear between). Single-edge features of either type use fading instead of solid fill.

GAP = 'GAP'
RINGLET = 'RINGLET'
class RingPerturbationMode(mode_num: int, amplitude: float, phase: float, pattern_speed: float)[source]

Bases: object

A single radial or inclination perturbation mode for a ring edge.

Higher-order perturbations are superimposed on the base orbit defined by RingBaseOrbitMode. They represent resonance-driven distortions.

Modes with mode_num > 90 are inclination (out-of-plane) perturbations. These are stored in the data model because they appear in real YAML config files (e.g. Cassini Division features). However, inclination modes are not supported for radial backplane rendering because oops.ext_bp.radial_mode only handles in-plane distortions. Use is_inclination_mode or RingEdgeData.radial_perturbations() to filter them out.

Parameters:
  • mode_num – Perturbation mode number passed to oops.ext_bp.radial_mode. Values > 90 indicate inclination modes. Mission ring tables also use non-positive indices (e.g. 0 or negative modes in Saturn YAML).

  • amplitude – Perturbation amplitude in km.

  • phase – Perturbation phase in degrees.

  • pattern_speed – Pattern speed in degrees/day.

Raises:

ValueError – If mode_num is not an integer (bool is rejected), if amplitude is not an int/float (bool rejected), is not finite, or is negative, or if phase or pattern_speed is not a finite int/float.

__post_init__() None[source]

Validate mode number, amplitude, phase, and pattern_speed.

amplitude: float
property is_inclination_mode: bool

Return True if this is an inclination (out-of-plane) mode.

Inclination modes have mode_num > 90. They represent vertical perturbations that require out-of-plane backplane support, which is not yet implemented. Callers should exclude these from radial rendering.

mode_num: int
pattern_speed: float
phase: float

Ring feature domain object and cross-feature date-overlap validation.

This module is the core of the ring domain model. It defines:

  • RingFeature: An immutable domain object representing a single ring gap or ringlet. It owns backplane-based rendering via render(context) and provides query methods used by the filter and orchestrator.

  • validate_no_date_overlaps(): A cross-feature validation function that detects authoring errors in the YAML config where two features cover the same radial region with overlapping date ranges.

Validation philosophy: from_config() raises ValueError on any malformed per-feature data (bad types, missing fields, out-of-range values). validate_no_date_overlaps() raises ValueError on cross-feature date conflicts. Both are hard errors because bad config is an authoring mistake that should be caught immediately, not silently degraded at render time. The RingFeatureFilter handles valid features that are not relevant to a particular observation.

Immutability: RingFeature is a frozen dataclass. All attributes are set at construction and never mutated. Derived cached fields (_start_et, _end_et) are set in __post_init__ using object.__setattr__(), the standard Python pattern for frozen dataclasses that need computed cached values – __post_init__ is the only place this bypass is appropriate. After construction completes, all fields are truly frozen.

Rendering dispatch: render() calls _compute_edge_radii() which uses RingEdgeData.parsed_modes_for_backplane() to get the mode tuples for the oops.ext_bp.radial_mode() backplane calls. The rendering path branches based on feature type and edge availability:

  • RINGLET with both edges -> _render_full_ringlet() -> one RingRenderResult

  • GAP with either or both edges, or single-edge RINGLET -> _render_single_edge() per present edge -> one RingRenderResult per edge

class RingFeature(key: str, name: str | None, feature_type: RingFeatureType, inner_edge: RingEdgeData | None, outer_edge: RingEdgeData | None, start_date: str | None = None, end_date: str | None = None)[source]

Bases: object

A single ring feature (gap or ringlet) with backplane rendering capability.

Frozen dataclass: all attributes are set at construction and never mutated. render() takes context and returns results without modifying feature state. This immutability guarantees that feature data loaded from YAML config remains consistent throughout the pipeline: multiple filter passes, render calls, and annotation creation all see the same data.

Owns backplane-based rendering for real observations. (Simulated observations never build RingFeatures: NavModelRingsSimulated predicts scene ring_system features through spindoctor.nav_model.sim_ring instead.)

__post_init__() None[source]

Validate and cache date conversions.

Uses object.__setattr__() to set derived fields on a frozen dataclass. This is the standard Python pattern for frozen dataclasses that need computed cached values – __post_init__ is the only place where this bypass is appropriate. After construction completes, all fields are truly frozen.

Raises:

ValueError – If both inner_edge and outer_edge are None, or if both dates convert to ET and start_date does not strictly precede end_date.

all_base_radii() list[tuple[float, str]][source]

Return (radius_km, edge_label) pairs for all present edges.

Edge labels follow the convention: - RINGLET inner edge: ‘IER’ (Inner Edge Ringlet) - RINGLET outer edge: ‘OER’ (Outer Edge Ringlet) - GAP inner edge: ‘IEG’ (Inner Edge Gap) - GAP outer edge: ‘OEG’ (Outer Edge Gap)

Returns:

List of (radius_km, label) tuples for present edges.

property edge_labels: dict[str, str]

Map of ‘inner’/’outer’ to edge label string.

Returns:

Dict with keys ‘inner’ and ‘outer’ mapping to label strings (‘IER’/’OER’ for ringlets, ‘IEG’/’OEG’ for gaps).

edge_uncertainty(edge_type: str) float[source]

RMS (km) of one named edge’s own orbit solution.

Unlike uncertainty (the max across the feature’s edges, a deliberately conservative per-feature scale for the robust fit’s per-vertex weighting), this is the requested edge’s own value. A radial displacement bound belongs to the edge whose orbit solution it describes: on the Keeler-A Ring OE ringlet the inner edge is fitted to 1.006 km while the outer is 10.18 km, and charging the inner edge the outer’s quality would price a well-determined edge at its sibling’s.

Parameters:

edge_type – The side name ('inner' / 'outer') or this feature’s catalog edge label for that side ('IER' / 'OER' for a ringlet, 'IEG' / 'OEG' for a gap). Both spellings are accepted because the render pipeline carries the catalog label on its edge tuples while callers working from the feature structure use the side name.

Returns:

That edge’s RMS in km, or 0.0 when the named edge is absent (a single-edge feature asked for the missing side).

Raises:

ValueError – If edge_type names neither a side nor one of this feature’s edge labels.

end_date: str | None = None
feature_type: RingFeatureType
classmethod from_config(key: str, data: dict[str, Any]) RingFeature[source]

Construct a RingFeature from a YAML feature dictionary.

Validates all fields at construction time. This follows the principle that bad config is an authoring error that should fail loudly and immediately, not silently degrade at render time.

Parameters:
  • key – Feature key (YAML dict key used as identifier).

  • data – Feature dictionary with keys: - feature_type: ‘GAP’ or ‘RINGLET’ (required) - name: Human-readable name (optional) - inner_data: List of mode dicts (optional) - outer_data: List of mode dicts (optional) - start_date: ISO date string (optional) - end_date: ISO date string (optional)

Returns:

Constructed RingFeature instance.

Raises:
  • TypeError – If key is not a str or data is not a dict.

  • ValueError – On any structural or value error: - feature_type not ‘GAP’ or ‘RINGLET’ - neither inner_data nor outer_data present - mode data is not a non-empty list - mode-1 data missing or has non-positive ‘a’ - rms < 0 - perturbation mode missing required fields

inner_edge: RingEdgeData | None
is_in_radius_range(min_r: float, max_r: float) bool[source]

Return True if at least one edge is within the given radius range.

A feature is kept if ANY of its edges falls in [min_r, max_r]. This enables partial visibility: a ringlet with one edge in range and one out of range is rendered as a single-edge feature by render().

Parameters:
  • min_r – Minimum ring radius in km (inclusive).

  • max_r – Maximum ring radius in km (inclusive).

Returns:

True if at least one edge base radius is in [min_r, max_r].

is_visible_at(obs_time_et: float) bool[source]

Return True if this feature is valid at the given observation time.

A feature with no date range is always visible. If only start_date is set, the feature is visible at and after that date. If only end_date is set, the feature is visible before that date. The range is half-open: [start_et, end_et).

Parameters:

obs_time_et – Observation time in TDB seconds (from utc_to_et).

Returns:

True if the feature is active at obs_time_et.

key: str
property max_extent_radius: float

Maximum possible radius (a + ae) across all present edges.

Returns the outermost radius the feature could occupy at any longitude, accounting for eccentricity. Used for a fast pre-filter check: if the minimum observed ring radius in the FOV exceeds this value, none of the feature can appear in the image regardless of orientation.

Returns:

Maximum of (base_orbit.a + base_orbit.ae) for all present edges.

Raises:

ValueError – If both inner_edge and outer_edge are None, so max_extent_radius cannot be computed.

name: str | None
outer_edge: RingEdgeData | None
render(context: RingsRenderContext) list[RingRenderResult][source]

Render this feature using backplane data.

Dispatch logic:

  • RINGLET with both edges visible -> _render_full_ringlet() -> one result

  • GAP with any edges, or single-edge RINGLET -> _render_single_edge() per edge -> one result per edge

For RINGLETs with one edge out of the visible radius range (partial visibility), RingFeatureFilter trims the out-of-range edge to None before this method is called, so render() naturally takes the single-edge path for the remaining in-range edge (fade rendering).

Parameters:

context – Immutable rendering context with obs, ring_target, epoch, per-pixel resolutions, fade config, and all_edge_radii.

Returns:

List of RingRenderResult objects. Typically one result for full ringlets and one per edge for gaps and single-edge features.

start_date: str | None = None
property uncertainty: float

Maximum RMS across all present edges (km).

The maximum (rather than minimum or average) is conservative: the overall uncertainty of a feature is dominated by its least well-characterized edge.

Returns:

Max of inner and outer edge RMS values, or the single edge RMS if only one edge is present.

uses_fade_for_edge(edge_type: str) bool[source]

Return True if the given edge uses fade rendering by structure.

Structural check based on feature configuration:

  • GAP edges always use fade (gaps render a fading gradient from each known edge; they do not fill solid between edges).

  • RINGLET edges use fade only when the feature has a single edge (the other is None).

This is a structural check. The RingFeatureFilter augments this with partial-visibility awareness: if a RINGLET has both edges but one is out of the visible radius range, the filter treats the in-range edge as fade-using even though this method returns False.

Parameters:

edge_type – ‘inner’ or ‘outer’.

Returns:

True if this edge uses fade rendering by structure.

Raises:

ValueError – If edge_type is not exactly 'inner' or 'outer'.

validate_no_date_overlaps(features: Sequence[RingFeature]) None[source]

Cross-feature validation: detect date-range overlaps in the same radial region.

Two features “overlap” if their date ranges intersect AND their radial extents intersect. This catches authoring errors in the YAML config where the same ring edge is defined twice with overlapping validity periods.

This function runs after all features are loaded via from_config() and before the runtime filter. It is a hard error (ValueError) because overlapping dates for the same radial region is a config authoring mistake, not an observation-dependent condition. The filter handles valid features that are not relevant for a particular observation.

Parameters:

features – All features loaded from one planet’s config.

Raises:

ValueError – If any pair of features has overlapping dates AND overlapping radial extents.

Ring feature filter pipeline.

This module implements a four-pass filter that decides which ring features (and which edges within each feature) are included in the final render. It is deliberately separated from the rendering logic in ring_feature.py for two reasons:

  1. Single responsibility: A feature object knows how to render itself; the filter knows which features are worth rendering for a given observation. These are distinct concerns – a feature’s physics do not change based on whether it is currently visible.

  2. Testability: The filter is a pure function of its inputs (features + observation parameters). It can be tested independently of backplane computation. Rendering involves complex oops backplane calls that require mocking; the filter only needs simple numeric comparisons.

Pass 4 (fade conflict) checks individual edges, not whole features, because:

  • A GAP feature has two fade edges that shade independently in opposite directions. The outer edge shading outward may be clear of conflicts while the inner edge shading inward is blocked – the outer edge is still useful for navigation.

  • Excluding the whole feature because one edge is blocked would silently remove valid navigation signals.

Pipeline order rationale:

  1. Date (cheapest: pure arithmetic) eliminates features not valid for this image.

  2. Radius eliminates features outside the current field of view.

  3. Resolvability eliminates two-edge features too narrow to see as a width.

  4. Fade conflict (most expensive: needs all surviving edge radii) eliminates or trims individual fade-using edges that are squeezed by a neighbor.

Processing in this order means expensive operations run only on the smaller set of features that survive the cheaper passes.

class RingFeatureFilter(*, obs_time_et: float, min_radius: float, max_radius: float, min_res_at_radius: Callable[[float], float | None], fade_width_pix: float, min_allowed_fade_width_pix: float, min_feature_pixels: float, logger: Any)[source]

Bases: object

Four-pass filter deciding which ring features and edges to include in a render.

Instantiate with observation-specific parameters, then call filter() with the full feature list. The filter is stateless between calls; the same instance can be reused for the same observation parameters.

Pipeline passes:
  1. Date: exclude features not valid at obs_time_et.

  2. Radius: exclude features with no edge inside [min_radius, max_radius].

  3. Resolvability: exclude two-edge features narrower than min_feature_pixels * min_res km.

  4. Fade conflict: exclude or trim individual fade-using edges whose conflict-adjusted fade width falls below min_allowed_fade_width_pix * min_res.

filter(features: Sequence[RingFeature]) list[RingFeature][source]

Run the four-pass filter and return surviving features.

Features that fail a pass are excluded entirely. Features that partially fail pass 4 (one edge of a GAP excluded) are returned with the failing edge set to None.

Parameters:

features – Ring features retrieved from configuration for the planet.

Returns:

Filtered list of features, possibly with some edges trimmed to None.

Pure mathematical functions for ring edge rendering.

This module provides standalone functions for ring edge fade gradients and anti-aliasing. Keeping the numerics here separate from orchestration gives:

  1. Testability: Pure functions are exercised with numpy arrays without backplane-heavy integration tests.

  2. Reuse: Every backplane-based ring rendering path relies on the same anti-aliasing and fade logic defined here.

  3. Single responsibility: RingFeature.render() chooses what to render and assembles results; this module performs the mathematical work.

Design notes

Per-pixel fade width: compute_edge_fade accepts fade_width_pix (a scalar pixel count) and a per-pixel resolutions array, computing fade_width_km = fade_width_pix * resolutions element-wise. This ensures the fade spans exactly fade_width_pix pixels everywhere in the image, regardless of the local radial resolution. The integration bounds therefore vary per pixel.

Shade direction: shade_above=True and shade_above=False share one implementation through an internal shade_sign (+1 or -1) in compute_fade_integral. The two directions differ only in the sign of two terms in the closed form.

Conflict detection vs exclusion: compute_edge_fade handles width reduction when a neighboring feature’s edge falls within the fade zone (halving the fade at the conflict boundary). Exclusion of edges whose adjusted width falls below the minimum is handled upstream by RingFeatureFilter before rendering. This function therefore always produces a valid result.

compute_antialiasing(*, radii: NDArray[floating[Any]], edge_radius: float, shade_above: bool, resolutions: NDArray[floating[Any]], max_value: float = 1.0) NDArray[floating[Any]][source]

Compute anti-aliasing shade at pixel boundaries near a ring edge.

Creates smooth sub-pixel transitions at the pixel boundary where the ring edge crosses. The shade value represents the fraction of the pixel that is covered by the ring, linearly interpolated between 0.0 and max_value as the edge moves from one side of the pixel to the other.

When the pixel center is exactly at the edge, shade = 0.5 * max_value. When the edge is half a resolution unit past the pixel center (in the shade direction), shade = max_value (full coverage). When the edge is half a resolution unit in the opposite direction, shade = 0.0.

Parameters:
  • radii – Array of ring radii at pixel centers (km).

  • edge_radius – Target edge radius (km).

  • shade_above – If True, shading is applied on the low-radius side of the edge (the object is above the edge, anti-aliasing goes below). If False, shading is applied on the high-radius side.

  • resolutions – Array of radial resolutions at each pixel (km/pixel).

  • max_value – Maximum shade value (default 1.0). Use values < 1.0 for partial-opacity rendering.

Returns:

Array of shade values in [0, max_value], same shape as radii. Results are clipped to [0, max_value].

Raises:

ValueError – If radii and resolutions differ in shape, contain non-finite values, or any resolution is not strictly positive; or if max_value is not finite and non-negative.

compute_edge_fade(*, model: NDArray[floating[Any]], radii: NDArray[floating[Any]], edge_radius: float, shade_above: bool, fade_width_pix: float, resolutions: NDArray[floating[Any]], all_edge_radii: Sequence[tuple[float, str]], logger: Any) NDArray[floating[Any]][source]

Compute a linear fade from a single ring edge with per-pixel fade width.

This function produces a linear gradient from full brightness at a known ring edge to zero over a configurable distance. The fade is necessary when a ring feature has only one known edge – without it, the model image would show a false sharp boundary where the ring ceases to be defined. The gradient provides a smooth signal that works well for correlation-based navigation.

Per-pixel fade width: The fade width in km varies per pixel: fade_width_km = fade_width_pix * resolutions. This ensures the fade always spans exactly fade_width_pix pixels at every location in the image, regardless of the local radial resolution. At the ansae (fine resolution) the fade covers fewer km; at foreshortened regions (coarse resolution) it covers more km.

Conflict detection and width reduction: When a neighboring feature’s edge falls within the fade zone, the fade width is reduced per pixel to half the distance to the neighbor. The RingFeatureFilter has already excluded edges where this reduction falls below min_allowed_fade_width_pix, so this function always produces a result.

Shade direction: shade_above maps to an internal shade_sign (+1 or -1) used by compute_fade_integral.

The integration uses four cases for pixel coverage:

  • Case 1: Both edge and fade end within the pixel.

  • Case 2: Edge within pixel, fade end extends beyond.

  • Case 3: Edge before pixel, fade end within pixel.

  • Case 4: Full coverage (edge before pixel, fade end after pixel).

Parameters:
  • model – Current model image array. The fade is added to this.

  • radii – Per-pixel ring radius array from the backplane (km).

  • edge_radius – Nominal radius of the ring edge (km).

  • shade_above – If True, shade toward larger radii (away from planet); if False, shade toward smaller radii (toward planet).

  • fade_width_pix – Desired fade extent in pixels (from config); must be strictly positive (zero would yield zero per-pixel width and break integration).

  • resolutions – Per-pixel radial resolution (km/pixel).

  • all_edge_radii – Sorted sequence of (radius, label) pairs for all surviving feature edges. Used to detect conflict and reduce fade width when a neighboring edge falls within the fade zone.

  • loggerPdsLogger from the ring NavModel (same as RingsRenderContext.logger) for optional debug output when fade width is narrowed by neighbor edges.

Returns:

per-pixel fade contribution is clipped to [0, 1], then added to the input model. The result may exceed 1.0 if the model already had large values.

Return type:

Updated model image

Raises:
  • ValueError – If array shapes differ, values are non-finite, any resolution is not strictly positive, fade_width_pix is not finite or is not strictly positive, or edge_radius is not finite.

  • TypeError – If fade_width_pix has an invalid type.

compute_fade_integral(a0: NDArray[floating[Any]], a1: NDArray[floating[Any]], *, edge_radius: float, width: NDArray[floating[Any]], resolutions: NDArray[floating[Any]], shade_sign: float) NDArray[floating[Any]][source]

Compute the definite integral of the linear fade function over a pixel.

The fade function is a linear gradient from 1.0 at the edge to 0.0 at edge_radius + shade_sign * width. The integral gives the average shade value for the portion of the pixel that overlaps the fade zone, which is what a properly anti-aliased renderer should compute.

shade_sign is +1.0 or -1.0 according to fade direction. The closed form depends on shade_sign only through the sign of two terms:

result = ((1 + shade_sign * edge_radius / width) * (a1 - a0)
          + shade_sign * (a0^2 - a1^2) / (2 * width)) / resolutions
Parameters:
  • a0 – Lower integration bounds per pixel (km).

  • a1 – Upper integration bounds per pixel (km).

  • edge_radius – Fixed edge radius (km).

  • width – Per-pixel fade width in km. Varies per pixel because fade_width_km = fade_width_pix * resolutions.

  • resolutions – Per-pixel radial resolution (km/pixel).

  • shade_sign – +1.0 for shade_above, -1.0 for shade_below.

Returns:

Per-pixel integral values, same shape as a0.

Raises:

ValueError – If a0 or a1 contain a non-finite value, if array shapes differ, or if width or resolutions contain a non-finite value or any element that is not strictly positive.

Immutable rendering context for ring feature backplane rendering.

This module defines RingsRenderContext, a frozen dataclass that bundles all dependencies needed to render one ring feature. Passing a single context object instead of many individual parameters achieves two goals:

  1. Clean method signatures: RingFeature.render(context) takes one argument instead of six. Adding a new rendering parameter only requires updating RingsRenderContext, not every call site.

  2. Immutability contract: Because the context is frozen, each call to render() receives the same data. Features cannot accidentally modify shared rendering state.

RingsRenderContext carries all_edge_radii – the sorted sequence of (radius, label) pairs for all features that survived filtering. This is needed at render time by compute_edge_fade to reduce the fade width when a neighboring edge falls within the fade zone (halving the fade at the conflict boundary). The filter has already handled exclusion (edges whose adjusted fade would be too narrow); compute_edge_fade handles reduction (edges whose adjusted fade is still acceptable but narrower than the requested fade_width_pix).

class RingsRenderContext(obs: Any, ring_target: str, epoch: float, resolutions: NDArray[floating[Any]], fade_width_pix: float, all_edge_radii: tuple[tuple[float, str], ...], logger: Any)[source]

Bases: object

Immutable context for backplane-based ring feature rendering.

Constructed by the orchestrator (NavModelRings) once per observation and passed unchanged to every RingFeature.render() call. Contains observation data, computed backplane arrays, fade configuration, and the sorted list of all surviving edge radii for conflict-based fade reduction.

The all_edge_radii tuple is built from features that survived all four filter passes. It is used by compute_edge_fade to reduce fade width when a neighboring feature’s edge is within the fade zone, preserving the current behavior of halving the fade extent at a conflict boundary rather than rendering with full width. This is a width reduction, not exclusion – exclusion is handled by RingFeatureFilter before rendering.

Parameters:
  • obs – The observation object (oops.Observation). Provides access to all backplane computation methods.

  • ring_target – Ring target string used for backplane calls, e.g. 'saturn:ring'.

  • epoch – TDB epoch time in seconds used as the reference time for multi-mode orbital perturbation calculations.

  • resolutions – 2-D array of per-pixel radial resolution in km/pixel. Shape matches the extended FOV. Used to compute per-pixel fade widths: fade_width_km = fade_width_pix * resolutions.

  • fade_width_pix – Fade extent in pixels as configured in the YAML (fade_width_pix key). Must be finite and strictly positive; per-pixel km extent is computed at render time from this value and resolutions.

  • all_edge_radii – Sorted tuple of (radius_km, edge_label) pairs for all edges of all features that survived filtering. Used by compute_edge_fade for conflict detection and width reduction.

  • loggerPdsLogger from the ring NavModel (same instance as NavModelRings._logger).

__post_init__() None[source]

Validate fields at construction (frozen dataclass: no mutation).

all_edge_radii: tuple[tuple[float, str], ...]
epoch: float
fade_width_pix: float
logger: Any
obs: Any
resolutions: NDArray[floating[Any]]
ring_target: str

Result object for ring feature backplane rendering.

This module defines RingRenderResult, the structured output of RingFeature.render(). Returning a typed dataclass instead of a tuple of arrays makes the return value self-documenting and lets the orchestrator access the uncertainty and annotation data without relying on positional unpacking.

The edge_info_list is computed during rendering rather than in a separate annotation pass. This avoids recomputing the edge radius backplanes a second time: the render method already has the computed backplane results in scope when it creates the edge masks for border_atop.

class RingRenderResult(model_img: ~numpy._typing._array_like.NDArray[~numpy.floating[~typing.Any]], model_mask: ~numpy._typing._array_like.NDArray[~numpy.bool], uncertainty: float, edge_info_list: list[tuple[~numpy._typing._array_like.NDArray[~numpy.bool], str, str]] = <factory>)[source]

Bases: object

Result of rendering a single ring feature edge or band.

Returned by RingFeature.render(). Contains the rendered model image and mask, the feature uncertainty (km), and pre-computed annotation edge data.

edge_info_list contains (edge_mask, label_text, edge_label) tuples for annotation creation. render() computes these during rendering to avoid recomputing the edge radius backplanes a second time. The orchestrator passes this list to NavModelRingsBase._create_edge_annotations().

Parameters:
  • model_img – Float64 array of rendered ring brightness values. Shape matches the extended FOV.

  • model_mask – Boolean mask array where True indicates pixels with non-zero ring model contribution.

  • uncertainty – Maximum RMS across all rendered edges (km). Sourced from RingEdgeData.rms via RingFeature.uncertainty.

  • edge_info_list – Pre-computed annotation data: list of (edge_mask, label_text, edge_label) tuples. edge_mask is a boolean array in extended FOV coordinates.

__post_init__() None[source]

Validate image/mask shape, uncertainty, and edge annotation tuples.

edge_info_list: list[tuple[NDArray[bool], str, str]]
model_img: NDArray[floating[Any]]
model_mask: NDArray[bool]
uncertainty: float

spindoctor.nav_model.stars

Catalog-driven star NavModel package.

The package is organised as a small set of helper modules around a thin orchestrator class:

  • catalog — multi-catalog reduction, stellar aberration, proper motion, FOV projection, dedup.

  • conflicts — body and ring occlusion checks for catalog stars.

  • predicted_snr — per-star integrated-SNR estimate plus the SCLASS_TO_B_MINUS_V spectral-class colour lookup.

  • smeared_psf — smear-aware PSF rendering and per-image smear vector.

  • detection — DAOPHOT-style source detection (matched filter, centroid fit, shape cuts) used by downstream techniques.

  • nav_model_starsNavModelStars orchestrator implementing the NavModel ABC.

Real-scene star NavModel.

Orchestrates the catalog-reduction, conflict-marking, predicted-SNR, and smear-aware PSF helpers so the NavModel ABC can emit one STAR NavFeature per detectable catalog star. Heavy lifting lives in sibling modules; this file is the entry point the orchestrator’s registry sees.

class NavModelStars(name: str, obs: Observation, *, config: Config | None = None)[source]

Bases: NavModel

Catalog-driven star NavModel.

Reduces the configured catalogs into a deduplicated star list, flags body/ring conflicts, and emits one STAR NavFeature per star whose predicted SNR clears the configured floor and whose conflict flags allow it.

Parameters:
  • name – Model name (typically 'stars').

  • obs – Observation snapshot.

  • config – Optional Config override.

create_model() None[source]

Build the reduced star list and populate metadata.

Steps:

  1. Compute the per-image smear vector via compute_smear_vector_px.

  2. Reduce all configured catalogs through reduce_catalogs — pulls per-bin chunks, dedupes against precedence, marks visual overlaps.

  3. Mark body and ring conflicts via mark_body_and_ring_conflicts.

  4. Populate self._metadata with summary fields used by the curator.

classmethod instances_for_obs(obs: Observation, *, config: Config | None = None) list[NavModel][source]

Return one star NavModel per real observation.

Simulated obs have no real star-catalog pointing, so no star model is built for them (the sim renders its own stars).

Parameters:
  • obs – Observation snapshot.

  • config – Configuration passed to the constructed instance. None uses DEFAULT_CONFIG.

Returns:

[NavModelStars('stars', obs)] for a real obs, else [].

property stars: list[MutableStar]

Reduced star list populated by create_model.

to_annotations(context: NavContext) Annotations[source]

Emit star-box overlays plus name/magnitude labels.

to_features(context: NavContext) list[NavFeature][source]

Emit one STAR feature per catalog star within the magnitude limit.

Stars with body or ring conflicts are emitted with the matching in_body_silhouette / in_saturation_or_cosmic_mask flags set; the reliability gate decides whether to keep them. Stars fainter than obs.star_max_usable_vmag() (or with no catalog magnitude) are skipped. Detectability is expressed through a magnitude-margin-derived effective SNR rather than a DN-based photometric SNR, so the gate carries no dependence on any DN-to-image-unit scale.

Parameters:

context – Per-image NavContext.

Returns:

List of STAR NavFeature instances.

Simulated-scene star NavModel.

Emits STAR NavFeature instances for a simulated frame exactly the way NavModelStars does for a real frame – same predicted-SNR, covariance, reliability, and annotation machinery – but builds its star list from the scene’s catalog entries in the filtered idealized view (obs.nav_params) rather than reducing real catalogs. The renderer’s output star records never cross the information boundary: the navigator knows the catalog, not what was drawn.

The scene renders each star at its catalog (v, u) shifted by the planted offset; this model predicts the unshifted catalog position, so a star technique that detects the shifted peak recovers the planted offset – the same prediction/observation split a real navigation has, which is why the recovery transfers.

class NavModelStarsSimulated(name: str, obs: Observation, *, config: Config | None = None)[source]

Bases: NavModelStars

Star NavModel populated from the scene’s idealized star catalog.

Inherits feature emission (to_features), annotations, and the extfov-coordinate plumbing from NavModelStars; only the model construction differs – the stars come from the scene’s nav_params star entries rather than from a catalog reduction.

Parameters:
  • name – Model name (typically 'stars').

  • obs – Simulated observation snapshot carrying nav_params.

  • config – Optional Config override.

create_model() None[source]

Build the star list from the scene’s idealized catalog entries.

Each nav_params star entry becomes a catalog record at its unshifted position, through the same builder the renderer uses, so prediction and render share one set of defaults while exchanging no rendered values. Smear is per-entry catalog data (zero by default) and there are no body/ring occlusion conflicts to mark – a simulated star field is clean by construction.

classmethod instances_for_obs(obs: Observation, *, config: Config | None = None) list[NavModel][source]

Return one star model for a simulated obs whose scene has stars.

Returns an empty list for a real obs (the catalog-driven NavModelStars handles those) and for a simulated obs whose scene lists no stars (so the orchestrator builds no empty star model).

Parameters:
  • obs – Observation snapshot.

  • config – Configuration passed to the constructed instance. None uses DEFAULT_CONFIG.

Returns:

[NavModelStarsSimulated('stars', obs)] for a simulated obs with at least one scene star, else [].

Star catalog reduction for the star NavModel.

The star pipeline pulls catalog records, then reduces them through:

  1. Stellar aberration. aberrate_star shifts a catalog RA/DEC into the spacecraft frame so the comparison against the image is fair.

  2. Proper motion. select_radec_list evaluates each star’s proper-motion vector at obs.midtime so the predicted pixel reflects the star’s position at the observation epoch.

  3. Multi-catalog precedence. reduce_catalogs walks the configured catalog order (ucac4tycho2ybsc by default), pulls stars in incremental magnitude bins, and dedupes matching entries so the most precise catalog wins per star.

  4. FOV projection + edge culling. Stars too close to the extfov edge for their PSF support to fit, or whose smear motion would push them off the edge mid-exposure, are dropped.

The reduction is exposed as small free functions so NavModelStars composes them without subclassing.

Three module-level cached catalog instances avoid the expense of re-loading UCAC4 / Tycho-2 / YBSC on every navigation; the getters construct each catalog lazily on first call.

CATALOG_MAGNITUDE_BINS: tuple[float, ...] = (0.0, 8.0, 9.0, 10.0, 10.5, 11.0, 11.5, 12.0, 12.5, 13.0, 14.0, 15.0, 16.0, 17.0)

Magnitude bin edges used to walk catalogs incrementally.

The bins are tighter near the threshold (10.0-13.0) where most catalog stars in a typical Cassini-WAC FOV live, and looser at the bright and faint ends. The walk stops when the configured max_stars budget is reached or the next bin’s lower edge exceeds the obs’s star_max_usable_vmag().

aberrate_star(obs: ObsSnapshot, star: MutableStar) None[source]

Apply stellar aberration to a star in place.

Builds an oops.Event at obs.midtime in the spacecraft frame, points its inverse-arrival vector at the catalog RA/DEC, and reads back the aberration-corrected RA/DEC from neg_arr_ap_j2000. star.ra and star.dec are overwritten.

Parameters:
  • obs – Observation snapshot supplying midtime, path, and frame.

  • star – Star record to mutate.

get_tycho2_catalog() SpiceStarCatalog[source]

Return the Tycho-2 catalog, lazily constructing on first call.

get_ucac4_catalog() UCAC4StarCatalog[source]

Return the UCAC4 catalog, lazily constructing on first call.

get_ybsc_catalog() YBSCStarCatalog[source]

Return the YBSC catalog, lazily constructing on first call.

reduce_catalogs(obs: ObsSnapshot, config: Config, *, radec_movement: tuple[float, float] | None = None) list[MutableStar][source]

Walk every configured catalog, deduplicate, and return the merged list.

Catalogs are walked in the order configured in config.stars.catalogs (default ['ucac4', 'tycho2', 'ybsc']). Within each catalog the search proceeds bin-by-bin against CATALOG_MAGNITUDE_BINS until the configured max_stars budget is hit. Across catalogs, stars whose RA/DEC and magnitude match a star already kept from an earlier (more precise) catalog are dropped; the kept entry inherits any nicer name field the later catalog supplies.

Parameters:
  • obs – Observation snapshot supplying extfov RA/DEC limits.

  • config – Project config carrying the catalog ordering and duplicate thresholds.

  • radec_movement – Optional half-exposure (dra, ddec) shift.

Returns:

Merged star list with image-space fields populated, sorted by descending DN, capped at config.stars.max_stars.

select_radec_list(stars: list[MutableStar], *, use_proper_motion: bool, midtime: float) list[tuple[float, float]][source]

Return the per-star RA/DEC list, with optional proper-motion update.

Parameters:
  • stars – List of star records.

  • use_proper_motion – When True, evaluate star.ra_dec_with_pm(midtime) for each entry; otherwise return the catalog (star.ra, star.dec) pair.

  • midtime – Observation midtime in TDB seconds; ignored when use_proper_motion is False.

Returns:

Parallel [(ra, dec), ...] list in radians.

stars_in_extfov(obs: ObsSnapshot, config: Config, *, catalog_name: str, mag_min: float, mag_max: float, radec_movement: tuple[float, float] | None = None) list[MutableStar][source]

Return all stars from one catalog that lie inside the extfov.

Thin wrapper around _find_stars_in_one_catalog that pulls the extfov RA/DEC limits from obs so callers do not need to compute them.

Parameters:
  • obs – Observation snapshot.

  • config – Project config.

  • catalog_name – One of 'ucac4', 'tycho2', 'ybsc'.

  • mag_min – Catalog magnitude window.

  • mag_max – Catalog magnitude window.

  • radec_movement – Optional half-exposure (dra, ddec) shift.

Returns:

Stars that fit inside the extfov with PSF support included.

Body and ring conflict marking for star records.

A star whose predicted pixel falls inside the silhouette of a body, or inside a known opaque ring annulus, cannot be detected — the body or ring overrides its signal. mark_body_and_ring_conflicts walks every star in the reduced list and, for each one, builds a tiny oops backplane around the predicted position and queries the body intercept plus the ring radius. When either query fires, the star’s conflicts field is set to a human-readable string starting with 'BODY: ' or 'RING: '.

Body-vs-ring precedence: a body intercept always wins, so a star whose predicted pixel lies on a moon in front of Saturn’s rings is tagged with the moon, not the rings.

mark_body_and_ring_conflicts(obs: ObsSnapshot, config: Config, stars: list[MutableStar]) None[source]

Tag each star whose predicted pixel is occluded by a body or ring.

The check has two parts:

  1. Body intercepts. A small meshgrid around the predicted star pixel is fed through Backplane.where_intercepted(body) for every body in the planet+satellites list pulled from config.satellites. Any intercept marks the star with conflicts = 'BODY: <body>' and short-circuits the ring check.

  2. Ring annulus occlusion. When stars.ring_occlusion_enabled is True and the closest-planet has annuli configured, the same meshgrid is queried for ring_radius and each valid pixel’s radius is tested against the annuli. When at least stars.ring_occlusion_min_opaque_fraction of the valid window pixels are opaque, the star is marked with conflicts = 'RING: <planet>'.

Stars already marked with a non-empty conflicts (e.g. 'STAR' from visual overlap) are left alone.

Parameters:
  • obs – Observation snapshot.

  • config – Project Config.

  • stars – Star list to mutate in place.

parse_ring_occlusion_annuli(raw: dict[str, list[list[float]]] | None) dict[str, list[tuple[float, float]]][source]

Validate and normalise a ring-occlusion annulus mapping.

The YAML config exposes per-planet annulus pairs as nested lists ([[inner_km, outer_km], ...]); this helper validates each pair, rejects degenerate (inner >= outer) annuli, and normalises the planet keys to upper case so lookup is case-insensitive.

Parameters:

raw – Mapping returned by config.stars.ring_occlusion_radii_km. None is treated as the empty mapping.

Returns:

{PLANET_UPPER: [(inner_km, outer_km), ...]} with float entries.

Raises:

ValueError – If an annulus is malformed or has inner >= outer.

Raw-DN photometry helpers for stars (predicted-SNR diagnostic).

The star NavModel no longer gates on this DN-based SNR: STAR features are selected purely by magnitude against obs.star_max_usable_vmag() (see spindoctor.nav_model.stars.nav_model_stars), which carries no dependence on any DN-to-image-unit scale. This module is retained for its reusable photometry helpers — psf_sigma_px (imported by detection and nav_model_body), psf_aperture_pixels, integrated_signal_dn, and the SCLASS_TO_B_MINUS_V re-export — and the predicted_snr formula is kept as a raw-DN diagnostic, not used by the navigator’s star gate.

The (diagnostic) predicted_snr estimate of how detectable a star is at its predicted pixel position uses three inputs:

  • obs.star_psf() — the per-camera-per-filter PSF.

  • NavContext.image_noise_sigma — robust MAD-based noise estimate over the sensor area in the image’s native units.

  • star.dn — the integrated DN expected for the star in the instrument’s bandpass, computed from its catalog V magnitude via the 2.512 ** -(vmag - 4) flux-to-DN scaling. star.dn is the total signal across the PSF support; the per-pixel signal is that total spread over a circular Gaussian-PSF aperture.

The integrated SNR follows the form in Part 1’s “Position covariance per feature type” section:

SNR = total_signal / sqrt(total_signal + read_noise**2 * N_aperture)

with total_signal in DN and read_noise**2 * N_aperture standing in for the variance contribution from background and read noise. image_noise_sigma is treated as a Gaussian read-noise proxy because the MAD estimator is dominated by background pixels and combines shot, read, and dark contributions into a single per-pixel sigma.

For raw-DN instruments the catalog signal and image_noise_sigma are already in the same units (DN) and the formula applies directly. For calibrated-IF instruments (Cassini ISS _CALIB.IMG and similar) the image’s noise sigma is in I/F while the catalog signal is still in DN; signal_dn_to_image_unit_scale (the per-camera DN-to-image-unit factor) converts image_noise_sigma back to a DN-equivalent before the SNR is formed. Without this conversion the SNR for every catalog star collapses to sqrt(signal_dn) on calibrated images and the reliability gate drops them all.

SCLASS_TO_B_MINUS_V is re-exported here so callers that need the spectral-class colour mapping can pull it from the same module that owns the predicted-SNR formula.

integrated_signal_dn(star: MutableStar, mag_offset: float) float[source]

Return the predicted in-band DN for a catalog star.

Applies a per-camera-per-filter mag_offset to convert the catalog V-band magnitude into the instrument’s bandpass, then uses the standard 2.512 ** -(vmag - 4) flux-to-DN scaling. Stars without a catalog magnitude (vmag is None) are not detectable and return 0.0.

Parameters:
  • star – Star record carrying vmag.

  • mag_offset – Per-instrument-per-filter magnitude offset (mag_in_band - mag_v). Positive values mean the instrument sees the star fainter than the catalog magnitude.

Returns:

Predicted integrated DN in the instrument’s bandpass.

predicted_snr(star: MutableStar, *, psf: PSF, image_noise_sigma: float, mag_offset: float = 0.0, signal_dn_to_image_unit_scale: float = 1.0) float[source]

Predicted integrated SNR for a star at its predicted pixel.

Treats image_noise_sigma as the per-pixel Gaussian noise from background + read noise + dark current; star.dn is the integrated signal across the PSF support. Implements the formula from the design’s STAR section:

SNR = total_signal / sqrt(total_signal + sigma_dn**2 * N_aperture)

with N_aperture = 4 * pi * sigma_PSF**2. sigma_dn is the DN-equivalent of image_noise_sigma obtained by dividing through signal_dn_to_image_unit_scale; for raw-DN instruments the scale is 1.0 and sigma_dn == image_noise_sigma so the formula reduces to the classic form. For calibrated-IF instruments the scale is typically of order 1e-7 (DN-to-I/F).

Parameters:
  • star – Star record.

  • psf – PSF (typically from obs.star_psf()).

  • image_noise_sigma – Robust per-pixel noise sigma in the image’s native units (DN for raw, I/F for calibrated).

  • mag_offset – Catalog-to-instrument magnitude offset (default 0).

  • signal_dn_to_image_unit_scale – Scale that converts a DN signal into the same units as image_noise_sigma. 1.0 for raw-DN instruments; per-camera value loaded from noise.signal_dn_to_image_unit_scale for calibrated-IF instruments.

Returns:

Predicted SNR (dimensionless, >= 0).

Raises:

ValueError – If image_noise_sigma or signal_dn_to_image_unit_scale is non-positive.

psf_aperture_pixels(sigma_px_value: float) float[source]

Return the effective number of pixels in the PSF support.

Uses the standard “noise-equivalent area” of a 2-D Gaussian, 4 * pi * sigma**2, which is the right scale for converting an integrated DN signal into a per-pixel matched-filter SNR.

Parameters:

sigma_px_value – Per-pixel PSF sigma in pixels.

Returns:

Effective aperture area in pixels (always > 0 for sigma > 0).

psf_sigma_px(psf: PSF) float[source]

Return the Gaussian-equivalent sigma of psf in pixels.

Treats every PSF as a 2-D Gaussian for SNR / CRLB purposes. The pipeline ships psfmodel.GaussianPSF instances populated from star_psf_sigma in config_4N0_inst_*.yaml; that class exposes per-axis sigma_x / sigma_y attributes (typically equal). When neither per-axis sigma is available we fall back to a single sigma attribute (legacy interface) or fwhm() / 2.3548 (a third-party PSF subclass).

Parameters:

psf – PSF instance from obs.star_psf().

Returns:

Gaussian sigma in pixels. When the PSF is anisotropic, the per-axis values are averaged.

Smear-aware PSF rendering helpers.

When the spacecraft attitude rate is non-zero during an exposure, stars smear into trails along the per-pixel motion vector. psfmodel exposes a smear-aware eval_rect(..., movement=, movement_granularity=) API that integrates the PSF along the trail. These helpers build the small postage stamp returned by that call and choose a sensible movement_granularity from the smear amplitude.

A second helper computes the per-image smear vector (my, mx) (in pixels) from the SPICE pointing brackets at the start and end of the exposure, exposed as a free function so the orchestrator can drive it from NavContext without subclassing the obs.

compute_smear_vector_px(obs: ObsSnapshot) tuple[float, float][source]

Compute the per-image smear vector (my, mx) in pixels.

Implements the design’s “smear from SPICE bracket” approach: project the camera attitude at obs.time[0] and obs.time[1] into pixel coordinates by re-evaluating the boresight RA/DEC through the obs FOV at both bracket times, and take the difference. The result is the total per-exposure displacement of a star at the centre of the FOV.

Parameters:

obs – Observation snapshot.

Returns:

Tuple (my, mx) in pixels (vertical, horizontal).

movement_granularity_px(move_v: float, move_u: float, *, max_steps: int = 50) float[source]

Choose the movement_granularity step for psf.eval_rect.

Targets at most max_steps integration samples along the smear path, clamped to [0.1, 1.0] pixels per sample. The lower bound keeps the integration tractable for very short smears; the upper bound keeps long smears from sub-sampling the PSF.

Parameters:
  • move_v – Per-exposure smear amplitude along V.

  • move_u – Per-exposure smear amplitude along U.

  • max_steps – Maximum samples along the smear (default 50).

Returns:

Step size in pixels per integration sample.

render_smeared_psf(psf: PSF, *, star: MutableStar, max_movement_steps: int) NDArrayFloatType[source]

Render one star’s smeared PSF stamp.

The output stamp shape is (2 * psf_half_v + 1) x (2 * psf_half_u + 1) where each half-size accounts for the PSF support plus the rounded smear amplitude. The amplitude is folded into the half-sizes so the integrated stamp captures the entire trail without clipping.

Parameters:
  • psf – PSF instance from obs.star_psf().

  • star – Star record carrying psf_size, move_v, move_u, u, v, and dn.

  • max_movement_steps – Cap on the number of integration steps along the smear path; passed through to movement_granularity_px.

Returns:

np.ndarray postage stamp containing the rendered PSF, in DN.

smear_length_px(move_v: float, move_u: float) float[source]

Return the smear length sqrt(my**2 + mx**2) in pixels.

Parameters:
  • move_v – Per-exposure smear amplitude along the V (row) axis.

  • move_u – Per-exposure smear amplitude along the U (column) axis.

Returns:

Euclidean smear length in pixels (always >= 0).

DAOPHOT-style star detection helpers.

The orchestrator never calls this module — predicted-from-catalog star features are emitted by NavModelStars.to_features directly. The detector exists so a downstream technique (StarRefineNav etc.) can sweep the image for centroidable stars when the catalog match is ambiguous; keeping it in the same package as the catalog reduction guarantees the matched filter, the smear-aware kernel, and the shape-based cuts stay in sync.

The pipeline is the canonical DAOPHOT sequence in three stages:

  1. Matched filter. Cross-correlate the image with the smeared PSF kernel. The peak amplitude at each pixel is the maximum-likelihood estimate of an unsmoothed star signal at that location.

  2. Local maxima. Find every pixel that is the local maximum in a window matched to the PSF support and is above the per-image detection threshold (k * image_noise_sigma after matched filtering).

  3. Centroid + cuts. Fit a Gaussian to a small box around each maximum. Stars hitting the saturation DN switch to an annular moment because the Gaussian fit blows up. Hot pixels and asymmetric stars are rejected via classic DAOPHOT sharpness / roundness criteria.

CCD bloom columns are detected up-front so saturated bright stars are not double-counted as multiple detections along the bloom trail.

DAOPHOT_DEFAULT_DETECTION_SIGMA: float = 4.0

Threshold (in image_noise_sigma) for matched-filter peaks.

Matches the DAOPHOT convention of “minimum sigma above sky for a real source” before shape-based cuts. Below 4 sigma the cosmic-ray-driven false-positive rate dominates real detections.

DAOPHOT_DEFAULT_ROUNDNESS_BOUND: float = 1.0

Maximum |roundness| for a real star.

Computed as the per-axis Gaussian-marginal asymmetry; > 1 in absolute value points at a CCD bloom or one-axis trail rather than a smear- oriented PSF.

DAOPHOT_DEFAULT_SHARPNESS_MAX: float = 1.0

Maximum DAOPHOT sharpness for a real star.

Sharpness > 1.0 indicates an extended source (galaxy / blended pair) whose central pixel does not dominate.

DAOPHOT_DEFAULT_SHARPNESS_MIN: float = 0.2

Minimum DAOPHOT sharpness for a real star.

Sharpness < 0.2 is dominated by single-pixel hot spikes; the wing contribution is too small to be a star.

class DetectedSource(v: float, u: float, peak_dn: float, sharpness: float, roundness: float, saturated: bool)[source]

Bases: object

One source returned by detect_sources.

Parameters:
  • v – Sub-pixel V (row) centroid.

  • u – Sub-pixel U (column) centroid.

  • peak_dn – Matched-filter peak amplitude at the centroid.

  • sharpness – DAOPHOT sharpness statistic.

  • roundness – DAOPHOT roundness statistic (signed).

  • saturated – True when the central pixel hit the saturation DN.

peak_dn: float
roundness: float
saturated: bool
sharpness: float
u: float
v: float
apply_shape_cuts(sharpness: float, roundness: float, *, sharp_min: float = 0.2, sharp_max: float = 1.0, round_bound: float = 1.0) bool[source]

Return True if a detection passes the DAOPHOT shape cuts.

Parameters:
  • sharpness – DAOPHOT sharpness from _sharpness_roundness.

  • roundness – DAOPHOT roundness from _sharpness_roundness.

  • sharp_min – Acceptance window for sharpness.

  • sharp_max – Acceptance window for sharpness.

  • round_bound – Maximum absolute roundness.

Returns:

True when the detection is keeper-quality.

centroid_gaussian_fit(box: NDArrayFloatType) tuple[float, float][source]

Fit a 2-D Gaussian centroid to a small detection box.

Uses the standard DAOPHOT moment-form: the centroid is sum(coord * (box - bg)) / sum(box - bg) over pixels above the box-median background. The box is small enough (3-5 px on a side) that this matches the iterative least-squares centroid to sub-pixel agreement.

Parameters:

box – Square detection box, (2N+1, 2N+1).

Returns:

(dv, du) offset in pixels from the centre of box.

centroid_saturated(box: NDArrayFloatType, *, full_well_dn: float, half_width_inner: int, half_width_outer: int) tuple[float, float][source]

Return a saturated-star centroid via an annular brightness moment.

Saturated cores have wrong DN values; the only reliable centroid information is in the annulus around the saturated core where the PSF wings are still linear. This routine computes the brightness-weighted moment of pixels whose DN is below full_well_dn and whose distance from the box centre lies in [half_width_inner, half_width_outer].

Parameters:
  • box – Square detection box, (2N+1, 2N+1).

  • full_well_dn – Saturation DN; pixels at this value are excluded from the moment.

  • half_width_inner – Inner radius of the annulus (in pixels).

  • half_width_outer – Outer radius of the annulus (in pixels).

Returns:

(dv, du) offset in pixels from the centre of box.

detect_ccd_bloom_columns(image: NDArrayFloatType, *, full_well_dn: float, min_run: int = 5) NDArrayBoolType[source]

Mark CCD bloom columns where saturation runs vertically.

A bloom column is detected when at least min_run consecutive saturated pixels appear in any single column. The whole column is marked, not just the saturated stretch, so single-pixel detections on the bloom trail are correctly suppressed.

Parameters:
  • image – 2-D float input array.

  • full_well_dn – Per-instrument full-well DN.

  • min_run – Minimum consecutive saturated pixels to declare a bloom column.

Returns:

Boolean mask of the same shape as image with True over the identified bloom columns.

Raises:

ValueError – If min_run is < 2.

detect_sources(image: NDArrayFloatType, *, psf: PSF, image_noise_sigma: float, full_well_dn: float, smear_kernel: NDArrayFloatType, bloom_mask: NDArrayBoolType | None = None, detection_sigma: float = 4.0) list[DetectedSource][source]

Detect candidate stars in image and return one entry per surviving source.

Three-stage pipeline:

  1. Matched-filter the image against smear_kernel.

  2. Find local maxima above detection_sigma * image_noise_sigma.

  3. For each maximum, fit a Gaussian centroid (or annular moment when saturated), compute DAOPHOT sharpness/roundness, and pass the result through apply_shape_cuts.

Parameters:
  • image – 2-D float input array.

  • psf – PSF used for the matched-filter shape window. Per-pixel sigma is obtained via spindoctor.nav_model.stars.predicted_snr.psf_sigma_px(), which reads sigma_x / sigma_y from psfmodel.GaussianPSF (averaged for anisotropic PSFs) and falls back to a single sigma attribute or fwhm() / 2.3548 for third-party PSF subclasses. The returned sigma is in pixels. The smear is already baked into smear_kernel; this PSF parameter only sets the centroid-fit box half-width.

  • image_noise_sigma – Robust per-pixel noise sigma in DN.

  • full_well_dn – Saturation DN.

  • smear_kernel – Pre-rendered smeared PSF kernel matched to the current obs’s smear vector.

  • bloom_mask – Optional CCD bloom column mask; when provided, detections falling on bloom columns are suppressed.

  • detection_sigma – Threshold multiplier on image_noise_sigma.

Returns:

List of DetectedSource records, one per surviving detection.

matched_filter_image(image: NDArrayFloatType, *, kernel: NDArrayFloatType) NDArrayFloatType[source]

Return the matched-filter response of image against kernel.

Implements the standard DAOPHOT matched filter: subtract the kernel mean, normalise to unit-energy, and convolve. The peak amplitude at each pixel is then the linear-least-squares estimate of the signal scale at that location.

Parameters:
  • image – 2-D float input array.

  • kernel – PSF stamp produced by psf.eval_rect (smear-aware when smear is non-trivial).

Returns:

Matched-filter response array of the same shape as image.

Raises:

ValueError – If kernel has zero norm (e.g. an all-zero stamp).