Source code for spindoctor.sim.forward.artifacts_catalog

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

import copy
from collections.abc import Mapping
from typing import Any

from spindoctor.sim.forward.artifact_modes import ARTIFACT_MODES, MODE_KEYS

__all__ = [
    'ARTIFACT_MODES',
    'DETECTOR_DEFAULTS',
    'DISTORTION_RESIDUAL_RMS_PX',
    'MODE_KEYS',
    'PSF_KERNELS',
    'SKY_PIXEL_SCALE_ARCSEC',
    'STAR_FLUX_DN_PER_S_VMAG0',
    'STAR_FLUX_E_PER_S_VMAG0',
    'resolve_detector_defaults',
    'resolve_mode_with_catalog',
    'resolve_sky_pixel_scale_arcsec',
    'resolve_star_flux_zero_point',
]

# The artifact-mode registry (:mod:`spindoctor.sim.forward.artifact_modes`) is the
# single source of truth for the ``artifacts`` block's per-mode keys, their
# rendering stage, per-instrument availability, and parameter schemas.  It is
# re-exported here so the detector catalog and the mode registry share one
# import point.  The *missing-data* loss modes default to 0 even under
# ``instrument_defaults`` -- naming an instrument selects a signal chain, not a
# set of transmission defects -- so they are planted only by scenes that
# configure them explicitly.  Cosmic rays are different: radiation on the
# detector is environment physics, so each instrument entry carries a
# ``cosmic_ray_rate_per_sec`` that is either cohort-measured or an explicit
# retained zero with its reason (see the per-entry comments).

# Whole-scene PSF kernel parameters per instrument, all radii in detector
# pixels.  Wing parameters (w, r0, n) are expressed as wing energy fractions
# so the delivered kernels conserve flux.
#
# Provenance:
# - coiss_nac: TUNED 2026-07-17 by the realism match against the Cassini
#   CALIB image-library cohort (brightest unsaturated stars over the 11
#   contributing NAC star frames): cohort encircled-energy radii EE50 = 0.91 px,
#   EE80 = 1.79 px; these parameters reproduce EE50/EE80 = 0.90/1.72 px
#   through the same estimator.  The EE80/EE50 ratio (1.97 vs 1.52 for a
#   pure Gaussian) requires substantial mid-range wing energy, so this is
#   an *effective* in-window kernel: the fitted w = 0.12 carries the
#   measured 1-8 px halo energy that the interim FWHM-derived core
#   (sigma 0.55, w 0.025) put nowhere.  A first fit at w = 0.20 matched
#   the encircled energy but measurably over-lifted the wide-field halo
#   (sky-patch means and mid-size-body limb widths); w = 0.12 with the
#   softer core matches the stars without that excess.  The far halo
#   beyond the truncation window remains stray-light scope.  Replaces the
#   interim FWHM/2.355 value.
# - coiss_wac: TUNED 2026-07-17 from the cohort's two WAC star frames
#   (9 usable star cutouts): cohort EE50/EE80 = 1.33/2.16 px through the
#   final estimator; sigma follows the measured EE50 with the NAC wing
#   shape.  Two-frame evidence -- treat as cohort-limited, revisit when
#   more WAC star frames land.  Replaces the interim FWHM/2.355 value.
# - vgiss: RETAINED interim estimate: the cohort's one star frame (8
#   usable star cutouts, EE50 1.22 px) cannot constrain the kernel
#   through GEOMED resampling (the flat-top guard leaves two simulated
#   cutouts to compare against); the Voyager references publish no FWHM.
# - gossi: RETAINED interim published sigma: the Galileo cohort holds no
#   star frames (negative cases only), so there is no independent PSF
#   evidence; gossi sim accuracy is bounded by unverified PSF fidelity
#   until the star-calibration frames land.
# - nhlorri: RETAINED interim elliptical values (LORRI PSF references,
#   1x1 mode).  The cohort's two star frames are 4x4-binned (measured
#   binned-pixel EE50 0.59 px) and cannot constrain the 1x1 kernel; a
#   per-readout-mode kernel is future work.
PSF_KERNELS: dict[str, dict[str, float]] = {
    'coiss_nac': {'sigma_v': 0.70, 'sigma_u': 0.70, 'w': 0.12, 'r0': 3.0, 'n': 4.0},
    'coiss_wac': {'sigma_v': 1.05, 'sigma_u': 1.05, 'w': 0.12, 'r0': 3.0, 'n': 4.0},
    'vgiss': {'sigma_v': 0.85, 'sigma_u': 0.85, 'w': 1.2e-2, 'r0': 2.0, 'n': 3.0},
    'gossi': {'sigma_v': 0.80, 'sigma_u': 0.80, 'w': 1.2e-2, 'r0': 2.0, 'n': 3.0},
    'nhlorri': {'sigma_v': 1.13, 'sigma_u': 0.87, 'w': 1.2e-2, 'r0': 2.0, 'n': 3.0},
}

