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.
- getframes/__about__.py +4 -0
- getframes/__init__.py +91 -0
- getframes/analysis/__init__.py +18 -0
- getframes/analysis/apertures.py +92 -0
- getframes/analysis/ptc.py +109 -0
- getframes/calibrate.py +182 -0
- getframes/camera.py +649 -0
- getframes/cli.py +214 -0
- getframes/config.py +420 -0
- getframes/dataset.py +294 -0
- getframes/frame.py +107 -0
- getframes/noise.py +637 -0
- getframes/observation.py +162 -0
- getframes/presets/__init__.py +90 -0
- getframes/presets/data/__init__.py +3 -0
- getframes/presets/data/andor_ikon_m934.toml +22 -0
- getframes/presets/data/andor_ixon_ultra_888.toml +22 -0
- getframes/presets/data/generic_ccd.toml +18 -0
- getframes/presets/data/generic_cmos.toml +18 -0
- getframes/presets/data/generic_eapd.toml +20 -0
- getframes/presets/data/generic_emccd.toml +20 -0
- getframes/presets/data/generic_scmos.toml +21 -0
- getframes/presets/data/hamamatsu_orca_fusion.toml +25 -0
- getframes/presets/data/leonardo_saphira.toml +32 -0
- getframes/presets/data/zwo_asi2600mm.toml +20 -0
- getframes/py.typed +0 -0
- getframes/scene/__init__.py +51 -0
- getframes/scene/optics.py +180 -0
- getframes/scene/photometry.py +311 -0
- getframes/scene/psf.py +371 -0
- getframes/scene/scene.py +205 -0
- getframes/scene/sources.py +683 -0
- getframes/scene/thermal.py +114 -0
- getframes/scene/wcs.py +110 -0
- getframes/spectral.py +449 -0
- getframes-2.0.0.dist-info/METADATA +218 -0
- getframes-2.0.0.dist-info/RECORD +40 -0
- getframes-2.0.0.dist-info/WHEEL +4 -0
- getframes-2.0.0.dist-info/entry_points.txt +2 -0
- 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.")
|