getframes 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. getframes/__about__.py +4 -0
  2. getframes/__init__.py +91 -0
  3. getframes/analysis/__init__.py +18 -0
  4. getframes/analysis/apertures.py +92 -0
  5. getframes/analysis/ptc.py +109 -0
  6. getframes/calibrate.py +182 -0
  7. getframes/camera.py +649 -0
  8. getframes/cli.py +214 -0
  9. getframes/config.py +420 -0
  10. getframes/dataset.py +294 -0
  11. getframes/frame.py +107 -0
  12. getframes/noise.py +637 -0
  13. getframes/observation.py +162 -0
  14. getframes/presets/__init__.py +90 -0
  15. getframes/presets/data/__init__.py +3 -0
  16. getframes/presets/data/andor_ikon_m934.toml +22 -0
  17. getframes/presets/data/andor_ixon_ultra_888.toml +22 -0
  18. getframes/presets/data/generic_ccd.toml +18 -0
  19. getframes/presets/data/generic_cmos.toml +18 -0
  20. getframes/presets/data/generic_eapd.toml +20 -0
  21. getframes/presets/data/generic_emccd.toml +20 -0
  22. getframes/presets/data/generic_scmos.toml +21 -0
  23. getframes/presets/data/hamamatsu_orca_fusion.toml +25 -0
  24. getframes/presets/data/leonardo_saphira.toml +32 -0
  25. getframes/presets/data/zwo_asi2600mm.toml +20 -0
  26. getframes/py.typed +0 -0
  27. getframes/scene/__init__.py +51 -0
  28. getframes/scene/optics.py +180 -0
  29. getframes/scene/photometry.py +311 -0
  30. getframes/scene/psf.py +371 -0
  31. getframes/scene/scene.py +205 -0
  32. getframes/scene/sources.py +683 -0
  33. getframes/scene/thermal.py +114 -0
  34. getframes/scene/wcs.py +110 -0
  35. getframes/spectral.py +449 -0
  36. getframes-2.0.0.dist-info/METADATA +218 -0
  37. getframes-2.0.0.dist-info/RECORD +40 -0
  38. getframes-2.0.0.dist-info/WHEEL +4 -0
  39. getframes-2.0.0.dist-info/entry_points.txt +2 -0
  40. getframes-2.0.0.dist-info/licenses/LICENSE +21 -0