# Residual geometric-distortion RMS displacement over the frame, in detector
# pixels: the error remaining after the navigator applies each instrument's
# known distortion model (the only distortion actually present in the frames
# the pipeline consumes).  All values are interim, pending the per-instrument
# star-field residual measurement; the Cassini corrected field is sub-pixel,
# Voyager GEOMED products carry ~1 px internal error, and the mapped-field
# cameras sit in between.
DISTORTION_RESIDUAL_RMS_PX: dict[str, float] = {
    'coiss_nac': 0.1,
    'coiss_wac': 0.1,
    'gossi': 0.05,
    'nhlorri': 0.05,
    'vgiss': 1.0,
}


# Photometric zero point: the flux a magnitude-0 star deposits per second of
# exposure, so a star's total signal is
# ``zero_point * 10**(-0.4 * vmag) * exposure_sec`` -- flux normalization, not a
# peak-normalized profile (the optics PSF then dictates the shape and the peak).
# The CCD table is in electrons per second (deposited into the detector's
# electron plane before Poisson); the vidicon table is in DN per second (the
# vidicon has no electron domain, so its point sources are DN).  All values are
# interim and provenance-tagged:
# - The Cassini / LORRI / Galileo electron zero points are sized from each
#   camera's Section 5 limiting magnitude at SNR ~5 (a magnitude-0 star of a
#   given zero point lands its limiting magnitude near the matched-filter
#   detection boundary at that camera's read noise and PSF core).
# - The Voyager DN zero point is sized so the 5.3 vidicon limiting magnitudes
#   land at the matched-filter boundary in the DN chain.
# - The generic zero point is DERIVED, not measured: it reproduces the prior
#   peak-normalized brightness of a magnitude-4 star at the generic detector
#   (signal_full_scale_frac 0.5, full_well 4095 e-, star_psf_sigma 1.0), whose
#   integrated signal was ``(1 / 2.512**4) * 2*pi * 1.0**2 * 0.5 * 4095`` ~ 324
#   electrons; ``1.3e4 * 10**(-0.4 * 4)`` ~ 326 matches it, so a generic scene's
#   stars keep their sane DN range and the unit-test workhorse is undisturbed.
STAR_FLUX_E_PER_S_VMAG0: dict[str, float] = {
    'coiss_nac': 1.0e7,
    'coiss_wac': 2.6e6,
    'gossi': 4.0e6,
    'nhlorri': 7.0e7,
    'generic': 1.3e4,
}

# Vidicon photometric zero point in DN per second (Voyager: no electron domain).
STAR_FLUX_DN_PER_S_VMAG0: dict[str, float] = {
    'vgiss': 3.0e3,
}

# Angular pixel scale (arcsec / detector pixel) used to convert a scene's
# pixel-area field of view into square degrees for the background-sky star
# count.  A camera constant, kept here beside the other interim sim per-instrument
# radiometry tables rather than in the shared real-pipeline config blocks.  All
# values interim, provenance-tagged from the published plate scales (Section 12):
# COISS NAC ~6 urad/px, WAC ~60 urad/px, Galileo SSI ~10.2 urad/px, LORRI ~5
# urad/px (1x1), Voyager ISS ~9.3 urad/px; the generic block is a nominal 1
# arcsec/px.
SKY_PIXEL_SCALE_ARCSEC: dict[str, float] = {
    'coiss_nac': 1.237,
    'coiss_wac': 12.37,
    'gossi': 2.095,
    'nhlorri': 1.023,
    'vgiss': 1.910,
    'generic': 1.0,
}


def _coiss_alias(table: dict[str, Any]) -> None:
    """Point the calibrated Cassini instrument names at their raw entries."""
    for calib, raw in (('coiss_calib_nac', 'coiss_nac'), ('coiss_calib_wac', 'coiss_wac')):
        if raw in table and calib not in table:
            table[calib] = table[raw]


