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/camera.py
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""The :class:`Camera` — the main user-facing object for generating frames."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from . import noise
|
|
11
|
+
from .config import CameraConfig
|
|
12
|
+
from .frame import Frame, FrameTruth
|
|
13
|
+
from .observation import Observation, ObservationTruth, Pointing
|
|
14
|
+
from .presets import load_preset
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Iterator, Sequence
|
|
18
|
+
|
|
19
|
+
from numpy.typing import NDArray
|
|
20
|
+
|
|
21
|
+
from .noise import PhotonRate
|
|
22
|
+
from .scene import Scene
|
|
23
|
+
from .scene.sources import Source
|
|
24
|
+
|
|
25
|
+
# Sub-stream salt for the pointing RNG, kept distinct from per-frame noise seeds.
|
|
26
|
+
_POINTING_STREAM = 0x504F494E54 # "POINT"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Camera:
|
|
30
|
+
"""A camera that generates realistic synthetic frames.
|
|
31
|
+
|
|
32
|
+
A :class:`Camera` wraps a :class:`~getframes.config.CameraConfig` and exposes
|
|
33
|
+
high-level frame-generation methods. Construct one directly from a config, or
|
|
34
|
+
load a built-in preset:
|
|
35
|
+
|
|
36
|
+
>>> import getframes as gf
|
|
37
|
+
>>> cam = gf.Camera.from_preset("andor_ikon_m934")
|
|
38
|
+
>>> frame = cam.dark_frame(exposure=30.0, temperature=-60.0, seed=0)
|
|
39
|
+
>>> frame.shape
|
|
40
|
+
(1024, 1024)
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
config:
|
|
45
|
+
The detector configuration.
|
|
46
|
+
default_temperature_c:
|
|
47
|
+
Temperature (deg C) used when a frame method is called without an explicit
|
|
48
|
+
temperature. Defaults to the config's dark-current reference temperature.
|
|
49
|
+
seed:
|
|
50
|
+
Optional seed for this camera's internal random generator, giving
|
|
51
|
+
reproducible output across calls when no per-call seed is supplied.
|
|
52
|
+
precision:
|
|
53
|
+
Working floating-point precision of the signal chain: ``"float64"`` (the
|
|
54
|
+
exact default) or ``"float32"`` for the memory-light fast path — half the
|
|
55
|
+
per-pixel memory, useful for large detectors and bulk dataset generation.
|
|
56
|
+
The digitised ADU stay integer either way; only the floating-point arrays
|
|
57
|
+
(including each frame's ground truth) change.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
_PRECISIONS: ClassVar[dict[str, type[np.floating[Any]]]] = {
|
|
61
|
+
"float32": np.float32,
|
|
62
|
+
"float64": np.float64,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
config: CameraConfig,
|
|
68
|
+
*,
|
|
69
|
+
default_temperature_c: float | None = None,
|
|
70
|
+
seed: int | None = None,
|
|
71
|
+
precision: str = "float64",
|
|
72
|
+
) -> None:
|
|
73
|
+
if not isinstance(config, CameraConfig):
|
|
74
|
+
raise TypeError("config must be a CameraConfig instance.")
|
|
75
|
+
if precision not in self._PRECISIONS:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"precision must be one of {sorted(self._PRECISIONS)}, got {precision!r}."
|
|
78
|
+
)
|
|
79
|
+
self.config = config
|
|
80
|
+
self.default_temperature_c = (
|
|
81
|
+
default_temperature_c
|
|
82
|
+
if default_temperature_c is not None
|
|
83
|
+
else config.dark_current_ref_temp_c
|
|
84
|
+
)
|
|
85
|
+
self.precision = precision
|
|
86
|
+
self._float_dtype = self._PRECISIONS[precision]
|
|
87
|
+
self._rng = np.random.default_rng(seed)
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# Constructors
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_preset(cls, name: str, **kwargs: Any) -> Camera:
|
|
94
|
+
"""Create a camera from a built-in preset (see :func:`getframes.available_presets`)."""
|
|
95
|
+
return cls(load_preset(name), **kwargs)
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_dict(cls, data: dict[str, Any], **kwargs: Any) -> Camera:
|
|
99
|
+
"""Create a camera from a plain configuration dictionary."""
|
|
100
|
+
return cls(CameraConfig.from_dict(data), **kwargs)
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Convenience accessors
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
@property
|
|
106
|
+
def name(self) -> str:
|
|
107
|
+
return self.config.name
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def resolution(self) -> tuple[int, int]:
|
|
111
|
+
return self.config.resolution
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def sensor_type(self) -> str:
|
|
115
|
+
return self.config.sensor_type.value
|
|
116
|
+
|
|
117
|
+
def with_config(self, **changes: Any) -> Camera:
|
|
118
|
+
"""Return a new camera with configuration fields overridden."""
|
|
119
|
+
return Camera(
|
|
120
|
+
self.config.replace(**changes),
|
|
121
|
+
default_temperature_c=self.default_temperature_c,
|
|
122
|
+
precision=self.precision,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
# Frame generation
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
def _resolve_rng(self, seed: int | None) -> np.random.Generator:
|
|
129
|
+
return np.random.default_rng(seed) if seed is not None else self._rng
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _series_seeds(seed: int | None, n_frames: int) -> list[int | None]:
|
|
133
|
+
"""Derive ``n_frames`` independent-but-reproducible per-frame seeds.
|
|
134
|
+
|
|
135
|
+
When ``seed`` is given, each frame gets a distinct seed spawned from a
|
|
136
|
+
:class:`numpy.random.SeedSequence`, so the frames are statistically
|
|
137
|
+
independent yet the whole series repeats exactly. When ``seed`` is ``None``,
|
|
138
|
+
every frame draws from the camera's internal generator instead.
|
|
139
|
+
"""
|
|
140
|
+
if n_frames < 1:
|
|
141
|
+
raise ValueError("n_frames must be >= 1.")
|
|
142
|
+
if seed is None:
|
|
143
|
+
return [None] * n_frames
|
|
144
|
+
ss = np.random.SeedSequence(seed)
|
|
145
|
+
return [int(s.generate_state(1)[0]) for s in ss.spawn(n_frames)]
|
|
146
|
+
|
|
147
|
+
def dark_frame(
|
|
148
|
+
self,
|
|
149
|
+
exposure: float,
|
|
150
|
+
temperature: float | None = None,
|
|
151
|
+
*,
|
|
152
|
+
seed: int | None = None,
|
|
153
|
+
) -> Frame:
|
|
154
|
+
"""Generate a single dark frame.
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
exposure:
|
|
159
|
+
Integration time in seconds.
|
|
160
|
+
temperature:
|
|
161
|
+
Sensor temperature in degrees Celsius. Defaults to the camera's
|
|
162
|
+
:attr:`default_temperature_c`.
|
|
163
|
+
seed:
|
|
164
|
+
If given, use a fresh generator seeded with this value, producing a
|
|
165
|
+
fully reproducible frame independent of prior calls. If omitted, the
|
|
166
|
+
camera's internal generator advances.
|
|
167
|
+
|
|
168
|
+
Returns
|
|
169
|
+
-------
|
|
170
|
+
Frame
|
|
171
|
+
The simulated frame (ADU) with descriptive metadata.
|
|
172
|
+
"""
|
|
173
|
+
temp = self.default_temperature_c if temperature is None else temperature
|
|
174
|
+
rng = self._resolve_rng(seed)
|
|
175
|
+
data = noise.generate_dark_frame(self.config, exposure, temp, rng=rng)
|
|
176
|
+
return Frame(data=data, metadata=self._metadata("dark", exposure, temp, seed))
|
|
177
|
+
|
|
178
|
+
def dark_series(
|
|
179
|
+
self,
|
|
180
|
+
exposure: float,
|
|
181
|
+
n_frames: int,
|
|
182
|
+
temperature: float | None = None,
|
|
183
|
+
*,
|
|
184
|
+
seed: int | None = None,
|
|
185
|
+
) -> Iterator[Frame]:
|
|
186
|
+
"""Yield ``n_frames`` independent dark frames (e.g. for building a master dark).
|
|
187
|
+
|
|
188
|
+
When ``seed`` is given the series is reproducible; each frame uses a distinct
|
|
189
|
+
derived seed so the frames are independent but the whole series is repeatable.
|
|
190
|
+
"""
|
|
191
|
+
for i, frame_seed in enumerate(self._series_seeds(seed, n_frames)):
|
|
192
|
+
frame = self.dark_frame(exposure, temperature, seed=frame_seed)
|
|
193
|
+
frame.metadata["frame_index"] = i
|
|
194
|
+
yield frame
|
|
195
|
+
|
|
196
|
+
def expose(
|
|
197
|
+
self,
|
|
198
|
+
photon_rate: PhotonRate,
|
|
199
|
+
exposure: float,
|
|
200
|
+
temperature: float | None = None,
|
|
201
|
+
*,
|
|
202
|
+
background: PhotonRate = 0.0,
|
|
203
|
+
quantum_efficiency: float | None = None,
|
|
204
|
+
extra_electrons: PhotonRate = 0.0,
|
|
205
|
+
seed: int | None = None,
|
|
206
|
+
include_truth: bool = True,
|
|
207
|
+
) -> Frame:
|
|
208
|
+
"""Expose the sensor to an incident photon rate and return a frame.
|
|
209
|
+
|
|
210
|
+
This is the general signal path; :meth:`dark_frame`, :meth:`flat_frame`,
|
|
211
|
+
and :meth:`bias_frame` are convenience wrappers around it.
|
|
212
|
+
|
|
213
|
+
Parameters
|
|
214
|
+
----------
|
|
215
|
+
photon_rate:
|
|
216
|
+
Incident photon rate in photons/s/pixel, as a scalar (uniform
|
|
217
|
+
illumination) or a 2-D array matching :attr:`resolution`.
|
|
218
|
+
exposure:
|
|
219
|
+
Integration time in seconds.
|
|
220
|
+
temperature:
|
|
221
|
+
Sensor temperature in degrees Celsius. Defaults to
|
|
222
|
+
:attr:`default_temperature_c`.
|
|
223
|
+
background:
|
|
224
|
+
Additive background (sky/thermal) photon rate in photons/s/pixel.
|
|
225
|
+
quantum_efficiency:
|
|
226
|
+
Overrides the config's scalar QE for this exposure. Spectral mode uses
|
|
227
|
+
this with an already-photoelectron map and ``1.0``; most callers leave
|
|
228
|
+
it ``None``.
|
|
229
|
+
extra_electrons:
|
|
230
|
+
Additive noise-free signal in electrons (scalar or array) injected
|
|
231
|
+
before shot noise. Used by :meth:`observe_series` to carry latent charge
|
|
232
|
+
from image persistence; most callers leave it ``0.0``.
|
|
233
|
+
seed:
|
|
234
|
+
If given, use a fresh generator seeded with this value for a fully
|
|
235
|
+
reproducible frame.
|
|
236
|
+
include_truth:
|
|
237
|
+
If ``True`` (default), attach the noise-free ground truth to the
|
|
238
|
+
returned :class:`~getframes.frame.Frame` for pipeline validation.
|
|
239
|
+
"""
|
|
240
|
+
temp = self.default_temperature_c if temperature is None else temperature
|
|
241
|
+
rng = self._resolve_rng(seed)
|
|
242
|
+
result = noise.simulate_frame(
|
|
243
|
+
self.config,
|
|
244
|
+
photon_rate,
|
|
245
|
+
exposure,
|
|
246
|
+
temperature_c=temp,
|
|
247
|
+
background_photon_rate=background,
|
|
248
|
+
quantum_efficiency=quantum_efficiency,
|
|
249
|
+
extra_electrons=extra_electrons,
|
|
250
|
+
rng=rng,
|
|
251
|
+
float_dtype=self._float_dtype,
|
|
252
|
+
)
|
|
253
|
+
truth = (
|
|
254
|
+
FrameTruth(
|
|
255
|
+
mean_electrons=result.mean_photoelectrons
|
|
256
|
+
+ result.mean_dark_electrons
|
|
257
|
+
+ np.asarray(extra_electrons, dtype=self._float_dtype),
|
|
258
|
+
mean_photoelectrons=result.mean_photoelectrons,
|
|
259
|
+
photon_rate=result.photon_rate,
|
|
260
|
+
)
|
|
261
|
+
if include_truth
|
|
262
|
+
else None
|
|
263
|
+
)
|
|
264
|
+
metadata = self._metadata("light", exposure, temp, seed)
|
|
265
|
+
return Frame(data=result.adu, metadata=metadata, truth=truth)
|
|
266
|
+
|
|
267
|
+
def flat_frame(
|
|
268
|
+
self,
|
|
269
|
+
photon_rate: PhotonRate,
|
|
270
|
+
exposure: float,
|
|
271
|
+
temperature: float | None = None,
|
|
272
|
+
*,
|
|
273
|
+
background: PhotonRate = 0.0,
|
|
274
|
+
seed: int | None = None,
|
|
275
|
+
include_truth: bool = True,
|
|
276
|
+
) -> Frame:
|
|
277
|
+
"""A uniformly (or per-pixel) illuminated flat-field frame.
|
|
278
|
+
|
|
279
|
+
Equivalent to :meth:`expose`; provided as a named entry point for
|
|
280
|
+
flat-field/photon-transfer workflows. Pass a scalar ``photon_rate`` for a
|
|
281
|
+
uniform flat.
|
|
282
|
+
"""
|
|
283
|
+
frame = self.expose(
|
|
284
|
+
photon_rate,
|
|
285
|
+
exposure,
|
|
286
|
+
temperature,
|
|
287
|
+
background=background,
|
|
288
|
+
seed=seed,
|
|
289
|
+
include_truth=include_truth,
|
|
290
|
+
)
|
|
291
|
+
frame.metadata["frame_type"] = "flat"
|
|
292
|
+
return frame
|
|
293
|
+
|
|
294
|
+
def bias_frame(
|
|
295
|
+
self,
|
|
296
|
+
temperature: float | None = None,
|
|
297
|
+
*,
|
|
298
|
+
seed: int | None = None,
|
|
299
|
+
) -> Frame:
|
|
300
|
+
"""A zero-exposure bias frame (bias pedestal + read noise only)."""
|
|
301
|
+
frame = self.expose(0.0, 0.0, temperature, seed=seed, include_truth=False)
|
|
302
|
+
frame.metadata["frame_type"] = "bias"
|
|
303
|
+
return frame
|
|
304
|
+
|
|
305
|
+
def observe(
|
|
306
|
+
self,
|
|
307
|
+
scene: Scene,
|
|
308
|
+
exposure: float,
|
|
309
|
+
temperature: float | None = None,
|
|
310
|
+
*,
|
|
311
|
+
seed: int | None = None,
|
|
312
|
+
include_truth: bool = True,
|
|
313
|
+
) -> Frame:
|
|
314
|
+
"""Observe a :class:`~getframes.scene.Scene` and return a science frame.
|
|
315
|
+
|
|
316
|
+
Renders the scene to an incident photon-rate map, then exposes the sensor
|
|
317
|
+
to it (adding the scene's sky as a uniform background). The scene's
|
|
318
|
+
``shape`` must match this camera's :attr:`resolution`.
|
|
319
|
+
|
|
320
|
+
**Spectral mode** activates automatically when this camera's config has a
|
|
321
|
+
:attr:`~getframes.config.CameraConfig.qe_curve` *and* the scene's band
|
|
322
|
+
carries a spectral response: each source then gets a colour-dependent
|
|
323
|
+
effective QE from its SED, instead of the scalar ``quantum_efficiency``.
|
|
324
|
+
"""
|
|
325
|
+
if tuple(scene.shape) != self.resolution:
|
|
326
|
+
raise ValueError(
|
|
327
|
+
f"scene.shape {tuple(scene.shape)} does not match camera "
|
|
328
|
+
f"resolution {self.resolution}."
|
|
329
|
+
)
|
|
330
|
+
rate, background, qe, spectral = self._scene_inputs(scene)
|
|
331
|
+
frame = self.expose(
|
|
332
|
+
rate,
|
|
333
|
+
exposure,
|
|
334
|
+
temperature,
|
|
335
|
+
background=background,
|
|
336
|
+
quantum_efficiency=qe,
|
|
337
|
+
seed=seed,
|
|
338
|
+
include_truth=include_truth,
|
|
339
|
+
)
|
|
340
|
+
self._tag_science(frame, scene, spectral)
|
|
341
|
+
return frame
|
|
342
|
+
|
|
343
|
+
def _scene_inputs(
|
|
344
|
+
self,
|
|
345
|
+
scene: Scene,
|
|
346
|
+
time_s: float | None = None,
|
|
347
|
+
offset_xy: tuple[float, float] = (0.0, 0.0),
|
|
348
|
+
) -> tuple[PhotonRate, PhotonRate, float | None, bool]:
|
|
349
|
+
"""Render a scene to the ``(rate, background, qe, spectral)`` :meth:`expose` inputs.
|
|
350
|
+
|
|
351
|
+
Selects spectral mode when the config carries a QE curve and the scene's
|
|
352
|
+
band has a spectral response. ``time_s`` and ``offset_xy`` thread the
|
|
353
|
+
per-frame time and pointing offset through to the scene renderer.
|
|
354
|
+
"""
|
|
355
|
+
spectral = self.config.qe_curve is not None and scene.is_spectral_capable
|
|
356
|
+
dtype = self._float_dtype
|
|
357
|
+
if spectral:
|
|
358
|
+
assert self.config.qe_curve is not None # narrowed by `spectral`
|
|
359
|
+
rate = scene.photoelectron_rate_map(self.config.qe_curve, time_s, offset_xy, dtype)
|
|
360
|
+
return rate, scene.background_electron_rate(self.config.qe_curve), 1.0, True
|
|
361
|
+
rate = scene.photon_rate_map(time_s, offset_xy, dtype)
|
|
362
|
+
return rate, scene.background_photon_rate(), None, False
|
|
363
|
+
|
|
364
|
+
@staticmethod
|
|
365
|
+
def _tag_science(frame: Frame, scene: Scene, spectral: bool) -> None:
|
|
366
|
+
"""Stamp the shared science-frame metadata (frame type, spectral flag, WCS)."""
|
|
367
|
+
frame.metadata["frame_type"] = "science"
|
|
368
|
+
frame.metadata["spectral"] = spectral
|
|
369
|
+
if scene.wcs is not None:
|
|
370
|
+
frame.metadata.update(scene.wcs.header_cards())
|
|
371
|
+
|
|
372
|
+
def expose_series(
|
|
373
|
+
self,
|
|
374
|
+
photon_rate: PhotonRate,
|
|
375
|
+
exposure: float,
|
|
376
|
+
n_frames: int,
|
|
377
|
+
temperature: float | None = None,
|
|
378
|
+
*,
|
|
379
|
+
background: PhotonRate = 0.0,
|
|
380
|
+
seed: int | None = None,
|
|
381
|
+
include_truth: bool = True,
|
|
382
|
+
) -> Iterator[Frame]:
|
|
383
|
+
"""Yield ``n_frames`` independent illuminated frames (the :meth:`expose` series).
|
|
384
|
+
|
|
385
|
+
The light-frame analogue of :meth:`dark_series`. When ``seed`` is given the
|
|
386
|
+
series is reproducible; each frame uses a distinct derived seed so the
|
|
387
|
+
frames are independent but the whole series repeats.
|
|
388
|
+
"""
|
|
389
|
+
for i, frame_seed in enumerate(self._series_seeds(seed, n_frames)):
|
|
390
|
+
frame = self.expose(
|
|
391
|
+
photon_rate,
|
|
392
|
+
exposure,
|
|
393
|
+
temperature,
|
|
394
|
+
background=background,
|
|
395
|
+
seed=frame_seed,
|
|
396
|
+
include_truth=include_truth,
|
|
397
|
+
)
|
|
398
|
+
frame.metadata["frame_index"] = i
|
|
399
|
+
yield frame
|
|
400
|
+
|
|
401
|
+
def observe_series(
|
|
402
|
+
self,
|
|
403
|
+
scene: Scene,
|
|
404
|
+
exposure: float,
|
|
405
|
+
n_frames: int,
|
|
406
|
+
temperature: float | None = None,
|
|
407
|
+
*,
|
|
408
|
+
cadence: float | None = None,
|
|
409
|
+
pointing: Pointing | None = None,
|
|
410
|
+
jitter_arcsec: float = 0.0,
|
|
411
|
+
seed: int | None = None,
|
|
412
|
+
include_truth: bool = True,
|
|
413
|
+
) -> Observation:
|
|
414
|
+
"""Observe ``scene`` over time, returning a reproducible :class:`Observation`.
|
|
415
|
+
|
|
416
|
+
Produces a time-ordered stack of science frames. Frame ``i`` is exposed at
|
|
417
|
+
timestamp ``t_i = i * cadence`` (the start of its exposure); sources
|
|
418
|
+
carrying a :class:`~getframes.scene.sources.LightCurve` vary accordingly,
|
|
419
|
+
and a :class:`~getframes.observation.Pointing` model shifts the field per
|
|
420
|
+
frame. If the detector has a non-zero
|
|
421
|
+
:attr:`~getframes.config.CameraConfig.persistence_fraction`, latent charge
|
|
422
|
+
is carried across frames.
|
|
423
|
+
|
|
424
|
+
The returned :class:`Observation` is iterable over its frames (so
|
|
425
|
+
``for f in cam.observe_series(...)`` still works) and carries the per-frame
|
|
426
|
+
timestamps, realised pointing offsets, and the ground-truth light curve.
|
|
427
|
+
|
|
428
|
+
Parameters
|
|
429
|
+
----------
|
|
430
|
+
scene, exposure, temperature:
|
|
431
|
+
As in :meth:`observe`.
|
|
432
|
+
n_frames:
|
|
433
|
+
Number of frames in the series.
|
|
434
|
+
cadence:
|
|
435
|
+
Seconds between successive frame start times. Defaults to ``exposure``
|
|
436
|
+
(back-to-back frames with no dead time).
|
|
437
|
+
pointing:
|
|
438
|
+
A :class:`~getframes.observation.Pointing` model for per-frame field
|
|
439
|
+
offsets. If ``None`` and ``jitter_arcsec`` is given, a jitter-only model
|
|
440
|
+
is built from it.
|
|
441
|
+
jitter_arcsec:
|
|
442
|
+
Convenience for the common case: the RMS of a per-frame Gaussian
|
|
443
|
+
pointing jitter (ignored if ``pointing`` is given explicitly).
|
|
444
|
+
seed:
|
|
445
|
+
When given, the series is reproducible; each frame draws a distinct
|
|
446
|
+
derived seed (independent frames) and the pointing jitter uses its own
|
|
447
|
+
derived stream, so the whole observation repeats exactly.
|
|
448
|
+
include_truth:
|
|
449
|
+
Whether to attach per-frame :class:`~getframes.frame.FrameTruth` and
|
|
450
|
+
build the observation's light-curve truth.
|
|
451
|
+
"""
|
|
452
|
+
cadence = exposure if cadence is None else cadence
|
|
453
|
+
if pointing is None and jitter_arcsec > 0:
|
|
454
|
+
pointing = Pointing(jitter_arcsec=jitter_arcsec)
|
|
455
|
+
plate_scale = scene.optics.plate_scale_arcsec_per_pixel
|
|
456
|
+
|
|
457
|
+
frame_seeds = self._series_seeds(seed, n_frames)
|
|
458
|
+
# A pointing stream independent of the per-frame shot/read-noise seeds, so
|
|
459
|
+
# jitter is reproducible without coupling to (or perturbing) frame noise.
|
|
460
|
+
point_seq = None if seed is None else np.random.SeedSequence([int(seed), _POINTING_STREAM])
|
|
461
|
+
point_rng = np.random.default_rng(point_seq)
|
|
462
|
+
|
|
463
|
+
names = self._source_names(scene.sources)
|
|
464
|
+
light_curve: dict[str, list[float]] = {name: [] for name in names}
|
|
465
|
+
frames: list[Frame] = []
|
|
466
|
+
times: list[float] = []
|
|
467
|
+
offsets: list[tuple[float, float]] = []
|
|
468
|
+
latent: PhotonRate = 0.0 # trapped charge (electrons) carried across frames
|
|
469
|
+
|
|
470
|
+
for i, frame_seed in enumerate(frame_seeds):
|
|
471
|
+
t = i * cadence
|
|
472
|
+
offset = (0.0, 0.0)
|
|
473
|
+
if pointing is not None and not pointing.is_static:
|
|
474
|
+
offset = pointing.offset_pixels(i, t, plate_scale, point_rng)
|
|
475
|
+
|
|
476
|
+
rate, background, qe, spectral = self._scene_inputs(scene, t, offset)
|
|
477
|
+
extra = self.config.persistence_decay * latent if self._has_persistence else 0.0
|
|
478
|
+
frame = self.expose(
|
|
479
|
+
rate,
|
|
480
|
+
exposure,
|
|
481
|
+
temperature,
|
|
482
|
+
background=background,
|
|
483
|
+
quantum_efficiency=qe,
|
|
484
|
+
extra_electrons=extra,
|
|
485
|
+
seed=frame_seed,
|
|
486
|
+
include_truth=include_truth,
|
|
487
|
+
)
|
|
488
|
+
self._tag_science(frame, scene, spectral)
|
|
489
|
+
frame.metadata["frame_index"] = i
|
|
490
|
+
frame.metadata["time_s"] = t
|
|
491
|
+
frame.metadata["pointing_offset_px"] = offset
|
|
492
|
+
|
|
493
|
+
if self._has_persistence:
|
|
494
|
+
latent = self._update_latent(latent, extra, rate, exposure, background, qe)
|
|
495
|
+
if include_truth:
|
|
496
|
+
for name, source in zip(names, scene.sources):
|
|
497
|
+
light_curve[name].append(scene._source_photon_rate(source, t) * exposure)
|
|
498
|
+
|
|
499
|
+
frames.append(frame)
|
|
500
|
+
times.append(t)
|
|
501
|
+
offsets.append(offset)
|
|
502
|
+
|
|
503
|
+
truth = (
|
|
504
|
+
ObservationTruth(
|
|
505
|
+
times_s=np.asarray(times, dtype=np.float64),
|
|
506
|
+
light_curve={k: np.asarray(v, dtype=np.float64) for k, v in light_curve.items()},
|
|
507
|
+
)
|
|
508
|
+
if include_truth
|
|
509
|
+
else None
|
|
510
|
+
)
|
|
511
|
+
return Observation(
|
|
512
|
+
frames=frames,
|
|
513
|
+
times_s=np.asarray(times, dtype=np.float64),
|
|
514
|
+
offsets_pixels=np.asarray(offsets, dtype=np.float64).reshape(n_frames, 2),
|
|
515
|
+
truth=truth,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
@property
|
|
519
|
+
def _has_persistence(self) -> bool:
|
|
520
|
+
return self.config.persistence_fraction > 0.0
|
|
521
|
+
|
|
522
|
+
def _update_latent(
|
|
523
|
+
self,
|
|
524
|
+
latent: PhotonRate,
|
|
525
|
+
released: PhotonRate,
|
|
526
|
+
rate: PhotonRate,
|
|
527
|
+
exposure: float,
|
|
528
|
+
background: PhotonRate,
|
|
529
|
+
qe: float | None,
|
|
530
|
+
) -> NDArray[np.float64]:
|
|
531
|
+
"""Advance the latent-charge state after a frame.
|
|
532
|
+
|
|
533
|
+
Trapped charge releases ``persistence_decay`` of itself into the frame just
|
|
534
|
+
exposed (``released``) and captures ``persistence_fraction`` of that frame's
|
|
535
|
+
noise-free photo-signal. The remainder stays trapped for the next frame.
|
|
536
|
+
"""
|
|
537
|
+
signal = noise.photo_signal_map(self.config, rate, exposure, background, qe)
|
|
538
|
+
captured = self.config.persistence_fraction * signal
|
|
539
|
+
latent_arr = np.asarray(latent, dtype=np.float64)
|
|
540
|
+
released_arr = np.asarray(released, dtype=np.float64)
|
|
541
|
+
updated: NDArray[np.float64] = latent_arr - released_arr + captured
|
|
542
|
+
return updated
|
|
543
|
+
|
|
544
|
+
@staticmethod
|
|
545
|
+
def _source_names(sources: Sequence[Source]) -> list[str]:
|
|
546
|
+
"""A stable, unique name per source (falling back to ``source_{i}``)."""
|
|
547
|
+
names: list[str] = []
|
|
548
|
+
seen: set[str] = set()
|
|
549
|
+
for i, source in enumerate(sources):
|
|
550
|
+
name = source.name if source.name is not None else f"source_{i}"
|
|
551
|
+
if name in seen:
|
|
552
|
+
name = f"{name}_{i}"
|
|
553
|
+
seen.add(name)
|
|
554
|
+
names.append(name)
|
|
555
|
+
return names
|
|
556
|
+
|
|
557
|
+
# ------------------------------------------------------------------
|
|
558
|
+
# Calibration masters
|
|
559
|
+
# ------------------------------------------------------------------
|
|
560
|
+
def master_bias(
|
|
561
|
+
self,
|
|
562
|
+
n_frames: int,
|
|
563
|
+
temperature: float | None = None,
|
|
564
|
+
*,
|
|
565
|
+
seed: int | None = None,
|
|
566
|
+
method: str = "median",
|
|
567
|
+
) -> Frame:
|
|
568
|
+
"""Combine ``n_frames`` bias frames into a master bias (see :func:`getframes.combine`)."""
|
|
569
|
+
from .calibrate import combine
|
|
570
|
+
|
|
571
|
+
frames = (self.bias_frame(temperature, seed=s) for s in self._series_seeds(seed, n_frames))
|
|
572
|
+
return combine(frames, method=method)
|
|
573
|
+
|
|
574
|
+
def master_dark(
|
|
575
|
+
self,
|
|
576
|
+
exposure: float,
|
|
577
|
+
n_frames: int,
|
|
578
|
+
temperature: float | None = None,
|
|
579
|
+
*,
|
|
580
|
+
seed: int | None = None,
|
|
581
|
+
method: str = "median",
|
|
582
|
+
) -> Frame:
|
|
583
|
+
"""Combine ``n_frames`` dark frames into a master dark.
|
|
584
|
+
|
|
585
|
+
The result still contains the bias pedestal, so it is subtracted directly
|
|
586
|
+
from an exposure-matched science frame (``calibrate(sci, dark=master)``).
|
|
587
|
+
"""
|
|
588
|
+
from .calibrate import combine
|
|
589
|
+
|
|
590
|
+
return combine(self.dark_series(exposure, n_frames, temperature, seed=seed), method=method)
|
|
591
|
+
|
|
592
|
+
def master_flat(
|
|
593
|
+
self,
|
|
594
|
+
photon_rate: PhotonRate,
|
|
595
|
+
exposure: float,
|
|
596
|
+
n_frames: int,
|
|
597
|
+
temperature: float | None = None,
|
|
598
|
+
*,
|
|
599
|
+
background: PhotonRate = 0.0,
|
|
600
|
+
bias: Frame | NDArray[np.floating[Any]] | None = None,
|
|
601
|
+
seed: int | None = None,
|
|
602
|
+
method: str = "median",
|
|
603
|
+
) -> Frame:
|
|
604
|
+
"""Combine ``n_frames`` flat frames into a master flat.
|
|
605
|
+
|
|
606
|
+
If ``bias`` is given it is subtracted, yielding a pedestal-free flat whose
|
|
607
|
+
pixel-to-pixel structure is the detector's response — the form
|
|
608
|
+
:func:`getframes.calibrate` expects to normalise and divide by.
|
|
609
|
+
"""
|
|
610
|
+
from .calibrate import combine
|
|
611
|
+
|
|
612
|
+
frames = self.expose_series(
|
|
613
|
+
photon_rate,
|
|
614
|
+
exposure,
|
|
615
|
+
n_frames,
|
|
616
|
+
temperature,
|
|
617
|
+
background=background,
|
|
618
|
+
seed=seed,
|
|
619
|
+
include_truth=False,
|
|
620
|
+
)
|
|
621
|
+
master = combine(frames, method=method)
|
|
622
|
+
if bias is None:
|
|
623
|
+
return master
|
|
624
|
+
data = np.asarray(master.data, dtype=np.float64) - np.asarray(bias, dtype=np.float64)
|
|
625
|
+
metadata = {**master.metadata, "bias_subtracted": True}
|
|
626
|
+
return Frame(data=data, metadata=metadata)
|
|
627
|
+
|
|
628
|
+
def _metadata(
|
|
629
|
+
self, frame_type: str, exposure: float, temperature: float, seed: int | None
|
|
630
|
+
) -> dict[str, Any]:
|
|
631
|
+
return {
|
|
632
|
+
"camera": self.config.name,
|
|
633
|
+
"sensor": self.config.sensor_type.value,
|
|
634
|
+
"frame_type": frame_type,
|
|
635
|
+
"exposure_s": exposure,
|
|
636
|
+
"temperature_c": temperature,
|
|
637
|
+
"dark_e_per_s": self.config.dark_current_at(temperature),
|
|
638
|
+
"read_noise_e": self.config.read_noise_e,
|
|
639
|
+
"gain_e_per_adu": self.config.gain_e_per_adu,
|
|
640
|
+
"em_gain": self.config.em_gain,
|
|
641
|
+
"seed": seed,
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
def __repr__(self) -> str:
|
|
645
|
+
h, w = self.config.resolution
|
|
646
|
+
return (
|
|
647
|
+
f"Camera(name={self.config.name!r}, sensor={self.config.sensor_type.value!r}, "
|
|
648
|
+
f"resolution={h}x{w})"
|
|
649
|
+
)
|