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, andbody_occlusion(per-body mutual-event truth:visible_fractionandoccluded_limb_arc_degagainst 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
oversamplekey wins. Otherwise a scene with an active PSF – anoptics.psfblock or theinstrument_defaultsopt-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:
objectThe 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.
- class Stage(*args, **kwargs)[source]
Bases:
ProtocolOne rendering stage: a pure in-place transform of a
SimFrame.- Parameters:
frame – The frame to mutate.
params – The full validated scene
sim_paramsmapping. 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**2subsamples, so the intensivesignalpasses 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 byos. Atoversample == 1this 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
SimFramefor 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
signalandpoint_eplanes.
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
framein the fixed order, mutating it in place.- Parameters:
frame – The frame to render into.
params – The full scene
sim_paramsmapping (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
truthdict 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_seedinstead 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:
objectOne translucent-screen application over the composed image.
Applied to the
(box_v, box_u)view of the frame asview[mask] = intensity[mask] + transmission[mask] * view[mask](the screen is identity outside its box);is_ringroutes 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).
- 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_kmis the compositing depth. Two bodies whose painted pixels overlap must both carry an explicit scenerange_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 scenerange_kmon 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_kmon 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_kmhas 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:
shadingselects 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_degapply 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_scatterdraws 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 aspose_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 keysshading,limb_relief_*, andpose_scatterare read here). The mapping is never mutated: when a pose scatter is drawn, the returned body info’sparamsis a copy of this mapping withpose_scatter_drawn_degadded (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:
objectEverything 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
oversample1, 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
- disc_texture: DiscTextureSpec | None = None
- 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
sizeintensity 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:
objectOne body’s
albedo_textureblock, 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.
- 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:
objectOne body’s
disc_textureblock (zones/belts plus storm ovals).The band factor is
1 + band_amplitude * cos(band_wavenumber * lat_p + band_phase)withlat_pthe body-polar latitude in radians, soband_wavenumbercounts full cosine cycles per radian of latitude (a Saturn-like scene uses ~6-10) andband_phase_degslides 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.
- class TransitSpec(moon: tuple[float, float, float, float] | None = None, shadow: tuple[float, float, float, float] | None = None)[source]
Bases:
objectOne
transitsentry: 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 by1 - darkness. None for a moon-only entry.
- albedo_spec_from_params(body_params: Mapping[str, Any], *, seed: int) AlbedoTextureSpec | None[source]
Build an
AlbedoTextureSpecfrom 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
intensityin 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
DiscTextureSpecfrom 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
TransitSpectuple (empty withouttransits).- Parameters:
body_params – One scene body entry.
- Returns:
One spec per
transitsentry, 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:
objectDiagnostics 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.
- class ReliefField(grid: NDArray[floating[Any]], rms: float, corr_deg: float, h_min: float, h_max: float)[source]
Bases:
objectOne realization of the fractional relief field
h(lat, lon).- Parameters:
grid –
(n, n)periodic field over the full (lat, lon) torus; rowiis latitude-pi + 2*pi*i/n(the limb equator is rown // 2), columnjis longitude2*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).
- limb_delta(phi: NDArray[floating[Any]]) NDArray[floating[Any]][source]
The limb perturbation
delta(phi)at azimuthsphi(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*piinclusive (the final node repeats the first, closing the circle) and the field values on the equator row, ready fornp.interp.- Return type:
Tuple
(phi_nodes, values)
- 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_pxpixels of surface arc (the image-plane step isstep / arc_per_pxat 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 distancedsatisfiesH_up - H_pt > d / tan(i_pt). The per-point capd_max = min((H_max - H_min) * tan(i_pt), sqrt(2 * R * H_max))bounds the work;H_max/H_minare 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
ReliefFieldsampling 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
ReliefFieldrealization (holding the albedo perturbation; the multiplicative factor is1 + 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
ReliefFieldrealization.
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)) * mu0with the McEwen (1991) cubic blendL(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_incidence –
mu0per pixel (negative values are night side).cos_emission –
muper 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:taubetweenr_eandr_e + w(anti-aliased edges).gap: the same band as a tau suppression (subtracts, clipped at zero).edge: a one-sided step;side: 'in'carriestauforr <= r_e(a sheet bounded by its outer edge, the B-ring-edge case),side: 'out'forr >= r_e.ramp: a linear transition across[r_e, r_e + w];side: 'out'rises from 0 atr_etotauatr_e + w(compose with anedgethere 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 atr_e:dtau(x) = tau * exp(-x / damping) * sin(2*pi*x / wavelength)forx = r - r_e >= 0and exactly zero upstream (the clamp mirrors the azimuthal edge wave’s: the envelope grows without bound forx < 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 limitA/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:
objectPer-pixel maps of a rendered ring system on the (oversampled) grid.
- Parameters:
intensity – Emitted ring brightness
Iper pixel (normalized signal units).transmission –
exp(-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_kmper pixel, or None when the scene gives the system norange_km(the system then has no depth relation to bodies; overlap is a scene error).
- 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.5with alpha the phase angle (0 at opposition): negativegbackscatters (bright at low phase), positivegforward-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_degplus 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_systemmapping.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_kmis 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 * Pthis is the emitted intensity: the lit form saturates towardmu0/(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_sigmathe effective core area is2*pi*sigma**2pixels, so the noise over the core isread_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*mper square degree, scaled by the frame’s field of view (the detector pixel count times the angular pixel scale squared) and thedensity_factormultiplier, 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)atm = 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 theoversample**2weight, 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-starcatalog_error_*plus the seeded scene scatter), at a variable brightness (delta_mag), and with an unresolvedcompanion– 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**2weight).
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:
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.
Distortion warps the geometric image: the residual field-position error the navigator does not correct maps where each point of the scene lands.
PSF blurs the mapped image by the aperture’s core-plus-wing kernel; the limb, ring-edge, and star profiles all inherit it.
Ghosts add displaced, defocused, low-amplitude copies of the formed focal-plane image (internal reflections).
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
opticsmapping contributes nothing.- Parameters:
frame – The frame whose signal and point-source planes are modified.
params – The full scene mapping; reads the
opticsblock.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
modelis 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.psfblock wins; the authored navigator-matched form ({match_navigator: true}) resolves here to the navigator’s own Gaussian at the emulated instrument’s configuredstar_psf_sigma. Otherwiseinstrument_defaultssupplies 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.smearlist 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)/2to+(dv, du)/2about 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_seedfor the non-radial field’s stream and, whendistortionis not passed, theoptics.distortionblock.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.ghostslist 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:
objectThe 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_pixelsartifact 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_8bitquantization 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.
- 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.truthwere 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_espills along the column (the v axis) up tobloom_lengthpixels 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 readfull_well_e / gainDN 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
modeis 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_paramsmapping.- 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:
objectThe 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_pixelsartifact 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_8bitquantization 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.
- 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_paramsmapping.- 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.truthwere 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_espills along the column (the v axis) up tobloom_lengthpixels 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 readfull_well_e / gainDN 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
modeis 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_pxwith a per-seed random phase, plus a smaller per-row random-phase component. Whenfreq_step_factordiffers 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_factortimes 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
pixelsas[v, u]pairs and theiramplitudes_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 fromcandidate_poolfor 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.
bothlays down a vertical comb plus a horizontal band. A mid-image readout pause steps the horizontal spatial frequency byfreq_step_factorand, whendark_step_dnis set, steps the dark level at that line (converted to electrons throughgain_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_eis the extra dark charge at the last line, andnonlinearbends the growth (an exponent != 1 gives the vidicon’s nonlinear wait-time dependence).rbi_column_factoradds the Cassini residual-bulk-image flavor: columns read out above a hot pixel carry an enhanced ramp, so the listedhot_columnsget 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_pxpixels raised bystitch_amplitude_dn; the jail bars are an even/odd column offset ofjail_bar_dnwhose 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 reachingvignetting_fracat the corners), anddust_donut_countfaint 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_offsetaddsamplitudetimes a copy of the frame shifted by(offset_v, offset_u);flatadds a uniformamplitudetimes 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_fracof the ADC ceiling seeds a tail oflength_pxindirection. 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_factorat line 0 tobottom_factorat 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:
Lossy DCT compression (
compression_dct): the codec blockiness a lossy downlink plants on the transmitted signal before any packet loss, honoring the commandedtruth_windowcarve-out.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 commandedtruth_windowcarve-out resolved first and passed tomissing_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.Voyager GEOMED archive-processing scars, applied to the already-loss-bearing frame:
reseau_scars(reseau-removal smudges on the lattice) thenresample_texture(the GEOMED resample warp, blank border, and missing-line interpolation banding).Missing-data markers: the generic per-pixel
noise.missing_data_ratedropout 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
artifactsblock (per-mode maps plusadversarial) and thenoiseblock’smissing_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:dropblanks every Nth line from the phase (the severe-Huffman entropy dropout, which loses one line per period), whilekeepblanks 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
countfixes 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
countfixes 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_framekeeps 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_pxpixels 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_linesrow grid (8 lines for Galileo ICT slices). Withstart_mid_linethe 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). Aprotectrectangle (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
bitflipamplitude 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); theuniformmodel replaces it with a random DN. Both stay inside the instrument’s ADC word: the flipped bit positions and the uniform draw are bounded bydn_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
linescount wins overfraction; 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
blockxblocktile to the DCT domain, quantizes the coefficients to a step ofscale_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 irregularblank_border_pxborder, 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_pxdisc 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 intoframe.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_linesrow grid; a lost block zeros every row of its block, so a grid-aligned band ofblock_linesfully-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_runfloor 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_runand 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
truthmapping (frame.truth); itsartifactssub-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:
objectThe 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.
- 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
npixels: near features when adversarial, else uniform.An adversarial draw picks a feature pixel and jitters it within
radiuspixels, 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
nrows: 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
radiusof 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
truthmapping (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:
objectOne 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.
- 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:
objectThe 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 atdetached_px, so the same smooth atmosphere re-expressed at a differentref_altitude_px(withtau_refrescaled 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 withdetached_pxset 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.
- class HaloScreen(emission: NDArray[floating[Any]], transmission: NDArray[floating[Any]], box_v: slice, box_u: slice, mask: NDArray[bool])[source]
Bases:
objectThe 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_behindover the screen’s pixels, and point sources (which sit at infinity, behind every halo) multiply bytransmission.- 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.
- 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
AtmosphereSpecfrom a body’satmosphereblock.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
oversampleexactly 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
atmosphereblock.
- 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, socos(Theta) = -cos(phase); a positivegpeaks atTheta= 0 (phase = pi), which is why forward-scattering haze is brightest at high phase. The factor is normalized to 1 atg= 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:
objectA registered artifact mode.
- Parameters:
name – The registry key (also the
artifactsblock key).stage – The rendering stage that applies it,
telemetryordetector.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 (
incidencefirst, 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
incidencefor 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.
- class ModeParam(name: str, kind: str, default: Any = None, choices: tuple[Any, ...] | None = None, length: int | None = None)[source]
Bases:
objectOne 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(withchoices), orint_list(withlength).default – The value used when the key is absent.
Nonemarks an optional parameter with no default shape (the renderer supplies its own fallback, e.g. a centred window).choices – The permitted values for an
enumparameter.length – The required length of an
int_listparameter.
- mode_available(mode_name: str, instrument: str | None) bool[source]
Whether
mode_nameis available oninstrument.- 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.
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 togeneric, 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, orNonefor the instrument-agnostic block.- Returns:
A fresh copy of the instrument’s
DETECTOR_DEFAULTSentry, 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_modesinDETECTOR_DEFAULTS), then the registry default.incidenceis 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 underinstrument_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.domainis'dn'for the vidicon (its point sources are DN) and'electrons'for every CCD camera and the generic detector.zero_pointis 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:
objectThe projected-ellipsoid working grids both simulator sides shade from.
Produced by
project_ellipsoid(); every array lives on the working grid (the render grid supersampled byaa_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).
- 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 issin(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
EllipsoidProjectionworking 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:
objectA 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.
- 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:
objectThe 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.
- 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_octavesadds banks of higher-frequency modes with geometric amplitude falloff: octavekdrawsn_modesfresh modes with angular wavenumbers in[3 * 2**(k-1) + 1, 3 * 2**k](the base modes occupy 1..3) at amplitude0.5**krelative to the base bank. Octave draws consume the generator after the base draws, sodetail_octaves = 0reproduces 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_lonshould 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
MeshBodySpecfrom 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_degdoes 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_bodyso 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:
lamis 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_degis 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_obsis the observer’s ring opening angle B in (-90, 90], positive north.|B| = 90reduces 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 forB > 0the near arm is they < 0half 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:
objectA 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 oflam0: 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 forlam < lam0– and the modular form is chosen because rings are periodic and the wave physically wraps the full turn: immediately upstream oflam0it carries a wrap-seam residual ofamp * exp(-2*pi/damp)of its launch amplitude. The scene validator capsdampat 2.0 radians, bounding that residual atexp(-pi), about 4.3% ofamp.- 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).
- class RingOrbit(a: float, ae: float, long_peri: float, rate_peri: float, modes: tuple[RingOrbitMode, ...] = (), edge_wave: RingEdgeWave | None = None)[source]
Bases:
objectA ring feature’s full orbit model: mode-1 ellipse plus perturbations.
This is the shared parsed form of the idealized per-feature
orbitscene 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.
- edge_wave: RingEdgeWave | None = None
- modes: tuple[RingOrbitMode, ...] = ()
- 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
drradial units.A feature’s outer edge is its inner-edge orbit widened by the radial width: the ellipse grows by
drwhile 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:
objectOne m >= 2 radial mode of a ring-feature orbit.
The mode perturbs the edge radius by
-amp * cos(m * (lam - peri))withlamthe ring-plane longitude from the ascending node, so for a pure m-mode on a circular base orbitr(lam) = a - amp * cos(m * (lam - peri))exactly (the normative closed form).ampisa * ein the same radial units as the semimajor axis;periis 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).
- 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(orr_outer - r) so a pixel inside the band shades toward 1, and a moonlet disc passesradius - distso 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 / ais 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 / ais 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)withdlam = (lam - lam0) mod 2*piin [0, 2*pi): the wave exists only DOWNSTREAM of the launch longitude, so the exponential argument is never negative (evaluating the raw form forlam < lam0would grow without bound – the clamp is load-bearing). The wrapped wave carries an upstream residual ofamp * exp(-2*pi/damp)just beforelam0; the validator’s cap ofdamp <= 2.0radians bounds it atexp(-pi), about 4.3% ofamp.ais 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 (
dampin 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 / ais 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: forB > 0the near arm is they < 0half, the ring’s nearest point sits atlam = 270degrees whennode = 0, and the ansae (lam = 0and180) have zero depth by construction. Compositing against a body orders by observer distancerange_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
orbitscene 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[].orbitmapping.- 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()forB != 0(an edge-on ring has no invertible projection; callers render nothing atB = 0). At|B| = 90the mapping is a pure rotation, sorequals the sky-plane radiushypot(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_obsis 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| = 90the 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
Bis the observer opening angle andnodethe sky position angle of the ascending node (see the module docstring for both conventions).lamis ring-plane longitude from the ascending node;node_degenters 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 ofr.
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
starsentry (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:
ValueErrorRaised when a sim scene YAML is missing or malformed.
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_KEYSentry stripped. For bodies, anav_overridemapping is overlaid first and the key dropped, so the navigator sees the geometry it believes without learning the true values underneath. An object flaggednavigable: falseis 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_paramsmapping exposed asobs.nav_params.
- iter_scene_paths(root: Path) list[Path][source]
Return every
<class>/<name>.yamlscene path underroot, sorted.
- load_sim_scene(path: Path) dict[str, Any][source]
Parse and validate a sim scene YAML into a flat
sim_paramsdict.The returned mapping is exactly what
spindoctor.sim.render.render_combined_model()andspindoctor.obs.obs_inst_sim.ObsSimconsume; theschema_versionandscene_namekeys are metadata the renderer ignores.- Parameters:
path – Path to a
<scene_name>.yamlfile. Thescene_namefield must equal the filename stem.- Returns:
The validated flat
sim_paramsmapping.- Raises:
SimSceneValidationError – On any missing/invalid field.
- save_sim_scene(sim_params: dict[str, Any], path: Path) None[source]
Validate
sim_paramsand write it topathas a flat YAML scene.The
schema_versionandscene_name(= the filename stem) keys are injected so the written file validates on reload. Saving never mutatessim_params(the scene is deep-copied first), and the authored form is persisted verbatim – in particularoptics.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>.yamlpath; 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_paramsmapping 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_versionandscene_nameare 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:
ValueErrorRaised 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.
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 asoptics.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_configoverrides.
- 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
simblock’s margin is used.- Parameters:
inst_config – The resolved per-instrument config block.
fallback_config – The generic
simblock 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, orNonefor 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
overridesare supplied, otherwise the live config block).- Raises:
ValueError – If
instrumentis 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
reprmust 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_paramsscene and save it as a viewable PNG.- Parameters:
sim_params – The scene parameters consumed by
spindoctor.sim.render.render_combined_model().path – Destination
.pngpath.ignore_offset – Render the unshifted geometry (the planted navigation offset is a small sub-pixel shift that is not meant to be visible); set
Falseto 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
.pngpath; 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
gammaabove 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
uint8array 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:
spindoctor.sim.realism.divergence: the Wasserstein-1 divergence on quantile-clipped samples, normalized by the real distribution’s IQR, plus cohort support labeling.spindoctor.sim.realism.noise: sky-region statistics – near-uniform patch selection, the paired-difference noise estimator, and the sky spatial power spectrum (FOM 1).spindoctor.sim.realism.profiles: star-cutout radial profiles and encircled energy (FOM 2), and edge-normal profile sampling shared by the limb (FOM 3) and ring-edge (FOM 4) comparisons.spindoctor.sim.realism.dynamic_range: exposure-stratified dynamic range statistics (FOM 5) and the stratification logic itself.spindoctor.sim.realism.artifact_incidence: measured artifact rates (missing lines, hot pixels, transient spikes) for FOM 6.
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:
objectPer-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.
- 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_factortimes 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 thanmin_recurrenceframes 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:
EnumHow well a cohort supports a distributional statistic.
The labels:
SUPPORTEDmeans enough frames for a per-frame distribution statement;LIMITEDmeans a comparison is reported but flagged as resting on too few frames for distributional confidence; andUNSUPPORTEDmeans 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:
objectThe 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_normalized –
w1divided 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.
- cohort_support(n_frames: int, *, supported_min: int = 8, limited_min: int = 2) CohortSupport[source]
Label the support a cohort of
n_framesgives a per-frame statistic.An IQR of two frames is not a statistic: below
limited_minframes the figure of merit is unsupported and must be labeled as such rather than reported as a distribution. Betweenlimited_minandsupported_minthe 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_simrecord 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_normalizedis 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:
objectPer-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.
- 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:
objectOne 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.
- 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_sizesquares. A patch qualifies when (a) its mean lies at or below the frame’smax_mean_quantilepatch-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 exceedmax_structure_ratiotimes 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)wheredare 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 lengthn_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_samplespoints 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
fractionor 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)whereeeclimbs 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
hicrossing and the innermostlocrossing 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 lengthn_bins; empty bins carry NaN. Returns all-NaN intensity when the cutout leaves the image.