_coiss_alias(PSF_KERNELS)
_coiss_alias(DISTORTION_RESIDUAL_RMS_PX)
_coiss_alias(STAR_FLUX_E_PER_S_VMAG0)
_coiss_alias(SKY_PIXEL_SCALE_ARCSEC)


def _catalog_key(instrument: str | None) -> str:
    """The catalog key for a sim instrument name (generic aliases fold to one)."""
    if instrument is None or instrument in ('generic', 'sim'):
        return 'generic'
    return instrument


[docs] def resolve_star_flux_zero_point(instrument: str | None) -> tuple[float, str]: """Return the star photometric zero point and its unit domain. Parameters: instrument: The sim instrument name, a generic alias, or ``None``. Returns: A ``(zero_point, domain)`` pair. ``domain`` is ``'dn'`` for the vidicon (its point sources are DN) and ``'electrons'`` for every CCD camera and the generic detector. ``zero_point`` is the flux a magnitude-0 star deposits per second of exposure in that domain. """ key = _catalog_key(instrument) if key in STAR_FLUX_DN_PER_S_VMAG0: return STAR_FLUX_DN_PER_S_VMAG0[key], 'dn' return STAR_FLUX_E_PER_S_VMAG0.get(key, STAR_FLUX_E_PER_S_VMAG0['generic']), 'electrons'
[docs] def resolve_sky_pixel_scale_arcsec(instrument: str | None) -> float: """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. """ return SKY_PIXEL_SCALE_ARCSEC.get(_catalog_key(instrument), SKY_PIXEL_SCALE_ARCSEC['generic'])
# Per-instrument detector electron-chain and noise-model parameters. Every # value is interim and provenance-tagged; the electron chain, gain tables, read # noise, and vidicon numbers come from the instrument calibration references # (plan Section 5) and are the first quantities the realism-match pass revisits. # # Chain (CCD): electrons = signal * signal_full_scale_frac * full_well_e * # (exposure_sec / exposure_ref_sec); Poisson; electron-domain full-well bloom # against full_well_e; read noise (read_noise_e); DN = electrons / gain_e_per_dn # + bias_dn; quantization; clip at saturation_dn. gain_e_per_dn is selected # from gain_e_per_dn_by_state[gain_state]; a scene selecting a state absent from # that table is a validation error (5.2), not a silent guess. The image-side # well in DN is DERIVED (full_well_e / gain_e_per_dn); the navigator-side # full_well_dn config key is a separate published ADC-referenced value. # # The 'instrument_defaults' physical-chain knobs (dark_current_e_per_sec, # hot_pixel_fraction, hot_pixel_amplitude_e, banding_amplitude_e, # banding_period_px, bias_pedestal_sigma_dn, bias_row_gradient_dn, # bias_col_gradient_dn, bloom_length, quantization) are interim placeholders # sized from the 5.2-5.6 descriptions; per-scene overrides ride in the # truth-side noise block. instrument_defaults also turns on Poisson shot # noise (a property of the electron chain itself, not a per-camera number) # and the entry's cosmic_ray_rate_per_sec; the missing-data loss modes are # artifact incidences and stay at zero. # # cosmic_ray_rate_per_sec is in events per pixel per second: the chain # multiplies it by exposure_sec, pixel_area_cm2 (default 1.0), and the pixel # count, so at the default unit pixel area the fluence reads per pixel. # The realism match's FOM 6 transient split (single-pixel spikes that do # NOT recur at fixed detector positions across the cohort) measures the # candidate rate per instrument. Every current entry retains zero, each # with its measured rate, the specific reason adoption fails through this # chain (exposure-scaling / full-well-amplitude mismatch for Cassini, # double-counting against the tuned per-scene hot pixels for Galileo, # star-field contamination for LORRI, no vidicon stage for Voyager), and # the unblocking condition, recorded beside the value. # # The vidicon (Voyager) path skips the electron conversion; its noise is applied # directly in DN (5.3): line-correlated read noise (per-line offset + # within-line white), a faint coherent periodic component, and 8-bit # quantization. A vidicon entry carries a 'vidicon' sub-map instead of the CCD # electron keys. DETECTOR_DEFAULTS: dict[str, dict[str, Any]] = { 'coiss_nac': { 'detector_model': 'ccd', # 5.2 interim: full well ~110k e- (saturates ~3600 DN at gain 2, below # the 4095 ADC clip). 'full_well_e': 110.0e3, # 5.2 interim: a typical NAC science exposure, so a converted scene at # this exposure keeps today's brightness scale. 'exposure_ref_sec': 1.0, # 5.2 interim gain states ~233/95/30/13 e-/DN; tour-standard state 2. 'gain_e_per_dn_by_state': {0: 233.0, 1: 95.0, 2: 30.0, 3: 13.0}, 'default_gain_state': 2, 'read_noise_e': 12.0, # 5.2 interim 'bias_dn': 20.0, 'dark_current_e_per_sec': 5.0, # 5.2 interim (RBI-dominated dark) 'hot_pixel_fraction': 2.0e-3, # 5.2 interim (~0.15-0.28% of pixels) 'hot_pixel_amplitude_e': 4.0e4, # 5.2 interim (near full well) # 5.2 interim: total-charge fraction bled into the warm column above a # hot pixel (the streak integral, not a per-pixel amplitude). 'hot_pixel_column_factor': 0.3, # RETAINED zero 2026-07-18 after an adoption attempt. The realism # match measures a real transient single-pixel spike fraction of # 2.75e-4 per frame on the 58-frame CALIB NAC cohort (stationary # hot pixels excluded; star-bearing and body/ring frames agree at # 2.60e-4 vs 2.79e-4, so scene point sources are not the driver). # But the measured incidence is exposure-INdependent -- mean # fraction ~2e-4 in every exposure band from <= 0.2 s to > 2 s, # and the 22 s frame measures 1.1e-4 where an exposure-scaling # fluence fitted at the median frame predicts ~3e-3 -- while this # chain's cosmic-ray stage scales event counts with exposure_sec # and deposits near full well. Adopting a fitted 1.5e-4 # events/px/s (tried 2026-07-17) corrupted the matched frames' # FOM 1 statistics (sim sky-patch sigma median 2.2e-4 -> 1.0e-3 # I/F against 2.1e-4 real) and overshot the FOM 6 transient # fraction ~2x through the multi-pixel event morphology. # Unblocked by a per-readout (exposure-independent), # modest-amplitude transient term in the detector chain. 'cosmic_ray_rate_per_sec': 0.0, 'banding_amplitude_e': 30.0, # 5.2 interim (~30 e- NAC 2 Hz) 'banding_period_px': 64.0, # 5.2 interim (line-readout-rate period) 'bias_pedestal_sigma_dn': 2.0, # 5.2 interim (per-image pedestal jitter) 'bias_row_gradient_dn': 1.0, # 5.2 interim (readout-direction gradient) 'bias_col_gradient_dn': 0.5, # 5.2 interim 'bloom_length': 4, # 5.2 interim (no antiblooming; column bleed above the well) 'quantization': 'exact', # Per-mode shape defaults (interim, 5.2). incidence is never catalogued: # a mode activates only when a scene sets it. Shapes here are the values # a scene inherits when it names a mode without spelling every parameter. 'artifact_modes': { # ~30 e- NAC 2 Hz horizontal banding, line-readout-rate period. 'banding_coherent': { 'amplitude_e': 30.0, 'period_px': 64.0, 'orientation': 'horizontal', }, # Per-image pedestal jitter plus readout-direction gradients. 'bias_structure': { 'pedestal_sigma_dn': 2.0, 'row_gradient_dn': 1.0, 'col_gradient_dn': 0.5, }, # RBI-dominated dark grows toward the last readout line. 'dark_ramp': {'kind': 'dark_gradient', 'amplitude_e': 150.0}, 'bloom': {'bloom_length': 4}, # Anti-blooming vertical 2-px pairs in unsummed long exposures. 'bright_dark_pairs': {'amplitude_e': 4.0e3}, # No published ISS rate; the interplanetary regime near full-well amp. 'radiation_transients': {'amplitude_e': 4.0e4}, # Dust donuts (<1% each) accumulating over the mission. 'fixed_pattern': {'dust_donut_count': 5}, }, }, 'coiss_wac': { 'detector_model': 'ccd', 'full_well_e': 95.0e3, # 5.2 interim 'exposure_ref_sec': 1.0, # 5.2: only state 2 is catalogued for the WAC; selecting another WAC # state is a validation error until its full table is sourced. 'gain_e_per_dn_by_state': {2: 28.0}, 'default_gain_state': 2, 'read_noise_e': 12.0, # 5.2 interim 'bias_dn': 20.0, 'dark_current_e_per_sec': 5.0, 'hot_pixel_fraction': 2.0e-3, 'hot_pixel_amplitude_e': 3.5e4, 'hot_pixel_column_factor': 0.3, # RETAINED zero 2026-07-18: same exposure-independence and # full-well-amplitude mismatch as the NAC entry (the two cameras # share this chain model), and the WAC's own 4-frame cohort is # star-contaminated besides -- its measured 4.89e-4 per-frame # transient fraction comes entirely from the three star-bearing # frames while the one body frame measures zero. Unblocked # together with the NAC's per-readout transient term. 'cosmic_ray_rate_per_sec': 0.0, 'banding_amplitude_e': 6.0, # 5.2 interim (~6 e- WAC 4 Hz) 'banding_period_px': 64.0, 'bias_pedestal_sigma_dn': 2.0, 'bias_row_gradient_dn': 1.0, 'bias_col_gradient_dn': 0.5, 'bloom_length': 4, # 5.2 interim (no antiblooming; column bleed above the well) 'quantization': 'exact', # Per-mode shape defaults (interim, 5.2); ~6 e- WAC 4 Hz banding. 'artifact_modes': { 'banding_coherent': { 'amplitude_e': 6.0, 'period_px': 64.0, 'orientation': 'horizontal', }, 'bias_structure': { 'pedestal_sigma_dn': 2.0, 'row_gradient_dn': 1.0, 'col_gradient_dn': 0.5, }, 'dark_ramp': {'kind': 'dark_gradient', 'amplitude_e': 150.0}, 'bloom': {'bloom_length': 4}, 'bright_dark_pairs': {'amplitude_e': 3.5e3}, 'radiation_transients': {'amplitude_e': 3.5e4}, 'fixed_pattern': {'dust_donut_count': 5}, }, }, 'gossi': { 'detector_model': 'ccd', 'full_well_e': 108.0e3, # 5.4 interim 'exposure_ref_sec': 0.2, # 5.4 interim (typical science exposure) # 5.4 interim gain states ~1822/377/187/39 e-/DN; common science state 2. 'gain_e_per_dn_by_state': {0: 1822.0, 1: 377.0, 2: 187.0, 3: 39.0}, 'default_gain_state': 2, 'read_noise_e': 31.0, # 5.4 (full-res; 44 e- in summation mode) 'bias_dn': 20.0, 'dark_current_e_per_sec': 10.0, # 5.4 interim (RTG-driven dark spikes) # TUNED 2026-07-17 by the realism match: the 8-frame Galileo REDR # cohort's median measured spike fraction is 1.2e-4 with a # stationary component of only 1e-6 -- the REDR blemish correction # removes most hot pixels -- so the interim 3e-3 overstated the # products the navigator actually sees by ~25x. 'hot_pixel_fraction': 1.0e-4, 'hot_pixel_amplitude_e': 4.0e4, # 5.4 interim (early-blooming columns; total-charge fraction). 'hot_pixel_column_factor': 0.5, # RETAINED zero 2026-07-18 after an adoption attempt. The realism # match measures a real transient spike fraction of 1.17e-4 per # frame (8-frame REDR cohort, all negative cases, so no scene # point sources contaminate the split) -- but that population is # already carried by the tuned hot_pixel_fraction above, which was # sized to the cohort's TOTAL single-pixel incidence (1.2e-4; # the truly stationary component is only 1e-6) and which the FOM 6 # split itself counts as transient because sim hot pixels reseed # per scene. Adding a separate cosmic-ray term double-counts the # same measured spikes: two rates were tried 2026-07-18 (1.6e-3 # from the raw split, then 5.4e-4 refit excluding star frames); # even the refit raised the simulated fraction to 2.3e-4 against # the 1.2e-4 real. # Unblocked by a fixed-position per-detector hot-pixel map, which # would free the transient budget for a real radiation term. 'cosmic_ray_rate_per_sec': 0.0, 'banding_amplitude_e': 65.0, # 5.4 interim (~0.35 DN at gain 2 -> ~65 e-) 'banding_period_px': 42.0, # 5.4 (2400 Hz supply-noise comb every 42 px) 'bias_pedestal_sigma_dn': 1.0, 'bias_row_gradient_dn': 1.0, # 5.4 (summation-mode L-R shading ramp) 'bias_col_gradient_dn': 0.5, 'bloom_length': 6, # 5.4 interim (early-blooming columns) 'quantization': 'exact', # Per-mode shape defaults (interim, 5.4). 'artifact_modes': { # 42-px vertical supply-noise comb (~0.35 DN at gain 2 -> ~65 e-), # plus a <8 Hz horizontal component in high gain (orientation both). 'banding_coherent': {'amplitude_e': 65.0, 'period_px': 42.0, 'orientation': 'both'}, 'bias_structure': { 'pedestal_sigma_dn': 1.0, 'row_gradient_dn': 1.0, 'col_gradient_dn': 0.5, }, # Shutter line-dependent exposure offset (~1.5 -> ~1.05 ms, line 1->800). 'dark_ramp': {'kind': 'exposure_shading', 'top_factor': 1.5, 'bottom_factor': 1.05}, 'bloom': {'bloom_length': 6}, # Ganymede-distance regime: ~1e4 spikes/frame scale in the readout, # amplitudes few DN steeply falling (near full well at the top). 'radiation_transients': {'amplitude_e': 4.0e4}, # 33-px photolithography stitch comb plus corner vignetting; the # 8-bit ADC contours worst at DN multiples of 8. 'fixed_pattern': { 'stitch_period_px': 33, 'stitch_amplitude_dn': 1.0, 'vignetting_frac': 0.03, 'dust_donut_count': 4, }, 'contouring_8bit': {'step': 8}, # HMA/HCA vertical decimation (5.4): only every Nth line carries # valid data, so the Galileo periodic-line default is 'keep'. 'alternating_lines': {'mode': 'keep'}, }, }, 'nhlorri': { 'detector_model': 'ccd', # 5.5: ADC-limited full well 4095 DN x 21 e-/DN. 'full_well_e': 86.0e3, 'exposure_ref_sec': 0.1, # 5.5 interim (typical encounter exposure) # 5.5: single gain state ~21 e-/DN (1x1); 4x4 binning reads ~19.4. 'gain_e_per_dn_by_state': {0: 21.0}, 'default_gain_state': 0, 'read_noise_e': 23.0, # 5.5 (~1.1 DN) 'bias_dn': 545.0, # 5.5 (~545 DN estimated per-image from dark columns) 'dark_current_e_per_sec': 0.04, # 5.5 (negligible dark current) # 5.5: LORRI has NO hot pixels (PDS maps are zeroes); disabled. 'hot_pixel_fraction': 0.0, 'hot_pixel_amplitude_e': 0.0, 'hot_pixel_column_factor': 0.0, # RETAINED zero 2026-07-17: the realism match measured a 3.36e-4 # per-frame transient spike fraction (two 5 s frames), but both # cohort frames are 4x4-binned star fields and a binned faint star # is indistinguishable from a transient to the single-pixel spike # detector -- the cohort has no star-free frame to bound that # contamination, so the measured rate is not adoptable. Unblocked # by any non-star LORRI frame entering the library; scenes can # still plant the radiation_transients mode explicitly. 'cosmic_ray_rate_per_sec': 0.0, 'banding_amplitude_e': 17.0, # 5.5 interim (~0.8 DN vertical banding) 'banding_period_px': 128.0, # 5.5 interim 'bias_pedestal_sigma_dn': 1.0, 'bias_row_gradient_dn': 0.5, 'bias_col_gradient_dn': 0.5, 'bloom_length': 2, # 5.5 interim (short column bleed) 'quantization': 'exact', # Per-mode shape defaults (interim, 5.5). frame_transfer_smear is the # one artifact mode LORRI turns on under instrument_defaults (the defining # LORRI artifact, per 15.7): the detector resolver injects it at these # nominal scrub/transfer times when instrument_defaults is on and the # scene has not overridden it. Every other mode stays off until a scene # sets its incidence. 'artifact_modes': { # ~12 ms pre-exposure scrub, ~11 ms post-exposure transfer. 'frame_transfer_smear': {'t_scrub_sec': 0.012, 't_transfer_sec': 0.011}, # Saturated compact sources undershoot up to ~12 DN along readout. 'serial_tail': {'amplitude_dn': 12.0, 'length_px': 8, 'direction': 'right'}, # <=1 DN horizontal striping plus ~0.8 DN vertical banding near low # columns (~17 e-); orientation both approximates the two families. 'banding_coherent': {'amplitude_e': 17.0, 'period_px': 128.0, 'orientation': 'both'}, # ~4% corner vignetting, 0.9% PRNU, <=0.5 DN even/odd jail bars, # ~1% dust donuts. 'fixed_pattern': { 'vignetting_frac': 0.04, 'prnu_rms': 0.009, 'jail_bar_dn': 0.5, 'dust_donut_count': 3, }, # ~16 hits per readout-dominated short exposure; mostly single px. 'radiation_transients': {'amplitude_e': 8.6e4}, }, }, 'vgiss': { 'detector_model': 'vidicon', # The vidicon skips the electron conversion; signal maps straight to the # 8-bit DN full scale, then the DN-domain vidicon noise model applies. 'exposure_ref_sec': 1.0, 'bias_dn': 20.0, 'quantization': '8bit', # Cosmic rays RETAINED at zero 2026-07-17 (no key: the vidicon DN # path carries no cosmic-ray stage, so a rate here would be inert). # The realism match measured a 3.5e-5 transient spike fraction over # the 3-frame GEOMED cohort -- GEOMED's reseau-anchored resampling # interpolates most single-pixel transients away. Unblocked by a # DN-domain transient stage plus more Voyager frames. 'vidicon': { # TUNED 2026-07-17 by the realism match: the 3-frame GEOMED # cohort's paired-difference sky sigma is ~0.22 DN equivalent # (median 1.75e-3 I/F), placing the products in the low-gain # regime of the published 0.3-0.75 DN range after GEOMED # resampling smoothing. The interim 1.8 DN values (the # high-gain end) overstated the cohort's measured 0.22 DN sky # sigma about 9x. 0.25 DN per # component (quadrature sum ~0.35 DN, the low end of the # published range): the sim's 8-bit quantization floors any # sub-LSB value, so the choice is anchored to the published # low-gain range rather than to the (unreachable) 0.22 DN # cohort estimate -- see the realism report's known-gaps list. # 3 frames is limited evidence; revisit as VGISS frames land. 'read_noise_line_dn': 0.25, 'read_noise_pixel_dn': 0.25, # 5.3: faint coherent periodic component (2.4 kHz vertical, ~0.5 DN # peak-to-peak). 'coherent_amplitude_dn': 0.25, 'coherent_period_px': 8.0, }, # Per-mode shape defaults (interim, 5.3), all GEOMED-level: the beam-bend # limb bias that survives reseau-anchored correction, the shortened # erase-cycle residual image, the readout dark-current line ramp, the # reseau-removal scars on the ~46-px lattice, and the GEOMED resample # texture (blank border + missing-line interpolation banding). 'artifact_modes': { 'beam_bend': {'amplitude_px': 1.0}, 'residual_image': {'amplitude': 0.05, 'prior': 'self_offset', 'offset_px': [5, 5]}, # 48 x n s readout ramp, nonlinear in wait time. 'dark_ramp': {'kind': 'dark_gradient', 'amplitude_e': 6.0, 'nonlinear': 1.5}, 'contouring_8bit': {'step': 8}, 'reseau_scars': {'spacing_px': 46, 'patch_radius_px': 4}, 'resample_texture': { 'warp_amp_px': 0.3, 'blank_border_px': 2, 'missing_line_interp': False, }, }, }, # The instrument-agnostic 'generic' / 'sim' block: an ideal 12-bit detector # whose electron well equals its DN depth at unit gain, so a generic scene's # electron chain reproduces the direct signal-to-DN mapping (electrons == DN) # rather than imposing a specific camera's radiometry. 'generic': { 'detector_model': 'ccd', 'full_well_e': 4095.0, 'exposure_ref_sec': 1.0, 'gain_e_per_dn_by_state': {0: 1.0}, 'default_gain_state': 0, 'read_noise_e': 1.0, 'bias_dn': 20.0, 'dark_current_e_per_sec': 0.0, 'hot_pixel_fraction': 0.0, 'hot_pixel_amplitude_e': 0.0, 'hot_pixel_column_factor': 0.0, # The generic block is an ideal detector with no environment, so # there is no cosmic-ray flux to measure; explicitly zero. 'cosmic_ray_rate_per_sec': 0.0, 'banding_amplitude_e': 0.0, 'banding_period_px': 64.0, 'bias_pedestal_sigma_dn': 0.0, 'bias_row_gradient_dn': 0.0, 'bias_col_gradient_dn': 0.0, 'bloom_length': 0, 'quantization': 'exact', }, } # Calibrated Cassini products (CALIB, what the navigator's cassini_iss_calib # path consumes) run the same cameras through CISSCAL, whose bias/dark # subtraction, flat-fielding, and 2-Hz noise removal strip most of the raw # chain's structure -- so the calibrated instruments get their own detector # entries instead of the raw alias. TUNED 2026-07-17 by the realism match # against the 62-frame CALIB image-library cohort; every key not overridden # is inherited from the matching raw entry: # - hot_pixel_fraction: measured stationary spike fraction 1.6e-5 # (single-pixel spikes recurring at fixed positions across the 58-frame # NAC cohort; the interim raw-chain 2e-3 overstated the calibrated # products by ~100x and dominated their p99 signal percentile). # - banding_amplitude_e 0: CISSCAL's 2-Hz noise removal strips the coherent # banding, and the cohort sky floor sits below the level at which the raw # chain's 30 e- banding would register. # - bias_pedestal_sigma_dn / bias_row_gradient_dn / bias_col_gradient_dn 0: # CISSCAL subtracts the full 2-D bias structure, not just the constant # pedestal the calibration inverse already removes. _COISS_CALIB_OVERRIDES: dict[str, Any] = { 'hot_pixel_fraction': 1.6e-5, 'banding_amplitude_e': 0.0, 'bias_pedestal_sigma_dn': 0.0, 'bias_row_gradient_dn': 0.0, 'bias_col_gradient_dn': 0.0, } DETECTOR_DEFAULTS['coiss_calib_nac'] = { **copy.deepcopy(DETECTOR_DEFAULTS['coiss_nac']), **_COISS_CALIB_OVERRIDES, } DETECTOR_DEFAULTS['coiss_calib_wac'] = { **copy.deepcopy(DETECTOR_DEFAULTS['coiss_wac']), **_COISS_CALIB_OVERRIDES, } _coiss_alias(DETECTOR_DEFAULTS)
[docs] def resolve_detector_defaults(instrument: str | None) -> dict[str, Any]: """Return the detector-parameter defaults for a sim instrument. Parameters: instrument: The sim instrument name (see ``spindoctor.sim.instruments.SIM_INSTRUMENTS``), one of the generic aliases, or ``None`` for the instrument-agnostic block. Returns: A fresh copy of the instrument's ``DETECTOR_DEFAULTS`` entry, falling back to the generic block for the generic aliases or an unknown name. """ key = 'generic' if instrument is None or instrument in ('generic', 'sim') else instrument entry = DETECTOR_DEFAULTS.get(key, DETECTOR_DEFAULTS['generic']) return copy.deepcopy(entry)
[docs] def resolve_mode_with_catalog( mode_name: str, scene_cfg: Mapping[str, Any], instrument: str | None ) -> dict[str, Any]: """Resolve an artifact mode's parameters with the per-instrument catalog. The resolution precedence for a mode's shape parameters is scene value, then the instrument's catalog default block (``artifact_modes`` in ``DETECTOR_DEFAULTS``), then the registry default. ``incidence`` is never read from the catalog: a mode activates only when a scene sets its incidence (or, for the one physical-signal-chain member LORRI turns on, when the detector resolver injects it under ``instrument_defaults``). Parameters: mode_name: A registered artifact-mode name. scene_cfg: The scene's map for the mode (already validated). instrument: The sim instrument name, for the catalog lookup. Returns: A fresh dict carrying every parameter at its resolved value. """ catalog_modes = resolve_detector_defaults(instrument).get('artifact_modes') or {} catalog = catalog_modes.get(mode_name) or {} resolved: dict[str, Any] = {} for param in ARTIFACT_MODES[mode_name].params: name = param.name if name in scene_cfg and scene_cfg[name] is not None: resolved[name] = scene_cfg[name] elif name != 'incidence' and name in catalog: resolved[name] = catalog[name] else: resolved[name] = param.default return resolved