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
NavModelABC. Concrete subclasses implementcreate_model,to_features, andto_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.starsCatalog-driven star
NavModeland 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_modelpopulates the model’s internal state and metadata.to_features(context)returnsNavFeatureinstances ready for technique consumption.to_annotations(context)returns anAnnotationscollection 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]
-
Base class for predicted-scene generators.
- Class attributes:
- _registry: List of every concrete subclass discovered at import
time. Iterated by
build_models_for_obsto 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’smodel_metadatadict.obs – Observation snapshot the model is built against.
config – Optional
Configoverride (defaults toDEFAULT_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] = Truein the class body.
- abstractmethod create_model() → None[source]
Populate the model’s internal state and
metadata.Called once per image before
to_featuresorto_annotations. Implementations evaluate the SPICE prediction, render any required templates, and populateself._metadatawith 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 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:
Annotationscollection (possibly empty).
- abstractmethod to_features(context: NavContext) → list[NavFeature][source]
Return navigation-ready features for this model.
Called after
create_model. Implementations build per-elementNavFeatureinstances (stars, body limbs, ring edges) and assign each its preferred filter, position covariance, and reliability.- Parameters:
context – Per-image
NavContextcarrying global statistics.- Returns:
List of
NavFeatureinstances; 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._registryand 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 whichBODY_BLOBis preferred over an unresolved limb.shape_class_hint—regular/irregular/highly_irregular/unknown; used in human-readable logs and by reviewers reading sidecar diagnostics.
Documentation-only fields in the YAML (radii_km, albedo_mean,
_sources) are intentionally not surfaced on the runtime dataclass:
radii_km would compete with oops body radii and albedo_mean
has no consumer yet. Adding them at runtime is a one-line dataclass
extension whenever a consumer materializes.
- 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.yamlor when individual numeric fields in the YAML entry arenull(the YAML value wins when it is set). Bodies absent from this table fall through toDEFAULT_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:
objectPer-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.
- 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
BodyShapeforbody_name(case-insensitive).Merges three sources, in priority order:
config.body_shape[<BODY>]— operator-curated YAML entry fromconfig_220_body_shape.yaml. Each non-null field overrides the hard-coded baseline.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).DEFAULT_BODY_SHAPE— final fallback for bodies unknown to both sources.
- Parameters:
body_name – Body name in any case (
'mimas'/'MIMAS').config – Optional
Configoverride; defaults toDEFAULT_CONFIG. Tests may pass a stub that exposes abody_shapeattribute (mapping orAttrDict).
- Returns:
A
BodyShapepopulated 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:
Builds an oversampled meshgrid around the predicted body bounding box so the limb silhouette is anti-aliased.
Extracts the limb and terminator polylines from the discrete silhouette masks.
Looks up the per-body shape parameters via
spindoctor.nav_model.body_shape.load_body_shape(), which merges the operator-curatedconfig_220_body_shape.yamlover the hard-codedBODY_SHAPE_TABLEprofiles.Decides which features to emit by computing
limb_uncertainty_pxand thevisible_lit_fraction/overflow_fractionquantities the design specifies.
The feature-by-feature emission rules:
LIMB_ARCis emitted whenlimb_uncertainty_px <= LIMB_ARC_MAX_UNCERTAINTY_PXand there are surviving limb vertices.BODY_BLOBis emitted when the predicted disc diameter is at leastmax(BODY_BLOB_MIN_DIAMETER_PX, shape.min_blob_diameter_px)and the limb uncertainty is too high forLIMB_ARCand 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_DISCis emitted alongsideLIMB_ARCwhen the body fits inside the FOV with at leastBODY_DISC_MIN_VISIBLE_LIT_FRACTIONof its lit side visible andoverflow_fractionbelowBODY_DISC_MAX_OVERFLOW_FRACTION.TERMINATOR_ARCis emitted when the terminator polyline has at leastTERMINATOR_MIN_VERTICESsurviving vertices and the phase-angle factor (sin(phase_angle)) is aboveTERMINATOR_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.inventorybounding 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_BLOBso 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:
NavModelBodyBaseCatalog-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.inventoryon demand otherwise.config – Optional
Configoverride.
- 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, andNavModelTitanhandles 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
NavModelBodyper 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.
- 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
NavModelBody–NavModelTitanrecords 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.inventoryonce with the planet plus its configured satellites and keeps every body whoseinventory_body_in_extfovpredicate 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;
NoneusesDEFAULT_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, becauseincidence_factorsaturates 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_factor –
sin(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_bodyexemplars (a 6 px Enceladus whose sidecar requiresBodyBlobNavto 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:
NavModelBase 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_featureand its_phase_irregularity_factor/_lit_weighted_centroid_vuhelpers) lives here so the SPICE-backedNavModelBodyand the simulatedNavModelBodySimulatedshare 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:
NavModelBodyBaseBody 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 toFalse: 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:
namecenter_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
Configoverride.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 nearerrange_kmoccludes 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.
- 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-backedNavModelBodyhandles those instead.A scene body may carry a
nav_overridemapping: 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
NavModelBodySimulatedper 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_DISCcarrying the rendered template (for the correlation technique). Also emits aBODY_BLOBwhenever 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 aLIMB_ARC; a body at appreciable phase adds aTERMINATOR_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_EDGENavFeaturecarrying a per-vertexRingEdgePolylinewith its own sigma_radial / sigma_along_edge derived from the catalogrms, orone
RING_ANNULUSNavFeaturecarrying 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:
NavModelRingsBaseCatalog-driven ring NavModel for one planet.
- Parameters:
name – Model instance name (e.g.
'rings:SATURN').obs – Observation snapshot.
config – Optional
Configoverride.
- 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:
NavModelBase class for ring navigation models.
Provides the
_create_edge_annotationshelper for creating annotations consistent between the real and simulated ring model implementations. Anti-aliasing math is inspindoctor.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:
NavModelRingsBaseRing 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, catalogorbit,tau,declared_orbit_sigma).ring_system – The idealized
ring_systemblock (sharedgeometry,range_km,km_per_pixel,phase_deg).config – Optional
Configoverride.
- 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-backedNavModelRingshandles those instead.- Parameters:
obs – Observation snapshot.
config – Configuration passed to the constructed instances. None uses
DEFAULT_CONFIG.
- Returns:
One
NavModelRingsSimulatedper 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_ANNULUScarrying 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 tobbox_extfov_vu, so the ext-FOV-sized prediction is cropped to the mask’s tight bbox before emission. Also emits oneRING_EDGEper predicted catalog boundary – a per-vertex polyline with outward radial normals thatRingEdgeNavfits 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:
objectOne 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).
- class PredictedRingFeature(template: NDArray[floating[Any]] | None, mask: NDArray[bool], edges: list[PredictedRingEdge])[source]
Bases:
objectThe 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]
- 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:
NavModelTitan 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
Configoverride.
- 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.
NoneusesDEFAULT_CONFIG.
- Returns:
A single
NavModelTitanwhen 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
Annotationscollection, 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 toRingFeature.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:
RingBaseOrbitModeandRingPerturbationModeare distinct types to resolve the ambiguity in the YAML config wheremode: 1can appear with either base-orbit fields (a,ae, …) or perturbation fields (amplitude,phase, …). Having separate types makes the dispatch explicit inRingFeature.from_config().Inclination modes (
mode_num > 90) are stored inRingEdgeData.perturbationsbecause they are valid YAML data. However,RingEdgeData.radial_perturbations()andparsed_modes_for_backplane()exclude them, because theoopsbackplaneradial_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:
objectMode-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 (
boolis rejected because it is a subclass ofint).ValueError – If
a<= 0,ae< 0, orrms< 0.
- class RingEdgeData(base_orbit: RingBaseOrbitMode, perturbations: tuple[RingPerturbationMode, ...])[source]
Bases:
objectAll 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 immutabletuple.
- Raises:
ValueError – If
base_orbitorperturbationsisNone.TypeError – If
base_orbitis not aRingBaseOrbitMode, ifperturbationsis not a non-string sequence, or if any sequence element is not aRingPerturbationMode.
- 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_modeonly 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.
- class RingFeatureType(*values)[source]
Bases:
EnumClassification 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:
objectA 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 > 90are 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 becauseoops.ext_bp.radial_modeonly handles in-plane distortions. Useis_inclination_modeorRingEdgeData.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.0or 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_numis not an integer (boolis rejected), ifamplitudeis not anint/float(boolrejected), is not finite, or is negative, or ifphaseorpattern_speedis not a finiteint/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 viarender(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()-> oneRingRenderResultGAP with either or both edges, or single-edge RINGLET ->
_render_single_edge()per present edge -> oneRingRenderResultper 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:
objectA 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:
NavModelRingsSimulatedpredicts scene ring_system features throughspindoctor.nav_model.sim_ringinstead.)- __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_datedoes not strictly precedeend_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.0when the named edge is absent (a single-edge feature asked for the missing side).- Raises:
ValueError – If
edge_typenames neither a side nor one of this feature’s edge labels.
- 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
RingFeatureinstance.- Raises:
TypeError – If
keyis not astrordatais not adict.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 byrender().- 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_dateis set, the feature is visible at and after that date. If onlyend_dateis 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.
- 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_edgeandouter_edgeareNone, somax_extent_radiuscannot be computed.
- 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 resultGAP 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),
RingFeatureFiltertrims the out-of-range edge toNonebefore this method is called, sorender()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
RingRenderResultobjects. Typically one result for full ringlets and one per edge for gaps and single-edge features.
- 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
RingFeatureFilteraugments 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_typeis 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:
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.
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:
Date (cheapest: pure arithmetic) eliminates features not valid for this image.
Radius eliminates features outside the current field of view.
Resolvability eliminates two-edge features too narrow to see as a width.
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:
objectFour-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:
Date: exclude features not valid at
obs_time_et.Radius: exclude features with no edge inside
[min_radius, max_radius].Resolvability: exclude two-edge features narrower than
min_feature_pixels * min_reskm.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:
Testability: Pure functions are exercised with numpy arrays without backplane-heavy integration tests.
Reuse: Every backplane-based ring rendering path relies on the same anti-aliasing and fade logic defined here.
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_valueas 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
radiiandresolutionsdiffer in shape, contain non-finite values, or any resolution is not strictly positive; or ifmax_valueis 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 exactlyfade_width_pixpixels 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
RingFeatureFilterhas already excluded edges where this reduction falls belowmin_allowed_fade_width_pix, so this function always produces a result.Shade direction:
shade_abovemaps to an internalshade_sign(+1 or -1) used bycompute_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.
logger –
PdsLoggerfrom the ringNavModel(same asRingsRenderContext.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 inputmodel. The result may exceed1.0if 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_pixis not finite or is not strictly positive, oredge_radiusis not finite.TypeError – If
fade_width_pixhas 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_signis +1.0 or -1.0 according to fade direction. The closed form depends onshade_signonly 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
a0ora1contain a non-finite value, if array shapes differ, or ifwidthorresolutionscontain 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:
Clean method signatures:
RingFeature.render(context)takes one argument instead of six. Adding a new rendering parameter only requires updatingRingsRenderContext, not every call site.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:
objectImmutable context for backplane-based ring feature rendering.
Constructed by the orchestrator (
NavModelRings) once per observation and passed unchanged to everyRingFeature.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_radiituple is built from features that survived all four filter passes. It is used bycompute_edge_fadeto 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 byRingFeatureFilterbefore 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_pixkey). Must be finite and strictly positive; per-pixel km extent is computed at render time from this value andresolutions.all_edge_radii – Sorted tuple of
(radius_km, edge_label)pairs for all edges of all features that survived filtering. Used bycompute_edge_fadefor conflict detection and width reduction.logger –
PdsLoggerfrom the ringNavModel(same instance asNavModelRings._logger).
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:
objectResult 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_listcontains(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 toNavModelRingsBase._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.rmsviaRingFeature.uncertainty.edge_info_list – Pre-computed annotation data: list of
(edge_mask, label_text, edge_label)tuples.edge_maskis a boolean array in extended FOV coordinates.
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 theSCLASS_TO_B_MINUS_Vspectral-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_stars—NavModelStarsorchestrator implementing theNavModelABC.
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:
NavModelCatalog-driven star NavModel.
Reduces the configured catalogs into a deduplicated star list, flags body/ring conflicts, and emits one STAR
NavFeatureper 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
Configoverride.
- create_model() → None[source]
Build the reduced star list and populate metadata.
Steps:
Compute the per-image smear vector via
compute_smear_vector_px.Reduce all configured catalogs through
reduce_catalogs— pulls per-bin chunks, dedupes against precedence, marks visual overlaps.Mark body and ring conflicts via
mark_body_and_ring_conflicts.Populate
self._metadatawith 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_maskflags set; the reliability gate decides whether to keep them. Stars fainter thanobs.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
NavFeatureinstances.
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:
NavModelStarsStar NavModel populated from the scene’s idealized star catalog.
Inherits feature emission (
to_features), annotations, and the extfov-coordinate plumbing fromNavModelStars; only the model construction differs – the stars come from the scene’snav_paramsstar entries rather than from a catalog reduction.- Parameters:
name – Model name (typically
'stars').obs – Simulated observation snapshot carrying
nav_params.config – Optional
Configoverride.
- create_model() → None[source]
Build the star list from the scene’s idealized catalog entries.
Each
nav_paramsstar 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
NavModelStarshandles 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:
Stellar aberration.
aberrate_starshifts a catalog RA/DEC into the spacecraft frame so the comparison against the image is fair.Proper motion.
select_radec_listevaluates each star’s proper-motion vector atobs.midtimeso the predicted pixel reflects the star’s position at the observation epoch.Multi-catalog precedence.
reduce_catalogswalks the configured catalog order (ucac4→tycho2→ybscby default), pulls stars in incremental magnitude bins, and dedupes matching entries so the most precise catalog wins per star.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_starsbudget is reached or the next bin’s lower edge exceeds the obs’sstar_max_usable_vmag().
- aberrate_star(obs: ObsSnapshot, star: MutableStar) → None[source]
Apply stellar aberration to a star in place.
Builds an
oops.Eventatobs.midtimein the spacecraft frame, points its inverse-arrival vector at the catalog RA/DEC, and reads back the aberration-corrected RA/DEC fromneg_arr_ap_j2000.star.raandstar.decare overwritten.- Parameters:
obs – Observation snapshot supplying
midtime,path, andframe.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 againstCATALOG_MAGNITUDE_BINSuntil the configuredmax_starsbudget 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 nicernamefield 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_motionis 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_catalogthat pulls the extfov RA/DEC limits fromobsso 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:
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 fromconfig.satellites. Any intercept marks the star withconflicts = 'BODY: <body>'and short-circuits the ring check.Ring annulus occlusion. When
stars.ring_occlusion_enabledis True and the closest-planet has annuli configured, the same meshgrid is queried forring_radiusand each valid pixel’s radius is tested against the annuli. When at leaststars.ring_occlusion_min_opaque_fractionof the valid window pixels are opaque, the star is marked withconflicts = '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.Noneis 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 the2.512 ** -(vmag - 4)flux-to-DN scaling.star.dnis 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_offsetto convert the catalog V-band magnitude into the instrument’s bandpass, then uses the standard2.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_sigmaas the per-pixel Gaussian noise from background + read noise + dark current;star.dnis 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_dnis the DN-equivalent ofimage_noise_sigmaobtained by dividing throughsignal_dn_to_image_unit_scale; for raw-DN instruments the scale is 1.0 andsigma_dn == image_noise_sigmaso the formula reduces to the classic form. For calibrated-IF instruments the scale is typically of order1e-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.0for raw-DN instruments; per-camera value loaded fromnoise.signal_dn_to_image_unit_scalefor calibrated-IF instruments.
- Returns:
Predicted SNR (dimensionless, >= 0).
- Raises:
ValueError – If
image_noise_sigmaorsignal_dn_to_image_unit_scaleis 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
psfin pixels.Treats every PSF as a 2-D Gaussian for SNR / CRLB purposes. The pipeline ships
psfmodel.GaussianPSFinstances populated fromstar_psf_sigmainconfig_4N0_inst_*.yaml; that class exposes per-axissigma_x/sigma_yattributes (typically equal). When neither per-axis sigma is available we fall back to a singlesigmaattribute (legacy interface) orfwhm() / 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]andobs.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_granularitystep forpsf.eval_rect.Targets at most
max_stepsintegration 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, anddn.max_movement_steps – Cap on the number of integration steps along the smear path; passed through to
movement_granularity_px.
- Returns:
np.ndarraypostage 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:
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.
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_sigmaafter matched filtering).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:
objectOne 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.
- 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 ofbox.
- 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_dnand 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 ofbox.
- 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_runconsecutive 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
imagewith True over the identified bloom columns.- Raises:
ValueError – If
min_runis < 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
imageand return one entry per surviving source.Three-stage pipeline:
Matched-filter the image against
smear_kernel.Find local maxima above
detection_sigma * image_noise_sigma.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 readssigma_x/sigma_yfrompsfmodel.GaussianPSF(averaged for anisotropic PSFs) and falls back to a singlesigmaattribute orfwhm() / 2.3548for third-party PSF subclasses. The returned sigma is in pixels. The smear is already baked intosmear_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
DetectedSourcerecords, one per surviving detection.
- matched_filter_image(image: NDArrayFloatType, *, kernel: NDArrayFloatType) → NDArrayFloatType[source]
Return the matched-filter response of
imageagainstkernel.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
kernelhas zero norm (e.g. an all-zero stamp).