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/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
+ )