getframes/cli.py ADDED
@@ -0,0 +1,214 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """The ``getframes`` command-line interface (roadmap phase 1.6).
3
+
4
+ A thin wrapper that turns a TOML configuration file into frames or an ML dataset,
5
+ so an experiment is a file you can share and run without writing Python. Three
6
+ subcommands:
7
+
8
+ * ``getframes presets`` — list the built-in camera presets.
9
+ * ``getframes generate config.toml -o frame.fits`` — generate one frame (or a
10
+ short series) of a given type (dark/bias/flat/light).
11
+ * ``getframes dataset config.toml -o train/`` — stream raw + truth pairs to disk.
12
+
13
+ See :func:`main`. Run ``getframes --help`` for the full usage.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import numpy as np
24
+
25
+ from .__about__ import __version__
26
+ from .camera import Camera
27
+ from .config import CameraConfig
28
+ from .frame import Frame
29
+ from .presets import available_presets, load_preset
30
+
31
+ if sys.version_info >= (3, 11):
32
+ import tomllib
33
+ else: # pragma: no cover - exercised only on 3.10
34
+ import tomli as tomllib
35
+
36
+ # CLI-only keys in a [camera] table that are not CameraConfig fields.
37
+ _CAMERA_META_KEYS = ("preset", "default_temperature_c", "precision")
38
+
39
+
40
+ def _load_toml(path: str) -> dict[str, Any]:
41
+ data = Path(path).read_bytes()
42
+ return tomllib.loads(data.decode("utf-8"))
43
+
44
+
45
+ def _camera_from_config(cfg: dict[str, Any]) -> Camera:
46
+ """Build a :class:`Camera` from a config's ``[camera]`` table.
47
+
48
+ Either ``preset = "<slug>"`` (with optional overrides) or an inline
49
+ :class:`~getframes.config.CameraConfig` table. ``default_temperature_c`` and
50
+ ``precision`` are camera-construction options, not config fields.
51
+ """
52
+ cam_cfg = dict(cfg.get("camera", {}))
53
+ kwargs: dict[str, Any] = {}
54
+ if "default_temperature_c" in cam_cfg:
55
+ kwargs["default_temperature_c"] = float(cam_cfg["default_temperature_c"])
56
+ if "precision" in cam_cfg:
57
+ kwargs["precision"] = str(cam_cfg["precision"])
58
+
59
+ if "preset" in cam_cfg:
60
+ config = load_preset(str(cam_cfg["preset"]))
61
+ else:
62
+ fields = {k: v for k, v in cam_cfg.items() if k not in _CAMERA_META_KEYS}
63
+ if not fields:
64
+ raise ValueError("The [camera] table needs a `preset` or inline config fields.")
65
+ config = CameraConfig.from_dict(fields)
66
+ return Camera(config, **kwargs)
67
+
68
+
69
+ def _write_frame(frame: Frame, path: str) -> None:
70
+ """Write a frame by output extension: ``.fits``, ``.npy``, or ``.npz``."""
71
+ suffix = Path(path).suffix.lower()
72
+ if suffix in (".fits", ".fit"):
73
+ frame.to_fits(path, overwrite=True)
74
+ elif suffix == ".npy":
75
+ np.save(path, np.asarray(frame.data))
76
+ elif suffix == ".npz":
77
+ raw = np.asarray(frame.data)
78
+ if frame.truth is not None:
79
+ np.savez(path, raw=raw, truth=np.asarray(frame.truth.mean_electrons))
80
+ else:
81
+ np.savez(path, raw=raw)
82
+ else:
83
+ raise ValueError(f"Unsupported output extension {suffix!r} (use .fits, .npy, or .npz).")
84
+
85
+
86
+ def _generate_frame(camera: Camera, spec: dict[str, Any], seed: int | None) -> Frame:
87
+ """Generate one frame from a ``[frame]`` spec (dark/bias/flat/light)."""
88
+ ftype = str(spec.get("type", "dark")).lower()
89
+ exposure = float(spec.get("exposure_s", 0.0))
90
+ temperature = spec.get("temperature_c")
91
+ temp = None if temperature is None else float(temperature)
92
+ if ftype == "bias":
93
+ return camera.bias_frame(temp, seed=seed)
94
+ if ftype == "dark":
95
+ return camera.dark_frame(exposure, temp, seed=seed)
96
+ if ftype == "flat":
97
+ return camera.flat_frame(float(spec.get("photon_rate", 0.0)), exposure, temp, seed=seed)
98
+ if ftype == "light":
99
+ return camera.expose(float(spec.get("photon_rate", 0.0)), exposure, temp, seed=seed)
100
+ raise ValueError(f"Unknown frame type {ftype!r} (use dark, bias, flat, or light).")
101
+
102
+
103
+ def _cmd_presets(_args: argparse.Namespace) -> int:
104
+ for name in available_presets():
105
+ print(name)
106
+ return 0
107
+
108
+
109
+ def _cmd_generate(args: argparse.Namespace) -> int:
110
+ cfg = _load_toml(args.config)
111
+ camera = _camera_from_config(cfg)
112
+ spec = dict(cfg.get("frame", {}))
113
+ base_seed = spec.get("seed")
114
+ base_seed = None if base_seed is None else int(base_seed)
115
+ n_frames = int(spec.get("n_frames", 1))
116
+ if n_frames < 1:
117
+ raise ValueError("frame.n_frames must be >= 1.")
118
+
119
+ seeds = Camera._series_seeds(base_seed, n_frames)
120
+ out = args.output
121
+ for i, seed in enumerate(seeds):
122
+ frame = _generate_frame(camera, spec, seed)
123
+ if out is None:
124
+ stats = frame.stats()
125
+ summary = ", ".join(f"{k}={v:.3f}" for k, v in stats.items())
126
+ print(f"frame {i}: {summary}")
127
+ else:
128
+ path = out if n_frames == 1 else _indexed_path(out, i)
129
+ _write_frame(frame, path)
130
+ print(f"wrote {path}")
131
+ return 0
132
+
133
+
134
+ def _indexed_path(path: str, index: int) -> str:
135
+ p = Path(path)
136
+ return str(p.with_name(f"{p.stem}_{index:03d}{p.suffix}"))
137
+
138
+
139
+ def _cmd_dataset(args: argparse.Namespace) -> int:
140
+ from . import dataset as dataset_mod
141
+
142
+ cfg = _load_toml(args.config)
143
+ camera = _camera_from_config(cfg)
144
+ spec = dict(cfg.get("dataset", {}))
145
+ shape = tuple(int(s) for s in spec["shape"])
146
+ if len(shape) != 2:
147
+ raise ValueError("dataset.shape must be [height, width].")
148
+ # The dataset shape drives the detector size, so the synthetic scenes fit.
149
+ camera = camera.with_config(resolution=[shape[0], shape[1]])
150
+ n_stars: Any = spec.get("n_stars", (20, 200))
151
+ if isinstance(n_stars, list):
152
+ n_stars = tuple(int(v) for v in n_stars)
153
+ mag_range = spec.get("mag_range", (16.0, 22.0))
154
+ seed = spec.get("seed")
155
+ seed = None if seed is None else int(seed)
156
+
157
+ scenes = dataset_mod.random_star_fields(
158
+ n=int(spec["n"]),
159
+ shape=(shape[0], shape[1]),
160
+ n_stars=n_stars,
161
+ mag_range=(float(mag_range[0]), float(mag_range[1])),
162
+ seed=seed,
163
+ )
164
+ ds = dataset_mod.pairs(
165
+ camera=camera,
166
+ scenes=scenes,
167
+ exposure=float(spec["exposure_s"]),
168
+ dtype=str(spec.get("dtype", "float32")),
169
+ seed=seed,
170
+ )
171
+ paths = ds.to_npz(args.output, compress=bool(spec.get("compress", False)))
172
+ print(f"wrote {len(paths)} pairs to {args.output}")
173
+ return 0
174
+
175
+
176
+ def build_parser() -> argparse.ArgumentParser:
177
+ """Construct the ``getframes`` argument parser."""
178
+ parser = argparse.ArgumentParser(
179
+ prog="getframes",
180
+ description="Generate physically realistic synthetic camera frames from a config file.",
181
+ )
182
+ parser.add_argument("--version", action="version", version=f"getframes {__version__}")
183
+ sub = parser.add_subparsers(dest="command", required=True)
184
+
185
+ p_presets = sub.add_parser("presets", help="List the built-in camera presets.")
186
+ p_presets.set_defaults(func=_cmd_presets)
187
+
188
+ p_gen = sub.add_parser("generate", help="Generate a frame (or series) from a config file.")
189
+ p_gen.add_argument("config", help="Path to a TOML config file.")
190
+ p_gen.add_argument(
191
+ "-o", "--output", default=None, help="Output path (.fits/.npy/.npz); omit to print stats."
192
+ )
193
+ p_gen.set_defaults(func=_cmd_generate)
194
+
195
+ p_ds = sub.add_parser("dataset", help="Generate a raw+truth dataset from a config file.")
196
+ p_ds.add_argument("config", help="Path to a TOML config file.")
197
+ p_ds.add_argument("-o", "--output", required=True, help="Output directory for the .npz pairs.")
198
+ p_ds.set_defaults(func=_cmd_dataset)
199
+ return parser
200
+
201
+
202
+ def main(argv: list[str] | None = None) -> int:
203
+ """Entry point for the ``getframes`` command. Returns a process exit code."""
204
+ parser = build_parser()
205
+ args = parser.parse_args(argv)
206
+ try:
207
+ exit_code: int = args.func(args)
208
+ except (ValueError, KeyError, FileNotFoundError) as exc:
209
+ parser.error(str(exc))
210
+ return exit_code
211
+
212
+
213
+ if __name__ == "__main__": # pragma: no cover
214
+ raise SystemExit(main())
getframes/config.py ADDED
@@ -0,0 +1,420 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Camera configuration: the physical and electronic parameters of a detector.
3
+
4
+ A :class:`CameraConfig` is a plain, immutable description of a camera. It carries
5
+ no simulation logic itself; it is consumed by :class:`getframes.camera.Camera`
6
+ and the noise models in :mod:`getframes.noise`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from dataclasses import asdict, dataclass, field
13
+ from enum import Enum
14
+ from typing import Any
15
+
16
+ from .spectral import QE
17
+
18
+
19
+ class SensorType(str, Enum):
20
+ """The detector architecture, which selects the noise model used."""
21
+
22
+ CCD = "CCD"
23
+ CMOS = "CMOS"
24
+ EMCCD = "EMCCD"
25
+ EAPD = "EAPD" # electron-avalanche photodiode (e.g. SAPHIRA IR arrays)
26
+ SCMOS = "SCMOS" # scientific CMOS (per-pixel read noise, rolling shutter)
27
+
28
+ @classmethod
29
+ def coerce(cls, value: SensorType | str) -> SensorType:
30
+ """Accept either a :class:`SensorType` or a case-insensitive string."""
31
+ if isinstance(value, cls):
32
+ return value
33
+ try:
34
+ return cls(str(value).upper())
35
+ except ValueError as exc: # pragma: no cover - trivial
36
+ valid = ", ".join(s.value for s in cls)
37
+ raise ValueError(f"Unknown sensor type {value!r}. Expected one of: {valid}.") from exc
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class CameraConfig:
42
+ """Physical and electronic parameters of a camera/detector.
43
+
44
+ All electron quantities are in electrons (``e-``); all digital quantities are
45
+ in analog-to-digital units (ADU, sometimes called counts or DN).
46
+
47
+ Parameters
48
+ ----------
49
+ name:
50
+ Human-readable identifier (e.g. ``"Andor iKon-M 934"``).
51
+ sensor_type:
52
+ One of :class:`SensorType` (CCD, CMOS, EMCCD, EAPD). Selects the noise
53
+ model; EMCCD and EAPD additionally use the stochastic gain stage.
54
+ resolution:
55
+ Sensor size as ``(height, width)`` in pixels, matching NumPy's row-major
56
+ array convention.
57
+ pixel_size_um:
58
+ Physical pixel pitch in microns. Informational; not used for dark frames.
59
+ quantum_efficiency:
60
+ Band-averaged quantum efficiency in ``[0, 1]``. Used by the signal path to
61
+ convert photons to photoelectrons. Ignored for dark frames.
62
+ qe_curve:
63
+ Optional wavelength-resolved quantum efficiency
64
+ (:class:`~getframes.spectral.QE`). When set, :meth:`Camera.observe`
65
+ switches to spectral mode and computes a colour-dependent effective QE from
66
+ each source's SED and the band's spectral response, instead of the scalar
67
+ ``quantum_efficiency``. ``None`` keeps the band-averaged model.
68
+ full_well_e:
69
+ Full-well capacity in electrons. Signal saturates here before digitization.
70
+ bit_depth:
71
+ ADC resolution in bits. The output saturates at ``2**bit_depth - 1``.
72
+ gain_e_per_adu:
73
+ Camera conversion gain in electrons per ADU. Electrons reaching the ADC
74
+ are divided by this to produce counts.
75
+ bias_offset_adu:
76
+ Electronic offset (pedestal) added to every pixel, in ADU.
77
+ read_noise_e:
78
+ RMS read noise in electrons. For sCMOS this is the *median*; see
79
+ ``read_noise_nonuniformity``.
80
+ read_noise_nonuniformity:
81
+ Fractional pixel-to-pixel spread of the read-noise RMS (e.g. ``0.3`` for a
82
+ 30% log-normal spread). Models the per-pixel read-noise distribution of
83
+ sCMOS sensors. ``0`` gives a single uniform read noise.
84
+ nonlinearity:
85
+ Fractional signal compression at full well, in ``[0, 0.5)``. The collected
86
+ charge is bent as ``q -> q * (1 - nonlinearity * q / full_well_e)``, so a
87
+ pixel at full well reads ``nonlinearity`` fraction low. ``0`` is perfectly
88
+ linear. Superseded by ``nonlinearity_coeffs`` when that is given.
89
+ nonlinearity_coeffs:
90
+ Optional polynomial generalisation of ``nonlinearity``. A sequence
91
+ ``(c1, c2, ...)`` defines the response multiplier
92
+ ``q -> q * (1 + c1 * u + c2 * u**2 + ...)`` with ``u = q / full_well_e``, so
93
+ an arbitrary measured nonlinearity curve (or look-up) can be reproduced.
94
+ When set it replaces the single-parameter ``nonlinearity`` model. ``None``
95
+ keeps the scalar model.
96
+ cti:
97
+ Charge-transfer inefficiency (CTI) of a CCD, the fraction of charge left
98
+ behind per pixel-to-pixel transfer during readout, in ``[0, 1)``. A bright
99
+ pixel ``r`` rows from the readout register undergoes ``r`` transfers and
100
+ smears a deferred-charge tail away from the register. ``0`` is a perfect
101
+ CCD. (Trap-driven deferral; the readout register is taken to be row 0.)
102
+ blooming:
103
+ When ``True``, charge collected above ``full_well_e`` spills (blooms) into
104
+ the vertically adjacent pixels of the same column until it is below full
105
+ well or runs off the array, charge-conserving — the bright bleed columns of
106
+ a saturated CCD. ``False`` simply clips at full well.
107
+ ipc_coupling:
108
+ Inter-pixel capacitance (IPC): the fraction of each pixel's signal that
109
+ couples capacitively into *each* of its four nearest neighbours at readout,
110
+ in ``[0, 0.25)``. Applied as a charge-conserving 3x3 convolution (CMOS/IR
111
+ hybrid arrays). ``0`` disables it.
112
+ reset_noise_e:
113
+ kTC / reset noise RMS in electrons: an independent per-pixel, per-frame
114
+ Gaussian charge uncertainty from resetting the sense node, added alongside
115
+ read noise. ``0`` disables it (or assumes it is removed by correlated double
116
+ sampling).
117
+ amplifier_layout:
118
+ Multi-amplifier readout as ``(n_rows, n_cols)`` of amplifiers tiling the
119
+ sensor (e.g. ``(2, 2)`` for a four-quadrant CCD). Each amplifier block gets
120
+ its own small gain and offset error (see ``amp_gain_nonuniformity`` /
121
+ ``amp_offset_spread_adu``), producing the characteristic seams. ``(1, 1)``
122
+ is a single amplifier.
123
+ amp_gain_nonuniformity:
124
+ Fractional RMS spread of per-amplifier gain about ``gain_e_per_adu`` (a
125
+ fixed pattern keyed on ``fixed_pattern_seed``). Ignored for a single
126
+ amplifier.
127
+ amp_offset_spread_adu:
128
+ RMS spread of per-amplifier bias offset in ADU, about ``bias_offset_adu``
129
+ (fixed pattern). Ignored for a single amplifier.
130
+ cosmic_ray_track_length_px:
131
+ Mean length in pixels of cosmic-ray *tracks*. ``0`` keeps the single-pixel
132
+ hit model; a positive value draws an exponential track length and a random
133
+ direction per hit, depositing the charge along the track (glancing muons).
134
+ bad_column_fraction:
135
+ Fraction of columns that are defective (dead): a fixed, deterministic set of
136
+ whole columns forced to zero signal in every frame — the bad columns a flat
137
+ cannot rescue. ``0`` disables.
138
+ dead_pixel_fraction:
139
+ Fraction of individual pixels that are dead (zero response), a fixed map.
140
+ ``0`` disables.
141
+ bias_structure_amplitude_adu:
142
+ Peak amplitude in ADU of a fixed, structured bias pattern (a smooth gradient
143
+ plus per-column offsets) added on top of the flat ``bias_offset_adu``
144
+ pedestal. ``0`` keeps the bias a flat pedestal.
145
+ cosmic_ray_rate_per_cm2_s:
146
+ Cosmic-ray hit rate in events per cm^2 per second (sea level is ~5). The
147
+ number of hits scales with sensor area and exposure; each deposits a burst
148
+ of charge in a random pixel.
149
+ prnu:
150
+ Photo-response non-uniformity: fractional pixel-to-pixel variation in
151
+ sensitivity (e.g. ``0.01`` for 1% RMS). Imprints a fixed multiplicative
152
+ pattern on the *photo* signal (not the dark signal). Ignored for dark
153
+ frames, where there is no light.
154
+ dark_current_e_per_s:
155
+ Dark current in electrons per pixel per second, specified at
156
+ ``dark_current_ref_temp_c``.
157
+ detector_glow_e_per_s:
158
+ Detector self-emission ("glow") in electrons per pixel per second, added to
159
+ the dark signal (it scales with exposure and so is removed by an
160
+ exposure-matched master dark). A uniform model of amplifier/array glow,
161
+ relevant for IR arrays alongside the thermal background. ``0`` disables it.
162
+ dark_current_ref_temp_c:
163
+ Temperature (deg C) at which ``dark_current_e_per_s`` is quoted.
164
+ dark_current_doubling_temp_c:
165
+ Temperature increase (deg C) that doubles the dark current. Typical CCD/CMOS
166
+ silicon values are 5-8 C.
167
+ em_gain:
168
+ Mean gain of the stochastic multiplication stage: the EM register of an
169
+ EMCCD or the avalanche gain of an eAPD. ``1.0`` disables it (CCD/CMOS).
170
+ excess_noise_factor:
171
+ Excess noise factor ``F`` of the gain stage, quantifying the extra noise
172
+ from stochastic multiplication. ``F = 1`` is noiseless multiplication;
173
+ EMCCDs approach ``F = sqrt(2) ~ 1.41`` at high gain; eAPDs are much
174
+ quieter (``F ~ 1.2-1.4``). If ``None`` (default), an appropriate value is
175
+ used for the sensor type (sqrt(2) for EMCCD, 1.0 otherwise) --- see
176
+ :attr:`gain_excess_noise_factor`.
177
+ clock_induced_charge_e:
178
+ Clock-induced charge (spurious charge) in electrons per pixel per frame.
179
+ Relevant mainly for EMCCD.
180
+ persistence_fraction:
181
+ Fraction of a frame's collected charge captured into traps as a latent
182
+ image (image persistence), in ``[0, 1]``. Relevant for IR arrays (eAPD).
183
+ The trapped charge is released into subsequent frames of an
184
+ :class:`~getframes.observation.Observation` (it needs the cross-frame state
185
+ that :meth:`Camera.observe_series` provides). ``0`` disables persistence.
186
+ persistence_decay:
187
+ Fraction of the trapped charge released each subsequent frame, in
188
+ ``[0, 1]``. ``1`` dumps all latent charge into the very next frame; smaller
189
+ values give a slowly fading ghost over several frames.
190
+ dark_current_nonuniformity:
191
+ Fractional pixel-to-pixel dark-signal non-uniformity (DSNU), e.g. ``0.05``
192
+ for 5% RMS. Models fixed-pattern structure in the dark signal.
193
+ hot_pixel_fraction:
194
+ Fraction of pixels that are "hot" (anomalously high dark current).
195
+ hot_pixel_factor:
196
+ Multiplicative dark-current factor applied to hot pixels.
197
+ fixed_pattern_seed:
198
+ Seed for the sensor's *fixed-pattern* noise (PRNU, DSNU, and the hot-pixel
199
+ map). These patterns are a property of the physical sensor, so they are the
200
+ *same in every frame* this camera produces --- which is exactly what lets a
201
+ master flat or dark capture and remove them. Two configs with the same seed
202
+ and shape share a pattern; change it to mint a different sensor. Independent
203
+ of the per-frame ``seed`` that drives shot/read noise.
204
+ manufacturer, model, notes:
205
+ Optional provenance metadata.
206
+ """
207
+
208
+ name: str
209
+ sensor_type: SensorType
210
+ resolution: tuple[int, int]
211
+ pixel_size_um: float
212
+ quantum_efficiency: float
213
+ full_well_e: float
214
+ bit_depth: int
215
+ gain_e_per_adu: float
216
+ bias_offset_adu: float
217
+ read_noise_e: float
218
+ dark_current_e_per_s: float
219
+ qe_curve: QE | None = None
220
+ detector_glow_e_per_s: float = 0.0
221
+ prnu: float = 0.0
222
+ read_noise_nonuniformity: float = 0.0
223
+ nonlinearity: float = 0.0
224
+ nonlinearity_coeffs: tuple[float, ...] | None = None
225
+ cti: float = 0.0
226
+ blooming: bool = False
227
+ ipc_coupling: float = 0.0
228
+ reset_noise_e: float = 0.0
229
+ amplifier_layout: tuple[int, int] = (1, 1)
230
+ amp_gain_nonuniformity: float = 0.0
231
+ amp_offset_spread_adu: float = 0.0
232
+ cosmic_ray_track_length_px: float = 0.0
233
+ bad_column_fraction: float = 0.0
234
+ dead_pixel_fraction: float = 0.0
235
+ bias_structure_amplitude_adu: float = 0.0
236
+ cosmic_ray_rate_per_cm2_s: float = 0.0
237
+ dark_current_ref_temp_c: float = 20.0
238
+ dark_current_doubling_temp_c: float = 6.3
239
+ em_gain: float = 1.0
240
+ excess_noise_factor: float | None = None
241
+ clock_induced_charge_e: float = 0.0
242
+ persistence_fraction: float = 0.0
243
+ persistence_decay: float = 0.5
244
+ dark_current_nonuniformity: float = 0.0
245
+ hot_pixel_fraction: float = 0.0
246
+ hot_pixel_factor: float = 100.0
247
+ fixed_pattern_seed: int = 0
248
+ manufacturer: str | None = None
249
+ model: str | None = None
250
+ notes: str | None = None
251
+ extra: dict[str, Any] = field(default_factory=dict)
252
+
253
+ def __post_init__(self) -> None:
254
+ # Normalise/validate without mutating frozen fields directly.
255
+ object.__setattr__(self, "sensor_type", SensorType.coerce(self.sensor_type))
256
+ object.__setattr__(self, "resolution", tuple(int(n) for n in self.resolution))
257
+ object.__setattr__(self, "amplifier_layout", tuple(int(n) for n in self.amplifier_layout))
258
+ if self.nonlinearity_coeffs is not None:
259
+ object.__setattr__(
260
+ self, "nonlinearity_coeffs", tuple(float(c) for c in self.nonlinearity_coeffs)
261
+ )
262
+ self._validate()
263
+
264
+ def _validate(self) -> None:
265
+ if len(self.resolution) != 2 or any(n <= 0 for n in self.resolution):
266
+ raise ValueError(f"resolution must be two positive ints, got {self.resolution!r}.")
267
+ if not 0.0 <= self.quantum_efficiency <= 1.0:
268
+ raise ValueError("quantum_efficiency must be in [0, 1].")
269
+ if self.bit_depth <= 0:
270
+ raise ValueError("bit_depth must be positive.")
271
+ if self.gain_e_per_adu <= 0:
272
+ raise ValueError("gain_e_per_adu must be positive.")
273
+ if self.read_noise_e < 0:
274
+ raise ValueError("read_noise_e must be non-negative.")
275
+ if self.prnu < 0:
276
+ raise ValueError("prnu must be non-negative.")
277
+ if self.read_noise_nonuniformity < 0:
278
+ raise ValueError("read_noise_nonuniformity must be non-negative.")
279
+ if not 0.0 <= self.nonlinearity < 0.5:
280
+ raise ValueError("nonlinearity must be in [0, 0.5).")
281
+ if self.nonlinearity_coeffs is not None and len(self.nonlinearity_coeffs) == 0:
282
+ raise ValueError("nonlinearity_coeffs must be a non-empty sequence or None.")
283
+ if not 0.0 <= self.cti < 1.0:
284
+ raise ValueError("cti must be in [0, 1).")
285
+ if not 0.0 <= self.ipc_coupling < 0.25:
286
+ raise ValueError("ipc_coupling must be in [0, 0.25).")
287
+ if self.reset_noise_e < 0:
288
+ raise ValueError("reset_noise_e must be non-negative.")
289
+ if len(self.amplifier_layout) != 2 or any(n <= 0 for n in self.amplifier_layout):
290
+ raise ValueError(
291
+ f"amplifier_layout must be two positive ints, got {self.amplifier_layout!r}."
292
+ )
293
+ if self.amp_gain_nonuniformity < 0:
294
+ raise ValueError("amp_gain_nonuniformity must be non-negative.")
295
+ if self.amp_offset_spread_adu < 0:
296
+ raise ValueError("amp_offset_spread_adu must be non-negative.")
297
+ if self.cosmic_ray_track_length_px < 0:
298
+ raise ValueError("cosmic_ray_track_length_px must be non-negative.")
299
+ if not 0.0 <= self.bad_column_fraction <= 1.0:
300
+ raise ValueError("bad_column_fraction must be in [0, 1].")
301
+ if not 0.0 <= self.dead_pixel_fraction <= 1.0:
302
+ raise ValueError("dead_pixel_fraction must be in [0, 1].")
303
+ if self.bias_structure_amplitude_adu < 0:
304
+ raise ValueError("bias_structure_amplitude_adu must be non-negative.")
305
+ if self.cosmic_ray_rate_per_cm2_s < 0:
306
+ raise ValueError("cosmic_ray_rate_per_cm2_s must be non-negative.")
307
+ if self.dark_current_e_per_s < 0:
308
+ raise ValueError("dark_current_e_per_s must be non-negative.")
309
+ if self.detector_glow_e_per_s < 0:
310
+ raise ValueError("detector_glow_e_per_s must be non-negative.")
311
+ if self.dark_current_doubling_temp_c <= 0:
312
+ raise ValueError("dark_current_doubling_temp_c must be positive.")
313
+ if self.em_gain < 1.0:
314
+ raise ValueError("em_gain must be >= 1.0 (use 1.0 to disable).")
315
+ if self.excess_noise_factor is not None and self.excess_noise_factor < 1.0:
316
+ raise ValueError("excess_noise_factor must be >= 1.0 (1.0 is noiseless).")
317
+ if self.full_well_e <= 0:
318
+ raise ValueError("full_well_e must be positive.")
319
+ if not 0.0 <= self.hot_pixel_fraction <= 1.0:
320
+ raise ValueError("hot_pixel_fraction must be in [0, 1].")
321
+ if not 0.0 <= self.persistence_fraction <= 1.0:
322
+ raise ValueError("persistence_fraction must be in [0, 1].")
323
+ if not 0.0 <= self.persistence_decay <= 1.0:
324
+ raise ValueError("persistence_decay must be in [0, 1].")
325
+ if self.qe_curve is not None and not isinstance(self.qe_curve, QE):
326
+ raise ValueError("qe_curve must be a getframes.spectral.QE instance or None.")
327
+
328
+ @property
329
+ def max_adu(self) -> int:
330
+ """The saturation value of the ADC output."""
331
+ return int(2**self.bit_depth - 1)
332
+
333
+ @property
334
+ def has_gain_stage(self) -> bool:
335
+ """Whether a stochastic multiplication stage (EM/avalanche) is active."""
336
+ return self.em_gain > 1.0
337
+
338
+ @property
339
+ def gain_excess_noise_factor(self) -> float:
340
+ """The effective excess noise factor ``F`` of the gain stage.
341
+
342
+ Returns :attr:`excess_noise_factor` if set, else a sensible default for the
343
+ sensor type: ``sqrt(2)`` for EMCCD (the high-gain limit) and ``1.0``
344
+ (noiseless) otherwise.
345
+ """
346
+ if self.excess_noise_factor is not None:
347
+ return self.excess_noise_factor
348
+ if self.sensor_type is SensorType.EMCCD:
349
+ return math.sqrt(2.0)
350
+ return 1.0
351
+
352
+ def dark_current_at(self, temperature_c: float) -> float:
353
+ """Dark current (e-/pixel/s) scaled to ``temperature_c``.
354
+
355
+ Uses the standard doubling-temperature model::
356
+
357
+ D(T) = D_ref * 2 ** ((T - T_ref) / T_double)
358
+ """
359
+ delta = temperature_c - self.dark_current_ref_temp_c
360
+ exponent = delta / self.dark_current_doubling_temp_c
361
+ return float(self.dark_current_e_per_s * 2.0**exponent)
362
+
363
+ def replace(self, **changes: Any) -> CameraConfig:
364
+ """Return a copy with the given fields overridden (like ``dataclasses.replace``)."""
365
+ data = self.to_dict()
366
+ data.update(changes)
367
+ return CameraConfig.from_dict(data)
368
+
369
+ def to_dict(self) -> dict[str, Any]:
370
+ """Serialise to a plain dict (sensor_type rendered as its string value)."""
371
+ data = asdict(self)
372
+ data["sensor_type"] = self.sensor_type.value
373
+ data["resolution"] = list(self.resolution)
374
+ data["amplifier_layout"] = list(self.amplifier_layout)
375
+ if self.nonlinearity_coeffs is not None:
376
+ data["nonlinearity_coeffs"] = list(self.nonlinearity_coeffs)
377
+ data["qe_curve"] = _serialize_qe_curve(self.qe_curve)
378
+ return data
379
+
380
+ @classmethod
381
+ def from_dict(cls, data: dict[str, Any]) -> CameraConfig:
382
+ """Build a config from a dict, ignoring unknown keys (stashed in ``extra``).
383
+
384
+ A ``qe_curve`` may be given as a :class:`~getframes.spectral.QE` or as a
385
+ mapping ``{"wavelength_nm": [...], "qe": [...]}`` (the form used in preset
386
+ TOML files).
387
+ """
388
+ known = {f for f in cls.__dataclass_fields__ if f != "extra"}
389
+ kwargs = {k: v for k, v in data.items() if k in known}
390
+ if "qe_curve" in kwargs:
391
+ kwargs["qe_curve"] = _parse_qe_curve(kwargs["qe_curve"])
392
+ passthrough = dict(data.get("extra", {}))
393
+ unknown = {k: v for k, v in data.items() if k not in known and k != "extra"}
394
+ merged = {**passthrough, **unknown}
395
+ if merged:
396
+ kwargs["extra"] = merged
397
+ return cls(**kwargs)
398
+
399
+
400
+ def _serialize_qe_curve(qe_curve: QE | None) -> dict[str, list[float]] | None:
401
+ """Render a QE curve to a plain ``{wavelength_nm, qe}`` mapping (or ``None``)."""
402
+ if qe_curve is None:
403
+ return None
404
+ return {
405
+ "wavelength_nm": [float(w) for w in qe_curve.wavelength_nm],
406
+ "qe": [float(v) for v in qe_curve.value],
407
+ }
408
+
409
+
410
+ def _parse_qe_curve(value: QE | dict[str, Any] | None) -> QE | None:
411
+ """Coerce a QE curve given as a :class:`QE`, a mapping, or ``None``."""
412
+ if value is None or isinstance(value, QE):
413
+ return value
414
+ if isinstance(value, dict):
415
+ wl = value.get("wavelength_nm")
416
+ qe = value.get("qe", value.get("value"))
417
+ if wl is None or qe is None:
418
+ raise ValueError("qe_curve mapping needs 'wavelength_nm' and 'qe' keys.")
419
+ return QE.from_arrays(wl, qe)
420
+ raise ValueError("qe_curve must be a QE, a mapping, or None.")