spindoctor.sim

Simulation package: common utilities for simulated bodies and rendering.

Entry point for rendering a simulated scene: params in, image + meta out.

render_combined_model is a thin, cached driver over the forward-model stage pipeline (spindoctor.sim.forward): it normalizes the scene parameters into a deterministic JSON cache key, runs the pipeline on a fresh SimFrame, and returns the rendered image with the renderer’s output metadata.

Callers are ObsSim, the scene-editor GUI, and sim/png_export.py (which the doc-gallery and sweep runners drive). The params-JSON caching contract: scene parameters are JSON-serializable scalars/lists/maps, no wall-clock or global RNG state is consulted, and two renders of the same scene are bit-identical on one machine.

clear_render_caches() None[source]

Drop every render-path lru_cache so RNG and shape paths re-run.

Test and tooling helper: the render caches are parameter-keyed and never stale in production, but determinism tests must re-execute the cached paths to prove reproducibility.

render_combined_model(sim_params: dict[str, Any], *, ignore_offset: bool = False) tuple[NDArray[floating[Any]], dict[str, Any]][source]

Render stars then bodies from a full sim_params dict. Returns (img, meta).

ignore_offset = True should be used when rendering the image in the GUI, but not when creating the simulated image to navigate.

Parameters:
  • sim_params – The parameters describing the simulated model.

  • ignore_offset – Whether to ignore the offset.

Returns:

stars (rendered star records), bodies, rings, inventory, star_info, body_masks, ring_masks, order_near_to_far, body_index_map, body_mask_map, and body_occlusion (per-body mutual-event truth: visible_fraction and occluded_limb_arc_deg against every nearer body).

Return type:

A tuple containing the image and metadata. Metadata keys

resolve_oversample(sim_params: dict[str, Any]) int[source]

Return the radiance oversampling factor for a scene.

An explicit oversample key wins. Otherwise a scene with an active PSF – an optics.psf block or the instrument_defaults opt-in, either of which puts a kernel on the frame – oversamples 4x by default so the convolution resolves sub-pixel edge structure; a scene with no PSF renders on the detector grid at oversample 1.

Parameters:

sim_params – The full scene mapping.

Returns:

The oversampling factor (a positive integer >= 1).

The simulator’s image side: the forward model that renders the scene.

This package owns everything that turns a validated sim_params scene into the image the navigator is handed: scene-radiance composition, optics, the detector model, and telemetry effects, run as an ordered pipeline of stages over a SimFrame.

The navigator-side simulated NavModels never import from this package; they consume only the filtered idealized view (obs.nav_params) built at the ObsSim boundary. Geometry conventions shared with the navigator side live in the neutral spindoctor.sim.*_geometry modules.

Entry point: spindoctor.sim.render.render_combined_model(), a thin driver over spindoctor.sim.forward.pipeline.run_pipeline().

The stage interface of the forward-model rendering pipeline.

A render is a fixed-order sequence of stages, each a callable matching the Stage protocol, mutating a SimFrame in place. The order (scene radiance, optics, downsample, detector, telemetry) is physical: light is composed, passes through the camera optics, lands on the detector grid, is read out, and is then transmitted.

Each stage receives its own numpy.random.Generator seeded from the scene’s single random_seed via derive_effect_seed(random_seed, '<stage-name>'), so one stage’s noise realization is independent of which other stages are enabled. A stage name is therefore part of its scenes’ noise realization: renaming a stage reseeds it and regenerates the affected baselines.

A scene with an active whole-scene PSF renders its radiance on an oversampled grid (oversample > 1) so the convolution resolves sub-detector-pixel edge structure; the box downsample after optics returns the image and the pixel-space truth metadata to the detector grid. A scene with no optics block renders at oversample 1, where the downsample is a no-op.

The signal plane carries normalized [0, ~1] intensive scene units through the optics stage; the detector stage converts it to electrons through the exposure and digitizes it to DN in place (the electron unit chain). The point_e plane carries the detector-native point sources (stars): electrons for a CCD, added into the electron image after the signal conversion and before Poisson so they never pass through the intensive scale; DN for the Voyager vidicon (which has no electron domain), added onto the converted signal before the DN-domain noise. Both planes share every optical transform (PSF, smear, distortion, ghosts), so a star’s shape tracks the limb and ring-edge profiles.

class SimFrame(signal: ~numpy._typing._array_like.NDArray[~numpy.floating[~typing.Any]], point_e: ~numpy._typing._array_like.NDArray[~numpy.floating[~typing.Any]], oversample: int = 1, truth: dict[str, ~typing.Any] = <factory>)[source]

Bases: object

The mutable image state threaded through the rendering stages.

Parameters:
  • signal(V*os, U*os) float64 image of intensive scene signal (I/F-like normalized units): bodies, rings, and diffuse backgrounds. The detector stage converts it to DN in place.

  • point_e(V*os, U*os) float64 image of detector-native point sources (stars), kept separate because one array cannot carry two unit systems through the detector stage’s signal-to-electron conversion. Electrons for a CCD, DN for the vidicon (see the module docstring); zeroed on a scene with no stars.

  • oversample – Oversampling factor os >= 1; the detector grid is (V, U).

  • truth – Feature truth accumulated by the radiance stage (the rendered star records, body masks, inventory, and z-order maps). This is renderer output metadata; none of it crosses the information boundary to the navigator side. Pixel-space entries are carried on the oversampled grid and returned to the detector grid by the downsample stage.

oversample: int = 1
point_e: NDArray[floating[Any]]
signal: NDArray[floating[Any]]
truth: dict[str, Any]
class Stage(*args, **kwargs)[source]

Bases: Protocol

One rendering stage: a pure in-place transform of a SimFrame.

Parameters:
  • frame – The frame to mutate.

  • params – The full validated scene sim_params mapping. A stage whose scene block is absent is disabled and contributes nothing (per-stage parameter blocks land with their phases).

  • rng – The stage’s own seeded random generator.

downsample_to_detector(frame: SimFrame, *, params: Mapping[str, Any], rng: Generator) None[source]

Box-downsample the oversampled planes to the detector grid.

The box filter is a mean over the os**2 subsamples, so the intensive signal passes through unchanged in level. The pixel-space truth metadata (body/ring masks, the body index map, inventory bounding boxes, and star hit-test records) is returned to the detector grid alongside the image: classifying arrays are sampled at each detector pixel’s central subsample, and pixel-unit scalars are divided by os. At oversample == 1 this stage is a no-op.

Parameters:
  • frame – The frame to downsample in place.

  • params – The scene mapping (unused; downsampling has no scene knobs).

  • rng – The stage generator (unused; downsampling is deterministic).

new_sim_frame(size_v: int, size_u: int, *, oversample: int = 1) SimFrame[source]

Allocate a zeroed SimFrame for a (size_v, size_u) detector.

Parameters:
  • size_v – Detector-grid height in pixels.

  • size_u – Detector-grid width in pixels.

  • oversample – Oversampling factor of the radiance grid.

Returns:

A frame with zeroed signal and point_e planes.

The fixed-order stage pipeline of the forward model.

Stage order is physical and does not vary per scene: scene radiance is composed, passes through the optics, is downsampled to the detector grid, is read out through the detector model, and is then subjected to telemetry loss. A stage whose scene block is absent contributes nothing, so a single-variable sweep can attribute error to exactly one effect.

Each stage draws from its own numpy.random.Generator seeded by derive_effect_seed(random_seed, '<stage-name>'): stage noise realizations are independent of which other stages are enabled, and adding a stage later leaves existing stages’ realizations unchanged. Renaming a stage reseeds its scenes and regenerates their baselines.

run_pipeline(frame: SimFrame, params: Mapping[str, Any]) None[source]

Run every stage over frame in the fixed order, mutating it in place.

Parameters:
  • frame – The frame to render into.

  • params – The full scene sim_params mapping (already stripped of the planted offset when rendering a GUI preview).

Scene-radiance stage: compose the noise-free signal image.

Composes the ring/body stack (far to near, nearer objects overwriting) into the frame’s normalized signal plane and the star field (catalog stars plus the background sky) into the frame’s point-source plane, applying the scene’s planted pointing offset and camera roll. Feature truth (rendered star records, body masks, inventory, z-order maps) is accumulated into frame.truth for the renderer’s output metadata.

Stars are point sources: each deposits its total flux (zero_point * 10**(-0.4 * vmag) * exposure_sec) as a sub-pixel point mass in the detector-native point-source plane (electrons for a CCD, DN for the vidicon), so the whole-scene optics PSF is the star’s only convolution. The background sky draws its counts from a cumulative star-count law and renders them through the same flux/point-mass path.

Occlusion: bodies paint far to near by range_km (overlaps without explicit ranges are a scene error). Only the solid silhouette paints opaquely: an atmospheric body’s above-limb halo is a translucent screen (emission plus exp(-tau) transmission) composited like the ring system’s, so it neither erases the background nor enters the body masks or the depth map. The halo does enter the overlap checks: its compositing order against whatever it covers is set by range_km, so a halo that reaches another body’s silhouette, another halo, or the ring system requires explicit ranges on both participants exactly as opaque overlaps do. The optical-depth ring_system composites over the painted stack as a transmission screen, per pixel and depth-ordered against the bodies (img = I_ring + exp(-tau/mu) * img_behind); the halo screens composite in the same far-to-near depth order, interleaved with the ring by each halo’s body range. Point sources sit at infinity: every translucent screen (ring, halo) attenuates them and an opaque body’s silhouette extinguishes them (the painted, lit silhouette – a fully dark night side does not occult, a stated approximation of the mask-based body renderers).

The translucent-screen machinery itself – the far-to-near screen ordering and the depth-ambiguity checks behind every stacking decision – lives in the sibling spindoctor.sim.forward.scene_compositing; this module paints the stack and applies the ordered screen ops it returns.

compose_scene_radiance(frame: SimFrame, *, params: Mapping[str, Any], rng: Generator) None[source]

Compose the scene’s noise-free radiance into the frame in place.

Parameters:
  • frame – The frame whose signal plane is composed in place; its truth dict receives the renderer output metadata (stars, bodies, ring_features, inventory, star_info, body_masks, ring_masks, order_near_to_far, body_index_map, body_mask_map, body_occlusion).

  • params – The full scene mapping.

  • rng – The stage generator. Unused directly: this stage’s randomized sub-effects (background stars, craters) run behind parameter-keyed caches that need scalar seeds, so they derive named sub-seeds from the scene’s random_seed instead of consuming generator state.

Translucent-screen compositing and depth-ambiguity checks for the radiance stage.

The radiance stage (spindoctor.sim.forward.scene_radiance) paints the opaque body stack far to near and then composites the scene’s translucent screens – the optical-depth ring system and the atmospheric bodies’ above-limb halos – over the painted image. This module owns that compositing machinery: translucent_screen_ops() orders the screens far to near (the ring’s per-pixel depth interleaving with the halos’ scalar body ranges) and returns them as ScreenOp applications, and the check_* functions enforce the depth contract behind every ordering decision – overlapping objects must ALL carry an explicit scene range_km, or their stacking would be a silent guess and the scene fails loudly.

Everything here operates on rendered geometry the radiance stage hands in (painted masks, halo screens, ring maps); no scene mapping is read directly.

class ScreenOp(box_v: slice, box_u: slice, mask: NDArray[bool], intensity: NDArray[floating[Any]], transmission: NDArray[floating[Any]], is_ring: bool)[source]

Bases: object

One translucent-screen application over the composed image.

Applied to the (box_v, box_u) view of the frame as view[mask] = intensity[mask] + transmission[mask] * view[mask] (the screen is identity outside its box); is_ring routes the emission to the rings class when the differential-smear layers replay the ops per class.

Parameters:
  • box_v – Frame rows the op is restricted to.

  • box_u – Frame columns the op is restricted to.

  • mask – Box-sized mask of the pixels the screen composites over.

  • intensity – Box-sized emission map.

  • transmission – Box-sized per-pixel background transmission.

  • is_ring – Whether the emission belongs to the rings class (else the bodies class – a body halo).

box_u: slice
box_v: slice
intensity: NDArray[floating[Any]]
is_ring: bool
mask: NDArray[bool]
transmission: NDArray[floating[Any]]
check_depth_ambiguity(painted_items: list[tuple[str, NDArray[bool], bool]], item_label: str, item_mask: NDArray[bool], explicit_depth: bool) None[source]

Fail when overlapping bodies are stacked without explicit depths.

range_km is the compositing depth. Two bodies whose painted pixels overlap must both carry an explicit scene range_km, or their stacking order would be a silent guess; a scene that leaves it off an overlapping body is malformed and fails loudly here. Non-overlapping bodies need no ranges: their paint order is unobservable.

Parameters:
  • painted_items(label, painted mask, explicit range_km?) for every body already painted, in render order.

  • item_label – Name of the body just painted.

  • item_mask – Pixels the body just painted.

  • explicit_depth – Whether the body carries an explicit range_km.

Raises:

spindoctor.sim.scene_schema.SimSceneValidationError – If the new object overlaps a painted one and either of the pair lacks an explicit range_km.

check_halo_ambiguity(painted_items: list[tuple[str, NDArray[bool], bool]], halo_check_items: list[tuple[str, bool, HaloScreen]]) None[source]

Fail when a halo overlaps another body or halo without explicit depths.

A halo composites against everything it covers by its body’s range_km, so an overlap ordered only by the positional default ranges would be a silent guess exactly like an opaque overlap: a halo that reaches another body’s painted silhouette, or another body’s halo, requires an explicit scene range_km on both bodies. A halo over only the empty sky needs no range: its order is unobservable.

Parameters:
  • painted_items(label, painted mask, explicit range_km?) for every painted body.

  • halo_check_items(body label, explicit range_km?, halo screen) for every atmospheric body, in render order.

Raises:

spindoctor.sim.scene_schema.SimSceneValidationError – If a halo overlaps another body’s paint or halo and either body lacks an explicit range_km.

check_ring_system_ambiguity(painted_items: list[tuple[str, NDArray[bool], bool]], halo_check_items: list[tuple[str, bool, HaloScreen]], ring_mask: NDArray[bool], *, ring_explicit: bool) None[source]

Fail when the ring system overlaps bodies or halos without depths.

Per-pixel depth ordering against a body needs an explicit range_km on both the ring system and the body. A body’s translucent halo takes the same rule: the ring interleaves with a halo by the two ranges, so a ring over a halo ordered only by positional defaults (or a depth-less ring that would silently screen the halo) is the same ambiguity.

Parameters:
  • painted_items(label, painted mask, explicit range_km?) for every painted body.

  • halo_check_items(body label, explicit range_km?, halo screen) for every atmospheric body, in render order.

  • ring_mask – Pixels where the ring system carries optical depth.

  • ring_explicit – Whether the ring system carries an explicit range_km.

Raises:

spindoctor.sim.scene_schema.SimSceneValidationError – On any overlap without a defined depth relation.

translucent_screen_ops(halo_screens: list[tuple[float, HaloScreen]], *, ring_maps: RingSystemMaps | None, ring_apply: NDArray[bool] | None, body_depth_map: NDArray[floating[Any]] | None) list[ScreenOp][source]

Order the scene’s translucent screens far to near for compositing.

Halo screens arrive in far-to-near body order. The ring system’s per-pixel depth interleaves with the halos’ scalar body ranges: the ring pixels at or beyond a halo’s body range apply before that halo (the ring shows through the glow, attenuated), and the pixels nearer than every halo apply last (the ring screens the glow). A ring system without a range_km has no depth relation, so it applies last in full. Each halo composites only where no nearer opaque body covers it.

Parameters:
  • halo_screens(body range_km, halo screen) per atmospheric body, far to near.

  • ring_maps – The rendered ring-system maps, or None.

  • ring_apply – Ring pixels that survive the body depth test, or None.

  • body_depth_map – Per-pixel depth of the nearest painted body; present whenever any screen exists.

Returns:

The screen applications, in application (far-to-near) order.

Image-side ellipsoid body renderer and its render-path dispatch.

A smooth Lambert ellipsoid at oversample 1 renders through the classic path here (create_simulated_body(), optionally with procedural crater texture). A body that needs more – a limb-relief field, a non-Lambert photometric law, an opposition surge, or an oversampled radiance grid – dispatches to the topographic renderer (spindoctor.sim.forward.body_topo), which shares this module’s crater carving and the ellipsoid shading conventions of spindoctor.sim.ellipsoid_geometry, so a scene’s planted geometry error is the only difference between rendered and predicted silhouettes.

The crater and relief knobs live only on this side of the information boundary: they are truth keys the navigator never sees (its best model is the smooth Lambert ellipsoid).

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, crater_fill: float = 0.0, crater_min_radius: float = 0.05, crater_max_radius: float = 0.25, crater_power_law_exponent: float = 3.0, crater_relief_scale: float = 0.6, anti_aliasing: float = 0.0, seed: int | None = None) NDArray[floating[Any]][source]

Create a simulated planetary body as an ellipsoid with shading and surface features.

The body is modeled as a 3D ellipsoid projected onto 2D. The ellipsoid can have internal craters of varying sizes and depths. The body is 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).

  • crater_fill – Approximate fraction of the ellipse to fill with craters.

  • crater_min_radius – Minimum radius of a crater as a fraction of axis1.

  • crater_max_radius – Maximum radius of a crater as a fraction of axis1.

  • crater_power_law_exponent – Power law exponent for the crater radius distribution.

  • crater_relief_scale – Scale factor for the crater depth.

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

  • seed – Random seed for crater generation. If None, uses a hash-based seed.

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.

render_single_body(img: NDArray[floating[Any]], body_params: dict[str, Any], offset_v: float, *, offset_u: float, seed: int | None = None, body_index: int = 0, ref_center_v: float, ref_center_u: float, oversample: int = 1) tuple[NDArray[bool], dict[str, Any]][source]

Render a single body into the image.

Parameters:
  • img – Image array to modify in-place.

  • body_params – Body parameters dictionary.

  • offset_v – V offset to apply.

  • offset_u – U offset to apply.

  • seed – Scene-level crater seed; a per-body sub-seed is derived from it so bodies with identical geometry get independent crater patterns (and, further derived, independent relief terrains).

  • body_index – Stable index of this body in the scene’s body list, mixed into the per-body crater sub-seed alongside the body name.

  • ref_center_v – Reference center V for body shape caching.

  • ref_center_u – Reference center U for body shape caching.

  • oversample – The render grid’s oversampling factor. The body’s pixel-space parameters must already be scaled to this grid; the factor selects the topographic renderer’s split-resolution path, which shades at the detector grid.

Returns:

Tuple of (body_mask, body_info_dict) where body_info_dict contains name, inventory item, and model params.

Image-side polyhedral-mesh body rendering.

The mesh primitives live in the shared spindoctor.sim.mesh_geometry module so the rendered mesh and the navigator’s predicted mesh are the same shape by construction. On top of that shared geometry this image side adds its truth-key upgrades, none of which the navigator’s prediction consumes:

  • shading selects the shared rasterizer’s mode for the RENDERED image (‘flat’ default, ‘gouraud’ per-vertex smooth shading). The rasterizer capability is shared; each side chooses its own mode, and the navigator’s predicted mesh keeps flat shading because the key is truth-side.

  • limb_relief_rms / limb_relief_corr_deg apply the same relief-field machinery the ellipsoid path uses, here as a per-vertex radial perturbation of the unit mesh (its own seeded ‘relief’ stream). The field is sampled in body-fixed spherical coordinates – mesh terrain is attached to the body and rotates with the pose – so the commanded limb-slice statistics apply to whichever great circle the pose turns toward the observer.

  • pose_scatter draws a seeded per-frame Gaussian perturbation (sigma_deg per Euler axis, its own ‘pose_scatter’ stream) added to the rendered pose only. The navigator predicts the catalog pose, so the drawn rotation is a known-wrong rotation state; the draw is recorded in the render truth as pose_scatter_drawn_deg.

render_single_mesh_body(img: NDArray[floating[Any]], body_params: dict[str, Any], body_name: str, *, center_v: float, center_u: float, axis1: float, axis2: float, axis3: float, illumination_angle: float, phase_angle: float, anti_aliasing: float, ref_center_v: float, ref_center_u: float, seed: int | None = None, body_index: int = 0) tuple[NDArray[bool], dict[str, Any]][source]

Render one polyhedral-mesh body into the image in place.

Parameters:
  • img – Image array to modify in-place.

  • body_params – Body parameters dictionary (mesh keys are parsed by mesh_spec_from_params; the truth keys shading, limb_relief_*, and pose_scatter are read here). The mapping is never mutated: when a pose scatter is drawn, the returned body info’s params is a copy of this mapping with pose_scatter_drawn_deg added (render truth metadata).

  • body_name – Upper-cased body name for the inventory keys.

  • center_v – Body center V in the image (offset already applied).

  • center_u – Body center U in the image (offset already applied).

  • axis1 – Full width of ellipsoidal-envelope axis 1 in pixels.

  • axis2 – Full width of ellipsoidal-envelope axis 2 in pixels.

  • axis3 – Full width of ellipsoidal-envelope axis 3 in pixels.

  • illumination_angle – Image-plane light azimuth in radians.

  • phase_angle – Phase angle in radians.

  • anti_aliasing – Limb supersampling control.

  • ref_center_v – Reference center V for shape caching.

  • ref_center_u – Reference center U for shape caching.

  • seed – Scene-level sub-seed the per-body truth streams derive from.

  • body_index – Stable index of this body in the scene’s body list.

Returns:

Tuple of (body_mask, body_info_dict) matching the ellipsoid path.

Topographic ellipsoid body renderer: relief limb, ragged terminator, laws.

This is the image-side body render path used whenever a body needs more than the classic smooth-Lambert ellipsoid: a limb-relief field (spindoctor.sim.forward.relief), a non-Lambert photometric law or opposition surge (spindoctor.sim.forward.photometry), or an oversampled radiance grid (where it is also the fast path – see below). spindoctor.sim.forward.body.render_single_body() dispatches here; a smooth Lambert body at oversample 1 keeps the classic path byte-for-byte.

Split-resolution rendering. Disc shading is low-frequency by construction – relief moves the silhouette and the terminator, never the disc shading – so the shading field (surface normals, crater texture, photometric law) is computed once at detector resolution and bilinearly upsampled, while the sharp content is rasterized at the full working grid: the silhouette mask (with the relief-perturbed limb) and the terminator shadow march. This replaces the per-subsample shading that made an oversampled body render ~16x more expensive than its detector-grid equivalent, and is what lets a body-bearing scene meet the render-time budget (see tests/integration/test_sim_perf.py).

Relief application. The renderer’s normalized ellipse radial function e(p) (1 exactly at the unperturbed limb) becomes e_adj(p) = e(p) / (1 + delta(theta)), placing the perturbed limb at r_ellipse * (1 + delta); delta is the relief field sampled along the sub-observer horizon circle. Shading normals keep the unperturbed e. The terminator march shadows near-terminator disc points against upstream terrain in absolute heights (the march itself lives in spindoctor.sim.forward.relief); shadowed pixels render at the dark-side floor, and the marched band is bounded by the cap, so raggedness grows toward the terminator and the cost stays bounded.

The mesh-body path does not dispatch here: a polyhedral-mesh body applies the relief field as a per-vertex radial perturbation in spindoctor.sim.forward.body_mesh, and the scene validator rejects the photometric-law, surface-texture, crater, and atmosphere keys on mesh bodies rather than letting them silently not render.

class TopoBodySpec(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, crater_fill: float = 0.0, crater_min_radius: float = 0.05, crater_max_radius: float = 0.25, crater_power_law_exponent: float = 3.0, crater_relief_scale: float = 0.6, anti_aliasing: float = 0.0, crater_seed: int | None = None, oversample: int = 1, limb_relief_rms: float = 0.0, limb_relief_corr_deg: float = 15.0, relief_seed: int = 0, photometric_law: str = 'lambert', minnaert_k: float = 0.5, surge_amplitude: float = 0.0, surge_width_deg: float = 6.0, albedo_texture: AlbedoTextureSpec | None = None, disc_texture: DiscTextureSpec | None = None, transits: tuple[TransitSpec, ...] = ())[source]

Bases: object

Everything the topographic renderer needs beyond size and center.

All pixel quantities are in units of the incoming render grid (the oversampled grid when oversample > 1).

Parameters:
  • axis1 – Full width of ellipsoid axis 1 (a) in pixels.

  • axis2 – Full width of ellipsoid axis 2 (b) in pixels.

  • axis3 – Full width of ellipsoid axis 3 (c, depth) in pixels.

  • rotation_z – In-plane rotation about the viewing axis, radians.

  • rotation_tilt – Tilt toward/away from the viewer, radians.

  • illumination_angle – In-plane light direction, radians (0 = top).

  • phase_angle – Phase angle in radians (0 fully lit, pi backlit).

  • crater_fill – Approximate crater coverage fraction (0 = none).

  • crater_min_radius – Minimum crater radius as a fraction of axis1.

  • crater_max_radius – Maximum crater radius as a fraction of axis1.

  • crater_power_law_exponent – Crater radius power-law exponent.

  • crater_relief_scale – Crater depth scale factor.

  • anti_aliasing – Silhouette anti-aliasing amount in [0, 1]; only consumed at oversample 1, where it supersamples the silhouette exactly as the classic path does (an oversampled scene already resolves the limb on its own grid).

  • crater_seed – Seed for crater placement (None with no craters).

  • oversample – The incoming grid’s oversampling factor; shading is computed at the detector grid size / oversample.

  • limb_relief_rms – Commanded limb-slice relief RMS (h/R; 0 = off).

  • limb_relief_corr_deg – Relief correlation length, degrees of arc.

  • relief_seed – Seed of the relief-field realization.

  • photometric_law – One of the photometry module’s law names.

  • minnaert_k – Minnaert exponent (law ‘minnaert’ only).

  • surge_amplitude – Opposition-surge amplitude (0 = none).

  • surge_width_deg – Opposition-surge angular width in degrees.

  • albedo_texture – Multiplicative albedo texture (noise field + spots), or None. Applied to the shading, never the silhouette.

  • disc_texture – Banded zones/belts plus storm ovals, or None.

  • transits – Transiting moon discs and cast shadows (texture on the rendered disc).

