"""Resolved detector parameters for one scene render.
:class:`DetectorParams` collapses the emulated instrument's config block, the
per-instrument catalog defaults (:mod:`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.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from spindoctor.config import DEFAULT_CONFIG
from spindoctor.sim.forward.artifact_modes import (
DETECTOR_MODE_ORDER,
mode_available,
normalize_instrument,
resolve_mode_config,
)
from spindoctor.sim.forward.artifacts_catalog import (
resolve_detector_defaults,
resolve_mode_with_catalog,
)
from spindoctor.sim.instruments import resolve_sim_inst_config
from spindoctor.sim.seeds import derive_effect_seed
__all__ = ['DetectorParams', 'resolve_detector_params']
class DetectorParamError(ValueError):
"""Raised when a scene selects an unavailable detector configuration."""
[docs]
@dataclass
class DetectorParams:
"""The flat, resolved detector view for one render.
Parameters:
detector_model: 'ccd' (electron chain) or 'vidicon' (DN chain).
data_units: 'raw_dn' or 'calibrated_if'.
signal_full_scale_frac: Well fraction a signal of 1.0 fills at the
reference exposure.
full_well_e: Full well in electrons (CCD path).
exposure_ref_sec: Exposure the well fraction references.
exposure_sec: The scene exposure.
gain_e_per_dn: Resolved gain (electrons per DN) for the selected state.
read_noise_e: Read-noise sigma in electrons (CCD path).
bias_dn: Additive DN bias pedestal.
saturation_dn: ADC clip ceiling in DN.
full_well_dn: Published ADC-referenced well (vidicon DN full scale).
quantization: ADC quantization sub-mode.
poisson: Whether shot noise is applied.
bloom_length: Electron-domain full-well bloom half-length (0 disables).
cosmic_ray_rate_per_sec: Cosmic-ray fluence (events / cm^2 / sec).
pixel_area_cm2: Detector pixel area (scales the cosmic-ray count).
dark_current_e_per_sec: Dark current (electrons / sec); 0 disables.
hot_pixel_fraction: Fraction of pixels that are hot; 0 disables.
hot_pixel_amplitude_e: Hot-pixel amplitude scale in electrons.
hot_pixel_column_factor: Warm-column fraction bled from a hot pixel.
banding_amplitude_e: Coherent-banding amplitude in electrons; 0 disables.
banding_period_px: Coherent-banding spatial period in pixels.
bias_pedestal_sigma_dn: Per-image bias-pedestal jitter (DN); 0 disables.
bias_row_gradient_dn: Low-order row bias gradient span (DN).
bias_col_gradient_dn: Low-order column bias gradient span (DN).
vidicon: The vidicon DN-noise sub-parameters (vidicon path only).
calibration_scale_dn_per_s_per_if: Derived I/F calibration scale.
dark_dn: Dark pedestal in DN subtracted before the I/F divide.
random_seed: The scene seed for the per-effect sub-streams.
instrument_defaults: Whether the physical-chain opt-in is on.
hot_pixel_adversarial: Whether the hot-pixel population is placed
adversarially (biased onto the navigation features) rather than
uniformly.
hot_pixel_mode_active: Whether the ``hot_pixels`` artifact mode drove
the hot-pixel knobs (in which case the chain records the realized
population in the frame truth), as opposed to the generic
noise-block / instrument_defaults path.
quantization_contour_step: The DN posterization step for the
``contour_8bit`` quantization sub-mode.
artifacts_adversarial: Whether stochastic detector artifact modes place
their events adversarially onto the navigation features.
detector_modes: The resolved config of every active detector-stage
artifact mode (registry defaults filled from the catalog), keyed by
mode name; the chain applies each at its physical point and records
it. Routed modes (bias, bloom, quantization) also appear here for
the truth record, their override already folded into the flat fields.
"""
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, Any]] = field(default_factory=dict)
def _default_or_zero(
scene_noise: Mapping[str, Any],
catalog: Mapping[str, Any],
key: str,
*,
instrument_defaults: bool,
) -> float:
"""A physical-chain knob: scene override, else catalog (if on), else 0."""
if key in scene_noise:
return float(scene_noise[key])
if instrument_defaults:
return float(catalog.get(key, 0.0))
return 0.0
def _resolve_gain(
catalog: Mapping[str, Any], detector_block: Mapping[str, Any], *, instrument: str | None
) -> float:
"""Select ``gain_e_per_dn`` from the catalog table by the scene gain state.
Parameters:
catalog: The instrument's ``DETECTOR_DEFAULTS`` entry.
detector_block: The scene ``detector`` block (may set ``gain_state``).
instrument: The sim instrument name, for error messages.
Returns:
The electrons-per-DN gain for the selected state.
Raises:
DetectorParamError: If the selected state has no catalog entry.
"""
table = catalog.get('gain_e_per_dn_by_state') or {0: 1.0}
state = detector_block.get('gain_state')
if state is None:
state = int(catalog.get('default_gain_state', 0))
state = int(state)
if state not in table:
raise DetectorParamError(
f'sim instrument {instrument!r} has no catalogued gain state {state}; '
f'available states: {sorted(table)}'
)
return float(table[state])
# Detector modes routed to an existing generic mechanic by overriding a flat
# DetectorParams field rather than by a dedicated chain call.
_ROUTED_ONLY_MODES: frozenset[str] = frozenset(
{'bias_structure', 'bloom', 'quantization_lut', 'quantization_ls8b', 'contouring_8bit'}
)
# Detector modes whose incidence is an expected event count (drawn Poisson in
# the mechanic), not a per-frame activation probability.
_COUNT_DETECTOR_MODES: frozenset[str] = frozenset({'radiation_transients', 'bright_dark_pairs'})
def _mode_active(mode_name: str, incidence: float, random_seed: int) -> bool:
"""Whether a detector artifact mode is active this frame.
Count modes are active whenever their incidence is positive (the mechanic
draws the event count); the deterministic-shape modes activate with
probability ``incidence``, drawn from the mode's own seeded stream so
toggling one never perturbs another.
"""
if incidence <= 0.0:
return False
if mode_name in _COUNT_DETECTOR_MODES:
return True
rng = np.random.default_rng(derive_effect_seed(random_seed, f'detector/{mode_name}/activate'))
return bool(rng.random() < incidence)
def _resolve_detector_modes(
artifacts: Mapping[str, Any],
instrument: str | None,
random_seed: int,
*,
instrument_defaults: bool,
) -> dict[str, dict[str, Any]]:
"""Resolve the active detector-stage artifact modes for a render.
Each present, available, and active mode is resolved (scene value over
catalog default over registry default) and returned keyed by name. LORRI's
``frame_transfer_smear`` is the one physical-signal-chain member turned on
under ``instrument_defaults``: when a LORRI scene has not set it, it is
injected at incidence 1 with the catalog's nominal scrub/transfer times.
The injection is LORRI-only; the instrument-agnostic generic block accepts
every mode but emulates no frame-transfer camera, so it gets no injection.
"""
modes: dict[str, dict[str, Any]] = {}
for name in DETECTOR_MODE_ORDER:
raw = artifacts.get(name)
if raw is None:
if (
name == 'frame_transfer_smear'
and instrument_defaults
and normalize_instrument(instrument) == 'nhlorri'
):
raw = {'incidence': 1.0}
else:
continue
if not isinstance(raw, Mapping) or not mode_available(name, instrument):
continue
cfg = resolve_mode_with_catalog(name, raw, instrument)
if not _mode_active(name, float(cfg.get('incidence', 0.0)), random_seed):
continue
modes[name] = cfg
return modes
[docs]
def resolve_detector_params(params: Mapping[str, Any]) -> DetectorParams:
"""Collapse the scene, config, and catalog into a resolved detector view.
Parameters:
params: The full scene ``sim_params`` mapping.
Returns:
The resolved :class:`DetectorParams`.
Raises:
DetectorParamError: If the scene selects an unavailable gain state.
"""
instrument = params.get('instrument')
inst_config = resolve_sim_inst_config(
DEFAULT_CONFIG, instrument, params.get('instrument_config')
)
inst_noise = inst_config.get('noise') or {}
sim_noise = DEFAULT_CONFIG.category('sim')['noise']
catalog = resolve_detector_defaults(instrument)
detector_block = params.get('detector') or {}
scene_noise = params.get('noise') or {}
artifacts = params.get('artifacts') or {}
instrument_defaults = bool(artifacts.get('instrument_defaults', False))
detector_model = str(detector_block.get('detector_model', catalog['detector_model']))
data_units = str(inst_config.get('data_units', 'raw_dn'))
exposure_sec = float(params.get('exposure_sec', 1.0))
exposure_ref_sec = float(
detector_block.get('exposure_ref_sec', catalog.get('exposure_ref_sec', 1.0))
)
quantization = str(detector_block.get('quantization', catalog.get('quantization', 'exact')))
signal_full_scale_frac = float(
scene_noise.get(
'signal_full_scale_frac',
inst_noise.get('signal_full_scale_frac', sim_noise['signal_full_scale_frac']),
)
)
bias_dn = float(
scene_noise.get('bias_dn', catalog.get('bias_dn', sim_noise.get('bias_dn', 0.0)))
)
saturation_dn = float(inst_noise.get('saturation_dn', sim_noise['saturation_dn']))
full_well_dn = float(inst_noise.get('full_well_dn', sim_noise['full_well_dn']))
gain_e_per_dn = _resolve_gain(catalog, detector_block, instrument=instrument)
full_well_e = float(catalog.get('full_well_e', full_well_dn * gain_e_per_dn))
# Read noise: a scene DN override wins (converted to electrons via gain);
# otherwise the catalog electrons value when instrument_defaults is on;
# otherwise the honest floor (no read noise).
if 'read_noise_dn' in scene_noise:
read_noise_e = float(scene_noise['read_noise_dn']) * gain_e_per_dn
elif instrument_defaults:
read_noise_e = float(catalog.get('read_noise_e', 0.0))
else:
read_noise_e = 0.0
# Poisson: an explicit noise key wins (noise: {poisson: false} turns it off
# even under instrument_defaults); otherwise the physical-chain opt-in turns
# shot noise on, and the floor is off.
if 'poisson' in scene_noise:
poisson = bool(scene_noise['poisson'])
else:
poisson = instrument_defaults
# Full-well bloom follows the same precedence: explicit scene value, else
# the catalog's electron-domain bloom when instrument_defaults is on, else
# the disabled floor.
if 'bloom_length' in scene_noise:
bloom_length = int(scene_noise['bloom_length'])
elif instrument_defaults:
bloom_length = int(catalog.get('bloom_length', 0))
else:
bloom_length = int(sim_noise.get('bloom_length', 0))
# Cosmic rays: scene value, else the catalog's cohort-measured rate when
# instrument_defaults is on (the catalog records it per pixel per second
# at the chain's unit pixel area), else the config floor.
if 'cosmic_ray_rate_per_sec' in scene_noise:
cosmic_ray_rate = float(scene_noise['cosmic_ray_rate_per_sec'])
elif instrument_defaults and 'cosmic_ray_rate_per_sec' in catalog:
cosmic_ray_rate = float(catalog['cosmic_ray_rate_per_sec'])
else:
cosmic_ray_rate = float(sim_noise.get('cosmic_ray_rate_per_sec', 0.0))
pixel_area_cm2 = float(scene_noise.get('pixel_area_cm2', sim_noise.get('pixel_area_cm2', 1.0)))
dark_current = _default_or_zero(
scene_noise, catalog, 'dark_current_e_per_sec', instrument_defaults=instrument_defaults
)
hot_pixel_fraction = _default_or_zero(
scene_noise, catalog, 'hot_pixel_fraction', instrument_defaults=instrument_defaults
)
banding_amplitude = _default_or_zero(
scene_noise, catalog, 'banding_amplitude_e', instrument_defaults=instrument_defaults
)
bias_pedestal_sigma = _default_or_zero(
scene_noise, catalog, 'bias_pedestal_sigma_dn', instrument_defaults=instrument_defaults
)
bias_row_gradient = _default_or_zero(
scene_noise, catalog, 'bias_row_gradient_dn', instrument_defaults=instrument_defaults
)
bias_col_gradient = _default_or_zero(
scene_noise, catalog, 'bias_col_gradient_dn', instrument_defaults=instrument_defaults
)
# Shape parameters (period, amplitude scales) track the catalog; they never
# activate a stage on their own (the amplitude/fraction gate does that).
hot_pixel_amplitude = float(
scene_noise.get('hot_pixel_amplitude_e', catalog.get('hot_pixel_amplitude_e', 0.0))
)
hot_pixel_column_factor = float(
scene_noise.get('hot_pixel_column_factor', catalog.get('hot_pixel_column_factor', 0.0))
)
banding_period_px = float(
scene_noise.get('banding_period_px', catalog.get('banding_period_px', 64.0))
)
# The hot_pixels artifact mode (routed here from the registry) wins over the
# noise-block / instrument_defaults hot-pixel knobs: an explicit mode is a
# deliberate placement, so it overrides the generic stress path. incidence
# is the hot-pixel fraction; amplitude and column factor fall back to the
# catalog when the mode leaves them unset. The mode is honored only where
# it is available (validation already rejects it elsewhere, e.g. LORRI).
hot_pixel_adversarial = False
hot_pixel_mode_active = False
hot_cfg = artifacts.get('hot_pixels') if isinstance(artifacts, Mapping) else None
if isinstance(hot_cfg, Mapping) and mode_available('hot_pixels', instrument):
hot_pixel_mode_active = True
resolved = resolve_mode_config('hot_pixels', hot_cfg)
hot_pixel_fraction = float(resolved['incidence'])
mode_amplitude = resolved.get('amplitude_e')
if mode_amplitude is not None:
hot_pixel_amplitude = float(mode_amplitude)
elif hot_pixel_amplitude <= 0.0:
hot_pixel_amplitude = float(catalog.get('hot_pixel_amplitude_e', 0.0))
mode_column_factor = resolved.get('column_factor')
if mode_column_factor is not None:
hot_pixel_column_factor = float(mode_column_factor)
elif hot_pixel_column_factor <= 0.0:
hot_pixel_column_factor = float(catalog.get('hot_pixel_column_factor', 0.0))
hot_pixel_adversarial = bool(artifacts.get('adversarial', False))
# The vidicon DN-noise sub-parameters activate on the vidicon path only, and
# like every physical-chain artifact stay disabled until instrument_defaults
# is on or the scene sets them explicitly (the stage-activation floor).
catalog_vidicon = dict(catalog.get('vidicon') or {}) if instrument_defaults else {}
scene_vidicon = dict(scene_noise.get('vidicon') or {})
vidicon = {**catalog_vidicon, **{k: float(v) for k, v in scene_vidicon.items()}}
# Derived I/F calibration scale: a noise-free signal of 1.0 round-trips
# through the inverse transform to I/F 1.0. Uses the image-side DN well
# (frac * full_well_e / gain for the CCD path, frac * full_well_dn for the
# vidicon path). The shared inverse divides by scale * exposure_sec; the
# CCD forward chain scales electrons by exposure_sec / exposure_ref_sec, so
# its scale carries 1 / exposure_ref_sec, while the vidicon forward mapping
# has no exposure term at all, so its scale folds in 1 / exposure_sec and
# the exposure cancels (the inverse divides by the DN full scale alone).
if detector_model == 'vidicon':
image_well_dn = signal_full_scale_frac * full_well_dn
calibration_scale = image_well_dn / exposure_sec if exposure_sec > 0.0 else image_well_dn
else:
image_well_dn = signal_full_scale_frac * full_well_e / gain_e_per_dn
calibration_scale = (
image_well_dn / exposure_ref_sec if exposure_ref_sec > 0.0 else image_well_dn
)
dark_dn = 0.0 if detector_model == 'vidicon' else dark_current * exposure_sec / gain_e_per_dn
# Detector-stage artifact modes. An explicit mode wins over the generic
# noise-block / instrument_defaults knob for the same mechanic (the
# precedence hot_pixels already uses): the routed modes fold their override
# into the flat field here, and banding_coherent silences the generic
# banding so only the explicit family renders.
artifacts_adversarial = bool(artifacts.get('adversarial', False))
detector_modes = _resolve_detector_modes(
artifacts,
instrument,
int(params.get('random_seed', 42)),
instrument_defaults=instrument_defaults,
)
contour_step = 8
if 'banding_coherent' in detector_modes:
banding_amplitude = 0.0
if 'bias_structure' in detector_modes:
bias_mode = detector_modes['bias_structure']
if bias_mode.get('pedestal_sigma_dn') is not None:
bias_pedestal_sigma = float(bias_mode['pedestal_sigma_dn'])
if bias_mode.get('row_gradient_dn') is not None:
bias_row_gradient = float(bias_mode['row_gradient_dn'])
if bias_mode.get('col_gradient_dn') is not None:
bias_col_gradient = float(bias_mode['col_gradient_dn'])
if 'bloom' in detector_modes:
bloom_override = detector_modes['bloom'].get('bloom_length')
if bloom_override is not None:
bloom_length = int(bloom_override)
if 'quantization_lut' in detector_modes:
quantization = 'sqrt_lut'
if 'quantization_ls8b' in detector_modes:
quantization = 'ls8b'
if 'contouring_8bit' in detector_modes:
quantization = 'contour_8bit'
contour_step = int(detector_modes['contouring_8bit'].get('step', 8))
return DetectorParams(
detector_model=detector_model,
data_units=data_units,
signal_full_scale_frac=signal_full_scale_frac,
full_well_e=full_well_e,
exposure_ref_sec=exposure_ref_sec,
exposure_sec=exposure_sec,
gain_e_per_dn=gain_e_per_dn,
read_noise_e=read_noise_e,
bias_dn=bias_dn,
saturation_dn=saturation_dn,
full_well_dn=full_well_dn,
quantization=quantization,
poisson=poisson,
bloom_length=bloom_length,
cosmic_ray_rate_per_sec=cosmic_ray_rate,
pixel_area_cm2=pixel_area_cm2,
dark_current_e_per_sec=dark_current,
hot_pixel_fraction=hot_pixel_fraction,
hot_pixel_amplitude_e=hot_pixel_amplitude,
hot_pixel_column_factor=hot_pixel_column_factor,
banding_amplitude_e=banding_amplitude,
banding_period_px=banding_period_px,
bias_pedestal_sigma_dn=bias_pedestal_sigma,
bias_row_gradient_dn=bias_row_gradient,
bias_col_gradient_dn=bias_col_gradient,
vidicon=vidicon,
calibration_scale_dn_per_s_per_if=calibration_scale,
dark_dn=dark_dn,
random_seed=int(params.get('random_seed', 42)),
instrument_defaults=instrument_defaults,
hot_pixel_adversarial=hot_pixel_adversarial,
hot_pixel_mode_active=hot_pixel_mode_active,
quantization_contour_step=contour_step,
artifacts_adversarial=artifacts_adversarial,
detector_modes=detector_modes,
)