albedo_texture: AlbedoTextureSpec | None = None
anti_aliasing: float = 0.0
axis1: float
axis2: float
axis3: float
crater_fill: float = 0.0
crater_max_radius: float = 0.25
crater_min_radius: float = 0.05
crater_power_law_exponent: float = 3.0
crater_relief_scale: float = 0.6
crater_seed: int | None = None
disc_texture: DiscTextureSpec | None = None
illumination_angle: float = 0.0
limb_relief_corr_deg: float = 15.0
limb_relief_rms: float = 0.0
minnaert_k: float = 0.5
oversample: int = 1
phase_angle: float = 0.0
photometric_law: str = 'lambert'
relief_seed: int = 0
rotation_tilt: float = 0.0
rotation_z: float = 0.0
surge_amplitude: float = 0.0
surge_width_deg: float = 6.0
transits: tuple[TransitSpec, ...] = ()
create_topographic_body(size: tuple[int, int], center: tuple[float, float], spec: TopoBodySpec) NDArray[floating[Any]][source]

Render one topographic ellipsoid body onto a black frame.

Parameters:
  • size – Frame dimensions (rows, columns) on the incoming render grid.

  • center – Body center (v, u) in incoming-grid pixels.

  • spec – The body’s geometry, texture, relief, and photometry.

Returns:

The size intensity image in [0, 1] (0 outside the body).

Raises:

ValueError – If the frame size is not divisible by the oversample.

Truth-side multiplicative surface textures for the topographic body renderer.

Three texture families live here, all applied to the body’s SHADING (the photometric-law output on the detector-resolution shading grid) and never to the silhouette, so they change what disc correlation sees without moving the limb the navigator fits:

  • Albedo texture (albedo_texture): a band-limited multiplicative noise field on the surface plus discrete circular albedo spots – the realistic disc contrast of an icy moon. The noise field reuses the relief-field spectral synthesis with its own seeded 'albedo' stream (spindoctor.sim.forward.relief.synthesize_albedo_field()).

  • Disc texture (disc_texture): a low-frequency latitude-banded pattern plus discrete storm ovals – the zones/belts and GRS-class storms a giant-planet disc presents to disc correlation.

  • Transits (transits): a moon disc in front of the planet disc (bright or dark against the bands) and/or its cast shadow. Both are texture on the rendered disc, not body-on-body illumination coupling: the shadow is the sharp, high-contrast circular false crater that disc correlation and blob techniques can lock onto.

Coordinate convention. Surface points come from the topographic renderer’s unit-sphere parameterization (x, y, z) with x = v_rot / semi_a, y = u_rot / semi_b, z = sqrt(1 - x^2 - y^2) (so x^2 + y^2 + z^2 = 1 on the disc). Every texture – the albedo noise field, albedo spots, bands, and storms – lives in one body-polar frame: the pole is the body’s axis1 direction (lat = arcsin(x)), and longitude is measured from +axis2 toward the observer (lon = atan2(z, y); 90 deg = the sub-observer meridian). Texture is terrain attached to the body, so it rotates in the image with the body’s rotation_z pose, bands follow the planet’s belts, and the lat-lon synthesis grid’s polar pinch sits at the foreshortened limb points on the pole axis rather than anywhere on the disc face.

The commanded albedo correlation length is corr_px in detector pixels on the disc: it is converted to degrees of surface arc using the body’s mean projected radius, so it is exact where the surface is face-on (the field’s equator crosses the disc center) and foreshortens toward the limb the way real surface texture does.

Transit geometry (dv_px / du_px offsets from the body center and radius_px) is in detector pixels on the image plane, consumed directly on the detector-resolution shading grid. A transiting disc renders only where it overlaps the parent silhouette – a moon off the limb is a separate scene body, not a transit entry.

All keys here are truth keys: the navigator’s predicted body is always the untextured smooth-law template, so every texture is planted model error by construction.

class AlbedoTextureSpec(rms: float = 0.0, corr_px: float = 20.0, spots: tuple[tuple[float, float, float, float], ...] = (), seed: int = 0)[source]

Bases: object

One body’s albedo_texture block, resolved for rendering.

Parameters:
  • rms – Global standard deviation of the multiplicative noise field (0 disables the field; spots still apply).

  • corr_px – Noise correlation length in detector pixels on the disc.

  • spots – Circular albedo spots as (lat_deg, lon_deg, radius_deg, albedo_factor) tuples in the body-polar frame.

  • seed – The ‘albedo’ stream seed of the noise-field realization.

corr_px: float = 20.0
rms: float = 0.0
seed: int = 0
spots: tuple[tuple[float, float, float, float], ...] = ()
class DiscTextureSpec(band_amplitude: float = 0.0, band_wavenumber: float = 8.0, band_phase_deg: float = 0.0, storms: tuple[tuple[float, float, float, float], ...] = ())[source]

Bases: object

One body’s disc_texture block (zones/belts plus storm ovals).

The band factor is 1 + band_amplitude * cos(band_wavenumber * lat_p + band_phase) with lat_p the body-polar latitude in radians, so band_wavenumber counts full cosine cycles per radian of latitude (a Saturn-like scene uses ~6-10) and band_phase_deg slides the pattern in latitude.

Parameters:
  • band_amplitude – Multiplicative contrast of the banded pattern (0 disables the bands; storms still apply).

  • band_wavenumber – Cosine cycles per radian of body-polar latitude.

  • band_phase_deg – Phase offset of the band pattern, in degrees.

  • storms – Storm ovals as (lat_deg, lon_deg, radius_deg, albedo_factor) tuples in the body-polar frame.

band_amplitude: float = 0.0
band_phase_deg: float = 0.0
band_wavenumber: float = 8.0
storms: tuple[tuple[float, float, float, float], ...] = ()
class TransitSpec(moon: tuple[float, float, float, float] | None = None, shadow: tuple[float, float, float, float] | None = None)[source]

Bases: object

One transits entry: a transiting moon disc and/or its cast shadow.

Parameters:
  • moon(dv_px, du_px, radius_px, albedo_factor) – the moon disc’s offset from the parent body center, its radius, and its brightness as a factor of the local smooth-law shading (bright moon > 1 is clipped at the signal ceiling; dark moon < 1). None for a shadow-only entry.

  • shadow(dv_px, du_px, radius_px, darkness) – the cast shadow disc; the textured shading inside it is multiplied by 1 - darkness. None for a moon-only entry.

moon: tuple[float, float, float, float] | None = None
shadow: tuple[float, float, float, float] | None = None
albedo_spec_from_params(body_params: Mapping[str, Any], *, seed: int) AlbedoTextureSpec | None[source]

Build an AlbedoTextureSpec from a body mapping, or None.

Parameters:
  • body_params – One scene body entry.

  • seed – The body’s ‘albedo’ stream seed.

Returns:

The resolved spec, or None when the body has no albedo_texture.

apply_transits(intensity: NDArray[floating[Any]], transits: tuple[TransitSpec, ...], *, base_intensity: NDArray[floating[Any]], v_ctr: NDArray[floating[Any]], u_ctr: NDArray[floating[Any]], disc_mask: NDArray[bool]) None[source]

Render the transit shadows and moon discs into intensity in place.

Render order per the layering contract: the banded texture is already in intensity; every shadow multiplies that texture first, then every moon disc overwrites on top (a moon in front occludes the texture and any shadow under it). The moon disc’s brightness derives from the smooth-law shading (base_intensity): a foreground moon does not inherit the parent’s texture.

Parameters:
  • intensity – The textured shading-grid intensity, updated in place.

  • transits – The body’s transit specs.

  • base_intensity – The pre-texture smooth-law shading.

  • v_ctr – Body-centered v coordinate of each shading pixel.

  • u_ctr – Body-centered u coordinate of each shading pixel.

  • disc_mask – True inside the parent disc; transits render only there.

disc_texture_spec_from_params(body_params: Mapping[str, Any]) DiscTextureSpec | None[source]

Build a DiscTextureSpec from a body mapping, or None.

Parameters:

body_params – One scene body entry.

Returns:

The resolved spec, or None when the body has no disc_texture.

surface_texture_factor(albedo: AlbedoTextureSpec | None, disc_texture: DiscTextureSpec | None, *, x: NDArray[floating[Any]], y: NDArray[floating[Any]], z: NDArray[floating[Any]], mean_radius_px: float) NDArray[floating[Any]] | None[source]

The combined multiplicative texture factor over the shading grid.

Parameters:
  • albedo – The body’s albedo-texture spec, or None.

  • disc_texture – The body’s disc-texture spec, or None.

  • x – Unit-sphere axis1 component per shading pixel (v_rot / a).

  • y – Unit-sphere axis2 component per shading pixel (u_rot / b).

  • z – Unit-sphere toward-observer component (0 outside the disc, so off-disc pixels carry limb values for the upsample continuity).

  • mean_radius_px – Mean projected body radius in shading-grid (detector) pixels, the corr_px -> surface-arc conversion scale.

Returns:

The per-pixel multiplicative factor (clipped non-negative), or None when neither texture is configured.

transit_specs_from_params(body_params: Mapping[str, Any]) tuple[TransitSpec, ...][source]

Build the body’s TransitSpec tuple (empty without transits).

Parameters:

body_params – One scene body entry.

Returns:

One spec per transits entry, in scene order.

Truth-side body relief: the limb/terminator topography field.

The relief is a 2-D field on the body surface, not a 1-D field on the limb, so the limb perturbation and the terminator shadowing are slices of one consistent surface. h(lat, lon) is fractional relief (height / local radius): a periodic 2-D Gaussian random field synthesized by 2-D FFT on a (lat, lon) grid. The spectral coefficients are independent complex Gaussians with variance S(k) proportional to exp(-(|k| * corr_rad / 2)**2), where |k| is the total angular wavenumber and corr_rad is the correlation length in radians of surface arc; the band limit is kmax = ceil(8 / corr_rad), where S has fallen to ~1e-7 of its peak.

Modes with total wavenumber below 3 are zeroed: the degree-1 content of a radius perturbation is, to first order, a translation of the body – a planted, untruthed center offset no limb fit could distinguish from the pointing error – and degree-2 content aliases ellipsoid shape error, which is its own scene knob. After zeroing, the field is rescaled so the limb slice’s standard deviation equals the commanded RMS per-realization.

Coordinate convention. The field’s poles sit on the observer axis, so the sub-observer horizon circle (the limb) is the field’s equator: latitude is elevation out of the limb plane toward the observer, longitude is azimuth around the limb. On the equator the (lat, lon) grid metric is exact, so the limb slice – the statistic the scene commands – carries the commanded RMS and correlation length with no map distortion. The known cost is the standard lat-lon caveat: high field latitudes (near the disc center, and the terminator’s approach to it at high phase) are over-corrugated in longitude by ~cos(lat). At the default 15-degree correlation length the affected caps are small; the contract fixes the spectrum and the limb-slice statistics, not the synthesis mesh.

The terminator shadow march (march_shadows()) works in absolute heights H = h * R in pixels (R the local body radius in pixels): the fractional field (~0.01) and surface distances (pixels) are never compared directly. A point is shadowed iff some upstream sample at surface distance d toward the sun satisfies H_up - H_pt > d / tan(i_pt), with i_pt the point’s incidence angle. The march is capped at d_max = min((H_max - H_min) * tan(i_pt), sqrt(2 * R * H_max)): the first term is the longest shadow the terrain can cast, the second the horizon limit that bounds the tangent’s divergence at the terminator itself, so cost is bounded and the geometry stays physical.

class MarchStats(candidate_count: int, steps_executed: int, max_steps: int)[source]

Bases: object

Diagnostics of one terminator shadow march.

Parameters:
  • candidate_count – Number of points the march started from.

  • steps_executed – Surface-arc steps actually executed (the loop count).

  • max_steps – The global step bound implied by the march cap; the loop can never exceed it regardless of the domain’s extent.

candidate_count: int
max_steps: int
steps_executed: int
class ReliefField(grid: NDArray[floating[Any]], rms: float, corr_deg: float, h_min: float, h_max: float)[source]

Bases: object

One realization of the fractional relief field h(lat, lon).

Parameters:
  • grid(n, n) periodic field over the full (lat, lon) torus; row i is latitude -pi + 2*pi*i/n (the limb equator is row n // 2), column j is longitude 2*pi*j/n.

  • rms – The commanded limb-slice standard deviation (h/R, unitless).

  • corr_deg – The correlation length in degrees of surface arc.

  • h_min – Global field minimum (fractional).

  • h_max – Global field maximum (fractional).

corr_deg: float
grid: NDArray[floating[Any]]
h_max: float
h_min: float
limb_delta(phi: NDArray[floating[Any]]) NDArray[floating[Any]][source]

The limb perturbation delta(phi) at azimuths phi (radians).

Parameters:

phi – Azimuths around the limb (any real values; wrapped to [0, 2*pi)).

Returns:

Fractional limb displacement values, same shape as phi.

limb_slice() tuple[NDArray[floating[Any]], NDArray[floating[Any]]][source]

The limb slice h(0, phi) as interpolation nodes.

Returns:

longitudes 0 .. 2*pi inclusive (the final node repeats the first, closing the circle) and the field values on the equator row, ready for np.interp.

Return type:

Tuple (phi_nodes, values)

rms: float
sample(lat: NDArray[floating[Any]], lon: NDArray[floating[Any]]) NDArray[floating[Any]][source]

Bilinearly sample the periodic field at (lat, lon) in radians.

Parameters:
  • lat – Latitudes above the limb plane (any shape; wrapped).

  • lon – Longitudes around the limb (same shape as lat; wrapped).

Returns:

Fractional relief values, same shape as the inputs.

march_shadows(start_v: NDArray[floating[Any]], start_u: NDArray[floating[Any]], *, h_point: NDArray[floating[Any]], tan_incidence: NDArray[floating[Any]], radius_px: NDArray[floating[Any]], height_map: NDArray[floating[Any]], arc_per_px_map: NDArray[floating[Any]], domain_mask: NDArray[bool], h_max: float, h_min: float, sun_v: float, sun_u: float, step_arc_px: float = 1.0) tuple[NDArray[bool], MarchStats][source]

March candidate points toward the sun and flag the shadowed ones.

Each candidate steps toward the sun in units of step_arc_px pixels of surface arc (the image-plane step is step / arc_per_px at the current sample, so foreshortening near the limb is corrected: an image pixel there spans a large surface step). A candidate is shadowed iff an upstream sample at surface distance d satisfies H_up - H_pt > d / tan(i_pt). The per-point cap d_max = min((H_max - H_min) * tan(i_pt), sqrt(2 * R * H_max)) bounds the work; H_max/H_min are the field’s global extremes at the point’s local radius.

Parameters:
  • start_v – Candidate image-row positions (work-grid pixels, float).

  • start_u – Candidate image-column positions, same shape.

  • h_point – Fractional relief at each candidate’s surface point.

  • tan_incidence – Tangent of each candidate’s incidence angle (> 0).

  • radius_px – Local body radius at each candidate, in work pixels.

  • height_map – Absolute heights h * R_local (work px) sampled at each domain pixel’s surface point; zero outside the domain.

  • arc_per_px_map – Surface arc (work px) spanned by one image pixel along the sun direction, per domain pixel.

  • domain_mask – Pixels where the maps are valid; a ray leaving the domain stops marching (the caller sizes the domain to cover the longest capped march, so no caster can sit beyond it).

  • h_max – Global field maximum (fractional).

  • h_min – Global field minimum (fractional).

  • sun_v – Image-plane unit direction toward the sun, row component.

  • sun_u – Image-plane unit direction toward the sun, column component.

  • step_arc_px – Surface arc per step, in work pixels. 1 (the default) samples the terrain at the render grid itself; a caller whose terrain is band-limited far above the render grid’s scale may step at the terrain’s own resolution instead (see the topographic renderer), which changes no decision the sampled terrain can express.

Returns:

Tuple of (shadow flags per candidate, MarchStats).

synthesize_albedo_field(rms: float, corr_deg: float, seed: int) ReliefField[source]

Synthesize one multiplicative-albedo field realization.

The albedo texture reuses the relief field’s spectral synthesis and its ReliefField sampling container, with two deliberate differences: the commanded RMS is the field’s global standard deviation (disc-wide texture contrast rather than a limb statistic), and only the spectral mean is zeroed – albedo multiplies the shading and never moves the silhouette, so low-degree content cannot alias pointing or shape error.

Parameters:
  • rms – Commanded global standard deviation of the fractional albedo perturbation; 0 yields the all-zero field.

  • corr_deg – Correlation length in degrees of surface arc.

  • seed – The realization seed, derived from the body’s ‘albedo’ stream.

Returns:

The scaled ReliefField realization (holding the albedo perturbation; the multiplicative factor is 1 + field).

synthesize_relief_field(rms: float, corr_deg: float, seed: int) ReliefField[source]

Synthesize one relief-field realization.

Parameters:
  • rms – Commanded limb-slice standard deviation (h/R, unitless); 0 yields the all-zero field.

  • corr_deg – Correlation length in degrees of surface arc.

  • seed – The realization seed, derived from the body’s seed chain.

Returns:

The scaled ReliefField realization.

Photometric surface-scattering laws for the image-side body renderer.

The forward renderer shades body discs with a scene-selected law – Lambert, Lommel-Seeliger, Minnaert(k), or a lunar-Lambert blend – plus an optional opposition-surge factor. The law is a truth key: the navigator’s predicted-body model always shades with Lambert (spindoctor.sim.ellipsoid_geometry.lambert_from_normals()), so a non-Lambert scene plants a photometric mismatch that moves the terminator and the limb-darkening profile by a known amount.

All laws are normalized to 1 at disc center under head-on illumination (mu = mu0 = 1) and expressed in mu0 = cos(incidence) and mu = cos(emission); for this renderer’s orthographic view mu is the surface normal’s z component. Forms:

  • lambert: I = mu0.

  • lommel_seeliger: I = 2 * mu0 / (mu0 + mu) (reaches 2 toward the limb; the [dark floor, 1] clip below caps it at the signal ceiling).

  • minnaert: I = mu0**k * mu**(k - 1); k = 1 is Lambert, k = 0.5 is the classic limb-brightened lunar value. The disc-center normalization holds; near the limb the law diverges and is capped by the [dark floor, 1] clip below.

  • lunar_lambert: I = 2 * L(alpha) * mu0 / (mu0 + mu) + (1 - L(alpha)) * mu0 with the McEwen (1991) cubic blend L(alpha) = 1 - 0.019 * a + 2.42e-4 * a**2 - 1.46e-6 * a**3 (a = phase in degrees), clipped to [0, 1]: pure Lommel-Seeliger at alpha = 0, pure Lambert once the cubic reaches 0 (~119 deg).

The opposition surge is a simple normalized exponential factor (1 + amplitude * exp(-alpha / width)) / (1 + amplitude): an interim knob for the realism match, planting the surge’s brightness-versus-phase signature while keeping the normalized signal plane within [0, 1] (a scene at large phase renders darker by 1 / (1 + amplitude) rather than the opposition frame clipping at the full scale).

The dark-side floor keeps today’s semantics: shade_surface() clips every visible-hemisphere pixel to [0.01, 1] (none of the supported laws defines a dark-side term, so geometric night renders at the floor), and the far hemisphere is 0. The floor is a guarantee on the shading stage’s output only: later multiplicative stages – a transit’s cast shadow in particular – may darken lit-disc pixels below it.

incidence_cosines(normal_v: NDArray[floating[Any]], normal_u: NDArray[floating[Any]], normal_z: NDArray[floating[Any]], *, illumination_angle: float, phase_angle: float) NDArray[floating[Any]][source]

Cosine of the incidence angle for image-frame unit surface normals.

Parameters:
  • normal_v – V component of the unit surface normal in image coordinates.

  • normal_u – U component of the unit surface normal in image coordinates.

  • normal_z – Z (toward-observer) component of the unit surface normal.

  • illumination_angle – In-plane light direction in radians; 0 is from the top of the image, pi/2 from the right.

  • phase_angle – Phase angle in radians; 0 is fully lit, pi is backlit.

Returns:

cos(incidence) per pixel (negative on the night side).

shade_surface(cos_incidence: NDArray[floating[Any]], cos_emission: NDArray[floating[Any]], *, law: str = 'lambert', phase_angle: float = 0.0, minnaert_k: float = 0.5, surge_amplitude: float = 0.0, surge_width_deg: float = 6.0) NDArray[floating[Any]][source]

Surface brightness under the selected photometric law.

Parameters:
  • cos_incidencemu0 per pixel (negative values are night side).

  • cos_emissionmu per pixel; > 0 marks the visible hemisphere.

  • law – One of PHOTOMETRIC_LAWS.

  • phase_angle – Phase angle in radians (drives the lunar-Lambert blend and the opposition surge).

  • minnaert_k – Minnaert exponent (used by law='minnaert' only).

  • surge_amplitude – Opposition-surge amplitude (0 = no surge).

  • surge_width_deg – Opposition-surge angular width in degrees.

Returns:

the law value times the surge factor, clipped to [DARK_SIDE_FLOOR, 1] on the visible hemisphere, 0 off it.

Return type:

Brightness in [0, 1]

Raises:

ValueError – On an unknown law name.

surge_factor(phase_angle: float, *, amplitude: float, width_deg: float) float[source]

The normalized opposition-surge brightness factor at a phase angle.

(1 + amplitude * exp(-alpha / width)) / (1 + amplitude): 1 at exact opposition, 1 / (1 + amplitude) far from it.

Parameters:
  • phase_angle – Phase angle in radians.

  • amplitude – Surge amplitude (0 disables the factor).

  • width_deg – Angular e-folding width of the surge in degrees of phase.

Returns:

The multiplicative brightness factor in (0, 1].

Image-side optical-depth ring-system renderer.

Draws the ring_system scene block: radial tau features (ringlets, gaps, one-sided edges, ramps, and damped density-wave trains) on mode-1 eccentric precessing orbits with optional m >= 2 modes and satellite edge waves, projected through the shared opening-angle geometry (spindoctor.sim.ring_geometry) and lit by the single-scattering closed forms. The output is a set of per-pixel maps – emitted intensity, transmission exp(-tau/mu), and line-of-sight depth – that the radiance stage composites against the body stack as a transmission screen: img = I_ring + exp(-tau/mu) * img_behind, evaluated far to near per pixel, so low-tau features reveal the background instead of erasing it and stars behind the ring attenuate physically.

Radial tau profiles by feature kind (r_e(lam) the feature’s perturbed orbit radius, w its radial width, distances in ring-plane radial units):

  • ringlet: tau between r_e and r_e + w (anti-aliased edges).

  • gap: the same band as a tau suppression (subtracts, clipped at zero).

  • edge: a one-sided step; side: 'in' carries tau for r <= r_e (a sheet bounded by its outer edge, the B-ring-edge case), side: 'out' for r >= r_e.

  • ramp: a linear transition across [r_e, r_e + w]; side: 'out' rises from 0 at r_e to tau at r_e + w (compose with an edge there for a sheet with a gradual inner boundary), side: 'in' mirrors it. Zero outside the band; the sharp end is anti-aliased.

  • wave: a damped radial sinusoid launched at r_e: dtau(x) = tau * exp(-x / damping) * sin(2*pi*x / wavelength) for x = r - r_e >= 0 and exactly zero upstream (the clamp mirrors the azimuthal edge wave’s: the envelope grows without bound for x < 0). A density-wave train is visual clutter riding on a sheet; its negative lobes subtract from the composed tau.

Photometry (the normative equation set), with mu = |sin B_obs|, mu0 = |sin B_sun|, lit iff sign(B_obs) == sign(B_sun), and one-term Henyey-Greenstein P(g, alpha):

  • lit: I = A/4 * P * mu0/(mu0 + mu) * (1 - exp(-tau*(1/mu0 + 1/mu)))

  • unlit: I = A/4 * P * mu0/(mu0 - mu) * (exp(-tau/mu0) - exp(-tau/mu)), with the limit A/4 * P * (tau/mu) * exp(-tau/mu) when |mu0 - mu| < 1e-6.

An opening angle of exactly 0 (either side) renders nothing. The unlit branch produces the real inversion: moderate-tau features bright from the dark side, high-tau features nearly black.

The albedo A and asymmetry g are per-feature truth keys; where features overlap radially the composed emission uses the tau-weighted mean of their A/4 * P factors over the positive (ringlet) contributions, which reduces exactly to the closed form for any single feature.

Truth-side clutter: the azimuthal block (brightness modulation, a planet-shadow wedge, seeded spokes) scales the emitted intensity without touching tau or transmission, and the moonlets list embeds opaque discs at the ring’s depth, each optionally carrying a propeller tau disturbance.

Each feature is evaluated only inside its radial annulus: the orbit’s exact radial bounds, widened by the kind’s shape extent and an anti-aliasing margin, map through the projection to a pixel bounding box, and outside the bounded band every contribution is exactly 0.0 (clipped shades, the ramp’s clipped fraction, the wave envelope’s float64 underflow). The bounding changes rendering cost with feature count, never the rendered values.

class RingSystemMaps(intensity: NDArray[floating[Any]], transmission: NDArray[floating[Any]], mask: NDArray[bool], depth_km: NDArray[floating[Any]] | None)[source]

Bases: object

Per-pixel maps of a rendered ring system on the (oversampled) grid.

Parameters:
  • intensity – Emitted ring brightness I per pixel (normalized signal units).

  • transmissionexp(-tau/mu) per pixel: the fraction of background light (bodies behind the ring, stars, sky) that passes through.

  • mask – Pixels where the system carries any optical depth.

  • depth_km – Observer distance range_km - dlos_km per pixel, or None when the scene gives the system no range_km (the system then has no depth relation to bodies; overlap is a scene error).

depth_km: NDArray[floating[Any]] | None
intensity: NDArray[floating[Any]]
mask: NDArray[bool]
transmission: NDArray[floating[Any]]
henyey_greenstein_phase(g: float, alpha_deg: float) float[source]

One-term Henyey-Greenstein phase function at phase angle alpha.

P = (1 - g**2) / (1 + g**2 + 2*g*cos(alpha))**1.5 with alpha the phase angle (0 at opposition): negative g backscatters (bright at low phase), positive g forward-scatters (dusty features brighten strongly toward alpha = 180).

Parameters:
  • g – Asymmetry parameter, strictly inside (-1, 1).

  • alpha_deg – Phase angle in degrees, [0, 180].

Returns:

The phase function value (positive).

render_ring_system(shape: tuple[int, int], ring_system: Mapping[str, Any], *, center_v: float, center_u: float, node_deg: float, time: float = 0.0, epoch: float = 0.0, oversample: int = 1, spokes_seed: int = 0) RingSystemMaps[source]

Render a ring system’s per-pixel intensity/transmission/depth maps.

The caller resolves the sky placement (planted offset, spacecraft- ephemeris parallax, camera roll – a roll rotates the projected pattern, which is exactly node_deg plus the roll angle) and passes the final center and node; orbit radii and widths are detector-pixel scene values scaled to the render grid here.

Parameters:
  • shape – The (oversampled) render-grid shape (V*os, U*os).

  • ring_system – The validated scene ring_system mapping.

  • center_v – Ring-system center v on the render grid (offset applied).

  • center_u – Ring-system center u on the render grid (offset applied).

  • node_deg – Sky position angle of the ascending node, in degrees (camera roll already added).

  • time – Scene time in TDB seconds (mode-1 pericenter precession).

  • epoch – Ring epoch in TDB seconds.

  • oversample – The render-grid oversampling factor; radii, widths, and the anti-aliasing window scale by it, and the depth map converts back through it so depth_km is grid-independent.

  • spokes_seed – Seed for the azimuthal spoke field’s realization (derived by the caller from the scene seed via the ‘scene_radiance/ring_system/spokes’ stream).

Returns:

The rendered RingSystemMaps.

Raises:

ValueError – If a feature’s kind is not a renderable profile (the validator rejects these; this guards direct callers).

ring_reflection_factor(tau: NDArray[floating[Any]], mu: float, mu0: float, *, lit: bool) NDArray[floating[Any]][source]

The geometric factor of the single-scattering closed forms.

Multiplied by A/4 * P this is the emitted intensity: the lit form saturates toward mu0/(mu0 + mu) at high tau, while the unlit form peaks at moderate tau and falls to zero for an opaque ring (nothing diffuses through) – the real dark-side inversion.

Parameters:
  • tau – Normal optical depth per pixel (non-negative).

  • mu|sin B_obs|, nonzero.

  • mu0|sin B_sun|, nonzero.

  • lit – Whether the observer sees the lit face (sign(B_obs) == sign(B_sun)).

Returns:

The per-pixel geometric factor.

Image-side star rendering: catalog stars and the background sky field.

Stars are point sources, so they render into the detector-native point-source plane (SimFrame.point_e) rather than the intensive signal plane: each star’s total flux is deposited as a sub-pixel-positioned point mass (a bilinear splat), and the whole-scene optics PSF is the ONLY convolution it receives. Pre-spreading a star and then convolving it would widen it by sqrt(2) and desync its shape from the limb and ring-edge profiles.

The flux is normalized (not peak = f(vmag)): a star of magnitude vmag at exposure_sec deposits zero_point * 10**(-0.4 * vmag) * exposure_sec, and the PSF then dictates the peak. The zero point is in electrons per second for a CCD (the mass lands in the electron plane, added before Poisson) and in DN per second for the Voyager vidicon (which has no electron domain). The deposit weight carries a factor oversample**2 so the box-mean downsample conserves the per-star detector-grid sum exactly.

The background sky field draws its star counts from a cumulative star-count law log10 N(<m) = a + b*m per square degree, scaled by the frame’s field of view, and renders every drawn star through the same flux/point-mass path (no separate PSF sigma).

The rendered MutableStar records and the star_info hit-test entries are renderer output metadata (they carry the planted offset, the rendered PSF sigma, and the deposited total flux); they stay on the image side of the information boundary and are never handed to the navigator-side models.

faint_sky_cutoff_mag(*, zero_point: float, exposure_sec: float, read_noise: float, psf_sigma: float) float[source]

Return the faint magnitude below which a sky star is not worth rendering.

A star falls below the sky background once its matched-filter signal drops to the read-noise floor integrated over its PSF core: with a Gaussian core of sigma psf_sigma the effective core area is 2*pi*sigma**2 pixels, so the noise over the core is read_noise * sqrt(2*pi*sigma**2) and the cutoff is where the total flux equals it. Fainter draws add nothing above the noise, so the count integral is truncated there.

Parameters:
  • zero_point – Flux a magnitude-0 star deposits per second.

  • exposure_sec – The scene exposure in seconds.

  • read_noise – The camera’s nominal per-pixel read noise (electrons for a CCD, DN for the vidicon), in the zero point’s unit domain.

  • psf_sigma – The scene PSF core sigma in detector pixels.

Returns:

The faint cutoff magnitude.

render_sky_counts(point_plane: NDArray[floating[Any]], *, seed: int, a: float, b: float, density_factor: float, pixel_scale_arcsec: float, faint_cutoff_mag: float, zero_point: float, exposure_sec: float, diffuse_flux_per_px: float, oversample: int) None[source]

Render the background-sky star field (and optional diffuse floor) in place.

The star count is drawn from log10 N(<m) = a + b*m per square degree, scaled by the frame’s field of view (the detector pixel count times the angular pixel scale squared) and the density_factor multiplier, down to the faint cutoff. Every star renders through the same flux/point-mass path as a catalog star, so the scene PSF shapes it. A non-zero diffuse floor adds a flat pedestal (the box-mean downsample leaves a constant unchanged, so it is a per-detector-pixel level).

Parameters:
  • point_plane – The (oversampled) point-source plane, modified in place.

  • seed – The stage sub-seed for the sky realization.

  • a – Cumulative-count law intercept log10 N(<m) at m = 0.

  • b – Cumulative-count law slope per magnitude.

  • density_factor – Multiplier on the counts (1 is mid galactic latitude).

  • pixel_scale_arcsec – Angular pixel scale (arcsec / detector pixel).

  • faint_cutoff_mag – The faint magnitude the count integral is truncated at.

  • zero_point – Flux a magnitude-0 star deposits per second.

  • exposure_sec – The scene exposure in seconds.

  • diffuse_flux_per_px – A flat diffuse-sky pedestal per detector pixel (0 disables it).

  • oversample – The render-grid oversampling factor.

render_stars(point_plane: NDArray[floating[Any]], stars_params: list[dict[str, Any]], offset_v: float, *, offset_u: float, zero_point: float, exposure_sec: float, rendered_sigma: float, rotation_deg: float = 0.0, oversample: int = 1, catalog_scatter_px: float = 0.0, catalog_scatter_seed: int = 0) tuple[NDArray[floating[Any]], list[MutableStar], list[dict[str, Any]]][source]

Deposit catalog stars into the point-source plane. Returns (plane, list, info).

Each star’s total flux (zero_point * 10**(-0.4 * vmag) * exposure_sec) is deposited as a sub-pixel point mass carrying the oversample**2 weight, so the box-mean downsample conserves the per-star sum and the whole-scene optics PSF is the star’s only convolution. A star may render off its catalog position (a per-star catalog_error_* plus the seeded scene scatter), at a variable brightness (delta_mag), and with an unresolved companion – all image-side truth the navigator is not told (see the star truth keys).

Parameters:
  • point_plane – The (oversampled) point-source plane, deposited into in place (electrons for a CCD, DN for the vidicon).

  • stars_params – Per-star parameter dictionaries.

  • offset_v – V offset (oversampled units) applied to every star.

  • offset_u – U offset (oversampled units) applied to every star.

  • zero_point – Flux a magnitude-0 star deposits per second.

  • exposure_sec – The scene exposure in seconds.

  • rendered_sigma – The scene PSF core sigma the stars render at (oversampled units), recorded in the hit-test metadata.

  • rotation_deg – Camera-roll angle (degrees) applied about the image centre before the translation offset, modelling a pointing rotation the star techniques recover.

  • oversample – The render-grid oversampling factor.

  • catalog_scatter_px – Scene-level per-star position-scatter sigma (oversampled units); 0 disables it.

  • catalog_scatter_seed – Seed for the per-star scatter stream.

total_flux_for_vmag(vmag: float, *, zero_point: float, exposure_sec: float) float[source]

Return a star’s deposited total flux for its magnitude and exposure.

Parameters:
  • vmag – The star’s catalog magnitude.

  • zero_point – Flux a magnitude-0 star deposits per second (electrons for a CCD, DN for the vidicon).

  • exposure_sec – The scene exposure in seconds.

Returns:

The total flux to deposit as a point mass (before the os**2 weight).

Image-side optics stage: what the camera’s optical path does to the scene.

The optics stage runs on the oversampled radiance image, in a fixed internal order chosen to mirror image formation:

  1. Smear averages the scene radiance over the exposure along the pointing drift (whole-scene, or per object class for differential smear). It runs first, while the per-class layers are still separable, so the optics below form the image of the time-averaged radiance.

  2. Distortion warps the geometric image: the residual field-position error the navigator does not correct maps where each point of the scene lands.

  3. PSF blurs the mapped image by the aperture’s core-plus-wing kernel; the limb, ring-edge, and star profiles all inherit it.

  4. Ghosts add displaced, defocused, low-amplitude copies of the formed focal-plane image (internal reflections).

  5. Stray light adds the smooth scattered-light background last.

A stage whose scene block is absent contributes nothing. Only the distortion non-radial field draws randomness, and it derives its own seeded stream from the scene seed, so the optics stage does not consume the pipeline generator.

apply_optics(frame: SimFrame, *, params: Mapping[str, Any], rng: Generator) None[source]

Optics stage: apply the scene’s optical-path effects in place.

Runs the smear, distortion, PSF, ghost, and stray-light sub-stages in the fixed internal order documented at the module level. A sub-stage whose block is absent from the scene optics mapping contributes nothing.

Parameters:
  • frame – The frame whose signal and point-source planes are modified.

  • params – The full scene mapping; reads the optics block.

  • rng – The stage generator (used by the seeded distortion field).

apply_stray_light(img: NDArray[floating[Any]], *, amplitude: float, direction_deg: float = 0.0, model: str = 'linear', center_v: float | None = None, center_u: float | None = None) None[source]

Add a smooth low-frequency stray-light field to the signal in place.

Scattered light raises a slowly-varying background across the frame; the navigator’s BANDPASS_DOG source-image filter is meant to remove it. The field is additive, so it brightens dark sky as well as lit features (a multiplicative field would leave the dark sky – where the gradient most needs suppressing – untouched). It is applied to the noise-free signal in [0, 1] before the detector stage.

Parameters:
  • img – Normalized [0, 1] signal image, modified in place.

  • amplitude – Peak stray-light level added, in normalized signal units.

  • direction_deg – Ramp direction for the ‘linear’ model, in degrees.

  • model – ‘linear’ (a ramp spanning [0, amplitude]) or ‘radial’ (a bump of height amplitude fading to 0 at the farthest corner).

  • center_v – Bump centre v for ‘radial’; frame centre when None.

  • center_u – Bump centre u for ‘radial’; frame centre when None.

Raises:

ValueError – If model is not ‘linear’ or ‘radial’.

effective_psf(params: Mapping[str, Any]) dict[str, Any] | None[source]

The PSF block to apply: an explicit optics.psf, else the catalog kernel.

An explicit optics.psf block wins; the authored navigator-matched form ({match_navigator: true}) resolves here to the navigator’s own Gaussian at the emulated instrument’s configured star_psf_sigma. Otherwise instrument_defaults supplies the instrument’s empirical kernel from the catalog. Absent both, there is no PSF (the stage-activation floor).

Parameters:

params – The full scene mapping.

Returns:

The resolved PSF parameter mapping, or None when no PSF is active.

instrument_defaults_on(params: Mapping[str, Any]) bool[source]

Whether the scene opts into the instrument’s physical signal chain.

Motion smear for the optics stage: exposure-time averaging of the scene.

During an exposure the scene drifts across the detector, so the recorded frame is the average of the radiance over the drift track. A single object_class: all entry smears the whole scene by one motion vector; several entries give differential smear, where each object class (stars, bodies, rings) carries its own vector – the fast-flyby regime where a tracked target stays sharp while the star field trails, or vice versa.

Differential smear composites the per-class radiance layers the radiance stage records. The body and ring layers are intensive-signal layers (they sum back into frame.signal); the star layer is a point-source layer in the electron / DN domain (it sums back into frame.point_e), because stars never pass through the detector’s intensive conversion. Cross-class occlusion is resolved before smear (each class is smeared in isolation, then the classes are summed), which is a stated approximation where two classes overlap; at present fidelity rings sit behind bodies and neither overlaps the star field, so the approximation is exact for the scenes it serves. Smear runs first among the optics sub-stages, on the un-blurred layers, so the downstream PSF and distortion form the image of the time-averaged radiance.

apply_smear(frame: SimFrame, *, smear: Sequence[Mapping[str, Any]], oversample: int) None[source]

Apply whole-scene or differential motion smear in place.

Parameters:
  • frame – The frame whose signal (and, for whole-scene smear, point-source) plane is smeared in place.

  • smear – The scene optics.smear list of per-class motion entries.

  • oversample – The render-grid oversampling factor (motion vectors are in detector pixels and scale to the render grid).

smear_kernel(dv_px: float, du_px: float) NDArray[floating[Any]] | None[source]

Build a normalized line-segment motion-blur kernel.

The kernel is the drift track from -(dv, du)/2 to +(dv, du)/2 about the centre, sampled and bilinearly splatted so the smear is centred (the centroid does not move).

Parameters:
  • dv_px – Total drift along v in pixels of the render grid.

  • du_px – Total drift along u in pixels of the render grid.

Returns:

A normalized 2-D kernel, or None when the drift is negligible.

Residual geometric distortion for the optics stage.

The navigator corrects each camera’s known distortion model, so the quantity actually present in the frames the pipeline consumes – and the only quantity this stage plants – is the residual: a low-order radial polynomial about the optical centre plus an optional small non-radial wander. A limb fitted at the frame edge then disagrees with a ring fitted through the centre by the differential residual between their positions, which the navigator gets no model to remove.

The warp maps each output pixel p to a source position center + (p - center) * (1 + k1*rho^2 + k2*rho^4) with rho = |p - center| / rho_ref and rho_ref half the image diagonal; the scene is resampled there by cubic interpolation. The non-radial term adds a seeded, smooth 2-D displacement field (its own RNG stream) at the commanded RMS amplitude.

apply_distortion(frame: SimFrame, *, params: Mapping[str, Any], oversample: int, distortion: Mapping[str, Any] | None = None) None[source]

Warp the signal and point-source planes by the residual distortion.

A disabled block (all-zero radial coefficients and no non-radial wander) is a true no-op, so a scene that names a distortion block without amplitude renders bit-identically to one without it.

Parameters:
  • frame – The frame whose planes are warped in place.

  • params – The full scene mapping; supplies the scene random_seed for the non-radial field’s stream and, when distortion is not passed, the optics.distortion block.

  • oversample – The render-grid oversampling factor (centre and amplitude are in detector pixels and scale to the render grid).

  • distortion – An explicit distortion block; when None the block is read from params['optics']['distortion'] (the instrument-defaults residual is passed here explicitly).

Whole-scene point-spread function for the optics stage.

The camera’s PSF blurs the entire composed radiance image, so the limb gradient, the ring-edge gradient, and every star shape inherit one profile. The kernel is a core Gaussian plus a Moffat wing:

K(r) = (1 - w) * G_norm(r; sigma_v, sigma_u) + w * M_norm(r; r0, n)

Each term is separately normalized to unit sum over the truncation window, so w is exactly the fraction of the kernel’s energy in the wing and the whole kernel conserves flux. The Gaussian core is elliptical (sigma_v may differ from sigma_u); the Moffat wing is isotropic. All radii are in detector pixels; the kernel is sampled on the oversampled render grid, and the whole convolution runs there before the box downsample.

apply_psf(signal: NDArray[floating[Any]], point_e: NDArray[floating[Any]], *, sigma_v: float, sigma_u: float, w: float, r0: float, n: float, truncation_px: int, oversample: int) None[source]

Convolve the signal and point-source planes with the PSF kernel in place.

Both planes share every optical transform, so the same kernel blurs the intensive signal (bodies, rings, sky) and the point-source electrons. The convolution is an FFT convolution (deterministic) at mode='same'.

Parameters:
  • signal – The oversampled intensive-signal plane, modified in place.

  • point_e – The oversampled point-source plane, modified in place.

  • sigma_v – Gaussian core sigma along v, in detector pixels.

  • sigma_u – Gaussian core sigma along u, in detector pixels.

  • w – Wing energy fraction in [0, 1].

  • r0 – Moffat core radius in detector pixels.

  • n – Moffat index.

  • truncation_px – Kernel half-width in detector pixels.

  • oversample – Oversampling factor of the render grid.

psf_kernel(sigma_v: float, sigma_u: float, w: float, r0: float, n: float, *, truncation_px: int, oversample: int) NDArray[floating[Any]][source]

Build the core-plus-wing PSF kernel on the oversampled grid.

Parameters:
  • sigma_v – Gaussian core sigma along v, in detector pixels.

  • sigma_u – Gaussian core sigma along u, in detector pixels.

  • w – Wing energy fraction in [0, 1] (the Moffat term’s total weight).

  • r0 – Moffat core radius in detector pixels.

  • n – Moffat index.

  • truncation_px – Kernel half-width in detector pixels.

  • oversample – Oversampling factor of the render grid.

Returns:

A (2*truncation_px*os + 1) square kernel summing to 1.0.

psf_truncation_for_instrument(instrument: str | None) int[source]

Return the PSF truncation radius (detector px) for an instrument.

Parameters:

instrument – The sim instrument name, or None for the generic block.

Returns:

32 for the Cassini ISS cameras (documented long wings), else 16.

Ghost reflections for the optics stage.

A ghost is a faint internal reflection of the focal-plane image: a displaced, defocused, low-amplitude copy of the scene added back onto it. A bright star casts a ghost the same way an extended source does, so each ghost reflects both the intensive signal (bodies, rings, sky) and the point-source plane (stars). Each ghost copies the pre-ghost planes (so ghosts do not reflect one another), shifts them by its offset, blurs them by its defocus, scales them by its amplitude, and adds them back in.

apply_ghosts(frame: SimFrame, *, ghosts: Sequence[Mapping[str, Any]], oversample: int) None[source]

Add displaced, defocused, scaled copies of the scene in place.

Parameters:
  • frame – The frame whose signal and point-source planes receive the ghosts.

  • ghosts – The scene optics.ghosts list of ghost specifications.

  • oversample – The render-grid oversampling factor (offsets and defocus are in detector pixels and scale to the render grid).

The detector stage of the forward model (electron chain, vidicon, calibration).

Re-exports the pipeline entry point and the tested primitives; the implementation is split across params (resolved parameters), chain (the unit chain, quantization, and orchestration), and noise_stages (the stochastic and structured sub-effects).

class DetectorParams(detector_model: str, data_units: str, signal_full_scale_frac: float, full_well_e: float, exposure_ref_sec: float, exposure_sec: float, gain_e_per_dn: float, read_noise_e: float, bias_dn: float, saturation_dn: float, full_well_dn: float, quantization: str, poisson: bool, bloom_length: int, cosmic_ray_rate_per_sec: float, pixel_area_cm2: float, dark_current_e_per_sec: float, hot_pixel_fraction: float, hot_pixel_amplitude_e: float, hot_pixel_column_factor: float, banding_amplitude_e: float, banding_period_px: float, bias_pedestal_sigma_dn: float, bias_row_gradient_dn: float, bias_col_gradient_dn: float, vidicon: dict[str, float], calibration_scale_dn_per_s_per_if: float, dark_dn: float, random_seed: int = 42, instrument_defaults: bool = False, hot_pixel_adversarial: bool = False, hot_pixel_mode_active: bool = False, quantization_contour_step: int = 8, artifacts_adversarial: bool = False, detector_modes: dict[str, dict[str, ~typing.Any]] = <factory>)[source]

Bases: object

The flat, resolved detector view for one render.

Parameters:
  • detector_model – ‘ccd’ (electron chain) or ‘vidicon’ (DN chain).

  • data_units – ‘raw_dn’ or ‘calibrated_if’.

  • signal_full_scale_frac – Well fraction a signal of 1.0 fills at the reference exposure.

  • full_well_e – Full well in electrons (CCD path).

  • exposure_ref_sec – Exposure the well fraction references.

  • exposure_sec – The scene exposure.

  • gain_e_per_dn – Resolved gain (electrons per DN) for the selected state.

  • read_noise_e – Read-noise sigma in electrons (CCD path).

  • bias_dn – Additive DN bias pedestal.

  • saturation_dn – ADC clip ceiling in DN.

  • full_well_dn – Published ADC-referenced well (vidicon DN full scale).

  • quantization – ADC quantization sub-mode.

  • poisson – Whether shot noise is applied.

  • bloom_length – Electron-domain full-well bloom half-length (0 disables).

  • cosmic_ray_rate_per_sec – Cosmic-ray fluence (events / cm^2 / sec).

  • pixel_area_cm2 – Detector pixel area (scales the cosmic-ray count).

  • dark_current_e_per_sec – Dark current (electrons / sec); 0 disables.

  • hot_pixel_fraction – Fraction of pixels that are hot; 0 disables.

  • hot_pixel_amplitude_e – Hot-pixel amplitude scale in electrons.

  • hot_pixel_column_factor – Warm-column fraction bled from a hot pixel.

  • banding_amplitude_e – Coherent-banding amplitude in electrons; 0 disables.

  • banding_period_px – Coherent-banding spatial period in pixels.

  • bias_pedestal_sigma_dn – Per-image bias-pedestal jitter (DN); 0 disables.

  • bias_row_gradient_dn – Low-order row bias gradient span (DN).

  • bias_col_gradient_dn – Low-order column bias gradient span (DN).

  • vidicon – The vidicon DN-noise sub-parameters (vidicon path only).

  • calibration_scale_dn_per_s_per_if – Derived I/F calibration scale.

  • dark_dn – Dark pedestal in DN subtracted before the I/F divide.

  • random_seed – The scene seed for the per-effect sub-streams.

  • instrument_defaults – Whether the physical-chain opt-in is on.

  • hot_pixel_adversarial – Whether the hot-pixel population is placed adversarially (biased onto the navigation features) rather than uniformly.

  • hot_pixel_mode_active – Whether the hot_pixels artifact mode drove the hot-pixel knobs (in which case the chain records the realized population in the frame truth), as opposed to the generic noise-block / instrument_defaults path.

  • quantization_contour_step – The DN posterization step for the contour_8bit quantization sub-mode.

  • artifacts_adversarial – Whether stochastic detector artifact modes place their events adversarially onto the navigation features.

  • detector_modes – The resolved config of every active detector-stage artifact mode (registry defaults filled from the catalog), keyed by mode name; the chain applies each at its physical point and records it. Routed modes (bias, bloom, quantization) also appear here for the truth record, their override already folded into the flat fields.

artifacts_adversarial: bool = False
banding_amplitude_e: float
banding_period_px: float
bias_col_gradient_dn: float
bias_dn: float
bias_pedestal_sigma_dn: float
bias_row_gradient_dn: float
bloom_length: int
calibration_scale_dn_per_s_per_if: float
cosmic_ray_rate_per_sec: float
dark_current_e_per_sec: float
dark_dn: float
data_units: str
detector_model: str
detector_modes: dict[str, dict[str, Any]]
exposure_ref_sec: float
exposure_sec: float
full_well_dn: float
full_well_e: float
gain_e_per_dn: float
hot_pixel_adversarial: bool = False
hot_pixel_amplitude_e: float
hot_pixel_column_factor: float
hot_pixel_fraction: float
hot_pixel_mode_active: bool = False
instrument_defaults: bool = False
pixel_area_cm2: float
poisson: bool
quantization: str
quantization_contour_step: int = 8
random_seed: int = 42
read_noise_e: float
saturation_dn: float
signal_full_scale_frac: float
vidicon: dict[str, float]
apply_detector(frame: SimFrame, *, params: Mapping[str, Any], rng: Generator) None[source]

Detector stage: convert the composed signal to detector counts in place.

All signal is composed before this stage runs, so the shot term sees the noise-free signal it should grow with. Feature masks in frame.truth were derived from the noise-free signal and are unaffected.

Parameters:
  • frame – The frame whose signal plane is converted in place.

  • params – The full scene mapping; resolved into detector parameters via spindoctor.sim.forward.detector.params.resolve_detector_params().

  • rng – The stage generator, used for the shot and read-noise streams; the structured sub-effects derive their own named streams from the scene seed.

apply_saturation(electrons: NDArray[floating[Any]], *, full_well_e: float, bloom_length: int = 0) None[source]

Cap the electron image at the full well, optionally blooming along columns.

Charge above full_well_e spills along the column (the v axis) up to bloom_length pixels each way, conserving the total excess, before every pixel is capped at the full well. A saturated star therefore blooms into a vertical streak the way it does on cameras with column bleed, and the capped pixels read full_well_e / gain DN after conversion (below the ADC clip on an antiblooming-free camera such as Cassini’s NAC).

Parameters:
  • electrons – The electron image, modified in place.

  • full_well_e – The full-well ceiling in electrons.

  • bloom_length – Column-bloom half-length in pixels; 0 disables bloom.

quantize_dn(dn: NDArray[floating[Any]], *, mode: str, saturation_dn: float, contour_step: int = 8) NDArray[floating[Any]][source]

Quantize a DN image by the selected ADC sub-mode.

Parameters:
  • dn – The DN image (float).

  • mode – ‘exact’ (round to integer, uniform bins), ‘8bit’ (integer bins with a hard 255 code ceiling), ‘uneven_12bit’ (integer bins with histogram spikes at the power-of-two bit boundaries), ‘sqrt_lut’ (square-root companding to 8 bits and back, leaving a signal-dependent residual), ‘ls8b’ (low 8 bits kept: values above 255 wrap modulo 256, the banded wraparound on bright targets), or ‘contour_8bit’ (8-bit output posterized to multiples of contour_step, the uneven-bin contouring of a coarse ADC).

  • saturation_dn – The ADC ceiling, used to scale the companding LUT.

  • contour_step – The DN posterization step for the ‘contour_8bit’ mode.

Returns:

The quantized DN image.

Raises:

ValueError – If mode is not a known quantization sub-mode.

resolve_detector_params(params: Mapping[str, Any]) DetectorParams[source]

Collapse the scene, config, and catalog into a resolved detector view.

Parameters:

params – The full scene sim_params mapping.

Returns:

The resolved DetectorParams.

Raises:

DetectorParamError – If the scene selects an unavailable gain state.

Resolved detector parameters for one scene render.

DetectorParams collapses the emulated instrument’s config block, the per-instrument catalog defaults (spindoctor.sim.forward.artifacts_catalog), the scene detector / noise blocks, and the artifacts.instrument_defaults switch into one flat, resolved view the detector stage reads.

Resolution precedence, highest first: an explicit scene key (detector block, then noise block), then the catalog value when instrument_defaults is on, then the disabled floor (physical-chain artifacts default to zero so an unconfigured scene renders a clean DN frame, per the stage-activation rule). instrument_defaults turns on the whole physical chain, including Poisson shot noise, the catalog’s electron-domain full-well bloom, and the catalog’s cohort-measured cosmic-ray rate where one is recorded (radiation on the detector is physics of the environment, not a transmission defect); the missing-data loss modes are artifact incidences, not physical-chain noise, and stay at zero.

The noise.read_noise_dn key is a DN value, converted to electrons through the resolved gain, so a scene that pins a DN read-noise level gets exactly that DN-level behavior out of the electron chain. signal_full_scale_frac is the well fraction a signal of 1.0 fills at the reference exposure; the image-side DN well is derived (full_well_e / gain_e_per_dn) and the navigator-side full_well_dn config key is a separate published value.

class DetectorParams(detector_model: str, data_units: str, signal_full_scale_frac: float, full_well_e: float, exposure_ref_sec: float, exposure_sec: float, gain_e_per_dn: float, read_noise_e: float, bias_dn: float, saturation_dn: float, full_well_dn: float, quantization: str, poisson: bool, bloom_length: int, cosmic_ray_rate_per_sec: float, pixel_area_cm2: float, dark_current_e_per_sec: float, hot_pixel_fraction: float, hot_pixel_amplitude_e: float, hot_pixel_column_factor: float, banding_amplitude_e: float, banding_period_px: float, bias_pedestal_sigma_dn: float, bias_row_gradient_dn: float, bias_col_gradient_dn: float, vidicon: dict[str, float], calibration_scale_dn_per_s_per_if: float, dark_dn: float, random_seed: int = 42, instrument_defaults: bool = False, hot_pixel_adversarial: bool = False, hot_pixel_mode_active: bool = False, quantization_contour_step: int = 8, artifacts_adversarial: bool = False, detector_modes: dict[str, dict[str, ~typing.Any]] = <factory>)[source]

Bases: object

The flat, resolved detector view for one render.

Parameters:
  • detector_model – ‘ccd’ (electron chain) or ‘vidicon’ (DN chain).

  • data_units – ‘raw_dn’ or ‘calibrated_if’.

  • signal_full_scale_frac – Well fraction a signal of 1.0 fills at the reference exposure.

  • full_well_e – Full well in electrons (CCD path).

  • exposure_ref_sec – Exposure the well fraction references.

  • exposure_sec – The scene exposure.

  • gain_e_per_dn – Resolved gain (electrons per DN) for the selected state.

  • read_noise_e – Read-noise sigma in electrons (CCD path).

  • bias_dn – Additive DN bias pedestal.

  • saturation_dn – ADC clip ceiling in DN.

  • full_well_dn – Published ADC-referenced well (vidicon DN full scale).

  • quantization – ADC quantization sub-mode.

  • poisson – Whether shot noise is applied.

  • bloom_length – Electron-domain full-well bloom half-length (0 disables).

  • cosmic_ray_rate_per_sec – Cosmic-ray fluence (events / cm^2 / sec).

  • pixel_area_cm2 – Detector pixel area (scales the cosmic-ray count).

  • dark_current_e_per_sec – Dark current (electrons / sec); 0 disables.

  • hot_pixel_fraction – Fraction of pixels that are hot; 0 disables.

  • hot_pixel_amplitude_e – Hot-pixel amplitude scale in electrons.

  • hot_pixel_column_factor – Warm-column fraction bled from a hot pixel.

  • banding_amplitude_e – Coherent-banding amplitude in electrons; 0 disables.

  • banding_period_px – Coherent-banding spatial period in pixels.

  • bias_pedestal_sigma_dn – Per-image bias-pedestal jitter (DN); 0 disables.

  • bias_row_gradient_dn – Low-order row bias gradient span (DN).

  • bias_col_gradient_dn – Low-order column bias gradient span (DN).

  • vidicon – The vidicon DN-noise sub-parameters (vidicon path only).

  • calibration_scale_dn_per_s_per_if – Derived I/F calibration scale.

  • dark_dn – Dark pedestal in DN subtracted before the I/F divide.

  • random_seed – The scene seed for the per-effect sub-streams.

  • instrument_defaults – Whether the physical-chain opt-in is on.

  • hot_pixel_adversarial – Whether the hot-pixel population is placed adversarially (biased onto the navigation features) rather than uniformly.

  • hot_pixel_mode_active – Whether the hot_pixels artifact mode drove the hot-pixel knobs (in which case the chain records the realized population in the frame truth), as opposed to the generic noise-block / instrument_defaults path.

  • quantization_contour_step – The DN posterization step for the contour_8bit quantization sub-mode.

  • artifacts_adversarial – Whether stochastic detector artifact modes place their events adversarially onto the navigation features.

  • detector_modes – The resolved config of every active detector-stage artifact mode (registry defaults filled from the catalog), keyed by mode name; the chain applies each at its physical point and records it. Routed modes (bias, bloom, quantization) also appear here for the truth record, their override already folded into the flat fields.

artifacts_adversarial: bool = False
banding_amplitude_e: float
banding_period_px: float
bias_col_gradient_dn: float
bias_dn: float
bias_pedestal_sigma_dn: float
bias_row_gradient_dn: float
bloom_length: int
calibration_scale_dn_per_s_per_if: float
cosmic_ray_rate_per_sec: float
dark_current_e_per_sec: float
dark_dn: float
data_units: str
detector_model: str
detector_modes: dict[str, dict[str, Any]]
exposure_ref_sec: float
exposure_sec: float
full_well_dn: float
full_well_e: float
gain_e_per_dn: float
hot_pixel_adversarial: bool = False
hot_pixel_amplitude_e: float
hot_pixel_column_factor: float
hot_pixel_fraction: float
hot_pixel_mode_active: bool = False
instrument_defaults: bool = False
pixel_area_cm2: float
poisson: bool
quantization: str
quantization_contour_step: int = 8
random_seed: int = 42
read_noise_e: float
saturation_dn: float
signal_full_scale_frac: float
vidicon: dict[str, float]
resolve_detector_params(params: Mapping[str, Any]) DetectorParams[source]

Collapse the scene, config, and catalog into a resolved detector view.

Parameters:

params – The full scene sim_params mapping.

Returns:

The resolved DetectorParams.

Raises:

DetectorParamError – If the scene selects an unavailable gain state.

The detector stage: composed signal to digitized detector counts.

This is the normative unit chain of the forward model. For a CCD the composed intensive signal is converted to electrons through the exposure, the point-source electron plane is added, and the frame passes through Poisson shot noise, electron-domain full-well bloom, read noise, coherent banding, gain to DN, bias structure, quantization, and the ADC clip. A camera’s physical saturation therefore emerges from full_well_e / gain_e_per_dn (below the ADC ceiling for Cassini), not from the ADC clip.

The Voyager vidicon skips the electron conversion and applies its noise directly in DN (line-correlated read noise plus a faint coherent component); its point sources (stars) are already DN and are added onto the converted signal before that DN noise. A calibrated (I/F) scene renders through the full DN chain and then inverts the calibration transform, so calibrated products carry propagated shot/read noise and quantization texture in I/F units.

The deterministic conversion (signal to DN) always runs – it is what makes a DN frame – while the stochastic and structured sub-effects (shot noise, read noise, cosmic rays, dark/hot pixels, banding, bias structure) activate only when their scene block or instrument_defaults requests them, so a scene with no such block renders a clean DN frame (the self-consistency floor).

apply_detector(frame: SimFrame, *, params: Mapping[str, Any], rng: Generator) None[source]

Detector stage: convert the composed signal to detector counts in place.

All signal is composed before this stage runs, so the shot term sees the noise-free signal it should grow with. Feature masks in frame.truth were derived from the noise-free signal and are unaffected.

Parameters:
  • frame – The frame whose signal plane is converted in place.

  • params – The full scene mapping; resolved into detector parameters via spindoctor.sim.forward.detector.params.resolve_detector_params().

  • rng – The stage generator, used for the shot and read-noise streams; the structured sub-effects derive their own named streams from the scene seed.

apply_saturation(electrons: NDArray[floating[Any]], *, full_well_e: float, bloom_length: int = 0) None[source]

Cap the electron image at the full well, optionally blooming along columns.

Charge above full_well_e spills along the column (the v axis) up to bloom_length pixels each way, conserving the total excess, before every pixel is capped at the full well. A saturated star therefore blooms into a vertical streak the way it does on cameras with column bleed, and the capped pixels read full_well_e / gain DN after conversion (below the ADC clip on an antiblooming-free camera such as Cassini’s NAC).

Parameters:
  • electrons – The electron image, modified in place.

  • full_well_e – The full-well ceiling in electrons.

  • bloom_length – Column-bloom half-length in pixels; 0 disables bloom.

quantize_dn(dn: NDArray[floating[Any]], *, mode: str, saturation_dn: float, contour_step: int = 8) NDArray[floating[Any]][source]

Quantize a DN image by the selected ADC sub-mode.

Parameters:
  • dn – The DN image (float).

  • mode – ‘exact’ (round to integer, uniform bins), ‘8bit’ (integer bins with a hard 255 code ceiling), ‘uneven_12bit’ (integer bins with histogram spikes at the power-of-two bit boundaries), ‘sqrt_lut’ (square-root companding to 8 bits and back, leaving a signal-dependent residual), ‘ls8b’ (low 8 bits kept: values above 255 wrap modulo 256, the banded wraparound on bright targets), or ‘contour_8bit’ (8-bit output posterized to multiples of contour_step, the uneven-bin contouring of a coarse ADC).

  • saturation_dn – The ADC ceiling, used to scale the companding LUT.

  • contour_step – The DN posterization step for the ‘contour_8bit’ mode.

Returns:

The quantized DN image.

Raises:

ValueError – If mode is not a known quantization sub-mode.

Stochastic and structured detector-noise stages (generic mechanics).

Each function here is a self-contained detector sub-effect: dark current and hot pixels, coherent horizontal banding, low-order bias structure, and the morphological cosmic-ray model. The chain orchestrator (spindoctor.sim.forward.detector.chain) draws each one an independent RNG via derive_effect_seed(random_seed, 'detector/<effect>') so toggling one never perturbs another’s realization, and each stage is a no-op when its gating amplitude/fraction/rate is zero (the stage-activation rule).

Dark current, hot pixels, banding, and cosmic rays act in the electron domain; bias structure acts in the DN domain (the amplifier pedestal and the read-out row/column gradients ride on the digitized signal). Per-instrument parameterizations come with the catalog defaults; these are the generic shapes.

add_banding(electrons: NDArray[floating[Any]], *, amplitude_e: float, period_px: float, rng: Generator, freq_step_factor: float = 1.0) None[source]

Add horizontal coherent + random-phase banding (electrons) in place.

The banding is line-correlated (constant along a row, varying with row index): a coherent sinusoid at period_px with a per-seed random phase, plus a smaller per-row random-phase component. When freq_step_factor differs from 1, the sinusoid’s period changes across the image mid-line, the readout-pause frequency step some cameras show. A zero amplitude or non-positive period is a no-op.

Parameters:
  • electrons – The electron image, modified in place.

  • amplitude_e – Coherent-banding amplitude in electrons.

  • period_px – Sinusoid spatial period along the row axis, in pixels.

  • rng – The stage’s seeded generator.

  • freq_step_factor – Period multiplier below the image mid-line (1 = none).

add_bias_structure(dn: NDArray[floating[Any]], *, pedestal_sigma_dn: float, row_gradient_dn: float, col_gradient_dn: float, rng: Generator) None[source]

Add a bias pedestal offset and low-order bias gradients (DN) in place.

A per-image pedestal offset (a single seeded draw) rides on the flat bias level, and shallow row and column gradients model the read-out bias structure. A no-op when every amplitude is zero.

Parameters:
  • dn – The DN image, modified in place.

  • pedestal_sigma_dn – Standard deviation of the per-image pedestal (DN).

  • row_gradient_dn – Peak-to-peak row (v-axis) bias gradient (DN).

  • col_gradient_dn – Peak-to-peak column (u-axis) bias gradient (DN).

  • rng – The stage’s seeded generator.

add_cosmic_rays(electrons: NDArray[floating[Any]], *, rate_per_sec: float, exposure_sec: float, pixel_area_cm2: float, amplitude_e: float, rng: Generator) None[source]

Deposit morphological cosmic-ray events (electrons) in place.

Events are point hits (the common case, a degenerate single pixel), short streaks at a random angle whose length is drawn from an incidence-angle distribution, and rare multi-pixel splatters. Each deposits a charge well above the full well so the digitized frame clips at the ADC ceiling and the orchestrator’s cosmic-ray/saturation masks catch it. The event count scales with the exposure. A zero rate is a no-op.

Parameters:
  • electrons – The electron image, modified in place.

  • rate_per_sec – Cosmic-ray fluence in events / cm^2 / sec.

  • exposure_sec – Exposure time in seconds.

  • pixel_area_cm2 – Detector pixel area in cm^2.

  • amplitude_e – Charge-deposit scale in electrons (per pixel of an event).

  • rng – The stage’s seeded generator.

add_dark_current(electrons: NDArray[floating[Any]], *, rate_e_per_sec: float, exposure_sec: float) None[source]

Add a uniform dark-current pedestal (electrons) in place, pre-Poisson.

The dark signal accumulates over the exposure. Because the chain adds it before the Poisson stage, it carries its own shot noise whenever that stage is on (as it is under instrument_defaults); with Poisson explicitly disabled it is a noise-free pedestal. A zero rate is a no-op.

Parameters:
  • electrons – The electron image, modified in place.

  • rate_e_per_sec – Dark current in electrons per second.

  • exposure_sec – Exposure time in seconds.

add_hot_pixels(electrons: NDArray[floating[Any]], *, fraction: float, amplitude_e: float, column_factor: float, rng: Generator, candidate_pool: tuple[NDArray[integer[Any]], NDArray[integer[Any]]] | None = None) dict[str, Any][source]

Add a fixed per-seed hot-pixel population (electrons) in place.

A hot pixel holds a large fixed charge; on CCDs read through it, a fraction of that charge contaminates the column above it (a warm streak). The population is drawn from the stage’s own seeded stream, so it is the same set of pixels every render of the scene. A zero fraction or amplitude is a no-op.

Parameters:
  • electrons – The electron image, modified in place.

  • fraction – Fraction of pixels that are hot.

  • amplitude_e – Hot-pixel amplitude scale in electrons (exponentially distributed about this scale, so a few are very hot).

  • column_factor – Fraction of a hot pixel’s TOTAL charge bled up its column: the warm streak’s integral is column_factor times the hot pixel’s charge, independent of the frame height.

  • rng – The stage’s seeded generator.

  • candidate_pool – Optional (v, u) coordinate pool the population is drawn from (adversarial placement onto the navigation features). When None or empty, the placement is uniform over the frame.

Returns:

the planted pixels as [v, u] pairs and their amplitudes_e, empty on a no-op.

Return type:

The realized population for the truth record

deposit_morphological_events(electrons: NDArray[floating[Any]], *, n_events: int, amplitude_e: float, rng: Generator, amplitude_dist: str = 'lognormal') None[source]

Deposit a fixed count of morphological charge events (electrons) in place.

The event-type mix is the same for cosmic rays and for the Galileo radiation regime – mostly single-pixel point hits, some grazing streaks, a few multi-pixel splatters – but the amplitude distribution differs: cosmic-ray events are lognormal about the deposit scale, while the radiation regime’s amplitudes fall steeply from a few DN (an exponential draw). A zero count or amplitude is a no-op.

Parameters:
  • electrons – The electron image, modified in place.

  • n_events – The number of events to deposit.

  • amplitude_e – The charge-deposit scale in electrons.

  • rng – The stage’s seeded generator.

  • amplitude_dist – ‘lognormal’ (cosmic rays) or ‘exponential’ (radiation, steeply-falling amplitudes).

Detector-electronics artifact mechanics (the registry’s detector modes).

Each function here renders one detector/electronics artifact mode from the artifact-mode registry onto a detector-grid plane in place, and (where a placement is stochastic) accepts a seeded generator and an optional adversarial candidate pool. The chain orchestrator (spindoctor.sim.forward.detector.chain) wires each one at the physically right point in the unit chain and records its realized geometry into the frame truth; these functions carry only the mechanics, so each is unit-testable on a synthetic plane and is a no-op when its gating amplitude / count is zero (the stage-activation rule).

Domains follow the physics: fixed-pattern PRNU, vignetting, and dust donuts are multiplicative on the electron plane before Poisson (a per-pixel response, not an added signal); the dark ramp and frame-transfer smear add electrons; the stitch combs, jail bars, and serial tail act in the DN domain after the gain divide (readout-chain and amplifier structure rides on the digitized signal); and the Voyager beam bend and residual image are geometric / pre-noise effects on the scene plane.

add_bright_dark_pairs(electrons: NDArray[floating[Any]], *, count: int, amplitude_e: float, rng: Generator, candidate_pool: tuple[NDArray[integer[Any]], NDArray[integer[Any]]] | None = None) dict[str, Any][source]

Deposit scattered vertical bright/dark pixel pairs (electrons) in place.

The Cassini anti-blooming mode produces isolated vertical two-pixel pairs in unsummed long exposures: one pixel raised by amplitude_e, the pixel below it lowered by the same charge. Placement is uniform, or drawn from candidate_pool for adversarial placement onto the navigation features. A zero count or amplitude is a no-op.

Parameters:
  • electrons – The electron image, modified in place.

  • count – Number of bright/dark pairs to deposit.

  • amplitude_e – Pair amplitude in electrons.

  • rng – The mode’s seeded generator.

  • candidate_pool – Optional (v, u) pool for adversarial placement.

Returns:

The realized pair geometry for the truth record.

add_coherent_banding(electrons: NDArray[floating[Any]], *, amplitude_e: float, period_px: float, orientation: str, freq_step_factor: float, dark_step_dn: float, gain_e_per_dn: float, rng: Generator) dict[str, Any][source]

Add coherent banding (electrons) in place, horizontal and/or vertical.

A horizontal family is line-correlated (constant along a row, a sinusoid in the row index) – the Cassini 2 Hz / LORRI striping shape; a vertical family is column-correlated (a sinusoid in the column index) – the Galileo 42-px supply-noise comb. both lays down a vertical comb plus a horizontal band. A mid-image readout pause steps the horizontal spatial frequency by freq_step_factor and, when dark_step_dn is set, steps the dark level at that line (converted to electrons through gain_e_per_dn). A zero amplitude or non-positive period is a no-op.

Parameters:
  • electrons – The electron image, modified in place.

  • amplitude_e – Coherent-banding amplitude in electrons.

  • period_px – Sinusoid spatial period, in pixels.

  • orientation – ‘horizontal’, ‘vertical’, or ‘both’.

  • freq_step_factor – Horizontal-band period multiplier below the mid-line.

  • dark_step_dn – Dark-level step (DN) applied below the mid-line.

  • gain_e_per_dn – Gain, converting the DN dark step to electrons.

  • rng – The mode’s seeded generator.

Returns:

The realized banding geometry for the truth record.

add_dark_ramp(electrons: NDArray[floating[Any]], *, amplitude_e: float, nonlinear: float, rbi_column_factor: float, hot_columns: NDArray[integer[Any]] | None) dict[str, Any][source]

Add a dark signal growing with line number (readout gradient) in place.

The dark ramp accumulates during the line-by-line readout, so it grows from line 0 to the last line: amplitude_e is the extra dark charge at the last line, and nonlinear bends the growth (an exponent != 1 gives the vidicon’s nonlinear wait-time dependence). rbi_column_factor adds the Cassini residual-bulk-image flavor: columns read out above a hot pixel carry an enhanced ramp, so the listed hot_columns get an extra factor. A zero amplitude is a no-op.

Parameters:
  • electrons – The electron image, modified in place.

  • amplitude_e – Extra dark charge at the last line, in electrons.

  • nonlinear – Ramp exponent (1 = linear growth with line number).

  • rbi_column_factor – Extra ramp fraction on the enhanced columns.

  • hot_columns – Columns carrying the enhanced RBI ramp, or None.

Returns:

The realized ramp geometry for the truth record.

add_fixed_pattern_dn(dn: NDArray[floating[Any]], *, stitch_period_px: int, stitch_amplitude_dn: float, jail_bar_dn: float, rng: Generator) dict[str, Any][source]

Add the static additive DN fixed pattern in place (combs and jail bars).

The photolithography stitch comb is a set of bright columns every stitch_period_px pixels raised by stitch_amplitude_dn; the jail bars are an even/odd column offset of jail_bar_dn whose sign is drawn once per seed (a power-cycle-dependent bias). All-zero parameters are a no-op.

Parameters:
  • dn – The DN image, modified in place.

  • stitch_period_px – Column period of the stitch comb (0 disables).

  • stitch_amplitude_dn – Stitch-comb column amplitude (DN).

  • jail_bar_dn – Even/odd column offset amplitude (DN).

  • rng – The mode’s seeded generator.

Returns:

The realized pattern summary for the truth record.

add_fixed_pattern_response(electrons: NDArray[floating[Any]], *, prnu_rms: float, vignetting_frac: float, dust_donut_count: int, rng: Generator) dict[str, Any][source]

Multiply the electron plane by the static per-pixel response in place.

The multiplicative fixed pattern is a per-pixel response applied before Poisson: photo-response non-uniformity (a per-pixel gain jitter of prnu_rms), corner vignetting (a radial falloff reaching vignetting_frac at the corners), and dust_donut_count faint ring-shaped shadows. The pattern is drawn from the mode’s seeded stream, so it is the same every render of the scene. All-zero parameters are a no-op.

Parameters:
  • electrons – The electron image, modified in place.

  • prnu_rms – RMS of the per-pixel response jitter.

  • vignetting_frac – Corner response deficit (0 = none).

  • dust_donut_count – Number of dust-donut shadow rings.

  • rng – The mode’s seeded generator.

Returns:

The realized response summary for the truth record.

add_residual_image(signal: NDArray[floating[Any]], *, amplitude: float, prior: str, offset_v: int, offset_u: int) dict[str, Any][source]

Add a faint ghost of a prior frame in place (the erase-cycle residual).

When the light-flood erase cycle is shortened, a faint copy of the prior frame survives into the next. With no prior frame available the current frame stands in for it: self_offset adds amplitude times a copy of the frame shifted by (offset_v, offset_u); flat adds a uniform amplitude times the frame mean. Applied before the detector noise, so the ghost carries the noise the rest of the frame does. A zero amplitude is a no-op.

Parameters:
  • signal – The image plane, modified in place.

  • amplitude – Ghost strength as a fraction of the prior frame.

  • prior – ‘self_offset’ (a displaced copy of this frame) or ‘flat’.

  • offset_v – Row shift of the ghost (self_offset).

  • offset_u – Column shift of the ghost (self_offset).

Returns:

The realized ghost summary for the truth record.

add_serial_tail(dn: NDArray[floating[Any]], *, saturation_dn: float, saturation_frac: float, amplitude_dn: float, length_px: int, direction: str) dict[str, Any][source]

Add a horizontal bright-then-dark serial tail off saturated cores in place.

An antiblooming CCD shows no column bloom; a hard-saturated compact source instead drives an amplifier undershoot along the readout (serial) direction: a short bright overshoot immediately after the source, then a longer dark undershoot. Every pixel at or above saturation_frac of the ADC ceiling seeds a tail of length_px in direction. A zero amplitude or length, or no saturated pixel, is a no-op.

Parameters:
  • dn – The DN image, modified in place.

  • saturation_dn – The ADC ceiling (DN).

  • saturation_frac – Fraction of the ceiling that counts as saturated.

  • amplitude_dn – Peak tail amplitude (DN).

  • length_px – Tail length in pixels.

  • direction – ‘right’ (+u readout) or ‘left’ (-u readout).

Returns:

The realized tail summary for the truth record.

apply_beam_bend(signal: NDArray[floating[Any]], *, amplitude_px: float) dict[str, Any][source]

Warp the image near bright boundaries by a brightness-dependent bias.

A vidicon readout beam deflects toward stored charge, so a bright disc’s limb position shifts by up to a pixel or two, the shift growing with local brightness. This plants that residual geometric error: a smooth vertical displacement field whose amplitude scales with the locally-smoothed brightness and whose direction follows the brightness gradient (toward the brighter side). It is a deliberately simple, tunable model of a real navigation-error source – the amplitude and sign are knobs a later calibration pass fits, not a first-principles beam-physics solution. A zero amplitude is a no-op.

Parameters:
  • signal – The DN image, warped in place.

  • amplitude_px – Peak displacement, in pixels, at full local brightness.

Returns:

The realized bend summary for the truth record.

apply_exposure_shading(electrons: NDArray[floating[Any]], *, top_factor: float, bottom_factor: float) dict[str, Any][source]

Scale the signal by a line-dependent shutter exposure gradient in place.

A focal-plane shutter opens and closes line by line, so the effective exposure varies from top_factor at line 0 to bottom_factor at the last line (the Galileo ~1.5 -> ~1.05 ms shading). Modeled as a multiplicative gradient on the accumulated electrons. Equal factors are a no-op.

Parameters:
  • electrons – The electron image, modified in place.

  • top_factor – Exposure multiplier at line 0.

  • bottom_factor – Exposure multiplier at the last line.

Returns:

The realized gradient for the truth record.

apply_frame_transfer_smear(electrons: NDArray[floating[Any]], *, t_scrub_sec: float, t_transfer_sec: float, exposure_sec: float) dict[str, Any][source]

Add the frame-transfer vertical column pedestal (electrons) in place.

A shutterless frame-transfer CCD keeps integrating while the image shifts through the bright rows during the pre-exposure scrub and the post-exposure transfer, so every column carries a pedestal proportional to its signal integral times (t_scrub + t_transfer) / t_exp. The pedestal differs on the two sides of the column’s flux centroid: rows below the centroid in image coordinates (larger line numbers) receive the scrub share, rows above it receive the transfer share. The scrub and transfer times are independent knobs, so swapping their values swaps the side assignment. A zero transfer time or exposure is a no-op.

The desmear-residual behavior (the ground pipeline’s desmear failing through saturated columns and leaving residual banding) is not modeled here; the planted pedestal is the raw smear.

Parameters:
  • electrons – The electron image, modified in place.

  • t_scrub_sec – Pre-exposure scrub time (seconds).

  • t_transfer_sec – Post-exposure transfer time (seconds).

  • exposure_sec – The scene exposure (seconds).

Returns:

The realized smear summary for the truth record.

Image-side telemetry stage: what transmission loses or mangles.

The telemetry stage runs at the detector grid (after the box downsample), so its loss geometry is not coupled to the oversampling factor: a missing line is one detector line, and a missing block aligns to the detector-row compression grid. It applies its sub-effects in the physical order of transmission:

  1. Lossy DCT compression (compression_dct): the codec blockiness a lossy downlink plants on the transmitted signal before any packet loss, honoring the commanded truth_window carve-out.

  2. Structured data loss: the registry loss modes in STRUCTURED_LOSS_ORDER (commanded frame shapes, then line losses, then block losses, then garble, then per-pixel losses, then the row-0 header), with the commanded truth_window carve-out resolved first and passed to missing_blocks. Each mode is honored only where its registry availability lists the instrument, and its shape parameters resolve through the per-instrument catalog (scene value over catalog default over registry default), like the flanking artifact loops.

  3. Voyager GEOMED archive-processing scars, applied to the already-loss-bearing frame: reseau_scars (reseau-removal smudges on the lattice) then resample_texture (the GEOMED resample warp, blank border, and missing-line interpolation banding).

  4. Missing-data markers: the generic per-pixel noise.missing_data_rate dropout knob (a worst-case stress knob, not an instrument artifact), applied on the raw-DN path only.

Every structured loss mode is opt-in through the artifacts block, disabled at incidence 0, and individually seeded via derive_effect_seed(random_seed, 'telemetry/<mode>') so toggling one never perturbs another. Each applied mode records its realized geometry into frame.truth['artifacts'][mode] for later planted-vs-measured comparison. Adversarial placement (artifacts.adversarial) biases each stochastic mode onto the navigation features; it is uniform otherwise.

apply_telemetry(frame: SimFrame, *, params: Mapping[str, Any], rng: Generator) None[source]

Telemetry stage: apply structured data loss and missing-data markers.

Runs after the detector stage, so loss overwrites readout values the way a downlink dropout erases transmitted pixels.

Parameters:
  • frame – The frame whose signal plane is modified in place; its truth['artifacts'] map receives each applied mode’s realized geometry. Must be at the detector grid (oversample == 1).

  • params – The full scene mapping; reads the artifacts block (per-mode maps plus adversarial) and the noise block’s missing_data_rate.

  • rng – The stage generator, used for the generic missing-data markers; the structured loss modes derive their own per-mode streams from the scene seed.

The structured telemetry loss modes: what a downlink erases or mangles.

Each function here renders one loss mode from the artifact-mode registry onto a detector-grid DN image in place, and returns the realized geometry (which lines, blocks, or pixels it touched) for the frame’s truth record so a later incidence measurement can compare planted against measured. Every mode is a no-op at incidence 0 (the stage-activation rule), draws from its own seeded generator (so toggling one never perturbs another), and honors adversarial placement through the shared feature_loci helpers: its stochastic placement biases onto the navigation features when adversarial is on and is uniform otherwise.

The geometry is exact: a missing line is a whole line, missing blocks align to the compression-block row grid, a spike flips a pixel to a wrong value rather than a marker, and a garbled line carries noise rather than the zero marker. The appliers share one keyword signature so the telemetry stage can dispatch them from the registry’s order list; each uses the arguments its shape needs. The stage passes each applier the emulated instrument’s ADC ceiling (dn_ceiling), so the wrong values a garble or spike writes stay inside the camera’s word depth (255 on an 8-bit camera, 4095 on a 12-bit one).

apply_alternating_lines(signal: NDArray[floating[Any]], cfg: dict[str, Any], *, marker_dn: float, rng: Generator, loci: FeatureLoci, adversarial: bool, **_ignored: Any) dict[str, Any][source]

Blank lines on a periodic grid (the jail-bar alternating-line shape).

Two semantics share the periodic grid, selected by mode: drop blanks every Nth line from the phase (the severe-Huffman entropy dropout, which loses one line per period), while keep blanks every line EXCEPT the Nth (the Galileo HMA / HCA vertical decimation, where only every Nth line carries valid data). The pattern is periodic and deterministic once active, so adversarial placement does not steer it; the incidence only decides whether the pattern fires this frame.

apply_cutout_window(signal: NDArray[floating[Any]], cfg: dict[str, Any], *, marker_dn: float, rng: Generator, **_ignored: Any) dict[str, Any][source]

Confine the scene to a commanded rectangle, hard-zeroing the border.

The Galileo / LORRI windowed-downlink shape: only pixels inside the rect survive, everything else is blanked. A commanded mode: activation is by incidence.

apply_dead_columns(signal: NDArray[floating[Any]], cfg: dict[str, Any], *, rng: Generator, loci: FeatureLoci, adversarial: bool, **_ignored: Any) dict[str, Any][source]

Set a fixed per-seed set of whole columns to a low response.

A count fixes the number of dead columns; otherwise incidence is the Poisson mean. Adversarial placement prefers columns that cross a feature.

apply_dead_pixels(signal: NDArray[floating[Any]], cfg: dict[str, Any], *, rng: Generator, loci: FeatureLoci, adversarial: bool, **_ignored: Any) dict[str, Any][source]

Set a fixed per-seed set of singleton pixels to a low response.

A count fixes the number of dead pixels; otherwise incidence is the Poisson mean. Adversarial placement prefers pixels on or beside a feature.

apply_edited_frame(signal: NDArray[floating[Any]], cfg: dict[str, Any], *, marker_dn: float, rng: Generator, **_ignored: Any) dict[str, Any][source]

Keep only a centred vertical band of each line, or a half-height frame.

An explicit half_frame keeps one half of the frame height (the 2:1 scan modes); otherwise a band width keeps a centred column band and blanks the rest of every line (the Voyager edited modes, whose registry default of 440 px matches the Voyager IM band widths, so a bare incidence renders the band shape). A commanded mode: activation is by incidence.

apply_embedded_header(signal: NDArray[floating[Any]], cfg: dict[str, Any], *, rng: Generator, **_ignored: Any) dict[str, Any][source]

Overwrite the first header_px pixels of row 0 with housekeeping values.

The LORRI embedded header is binary housekeeping in row 0 of every image, never scene data. A commanded mode: activation is by incidence.

apply_line_garble(signal: NDArray[floating[Any]], cfg: dict[str, Any], *, dn_ceiling: float, rng: Generator, loci: FeatureLoci, adversarial: bool, **_ignored: Any) dict[str, Any][source]

Replace lines from a bit-error column onward with garbage (not markers).

A variable-length code cannot resync mid-line, so the remainder of the line carries garbage values rather than the zero marker (Voyager IDC / Galileo Reed-Solomon overflow). The garbage stays inside the instrument’s ADC word (dn_ceiling), so an 8-bit camera never carries a 12-bit garbage value.

apply_missing_blocks(signal: NDArray[floating[Any]], cfg: dict[str, Any], *, marker_dn: float, rng: Generator, loci: FeatureLoci, adversarial: bool, protect: tuple[int, int, int, int] | None = None, **_ignored: Any) dict[str, Any][source]

Zero-fill bands quantized to the compression-block row grid.

Blocks align to the block_lines row grid (8 lines for Galileo ICT slices). With start_mid_line the first row of a block is lost only from a random column to the right, then the remaining rows of the block are whole (the bit-error-mid-line ICT shape). A protect rectangle (the commanded truth window) is left untouched. Adversarial placement chooses blocks whose rows cross a feature.

apply_missing_lines(signal: NDArray[floating[Any]], cfg: dict[str, Any], *, marker_dn: float, rng: Generator, loci: FeatureLoci, adversarial: bool, **_ignored: Any) dict[str, Any][source]

Zero-fill whole image lines: a missing line is a full line.

A contiguous run loses a single band of adjacent lines; otherwise the lost lines are scattered. Adversarial placement chooses lines that cross a feature (a run starts on a feature row; scattered lines are drawn from the feature rows).

apply_partial_lines(signal: NDArray[floating[Any]], cfg: dict[str, Any], *, marker_dn: float, rng: Generator, loci: FeatureLoci, adversarial: bool, **_ignored: Any) dict[str, Any][source]

Truncate lines from a random column to the end (up to two surviving segments).

With max_surviving_segments >= 2 a line may instead lose a middle segment, leaving two good segments – the Cassini partial-line-segment shape, where one image line spans several telemetry packets.

apply_pixel_spikes(signal: NDArray[floating[Any]], cfg: dict[str, Any], *, dn_ceiling: float, rng: Generator, loci: FeatureLoci, adversarial: bool, **_ignored: Any) dict[str, Any][source]

Flip isolated pixels to wrong values (salt-and-pepper), never zeroing them.

The bitflip amplitude model XORs a power-of-two bit into the integer DN (the Voyager uncompressed-era bit error, which shifts a pixel by a power of 2); the uniform model replaces it with a random DN. Both stay inside the instrument’s ADC word: the flipped bit positions and the uniform draw are bounded by dn_ceiling, so an 8-bit camera never spikes above 255.

apply_truncated_frame(signal: NDArray[floating[Any]], cfg: dict[str, Any], *, marker_dn: float, rng: Generator, **_ignored: Any) dict[str, Any][source]

Cut a clean full-width band of lines from the bottom or top of the frame.

An explicit lines count wins over fraction; the registry’s fraction default of 0.25 gives a bare incidence the common quarter-frame truncation.

Telemetry-stage artifact modes beyond the structured loss modes.

Two families live here: the lossy DCT compression blockiness a codec plants on the transmitted signal before any packet loss, and the Voyager GEOMED archive-processing scars (reseau-removal smudges and the resample texture) the ground pipeline leaves in the products the navigator consumes. Each renders one registry mode onto a detector-grid DN image in place and returns its realized geometry for the frame’s truth record; each is a no-op when its incidence does not fire (the stage-activation rule) and draws from its own seeded generator.

Order within the telemetry stage is physical: compression runs before the structured loss (a lossy codec compresses, then packets drop), and the GEOMED scars run after it (they emulate archive processing applied to the already-loss-bearing raw frame).

apply_compression_dct(signal: NDArray[floating[Any]], cfg: dict[str, Any], *, rng: Generator, protect: tuple[int, int, int, int] | None) dict[str, Any][source]

Quantize the frame’s 8x8 DCT coefficients, planting blockiness and ringing.

The lossy downlink codec (Galileo ICT, Cassini lossy, LORRI lossy) transforms each block x block tile to the DCT domain, quantizes the coefficients to a step of scale_factor, and inverts – coarser steps give visible block edges and ringing around high-contrast features. Any commanded truth window (protect) is restored clean afterward, the losslessly-coded carve-out. Frame rows/columns past the last whole block are left unchanged. A non-firing incidence is a no-op.

Parameters:
  • signal – The DN image, modified in place.

  • cfg – The resolved mode config (incidence, scale_factor, block).

  • rng – The mode’s seeded generator (for the incidence draw).

  • protect – The commanded truth-window rectangle to leave clean, or None.

Returns:

The realized compression summary for the truth record.

apply_resample_texture(signal: NDArray[floating[Any]], cfg: dict[str, Any], *, rng: Generator) dict[str, Any][source]

Resample the frame with a subpixel warp, emulating GEOMED archive texture.

The GEOMED geometric resample softens noise and edges and correlates the noise spatially the way the archive product’s does. This applies a smooth seeded subpixel displacement field of amplitude warp_amp_px (bilinear resample), optionally blanks an irregular blank_border_px border, and optionally replaces alternate lines by the mean of their neighbors (missing_line_interp, the interpolate-across-lines banding). A non-firing incidence is a no-op.

Parameters:
  • signal – The DN image, modified in place.

  • cfg – The resolved mode config (incidence, warp_amp_px, blank_border_px, missing_line_interp).

  • rng – The mode’s seeded generator.

Returns:

The realized resample summary for the truth record.

apply_reseau_scars(signal: NDArray[floating[Any]], cfg: dict[str, Any], *, rng: Generator) dict[str, Any][source]

Smooth small patches on the reseau lattice, the reseau-removal scars.

The archive removes each reseau mark by interpolating over it, leaving an anomalously smooth patch on the triangular lattice. This blends the frame toward its locally-smoothed version inside a patch_radius_px disc at each lattice point, so a mark that sat on a limb or ring edge shows a smooth patch there. A non-firing incidence is a no-op.

Parameters:
  • signal – The DN image, modified in place.

  • cfg – The resolved mode config (incidence, spacing_px, patch_radius_px).

  • rng – The mode’s seeded generator (for the incidence draw).

Returns:

The realized scar geometry for the truth record.

Artifact-incidence measurement: planted truth vs image-measured counts.

The realism match compares how many defects a scene planted against how many a detector could measure on the rendered frame. This module carries both sides:

  • planted_incidence() reads the realized geometry the telemetry stage wrote into frame.truth['artifacts'] and returns a realized event count per mode (lost lines, truncated lines, lost blocks, dropped or spiked pixels, dead columns, or a commanded on/off). This is ground truth: it is exact by construction, since each applier records exactly what it touched.

  • The measured_* estimators recover the same counts from the DN image alone, the way a detector run against a real archive frame would, with no access to the truth record. They key on the missing-data marker (the DN a lost pixel carries: 0 on the raw-DN path, NaN on the calibrated path), so they are exact for the marker-based structural modes and only those.

Scope of the image estimators. A lost line, a truncated line, a lost block, and a dead pixel each overwrite pixels with the marker value, so a marker-run analysis recovers them exactly on a frame whose scene content is not itself at the marker level (a body or star frame with a non-zero bias floor; the dark-sky floor of an unbiased frame sits at the marker and is indistinguishable from a dropout). Modes that plant wrong values rather than the marker – garble (random DN), pixel spikes (bit-flips), and every detector-electronics mode – are not recoverable pixel-by-pixel from a single frame: distinguishing them from scene structure needs multi-frame or noise statistics, which is out of scope here. For those modes, use planted_incidence() (the truth side) only.

marker_mask(image: NDArray[floating[Any]], marker_value: float) NDArray[bool][source]

Boolean mask of pixels carrying the missing-data marker.

Parameters:
  • image – The DN image.

  • marker_value – The marker DN (0 on the raw-DN path, NaN on the calibrated path); NaN is matched with numpy.isnan().

Returns:

A boolean array, True where the pixel equals the marker.

measured_missing_blocks(image: NDArray[floating[Any]], *, block_lines: int, marker_value: float = 0.0) int[source]

Count compression blocks lost to the marker on the block-row grid.

Blocks align to the block_lines row grid; a lost block zeros every row of its block, so a grid-aligned band of block_lines fully-marker rows is one lost block.

Parameters:
  • image – The DN image.

  • block_lines – The compression block height in rows.

  • marker_value – The missing-data marker DN.

Returns:

The number of grid-aligned blocks that are entirely marker.

measured_missing_lines(image: NDArray[floating[Any]], *, marker_value: float = 0.0) int[source]

Count whole image lines lost to the marker (every pixel is the marker).

A missing line zeros the full row, so a fully-marker row is a lost line. A scene row that merely crosses the dark sky is not fully marker as long as the frame carries a non-zero floor (a bias pedestal or exposed scene), so the count is exact for the structural loss mode on such a frame.

Parameters:
  • image – The DN image.

  • marker_value – The missing-data marker DN.

Returns:

The number of fully-marker rows.

measured_partial_lines(image: NDArray[floating[Any]], *, marker_value: float = 0.0, min_run: int = 2) int[source]

Count truncated lines: rows with a long marker run but not a whole one.

A partial line loses a contiguous segment (from a column to the row end, or a middle segment), leaving a survivor, so an affected row carries a contiguous marker run yet is not fully marker. The min_run floor separates a truncated line from an incidental single dropped pixel or a lone dead column crossing the row; a fully-marker row is a whole missing line and is excluded.

Parameters:
  • image – The DN image.

  • marker_value – The missing-data marker DN.

  • min_run – The shortest contiguous marker run counted as a truncation.

Returns:

The number of rows whose longest contiguous marker run is at least min_run and that are not entirely marker.

measured_pixel_dropouts(image: NDArray[floating[Any]], *, marker_value: float = 0.0) int[source]

Count isolated marker pixels (dead pixels), excluding lines and columns.

A dead pixel is a singleton at the marker. Marker pixels that belong to a fully-marker row (a missing line) or a fully-marker column (a dead column) are excluded, so the count is the isolated-dropout population on a frame whose scene floor is above the marker.

Parameters:
  • image – The DN image.

  • marker_value – The missing-data marker DN.

Returns:

The number of marker pixels not lying in a fully-marker row or column.

planted_incidence(truth: Mapping[str, Any]) dict[str, int][source]

Realized per-mode event counts from a rendered frame’s truth record.

Parameters:

truth – A rendered frame’s truth mapping (frame.truth); its artifacts sub-map holds one record per applied mode. A frame with no artifacts returns an empty mapping.

Returns:

A mapping from mode name to its realized event count, for every mode the telemetry stage recorded. A mode that rendered as a no-op (incidence 0) leaves no record and does not appear.

Navigation-feature loci for adversarial artifact placement.

Adversarial placement (the worst-case artifact stress mode) seeds stochastic artifacts preferentially on the navigation features rather than uniformly. The renderer already knows where those features are: the radiance stage records the body and ring masks and the rendered star positions in frame.truth, all on the detector grid by the time the telemetry and detector stages run. This module extracts the loci from that truth – the rows a body or ring edge crosses and the pixels on a limb / ring arc or at a star – and offers the biased sampling helpers each adversarial mode’s placement hook calls. With adversarial off the samplers fall back to uniform draws, so the same code path serves both.

class FeatureLoci(rows: NDArray[integer[Any]], pixel_v: NDArray[integer[Any]], pixel_u: NDArray[integer[Any]])[source]

Bases: object

The navigation-feature loci extracted from a rendered frame’s truth.

Parameters:
  • rows – Sorted unique detector rows a body / ring feature or a star crosses (the candidate lines for adversarial line-loss placement).

  • pixel_v – Row coordinates of feature pixels (limb / ring-edge arcs and star centres) for adversarial per-pixel placement.

  • pixel_u – Column coordinates of those feature pixels.

property has_pixels: bool

Whether any feature pixels were found.

property has_rows: bool

Whether any feature rows were found.

pixel_u: NDArray[integer[Any]]
pixel_v: NDArray[integer[Any]]
rows: NDArray[integer[Any]]
choose_pixels(loci: FeatureLoci, n: int, shape: tuple[int, int], rng: Generator, *, adversarial: bool, radius: int = 3) tuple[NDArray[integer[Any]], NDArray[integer[Any]]][source]

Choose n pixels: near features when adversarial, else uniform.

An adversarial draw picks a feature pixel and jitters it within radius pixels, so the events land on or beside a limb arc or a star.

Parameters:
  • loci – The feature loci (feature pixels are the adversarial pool).

  • n – The number of pixels to choose.

  • shape – The detector-grid (size_v, size_u) shape.

  • rng – The mode’s seeded generator.

  • adversarial – Whether to bias placement onto the feature pixels.

  • radius – The jitter radius, in pixels, around a chosen feature pixel.

Returns:

The (v, u) coordinate arrays of the chosen pixels.

choose_rows(loci: FeatureLoci, n: int, size_v: int, rng: Generator, *, adversarial: bool) NDArray[integer[Any]][source]

Choose n rows: biased onto feature rows when adversarial, else uniform.

Parameters:
  • loci – The feature loci (feature rows are the adversarial pool).

  • n – The number of rows to choose.

  • size_v – The detector-grid height.

  • rng – The mode’s seeded generator.

  • adversarial – Whether to bias placement onto the feature rows.

Returns:

The chosen row indices (distinct where the pool allows).

dilated_pixels(loci: FeatureLoci, *, radius: int, shape: tuple[int, int]) tuple[NDArray[integer[Any]], NDArray[integer[Any]]][source]

Return every pixel within radius of a feature pixel (a placement pool).

The detector hot-pixel routing draws its adversarial population from this pool so hot pixels concentrate on and beside the features. Empty loci yield an empty pool, and the caller then falls back to uniform placement.

Parameters:
  • loci – The feature loci.

  • radius – The dilation radius in pixels.

  • shape – The detector-grid (size_v, size_u) shape.

Returns:

The (v, u) coordinate arrays of the dilated feature region.

extract_feature_loci(truth: Mapping[str, Any], shape: tuple[int, int]) FeatureLoci[source]

Extract the navigation-feature loci from a frame’s truth metadata.

Bodies and rings contribute the rows their disc / annulus spans and the pixels on their limb / edge arc; stars contribute their centre row and pixel. A frame with no features (an empty truth dict) yields empty loci, and the samplers then fall back to uniform placement.

Parameters:
  • truth – The frame’s truth mapping (body_masks, ring_masks, star_info), as recorded by the radiance stage on the detector grid.

  • shape – The detector-grid (size_v, size_u) shape, for bounds checks.

Returns:

The extracted FeatureLoci.

Image-side atmosphere rendering for haze-limb (Titan-class) bodies.

A body carrying an atmosphere block gains an exponential haze layer above its surface, evaluated by apply_atmosphere() after the disc is shaded. The haze splits into two compositing components (AtmosphereLayers): the on-disc haze joins the body’s opaque paint (the disc is opaque anyway, so the painted silhouette stays exactly the no-atmosphere silhouette), while the above-limb glow is a translucent HaloScreen – an emission map plus a per-pixel transmission exp(-tau) – that the radiance stage composites over the background exactly as the ring system’s transmission screen: img = glow + exp(-tau) * img_behind, with point sources attenuated by the same factor. A star behind the halo therefore dims by the tangent transmission instead of vanishing, and the body’s mask / depth / occlusion truth sees only the solid silhouette, never the halo. The haze is a truth key the navigator never sees: its predicted-body model keeps a hard limb at the reference radius, so the soft rendered limb is a designed mismatch (the substrate for the Titan altitude-versus-phase problem).

Tangent optical depth. A line of sight grazing the body at tangent altitude h (pixels above the reference radius) accumulates a slant optical depth

tau(h) = tau_ref * exp(-(h - ref_altitude_px) / scale_height_px)

so tau_ref is the tangent optical depth at ref_altitude_px. An optional detached haze shell adds a Gaussian bump in tau centred at detached_px above the surface. The shell exists only in this above-limb tangent depth: the on-disc excess column is shell-blind (a geometrically thin shell projected against the disc adds negligible slant contrast), so the shell renders solely as a second band in the tangent glow.

Single scattering. The emergent haze brightness is a source term times an opacity term. The source is a single-scattering albedo scaled by a Henyey-Greenstein phase factor (forward-scattering when g > 0, which brightens the limb at high phase and, in the limit, produces the ring of light past phase 150 deg) and a wrapped illumination weight that stays positive past the terminator over the horizon-dip angle sqrt(2 * H / R) of arc – the solar depression at which a column one scale height up loses direct sunlight – floored at 0.05 rad so a razor-thin atmosphere still wraps resolvably (so the terminator brightens past 90 deg incidence instead of cutting off). The opacity is 1 - exp(-tau): above the limb tau is the tangent optical depth, so the limb becomes a soft exponential ramp whose apparent radius grows with the haze brightness (hence with phase); on the disc the slant optical depth grows toward the limb as 1 / cos(emission), so the haze concentrates at the limb and stays faint at disc centre. The disc-side column scales from the physical vertical depth

tau_vert = tau_ref * exp(ref_altitude_px / H) / sqrt(2 * pi * R / H)

(the surface tangent depth divided by the grazing enhancement, R the mean radius and H the scale height in pixels), so the two sides of the limb describe one atmosphere at any reference altitude.

A body without an atmosphere block never calls into this module and renders hard-limbed, byte-for-byte as before.

class AtmosphereLayers(disc: NDArray[floating[Any]], halo: HaloScreen)[source]

Bases: object

One atmospheric body’s haze, split by compositing role.

Parameters:
  • disc – The body radiance with the on-disc haze composited, in [0, 1]. Zero exactly where the haze-free render was zero outside the silhouette, so it paints (opaquely) the same pixels a no-atmosphere body would.

  • halo – The translucent above-limb screen, covering every band pixel the opaque paint does not own.

disc: NDArray[floating[Any]]
halo: HaloScreen
class AtmosphereSpec(scale_height_px: float, tau_ref: float, ref_altitude_px: float = 0.0, g: float = 0.0, detached_px: float | None = None)[source]

Bases: object

The exponential haze layer of one atmospheric body.

All pixel quantities are in units of the render grid the haze is composited on (the oversampled grid when the scene oversamples), matching the body’s already-scaled semi-axes.

The detached shell’s tau bump peaks at a fixed multiple of tau_ref, not of the smooth column depth at detached_px, so the same smooth atmosphere re-expressed at a different ref_altitude_px (with tau_ref rescaled accordingly) carries a correspondingly rescaled shell. The reference-altitude invariance of the smooth column is therefore a parameterization choice that deliberately excludes the shell: a spec with detached_px set is tied to its stated reference altitude.

Parameters:
  • scale_height_px – Haze e-folding scale height in pixels (> 0).

  • tau_ref – Tangent optical depth at ref_altitude_px (> 0).

  • ref_altitude_px – Altitude above the reference radius at which the tangent optical depth equals tau_ref, in pixels.

  • g – Henyey-Greenstein asymmetry parameter in (-1, 1); positive is forward-scattering (bright limb at high phase).

  • detached_px – Altitude of an optional detached haze shell above the surface, in pixels; None for no shell.

detached_px: float | None = None
g: float = 0.0
ref_altitude_px: float = 0.0
scale_height_px: float
tau_ref: float
class HaloScreen(emission: NDArray[floating[Any]], transmission: NDArray[floating[Any]], box_v: slice, box_u: slice, mask: NDArray[bool])[source]

Bases: object

The translucent above-limb haze layer of one atmospheric body.

A transmission screen in the ring system’s sense: the radiance stage composites img = emission + transmission * img_behind over the screen’s pixels, and point sources (which sit at infinity, behind every halo) multiply by transmission.

Parameters:
  • emission – Per-pixel tangent glow of the halo, in [0, 1]; 0 where the halo carries no light.

  • transmission – Per-pixel exp(-tau) along the grazing line of sight: the fraction of background light that passes through; 1 outside the halo.

  • box_v – Grid rows outside which the screen is exactly identity (emission 0, transmission 1), so consumers may restrict their compositing to the box without changing any value.

  • box_u – Grid columns of the same bounding box.

  • mask – Box-sized mask of the pixels where the screen differs from identity, so consumers need not re-derive it from the maps.

box_u: slice
box_v: slice
emission: NDArray[floating[Any]]
mask: NDArray[bool]
transmission: NDArray[floating[Any]]
apply_atmosphere(body_shape: NDArray[floating[Any]], spec: AtmosphereSpec, *, center_v: float, center_u: float, semi_a: float, semi_b: float, semi_c: float, rotation_z: float, rotation_tilt: float, illumination_angle: float, phase_angle: float) AtmosphereLayers[source]

Evaluate the haze layer over a reference-centred body radiance.

The haze is evaluated over a limb band a few scale heights deep and split by compositing role: the on-disc haze (every band pixel the disc render painted, including its anti-aliased rim) is added to the opaque disc radiance, and the above-limb glow outside the painted silhouette becomes the translucent HaloScreen. All per-pixel work (coordinate grids included) is restricted to the bounding box of the body plus its halo out to the detached shell’s reach, so the haze cost scales with that box, not the frame. The returned arrays are new arrays; the input is never mutated (it may be a shared render cache entry).

Parameters:
  • body_shape – The shaded body radiance at the reference centre, in [0, 1], 0 outside the body.

  • spec – The haze spec (pixel lengths already on this grid).

  • center_v – Body centre v the shape was rendered at, in grid pixels.

  • center_u – Body centre u the shape was rendered at, in grid pixels.

  • semi_a – Semi-axis a in grid pixels.

  • semi_b – Semi-axis b in grid pixels.

  • semi_c – Depth semi-axis c in grid pixels.

  • rotation_z – In-plane rotation about the viewing axis, radians.

  • rotation_tilt – Tilt toward/away from the viewer, radians.

  • illumination_angle – In-plane light direction, radians (0 = top).

  • phase_angle – Phase angle in radians (0 fully lit, pi backlit).

Returns:

the opaque disc radiance (clipped to [0, 1]) and the translucent halo screen.

Return type:

The AtmosphereLayers

atmosphere_spec_from_params(body_params: dict[str, object], *, oversample: int) AtmosphereSpec | None[source]

Build an AtmosphereSpec from a body’s atmosphere block.

Returns None when the body carries no atmosphere, so a body without the block never enters the haze path. Pixel lengths are scaled to the render grid by oversample exactly as the body’s axes are.

Parameters:
  • body_params – One scene body entry.

  • oversample – The render grid’s oversampling factor.

Returns:

The scaled spec, or None when the body has no atmosphere block.

hg_phase_factor(g: float, phase_angle: float) float[source]

The Henyey-Greenstein phase factor at a phase angle (1 at g = 0).

Single scattering turns light through a scattering angle Theta = pi - phase, so cos(Theta) = -cos(phase); a positive g peaks at Theta = 0 (phase = pi), which is why forward-scattering haze is brightest at high phase. The factor is normalized to 1 at g = 0 for every phase, so it is a pure angular modulation of the haze source.

Parameters:
  • g – Henyey-Greenstein asymmetry parameter in (-1, 1).

  • phase_angle – Phase angle in radians.

Returns:

The multiplicative phase factor (> 0).

The artifact-mode registry: the single source of truth for scene defects.

The artifacts scene block is keyed, besides instrument_defaults and adversarial, by the artifact-mode names in this registry. Each mode is described once here – its rendering stage (telemetry or detector), its parameter schema, the instruments it is available on, and whether it is implemented yet – and every consumer (the scene validator, the telemetry stage, the detector hot-pixel routing) reads that description rather than carrying its own copy. Registering a mode is therefore the whole job of adding one: a detector-stage mode that is unimplemented today drops in by flipping its implemented flag and adding its rendering code, with no change to the validator or the block schema.

Availability. A mode lists the sim instruments it is available on; a scene that names the mode on any other instrument fails validation with a clear message (the LORRI hot-pixel case carries a bespoke one, since LORRI has no hot pixels by construction). The instrument-agnostic generic / sim block accepts every mode, which keeps unit scenes free to exercise any shape.

Incidence. Every mode takes an incidence parameter, disabled at 0 (the stage-activation rule: an incidence of 0, which is also the default even under instrument_defaults, renders the mode as a no-op). Its meaning is per-mode and documented in each mode’s incidence_semantics: for count modes it is the expected number of events (lost lines, blocks, spiked pixels) per frame, drawn Poisson; for commanded / periodic modes it is the per-frame probability that the mode activates at all.

class ArtifactMode(name: str, stage: str, implemented: bool, params: tuple[~spindoctor.sim.forward.artifact_modes.ModeParam, ...], availability: frozenset[str], incidence_semantics: str, unavailable_reason: ~collections.abc.Mapping[str, str] = <factory>)[source]

Bases: object

A registered artifact mode.

Parameters:
  • name – The registry key (also the artifacts block key).

  • stage – The rendering stage that applies it, telemetry or detector.

  • implemented – Whether the renderer implements it yet. An unimplemented mode is a reserved registry entry: it fixes the name, stage, and availability so the eventual implementation drops in without a schema change, and it fails validation with a clear message until its rendering code lands.

  • params – The mode’s parameters (incidence first, then mode-specific).

  • availability – The sim instruments the mode is available on (the generic block is always accepted, so it is never listed here).

  • incidence_semantics – Human-readable meaning of incidence for the mode, for the validator’s messages and the developer guide.

  • unavailable_reason – Per-instrument bespoke unavailability messages (e.g. LORRI hot pixels); other instruments get a generic message.

availability: frozenset[str]
implemented: bool
incidence_semantics: str
name: str
property param_map: dict[str, ModeParam]

The mode’s parameters keyed by name.

params: tuple[ModeParam, ...]
stage: str
unavailable_reason: Mapping[str, str]
class ModeParam(name: str, kind: str, default: Any = None, choices: tuple[Any, ...] | None = None, length: int | None = None)[source]

Bases: object

One parameter of an artifact mode.

Parameters:
  • name – The parameter key inside the mode’s scene map.

  • kind – The value’s type tag, one of bool, nonneg_number, unit_interval, int, nonneg_int, positive_int, enum (with choices), or int_list (with length).

  • default – The value used when the key is absent. None marks an optional parameter with no default shape (the renderer supplies its own fallback, e.g. a centred window).

  • choices – The permitted values for an enum parameter.

  • length – The required length of an int_list parameter.

choices: tuple[Any, ...] | None = None
default: Any = None
kind: str
length: int | None = None
name: str
mode_available(mode_name: str, instrument: str | None) bool[source]

Whether mode_name is available on instrument.

Parameters:
  • mode_name – A registered mode name.

  • instrument – A sim instrument name, a generic alias, or None.

Returns:

True if the generic block is selected (which accepts every mode) or the instrument is in the mode’s availability set.

mode_unavailable_message(mode_name: str, instrument: str | None) str[source]

A validation message explaining why a mode is unavailable on an instrument.

normalize_instrument(instrument: str | None) str[source]

Collapse an instrument name to its availability key.

The calibrated Cassini aliases share the raw detector’s availability, and the generic aliases (and None) map to generic, which accepts every mode.

Parameters:

instrument – A sim instrument name, a generic alias, or None.

Returns:

The normalized availability key.

resolve_mode_config(mode_name: str, raw_config: Mapping[str, Any]) dict[str, Any][source]

Fill a mode’s scene map with its parameter defaults for rendering.

Parameters:
  • mode_name – A registered mode name.

  • raw_config – The scene’s map for the mode (already validated).

Returns:

A fresh dict carrying every parameter, scene value overriding default.

Per-instrument artifact defaults for the forward model.

These tables hold the per-instrument optical parameters an instrument_defaults scene turns on: the whole-scene PSF kernel and the residual geometric-distortion amplitude. Every value here is interim – sized from published FWHMs and documented residual-error bounds, pending the per-instrument measurement passes – and is provenance-tagged as such in the comments beside it.

Keys are sim instrument names (see spindoctor.sim.instruments.SIM_INSTRUMENTS).

resolve_detector_defaults(instrument: str | None) dict[str, Any][source]

Return the detector-parameter defaults for a sim instrument.

Parameters:

instrument – The sim instrument name (see spindoctor.sim.instruments.SIM_INSTRUMENTS), one of the generic aliases, or None for the instrument-agnostic block.

Returns:

A fresh copy of the instrument’s DETECTOR_DEFAULTS entry, falling back to the generic block for the generic aliases or an unknown name.

resolve_mode_with_catalog(mode_name: str, scene_cfg: Mapping[str, Any], instrument: str | None) dict[str, Any][source]

Resolve an artifact mode’s parameters with the per-instrument catalog.

The resolution precedence for a mode’s shape parameters is scene value, then the instrument’s catalog default block (artifact_modes in DETECTOR_DEFAULTS), then the registry default. incidence is never read from the catalog: a mode activates only when a scene sets its incidence (or, for the one physical-signal-chain member LORRI turns on, when the detector resolver injects it under instrument_defaults).

Parameters:
  • mode_name – A registered artifact-mode name.

  • scene_cfg – The scene’s map for the mode (already validated).

  • instrument – The sim instrument name, for the catalog lookup.

Returns:

A fresh dict carrying every parameter at its resolved value.

resolve_sky_pixel_scale_arcsec(instrument: str | None) float[source]

Return the angular pixel scale (arcsec / pixel) for the sky-count FOV area.

Parameters:

instrument – The sim instrument name, a generic alias, or None.

Returns:

The interim plate scale in arcsec per detector pixel.

resolve_star_flux_zero_point(instrument: str | None) tuple[float, str][source]

Return the star photometric zero point and its unit domain.

Parameters:

instrument – The sim instrument name, a generic alias, or None.

Returns:

A (zero_point, domain) pair. domain is 'dn' for the vidicon (its point sources are DN) and 'electrons' for every CCD camera and the generic detector. zero_point is the flux a magnitude-0 star deposits per second of exposure in that domain.

Shared ellipsoid shading geometry for the simulator’s two sides.

The image-side forward renderer (spindoctor.sim.forward.body) and the navigator-side predicted-body renderer (spindoctor.nav_model.sim_body) must shade an ellipsoid with byte-identical conventions: the same surface normals, the same light direction, and the same Lambert clamp. With shared conventions the planted scene error is the only error in a recovery measurement; independent implementations would each carry their own conventions and any delta between them would contaminate the measurement as an unknown systematic.

These helpers take explicit geometry arguments only. They never read a scene parameter mapping, so they cannot carry truth-side information across the information boundary (see spindoctor.sim.scene).

class EllipsoidProjection(aa_scale: int, work_center_v: float, work_center_u: float, work_semi_major: float, work_semi_minor: float, work_semi_c: float, cos_rz: float, sin_rz: float, v_coords: NDArray[floating[Any]], u_coords: NDArray[floating[Any]], v_rot: NDArray[floating[Any]], u_rot: NDArray[floating[Any]], z_coords: NDArray[floating[Any]], ellipse_dist_sq: NDArray[floating[Any]], inside_mask: NDArray[bool], ellipse_mask: NDArray[floating[Any]])[source]

Bases: object

The projected-ellipsoid working grids both simulator sides shade from.

Produced by project_ellipsoid(); every array lives on the working grid (the render grid supersampled by aa_scale).

Parameters:
  • aa_scale – The anti-aliasing supersampling factor of the working grid.

  • work_center_v – Body center v at working resolution.

  • work_center_u – Body center u at working resolution.

  • work_semi_major – Semi-major axis (a) at working resolution.

  • work_semi_minor – Semi-minor axis (b) at working resolution.

  • work_semi_c – Depth semi-axis (c) at working resolution.

  • cos_rz – Cosine of the rotation_z angle.

  • sin_rz – Sine of the rotation_z angle.

  • v_coords – Centered v pixel coordinates at working resolution.

  • u_coords – Centered u pixel coordinates at working resolution.

  • v_rot – Rotated-frame v coordinate of each pixel (tilt applied).

  • u_rot – Rotated-frame u coordinate of each pixel.

  • z_coords – Depth of the visible ellipsoid surface at each pixel.

  • ellipse_dist_sq – Squared normalized ellipse distance of each pixel.

  • inside_mask – Pixels at or inside the projected ellipse boundary.

  • ellipse_mask – Ellipse coverage mask (a soft anti-aliased rim when anti_aliasing > 0, else the hard boundary as floats).

aa_scale: int
cos_rz: float
ellipse_dist_sq: NDArray[floating[Any]]
ellipse_mask: NDArray[floating[Any]]
inside_mask: NDArray[bool]
sin_rz: float
u_coords: NDArray[floating[Any]]
u_rot: NDArray[floating[Any]]
v_coords: NDArray[floating[Any]]
v_rot: NDArray[floating[Any]]
work_center_u: float
work_center_v: float
work_semi_c: float
work_semi_major: float
work_semi_minor: float
z_coords: NDArray[floating[Any]]
ellipsoid_image_normals(ellipse_mask: NDArray[floating[Any]], v_rot: NDArray[floating[Any]], u_rot: NDArray[floating[Any]], *, z_coords: NDArray[floating[Any]], work_semi_major: float, work_semi_minor: float, work_semi_c: float, cos_rz: float, sin_rz: float) tuple[NDArray[floating[Any]], NDArray[floating[Any]], NDArray[floating[Any]]][source]

Unit surface normals of the base ellipsoid in image coordinates.

For a 3D ellipsoid, the surface normal at body-frame point (v, u, z) is (v/a^2, u/b^2, z/c^2) normalized. The in-plane components are then rotated back from the ellipsoid’s rotated frame to image coordinates through the inverse of the rotation_z coordinate transformation; the z component is perpendicular to the image plane and unaffected. Both the smooth and the cratered shading paths derive their base normals here, so the two paths share a single illumination convention.

Parameters:
  • ellipse_mask – Ellipse coverage mask; normals are computed where > 0.

  • v_rot – Rotated-frame v coordinate of each pixel.

  • u_rot – Rotated-frame u coordinate of each pixel.

  • z_coords – Depth of the visible ellipsoid surface at each pixel.

  • work_semi_major – Semi-major axis (a) at working resolution.

  • work_semi_minor – Semi-minor axis (b) at working resolution.

  • work_semi_c – Depth semi-axis (c) at working resolution.

  • cos_rz – Cosine of the rotation_z angle.

  • sin_rz – Sine of the rotation_z angle.

Returns:

Tuple of (normal_v, normal_u, normal_z) unit-normal component arrays in image coordinates.

illumination_vector(*, illumination_angle: float, phase_angle: float) tuple[float, float, float][source]

The unit body-to-sun direction in image coordinates.

Both simulator sides derive the light direction here, so their illumination conventions cannot diverge.

The in-plane direction comes from illumination_angle (0 = from the top of the image, pi/2 = from the right; the v component is negated because v increases downward). The out-of-plane component encodes the phase angle – the observer-body-sun angle: z = cos(phase_angle) so phase 0 (full) lights the visible face head-on and phase pi (new) lights it from behind, while the in-plane magnitude is sin(phase_angle).

Parameters:
  • illumination_angle – In-plane light direction in radians; 0 is from the top of the image, pi/2 from the right.

  • phase_angle – Phase angle in radians; 0 is fully lit, pi is backlit.

Returns:

Tuple of (v, u, z) components of the unit illumination direction; z points toward the observer.

lambert_from_normals(normal_v: NDArray[floating[Any]], normal_u: NDArray[floating[Any]], normal_z: NDArray[floating[Any]], *, illumination_angle: float, phase_angle: float) NDArray[floating[Any]][source]

Lambertian illumination strength for image-frame unit surface normals.

The normals must already be unit length (or zero outside the body). Both the smooth and the cratered shading paths use this single implementation so their illumination conventions cannot diverge.

Parameters:
  • normal_v – V component of the unit surface normal in image coordinates.

  • normal_u – U component of the unit surface normal in image coordinates.

  • normal_z – Z (toward-observer) component of the unit surface normal.

  • illumination_angle – In-plane light direction in radians; 0 is from the top of the image, pi/2 from the right.

  • phase_angle – Phase angle in radians; 0 is fully lit, pi is backlit.

Returns:

Illumination strength array in [0, 1]; 0 on the far hemisphere.

project_ellipsoid(size: tuple[int, int], center: tuple[float, float], axis1: float, *, axis2: float, axis3: float, rotation_z: float = 0.0, rotation_tilt: float = 0.0, anti_aliasing: float = 0.0) EllipsoidProjection[source]

Project an ellipsoid onto the image plane and build the shading grids.

This is the single implementation of the projection both simulator sides shade from: the working-grid coordinate frames (in-plane rotation, tilt), the visible-hemisphere depth, and the (optionally anti-aliased) coverage mask. The image-side renderer carves craters into these grids and the navigator-side predicted-body renderer shades them smooth, so the planted scene error is the only geometric difference between the two.

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 in radians.

  • rotation_tilt – Tilt angle of the ellipsoid in radians (0 to pi/2).

  • anti_aliasing – Anti-aliasing amount in [0, 1]; > 0 supersamples the working grid (up to 4x) and softens the limb rim of ellipse_mask.

Returns:

The EllipsoidProjection working grids.

Shared polyhedral-mesh geometry for irregular sim bodies.

The ellipsoid renderers cannot produce the non-ellipsoidal silhouette of an irregular body (Hyperion, Phoebe). Because oops will not gain DSK support, the sim carries its own small renderer that projects a triangle mesh through a scene-supplied pose and rasterises the shaded silhouette. It is sim-only: the body’s orientation is ground truth from the scene, not from SPICE.

This module is deliberately shared between the image-side forward renderer (spindoctor.sim.forward.body_mesh) and the navigator-side predicted-body renderer (spindoctor.nav_model.nav_model_body_simulated): the mesh shape, pose, and rasterisation conventions are idealized information both sides may know, and sharing one implementation guarantees that a scene’s planted geometry error (via nav_override) is the only difference between the rendered and the predicted silhouette. Every function takes explicit geometry arguments; none reads the scene mapping, so no truth-side information can cross the boundary here (mesh_spec_from_params parses a body parameter mapping, but only its idealized mesh keys).

The output contract matches create_simulated_body: a (size_v, size_u) float array in [0, 1], lit by the same Lambertian convention so a mesh body and an ellipsoid body can be compared directly (the B7 shape-mismatch fixture).

class Mesh(vertices: NDArray[floating[Any]], faces: NDArray[integer[Any]])[source]

Bases: object

A triangle mesh in a unit-radius body-fixed frame.

Parameters:
  • vertices(N, 3) vertex coordinates (x, y, z).

  • faces(M, 3) vertex indices, wound so face normals point outward.

faces: NDArray[integer[Any]]
vertices: NDArray[floating[Any]]
class MeshBodySpec(lumpiness: float = 0.3, n_lat: int = 16, n_lon: int = 32, seed: int = 0, pose_euler_deg: tuple[float, float, float] = (0.0, 0.0, 0.0), detail_octaves: int = 0)[source]

Bases: object

The mesh shape and pose for an irregular body, read from scene params.

Kept separate from the image so the same spec drives both the rendered data and the navigator’s predicted silhouette (or, for a shape-mismatch fixture, two deliberately different specs).

Parameters:
  • lumpiness – Surface-relief amplitude as a fraction of the unit radius.

  • n_lat – Mesh latitude bands.

  • n_lon – Mesh longitude divisions.

  • seed – Seed selecting which irregular shape is generated.

  • pose_euler_deg – Body orientation as intrinsic X, Y, Z Euler angles, deg.

  • detail_octaves – Higher-frequency mode banks added to the base relief (see make_irregular_mesh()); part of the published shape.

detail_octaves: int = 0
lumpiness: float = 0.3
n_lat: int = 16
n_lon: int = 32
pose_euler_deg: tuple[float, float, float] = (0.0, 0.0, 0.0)
seed: int = 0
make_irregular_mesh(*, n_lat: int = 16, n_lon: int = 32, lumpiness: float = 0.3, n_modes: int = 6, seed: int = 0, detail_octaves: int = 0) Mesh[source]

Build a lumpy unit-radius mesh (a UV sphere with low-frequency relief).

The radius is modulated by a few random low-frequency angular modes, so the body is smoothly irregular rather than spiky. The same seed always yields the same mesh.

detail_octaves adds banks of higher-frequency modes with geometric amplitude falloff: octave k draws n_modes fresh modes with angular wavenumbers in [3 * 2**(k-1) + 1, 3 * 2**k] (the base modes occupy 1..3) at amplitude 0.5**k relative to the base bank. Octave draws consume the generator after the base draws, so detail_octaves = 0 reproduces the base mesh bit-exactly and raising the count never changes the base shape. The mesh resolution must support the frequency content: n_lat / n_lon should exceed roughly four samples per top-octave cycle (12 * 2**detail_octaves) or the extra modes alias.

Parameters:
  • n_lat – Number of latitude bands (>= 2).

  • n_lon – Number of longitude divisions (>= 3).

  • lumpiness – Relief amplitude as a fraction of the unit radius.

  • n_modes – Number of random angular modes per bank.

  • seed – Seed for the relief modes.

  • detail_octaves – Number of higher-frequency mode banks (0 = base only).

Returns:

An outward-wound Mesh.

mesh_spec_from_params(body_params: Mapping[str, Any]) MeshBodySpec[source]

Build a MeshBodySpec from a body parameter mapping.

The mesh seed and pose are explicit body parameters (not the scene’s noise seed), so the same body params reproduce the same shape on both the render and prediction sides.

Parameters:

body_params – A body parameter mapping.

Returns:

The resolved MeshBodySpec.

Raises:

ValueError – If pose_euler_deg does not hold exactly three angles.

render_mesh_body_image(*, size: tuple[int, int], center: tuple[float, float], semi_axes_px: tuple[float, float, float], spec: MeshBodySpec, illumination_angle: float = 0.0, phase_angle: float = 0.0, anti_aliasing: float = 1.0) NDArray[floating[Any]][source]

Render an irregular mesh body from a MeshBodySpec.

A thin convenience over make_irregular_mesh + render_polyhedral_body so the render path and the navigator’s prediction share one primitive.

Parameters:
  • size(size_v, size_u) output image size in pixels.

  • center(v, u) body centre in pixels.

  • semi_axes_px – Per-axis (a, b, c) half-sizes in pixels.

  • spec – The mesh shape and pose.

  • illumination_angle – Image-plane light azimuth in radians.

  • phase_angle – Phase angle in radians.

  • anti_aliasing – Limb supersampling control.

Returns:

A (size_v, size_u) float array in [0, 1].

render_polyhedral_body(*, size: tuple[int, int], center: tuple[float, float], mesh: Mesh, semi_axes_px: tuple[float, float, float], pose_euler_deg: tuple[float, float, float] = (0.0, 0.0, 0.0), illumination_angle: float = 0.0, phase_angle: float = 0.0, anti_aliasing: float = 1.0, shading: str = 'flat') NDArray[floating[Any]][source]

Render a mesh body to a [0, 1] shaded silhouette.

Two shading modes share one rasterization (the same front-face set, the same z-buffer, the same silhouette): 'flat' shades each face by its own normal, 'gouraud' computes per-vertex normals as the area-weighted average of the adjacent face normals and interpolates the per-vertex intensities barycentrically across each face, removing the facet-boundary shading discontinuities. The mode never moves the silhouette; each caller picks its own mode (the navigator’s predicted mesh keeps flat shading unless told otherwise).

Parameters:
  • size(size_v, size_u) output image size in pixels.

  • center(v, u) body centre in pixels.

  • mesh – The unit-radius body mesh.

  • semi_axes_px – Per-axis (a, b, c) half-sizes in pixels applied to the mesh in its body frame before the pose rotation.

  • pose_euler_deg – Body orientation as intrinsic X, Y, Z Euler angles, deg.

  • illumination_angle – Image-plane light azimuth in radians (0 = top).

  • phase_angle – Phase angle in radians (0 = fully lit, pi = back-lit).

  • anti_aliasing – 0 disables supersampling; (0, 1] supersamples the limb.

  • shading'flat' (per-face) or 'gouraud' (per-vertex interpolated).

Returns:

A (size_v, size_u) float array in [0, 1].

Raises:

ValueError – On an unknown shading mode.

Shared ring-edge geometry for the simulator’s two sides.

The image-side ring-system renderer (spindoctor.sim.forward.ring_system) and the navigator-side predicted-ring renderer (spindoctor.nav_model.sim_ring plus spindoctor.nav_model.nav_model_rings_simulated) must place a feature edge at byte-identical pixel positions: the same true-anomaly convention, the same pericenter precession, the same m-mode and edge-wave forms, and the same pixel-center rasterisation. With shared conventions the planted scene error is the only error in a recovery measurement.

The ring-plane projection helpers here are the single implementation of the optical-depth ring system’s opening-angle geometry, shared by design: the forward renderer draws the full system through them and the navigator-side ring model predicts navigable edges through the same functions, so predicted edges land in projected positions by construction. The conventions:

  • lam is ring-plane longitude measured from the ascending node, in the ring plane, increasing counterclockwise viewed from the north. Every orbital angle (pericenter longitudes and the like) lives in this frame.

  • node_deg is the sky position angle of the ascending node, measured counterclockwise from +u toward -v; it enters only the final sky rotation, never the orbit model.

  • opening_deg_obs is the observer’s ring opening angle B in (-90, 90], positive north. |B| = 90 reduces the projection to sky-plane circles (the flat-ring regression identity).

  • A point’s line-of-sight depth relative to the ring center is dlos = -y * cos(B), positive toward the observer, so for B > 0 the near arm is the y < 0 half and the ansae have zero depth.

Every function takes explicit geometry arguments; the only scene fragment read here is the per-feature orbit mapping (via ring_orbit_from_mapping()), which is an idealized block both sides are entitled to, so no truth-side information can cross the information boundary here (see spindoctor.sim.scene). The renderer applies its planted orbit_error truth values on its own side, before calling in.

class RingEdgeWave(amp: float, wavelength: float, damp: float, lam0: float)[source]

Bases: object

A satellite edge wave (Daphnis/Pan-style) on a ring-feature orbit.

The radial perturbation is dr(lam) = amp * exp(-(lam - lam0) / damp) * sin(2 * pi * (lam - lam0) * a / wavelength) evaluated ONLY downstream of lam0: the longitude difference is taken modulo 2*pi into [0, 2*pi), so the exponential argument is never negative. The clamp is load-bearing, not cosmetic – the exponential grows without bound if evaluated for lam < lam0 – and the modular form is chosen because rings are periodic and the wave physically wraps the full turn: immediately upstream of lam0 it carries a wrap-seam residual of amp * exp(-2*pi/damp) of its launch amplitude. The scene validator caps damp at 2.0 radians, bounding that residual at exp(-pi), about 4.3% of amp.

Parameters:
  • amp – Radial amplitude in the orbit’s radial units.

  • wavelength – Azimuthal wavelength as an arc length, in the orbit’s radial units (the sine argument is arc length over wavelength).

  • damp – Azimuthal damping constant in RADIANS of downstream longitude.

  • lam0 – Launch longitude in degrees (ring-plane frame; the perturbing moon’s longitude).

amp: float
damp: float
lam0: float
wavelength: float
class RingOrbit(a: float, ae: float, long_peri: float, rate_peri: float, modes: tuple[RingOrbitMode, ...] = (), edge_wave: RingEdgeWave | None = None)[source]

Bases: object

A ring feature’s full orbit model: mode-1 ellipse plus perturbations.

This is the shared parsed form of the idealized per-feature orbit scene mapping: the image-side renderer draws through it (after applying its planted orbit error on its own side) and the navigator-side model predicts through it unmodified, so both sides interpret the same catalog values identically by construction.

Parameters:
  • a – Semimajor axis (pixels; any consistent radial unit).

  • ae – Eccentricity times semimajor axis, same units.

  • long_peri – Mode-1 pericenter longitude in degrees (ring-plane frame).

  • rate_peri – Mode-1 pericenter precession rate in degrees/day.

  • modes – The m >= 2 radial modes.

  • edge_wave – The satellite edge wave, or None.

a: float
ae: float
edge_wave: RingEdgeWave | None = None
long_peri: float
modes: tuple[RingOrbitMode, ...] = ()
rate_peri: float
scaled(factor: float) RingOrbit[source]

The orbit with every radial quantity scaled by factor.

Used to move detector-pixel scene values onto an oversampled render grid: radii and radial amplitudes scale; angles, rates, and the (angular) edge-wave damping do not.

Parameters:

factor – The radial scale factor (the oversampling factor).

Returns:

The scaled orbit.

widened(dr: float) RingOrbit[source]

The same orbit shape displaced outward by dr radial units.

A feature’s outer edge is its inner-edge orbit widened by the radial width: the ellipse grows by dr while every perturbation keeps its amplitude and phase, so the band has constant radial width.

Parameters:

dr – Radial displacement in the orbit’s radial units.

Returns:

The widened orbit.

class RingOrbitMode(m: int, amp: float, peri: float)[source]

Bases: object

One m >= 2 radial mode of a ring-feature orbit.

The mode perturbs the edge radius by -amp * cos(m * (lam - peri)) with lam the ring-plane longitude from the ascending node, so for a pure m-mode on a circular base orbit r(lam) = a - amp * cos(m * (lam - peri)) exactly (the normative closed form). amp is a * e in the same radial units as the semimajor axis; peri is the mode’s pericenter longitude in degrees, in the ring-plane frame (the sky node angle never enters the orbit model).

Parameters:
  • m – Mode number (2 or greater; the mode-1 shape is the base ellipse).

  • amp – Radial amplitude in the orbit’s radial units.

  • peri – Pericenter longitude in degrees (ring-plane frame).

amp: float
m: int
peri: float
compute_antialiasing_shade(edge_dist: NDArray[floating[Any]], resolution: float) NDArray[floating[Any]][source]

Compute anti-aliasing shade from signed edge distance.

Callers pass the signed distance with the covered side positive: a ring feature passes r - r_edge (or r_outer - r) so a pixel inside the band shades toward 1, and a moonlet disc passes radius - dist so a pixel inside the disc shades toward 1.

Parameters:
  • edge_dist – Signed distance from pixel center to the edge (positive = inside the covered feature, negative = outside).

  • resolution – Pixel resolution for anti-aliasing (the shade ramps over one such window centred on the edge).

Returns:

Anti-aliasing shade value [0, 1]; 0.5 means the pixel center sits exactly on the edge, 1 fully covered, 0 fully outside.

compute_edge_radii_array(angles: NDArray[floating[Any]], *, a: float, ae: float, long_peri: float, rate_peri: float, epoch: float, time: float) NDArray[floating[Any]][source]

Compute edge radii array for all angles using mode 1 parameters.

Parameters:
  • angles – Array of angles in radians from center.

  • a – Semi-major axis in pixels.

  • ae – Eccentricity times semi-major axis in pixels.

  • long_peri – Longitude of pericenter in degrees.

  • rate_peri – Rate of precession in degrees/day.

  • epoch – Epoch time (TDB seconds).

  • time – Current time (TDB seconds).

Returns:

Array of edge radii in pixels at the given angles.

Raises:

ValueError – If ae / a is an eccentricity of 1 or more, which does not describe a closed elliptical edge.

compute_edge_radius_at_angle(angle: float, *, a: float, ae: float, long_peri: float, rate_peri: float, epoch: float, time: float) float[source]

Compute edge radius at a specific angle using mode 1 parameters.

Parameters:
  • angle – Angle in radians from center.

  • a – Semi-major axis in pixels.

  • ae – Eccentricity times semi-major axis in pixels.

  • long_peri – Longitude of pericenter in degrees.

  • rate_peri – Rate of precession in degrees/day.

  • epoch – Epoch time (TDB seconds).

  • time – Current time (TDB seconds).

Returns:

Edge radius in pixels at the given angle.

Raises:

ValueError – If ae / a is an eccentricity of 1 or more, which does not describe a closed elliptical edge.

compute_edge_wave_dr(lam: NDArray[floating[Any]], wave: RingEdgeWave, *, a: float) NDArray[floating[Any]][source]

The satellite edge wave’s radial perturbation at longitudes lam.

dr = amp * exp(-dlam / damp) * sin(2 * pi * dlam * a / wavelength) with dlam = (lam - lam0) mod 2*pi in [0, 2*pi): the wave exists only DOWNSTREAM of the launch longitude, so the exponential argument is never negative (evaluating the raw form for lam < lam0 would grow without bound – the clamp is load-bearing). The wrapped wave carries an upstream residual of amp * exp(-2*pi/damp) just before lam0; the validator’s cap of damp <= 2.0 radians bounds it at exp(-pi), about 4.3% of amp. a is the feature’s semimajor axis, making the sine argument arc length over wavelength (dimensionless).

Parameters:
  • lam – Ring-plane longitudes from the ascending node, in radians.

  • wave – The edge-wave parameters (damp in radians).

  • a – The feature’s semimajor axis, in the wave’s radial units.

Returns:

Radial perturbations at each longitude, in the wave’s radial units.

compute_orbit_radii(lam: NDArray[floating[Any]], orbit: RingOrbit, *, epoch: float, time: float) NDArray[floating[Any]][source]

Edge radii of a full orbit model at ring-plane longitudes lam.

The mode-1 precessing ellipse (exact conic form) minus each m >= 2 mode’s amp * cos(m * (lam - peri)), plus the edge wave’s downstream perturbation when the orbit carries one. All longitudes – lam, the pericenters, the wave’s launch longitude – live in the ring-plane frame measured from the ascending node; the sky node angle enters only the final projection, never here.

Parameters:
  • lam – Ring-plane longitudes from the ascending node, in radians.

  • orbit – The parsed orbit model.

  • epoch – Ring epoch (TDB seconds) for mode-1 precession.

  • time – Scene time (TDB seconds).

Returns:

Edge radii at each longitude, in the orbit’s radial units.

Raises:

ValueError – If ae / a is an eccentricity of 1 or more.

ring_los_depth(y: NDArray[floating[Any]], *, opening_deg_obs: float) NDArray[floating[Any]][source]

Line-of-sight depth of ring-plane points relative to the ring center.

dlos = -y * cos(B), positive toward the observer: for B > 0 the near arm is the y < 0 half, the ring’s nearest point sits at lam = 270 degrees when node = 0, and the ansae (lam = 0 and 180) have zero depth by construction. Compositing against a body orders by observer distance range_km - dlos_km: positive-toward-the- observer depth subtracts, so the nearer object has the smaller distance.

Parameters:
  • y – Node-aligned in-plane y coordinates (from ring_plane_from_sky()).

  • opening_deg_obs – Observer ring opening angle B in degrees.

Returns:

Depth values in the units of y, positive toward the observer.

ring_orbit_from_mapping(orbit: Mapping[str, Any]) RingOrbit[source]

Parse a validated per-feature orbit scene mapping into a RingOrbit.

This is the single interpretation of the idealized orbit block, shared by the forward renderer and the navigator-side model so both sides apply the same defaults to the same catalog values.

Parameters:

orbit – The validated ring_system.features[].orbit mapping.

Returns:

The parsed orbit.

ring_plane_from_sky(dv: NDArray[floating[Any]], du: NDArray[floating[Any]], *, opening_deg_obs: float, node_deg: float) tuple[NDArray[floating[Any]], NDArray[floating[Any]], NDArray[floating[Any]], NDArray[floating[Any]]][source]

Invert the ring projection: sky offsets to in-plane coordinates.

The exact inverse of ring_sky_from_plane() for B != 0 (an edge-on ring has no invertible projection; callers render nothing at B = 0). At |B| = 90 the mapping is a pure rotation, so r equals the sky-plane radius hypot(dv, du) – the flat-ring regression identity.

Parameters:
  • dv – Sky-plane v offsets from the ring center.

  • du – Sky-plane u offsets from the ring center.

  • opening_deg_obs – Observer ring opening angle B in degrees, nonzero.

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

Returns:

ring-plane radius, longitude from the ascending node in radians in [0, 2*pi), and the node-aligned in-plane coordinates.

Return type:

(r, lam, x, y)

Raises:

ValueError – If opening_deg_obs is 0 (edge-on; not invertible).

ring_radial_scale(r: NDArray[floating[Any]], x: NDArray[floating[Any]], y: NDArray[floating[Any]], *, opening_deg_obs: float) NDArray[floating[Any]][source]

Magnitude of the image-plane gradient of the ring-plane radius.

|grad r| = sqrt(x**2 + y**2 / sin(B)**2) / r: the change in ring-plane radius per image pixel of sky-plane displacement. Dividing a ring-plane radial distance by this converts it to image pixels, so an anti-aliased edge spans a constant width on the detector regardless of the foreshortening direction. At |B| = 90 the scale is exactly 1 everywhere (the sky-plane-circle identity); it grows toward the minor axis of an inclined ring, where radial structure is foreshortened.

Parameters:
  • r – Ring-plane radii (nonzero where meaningful; a zero radius yields a scale of 1 to keep the division benign at the exact center).

  • x – Node-aligned in-plane x coordinates.

  • y – Node-aligned in-plane y coordinates.

  • opening_deg_obs – Observer ring opening angle B in degrees, nonzero.

Returns:

The dimensionless radial foreshortening scale, >= 1.

ring_sky_from_plane(r: NDArray[floating[Any]], lam: NDArray[floating[Any]], *, opening_deg_obs: float, node_deg: float) tuple[NDArray[floating[Any]], NDArray[floating[Any]]][source]

Project ring-plane points (r, lam) to sky-plane offsets (dv, du).

Implements the normative projection: with node-aligned in-plane axes x = r*cos(lam), y = r*sin(lam),

  • du = x*cos(node) - y*sin(B)*sin(node)

  • dv = -(x*sin(node) + y*sin(B)*cos(node))

where B is the observer opening angle and node the sky position angle of the ascending node (see the module docstring for both conventions). lam is ring-plane longitude from the ascending node; node_deg enters only this final sky rotation.

Parameters:
  • r – Ring-plane radii (pixels; any consistent unit).

  • lam – Ring-plane longitudes from the ascending node, in radians.

  • opening_deg_obs – Observer ring opening angle B in degrees, (-90, 90].

  • node_deg – Sky position angle of the ascending node in degrees, counterclockwise from +u toward -v.

Returns:

(dv, du) sky-plane offsets from the ring center, in the units of r.

Shared construction of catalog star records from scene star parameters.

Both simulator sides build MutableStar records from a scene’s per-star mappings: the image-side renderer (spindoctor.sim.forward.star) to know what to draw and to report in its output metadata, and the navigator-side star model (spindoctor.nav_model.stars.nav_model_stars_simulated) to build its catalog from the filtered idealized view (obs.nav_params). Sharing one builder keeps the two sides’ defaults (position, magnitude, spectral class, PSF window) identical, so a scene that omits a field cannot silently give the renderer and the navigator different catalogs.

Only idealized star keys are read here. The record’s dn field is the catalog-derived relative flux 2.512 ** -(vmag - 4) – a pure function of the catalog magnitude, matching the real catalog reduction (spindoctor.nav_model.stars.catalog) – not a rendered pixel value.

star_record_from_params(star_params: dict[str, Any], *, index: int, default_v: float, default_u: float) MutableStar[source]

Build one catalog star record from a scene star mapping.

Parameters:
  • star_params – One scene stars entry (idealized keys only are read).

  • index – Zero-based position in the scene’s star list; drives the record’s unique number and default name.

  • default_v – V position used when the entry has no v (frame centre).

  • default_u – U position used when the entry has no u (frame centre).

Returns:

A fully populated star record at the unshifted catalog position.

Scene-spec schema for the simulator scene catalog.

A sim scene is a YAML file describing a synthetic frame the navigator can be run against: instrument, geometry, noise, stray light, and the planted offset the navigator should recover. The catalog is laid out as <scene_class>/<scene_name>.yaml (the directory is the registry).

The YAML fields are the flat runtime parameter names that the renderer (spindoctor.sim.render.render_combined_model()), spindoctor.obs.obs_inst_sim.ObsSim, and the GUI consume, so a validated scene file IS the sim_params mapping with no translation layer. load_sim_scene parses and validates a file and returns that dict; save_sim_scene validates a sim_params dict and writes it (injecting schema_version and scene_name); validate_sim_params validates an in-memory dict for programmatic scene authors. The validator is hand-rolled (no pydantic dependency): the key inventory and boundary classification live in spindoctor.sim.scene_schema and the per-field type checks in spindoctor.sim.scene_checks (body entries: spindoctor.sim.scene_checks_body; the ring_system block: spindoctor.sim.scene_checks_ring); this module is the public entry point and re-exports the boundary names.

The information boundary. Every key in the schema is classified as either idealized (information the production pipeline could know from catalogs, SPICE, labels, or config: exposed to the navigator through obs.nav_params) or truth (nature’s values, planted errors, variance knobs, and contaminants: readable only by the image-side renderer). build_nav_params constructs the filtered idealized view; TRUTH_KEYS is the machine-readable truth set the boundary test iterates. A key added to the schema without a classification fails the import-time completeness assertion (which runs when spindoctor.sim.scene_schema is imported here), so every future schema change must extend the boundary in the same change.

exception SimSceneValidationError[source]

Bases: ValueError

Raised when a sim scene YAML is missing or malformed.

build_nav_params(sim_params: dict[str, Any]) dict[str, Any][source]

Build the navigator’s filtered idealized view of a scene.

This is the information boundary (the independence guarantee of the simulator-realism program): the returned mapping contains only keys classified idealized, with every TRUTH_KEYS entry stripped. For bodies, a nav_override mapping is overlaid first and the key dropped, so the navigator sees the geometry it believes without learning the true values underneath. An object flagged navigable: false is dropped entirely, so a surviving object’s flag is always true and carries no hidden truth. All values are deep copies, so navigator-side code cannot mutate the renderer’s scene.

Parameters:

sim_params – The full scene mapping (the renderer’s input).

Returns:

The filtered nav_params mapping exposed as obs.nav_params.

iter_scene_paths(root: Path) list[Path][source]

Return every <class>/<name>.yaml scene path under root, sorted.

load_sim_scene(path: Path) dict[str, Any][source]

Parse and validate a sim scene YAML into a flat sim_params dict.

The returned mapping is exactly what spindoctor.sim.render.render_combined_model() and spindoctor.obs.obs_inst_sim.ObsSim consume; the schema_version and scene_name keys are metadata the renderer ignores.

Parameters:

path – Path to a <scene_name>.yaml file. The scene_name field must equal the filename stem.

Returns:

The validated flat sim_params mapping.

Raises:

SimSceneValidationError – On any missing/invalid field.

save_sim_scene(sim_params: dict[str, Any], path: Path) None[source]

Validate sim_params and write it to path as a flat YAML scene.

The schema_version and scene_name (= the filename stem) keys are injected so the written file validates on reload. Saving never mutates sim_params (the scene is deep-copied first), and the authored form is persisted verbatim – in particular optics.psf: {match_navigator: true} is written as authored, so it survives a save / load round-trip and is resolved only when the renderer builds the kernel.

Parameters:
  • sim_params – The flat GUI / render parameter mapping.

  • path – Destination <scene_name>.yaml path; its stem is the scene name.

scene_class_for_path(path: Path) str[source]

The scene class is the immediate parent directory name.

validate_sim_params(sim_params: dict[str, Any], *, source: str = 'sim_params') dict[str, Any][source]

Validate a flat sim_params mapping against the schema inventory.

This is the validation core shared by load_sim_scene(), save_sim_scene(), and programmatic scene authors (the calibration campaign generator, the doc-image galleries), which build dicts rather than files. schema_version and scene_name are optional here (a dict author has no filename); when present, the version must be current.

Validation never rewrites the scene: optics.psf: {match_navigator: true} stays in its authored form (the renderer resolves it into the navigator’s concrete Gaussian when it builds the kernel), so an editor’s live mapping and the persisted file both keep the author’s intent.

Parameters:
  • sim_params – The flat scene parameter mapping.

  • source – Label used in error messages (a path for file authors).

Returns:

sim_params, unchanged, for call-chaining.

Raises:

SimSceneValidationError – On any unknown or invalid field.

Key inventory and information-boundary classification for sim scenes.

Every top-level and per-object key a scene may carry is inventoried here (unknown keys fail validation so typos do not silently render the default scene), and every inventory key is classified as either idealized (information the production pipeline could know from catalogs, SPICE, labels, or config: exposed to the navigator through obs.nav_params), truth (nature’s values, planted errors, variance knobs, and contaminants: readable only by the image-side renderer), or test-only (the scene’s expected navigation outcome: read by the integration-test assertion machinery, and by neither the renderer nor the navigator). TRUTH_KEYS is the machine-readable truth set the ObsSim boundary filter strips and the structural boundary test iterates; the test-only keys are likewise stripped from the filtered view (default-deny), so they never reach the navigator either. The import-time completeness assertion below keeps the classification complete and disjoint over all three classes, so a key added to the schema without a classification fails everything loudly, not just one test.

spindoctor.sim.scene is the public entry point: it consumes this inventory for validation, builds the filtered navigator view from the classification, and re-exports the boundary names.

exception SimSceneValidationError[source]

Bases: ValueError

Raised when a sim scene YAML is missing or malformed.

Field-type validators for the sim-scene schema.

The _check_* / _require_* helpers here enforce the per-field types of every scene block: the per-object stars entries, the optics sub-blocks (PSF, smear, distortion, ghosts, stray light), the noise, detector, artifacts, and spk_error blocks, plus the primitive scalar checks they all share. Each block’s key inventory lives beside its checker (unknown keys fail validation, so a typo cannot silently render an un-blurred or clean frame). The bodies-entry checkers, the schema’s largest block, live in the sibling spindoctor.sim.scene_checks_body, and the ring_system block’s checkers in spindoctor.sim.scene_checks_ring; both build on the same primitives.

spindoctor.sim.scene.validate_sim_params() drives these helpers; every violation raises spindoctor.sim.scene_schema.SimSceneValidationError.

Body-entry field validators for the sim-scene schema.

One bodies entry carries the largest key surface in the schema: the idealized geometry, the mesh and crater shape knobs, and the body-appearance truth blocks (pose scatter, limb relief and photometry, albedo texture, disc texture, transits). The checkers for that entry live here, beside their key inventories; the scalar primitives they share with the other block checkers stay in spindoctor.sim.scene_checks, which also documents the overall validation contract (unknown keys fail, every violation raises spindoctor.sim.scene_schema.SimSceneValidationError).

spindoctor.sim.scene.validate_sim_params() drives _check_body_object once per bodies entry.

Ring-system field validators for the sim-scene schema.

The ring_system block is the schema’s one mapping-valued object block: the shared projection geometry, the radial optical-depth feature list (each entry carrying a kind, kind-specific shape keys, a catalog orbit with m-modes and an edge wave, planted orbit error, and photometric truth), and the truth-side azimuthal / moonlet clutter. Its checkers live here, beside their key inventories; the scalar primitives they share with the other block checkers stay in spindoctor.sim.scene_checks, which also documents the overall validation contract (unknown keys fail, every violation raises spindoctor.sim.scene_schema.SimSceneValidationError).

spindoctor.sim.scene.validate_sim_params() drives _check_ring_system once per scene.

Mapping from a sim instrument name to its per-instrument config block.

The simulator can render a frame as though it came from a specific camera, so that a ‘sim COISS NAC raw’ frame goes through the same per-instrument noise, saturation, marker, and unit settings the navigator applies to a real frame. This module resolves a sim instrument name to the matching config_4N0_inst_*.yaml block; physical parameters then delegate to that block, while sim-only knobs (signal full-scale, cosmic-ray rate, dropout rate) stay in the sim config block.

A value of None (or the generic aliases) selects the standalone sim block, preserving the instrument-agnostic defaults.

A scene may also carry instrument_config overrides that are deep-merged over the resolved block, so a scene can:

  • inherit every physical parameter from a named instrument (no overrides),

  • inherit and override individual parameters (override only those keys), or

  • fully self-specify by naming the generic block and overriding everything.

Overriding a key pins it to the scene, so a later change to the real camera’s config cannot silently shift a sim test’s behavior for that key; the non-overridden keys continue to track the instrument.

navigator_matched_psf(config: Config, instrument: str | None, overrides: Mapping[str, Any] | None = None) dict[str, float][source]

Return the navigator-matched whole-scene PSF block for an instrument.

The self-consistency floor sets the image-side PSF equal to the navigator’s own model: a pure Gaussian at the emulated instrument’s configured star_psf_sigma, with no Moffat wing and no field variation. A scene authors this as optics.psf: {match_navigator: true}; that authored form is preserved through save / load and in the editor, and the renderer calls this helper to resolve it into concrete kernel parameters when it builds the kernel.

Parameters:
  • config – The active configuration.

  • instrument – The sim instrument name, a generic alias, or None.

  • overrides – Optional scene-level instrument_config overrides.

Returns:

A concrete PSF parameter mapping (sigma_v, sigma_u, w, r0, n) equal to the navigator’s Gaussian.

resolve_extfov_margin(inst_config: Mapping[str, Any], fallback_config: Mapping[str, Any], size_v: int) Any[source]

Resolve the extended-FOV margin for a sim image of a given size.

A per-instrument margin table may be size-keyed and need not cover the sim image size; when it does not, the generic sim block’s margin is used.

Parameters:
  • inst_config – The resolved per-instrument config block.

  • fallback_config – The generic sim block to fall back to.

  • size_v – The sim image height in pixels (the margin-table key).

Returns:

The (v, u) margin entry.

Raises:

ValueError – If the generic fallback table is itself size-keyed and carries no entry for size_v.

resolve_sim_inst_config(config: Config, instrument: str | None, overrides: Mapping[str, Any] | None = None) Mapping[str, Any][source]

Resolve a sim instrument name to its per-instrument config block.

Parameters:
  • config – The active configuration.

  • instrument – A sim instrument name (see SIM_INSTRUMENTS), one of the generic aliases, or None for the instrument-agnostic block.

  • overrides – Optional scene-level overrides deep-merged over the resolved block. Overridden keys are pinned to the scene; the rest continue to track the instrument. Combined with the generic block this expresses a fully self-specified scene config.

Returns:

The resolved per-instrument config mapping (a fresh dict when overrides are supplied, otherwise the live config block).

Raises:

ValueError – If instrument is unrecognised, or the referenced config section / detector is missing.

Deterministic seed derivation for the simulator’s randomized effects.

Every randomized effect in the simulator (background noise, background stars, crater placement, and any effect added later) must draw from a stream that is byte-identical across processes and runs for identical inputs. Python’s built-in hash is unsuitable: it is salted per process for str and bytes (controlled by PYTHONHASHSEED), so hash((seed, 'noise')) varies between interpreter runs. These helpers derive sub-seeds from a stable cryptographic digest instead, so the same scene always renders the same pixels.

Sub-seeds are derived per effect by name rather than by draw order. Adding a new randomized effect therefore does not perturb the seeds of pre-existing effects, which keeps earlier rendered scenes and their regression baselines stable when a new effect lands.

derive_effect_seed(scene_seed: int, effect: str) int[source]

Derive a per-effect sub-seed from the scene’s single random seed.

The derivation is process-stable (independent of PYTHONHASHSEED) and keyed on the effect name, so distinct effects draw independent streams and adding a new effect leaves existing effects’ seeds unchanged.

Parameters:
  • scene_seed – The scene’s top-level random seed.

  • effect – A stable name identifying the effect (e.g. ‘noise’).

Returns:

An integer in [0, 2**32 - 1] suitable for seeding numpy.random.Generator.

stable_param_seed(*values: object) int[source]

Derive a process-stable seed from arbitrary parameter values.

Used as a fallback when no explicit seed is supplied but a deterministic stream is still required (e.g. crater placement keyed on body geometry).

Parameters:

values – The parameter values to derive a seed from; their repr must be stable across runs (numbers, strings, and tuples thereof are).

Returns:

An integer in [0, 2**32 - 1] suitable for seeding numpy.random.Generator.

Human-viewable PNG export for simulated images.

The simulator renders detector counts (DN), whose absolute range depends on the instrument full-scale and on any cosmic-ray spikes, so a raw cast to 8-bit would be unreadable: a single hot pixel can scale the whole frame to black. These helpers stretch a DN image to a visible grayscale PNG with a percentile clip (so a few outliers do not crush the body / star / ring signal) and an optional gamma that lifts dim features – a thin high-phase crescent, faint background stars – without blowing out a bright disc.

The two entry points are stretch_to_uint8() (DN array to an 8-bit array) and save_png() (write that array to a PNG file). render_scene_png() is the convenience that renders a sim_params scene and saves it in one call, used by the documentation-image and sweep-dump tooling.

render_scene_png(sim_params: dict[str, Any], path: str | Path | FCPath, *, ignore_offset: bool = True, low_percentile: float = 0.5, high_percentile: float = 99.5, gamma: float = 1.0, upscale: int = 1) Path[source]

Render a sim_params scene and save it as a viewable PNG.

Parameters:
  • sim_params – The scene parameters consumed by spindoctor.sim.render.render_combined_model().

  • path – Destination .png path.

  • ignore_offset – Render the unshifted geometry (the planted navigation offset is a small sub-pixel shift that is not meant to be visible); set False to render exactly what the navigator sees.

  • low_percentile – Percentile mapped to black.

  • high_percentile – Percentile mapped to white.

  • gamma – Display gamma; values above 1 lift dim features.

  • upscale – Integer nearest-neighbour magnification.

Returns:

The written path.

save_png(image: NDArray[floating[Any]], path: str | Path | FCPath, *, low_percentile: float = 0.5, high_percentile: float = 99.5, gamma: float = 1.0, upscale: int = 1) Path[source]

Stretch a DN image and write it as a grayscale PNG.

Parameters:
  • image – The DN image array.

  • path – Destination .png path; parent directories are created.

  • low_percentile – Percentile mapped to black.

  • high_percentile – Percentile mapped to white.

  • gamma – Display gamma; values above 1 lift dim features.

  • upscale – Integer nearest-neighbour magnification, so a small frame is still legible in a document (1 disables it).

Returns:

The written path.

stretch_to_uint8(image: NDArray[floating[Any]], *, low_percentile: float = 0.5, high_percentile: float = 99.5, gamma: float = 1.0) NDArray[uint8][source]

Stretch a DN image to an 8-bit grayscale array for human viewing.

The black and white points are taken at the requested percentiles of the finite pixels (not the absolute min/max), so a handful of cosmic-ray or saturated pixels do not collapse the rest of the frame to black. A gamma above 1 brightens the midtones, which makes a dim crescent or a faint star field legible alongside a bright disc. Non-finite pixels (the missing-data marker on calibrated frames) are mapped to the black point.

Parameters:
  • image – The DN image array.

  • low_percentile – Percentile mapped to black (0).

  • high_percentile – Percentile mapped to white (255).

  • gamma – Display gamma; values above 1 lift dim features.

Returns:

A uint8 array of the same shape, in [0, 255].

Statistics machinery for the sim-vs-real realism match.

This package holds the figure-of-merit (FOM) statistics the realism-match runner (tests/integration/sim_realism.py) computes over the curated image-library cohort and matched simulated frames:

Everything here is a pure function of numpy arrays and small dataclasses: no holdings access, no SPICE, no rendering. The runner supplies the pixels and the metadata; this package supplies the statistics, so the statistics are unit-testable on synthetic distributions with known answers.

FOM 7 (technique-diagnostic distributions) deliberately has no module here: it reuses divergence.w1_divergence() on diagnostics the runner collects from navigation runs, and is a read-only report – never a tuning target.

Measured artifact incidence rates for realism FOM 6.

These detectors measure, per frame, the rates of the artifact classes the catalog defaults describe: line loss (missing / interpolated rows), stationary hot pixels, and transient single-pixel spikes (cosmic rays and radiation hits). The same detectors run on real cohort frames and matched sim frames, so a detector’s bias cancels in the comparison; the absolute rates additionally document the cohort against the catalog’s per-instrument defaults (loss modes default to zero incidence under instrument_defaults – FOM 6 is the evidence for keeping or replacing those zeros).

The hot-pixel / transient-spike split needs more than one frame: a spike that recurs at one detector position across frames is a hot pixel, one that does not is a transient. measure_artifact_incidence therefore reports per-frame spike candidates, and split_stationary_spikes performs the cross-frame split.

class ArtifactIncidence(missing_line_fraction: float, spike_fraction: float, spike_positions_vu: NDArray[floating[Any]])[source]

Bases: object

Per-frame artifact measurements.

Parameters:
  • missing_line_fraction – Fraction of rows that are constant or exact neighbor interpolations (telemetry line loss).

  • spike_fraction – Fraction of pixels flagged as isolated positive spikes (hot pixels + transients, split across frames by split_stationary_spikes()).

  • spike_positions_vu(K, 2) integer positions of the flagged spikes, for the cross-frame stationary split.

missing_line_fraction: float
spike_fraction: float
spike_positions_vu: NDArray[floating[Any]]
measure_artifact_incidence(image: NDArray[floating[Any]], *, spike_q99_factor: float = 10.0) ArtifactIncidence[source]

Measure line loss and single-pixel spikes on one frame.

Line loss: a row counts as lost when it is constant while its neighbors are not, or when it exactly equals the mean of the adjacent rows (the interpolation a telemetry gap filler leaves). Constant rows in an all-constant frame (a blank frame) are not counted.

Spikes: a pixel counts as a spike when it exceeds its 3x3 median-filtered surrounding by spike_q99_factor times the 99th percentile of the frame’s absolute residuals. Anchoring the threshold to a high quantile of the residual distribution itself – rather than a MAD-based sigma – keeps the detector meaningful on real calibrated frames, whose residual texture is strongly non-Gaussian (quantization floors compress the MAD while banding and compression texture fatten the tails); an artifact spike (hot pixel near full well, cosmic ray) sits orders of magnitude above either. Scene point sources brighter than the threshold are also caught, which is why the comparison runs the same detector on both cohorts.

Parameters:
  • image – 2-D frame in its native units.

  • spike_q99_factor – Spike threshold as a multiple of the residual distribution’s 99th absolute percentile.

Returns:

The per-frame measurements.

split_stationary_spikes(per_frame: Sequence[ArtifactIncidence], *, min_recurrence: int = 2) tuple[float, float][source]

Split measured spikes into stationary (hot pixels) and transients.

Parameters:
  • per_frame – Incidence measurements from frames sharing a detector (same instrument, same frame shape).

  • min_recurrence – Number of frames a position must recur in to count as stationary.

Returns:

mean per-frame fractions of pixels flagged as stationary and transient spikes. (nan, nan) when fewer than min_recurrence frames are given (a single frame cannot distinguish the two).

Return type:

(stationary_fraction, transient_fraction)

Wasserstein-1 divergence and cohort-support labeling for the realism match.

The scalar divergence reported for every figure of merit is the Wasserstein-1 distance computed on quantile-clipped data (1st-99th percentile), normalized by the real distribution’s interquartile range. W1 is a transport metric in the variable’s own units – the actual reason to use it – but it is not outlier-robust (it grows linearly with displaced-tail distance), so the clip keeps a noise statistic from silently measuring an artifact statistic’s tails. No pass/fail threshold is attached; the number is reported per figure of merit and read by a human.

Each sample is winsorized at its own 1st/99th percentiles. Clipping the sim sample at the real sample’s bounds would cap the measured displacement of a grossly wrong sim distribution, hiding exactly the mismatch the statistic exists to surface; clipping each sample at its own tails removes only that sample’s outliers while preserving bulk displacement.

class CohortSupport(*values)[source]

Bases: Enum

How well a cohort supports a distributional statistic.

The labels: SUPPORTED means enough frames for a per-frame distribution statement; LIMITED means a comparison is reported but flagged as resting on too few frames for distributional confidence; and UNSUPPORTED means the statistic is not computed, so the instrument’s sim accuracy is bounded by unverified forward-model fidelity for this figure of merit.

LIMITED = 'limited'
SUPPORTED = 'supported'
UNSUPPORTED = 'unsupported'
class W1Result(w1: float, w1_normalized: float, real_iqr: float, n_real: int, n_sim: int)[source]

Bases: object

The scalar divergence between one real and one sim sample.

Parameters:
  • w1 – Wasserstein-1 distance between the winsorized samples, in the variable’s own units.

  • w1_normalizedw1 divided by the real distribution’s IQR; NaN when either sample is too small or the real IQR is zero (a degenerate real distribution is not a usable yardstick).

  • real_iqr – Interquartile range of the raw (unclipped) real sample.

  • n_real – Number of finite real samples.

  • n_sim – Number of finite sim samples.

n_real: int
n_sim: int
real_iqr: float
property usable: bool

True when the normalized divergence is a real number.

w1: float
w1_normalized: float
cohort_support(n_frames: int, *, supported_min: int = 8, limited_min: int = 2) CohortSupport[source]

Label the support a cohort of n_frames gives a per-frame statistic.

An IQR of two frames is not a statistic: below limited_min frames the figure of merit is unsupported and must be labeled as such rather than reported as a distribution. Between limited_min and supported_min the comparison is reported with an explicit low-count caveat.

Parameters:
  • n_frames – Number of cohort frames contributing to the statistic.

  • supported_min – Frame count at or above which the statistic is fully supported.

  • limited_min – Frame count at or above which a caveated comparison is reported at all.

Returns:

The support label.

w1_between_densities(x: NDArray[floating[Any]], real_density: NDArray[floating[Any]], sim_density: NDArray[floating[Any]]) W1Result[source]

W1 between two normalized densities sharing one support axis.

Used for profile-like figures of merit (power spectra, radial brightness profiles) where the comparison is between curve shapes: each curve is treated as a probability density over its axis, and W1 measures how far mass must move along the axis to turn one shape into the other. The result is in the axis’s units, normalized by the real density’s IQR along the axis. Quantile clipping is not applied: the support axis is a fixed finite grid, so there are no sample outliers to clip.

Parameters:
  • x – Support axis (ascending, e.g. spatial frequency or radius).

  • real_density – Non-negative curve values for the real cohort.

  • sim_density – Non-negative curve values for the sim frames.

Returns:

The W1Result; n_real/n_sim record the number of positive-mass support points of each curve.

w1_divergence(real: NDArray[floating[Any]], sim: NDArray[floating[Any]]) W1Result[source]

The winsorized, IQR-normalized Wasserstein-1 divergence of two samples.

Both samples are winsorized at their own 1st/99th percentiles, the Wasserstein-1 distance is computed between the winsorized samples, and the result is normalized by the raw real sample’s IQR.

Parameters:
  • real – Sample drawn from the real cohort (any shape; flattened).

  • sim – Sample drawn from the simulated frames (any shape; flattened).

Returns:

The W1Result; w1_normalized is NaN when either sample has fewer than 8 finite entries or the real IQR is zero.

Exposure-stratified dynamic-range statistics for realism FOM 5.

An unstratified cohort comparison measures what the spacecraft pointed at, not the forward model: a cohort of long ring exposures saturates more than a cohort of short satellite snaps regardless of detector fidelity. Every FOM 5 comparison therefore runs inside an exposure stratum, and a stratum is compared only when both the real and the sim side populate it.

class DynamicRangeStats(frac_saturated: float, frac_near_floor: float, percentiles: tuple[float, ...])[source]

Bases: object

Per-frame dynamic-range statistics.

Parameters:
  • frac_saturated – Fraction of pixels at or above the saturation level.

  • frac_near_floor – Fraction of pixels within one noise sigma of the frame’s floor (1st percentile) – the bias-hugging fraction.

  • percentiles – Signal values at SIGNAL_PERCENTILES.

frac_near_floor: float
frac_saturated: float
percentiles: tuple[float, ...]
frame_dynamic_range(image: NDArray[floating[Any]], *, saturation_level: float, noise_sigma: float) DynamicRangeStats[source]

Dynamic-range statistics of one frame in its native units.

Parameters:
  • image – 2-D frame.

  • saturation_level – Full-scale value in the frame’s units (for calibrated frames, the DN full scale propagated through the frame’s calibration transform).

  • noise_sigma – Per-pixel noise sigma in the frame’s units (from the FOM 1 paired-difference estimator); defines the near-floor band.

Returns:

The per-frame statistics.

stratify_by_exposure(exposures_sec: Sequence[float | None], *, edges_sec: Sequence[float] = (0.05, 0.5, 5.0)) dict[str, list[int]][source]

Group frame indices into exposure strata.

The default edges split at 50 ms, 500 ms, and 5 s, giving four strata that separate the short satellite snaps from the long ring and star-field exposures across the cohort instruments. Frames with no recorded exposure land in the 'unknown' stratum, which callers should compare only frame-by-frame (it mixes regimes).

Parameters:
  • exposures_sec – Per-frame exposure, or None where unrecorded.

  • edges_sec – Ascending stratum boundaries in seconds.

Returns:

Mapping from stratum label to the indices of its frames. Labels are 'lt_<edge>', '<lo>_to_<hi>', 'ge_<edge>', and 'unknown'; only populated strata appear.

Sky-region noise statistics for realism FOM 1.

Science frames have no flat-field pairs, so noise is estimated by local differencing inside near-uniform patches: paired horizontal pixel differences cancel scene structure that varies slowly across the patch, and dividing the difference scale by sqrt(2) recovers the per-pixel sigma. A robust (MAD-based) scale keeps residual stars, cosmic rays, and hot pixels in a patch from inflating the estimate; naive signal-binning would conflate scene texture with noise, which is exactly what this estimator avoids.

The sky spatial power spectrum (radially averaged over annuli of spatial frequency) catches banding and coherent noise that a scalar sigma cannot: white read noise is flat, banding is a comb.

class SkyPatch(v0: int, u0: int, size: int, mean: float, sigma: float)[source]

Bases: object

One near-uniform patch selected from a frame.

Parameters:
  • v0 – Top row of the patch (inclusive).

  • u0 – Left column of the patch (inclusive).

  • size – Patch edge length in pixels.

  • mean – Mean signal inside the patch.

  • sigma – Paired-difference noise sigma inside the patch.

mean: float
sigma: float
size: int
u0: int
v0: int
find_uniform_patches(image: NDArray[floating[Any]], *, patch_size: int = 32, max_mean_quantile: float | None = 0.25, max_structure_ratio: float = 2.0) list[SkyPatch][source]

Select near-uniform patches from a frame.

The frame is tiled into non-overlapping patch_size squares. A patch qualifies when (a) its mean lies at or below the frame’s max_mean_quantile patch-mean quantile (sky selection; disable by passing None to accept every signal level for the noise-vs-signal statistic), and (b) its internal structure is difference-dominated: the patch’s total standard deviation does not exceed max_structure_ratio times its paired-difference sigma. Condition (b) rejects patches crossed by limbs, rings, or bright stars, whose spatial structure would masquerade as noise.

Parameters:
  • image – 2-D frame in its native units.

  • patch_size – Edge length of the square tiles.

  • max_mean_quantile – Patch-mean quantile at or below which a patch counts as sky, or None to skip the sky cut.

  • max_structure_ratio – Maximum allowed ratio of total patch standard deviation to paired-difference sigma.

Returns:

The qualifying patches with their means and sigmas.

paired_difference_sigma(patch: NDArray[floating[Any]]) float[source]

Robust per-pixel noise sigma from paired horizontal differences.

Parameters:

patch – 2-D array of pixel values (a near-uniform region).

Returns:

MAD(d) * 1.4826 / sqrt(2) where d are horizontal neighbor differences; NaN for a degenerate patch.

radial_power_spectrum(patch: NDArray[floating[Any]], *, n_bins: int = 16) tuple[NDArray[floating[Any]], NDArray[floating[Any]]][source]

Radially averaged spatial power spectrum of one patch.

The patch is mean-subtracted and windowed (Hann, separable) to suppress edge leakage, then the 2-D periodogram is averaged over annuli of spatial frequency. The DC bin is excluded.

Parameters:
  • patch – 2-D square array of pixel values.

  • n_bins – Number of radial frequency bins between 0 and the Nyquist frequency (0.5 cycles / pixel).

Returns:

annulus-center spatial frequency in cycles / pixel and mean periodogram power. Empty annuli carry NaN power.

Return type:

(freq, power) arrays of length n_bins

Star radial profiles and edge-normal profiles for realism FOMs 2-4.

FOM 2 compares star-cutout radial profiles and encircled energy between matched sim and real frames. FOMs 3 and 4 compare intensity profiles sampled perpendicular to a predicted polyline (a body limb or a ring edge) after shifting the polyline by the frame’s known offset; the profile’s 10-90% rise width is the scalar each vertex contributes to the compared distribution – it is what a DT-based technique’s gradient threshold actually sees.

All sampling is bilinear (scipy.ndimage.map_coordinates order 1) on the caller’s image array; coordinates follow the (v, u) convention.

edge_normal_profiles(image: NDArray[floating[Any]], vertices_vu: NDArray[floating[Any]], normals_vu: NDArray[floating[Any]], *, half_length_px: float = 8.0, n_samples: int = 33) NDArray[floating[Any]][source]

Sample intensity profiles along each vertex’s outward normal.

Each row of the result is the image sampled at n_samples points from -half_length_px (inside the edge) to +half_length_px (outside, along the outward normal) centered on the vertex. Vertices whose sample track leaves the image are dropped.

Parameters:
  • image – 2-D frame.

  • vertices_vu(N, 2) vertex positions (v, u) – already shifted by the frame’s known offset so they lie on the actual edge.

  • normals_vu(N, 2) outward normal per vertex (normalized here).

  • half_length_px – Half-length of the sampling track in pixels.

  • n_samples – Sample count along the track (odd keeps a center tap).

Returns:

(M, n_samples) array of sampled profiles, M <= N.

ee_radius(radius: NDArray[floating[Any]], ee: NDArray[floating[Any]], fraction: float) float[source]

Radius at which the encircled energy first reaches fraction.

Parameters:
  • radius – Radii from encircled_energy().

  • ee – Monotone encircled-energy curve.

  • fraction – Energy fraction in (0, 1), e.g. 0.5 for EE50.

Returns:

Linear interpolation of the crossing radius; NaN when the curve never reaches fraction or is all-NaN.

encircled_energy(radius: NDArray[floating[Any]], intensity: NDArray[floating[Any]]) tuple[NDArray[floating[Any]], NDArray[floating[Any]]][source]

Cumulative encircled energy from an azimuthally averaged profile.

Each radial bin contributes intensity * 2 * pi * r * dr; the cumulative sum is normalized to 1 at the outer radius. Negative bin contributions (background over-subtraction) are clipped at zero before accumulation.

Parameters:
  • radius – Bin-center radii from radial_profile().

  • intensity – Matching mean intensities (NaN bins contribute zero).

Returns:

(radius, ee) where ee climbs from ~0 to 1; all-NaN when the profile carries no positive energy.

profile_rise_width(profile: NDArray[floating[Any]], *, spacing_px: float, lo: float = 0.1, hi: float = 0.9) float[source]

10-90% rise width of one edge profile, in pixels.

The profile is normalized between its inside plateau (median of the first quarter of samples) and outside plateau (median of the last quarter); the width is the distance between the outermost hi crossing and the innermost lo crossing of the normalized descent. Works for either polarity (bright-inside limbs and bright-outside ring gaps) by orienting on the plateau difference.

Parameters:
  • profile – 1-D sampled profile (inside first, outside last).

  • spacing_px – Pixel distance between adjacent samples.

  • lo – Lower normalized level of the rise measurement.

  • hi – Upper normalized level of the rise measurement.

Returns:

The rise width in pixels; NaN when the plateaus do not separate (no measurable edge) or the crossings cannot be bracketed.

radial_profile(image: NDArray[floating[Any]], center_vu: tuple[float, float], *, r_max: float = 8.0, n_bins: int = 16) tuple[NDArray[floating[Any]], NDArray[floating[Any]]][source]

Azimuthally averaged radial profile around a point source.

The local background (median of the annulus just outside r_max) is subtracted so profiles from frames with different pedestals compare.

Parameters:
  • image – 2-D frame.

  • center_vu – Sub-pixel (v, u) center of the source.

  • r_max – Outer radius of the profile in pixels.

  • n_bins – Number of radial bins between 0 and r_max.

Returns:

(radius, intensity) arrays of length n_bins; empty bins carry NaN. Returns all-NaN intensity when the cutout leaves the image.