Skip to content

API reference

Camera

getframes.camera.Camera

A camera that generates realistic synthetic frames.

A :class:Camera wraps a :class:~getframes.config.CameraConfig and exposes high-level frame-generation methods. Construct one directly from a config, or load a built-in preset:

import getframes as gf cam = gf.Camera.from_preset("andor_ikon_m934") frame = cam.dark_frame(exposure=30.0, temperature=-60.0, seed=0) frame.shape (1024, 1024)

Parameters:

Name Type Description Default
config CameraConfig

The detector configuration.

required
default_temperature_c float | None

Temperature (deg C) used when a frame method is called without an explicit temperature. Defaults to the config's dark-current reference temperature.

None
seed int | None

Optional seed for this camera's internal random generator, giving reproducible output across calls when no per-call seed is supplied.

None
precision str

Working floating-point precision of the signal chain: "float64" (the exact default) or "float32" for the memory-light fast path — half the per-pixel memory, useful for large detectors and bulk dataset generation. The digitised ADU stay integer either way; only the floating-point arrays (including each frame's ground truth) change.

'float64'
Source code in src/getframes/camera.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
class Camera:
    """A camera that generates realistic synthetic frames.

    A :class:`Camera` wraps a :class:`~getframes.config.CameraConfig` and exposes
    high-level frame-generation methods. Construct one directly from a config, or
    load a built-in preset:

    >>> import getframes as gf
    >>> cam = gf.Camera.from_preset("andor_ikon_m934")
    >>> frame = cam.dark_frame(exposure=30.0, temperature=-60.0, seed=0)
    >>> frame.shape
    (1024, 1024)

    Parameters
    ----------
    config:
        The detector configuration.
    default_temperature_c:
        Temperature (deg C) used when a frame method is called without an explicit
        temperature. Defaults to the config's dark-current reference temperature.
    seed:
        Optional seed for this camera's internal random generator, giving
        reproducible output across calls when no per-call seed is supplied.
    precision:
        Working floating-point precision of the signal chain: ``"float64"`` (the
        exact default) or ``"float32"`` for the memory-light fast path — half the
        per-pixel memory, useful for large detectors and bulk dataset generation.
        The digitised ADU stay integer either way; only the floating-point arrays
        (including each frame's ground truth) change.
    """

    _PRECISIONS: ClassVar[dict[str, type[np.floating[Any]]]] = {
        "float32": np.float32,
        "float64": np.float64,
    }

    def __init__(
        self,
        config: CameraConfig,
        *,
        default_temperature_c: float | None = None,
        seed: int | None = None,
        precision: str = "float64",
    ) -> None:
        if not isinstance(config, CameraConfig):
            raise TypeError("config must be a CameraConfig instance.")
        if precision not in self._PRECISIONS:
            raise ValueError(
                f"precision must be one of {sorted(self._PRECISIONS)}, got {precision!r}."
            )
        self.config = config
        self.default_temperature_c = (
            default_temperature_c
            if default_temperature_c is not None
            else config.dark_current_ref_temp_c
        )
        self.precision = precision
        self._float_dtype = self._PRECISIONS[precision]
        self._rng = np.random.default_rng(seed)

    # ------------------------------------------------------------------
    # Constructors
    # ------------------------------------------------------------------
    @classmethod
    def from_preset(cls, name: str, **kwargs: Any) -> Camera:
        """Create a camera from a built-in preset (see :func:`getframes.available_presets`)."""
        return cls(load_preset(name), **kwargs)

    @classmethod
    def from_dict(cls, data: dict[str, Any], **kwargs: Any) -> Camera:
        """Create a camera from a plain configuration dictionary."""
        return cls(CameraConfig.from_dict(data), **kwargs)

    # ------------------------------------------------------------------
    # Convenience accessors
    # ------------------------------------------------------------------
    @property
    def name(self) -> str:
        return self.config.name

    @property
    def resolution(self) -> tuple[int, int]:
        return self.config.resolution

    @property
    def sensor_type(self) -> str:
        return self.config.sensor_type.value

    def with_config(self, **changes: Any) -> Camera:
        """Return a new camera with configuration fields overridden."""
        return Camera(
            self.config.replace(**changes),
            default_temperature_c=self.default_temperature_c,
            precision=self.precision,
        )

    # ------------------------------------------------------------------
    # Frame generation
    # ------------------------------------------------------------------
    def _resolve_rng(self, seed: int | None) -> np.random.Generator:
        return np.random.default_rng(seed) if seed is not None else self._rng

    @staticmethod
    def _series_seeds(seed: int | None, n_frames: int) -> list[int | None]:
        """Derive ``n_frames`` independent-but-reproducible per-frame seeds.

        When ``seed`` is given, each frame gets a distinct seed spawned from a
        :class:`numpy.random.SeedSequence`, so the frames are statistically
        independent yet the whole series repeats exactly. When ``seed`` is ``None``,
        every frame draws from the camera's internal generator instead.
        """
        if n_frames < 1:
            raise ValueError("n_frames must be >= 1.")
        if seed is None:
            return [None] * n_frames
        ss = np.random.SeedSequence(seed)
        return [int(s.generate_state(1)[0]) for s in ss.spawn(n_frames)]

    def dark_frame(
        self,
        exposure: float,
        temperature: float | None = None,
        *,
        seed: int | None = None,
    ) -> Frame:
        """Generate a single dark frame.

        Parameters
        ----------
        exposure:
            Integration time in seconds.
        temperature:
            Sensor temperature in degrees Celsius. Defaults to the camera's
            :attr:`default_temperature_c`.
        seed:
            If given, use a fresh generator seeded with this value, producing a
            fully reproducible frame independent of prior calls. If omitted, the
            camera's internal generator advances.

        Returns
        -------
        Frame
            The simulated frame (ADU) with descriptive metadata.
        """
        temp = self.default_temperature_c if temperature is None else temperature
        rng = self._resolve_rng(seed)
        data = noise.generate_dark_frame(self.config, exposure, temp, rng=rng)
        return Frame(data=data, metadata=self._metadata("dark", exposure, temp, seed))

    def dark_series(
        self,
        exposure: float,
        n_frames: int,
        temperature: float | None = None,
        *,
        seed: int | None = None,
    ) -> Iterator[Frame]:
        """Yield ``n_frames`` independent dark frames (e.g. for building a master dark).

        When ``seed`` is given the series is reproducible; each frame uses a distinct
        derived seed so the frames are independent but the whole series is repeatable.
        """
        for i, frame_seed in enumerate(self._series_seeds(seed, n_frames)):
            frame = self.dark_frame(exposure, temperature, seed=frame_seed)
            frame.metadata["frame_index"] = i
            yield frame

    def expose(
        self,
        photon_rate: PhotonRate,
        exposure: float,
        temperature: float | None = None,
        *,
        background: PhotonRate = 0.0,
        quantum_efficiency: float | None = None,
        extra_electrons: PhotonRate = 0.0,
        seed: int | None = None,
        include_truth: bool = True,
    ) -> Frame:
        """Expose the sensor to an incident photon rate and return a frame.

        This is the general signal path; :meth:`dark_frame`, :meth:`flat_frame`,
        and :meth:`bias_frame` are convenience wrappers around it.

        Parameters
        ----------
        photon_rate:
            Incident photon rate in photons/s/pixel, as a scalar (uniform
            illumination) or a 2-D array matching :attr:`resolution`.
        exposure:
            Integration time in seconds.
        temperature:
            Sensor temperature in degrees Celsius. Defaults to
            :attr:`default_temperature_c`.
        background:
            Additive background (sky/thermal) photon rate in photons/s/pixel.
        quantum_efficiency:
            Overrides the config's scalar QE for this exposure. Spectral mode uses
            this with an already-photoelectron map and ``1.0``; most callers leave
            it ``None``.
        extra_electrons:
            Additive noise-free signal in electrons (scalar or array) injected
            before shot noise. Used by :meth:`observe_series` to carry latent charge
            from image persistence; most callers leave it ``0.0``.
        seed:
            If given, use a fresh generator seeded with this value for a fully
            reproducible frame.
        include_truth:
            If ``True`` (default), attach the noise-free ground truth to the
            returned :class:`~getframes.frame.Frame` for pipeline validation.
        """
        temp = self.default_temperature_c if temperature is None else temperature
        rng = self._resolve_rng(seed)
        result = noise.simulate_frame(
            self.config,
            photon_rate,
            exposure,
            temperature_c=temp,
            background_photon_rate=background,
            quantum_efficiency=quantum_efficiency,
            extra_electrons=extra_electrons,
            rng=rng,
            float_dtype=self._float_dtype,
        )
        truth = (
            FrameTruth(
                mean_electrons=result.mean_photoelectrons
                + result.mean_dark_electrons
                + np.asarray(extra_electrons, dtype=self._float_dtype),
                mean_photoelectrons=result.mean_photoelectrons,
                photon_rate=result.photon_rate,
            )
            if include_truth
            else None
        )
        metadata = self._metadata("light", exposure, temp, seed)
        return Frame(data=result.adu, metadata=metadata, truth=truth)

    def flat_frame(
        self,
        photon_rate: PhotonRate,
        exposure: float,
        temperature: float | None = None,
        *,
        background: PhotonRate = 0.0,
        seed: int | None = None,
        include_truth: bool = True,
    ) -> Frame:
        """A uniformly (or per-pixel) illuminated flat-field frame.

        Equivalent to :meth:`expose`; provided as a named entry point for
        flat-field/photon-transfer workflows. Pass a scalar ``photon_rate`` for a
        uniform flat.
        """
        frame = self.expose(
            photon_rate,
            exposure,
            temperature,
            background=background,
            seed=seed,
            include_truth=include_truth,
        )
        frame.metadata["frame_type"] = "flat"
        return frame

    def bias_frame(
        self,
        temperature: float | None = None,
        *,
        seed: int | None = None,
    ) -> Frame:
        """A zero-exposure bias frame (bias pedestal + read noise only)."""
        frame = self.expose(0.0, 0.0, temperature, seed=seed, include_truth=False)
        frame.metadata["frame_type"] = "bias"
        return frame

    def observe(
        self,
        scene: Scene,
        exposure: float,
        temperature: float | None = None,
        *,
        seed: int | None = None,
        include_truth: bool = True,
    ) -> Frame:
        """Observe a :class:`~getframes.scene.Scene` and return a science frame.

        Renders the scene to an incident photon-rate map, then exposes the sensor
        to it (adding the scene's sky as a uniform background). The scene's
        ``shape`` must match this camera's :attr:`resolution`.

        **Spectral mode** activates automatically when this camera's config has a
        :attr:`~getframes.config.CameraConfig.qe_curve` *and* the scene's band
        carries a spectral response: each source then gets a colour-dependent
        effective QE from its SED, instead of the scalar ``quantum_efficiency``.
        """
        if tuple(scene.shape) != self.resolution:
            raise ValueError(
                f"scene.shape {tuple(scene.shape)} does not match camera "
                f"resolution {self.resolution}."
            )
        rate, background, qe, spectral = self._scene_inputs(scene)
        frame = self.expose(
            rate,
            exposure,
            temperature,
            background=background,
            quantum_efficiency=qe,
            seed=seed,
            include_truth=include_truth,
        )
        self._tag_science(frame, scene, spectral)
        return frame

    def _scene_inputs(
        self,
        scene: Scene,
        time_s: float | None = None,
        offset_xy: tuple[float, float] = (0.0, 0.0),
    ) -> tuple[PhotonRate, PhotonRate, float | None, bool]:
        """Render a scene to the ``(rate, background, qe, spectral)`` :meth:`expose` inputs.

        Selects spectral mode when the config carries a QE curve and the scene's
        band has a spectral response. ``time_s`` and ``offset_xy`` thread the
        per-frame time and pointing offset through to the scene renderer.
        """
        spectral = self.config.qe_curve is not None and scene.is_spectral_capable
        dtype = self._float_dtype
        if spectral:
            assert self.config.qe_curve is not None  # narrowed by `spectral`
            rate = scene.photoelectron_rate_map(self.config.qe_curve, time_s, offset_xy, dtype)
            return rate, scene.background_electron_rate(self.config.qe_curve), 1.0, True
        rate = scene.photon_rate_map(time_s, offset_xy, dtype)
        return rate, scene.background_photon_rate(), None, False

    @staticmethod
    def _tag_science(frame: Frame, scene: Scene, spectral: bool) -> None:
        """Stamp the shared science-frame metadata (frame type, spectral flag, WCS)."""
        frame.metadata["frame_type"] = "science"
        frame.metadata["spectral"] = spectral
        if scene.wcs is not None:
            frame.metadata.update(scene.wcs.header_cards())

    def expose_series(
        self,
        photon_rate: PhotonRate,
        exposure: float,
        n_frames: int,
        temperature: float | None = None,
        *,
        background: PhotonRate = 0.0,
        seed: int | None = None,
        include_truth: bool = True,
    ) -> Iterator[Frame]:
        """Yield ``n_frames`` independent illuminated frames (the :meth:`expose` series).

        The light-frame analogue of :meth:`dark_series`. When ``seed`` is given the
        series is reproducible; each frame uses a distinct derived seed so the
        frames are independent but the whole series repeats.
        """
        for i, frame_seed in enumerate(self._series_seeds(seed, n_frames)):
            frame = self.expose(
                photon_rate,
                exposure,
                temperature,
                background=background,
                seed=frame_seed,
                include_truth=include_truth,
            )
            frame.metadata["frame_index"] = i
            yield frame

    def observe_series(
        self,
        scene: Scene,
        exposure: float,
        n_frames: int,
        temperature: float | None = None,
        *,
        cadence: float | None = None,
        pointing: Pointing | None = None,
        jitter_arcsec: float = 0.0,
        seed: int | None = None,
        include_truth: bool = True,
    ) -> Observation:
        """Observe ``scene`` over time, returning a reproducible :class:`Observation`.

        Produces a time-ordered stack of science frames. Frame ``i`` is exposed at
        timestamp ``t_i = i * cadence`` (the start of its exposure); sources
        carrying a :class:`~getframes.scene.sources.LightCurve` vary accordingly,
        and a :class:`~getframes.observation.Pointing` model shifts the field per
        frame. If the detector has a non-zero
        :attr:`~getframes.config.CameraConfig.persistence_fraction`, latent charge
        is carried across frames.

        The returned :class:`Observation` is iterable over its frames (so
        ``for f in cam.observe_series(...)`` still works) and carries the per-frame
        timestamps, realised pointing offsets, and the ground-truth light curve.

        Parameters
        ----------
        scene, exposure, temperature:
            As in :meth:`observe`.
        n_frames:
            Number of frames in the series.
        cadence:
            Seconds between successive frame start times. Defaults to ``exposure``
            (back-to-back frames with no dead time).
        pointing:
            A :class:`~getframes.observation.Pointing` model for per-frame field
            offsets. If ``None`` and ``jitter_arcsec`` is given, a jitter-only model
            is built from it.
        jitter_arcsec:
            Convenience for the common case: the RMS of a per-frame Gaussian
            pointing jitter (ignored if ``pointing`` is given explicitly).
        seed:
            When given, the series is reproducible; each frame draws a distinct
            derived seed (independent frames) and the pointing jitter uses its own
            derived stream, so the whole observation repeats exactly.
        include_truth:
            Whether to attach per-frame :class:`~getframes.frame.FrameTruth` and
            build the observation's light-curve truth.
        """
        cadence = exposure if cadence is None else cadence
        if pointing is None and jitter_arcsec > 0:
            pointing = Pointing(jitter_arcsec=jitter_arcsec)
        plate_scale = scene.optics.plate_scale_arcsec_per_pixel

        frame_seeds = self._series_seeds(seed, n_frames)
        # A pointing stream independent of the per-frame shot/read-noise seeds, so
        # jitter is reproducible without coupling to (or perturbing) frame noise.
        point_seq = None if seed is None else np.random.SeedSequence([int(seed), _POINTING_STREAM])
        point_rng = np.random.default_rng(point_seq)

        names = self._source_names(scene.sources)
        light_curve: dict[str, list[float]] = {name: [] for name in names}
        frames: list[Frame] = []
        times: list[float] = []
        offsets: list[tuple[float, float]] = []
        latent: PhotonRate = 0.0  # trapped charge (electrons) carried across frames

        for i, frame_seed in enumerate(frame_seeds):
            t = i * cadence
            offset = (0.0, 0.0)
            if pointing is not None and not pointing.is_static:
                offset = pointing.offset_pixels(i, t, plate_scale, point_rng)

            rate, background, qe, spectral = self._scene_inputs(scene, t, offset)
            extra = self.config.persistence_decay * latent if self._has_persistence else 0.0
            frame = self.expose(
                rate,
                exposure,
                temperature,
                background=background,
                quantum_efficiency=qe,
                extra_electrons=extra,
                seed=frame_seed,
                include_truth=include_truth,
            )
            self._tag_science(frame, scene, spectral)
            frame.metadata["frame_index"] = i
            frame.metadata["time_s"] = t
            frame.metadata["pointing_offset_px"] = offset

            if self._has_persistence:
                latent = self._update_latent(latent, extra, rate, exposure, background, qe)
            if include_truth:
                for name, source in zip(names, scene.sources):
                    light_curve[name].append(scene._source_photon_rate(source, t) * exposure)

            frames.append(frame)
            times.append(t)
            offsets.append(offset)

        truth = (
            ObservationTruth(
                times_s=np.asarray(times, dtype=np.float64),
                light_curve={k: np.asarray(v, dtype=np.float64) for k, v in light_curve.items()},
            )
            if include_truth
            else None
        )
        return Observation(
            frames=frames,
            times_s=np.asarray(times, dtype=np.float64),
            offsets_pixels=np.asarray(offsets, dtype=np.float64).reshape(n_frames, 2),
            truth=truth,
        )

    @property
    def _has_persistence(self) -> bool:
        return self.config.persistence_fraction > 0.0

    def _update_latent(
        self,
        latent: PhotonRate,
        released: PhotonRate,
        rate: PhotonRate,
        exposure: float,
        background: PhotonRate,
        qe: float | None,
    ) -> NDArray[np.float64]:
        """Advance the latent-charge state after a frame.

        Trapped charge releases ``persistence_decay`` of itself into the frame just
        exposed (``released``) and captures ``persistence_fraction`` of that frame's
        noise-free photo-signal. The remainder stays trapped for the next frame.
        """
        signal = noise.photo_signal_map(self.config, rate, exposure, background, qe)
        captured = self.config.persistence_fraction * signal
        latent_arr = np.asarray(latent, dtype=np.float64)
        released_arr = np.asarray(released, dtype=np.float64)
        updated: NDArray[np.float64] = latent_arr - released_arr + captured
        return updated

    @staticmethod
    def _source_names(sources: Sequence[Source]) -> list[str]:
        """A stable, unique name per source (falling back to ``source_{i}``)."""
        names: list[str] = []
        seen: set[str] = set()
        for i, source in enumerate(sources):
            name = source.name if source.name is not None else f"source_{i}"
            if name in seen:
                name = f"{name}_{i}"
            seen.add(name)
            names.append(name)
        return names

    # ------------------------------------------------------------------
    # Calibration masters
    # ------------------------------------------------------------------
    def master_bias(
        self,
        n_frames: int,
        temperature: float | None = None,
        *,
        seed: int | None = None,
        method: str = "median",
    ) -> Frame:
        """Combine ``n_frames`` bias frames into a master bias (see :func:`getframes.combine`)."""
        from .calibrate import combine

        frames = (self.bias_frame(temperature, seed=s) for s in self._series_seeds(seed, n_frames))
        return combine(frames, method=method)

    def master_dark(
        self,
        exposure: float,
        n_frames: int,
        temperature: float | None = None,
        *,
        seed: int | None = None,
        method: str = "median",
    ) -> Frame:
        """Combine ``n_frames`` dark frames into a master dark.

        The result still contains the bias pedestal, so it is subtracted directly
        from an exposure-matched science frame (``calibrate(sci, dark=master)``).
        """
        from .calibrate import combine

        return combine(self.dark_series(exposure, n_frames, temperature, seed=seed), method=method)

    def master_flat(
        self,
        photon_rate: PhotonRate,
        exposure: float,
        n_frames: int,
        temperature: float | None = None,
        *,
        background: PhotonRate = 0.0,
        bias: Frame | NDArray[np.floating[Any]] | None = None,
        seed: int | None = None,
        method: str = "median",
    ) -> Frame:
        """Combine ``n_frames`` flat frames into a master flat.

        If ``bias`` is given it is subtracted, yielding a pedestal-free flat whose
        pixel-to-pixel structure is the detector's response — the form
        :func:`getframes.calibrate` expects to normalise and divide by.
        """
        from .calibrate import combine

        frames = self.expose_series(
            photon_rate,
            exposure,
            n_frames,
            temperature,
            background=background,
            seed=seed,
            include_truth=False,
        )
        master = combine(frames, method=method)
        if bias is None:
            return master
        data = np.asarray(master.data, dtype=np.float64) - np.asarray(bias, dtype=np.float64)
        metadata = {**master.metadata, "bias_subtracted": True}
        return Frame(data=data, metadata=metadata)

    def _metadata(
        self, frame_type: str, exposure: float, temperature: float, seed: int | None
    ) -> dict[str, Any]:
        return {
            "camera": self.config.name,
            "sensor": self.config.sensor_type.value,
            "frame_type": frame_type,
            "exposure_s": exposure,
            "temperature_c": temperature,
            "dark_e_per_s": self.config.dark_current_at(temperature),
            "read_noise_e": self.config.read_noise_e,
            "gain_e_per_adu": self.config.gain_e_per_adu,
            "em_gain": self.config.em_gain,
            "seed": seed,
        }

    def __repr__(self) -> str:
        h, w = self.config.resolution
        return (
            f"Camera(name={self.config.name!r}, sensor={self.config.sensor_type.value!r}, "
            f"resolution={h}x{w})"
        )

from_preset(name, **kwargs) classmethod

Create a camera from a built-in preset (see :func:getframes.available_presets).

Source code in src/getframes/camera.py
92
93
94
95
@classmethod
def from_preset(cls, name: str, **kwargs: Any) -> Camera:
    """Create a camera from a built-in preset (see :func:`getframes.available_presets`)."""
    return cls(load_preset(name), **kwargs)

from_dict(data, **kwargs) classmethod

Create a camera from a plain configuration dictionary.

Source code in src/getframes/camera.py
 97
 98
 99
100
@classmethod
def from_dict(cls, data: dict[str, Any], **kwargs: Any) -> Camera:
    """Create a camera from a plain configuration dictionary."""
    return cls(CameraConfig.from_dict(data), **kwargs)

with_config(**changes)

Return a new camera with configuration fields overridden.

Source code in src/getframes/camera.py
117
118
119
120
121
122
123
def with_config(self, **changes: Any) -> Camera:
    """Return a new camera with configuration fields overridden."""
    return Camera(
        self.config.replace(**changes),
        default_temperature_c=self.default_temperature_c,
        precision=self.precision,
    )

dark_frame(exposure, temperature=None, *, seed=None)

Generate a single dark frame.

Parameters:

Name Type Description Default
exposure float

Integration time in seconds.

required
temperature float | None

Sensor temperature in degrees Celsius. Defaults to the camera's :attr:default_temperature_c.

None
seed int | None

If given, use a fresh generator seeded with this value, producing a fully reproducible frame independent of prior calls. If omitted, the camera's internal generator advances.

None

Returns:

Type Description
Frame

The simulated frame (ADU) with descriptive metadata.

Source code in src/getframes/camera.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
def dark_frame(
    self,
    exposure: float,
    temperature: float | None = None,
    *,
    seed: int | None = None,
) -> Frame:
    """Generate a single dark frame.

    Parameters
    ----------
    exposure:
        Integration time in seconds.
    temperature:
        Sensor temperature in degrees Celsius. Defaults to the camera's
        :attr:`default_temperature_c`.
    seed:
        If given, use a fresh generator seeded with this value, producing a
        fully reproducible frame independent of prior calls. If omitted, the
        camera's internal generator advances.

    Returns
    -------
    Frame
        The simulated frame (ADU) with descriptive metadata.
    """
    temp = self.default_temperature_c if temperature is None else temperature
    rng = self._resolve_rng(seed)
    data = noise.generate_dark_frame(self.config, exposure, temp, rng=rng)
    return Frame(data=data, metadata=self._metadata("dark", exposure, temp, seed))

dark_series(exposure, n_frames, temperature=None, *, seed=None)

Yield n_frames independent dark frames (e.g. for building a master dark).

When seed is given the series is reproducible; each frame uses a distinct derived seed so the frames are independent but the whole series is repeatable.

Source code in src/getframes/camera.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
def dark_series(
    self,
    exposure: float,
    n_frames: int,
    temperature: float | None = None,
    *,
    seed: int | None = None,
) -> Iterator[Frame]:
    """Yield ``n_frames`` independent dark frames (e.g. for building a master dark).

    When ``seed`` is given the series is reproducible; each frame uses a distinct
    derived seed so the frames are independent but the whole series is repeatable.
    """
    for i, frame_seed in enumerate(self._series_seeds(seed, n_frames)):
        frame = self.dark_frame(exposure, temperature, seed=frame_seed)
        frame.metadata["frame_index"] = i
        yield frame

expose(photon_rate, exposure, temperature=None, *, background=0.0, quantum_efficiency=None, extra_electrons=0.0, seed=None, include_truth=True)

Expose the sensor to an incident photon rate and return a frame.

This is the general signal path; :meth:dark_frame, :meth:flat_frame, and :meth:bias_frame are convenience wrappers around it.

Parameters:

Name Type Description Default
photon_rate PhotonRate

Incident photon rate in photons/s/pixel, as a scalar (uniform illumination) or a 2-D array matching :attr:resolution.

required
exposure float

Integration time in seconds.

required
temperature float | None

Sensor temperature in degrees Celsius. Defaults to :attr:default_temperature_c.

None
background PhotonRate

Additive background (sky/thermal) photon rate in photons/s/pixel.

0.0
quantum_efficiency float | None

Overrides the config's scalar QE for this exposure. Spectral mode uses this with an already-photoelectron map and 1.0; most callers leave it None.

None
extra_electrons PhotonRate

Additive noise-free signal in electrons (scalar or array) injected before shot noise. Used by :meth:observe_series to carry latent charge from image persistence; most callers leave it 0.0.

0.0
seed int | None

If given, use a fresh generator seeded with this value for a fully reproducible frame.

None
include_truth bool

If True (default), attach the noise-free ground truth to the returned :class:~getframes.frame.Frame for pipeline validation.

True
Source code in src/getframes/camera.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
def expose(
    self,
    photon_rate: PhotonRate,
    exposure: float,
    temperature: float | None = None,
    *,
    background: PhotonRate = 0.0,
    quantum_efficiency: float | None = None,
    extra_electrons: PhotonRate = 0.0,
    seed: int | None = None,
    include_truth: bool = True,
) -> Frame:
    """Expose the sensor to an incident photon rate and return a frame.

    This is the general signal path; :meth:`dark_frame`, :meth:`flat_frame`,
    and :meth:`bias_frame` are convenience wrappers around it.

    Parameters
    ----------
    photon_rate:
        Incident photon rate in photons/s/pixel, as a scalar (uniform
        illumination) or a 2-D array matching :attr:`resolution`.
    exposure:
        Integration time in seconds.
    temperature:
        Sensor temperature in degrees Celsius. Defaults to
        :attr:`default_temperature_c`.
    background:
        Additive background (sky/thermal) photon rate in photons/s/pixel.
    quantum_efficiency:
        Overrides the config's scalar QE for this exposure. Spectral mode uses
        this with an already-photoelectron map and ``1.0``; most callers leave
        it ``None``.
    extra_electrons:
        Additive noise-free signal in electrons (scalar or array) injected
        before shot noise. Used by :meth:`observe_series` to carry latent charge
        from image persistence; most callers leave it ``0.0``.
    seed:
        If given, use a fresh generator seeded with this value for a fully
        reproducible frame.
    include_truth:
        If ``True`` (default), attach the noise-free ground truth to the
        returned :class:`~getframes.frame.Frame` for pipeline validation.
    """
    temp = self.default_temperature_c if temperature is None else temperature
    rng = self._resolve_rng(seed)
    result = noise.simulate_frame(
        self.config,
        photon_rate,
        exposure,
        temperature_c=temp,
        background_photon_rate=background,
        quantum_efficiency=quantum_efficiency,
        extra_electrons=extra_electrons,
        rng=rng,
        float_dtype=self._float_dtype,
    )
    truth = (
        FrameTruth(
            mean_electrons=result.mean_photoelectrons
            + result.mean_dark_electrons
            + np.asarray(extra_electrons, dtype=self._float_dtype),
            mean_photoelectrons=result.mean_photoelectrons,
            photon_rate=result.photon_rate,
        )
        if include_truth
        else None
    )
    metadata = self._metadata("light", exposure, temp, seed)
    return Frame(data=result.adu, metadata=metadata, truth=truth)

flat_frame(photon_rate, exposure, temperature=None, *, background=0.0, seed=None, include_truth=True)

A uniformly (or per-pixel) illuminated flat-field frame.

Equivalent to :meth:expose; provided as a named entry point for flat-field/photon-transfer workflows. Pass a scalar photon_rate for a uniform flat.

Source code in src/getframes/camera.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
def flat_frame(
    self,
    photon_rate: PhotonRate,
    exposure: float,
    temperature: float | None = None,
    *,
    background: PhotonRate = 0.0,
    seed: int | None = None,
    include_truth: bool = True,
) -> Frame:
    """A uniformly (or per-pixel) illuminated flat-field frame.

    Equivalent to :meth:`expose`; provided as a named entry point for
    flat-field/photon-transfer workflows. Pass a scalar ``photon_rate`` for a
    uniform flat.
    """
    frame = self.expose(
        photon_rate,
        exposure,
        temperature,
        background=background,
        seed=seed,
        include_truth=include_truth,
    )
    frame.metadata["frame_type"] = "flat"
    return frame

bias_frame(temperature=None, *, seed=None)

A zero-exposure bias frame (bias pedestal + read noise only).

Source code in src/getframes/camera.py
294
295
296
297
298
299
300
301
302
303
def bias_frame(
    self,
    temperature: float | None = None,
    *,
    seed: int | None = None,
) -> Frame:
    """A zero-exposure bias frame (bias pedestal + read noise only)."""
    frame = self.expose(0.0, 0.0, temperature, seed=seed, include_truth=False)
    frame.metadata["frame_type"] = "bias"
    return frame

observe(scene, exposure, temperature=None, *, seed=None, include_truth=True)

Observe a :class:~getframes.scene.Scene and return a science frame.

Renders the scene to an incident photon-rate map, then exposes the sensor to it (adding the scene's sky as a uniform background). The scene's shape must match this camera's :attr:resolution.

Spectral mode activates automatically when this camera's config has a :attr:~getframes.config.CameraConfig.qe_curve and the scene's band carries a spectral response: each source then gets a colour-dependent effective QE from its SED, instead of the scalar quantum_efficiency.

Source code in src/getframes/camera.py
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
def observe(
    self,
    scene: Scene,
    exposure: float,
    temperature: float | None = None,
    *,
    seed: int | None = None,
    include_truth: bool = True,
) -> Frame:
    """Observe a :class:`~getframes.scene.Scene` and return a science frame.

    Renders the scene to an incident photon-rate map, then exposes the sensor
    to it (adding the scene's sky as a uniform background). The scene's
    ``shape`` must match this camera's :attr:`resolution`.

    **Spectral mode** activates automatically when this camera's config has a
    :attr:`~getframes.config.CameraConfig.qe_curve` *and* the scene's band
    carries a spectral response: each source then gets a colour-dependent
    effective QE from its SED, instead of the scalar ``quantum_efficiency``.
    """
    if tuple(scene.shape) != self.resolution:
        raise ValueError(
            f"scene.shape {tuple(scene.shape)} does not match camera "
            f"resolution {self.resolution}."
        )
    rate, background, qe, spectral = self._scene_inputs(scene)
    frame = self.expose(
        rate,
        exposure,
        temperature,
        background=background,
        quantum_efficiency=qe,
        seed=seed,
        include_truth=include_truth,
    )
    self._tag_science(frame, scene, spectral)
    return frame

expose_series(photon_rate, exposure, n_frames, temperature=None, *, background=0.0, seed=None, include_truth=True)

Yield n_frames independent illuminated frames (the :meth:expose series).

The light-frame analogue of :meth:dark_series. When seed is given the series is reproducible; each frame uses a distinct derived seed so the frames are independent but the whole series repeats.

Source code in src/getframes/camera.py
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
def expose_series(
    self,
    photon_rate: PhotonRate,
    exposure: float,
    n_frames: int,
    temperature: float | None = None,
    *,
    background: PhotonRate = 0.0,
    seed: int | None = None,
    include_truth: bool = True,
) -> Iterator[Frame]:
    """Yield ``n_frames`` independent illuminated frames (the :meth:`expose` series).

    The light-frame analogue of :meth:`dark_series`. When ``seed`` is given the
    series is reproducible; each frame uses a distinct derived seed so the
    frames are independent but the whole series repeats.
    """
    for i, frame_seed in enumerate(self._series_seeds(seed, n_frames)):
        frame = self.expose(
            photon_rate,
            exposure,
            temperature,
            background=background,
            seed=frame_seed,
            include_truth=include_truth,
        )
        frame.metadata["frame_index"] = i
        yield frame

observe_series(scene, exposure, n_frames, temperature=None, *, cadence=None, pointing=None, jitter_arcsec=0.0, seed=None, include_truth=True)

Observe scene over time, returning a reproducible :class:Observation.

Produces a time-ordered stack of science frames. Frame i is exposed at timestamp t_i = i * cadence (the start of its exposure); sources carrying a :class:~getframes.scene.sources.LightCurve vary accordingly, and a :class:~getframes.observation.Pointing model shifts the field per frame. If the detector has a non-zero :attr:~getframes.config.CameraConfig.persistence_fraction, latent charge is carried across frames.

The returned :class:Observation is iterable over its frames (so for f in cam.observe_series(...) still works) and carries the per-frame timestamps, realised pointing offsets, and the ground-truth light curve.

Parameters:

Name Type Description Default
scene Scene

As in :meth:observe.

required
exposure Scene

As in :meth:observe.

required
temperature Scene

As in :meth:observe.

required
n_frames int

Number of frames in the series.

required
cadence float | None

Seconds between successive frame start times. Defaults to exposure (back-to-back frames with no dead time).

None
pointing Pointing | None

A :class:~getframes.observation.Pointing model for per-frame field offsets. If None and jitter_arcsec is given, a jitter-only model is built from it.

None
jitter_arcsec float

Convenience for the common case: the RMS of a per-frame Gaussian pointing jitter (ignored if pointing is given explicitly).

0.0
seed int | None

When given, the series is reproducible; each frame draws a distinct derived seed (independent frames) and the pointing jitter uses its own derived stream, so the whole observation repeats exactly.

None
include_truth bool

Whether to attach per-frame :class:~getframes.frame.FrameTruth and build the observation's light-curve truth.

True
Source code in src/getframes/camera.py
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
def observe_series(
    self,
    scene: Scene,
    exposure: float,
    n_frames: int,
    temperature: float | None = None,
    *,
    cadence: float | None = None,
    pointing: Pointing | None = None,
    jitter_arcsec: float = 0.0,
    seed: int | None = None,
    include_truth: bool = True,
) -> Observation:
    """Observe ``scene`` over time, returning a reproducible :class:`Observation`.

    Produces a time-ordered stack of science frames. Frame ``i`` is exposed at
    timestamp ``t_i = i * cadence`` (the start of its exposure); sources
    carrying a :class:`~getframes.scene.sources.LightCurve` vary accordingly,
    and a :class:`~getframes.observation.Pointing` model shifts the field per
    frame. If the detector has a non-zero
    :attr:`~getframes.config.CameraConfig.persistence_fraction`, latent charge
    is carried across frames.

    The returned :class:`Observation` is iterable over its frames (so
    ``for f in cam.observe_series(...)`` still works) and carries the per-frame
    timestamps, realised pointing offsets, and the ground-truth light curve.

    Parameters
    ----------
    scene, exposure, temperature:
        As in :meth:`observe`.
    n_frames:
        Number of frames in the series.
    cadence:
        Seconds between successive frame start times. Defaults to ``exposure``
        (back-to-back frames with no dead time).
    pointing:
        A :class:`~getframes.observation.Pointing` model for per-frame field
        offsets. If ``None`` and ``jitter_arcsec`` is given, a jitter-only model
        is built from it.
    jitter_arcsec:
        Convenience for the common case: the RMS of a per-frame Gaussian
        pointing jitter (ignored if ``pointing`` is given explicitly).
    seed:
        When given, the series is reproducible; each frame draws a distinct
        derived seed (independent frames) and the pointing jitter uses its own
        derived stream, so the whole observation repeats exactly.
    include_truth:
        Whether to attach per-frame :class:`~getframes.frame.FrameTruth` and
        build the observation's light-curve truth.
    """
    cadence = exposure if cadence is None else cadence
    if pointing is None and jitter_arcsec > 0:
        pointing = Pointing(jitter_arcsec=jitter_arcsec)
    plate_scale = scene.optics.plate_scale_arcsec_per_pixel

    frame_seeds = self._series_seeds(seed, n_frames)
    # A pointing stream independent of the per-frame shot/read-noise seeds, so
    # jitter is reproducible without coupling to (or perturbing) frame noise.
    point_seq = None if seed is None else np.random.SeedSequence([int(seed), _POINTING_STREAM])
    point_rng = np.random.default_rng(point_seq)

    names = self._source_names(scene.sources)
    light_curve: dict[str, list[float]] = {name: [] for name in names}
    frames: list[Frame] = []
    times: list[float] = []
    offsets: list[tuple[float, float]] = []
    latent: PhotonRate = 0.0  # trapped charge (electrons) carried across frames

    for i, frame_seed in enumerate(frame_seeds):
        t = i * cadence
        offset = (0.0, 0.0)
        if pointing is not None and not pointing.is_static:
            offset = pointing.offset_pixels(i, t, plate_scale, point_rng)

        rate, background, qe, spectral = self._scene_inputs(scene, t, offset)
        extra = self.config.persistence_decay * latent if self._has_persistence else 0.0
        frame = self.expose(
            rate,
            exposure,
            temperature,
            background=background,
            quantum_efficiency=qe,
            extra_electrons=extra,
            seed=frame_seed,
            include_truth=include_truth,
        )
        self._tag_science(frame, scene, spectral)
        frame.metadata["frame_index"] = i
        frame.metadata["time_s"] = t
        frame.metadata["pointing_offset_px"] = offset

        if self._has_persistence:
            latent = self._update_latent(latent, extra, rate, exposure, background, qe)
        if include_truth:
            for name, source in zip(names, scene.sources):
                light_curve[name].append(scene._source_photon_rate(source, t) * exposure)

        frames.append(frame)
        times.append(t)
        offsets.append(offset)

    truth = (
        ObservationTruth(
            times_s=np.asarray(times, dtype=np.float64),
            light_curve={k: np.asarray(v, dtype=np.float64) for k, v in light_curve.items()},
        )
        if include_truth
        else None
    )
    return Observation(
        frames=frames,
        times_s=np.asarray(times, dtype=np.float64),
        offsets_pixels=np.asarray(offsets, dtype=np.float64).reshape(n_frames, 2),
        truth=truth,
    )

master_bias(n_frames, temperature=None, *, seed=None, method='median')

Combine n_frames bias frames into a master bias (see :func:getframes.combine).

Source code in src/getframes/camera.py
560
561
562
563
564
565
566
567
568
569
570
571
572
def master_bias(
    self,
    n_frames: int,
    temperature: float | None = None,
    *,
    seed: int | None = None,
    method: str = "median",
) -> Frame:
    """Combine ``n_frames`` bias frames into a master bias (see :func:`getframes.combine`)."""
    from .calibrate import combine

    frames = (self.bias_frame(temperature, seed=s) for s in self._series_seeds(seed, n_frames))
    return combine(frames, method=method)

master_dark(exposure, n_frames, temperature=None, *, seed=None, method='median')

Combine n_frames dark frames into a master dark.

The result still contains the bias pedestal, so it is subtracted directly from an exposure-matched science frame (calibrate(sci, dark=master)).

Source code in src/getframes/camera.py
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
def master_dark(
    self,
    exposure: float,
    n_frames: int,
    temperature: float | None = None,
    *,
    seed: int | None = None,
    method: str = "median",
) -> Frame:
    """Combine ``n_frames`` dark frames into a master dark.

    The result still contains the bias pedestal, so it is subtracted directly
    from an exposure-matched science frame (``calibrate(sci, dark=master)``).
    """
    from .calibrate import combine

    return combine(self.dark_series(exposure, n_frames, temperature, seed=seed), method=method)

master_flat(photon_rate, exposure, n_frames, temperature=None, *, background=0.0, bias=None, seed=None, method='median')

Combine n_frames flat frames into a master flat.

If bias is given it is subtracted, yielding a pedestal-free flat whose pixel-to-pixel structure is the detector's response — the form :func:getframes.calibrate expects to normalise and divide by.

Source code in src/getframes/camera.py
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
def master_flat(
    self,
    photon_rate: PhotonRate,
    exposure: float,
    n_frames: int,
    temperature: float | None = None,
    *,
    background: PhotonRate = 0.0,
    bias: Frame | NDArray[np.floating[Any]] | None = None,
    seed: int | None = None,
    method: str = "median",
) -> Frame:
    """Combine ``n_frames`` flat frames into a master flat.

    If ``bias`` is given it is subtracted, yielding a pedestal-free flat whose
    pixel-to-pixel structure is the detector's response — the form
    :func:`getframes.calibrate` expects to normalise and divide by.
    """
    from .calibrate import combine

    frames = self.expose_series(
        photon_rate,
        exposure,
        n_frames,
        temperature,
        background=background,
        seed=seed,
        include_truth=False,
    )
    master = combine(frames, method=method)
    if bias is None:
        return master
    data = np.asarray(master.data, dtype=np.float64) - np.asarray(bias, dtype=np.float64)
    metadata = {**master.metadata, "bias_subtracted": True}
    return Frame(data=data, metadata=metadata)

CameraConfig

getframes.config.CameraConfig dataclass

Physical and electronic parameters of a camera/detector.

All electron quantities are in electrons (e-); all digital quantities are in analog-to-digital units (ADU, sometimes called counts or DN).

Parameters:

Name Type Description Default
name str

Human-readable identifier (e.g. "Andor iKon-M 934").

required
sensor_type SensorType

One of :class:SensorType (CCD, CMOS, EMCCD, EAPD). Selects the noise model; EMCCD and EAPD additionally use the stochastic gain stage.

required
resolution tuple[int, int]

Sensor size as (height, width) in pixels, matching NumPy's row-major array convention.

required
pixel_size_um float

Physical pixel pitch in microns. Informational; not used for dark frames.

required
quantum_efficiency float

Band-averaged quantum efficiency in [0, 1]. Used by the signal path to convert photons to photoelectrons. Ignored for dark frames.

required
qe_curve QE | None

Optional wavelength-resolved quantum efficiency (:class:~getframes.spectral.QE). When set, :meth:Camera.observe switches to spectral mode and computes a colour-dependent effective QE from each source's SED and the band's spectral response, instead of the scalar quantum_efficiency. None keeps the band-averaged model.

None
full_well_e float

Full-well capacity in electrons. Signal saturates here before digitization.

required
bit_depth int

ADC resolution in bits. The output saturates at 2**bit_depth - 1.

required
gain_e_per_adu float

Camera conversion gain in electrons per ADU. Electrons reaching the ADC are divided by this to produce counts.

required
bias_offset_adu float

Electronic offset (pedestal) added to every pixel, in ADU.

required
read_noise_e float

RMS read noise in electrons. For sCMOS this is the median; see read_noise_nonuniformity.

required
read_noise_nonuniformity float

Fractional pixel-to-pixel spread of the read-noise RMS (e.g. 0.3 for a 30% log-normal spread). Models the per-pixel read-noise distribution of sCMOS sensors. 0 gives a single uniform read noise.

0.0
nonlinearity float

Fractional signal compression at full well, in [0, 0.5). The collected charge is bent as q -> q * (1 - nonlinearity * q / full_well_e), so a pixel at full well reads nonlinearity fraction low. 0 is perfectly linear. Superseded by nonlinearity_coeffs when that is given.

0.0
nonlinearity_coeffs tuple[float, ...] | None

Optional polynomial generalisation of nonlinearity. A sequence (c1, c2, ...) defines the response multiplier q -> q * (1 + c1 * u + c2 * u**2 + ...) with u = q / full_well_e, so an arbitrary measured nonlinearity curve (or look-up) can be reproduced. When set it replaces the single-parameter nonlinearity model. None keeps the scalar model.

None
cti float

Charge-transfer inefficiency (CTI) of a CCD, the fraction of charge left behind per pixel-to-pixel transfer during readout, in [0, 1). A bright pixel r rows from the readout register undergoes r transfers and smears a deferred-charge tail away from the register. 0 is a perfect CCD. (Trap-driven deferral; the readout register is taken to be row 0.)

0.0
blooming bool

When True, charge collected above full_well_e spills (blooms) into the vertically adjacent pixels of the same column until it is below full well or runs off the array, charge-conserving — the bright bleed columns of a saturated CCD. False simply clips at full well.

False
ipc_coupling float

Inter-pixel capacitance (IPC): the fraction of each pixel's signal that couples capacitively into each of its four nearest neighbours at readout, in [0, 0.25). Applied as a charge-conserving 3x3 convolution (CMOS/IR hybrid arrays). 0 disables it.

0.0
reset_noise_e float

kTC / reset noise RMS in electrons: an independent per-pixel, per-frame Gaussian charge uncertainty from resetting the sense node, added alongside read noise. 0 disables it (or assumes it is removed by correlated double sampling).

0.0
amplifier_layout tuple[int, int]

Multi-amplifier readout as (n_rows, n_cols) of amplifiers tiling the sensor (e.g. (2, 2) for a four-quadrant CCD). Each amplifier block gets its own small gain and offset error (see amp_gain_nonuniformity / amp_offset_spread_adu), producing the characteristic seams. (1, 1) is a single amplifier.

(1, 1)
amp_gain_nonuniformity float

Fractional RMS spread of per-amplifier gain about gain_e_per_adu (a fixed pattern keyed on fixed_pattern_seed). Ignored for a single amplifier.

0.0
amp_offset_spread_adu float

RMS spread of per-amplifier bias offset in ADU, about bias_offset_adu (fixed pattern). Ignored for a single amplifier.

0.0
cosmic_ray_track_length_px float

Mean length in pixels of cosmic-ray tracks. 0 keeps the single-pixel hit model; a positive value draws an exponential track length and a random direction per hit, depositing the charge along the track (glancing muons).

0.0
bad_column_fraction float

Fraction of columns that are defective (dead): a fixed, deterministic set of whole columns forced to zero signal in every frame — the bad columns a flat cannot rescue. 0 disables.

0.0
dead_pixel_fraction float

Fraction of individual pixels that are dead (zero response), a fixed map. 0 disables.

0.0
bias_structure_amplitude_adu float

Peak amplitude in ADU of a fixed, structured bias pattern (a smooth gradient plus per-column offsets) added on top of the flat bias_offset_adu pedestal. 0 keeps the bias a flat pedestal.

0.0
cosmic_ray_rate_per_cm2_s float

Cosmic-ray hit rate in events per cm^2 per second (sea level is ~5). The number of hits scales with sensor area and exposure; each deposits a burst of charge in a random pixel.

0.0
prnu float

Photo-response non-uniformity: fractional pixel-to-pixel variation in sensitivity (e.g. 0.01 for 1% RMS). Imprints a fixed multiplicative pattern on the photo signal (not the dark signal). Ignored for dark frames, where there is no light.

0.0
dark_current_e_per_s float

Dark current in electrons per pixel per second, specified at dark_current_ref_temp_c.

required
detector_glow_e_per_s float

Detector self-emission ("glow") in electrons per pixel per second, added to the dark signal (it scales with exposure and so is removed by an exposure-matched master dark). A uniform model of amplifier/array glow, relevant for IR arrays alongside the thermal background. 0 disables it.

0.0
dark_current_ref_temp_c float

Temperature (deg C) at which dark_current_e_per_s is quoted.

20.0
dark_current_doubling_temp_c float

Temperature increase (deg C) that doubles the dark current. Typical CCD/CMOS silicon values are 5-8 C.

6.3
em_gain float

Mean gain of the stochastic multiplication stage: the EM register of an EMCCD or the avalanche gain of an eAPD. 1.0 disables it (CCD/CMOS).

1.0
excess_noise_factor float | None

Excess noise factor F of the gain stage, quantifying the extra noise from stochastic multiplication. F = 1 is noiseless multiplication; EMCCDs approach F = sqrt(2) ~ 1.41 at high gain; eAPDs are much quieter (F ~ 1.2-1.4). If None (default), an appropriate value is used for the sensor type (sqrt(2) for EMCCD, 1.0 otherwise) --- see :attr:gain_excess_noise_factor.

None
clock_induced_charge_e float

Clock-induced charge (spurious charge) in electrons per pixel per frame. Relevant mainly for EMCCD.

0.0
persistence_fraction float

Fraction of a frame's collected charge captured into traps as a latent image (image persistence), in [0, 1]. Relevant for IR arrays (eAPD). The trapped charge is released into subsequent frames of an :class:~getframes.observation.Observation (it needs the cross-frame state that :meth:Camera.observe_series provides). 0 disables persistence.

0.0
persistence_decay float

Fraction of the trapped charge released each subsequent frame, in [0, 1]. 1 dumps all latent charge into the very next frame; smaller values give a slowly fading ghost over several frames.

0.5
dark_current_nonuniformity float

Fractional pixel-to-pixel dark-signal non-uniformity (DSNU), e.g. 0.05 for 5% RMS. Models fixed-pattern structure in the dark signal.

0.0
hot_pixel_fraction float

Fraction of pixels that are "hot" (anomalously high dark current).

0.0
hot_pixel_factor float

Multiplicative dark-current factor applied to hot pixels.

100.0
fixed_pattern_seed int

Seed for the sensor's fixed-pattern noise (PRNU, DSNU, and the hot-pixel map). These patterns are a property of the physical sensor, so they are the same in every frame this camera produces --- which is exactly what lets a master flat or dark capture and remove them. Two configs with the same seed and shape share a pattern; change it to mint a different sensor. Independent of the per-frame seed that drives shot/read noise.

0
manufacturer str | None

Optional provenance metadata.

None
model str | None

Optional provenance metadata.

None
notes str | None

Optional provenance metadata.

None
Source code in src/getframes/config.py
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
@dataclass(frozen=True, slots=True)
class CameraConfig:
    """Physical and electronic parameters of a camera/detector.

    All electron quantities are in electrons (``e-``); all digital quantities are
    in analog-to-digital units (ADU, sometimes called counts or DN).

    Parameters
    ----------
    name:
        Human-readable identifier (e.g. ``"Andor iKon-M 934"``).
    sensor_type:
        One of :class:`SensorType` (CCD, CMOS, EMCCD, EAPD). Selects the noise
        model; EMCCD and EAPD additionally use the stochastic gain stage.
    resolution:
        Sensor size as ``(height, width)`` in pixels, matching NumPy's row-major
        array convention.
    pixel_size_um:
        Physical pixel pitch in microns. Informational; not used for dark frames.
    quantum_efficiency:
        Band-averaged quantum efficiency in ``[0, 1]``. Used by the signal path to
        convert photons to photoelectrons. Ignored for dark frames.
    qe_curve:
        Optional wavelength-resolved quantum efficiency
        (:class:`~getframes.spectral.QE`). When set, :meth:`Camera.observe`
        switches to spectral mode and computes a colour-dependent effective QE from
        each source's SED and the band's spectral response, instead of the scalar
        ``quantum_efficiency``. ``None`` keeps the band-averaged model.
    full_well_e:
        Full-well capacity in electrons. Signal saturates here before digitization.
    bit_depth:
        ADC resolution in bits. The output saturates at ``2**bit_depth - 1``.
    gain_e_per_adu:
        Camera conversion gain in electrons per ADU. Electrons reaching the ADC
        are divided by this to produce counts.
    bias_offset_adu:
        Electronic offset (pedestal) added to every pixel, in ADU.
    read_noise_e:
        RMS read noise in electrons. For sCMOS this is the *median*; see
        ``read_noise_nonuniformity``.
    read_noise_nonuniformity:
        Fractional pixel-to-pixel spread of the read-noise RMS (e.g. ``0.3`` for a
        30% log-normal spread). Models the per-pixel read-noise distribution of
        sCMOS sensors. ``0`` gives a single uniform read noise.
    nonlinearity:
        Fractional signal compression at full well, in ``[0, 0.5)``. The collected
        charge is bent as ``q -> q * (1 - nonlinearity * q / full_well_e)``, so a
        pixel at full well reads ``nonlinearity`` fraction low. ``0`` is perfectly
        linear. Superseded by ``nonlinearity_coeffs`` when that is given.
    nonlinearity_coeffs:
        Optional polynomial generalisation of ``nonlinearity``. A sequence
        ``(c1, c2, ...)`` defines the response multiplier
        ``q -> q * (1 + c1 * u + c2 * u**2 + ...)`` with ``u = q / full_well_e``, so
        an arbitrary measured nonlinearity curve (or look-up) can be reproduced.
        When set it replaces the single-parameter ``nonlinearity`` model. ``None``
        keeps the scalar model.
    cti:
        Charge-transfer inefficiency (CTI) of a CCD, the fraction of charge left
        behind per pixel-to-pixel transfer during readout, in ``[0, 1)``. A bright
        pixel ``r`` rows from the readout register undergoes ``r`` transfers and
        smears a deferred-charge tail away from the register. ``0`` is a perfect
        CCD. (Trap-driven deferral; the readout register is taken to be row 0.)
    blooming:
        When ``True``, charge collected above ``full_well_e`` spills (blooms) into
        the vertically adjacent pixels of the same column until it is below full
        well or runs off the array, charge-conserving — the bright bleed columns of
        a saturated CCD. ``False`` simply clips at full well.
    ipc_coupling:
        Inter-pixel capacitance (IPC): the fraction of each pixel's signal that
        couples capacitively into *each* of its four nearest neighbours at readout,
        in ``[0, 0.25)``. Applied as a charge-conserving 3x3 convolution (CMOS/IR
        hybrid arrays). ``0`` disables it.
    reset_noise_e:
        kTC / reset noise RMS in electrons: an independent per-pixel, per-frame
        Gaussian charge uncertainty from resetting the sense node, added alongside
        read noise. ``0`` disables it (or assumes it is removed by correlated double
        sampling).
    amplifier_layout:
        Multi-amplifier readout as ``(n_rows, n_cols)`` of amplifiers tiling the
        sensor (e.g. ``(2, 2)`` for a four-quadrant CCD). Each amplifier block gets
        its own small gain and offset error (see ``amp_gain_nonuniformity`` /
        ``amp_offset_spread_adu``), producing the characteristic seams. ``(1, 1)``
        is a single amplifier.
    amp_gain_nonuniformity:
        Fractional RMS spread of per-amplifier gain about ``gain_e_per_adu`` (a
        fixed pattern keyed on ``fixed_pattern_seed``). Ignored for a single
        amplifier.
    amp_offset_spread_adu:
        RMS spread of per-amplifier bias offset in ADU, about ``bias_offset_adu``
        (fixed pattern). Ignored for a single amplifier.
    cosmic_ray_track_length_px:
        Mean length in pixels of cosmic-ray *tracks*. ``0`` keeps the single-pixel
        hit model; a positive value draws an exponential track length and a random
        direction per hit, depositing the charge along the track (glancing muons).
    bad_column_fraction:
        Fraction of columns that are defective (dead): a fixed, deterministic set of
        whole columns forced to zero signal in every frame — the bad columns a flat
        cannot rescue. ``0`` disables.
    dead_pixel_fraction:
        Fraction of individual pixels that are dead (zero response), a fixed map.
        ``0`` disables.
    bias_structure_amplitude_adu:
        Peak amplitude in ADU of a fixed, structured bias pattern (a smooth gradient
        plus per-column offsets) added on top of the flat ``bias_offset_adu``
        pedestal. ``0`` keeps the bias a flat pedestal.
    cosmic_ray_rate_per_cm2_s:
        Cosmic-ray hit rate in events per cm^2 per second (sea level is ~5). The
        number of hits scales with sensor area and exposure; each deposits a burst
        of charge in a random pixel.
    prnu:
        Photo-response non-uniformity: fractional pixel-to-pixel variation in
        sensitivity (e.g. ``0.01`` for 1% RMS). Imprints a fixed multiplicative
        pattern on the *photo* signal (not the dark signal). Ignored for dark
        frames, where there is no light.
    dark_current_e_per_s:
        Dark current in electrons per pixel per second, specified at
        ``dark_current_ref_temp_c``.
    detector_glow_e_per_s:
        Detector self-emission ("glow") in electrons per pixel per second, added to
        the dark signal (it scales with exposure and so is removed by an
        exposure-matched master dark). A uniform model of amplifier/array glow,
        relevant for IR arrays alongside the thermal background. ``0`` disables it.
    dark_current_ref_temp_c:
        Temperature (deg C) at which ``dark_current_e_per_s`` is quoted.
    dark_current_doubling_temp_c:
        Temperature increase (deg C) that doubles the dark current. Typical CCD/CMOS
        silicon values are 5-8 C.
    em_gain:
        Mean gain of the stochastic multiplication stage: the EM register of an
        EMCCD or the avalanche gain of an eAPD. ``1.0`` disables it (CCD/CMOS).
    excess_noise_factor:
        Excess noise factor ``F`` of the gain stage, quantifying the extra noise
        from stochastic multiplication. ``F = 1`` is noiseless multiplication;
        EMCCDs approach ``F = sqrt(2) ~ 1.41`` at high gain; eAPDs are much
        quieter (``F ~ 1.2-1.4``). If ``None`` (default), an appropriate value is
        used for the sensor type (sqrt(2) for EMCCD, 1.0 otherwise) --- see
        :attr:`gain_excess_noise_factor`.
    clock_induced_charge_e:
        Clock-induced charge (spurious charge) in electrons per pixel per frame.
        Relevant mainly for EMCCD.
    persistence_fraction:
        Fraction of a frame's collected charge captured into traps as a latent
        image (image persistence), in ``[0, 1]``. Relevant for IR arrays (eAPD).
        The trapped charge is released into subsequent frames of an
        :class:`~getframes.observation.Observation` (it needs the cross-frame state
        that :meth:`Camera.observe_series` provides). ``0`` disables persistence.
    persistence_decay:
        Fraction of the trapped charge released each subsequent frame, in
        ``[0, 1]``. ``1`` dumps all latent charge into the very next frame; smaller
        values give a slowly fading ghost over several frames.
    dark_current_nonuniformity:
        Fractional pixel-to-pixel dark-signal non-uniformity (DSNU), e.g. ``0.05``
        for 5% RMS. Models fixed-pattern structure in the dark signal.
    hot_pixel_fraction:
        Fraction of pixels that are "hot" (anomalously high dark current).
    hot_pixel_factor:
        Multiplicative dark-current factor applied to hot pixels.
    fixed_pattern_seed:
        Seed for the sensor's *fixed-pattern* noise (PRNU, DSNU, and the hot-pixel
        map). These patterns are a property of the physical sensor, so they are the
        *same in every frame* this camera produces --- which is exactly what lets a
        master flat or dark capture and remove them. Two configs with the same seed
        and shape share a pattern; change it to mint a different sensor. Independent
        of the per-frame ``seed`` that drives shot/read noise.
    manufacturer, model, notes:
        Optional provenance metadata.
    """

    name: str
    sensor_type: SensorType
    resolution: tuple[int, int]
    pixel_size_um: float
    quantum_efficiency: float
    full_well_e: float
    bit_depth: int
    gain_e_per_adu: float
    bias_offset_adu: float
    read_noise_e: float
    dark_current_e_per_s: float
    qe_curve: QE | None = None
    detector_glow_e_per_s: float = 0.0
    prnu: float = 0.0
    read_noise_nonuniformity: float = 0.0
    nonlinearity: float = 0.0
    nonlinearity_coeffs: tuple[float, ...] | None = None
    cti: float = 0.0
    blooming: bool = False
    ipc_coupling: float = 0.0
    reset_noise_e: float = 0.0
    amplifier_layout: tuple[int, int] = (1, 1)
    amp_gain_nonuniformity: float = 0.0
    amp_offset_spread_adu: float = 0.0
    cosmic_ray_track_length_px: float = 0.0
    bad_column_fraction: float = 0.0
    dead_pixel_fraction: float = 0.0
    bias_structure_amplitude_adu: float = 0.0
    cosmic_ray_rate_per_cm2_s: float = 0.0
    dark_current_ref_temp_c: float = 20.0
    dark_current_doubling_temp_c: float = 6.3
    em_gain: float = 1.0
    excess_noise_factor: float | None = None
    clock_induced_charge_e: float = 0.0
    persistence_fraction: float = 0.0
    persistence_decay: float = 0.5
    dark_current_nonuniformity: float = 0.0
    hot_pixel_fraction: float = 0.0
    hot_pixel_factor: float = 100.0
    fixed_pattern_seed: int = 0
    manufacturer: str | None = None
    model: str | None = None
    notes: str | None = None
    extra: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        # Normalise/validate without mutating frozen fields directly.
        object.__setattr__(self, "sensor_type", SensorType.coerce(self.sensor_type))
        object.__setattr__(self, "resolution", tuple(int(n) for n in self.resolution))
        object.__setattr__(self, "amplifier_layout", tuple(int(n) for n in self.amplifier_layout))
        if self.nonlinearity_coeffs is not None:
            object.__setattr__(
                self, "nonlinearity_coeffs", tuple(float(c) for c in self.nonlinearity_coeffs)
            )
        self._validate()

    def _validate(self) -> None:
        if len(self.resolution) != 2 or any(n <= 0 for n in self.resolution):
            raise ValueError(f"resolution must be two positive ints, got {self.resolution!r}.")
        if not 0.0 <= self.quantum_efficiency <= 1.0:
            raise ValueError("quantum_efficiency must be in [0, 1].")
        if self.bit_depth <= 0:
            raise ValueError("bit_depth must be positive.")
        if self.gain_e_per_adu <= 0:
            raise ValueError("gain_e_per_adu must be positive.")
        if self.read_noise_e < 0:
            raise ValueError("read_noise_e must be non-negative.")
        if self.prnu < 0:
            raise ValueError("prnu must be non-negative.")
        if self.read_noise_nonuniformity < 0:
            raise ValueError("read_noise_nonuniformity must be non-negative.")
        if not 0.0 <= self.nonlinearity < 0.5:
            raise ValueError("nonlinearity must be in [0, 0.5).")
        if self.nonlinearity_coeffs is not None and len(self.nonlinearity_coeffs) == 0:
            raise ValueError("nonlinearity_coeffs must be a non-empty sequence or None.")
        if not 0.0 <= self.cti < 1.0:
            raise ValueError("cti must be in [0, 1).")
        if not 0.0 <= self.ipc_coupling < 0.25:
            raise ValueError("ipc_coupling must be in [0, 0.25).")
        if self.reset_noise_e < 0:
            raise ValueError("reset_noise_e must be non-negative.")
        if len(self.amplifier_layout) != 2 or any(n <= 0 for n in self.amplifier_layout):
            raise ValueError(
                f"amplifier_layout must be two positive ints, got {self.amplifier_layout!r}."
            )
        if self.amp_gain_nonuniformity < 0:
            raise ValueError("amp_gain_nonuniformity must be non-negative.")
        if self.amp_offset_spread_adu < 0:
            raise ValueError("amp_offset_spread_adu must be non-negative.")
        if self.cosmic_ray_track_length_px < 0:
            raise ValueError("cosmic_ray_track_length_px must be non-negative.")
        if not 0.0 <= self.bad_column_fraction <= 1.0:
            raise ValueError("bad_column_fraction must be in [0, 1].")
        if not 0.0 <= self.dead_pixel_fraction <= 1.0:
            raise ValueError("dead_pixel_fraction must be in [0, 1].")
        if self.bias_structure_amplitude_adu < 0:
            raise ValueError("bias_structure_amplitude_adu must be non-negative.")
        if self.cosmic_ray_rate_per_cm2_s < 0:
            raise ValueError("cosmic_ray_rate_per_cm2_s must be non-negative.")
        if self.dark_current_e_per_s < 0:
            raise ValueError("dark_current_e_per_s must be non-negative.")
        if self.detector_glow_e_per_s < 0:
            raise ValueError("detector_glow_e_per_s must be non-negative.")
        if self.dark_current_doubling_temp_c <= 0:
            raise ValueError("dark_current_doubling_temp_c must be positive.")
        if self.em_gain < 1.0:
            raise ValueError("em_gain must be >= 1.0 (use 1.0 to disable).")
        if self.excess_noise_factor is not None and self.excess_noise_factor < 1.0:
            raise ValueError("excess_noise_factor must be >= 1.0 (1.0 is noiseless).")
        if self.full_well_e <= 0:
            raise ValueError("full_well_e must be positive.")
        if not 0.0 <= self.hot_pixel_fraction <= 1.0:
            raise ValueError("hot_pixel_fraction must be in [0, 1].")
        if not 0.0 <= self.persistence_fraction <= 1.0:
            raise ValueError("persistence_fraction must be in [0, 1].")
        if not 0.0 <= self.persistence_decay <= 1.0:
            raise ValueError("persistence_decay must be in [0, 1].")
        if self.qe_curve is not None and not isinstance(self.qe_curve, QE):
            raise ValueError("qe_curve must be a getframes.spectral.QE instance or None.")

    @property
    def max_adu(self) -> int:
        """The saturation value of the ADC output."""
        return int(2**self.bit_depth - 1)

    @property
    def has_gain_stage(self) -> bool:
        """Whether a stochastic multiplication stage (EM/avalanche) is active."""
        return self.em_gain > 1.0

    @property
    def gain_excess_noise_factor(self) -> float:
        """The effective excess noise factor ``F`` of the gain stage.

        Returns :attr:`excess_noise_factor` if set, else a sensible default for the
        sensor type: ``sqrt(2)`` for EMCCD (the high-gain limit) and ``1.0``
        (noiseless) otherwise.
        """
        if self.excess_noise_factor is not None:
            return self.excess_noise_factor
        if self.sensor_type is SensorType.EMCCD:
            return math.sqrt(2.0)
        return 1.0

    def dark_current_at(self, temperature_c: float) -> float:
        """Dark current (e-/pixel/s) scaled to ``temperature_c``.

        Uses the standard doubling-temperature model::

            D(T) = D_ref * 2 ** ((T - T_ref) / T_double)
        """
        delta = temperature_c - self.dark_current_ref_temp_c
        exponent = delta / self.dark_current_doubling_temp_c
        return float(self.dark_current_e_per_s * 2.0**exponent)

    def replace(self, **changes: Any) -> CameraConfig:
        """Return a copy with the given fields overridden (like ``dataclasses.replace``)."""
        data = self.to_dict()
        data.update(changes)
        return CameraConfig.from_dict(data)

    def to_dict(self) -> dict[str, Any]:
        """Serialise to a plain dict (sensor_type rendered as its string value)."""
        data = asdict(self)
        data["sensor_type"] = self.sensor_type.value
        data["resolution"] = list(self.resolution)
        data["amplifier_layout"] = list(self.amplifier_layout)
        if self.nonlinearity_coeffs is not None:
            data["nonlinearity_coeffs"] = list(self.nonlinearity_coeffs)
        data["qe_curve"] = _serialize_qe_curve(self.qe_curve)
        return data

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> CameraConfig:
        """Build a config from a dict, ignoring unknown keys (stashed in ``extra``).

        A ``qe_curve`` may be given as a :class:`~getframes.spectral.QE` or as a
        mapping ``{"wavelength_nm": [...], "qe": [...]}`` (the form used in preset
        TOML files).
        """
        known = {f for f in cls.__dataclass_fields__ if f != "extra"}
        kwargs = {k: v for k, v in data.items() if k in known}
        if "qe_curve" in kwargs:
            kwargs["qe_curve"] = _parse_qe_curve(kwargs["qe_curve"])
        passthrough = dict(data.get("extra", {}))
        unknown = {k: v for k, v in data.items() if k not in known and k != "extra"}
        merged = {**passthrough, **unknown}
        if merged:
            kwargs["extra"] = merged
        return cls(**kwargs)

max_adu property

The saturation value of the ADC output.

has_gain_stage property

Whether a stochastic multiplication stage (EM/avalanche) is active.

gain_excess_noise_factor property

The effective excess noise factor F of the gain stage.

Returns :attr:excess_noise_factor if set, else a sensible default for the sensor type: sqrt(2) for EMCCD (the high-gain limit) and 1.0 (noiseless) otherwise.

dark_current_at(temperature_c)

Dark current (e-/pixel/s) scaled to temperature_c.

Uses the standard doubling-temperature model::

D(T) = D_ref * 2 ** ((T - T_ref) / T_double)
Source code in src/getframes/config.py
352
353
354
355
356
357
358
359
360
361
def dark_current_at(self, temperature_c: float) -> float:
    """Dark current (e-/pixel/s) scaled to ``temperature_c``.

    Uses the standard doubling-temperature model::

        D(T) = D_ref * 2 ** ((T - T_ref) / T_double)
    """
    delta = temperature_c - self.dark_current_ref_temp_c
    exponent = delta / self.dark_current_doubling_temp_c
    return float(self.dark_current_e_per_s * 2.0**exponent)

replace(**changes)

Return a copy with the given fields overridden (like dataclasses.replace).

Source code in src/getframes/config.py
363
364
365
366
367
def replace(self, **changes: Any) -> CameraConfig:
    """Return a copy with the given fields overridden (like ``dataclasses.replace``)."""
    data = self.to_dict()
    data.update(changes)
    return CameraConfig.from_dict(data)

to_dict()

Serialise to a plain dict (sensor_type rendered as its string value).

Source code in src/getframes/config.py
369
370
371
372
373
374
375
376
377
378
def to_dict(self) -> dict[str, Any]:
    """Serialise to a plain dict (sensor_type rendered as its string value)."""
    data = asdict(self)
    data["sensor_type"] = self.sensor_type.value
    data["resolution"] = list(self.resolution)
    data["amplifier_layout"] = list(self.amplifier_layout)
    if self.nonlinearity_coeffs is not None:
        data["nonlinearity_coeffs"] = list(self.nonlinearity_coeffs)
    data["qe_curve"] = _serialize_qe_curve(self.qe_curve)
    return data

from_dict(data) classmethod

Build a config from a dict, ignoring unknown keys (stashed in extra).

A qe_curve may be given as a :class:~getframes.spectral.QE or as a mapping {"wavelength_nm": [...], "qe": [...]} (the form used in preset TOML files).

Source code in src/getframes/config.py
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
@classmethod
def from_dict(cls, data: dict[str, Any]) -> CameraConfig:
    """Build a config from a dict, ignoring unknown keys (stashed in ``extra``).

    A ``qe_curve`` may be given as a :class:`~getframes.spectral.QE` or as a
    mapping ``{"wavelength_nm": [...], "qe": [...]}`` (the form used in preset
    TOML files).
    """
    known = {f for f in cls.__dataclass_fields__ if f != "extra"}
    kwargs = {k: v for k, v in data.items() if k in known}
    if "qe_curve" in kwargs:
        kwargs["qe_curve"] = _parse_qe_curve(kwargs["qe_curve"])
    passthrough = dict(data.get("extra", {}))
    unknown = {k: v for k, v in data.items() if k not in known and k != "extra"}
    merged = {**passthrough, **unknown}
    if merged:
        kwargs["extra"] = merged
    return cls(**kwargs)

SensorType

getframes.config.SensorType

Bases: str, Enum

The detector architecture, which selects the noise model used.

Source code in src/getframes/config.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class SensorType(str, Enum):
    """The detector architecture, which selects the noise model used."""

    CCD = "CCD"
    CMOS = "CMOS"
    EMCCD = "EMCCD"
    EAPD = "EAPD"  # electron-avalanche photodiode (e.g. SAPHIRA IR arrays)
    SCMOS = "SCMOS"  # scientific CMOS (per-pixel read noise, rolling shutter)

    @classmethod
    def coerce(cls, value: SensorType | str) -> SensorType:
        """Accept either a :class:`SensorType` or a case-insensitive string."""
        if isinstance(value, cls):
            return value
        try:
            return cls(str(value).upper())
        except ValueError as exc:  # pragma: no cover - trivial
            valid = ", ".join(s.value for s in cls)
            raise ValueError(f"Unknown sensor type {value!r}. Expected one of: {valid}.") from exc

coerce(value) classmethod

Accept either a :class:SensorType or a case-insensitive string.

Source code in src/getframes/config.py
28
29
30
31
32
33
34
35
36
37
@classmethod
def coerce(cls, value: SensorType | str) -> SensorType:
    """Accept either a :class:`SensorType` or a case-insensitive string."""
    if isinstance(value, cls):
        return value
    try:
        return cls(str(value).upper())
    except ValueError as exc:  # pragma: no cover - trivial
        valid = ", ".join(s.value for s in cls)
        raise ValueError(f"Unknown sensor type {value!r}. Expected one of: {valid}.") from exc

Frame

getframes.frame.Frame dataclass

A single simulated image plus the metadata describing how it was made.

The pixel values live in :attr:data as a 2-D NumPy array in ADU. The object is array-like: np.asarray(frame) and most NumPy operations work directly.

Attributes:

Name Type Description
data NDArray[floating[Any] | integer[Any]]

2-D array of pixel values in ADU, shaped (height, width).

metadata dict[str, Any]

Free-form dictionary describing the simulation (camera name, exposure, temperature, frame type, etc.). Suitable for writing to a FITS header.

truth FrameTruth | None

Optional :class:FrameTruth holding the noise-free signal the frame was built from, for ground-truth comparisons. None if not requested.

Source code in src/getframes/frame.py
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
@dataclass(frozen=True)
class Frame:
    """A single simulated image plus the metadata describing how it was made.

    The pixel values live in :attr:`data` as a 2-D NumPy array in ADU. The object
    is array-like: ``np.asarray(frame)`` and most NumPy operations work directly.

    Attributes
    ----------
    data:
        2-D array of pixel values in ADU, shaped ``(height, width)``.
    metadata:
        Free-form dictionary describing the simulation (camera name, exposure,
        temperature, frame type, etc.). Suitable for writing to a FITS header.
    truth:
        Optional :class:`FrameTruth` holding the noise-free signal the frame was
        built from, for ground-truth comparisons. ``None`` if not requested.
    """

    data: NDArray[np.floating[Any] | np.integer[Any]]
    metadata: dict[str, Any] = field(default_factory=dict)
    truth: FrameTruth | None = None

    @property
    def shape(self) -> tuple[int, ...]:
        return tuple(self.data.shape)

    @property
    def dtype(self) -> np.dtype[Any]:
        return self.data.dtype

    def __array__(self, dtype: Any = None) -> NDArray[Any]:
        return np.asarray(self.data, dtype=dtype)

    def stats(self) -> dict[str, float]:
        """Common summary statistics of the pixel values (mean/median/std/min/max)."""
        arr = np.asarray(self.data, dtype=float)
        return {
            "mean": float(arr.mean()),
            "median": float(np.median(arr)),
            "std": float(arr.std()),
            "min": float(arr.min()),
            "max": float(arr.max()),
        }

    def to_fits(self, path: str, overwrite: bool = False) -> None:
        """Write the frame to a FITS file (requires ``astropy``).

        Metadata keys are written to the FITS header where they fit the 8-character
        keyword and value-type constraints.
        """
        try:
            from astropy.io import fits
        except ImportError as exc:  # pragma: no cover - astropy is a core dependency
            raise ImportError(
                "Writing FITS files requires astropy (a core dependency of getframes); "
                "reinstall with: pip install getframes"
            ) from exc

        hdu = fits.PrimaryHDU(data=np.asarray(self.data))
        for key, value in self.metadata.items():
            if isinstance(value, (str, int, float, bool)):
                hdu.header[key[:8].upper()] = value
        hdu.writeto(path, overwrite=overwrite)

    def __repr__(self) -> str:
        ftype = self.metadata.get("frame_type", "frame")
        cam = self.metadata.get("camera", "?")
        return f"Frame(type={ftype!r}, camera={cam!r}, shape={self.shape}, dtype={self.dtype})"

stats()

Common summary statistics of the pixel values (mean/median/std/min/max).

Source code in src/getframes/frame.py
73
74
75
76
77
78
79
80
81
82
def stats(self) -> dict[str, float]:
    """Common summary statistics of the pixel values (mean/median/std/min/max)."""
    arr = np.asarray(self.data, dtype=float)
    return {
        "mean": float(arr.mean()),
        "median": float(np.median(arr)),
        "std": float(arr.std()),
        "min": float(arr.min()),
        "max": float(arr.max()),
    }

to_fits(path, overwrite=False)

Write the frame to a FITS file (requires astropy).

Metadata keys are written to the FITS header where they fit the 8-character keyword and value-type constraints.

Source code in src/getframes/frame.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def to_fits(self, path: str, overwrite: bool = False) -> None:
    """Write the frame to a FITS file (requires ``astropy``).

    Metadata keys are written to the FITS header where they fit the 8-character
    keyword and value-type constraints.
    """
    try:
        from astropy.io import fits
    except ImportError as exc:  # pragma: no cover - astropy is a core dependency
        raise ImportError(
            "Writing FITS files requires astropy (a core dependency of getframes); "
            "reinstall with: pip install getframes"
        ) from exc

    hdu = fits.PrimaryHDU(data=np.asarray(self.data))
    for key, value in self.metadata.items():
        if isinstance(value, (str, int, float, bool)):
            hdu.header[key[:8].upper()] = value
    hdu.writeto(path, overwrite=overwrite)

getframes.frame.FrameTruth dataclass

Noise-free ground truth a :class:Frame was generated from.

Useful for validating analysis pipelines against exactly what went in. All arrays are in electrons unless noted, shaped like the frame.

Attributes:

Name Type Description
mean_electrons NDArray[float64]

Noise-free total signal (photo + dark) per pixel, in electrons. This is the expectation value before shot noise, gain, and read noise.

mean_photoelectrons NDArray[float64]

Noise-free photo signal per pixel, in electrons (i.e. excluding dark).

photon_rate NDArray[float64] | float

The incident photon rate the frame was exposed to, in photons/s/pixel, as provided by the caller (a scalar for uniform illumination, else an array).

Source code in src/getframes/frame.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@dataclass(frozen=True)
class FrameTruth:
    """Noise-free ground truth a :class:`Frame` was generated from.

    Useful for validating analysis pipelines against exactly what went in. All
    arrays are in electrons unless noted, shaped like the frame.

    Attributes
    ----------
    mean_electrons:
        Noise-free total signal (photo + dark) per pixel, in electrons. This is
        the expectation value before shot noise, gain, and read noise.
    mean_photoelectrons:
        Noise-free photo signal per pixel, in electrons (i.e. excluding dark).
    photon_rate:
        The incident photon rate the frame was exposed to, in photons/s/pixel, as
        provided by the caller (a scalar for uniform illumination, else an array).
    """

    mean_electrons: NDArray[np.float64]
    mean_photoelectrons: NDArray[np.float64]
    photon_rate: NDArray[np.float64] | float

Calibration

getframes.calibrate

Calibration: combine frames into masters and reduce raw frames against them.

These helpers close the loop the library is built for: generate raw frames (each optionally carrying :class:~getframes.frame.FrameTruth), then reduce them with master calibration frames and compare the result to the ground truth.

The reduction follows the standard, exposure-matched CCD equation::

reduced = (raw - dark) / normalised(flat)

where dark is an exposure-matched master dark (which still contains the bias pedestal, so subtracting it removes bias and dark current together) and flat is a pedestal-free master flat (see :meth:getframes.Camera.master_flat). Pass bias instead of dark to subtract only the bias pedestal.

combine(frames, *, method='median', sigma=3.0)

Combine a stack of frames pixel-wise into a single master frame.

Parameters:

Name Type Description Default
frames Iterable[FrameLike]

An iterable of :class:~getframes.frame.Frame (or plain 2-D arrays), all the same shape. Averaging n independent frames reduces the random noise by roughly sqrt(n).

required
method str

"median" (default, robust to outliers such as cosmic rays), "mean", or "sigma_clip" (reject pixels more than sigma standard deviations from the per-pixel median, then average the rest).

'median'
sigma float

Clipping threshold for method="sigma_clip".

3.0

Returns:

Type Description
Frame

The master frame (ADU, float64), with metadata recording the combination. Metadata common to all inputs is preserved; frame_type is prefixed with master_ when the inputs agree.

Source code in src/getframes/calibrate.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def combine(
    frames: Iterable[FrameLike],
    *,
    method: str = "median",
    sigma: float = 3.0,
) -> Frame:
    """Combine a stack of frames pixel-wise into a single master frame.

    Parameters
    ----------
    frames:
        An iterable of :class:`~getframes.frame.Frame` (or plain 2-D arrays), all
        the same shape. Averaging ``n`` independent frames reduces the random noise
        by roughly ``sqrt(n)``.
    method:
        ``"median"`` (default, robust to outliers such as cosmic rays), ``"mean"``,
        or ``"sigma_clip"`` (reject pixels more than ``sigma`` standard deviations
        from the per-pixel median, then average the rest).
    sigma:
        Clipping threshold for ``method="sigma_clip"``.

    Returns
    -------
    Frame
        The master frame (ADU, ``float64``), with metadata recording the
        combination. Metadata common to all inputs is preserved; ``frame_type`` is
        prefixed with ``master_`` when the inputs agree.
    """
    if method not in _COMBINE_METHODS:
        raise ValueError(f"method must be one of {_COMBINE_METHODS}, got {method!r}.")

    frame_list = list(frames)
    if not frame_list:
        raise ValueError("combine() needs at least one frame.")
    stack = np.stack([_as_array(f) for f in frame_list], axis=0)

    if method == "mean":
        data = stack.mean(axis=0)
    elif method == "median":
        data = np.median(stack, axis=0)
    else:  # sigma_clip
        median = np.median(stack, axis=0)
        std = stack.std(axis=0)
        # Keep pixels within sigma*std of the per-pixel median; where std == 0 every
        # value is identical, so keep them all.
        keep = (std == 0.0) | (np.abs(stack - median) <= sigma * std)
        kept = np.where(keep, stack, np.nan)
        with np.errstate(invalid="ignore"):
            data = np.nanmean(kept, axis=0)
        # Pixels with everything clipped (shouldn't happen) fall back to the median.
        data = np.where(np.isnan(data), median, data)

    metadata = _master_metadata(frame_list, method)
    return Frame(data=np.asarray(data, dtype=np.float64), metadata=metadata)

calibrate(raw, *, bias=None, dark=None, flat=None, dark_scale=1.0)

Reduce a raw frame with master calibration frames.

Performs, in order: subtract the additive pedestal (an exposure-matched master dark if given, else a bias), then divide by the normalised flat::

out = raw - dark_scale * dark        # or raw - bias if no dark
out = out / (flat / mean(flat))

Parameters:

Name Type Description Default
raw FrameLike

The frame to reduce (a :class:~getframes.frame.Frame or array, in ADU).

required
bias FrameLike | None

Master bias. Subtracted only when dark is not given (a master dark already contains the bias pedestal).

None
dark FrameLike | None

Exposure-matched master dark (including bias). Subtracted from raw.

None
flat FrameLike | None

Pedestal-free master flat. Divided out after normalising it to unit mean, so only its relative pixel-to-pixel response remains.

None
dark_scale float

Multiplier applied to dark before subtraction, to scale a master dark to a different exposure (use with a separately subtracted bias for strict correctness; 1.0 for the matched-exposure default).

1.0

Returns:

Type Description
Frame

The reduced frame (float64, in ADU), with frame_type="reduced" and provenance describing the steps applied.

Source code in src/getframes/calibrate.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def calibrate(
    raw: FrameLike,
    *,
    bias: FrameLike | None = None,
    dark: FrameLike | None = None,
    flat: FrameLike | None = None,
    dark_scale: float = 1.0,
) -> Frame:
    """Reduce a raw frame with master calibration frames.

    Performs, in order: subtract the additive pedestal (an exposure-matched master
    ``dark`` if given, else a ``bias``), then divide by the normalised ``flat``::

        out = raw - dark_scale * dark        # or raw - bias if no dark
        out = out / (flat / mean(flat))

    Parameters
    ----------
    raw:
        The frame to reduce (a :class:`~getframes.frame.Frame` or array, in ADU).
    bias:
        Master bias. Subtracted only when ``dark`` is not given (a master dark
        already contains the bias pedestal).
    dark:
        Exposure-matched master dark (including bias). Subtracted from ``raw``.
    flat:
        Pedestal-free master flat. Divided out after normalising it to unit mean,
        so only its relative pixel-to-pixel response remains.
    dark_scale:
        Multiplier applied to ``dark`` before subtraction, to scale a master dark
        to a different exposure (use with a separately subtracted ``bias`` for
        strict correctness; ``1.0`` for the matched-exposure default).

    Returns
    -------
    Frame
        The reduced frame (``float64``, in ADU), with ``frame_type="reduced"`` and
        provenance describing the steps applied.
    """
    out = _as_array(raw)
    steps: list[str] = []

    if dark is not None:
        out = out - dark_scale * _as_array(dark)
        steps.append("dark")
    elif bias is not None:
        out = out - _as_array(bias)
        steps.append("bias")

    if flat is not None:
        flat_arr = _as_array(flat)
        norm = float(flat_arr.mean())
        if norm == 0.0:
            raise ValueError("flat has zero mean; cannot normalise.")
        # Guard against divide-by-zero in dead pixels: leave them unscaled.
        safe = np.where(flat_arr != 0.0, flat_arr, norm)
        out = out * (norm / safe)
        steps.append("flat")

    metadata: dict[str, Any] = {}
    if isinstance(raw, Frame):
        metadata.update(raw.metadata)
    metadata["frame_type"] = "reduced"
    metadata["calibration"] = steps
    return Frame(data=out, metadata=metadata)

Presets

getframes.presets

Built-in library of camera/detector presets.

Presets are stored as TOML files in :mod:getframes.presets.data. They are loaded lazily and cached. Add a new camera by dropping a <name>.toml file into that directory (see the existing files for the schema) — no code changes required.

available_presets()

Return the sorted list of available preset names.

from getframes import available_presets "andor_ikon_m934" in available_presets() True

Source code in src/getframes/presets/__init__.py
38
39
40
41
42
43
44
45
def available_presets() -> list[str]:
    """Return the sorted list of available preset names.

    >>> from getframes import available_presets
    >>> "andor_ikon_m934" in available_presets()
    True
    """
    return list(_preset_files())

preset_info()

Return lightweight descriptors (name, manufacturer, model, sensor_type) for each preset.

Source code in src/getframes/presets/__init__.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def preset_info() -> list[dict[str, Any]]:
    """Return lightweight descriptors (name, manufacturer, model, sensor_type) for each preset."""
    info: list[dict[str, Any]] = []
    for slug in available_presets():
        data = _read_preset(slug)
        info.append(
            {
                "preset": slug,
                "name": data.get("name", slug),
                "manufacturer": data.get("manufacturer"),
                "model": data.get("model"),
                "sensor_type": data.get("sensor_type"),
            }
        )
    return info

load_preset(name)

Load a preset by name and return a :class:~getframes.config.CameraConfig.

Parameters:

Name Type Description Default
name str

A preset slug, e.g. "andor_ikon_m934". See :func:available_presets.

required
Source code in src/getframes/presets/__init__.py
75
76
77
78
79
80
81
82
83
84
85
86
87
def load_preset(name: str) -> CameraConfig:
    """Load a preset by name and return a :class:`~getframes.config.CameraConfig`.

    Parameters
    ----------
    name:
        A preset slug, e.g. ``"andor_ikon_m934"``. See :func:`available_presets`.
    """
    from ..config import CameraConfig

    data = dict(_read_preset(name))
    data.setdefault("name", name)
    return CameraConfig.from_dict(data)

Scene & optics

getframes.scene.scene.Scene dataclass

A focal-plane scene that renders to an incident photon-rate map.

Parameters:

Name Type Description Default
shape tuple[int, int]

Output size as (height, width) in pixels; should match the camera you intend to observe it with.

required
optics Telescope

The :class:~getframes.scene.optics.Telescope providing collecting area, throughput, plate scale, and the magnitude conversion.

required
psf PSF

The :class:~getframes.scene.psf.PSF used to spread each source.

required
sources Sequence[Source]

The sources in the field (point, extended, catalog, or uniform).

tuple()
sky Sky | None

Optional uniform sky background.

None
thermal Thermal | None

Optional :class:~getframes.scene.thermal.Thermal graybody background (warm optics / enclosure emission), added as a uniform background like the sky. Dominant in the thermal infrared; needs a band with a spectral response.

None
wcs WCSInfo | None

Optional :class:~getframes.scene.wcs.WCSInfo tagging the frame with sky coordinates; its FITS header cards are copied into the observed frame's metadata, and sources placed by RA/Dec are projected through it.

None
Source code in src/getframes/scene/scene.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
@dataclass
class Scene:
    """A focal-plane scene that renders to an incident photon-rate map.

    Parameters
    ----------
    shape:
        Output size as ``(height, width)`` in pixels; should match the camera you
        intend to observe it with.
    optics:
        The :class:`~getframes.scene.optics.Telescope` providing collecting area,
        throughput, plate scale, and the magnitude conversion.
    psf:
        The :class:`~getframes.scene.psf.PSF` used to spread each source.
    sources:
        The sources in the field (point, extended, catalog, or uniform).
    sky:
        Optional uniform sky background.
    thermal:
        Optional :class:`~getframes.scene.thermal.Thermal` graybody background (warm
        optics / enclosure emission), added as a uniform background like the sky.
        Dominant in the thermal infrared; needs a band with a spectral response.
    wcs:
        Optional :class:`~getframes.scene.wcs.WCSInfo` tagging the frame with sky
        coordinates; its FITS header cards are copied into the observed frame's
        metadata, and sources placed by RA/Dec are projected through it.
    """

    shape: tuple[int, int]
    optics: Telescope
    psf: PSF
    sources: Sequence[Source] = field(default_factory=tuple)
    sky: Sky | None = None
    thermal: Thermal | None = None
    wcs: WCSInfo | None = None

    def __post_init__(self) -> None:
        self.shape = tuple(int(n) for n in self.shape)  # type: ignore[assignment]
        if len(self.shape) != 2 or any(n <= 0 for n in self.shape):
            raise ValueError(f"shape must be two positive ints, got {self.shape!r}.")

    def add(self, *sources: Source) -> None:
        """Append one or more sources to the scene."""
        self.sources = [*self.sources, *sources]

    def _source_photon_rate(self, source: Source, time_s: float | None = None) -> float:
        """Total photons/s reaching the detector from a single source.

        When ``time_s`` is given and the source carries a
        :class:`~getframes.scene.sources.LightCurve`, the baseline rate is scaled by
        ``brightness(time_s)`` so the source varies in time.
        """
        return source.total_photon_rate(self.optics, time_s)

    def _pixel_transform(self) -> Callable[[float, float], tuple[float, float]] | None:
        """The distortion remap for source positions (``None`` if no distortion)."""
        distortion = self.optics.distortion
        if distortion is None:
            return None
        height, width = self.shape
        cx, cy = (width - 1) / 2.0, (height - 1) / 2.0
        return lambda x, y: distortion.apply(x, y, cx, cy)

    def _render(
        self,
        qe_scale: Callable[[SED | None], float],
        time_s: float | None,
        offset_xy: tuple[float, float],
        dtype: DTypeLike = np.float64,
    ) -> NDArray[np.float64]:
        """Deposit every source into a fresh map and apply vignetting."""
        ctx = RenderContext(
            optics=self.optics,
            psf=self.psf,
            wcs=self.wcs,
            time_s=time_s,
            offset_xy=offset_xy,
            qe_scale=qe_scale,
            pixel_transform=self._pixel_transform(),
        )
        image = np.zeros(self.shape, dtype=dtype)
        for source in self.sources:
            source.deposit(image, ctx)
        illumination = self.optics.illumination_map(self.shape)
        if illumination is not None:
            image *= illumination
        return image

    def photon_rate_map(
        self,
        time_s: float | None = None,
        offset_xy: tuple[float, float] = (0.0, 0.0),
        dtype: DTypeLike = np.float64,
    ) -> NDArray[np.float64]:
        """Render the sources through the PSF into a photons/s/pixel map.

        This is the incident rate at the detector *before* quantum efficiency; the
        camera applies QE, dark current, and noise when it exposes the scene.

        Parameters
        ----------
        time_s:
            Optional observation time in seconds. When set, sources carrying a
            :class:`~getframes.scene.sources.LightCurve` are sampled at this time.
            ``None`` (the default) renders the static, baseline scene.
        offset_xy:
            A whole-field pointing offset ``(dx, dy)`` in pixels added to every
            source position (models jitter / drift / dither). Defaults to no shift.
        dtype:
            Output (and working) floating-point dtype. ``float64`` is the exact
            default; ``float32`` halves the map's memory for the fast path.
        """
        return self._render(lambda _sed: 1.0, time_s, offset_xy, dtype)

    def sky_photon_rate(self) -> float:
        """Uniform sky background in photons/s/pixel (``0`` if no sky is set)."""
        if self.sky is None:
            return 0.0
        return self.optics.surface_brightness_photon_rate(self.sky.surface_brightness_mag_arcsec2)

    def thermal_photon_rate(self) -> float:
        """Uniform thermal (graybody) background in photons/s/pixel (``0`` if unset)."""
        if self.thermal is None:
            return 0.0
        return self.thermal.photon_rate(self.optics)

    @property
    def is_spectral_capable(self) -> bool:
        """Whether this scene's band carries a spectral response for spectral mode."""
        return self.optics.band is not None and self.optics.band.response is not None

    def photoelectron_rate_map(
        self,
        qe_curve: QE,
        time_s: float | None = None,
        offset_xy: tuple[float, float] = (0.0, 0.0),
        dtype: DTypeLike = np.float64,
    ) -> NDArray[np.float64]:
        """Render sources to a *photoelectron*-rate map (e-/s/pixel) in spectral mode.

        Like :meth:`photon_rate_map`, but each source's incident photon rate is
        multiplied by the colour-dependent effective QE for its SED (folding the
        detector ``qe_curve`` with the band's spectral response). The result is
        already in photoelectrons, so the camera applies a unit QE downstream.

        ``time_s`` and ``offset_xy`` behave as in :meth:`photon_rate_map`.

        Requires a band with a spectral response (see :attr:`is_spectral_capable`).
        """
        band = self.optics.band
        if band is None or band.response is None:
            raise ValueError("photoelectron_rate_map requires a band with a spectral response.")
        return self._render(lambda sed: band.effective_qe(qe_curve, sed), time_s, offset_xy, dtype)

    def sky_electron_rate(self, qe_curve: QE) -> float:
        """Uniform sky background in photoelectrons/s/pixel for spectral mode."""
        if self.sky is None:
            return 0.0
        band = self.optics.band
        if band is None or band.response is None:
            raise ValueError("sky_electron_rate requires a band with a spectral response.")
        return self.sky_photon_rate() * band.effective_qe(qe_curve, self.sky.sed)

    def thermal_electron_rate(self, qe_curve: QE) -> float:
        """Uniform thermal background in photoelectrons/s/pixel for spectral mode."""
        if self.thermal is None:
            return 0.0
        band = self.optics.band
        if band is None or band.response is None:
            raise ValueError("thermal_electron_rate requires a band with a spectral response.")
        return self.thermal_photon_rate() * band.effective_qe(qe_curve, self.thermal.photon_sed())

    def background_photon_rate(self) -> float:
        """Total uniform background (sky + thermal) in photons/s/pixel."""
        return self.sky_photon_rate() + self.thermal_photon_rate()

    def background_electron_rate(self, qe_curve: QE) -> float:
        """Total uniform background (sky + thermal) in photoelectrons/s/pixel (spectral)."""
        return self.sky_electron_rate(qe_curve) + self.thermal_electron_rate(qe_curve)

is_spectral_capable property

Whether this scene's band carries a spectral response for spectral mode.

add(*sources)

Append one or more sources to the scene.

Source code in src/getframes/scene/scene.py
68
69
70
def add(self, *sources: Source) -> None:
    """Append one or more sources to the scene."""
    self.sources = [*self.sources, *sources]

photon_rate_map(time_s=None, offset_xy=(0.0, 0.0), dtype=np.float64)

Render the sources through the PSF into a photons/s/pixel map.

This is the incident rate at the detector before quantum efficiency; the camera applies QE, dark current, and noise when it exposes the scene.

Parameters:

Name Type Description Default
time_s float | None

Optional observation time in seconds. When set, sources carrying a :class:~getframes.scene.sources.LightCurve are sampled at this time. None (the default) renders the static, baseline scene.

None
offset_xy tuple[float, float]

A whole-field pointing offset (dx, dy) in pixels added to every source position (models jitter / drift / dither). Defaults to no shift.

(0.0, 0.0)
dtype DTypeLike

Output (and working) floating-point dtype. float64 is the exact default; float32 halves the map's memory for the fast path.

float64
Source code in src/getframes/scene/scene.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def photon_rate_map(
    self,
    time_s: float | None = None,
    offset_xy: tuple[float, float] = (0.0, 0.0),
    dtype: DTypeLike = np.float64,
) -> NDArray[np.float64]:
    """Render the sources through the PSF into a photons/s/pixel map.

    This is the incident rate at the detector *before* quantum efficiency; the
    camera applies QE, dark current, and noise when it exposes the scene.

    Parameters
    ----------
    time_s:
        Optional observation time in seconds. When set, sources carrying a
        :class:`~getframes.scene.sources.LightCurve` are sampled at this time.
        ``None`` (the default) renders the static, baseline scene.
    offset_xy:
        A whole-field pointing offset ``(dx, dy)`` in pixels added to every
        source position (models jitter / drift / dither). Defaults to no shift.
    dtype:
        Output (and working) floating-point dtype. ``float64`` is the exact
        default; ``float32`` halves the map's memory for the fast path.
    """
    return self._render(lambda _sed: 1.0, time_s, offset_xy, dtype)

sky_photon_rate()

Uniform sky background in photons/s/pixel (0 if no sky is set).

Source code in src/getframes/scene/scene.py
141
142
143
144
145
def sky_photon_rate(self) -> float:
    """Uniform sky background in photons/s/pixel (``0`` if no sky is set)."""
    if self.sky is None:
        return 0.0
    return self.optics.surface_brightness_photon_rate(self.sky.surface_brightness_mag_arcsec2)

thermal_photon_rate()

Uniform thermal (graybody) background in photons/s/pixel (0 if unset).

Source code in src/getframes/scene/scene.py
147
148
149
150
151
def thermal_photon_rate(self) -> float:
    """Uniform thermal (graybody) background in photons/s/pixel (``0`` if unset)."""
    if self.thermal is None:
        return 0.0
    return self.thermal.photon_rate(self.optics)

photoelectron_rate_map(qe_curve, time_s=None, offset_xy=(0.0, 0.0), dtype=np.float64)

Render sources to a photoelectron-rate map (e-/s/pixel) in spectral mode.

Like :meth:photon_rate_map, but each source's incident photon rate is multiplied by the colour-dependent effective QE for its SED (folding the detector qe_curve with the band's spectral response). The result is already in photoelectrons, so the camera applies a unit QE downstream.

time_s and offset_xy behave as in :meth:photon_rate_map.

Requires a band with a spectral response (see :attr:is_spectral_capable).

Source code in src/getframes/scene/scene.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def photoelectron_rate_map(
    self,
    qe_curve: QE,
    time_s: float | None = None,
    offset_xy: tuple[float, float] = (0.0, 0.0),
    dtype: DTypeLike = np.float64,
) -> NDArray[np.float64]:
    """Render sources to a *photoelectron*-rate map (e-/s/pixel) in spectral mode.

    Like :meth:`photon_rate_map`, but each source's incident photon rate is
    multiplied by the colour-dependent effective QE for its SED (folding the
    detector ``qe_curve`` with the band's spectral response). The result is
    already in photoelectrons, so the camera applies a unit QE downstream.

    ``time_s`` and ``offset_xy`` behave as in :meth:`photon_rate_map`.

    Requires a band with a spectral response (see :attr:`is_spectral_capable`).
    """
    band = self.optics.band
    if band is None or band.response is None:
        raise ValueError("photoelectron_rate_map requires a band with a spectral response.")
    return self._render(lambda sed: band.effective_qe(qe_curve, sed), time_s, offset_xy, dtype)

sky_electron_rate(qe_curve)

Uniform sky background in photoelectrons/s/pixel for spectral mode.

Source code in src/getframes/scene/scene.py
181
182
183
184
185
186
187
188
def sky_electron_rate(self, qe_curve: QE) -> float:
    """Uniform sky background in photoelectrons/s/pixel for spectral mode."""
    if self.sky is None:
        return 0.0
    band = self.optics.band
    if band is None or band.response is None:
        raise ValueError("sky_electron_rate requires a band with a spectral response.")
    return self.sky_photon_rate() * band.effective_qe(qe_curve, self.sky.sed)

thermal_electron_rate(qe_curve)

Uniform thermal background in photoelectrons/s/pixel for spectral mode.

Source code in src/getframes/scene/scene.py
190
191
192
193
194
195
196
197
def thermal_electron_rate(self, qe_curve: QE) -> float:
    """Uniform thermal background in photoelectrons/s/pixel for spectral mode."""
    if self.thermal is None:
        return 0.0
    band = self.optics.band
    if band is None or band.response is None:
        raise ValueError("thermal_electron_rate requires a band with a spectral response.")
    return self.thermal_photon_rate() * band.effective_qe(qe_curve, self.thermal.photon_sed())

background_photon_rate()

Total uniform background (sky + thermal) in photons/s/pixel.

Source code in src/getframes/scene/scene.py
199
200
201
def background_photon_rate(self) -> float:
    """Total uniform background (sky + thermal) in photons/s/pixel."""
    return self.sky_photon_rate() + self.thermal_photon_rate()

background_electron_rate(qe_curve)

Total uniform background (sky + thermal) in photoelectrons/s/pixel (spectral).

Source code in src/getframes/scene/scene.py
203
204
205
def background_electron_rate(self, qe_curve: QE) -> float:
    """Total uniform background (sky + thermal) in photoelectrons/s/pixel (spectral)."""
    return self.sky_electron_rate(qe_curve) + self.thermal_electron_rate(qe_curve)

getframes.scene.sources.PointSource dataclass

Bases: Source

An unresolved point source (e.g. a star) at pixel position (x, y).

Specify the brightness in exactly one of two ways:

  • magnitude --- converted to a photon rate by the telescope's bandpass, or
  • photon_rate --- photons/s already arriving at the detector (post-optics, pre-quantum-efficiency), handy when you know the flux directly (e.g. an AO sub-aperture).

x is the column and y the row, in pixels; sub-pixel positions are fine.

sed is an optional spectral energy distribution (:class:~getframes.spectral.SED). It is used only in spectral mode, to give the source a colour-dependent effective QE; it has no effect on the integrated photon rate (the magnitude sets that). Defaults to a flat photon spectrum.

brightness is an optional :class:LightCurve. When set, the source's photon rate is multiplied by brightness(t) at each timestamp sampled by :meth:getframes.Camera.observe_series, making the source variable in time. A static :meth:getframes.Camera.observe (no time) ignores it.

name is an optional label used to key the source in an observation's per-frame truth light curve.

flux_sed is an alternative to magnitude/photon_rate: an absolute :class:~getframes.spectral.SED (SED.from_flux_density) whose integral over the band sets the photon rate directly (true spectral flux integration). When given it also serves as the colour SED for spectral mode.

Source code in src/getframes/scene/sources.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
@dataclass(frozen=True)
class PointSource(Source):
    """An unresolved point source (e.g. a star) at pixel position ``(x, y)``.

    Specify the brightness in exactly one of two ways:

    * ``magnitude`` --- converted to a photon rate by the telescope's bandpass, or
    * ``photon_rate`` --- photons/s already arriving at the detector (post-optics,
      pre-quantum-efficiency), handy when you know the flux directly (e.g. an AO
      sub-aperture).

    ``x`` is the column and ``y`` the row, in pixels; sub-pixel positions are fine.

    ``sed`` is an optional spectral energy distribution
    (:class:`~getframes.spectral.SED`). It is used only in spectral mode, to give
    the source a colour-dependent effective QE; it has no effect on the integrated
    photon rate (the magnitude sets that). Defaults to a flat photon spectrum.

    ``brightness`` is an optional :class:`LightCurve`. When set, the source's
    photon rate is multiplied by ``brightness(t)`` at each timestamp sampled by
    :meth:`getframes.Camera.observe_series`, making the source variable in time.
    A static :meth:`getframes.Camera.observe` (no time) ignores it.

    ``name`` is an optional label used to key the source in an observation's
    per-frame truth light curve.

    ``flux_sed`` is an alternative to ``magnitude``/``photon_rate``: an *absolute*
    :class:`~getframes.spectral.SED` (``SED.from_flux_density``) whose integral over
    the band sets the photon rate directly (true spectral flux integration). When
    given it also serves as the colour SED for spectral mode.
    """

    x: float
    y: float
    magnitude: float | None = None
    photon_rate: float | None = None
    sed: SED | None = None
    brightness: LightCurve | None = None
    name: str | None = None
    flux_sed: SED | None = None

    def __post_init__(self) -> None:
        _check_one_brightness(self.magnitude, self.photon_rate, self.flux_sed)

    def total_photon_rate(self, optics: Telescope, time_s: float | None = None) -> float:
        rate = _resolve_rate(self.magnitude, self.photon_rate, optics, self.flux_sed)
        return rate * _brightness_scale(self.brightness, time_s)

    def deposit(self, image: NDArray[np.float64], ctx: RenderContext) -> None:
        rate = self.total_photon_rate(ctx.optics, ctx.time_s) * ctx.qe_scale(_color_sed(self))
        if rate <= 0:
            return
        px, py = ctx.place(self.x, self.y, None, None)
        ctx.psf.add_source(image, px, py, rate, ctx.optics.plate_scale_arcsec_per_pixel)

getframes.scene.sources.ExtendedSource dataclass

Bases: Source

A resolved source rendered from an analytic Sersic profile or a pixel array.

Place it by pixel (x, y) or, with a scene :class:~getframes.scene.wcs.WCSInfo, by sky (ra_deg, dec_deg). Total brightness is set by magnitude or an explicit photon_rate (exactly one), as for :class:PointSource; the profile distributes that total flux over pixels and is normalised to conserve it.

Construct via :meth:sersic (a Sersic surface-brightness profile, optionally elliptical) or :meth:from_array (an arbitrary normalised image, e.g. a galaxy cutout). The profile is rendered directly to the focal plane and is not additionally convolved with the scene PSF --- supply a pre-convolved array, or rely on the profile being broad compared with the PSF.

Source code in src/getframes/scene/sources.py
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
@dataclass(frozen=True)
class ExtendedSource(Source):
    """A resolved source rendered from an analytic Sersic profile or a pixel array.

    Place it by pixel ``(x, y)`` or, with a scene :class:`~getframes.scene.wcs.WCSInfo`,
    by sky ``(ra_deg, dec_deg)``. Total brightness is set by ``magnitude`` or an
    explicit ``photon_rate`` (exactly one), as for :class:`PointSource`; the profile
    distributes that total flux over pixels and is normalised to conserve it.

    Construct via :meth:`sersic` (a Sersic surface-brightness profile, optionally
    elliptical) or :meth:`from_array` (an arbitrary normalised image, e.g. a galaxy
    cutout). The profile is rendered directly to the focal plane and is *not*
    additionally convolved with the scene PSF --- supply a pre-convolved array, or
    rely on the profile being broad compared with the PSF.
    """

    x: float | None = None
    y: float | None = None
    ra_deg: float | None = None
    dec_deg: float | None = None
    magnitude: float | None = None
    photon_rate: float | None = None
    profile: NDArray[np.float64] | None = None
    sersic_n: float | None = None
    r_eff_arcsec: float | None = None
    ellipticity: float = 0.0
    position_angle_deg: float = 0.0
    sed: SED | None = None
    brightness: LightCurve | None = None
    name: str | None = None
    flux_sed: SED | None = None

    def __post_init__(self) -> None:
        _check_one_brightness(self.magnitude, self.photon_rate, self.flux_sed)
        if (self.profile is None) == (self.sersic_n is None):
            raise ValueError("ExtendedSource needs exactly one of a `profile` or Sersic params.")
        if not 0.0 <= self.ellipticity < 1.0:
            raise ValueError("ellipticity must be in [0, 1).")
        if self.sersic_n is not None:
            if self.sersic_n <= 0:
                raise ValueError("Sersic index n must be positive.")
            if self.r_eff_arcsec is None or self.r_eff_arcsec <= 0:
                raise ValueError("Sersic profile requires a positive r_eff_arcsec.")

    @classmethod
    def sersic(
        cls,
        *,
        x: float | None = None,
        y: float | None = None,
        ra: float | None = None,
        dec: float | None = None,
        magnitude: float | None = None,
        photon_rate: float | None = None,
        n: float = 1.0,
        r_eff_arcsec: float,
        ellipticity: float = 0.0,
        position_angle_deg: float = 0.0,
        sed: SED | None = None,
        brightness: LightCurve | None = None,
        name: str | None = None,
        flux_sed: SED | None = None,
    ) -> ExtendedSource:
        """A Sersic profile ``I(r) ~ exp(-b_n[(r/r_eff)^(1/n) - 1])``.

        ``n=1`` is an exponential disk, ``n=4`` a de Vaucouleurs bulge. ``ellipticity``
        (``1 - b/a``) and ``position_angle_deg`` (of the major axis, measured
        counter-clockwise from the +x axis) shape an elliptical isophote.
        """
        return cls(
            x=x,
            y=y,
            ra_deg=ra,
            dec_deg=dec,
            magnitude=magnitude,
            photon_rate=photon_rate,
            sersic_n=n,
            r_eff_arcsec=r_eff_arcsec,
            ellipticity=ellipticity,
            position_angle_deg=position_angle_deg,
            sed=sed,
            brightness=brightness,
            name=name,
            flux_sed=flux_sed,
        )

    @classmethod
    def from_array(
        cls,
        image: NDArray[np.float64],
        *,
        x: float | None = None,
        y: float | None = None,
        ra: float | None = None,
        dec: float | None = None,
        magnitude: float | None = None,
        photon_rate: float | None = None,
        sed: SED | None = None,
        brightness: LightCurve | None = None,
        name: str | None = None,
        flux_sed: SED | None = None,
    ) -> ExtendedSource:
        """An arbitrary 2D ``image`` (e.g. a galaxy cutout) used as the profile.

        The array is normalised to unit sum and pasted centred on the source
        position at detector-pixel resolution, then scaled to the total flux.
        """
        arr = np.asarray(image, dtype=np.float64)
        if arr.ndim != 2 or arr.size == 0:
            raise ValueError("ExtendedSource.from_array needs a non-empty 2D image.")
        total = float(arr.sum())
        if total <= 0:
            raise ValueError("ExtendedSource.from_array image must have a positive sum.")
        return cls(
            x=x,
            y=y,
            ra_deg=ra,
            dec_deg=dec,
            magnitude=magnitude,
            photon_rate=photon_rate,
            profile=arr / total,
            sed=sed,
            brightness=brightness,
            name=name,
            flux_sed=flux_sed,
        )

    def total_photon_rate(self, optics: Telescope, time_s: float | None = None) -> float:
        rate = _resolve_rate(self.magnitude, self.photon_rate, optics, self.flux_sed)
        return rate * _brightness_scale(self.brightness, time_s)

    def deposit(self, image: NDArray[np.float64], ctx: RenderContext) -> None:
        flux = self.total_photon_rate(ctx.optics, ctx.time_s) * ctx.qe_scale(_color_sed(self))
        if flux <= 0:
            return
        px, py = ctx.place(self.x, self.y, self.ra_deg, self.dec_deg)
        if self.profile is not None:
            _paste_centered(image, self.profile * flux, px, py)
        else:
            self._deposit_sersic(image, px, py, flux, ctx.optics.plate_scale_arcsec_per_pixel)

    def _deposit_sersic(
        self,
        image: NDArray[np.float64],
        x: float,
        y: float,
        flux: float,
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        assert self.sersic_n is not None and self.r_eff_arcsec is not None
        r_eff_pix = self.r_eff_arcsec / plate_scale_arcsec_per_pixel
        if r_eff_pix <= 0:
            raise ValueError("plate scale and r_eff must yield a positive r_eff in pixels.")
        n = self.sersic_n
        # Ciotti & Bertin (1999) approximation to b_n.
        b_n = 2.0 * n - 1.0 / 3.0 + 4.0 / (405.0 * n) + 46.0 / (25515.0 * n * n)
        q = 1.0 - self.ellipticity  # minor/major axis ratio

        # Stamp out to ~8 effective radii along the major axis captures the flux.
        radius = int(np.ceil(8.0 * r_eff_pix)) + 1
        height, width = image.shape
        ix, iy = round(x), round(y)
        x0, x1 = max(0, ix - radius), min(width, ix + radius + 1)
        y0, y1 = max(0, iy - radius), min(height, iy + radius + 1)
        if x0 >= x1 or y0 >= y1:
            return

        xs = np.arange(x0, x1) - x
        ys = np.arange(y0, y1) - y
        theta = math.radians(self.position_angle_deg)
        cos_t, sin_t = math.cos(theta), math.sin(theta)
        # Coordinates along (major, minor) axes of the ellipse.
        u = xs[None, :] * cos_t + ys[:, None] * sin_t
        v = -xs[None, :] * sin_t + ys[:, None] * cos_t
        r = np.sqrt(u**2 + (v / q) ** 2) / r_eff_pix
        profile = np.exp(-b_n * (np.power(r, 1.0 / n) - 1.0))
        total = profile.sum()
        if total > 0:
            image[y0:y1, x0:x1] += flux * profile / total

    @property
    def position_angle(self) -> float:
        """Position angle of the major axis, in degrees (alias of the field)."""
        return self.position_angle_deg

position_angle property

Position angle of the major axis, in degrees (alias of the field).

sersic(*, x=None, y=None, ra=None, dec=None, magnitude=None, photon_rate=None, n=1.0, r_eff_arcsec, ellipticity=0.0, position_angle_deg=0.0, sed=None, brightness=None, name=None, flux_sed=None) classmethod

A Sersic profile I(r) ~ exp(-b_n[(r/r_eff)^(1/n) - 1]).

n=1 is an exponential disk, n=4 a de Vaucouleurs bulge. ellipticity (1 - b/a) and position_angle_deg (of the major axis, measured counter-clockwise from the +x axis) shape an elliptical isophote.

Source code in src/getframes/scene/sources.py
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
@classmethod
def sersic(
    cls,
    *,
    x: float | None = None,
    y: float | None = None,
    ra: float | None = None,
    dec: float | None = None,
    magnitude: float | None = None,
    photon_rate: float | None = None,
    n: float = 1.0,
    r_eff_arcsec: float,
    ellipticity: float = 0.0,
    position_angle_deg: float = 0.0,
    sed: SED | None = None,
    brightness: LightCurve | None = None,
    name: str | None = None,
    flux_sed: SED | None = None,
) -> ExtendedSource:
    """A Sersic profile ``I(r) ~ exp(-b_n[(r/r_eff)^(1/n) - 1])``.

    ``n=1`` is an exponential disk, ``n=4`` a de Vaucouleurs bulge. ``ellipticity``
    (``1 - b/a``) and ``position_angle_deg`` (of the major axis, measured
    counter-clockwise from the +x axis) shape an elliptical isophote.
    """
    return cls(
        x=x,
        y=y,
        ra_deg=ra,
        dec_deg=dec,
        magnitude=magnitude,
        photon_rate=photon_rate,
        sersic_n=n,
        r_eff_arcsec=r_eff_arcsec,
        ellipticity=ellipticity,
        position_angle_deg=position_angle_deg,
        sed=sed,
        brightness=brightness,
        name=name,
        flux_sed=flux_sed,
    )

from_array(image, *, x=None, y=None, ra=None, dec=None, magnitude=None, photon_rate=None, sed=None, brightness=None, name=None, flux_sed=None) classmethod

An arbitrary 2D image (e.g. a galaxy cutout) used as the profile.

The array is normalised to unit sum and pasted centred on the source position at detector-pixel resolution, then scaled to the total flux.

Source code in src/getframes/scene/sources.py
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
@classmethod
def from_array(
    cls,
    image: NDArray[np.float64],
    *,
    x: float | None = None,
    y: float | None = None,
    ra: float | None = None,
    dec: float | None = None,
    magnitude: float | None = None,
    photon_rate: float | None = None,
    sed: SED | None = None,
    brightness: LightCurve | None = None,
    name: str | None = None,
    flux_sed: SED | None = None,
) -> ExtendedSource:
    """An arbitrary 2D ``image`` (e.g. a galaxy cutout) used as the profile.

    The array is normalised to unit sum and pasted centred on the source
    position at detector-pixel resolution, then scaled to the total flux.
    """
    arr = np.asarray(image, dtype=np.float64)
    if arr.ndim != 2 or arr.size == 0:
        raise ValueError("ExtendedSource.from_array needs a non-empty 2D image.")
    total = float(arr.sum())
    if total <= 0:
        raise ValueError("ExtendedSource.from_array image must have a positive sum.")
    return cls(
        x=x,
        y=y,
        ra_deg=ra,
        dec_deg=dec,
        magnitude=magnitude,
        photon_rate=photon_rate,
        profile=arr / total,
        sed=sed,
        brightness=brightness,
        name=name,
        flux_sed=flux_sed,
    )

getframes.scene.sources.UniformIllumination dataclass

Bases: Source

A spatially flat illumination of photon_rate photons/s/pixel.

A clean, PSF-free flat field --- the natural input for a photon-transfer curve (PTC) or for building synthetic flats. brightness and sed behave as for other sources (time variability and spectral effective QE).

Source code in src/getframes/scene/sources.py
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
@dataclass(frozen=True)
class UniformIllumination(Source):
    """A spatially flat illumination of ``photon_rate`` photons/s/pixel.

    A clean, PSF-free flat field --- the natural input for a photon-transfer curve
    (PTC) or for building synthetic flats. ``brightness`` and ``sed`` behave as for
    other sources (time variability and spectral effective QE).
    """

    photon_rate: float
    sed: SED | None = None
    brightness: LightCurve | None = None
    name: str | None = None

    def __post_init__(self) -> None:
        if self.photon_rate < 0:
            raise ValueError("photon_rate must be non-negative.")

    def total_photon_rate(self, optics: Telescope, time_s: float | None = None) -> float:
        return self.photon_rate * _brightness_scale(self.brightness, time_s)

    def deposit(self, image: NDArray[np.float64], ctx: RenderContext) -> None:
        rate = self.total_photon_rate(ctx.optics, ctx.time_s) * ctx.qe_scale(self.sed)
        if rate != 0.0:
            image += rate

getframes.scene.sources.Catalog dataclass

Bases: Source

Many point sources sharing a PSF, SED, and optional light curve.

Build one from a table with :meth:from_table. Entries may be placed by pixel (x, y) or by sky (ra, dec); sky coordinates are projected to pixels through the scene's :class:~getframes.scene.wcs.WCSInfo, so a Gaia/2MASS-style catalogue drops straight into a WCS-tagged scene. The whole catalogue is keyed by a single :attr:name in observation truth (its summed flux).

Source code in src/getframes/scene/sources.py
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
@dataclass(frozen=True)
class Catalog(Source):
    """Many point sources sharing a PSF, SED, and optional light curve.

    Build one from a table with :meth:`from_table`. Entries may be placed by pixel
    ``(x, y)`` or by sky ``(ra, dec)``; sky coordinates are projected to pixels
    through the scene's :class:`~getframes.scene.wcs.WCSInfo`, so a Gaia/2MASS-style
    catalogue drops straight into a WCS-tagged scene. The whole catalogue is keyed
    by a single :attr:`name` in observation truth (its summed flux).
    """

    entries: tuple[CatalogEntry, ...] = field(default_factory=tuple)
    sed: SED | None = None
    brightness: LightCurve | None = None
    name: str | None = None

    @classmethod
    def from_table(
        cls,
        table: Mapping[str, Sequence[Any]] | Any,
        *,
        magnitude: str | None = None,
        photon_rate: str | None = None,
        x: str | None = None,
        y: str | None = None,
        ra: str | None = None,
        dec: str | None = None,
        sed: SED | None = None,
        brightness: LightCurve | None = None,
        name: str | None = None,
    ) -> Catalog:
        """Build a catalogue from column names of ``table``.

        ``table`` is anything column-indexable by name (an ``astropy`` ``Table``, a
        pandas ``DataFrame``, or a ``dict`` of arrays). Give the brightness column as
        ``magnitude`` or ``photon_rate``, and the position columns as either
        (``x``, ``y``) pixels or (``ra``, ``dec``) degrees.
        """
        if (magnitude is None) == (photon_rate is None):
            raise ValueError("Specify exactly one of `magnitude` or `photon_rate` column.")
        use_radec = ra is not None and dec is not None
        use_xy = x is not None and y is not None
        if use_radec == use_xy:
            raise ValueError("Specify position columns as either (ra, dec) or (x, y).")

        bright_col = magnitude if magnitude is not None else photon_rate
        assert bright_col is not None
        bright_vals = np.asarray(table[bright_col], dtype=np.float64)
        if use_radec:
            assert ra is not None and dec is not None
            pos_a = np.asarray(table[ra], dtype=np.float64)
            pos_b = np.asarray(table[dec], dtype=np.float64)
        else:
            assert x is not None and y is not None
            pos_a = np.asarray(table[x], dtype=np.float64)
            pos_b = np.asarray(table[y], dtype=np.float64)

        entries: list[CatalogEntry] = []
        is_mag = magnitude is not None
        for i in range(len(bright_vals)):
            mag = float(bright_vals[i]) if is_mag else None
            rate = None if is_mag else float(bright_vals[i])
            if use_radec:
                entries.append(
                    CatalogEntry(
                        magnitude=mag,
                        photon_rate=rate,
                        ra_deg=float(pos_a[i]),
                        dec_deg=float(pos_b[i]),
                    )
                )
            else:
                entries.append(
                    CatalogEntry(
                        magnitude=mag,
                        photon_rate=rate,
                        x=float(pos_a[i]),
                        y=float(pos_b[i]),
                    )
                )
        return cls(entries=tuple(entries), sed=sed, brightness=brightness, name=name)

    def __len__(self) -> int:
        return len(self.entries)

    def total_photon_rate(self, optics: Telescope, time_s: float | None = None) -> float:
        scale = _brightness_scale(self.brightness, time_s)
        return scale * sum(_resolve_rate(e.magnitude, e.photon_rate, optics) for e in self.entries)

    def deposit(self, image: NDArray[np.float64], ctx: RenderContext) -> None:
        scale = _brightness_scale(self.brightness, ctx.time_s) * ctx.qe_scale(self.sed)
        if scale <= 0 or not self.entries:
            return
        plate_scale = ctx.optics.plate_scale_arcsec_per_pixel
        rates = np.array(
            [_resolve_rate(e.magnitude, e.photon_rate, ctx.optics) for e in self.entries],
            dtype=np.float64,
        )
        rates *= scale
        xs, ys = self._placed_positions(ctx)
        # One batched (vectorised, chunked) deposit instead of a Python per-star loop.
        ctx.psf.add_sources(image, xs, ys, rates, plate_scale)

    def _placed_positions(
        self, ctx: RenderContext
    ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
        """Resolve every entry to detector pixels, vectorising the common path.

        Pixel-placed catalogues with no optical distortion are projected in one
        array op; RA/Dec or distortion fall back to the per-entry
        :meth:`RenderContext.place` so WCS and distortion behave exactly as for a
        single source.
        """
        pixel_only = all(e.ra_deg is None and e.x is not None for e in self.entries)
        if pixel_only and ctx.pixel_transform is None:
            xs = np.array([e.x for e in self.entries], dtype=np.float64)
            ys = np.array([e.y for e in self.entries], dtype=np.float64)
            return xs + ctx.offset_xy[0], ys + ctx.offset_xy[1]
        placed = [ctx.place(e.x, e.y, e.ra_deg, e.dec_deg) for e in self.entries]
        arr = np.asarray(placed, dtype=np.float64).reshape(len(placed), 2)
        return arr[:, 0], arr[:, 1]

from_table(table, *, magnitude=None, photon_rate=None, x=None, y=None, ra=None, dec=None, sed=None, brightness=None, name=None) classmethod

Build a catalogue from column names of table.

table is anything column-indexable by name (an astropy Table, a pandas DataFrame, or a dict of arrays). Give the brightness column as magnitude or photon_rate, and the position columns as either (x, y) pixels or (ra, dec) degrees.

Source code in src/getframes/scene/sources.py
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
@classmethod
def from_table(
    cls,
    table: Mapping[str, Sequence[Any]] | Any,
    *,
    magnitude: str | None = None,
    photon_rate: str | None = None,
    x: str | None = None,
    y: str | None = None,
    ra: str | None = None,
    dec: str | None = None,
    sed: SED | None = None,
    brightness: LightCurve | None = None,
    name: str | None = None,
) -> Catalog:
    """Build a catalogue from column names of ``table``.

    ``table`` is anything column-indexable by name (an ``astropy`` ``Table``, a
    pandas ``DataFrame``, or a ``dict`` of arrays). Give the brightness column as
    ``magnitude`` or ``photon_rate``, and the position columns as either
    (``x``, ``y``) pixels or (``ra``, ``dec``) degrees.
    """
    if (magnitude is None) == (photon_rate is None):
        raise ValueError("Specify exactly one of `magnitude` or `photon_rate` column.")
    use_radec = ra is not None and dec is not None
    use_xy = x is not None and y is not None
    if use_radec == use_xy:
        raise ValueError("Specify position columns as either (ra, dec) or (x, y).")

    bright_col = magnitude if magnitude is not None else photon_rate
    assert bright_col is not None
    bright_vals = np.asarray(table[bright_col], dtype=np.float64)
    if use_radec:
        assert ra is not None and dec is not None
        pos_a = np.asarray(table[ra], dtype=np.float64)
        pos_b = np.asarray(table[dec], dtype=np.float64)
    else:
        assert x is not None and y is not None
        pos_a = np.asarray(table[x], dtype=np.float64)
        pos_b = np.asarray(table[y], dtype=np.float64)

    entries: list[CatalogEntry] = []
    is_mag = magnitude is not None
    for i in range(len(bright_vals)):
        mag = float(bright_vals[i]) if is_mag else None
        rate = None if is_mag else float(bright_vals[i])
        if use_radec:
            entries.append(
                CatalogEntry(
                    magnitude=mag,
                    photon_rate=rate,
                    ra_deg=float(pos_a[i]),
                    dec_deg=float(pos_b[i]),
                )
            )
        else:
            entries.append(
                CatalogEntry(
                    magnitude=mag,
                    photon_rate=rate,
                    x=float(pos_a[i]),
                    y=float(pos_b[i]),
                )
            )
    return cls(entries=tuple(entries), sed=sed, brightness=brightness, name=name)

getframes.scene.sources.Sky dataclass

A uniform sky background of a given surface brightness.

The :class:~getframes.scene.scene.Scene treats the sky specially: it is added by the camera as a uniform background rather than deposited into the rendered source map, and is therefore not affected by vignetting.

Parameters:

Name Type Description Default
surface_brightness_mag_arcsec2 float

Sky brightness in magnitudes per square arcsecond (fainter = larger).

required
sed SED | None

Optional spectral energy distribution for the sky, used only in spectral mode for the sky's effective QE. Defaults to a flat photon spectrum.

None
Source code in src/getframes/scene/sources.py
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
@dataclass(frozen=True)
class Sky:
    """A uniform sky background of a given surface brightness.

    The :class:`~getframes.scene.scene.Scene` treats the sky specially: it is added
    by the camera as a uniform background rather than deposited into the rendered
    source map, and is therefore *not* affected by vignetting.

    Parameters
    ----------
    surface_brightness_mag_arcsec2:
        Sky brightness in magnitudes per square arcsecond (fainter = larger).
    sed:
        Optional spectral energy distribution for the sky, used only in spectral
        mode for the sky's effective QE. Defaults to a flat photon spectrum.
    """

    surface_brightness_mag_arcsec2: float
    sed: SED | None = None

getframes.scene.thermal.Thermal dataclass

A graybody thermal background from warm optics/enclosure.

Models the thermal emission seen by the detector as a graybody of emissivity :attr:emissivity at temperature :attr:temperature_k, integrated over the telescope band into a per-pixel photon rate. Attach it to a :class:~getframes.scene.scene.Scene (scene.thermal = Thermal(...)) and it is added as a uniform background by :meth:getframes.Camera.observe, like the sky but dominant in the thermal infrared.

Computing the rate requires the telescope band to carry a spectral response (the graybody is integrated over it).

Parameters:

Name Type Description Default
temperature_k float

Graybody temperature in kelvin (e.g. ~273--293 K for a warm enclosure).

required
emissivity float

Effective emissivity in [0, 1] (the warm optics' grey emission factor).

1.0
Source code in src/getframes/scene/thermal.py
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
@dataclass(frozen=True)
class Thermal:
    """A graybody thermal background from warm optics/enclosure.

    Models the thermal emission seen by the detector as a graybody of emissivity
    :attr:`emissivity` at temperature :attr:`temperature_k`, integrated over the
    telescope band into a per-pixel photon rate. Attach it to a
    :class:`~getframes.scene.scene.Scene` (``scene.thermal = Thermal(...)``) and it
    is added as a uniform background by :meth:`getframes.Camera.observe`, like the
    sky but dominant in the thermal infrared.

    Computing the rate requires the telescope band to carry a spectral
    ``response`` (the graybody is integrated over it).

    Parameters
    ----------
    temperature_k:
        Graybody temperature in kelvin (e.g. ~273--293 K for a warm enclosure).
    emissivity:
        Effective emissivity in ``[0, 1]`` (the warm optics' grey emission factor).
    """

    temperature_k: float
    emissivity: float = 1.0

    def __post_init__(self) -> None:
        if self.temperature_k <= 0:
            raise ValueError("temperature_k must be positive.")
        if not 0.0 <= self.emissivity <= 1.0:
            raise ValueError("emissivity must be in [0, 1].")

    def photon_rate(self, optics: Telescope) -> float:
        """Thermal background in photons/s/pixel reaching the detector through ``optics``.

        ``emissivity * Omega_pixel * A_collect * int L_ph(lambda, T) T_band(lambda)
        dlambda``, with ``Omega_pixel`` the per-pixel solid angle and ``A_collect``
        the collecting area. Requires a band with a spectral response.
        """
        band = optics.band
        if band is None or band.response is None:
            raise ValueError("Thermal.photon_rate requires the telescope band to have a response.")
        resp = band.response.response
        wl_m = resp.wavelength_nm * 1e-9
        integrand = _photon_radiance(wl_m, self.temperature_k) * resp.value
        radiance = float(_trapezoid(integrand, wl_m))  # photons/s/m^2/sr
        omega_sr = (optics.plate_scale_arcsec_per_pixel * _ARCSEC_TO_RAD) ** 2
        return self.emissivity * radiance * optics.collecting_area_m2 * omega_sr

    def photon_sed(
        self,
        wavelength_min_nm: float = 300.0,
        wavelength_max_nm: float = 3000.0,
        n_samples: int = 256,
    ) -> SED:
        """A *relative* SED of the graybody photon spectrum (for spectral effective QE)."""
        wl_nm = np.linspace(wavelength_min_nm, wavelength_max_nm, n_samples)
        radiance = _photon_radiance(wl_nm * 1e-9, self.temperature_k)
        return SED.from_arrays(wl_nm, radiance / radiance.max())

photon_rate(optics)

Thermal background in photons/s/pixel reaching the detector through optics.

emissivity * Omega_pixel * A_collect * int L_ph(lambda, T) T_band(lambda) dlambda, with Omega_pixel the per-pixel solid angle and A_collect the collecting area. Requires a band with a spectral response.

Source code in src/getframes/scene/thermal.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def photon_rate(self, optics: Telescope) -> float:
    """Thermal background in photons/s/pixel reaching the detector through ``optics``.

    ``emissivity * Omega_pixel * A_collect * int L_ph(lambda, T) T_band(lambda)
    dlambda``, with ``Omega_pixel`` the per-pixel solid angle and ``A_collect``
    the collecting area. Requires a band with a spectral response.
    """
    band = optics.band
    if band is None or band.response is None:
        raise ValueError("Thermal.photon_rate requires the telescope band to have a response.")
    resp = band.response.response
    wl_m = resp.wavelength_nm * 1e-9
    integrand = _photon_radiance(wl_m, self.temperature_k) * resp.value
    radiance = float(_trapezoid(integrand, wl_m))  # photons/s/m^2/sr
    omega_sr = (optics.plate_scale_arcsec_per_pixel * _ARCSEC_TO_RAD) ** 2
    return self.emissivity * radiance * optics.collecting_area_m2 * omega_sr

photon_sed(wavelength_min_nm=300.0, wavelength_max_nm=3000.0, n_samples=256)

A relative SED of the graybody photon spectrum (for spectral effective QE).

Source code in src/getframes/scene/thermal.py
102
103
104
105
106
107
108
109
110
111
def photon_sed(
    self,
    wavelength_min_nm: float = 300.0,
    wavelength_max_nm: float = 3000.0,
    n_samples: int = 256,
) -> SED:
    """A *relative* SED of the graybody photon spectrum (for spectral effective QE)."""
    wl_nm = np.linspace(wavelength_min_nm, wavelength_max_nm, n_samples)
    radiance = _photon_radiance(wl_nm * 1e-9, self.temperature_k)
    return SED.from_arrays(wl_nm, radiance / radiance.max())

getframes.scene.optics.Telescope dataclass

An optical system that turns source magnitudes into photon rates at the focal plane.

Parameters:

Name Type Description Default
aperture_diameter_m float

Primary aperture diameter in metres.

required
plate_scale_arcsec_per_pixel float

Angular size of one detector pixel, in arcseconds.

required
throughput float

End-to-end fraction of photons transmitted (optics x filter x atmosphere), in [0, 1].

1.0
central_obstruction float

Diameter of the central obstruction as a fraction of the aperture diameter (e.g. the secondary mirror); 0 for an unobstructed aperture.

0.0
band Bandpass | None

The :class:~getframes.scene.photometry.Bandpass used to convert magnitudes to photon rates. Required only if any source is specified by magnitude (rather than an explicit photon rate).

None
vignetting Vignetting | None

Optional :class:Vignetting illumination falloff applied to the rendered source map (sources only, not the uniform sky).

None
distortion RadialDistortion | None

Optional :class:RadialDistortion displacing source positions about the field centre before they are deposited.

None
Source code in src/getframes/scene/optics.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
@dataclass(frozen=True)
class Telescope:
    """An optical system that turns source magnitudes into photon rates at the focal plane.

    Parameters
    ----------
    aperture_diameter_m:
        Primary aperture diameter in metres.
    plate_scale_arcsec_per_pixel:
        Angular size of one detector pixel, in arcseconds.
    throughput:
        End-to-end fraction of photons transmitted (optics x filter x atmosphere),
        in ``[0, 1]``.
    central_obstruction:
        Diameter of the central obstruction as a fraction of the aperture diameter
        (e.g. the secondary mirror); ``0`` for an unobstructed aperture.
    band:
        The :class:`~getframes.scene.photometry.Bandpass` used to convert
        magnitudes to photon rates. Required only if any source is specified by
        magnitude (rather than an explicit photon rate).
    vignetting:
        Optional :class:`Vignetting` illumination falloff applied to the rendered
        source map (sources only, not the uniform sky).
    distortion:
        Optional :class:`RadialDistortion` displacing source positions about the
        field centre before they are deposited.
    """

    aperture_diameter_m: float
    plate_scale_arcsec_per_pixel: float
    throughput: float = 1.0
    central_obstruction: float = 0.0
    band: Bandpass | None = None
    vignetting: Vignetting | None = None
    distortion: RadialDistortion | None = None

    def __post_init__(self) -> None:
        if self.aperture_diameter_m <= 0:
            raise ValueError("aperture_diameter_m must be positive.")
        if self.plate_scale_arcsec_per_pixel <= 0:
            raise ValueError("plate_scale_arcsec_per_pixel must be positive.")
        if not 0.0 <= self.throughput <= 1.0:
            raise ValueError("throughput must be in [0, 1].")
        if not 0.0 <= self.central_obstruction < 1.0:
            raise ValueError("central_obstruction must be in [0, 1).")

    def illumination_map(self, shape: tuple[int, int]) -> NDArray[np.float64] | None:
        """Relative illumination map for ``shape`` (``None`` if no vignetting set)."""
        if self.vignetting is None:
            return None
        return self.vignetting.illumination_map(shape)

    @classmethod
    def unit(cls, plate_scale_arcsec_per_pixel: float = 1.0) -> Telescope:
        """A trivial 1 m, unit-throughput telescope.

        Handy when you supply source photon rates directly (already at the
        detector) and only need a plate scale --- e.g. AO sub-aperture simulations.
        """
        return cls(
            aperture_diameter_m=1.0,
            plate_scale_arcsec_per_pixel=plate_scale_arcsec_per_pixel,
            throughput=1.0,
        )

    @property
    def collecting_area_m2(self) -> float:
        """Unobstructed collecting area in square metres."""
        d = self.aperture_diameter_m
        return math.pi / 4.0 * (d**2 - (self.central_obstruction * d) ** 2)

    @property
    def pixel_solid_angle_arcsec2(self) -> float:
        """Solid angle subtended by one pixel, in square arcseconds."""
        return self.plate_scale_arcsec_per_pixel**2

    def photon_rate_from_magnitude(self, magnitude: float) -> float:
        """Photons/s reaching the detector from a point source of this magnitude."""
        if self.band is None:
            raise ValueError(
                "Telescope.band is required to use magnitudes; set a Bandpass or "
                "specify sources by photon_rate instead."
            )
        return self.band.photon_flux(magnitude) * self.collecting_area_m2 * self.throughput

    def photon_rate_from_sed(self, sed: SED) -> float:
        """Photons/s at the detector from a source described by an *absolute* SED.

        Integrates the SED over the band's spectral response
        (:meth:`~getframes.scene.photometry.Bandpass.photon_flux_from_sed`) and
        scales by collecting area and throughput --- the spectral-flux-integration
        counterpart of :meth:`photon_rate_from_magnitude`. Requires a band with a
        spectral response and an absolute SED
        (:meth:`getframes.spectral.SED.from_flux_density`).
        """
        if self.band is None:
            raise ValueError(
                "Telescope.band is required to integrate an SED; set a Bandpass with "
                "a spectral response."
            )
        return self.band.photon_flux_from_sed(sed) * self.collecting_area_m2 * self.throughput

    def surface_brightness_photon_rate(self, surface_brightness_mag_arcsec2: float) -> float:
        """Photons/s/pixel from a uniform sky of the given surface brightness."""
        per_arcsec2 = self.photon_rate_from_magnitude(surface_brightness_mag_arcsec2)
        return per_arcsec2 * self.pixel_solid_angle_arcsec2

collecting_area_m2 property

Unobstructed collecting area in square metres.

pixel_solid_angle_arcsec2 property

Solid angle subtended by one pixel, in square arcseconds.

illumination_map(shape)

Relative illumination map for shape (None if no vignetting set).

Source code in src/getframes/scene/optics.py
121
122
123
124
125
def illumination_map(self, shape: tuple[int, int]) -> NDArray[np.float64] | None:
    """Relative illumination map for ``shape`` (``None`` if no vignetting set)."""
    if self.vignetting is None:
        return None
    return self.vignetting.illumination_map(shape)

unit(plate_scale_arcsec_per_pixel=1.0) classmethod

A trivial 1 m, unit-throughput telescope.

Handy when you supply source photon rates directly (already at the detector) and only need a plate scale --- e.g. AO sub-aperture simulations.

Source code in src/getframes/scene/optics.py
127
128
129
130
131
132
133
134
135
136
137
138
@classmethod
def unit(cls, plate_scale_arcsec_per_pixel: float = 1.0) -> Telescope:
    """A trivial 1 m, unit-throughput telescope.

    Handy when you supply source photon rates directly (already at the
    detector) and only need a plate scale --- e.g. AO sub-aperture simulations.
    """
    return cls(
        aperture_diameter_m=1.0,
        plate_scale_arcsec_per_pixel=plate_scale_arcsec_per_pixel,
        throughput=1.0,
    )

photon_rate_from_magnitude(magnitude)

Photons/s reaching the detector from a point source of this magnitude.

Source code in src/getframes/scene/optics.py
151
152
153
154
155
156
157
158
def photon_rate_from_magnitude(self, magnitude: float) -> float:
    """Photons/s reaching the detector from a point source of this magnitude."""
    if self.band is None:
        raise ValueError(
            "Telescope.band is required to use magnitudes; set a Bandpass or "
            "specify sources by photon_rate instead."
        )
    return self.band.photon_flux(magnitude) * self.collecting_area_m2 * self.throughput

photon_rate_from_sed(sed)

Photons/s at the detector from a source described by an absolute SED.

Integrates the SED over the band's spectral response (:meth:~getframes.scene.photometry.Bandpass.photon_flux_from_sed) and scales by collecting area and throughput --- the spectral-flux-integration counterpart of :meth:photon_rate_from_magnitude. Requires a band with a spectral response and an absolute SED (:meth:getframes.spectral.SED.from_flux_density).

Source code in src/getframes/scene/optics.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
def photon_rate_from_sed(self, sed: SED) -> float:
    """Photons/s at the detector from a source described by an *absolute* SED.

    Integrates the SED over the band's spectral response
    (:meth:`~getframes.scene.photometry.Bandpass.photon_flux_from_sed`) and
    scales by collecting area and throughput --- the spectral-flux-integration
    counterpart of :meth:`photon_rate_from_magnitude`. Requires a band with a
    spectral response and an absolute SED
    (:meth:`getframes.spectral.SED.from_flux_density`).
    """
    if self.band is None:
        raise ValueError(
            "Telescope.band is required to integrate an SED; set a Bandpass with "
            "a spectral response."
        )
    return self.band.photon_flux_from_sed(sed) * self.collecting_area_m2 * self.throughput

surface_brightness_photon_rate(surface_brightness_mag_arcsec2)

Photons/s/pixel from a uniform sky of the given surface brightness.

Source code in src/getframes/scene/optics.py
177
178
179
180
def surface_brightness_photon_rate(self, surface_brightness_mag_arcsec2: float) -> float:
    """Photons/s/pixel from a uniform sky of the given surface brightness."""
    per_arcsec2 = self.photon_rate_from_magnitude(surface_brightness_mag_arcsec2)
    return per_arcsec2 * self.pixel_solid_angle_arcsec2

getframes.scene.optics.Vignetting dataclass

A radial illumination falloff toward the edges of the field.

Relative illumination is 1 - strength * (r / r_corner)^power, where r is the distance from the optical centre and r_corner is the distance to the farthest corner. strength is the fractional light loss at that corner; power=2 gives a gentle quadratic roll-off (power=4 approximates the cos^4 law). The map is clipped to [0, 1].

Source code in src/getframes/scene/optics.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@dataclass(frozen=True)
class Vignetting:
    """A radial illumination falloff toward the edges of the field.

    Relative illumination is ``1 - strength * (r / r_corner)^power``, where ``r`` is
    the distance from the optical centre and ``r_corner`` is the distance to the
    farthest corner. ``strength`` is the fractional light loss at that corner;
    ``power=2`` gives a gentle quadratic roll-off (``power=4`` approximates the cos^4
    law). The map is clipped to ``[0, 1]``.
    """

    strength: float
    power: float = 2.0

    def __post_init__(self) -> None:
        if not 0.0 <= self.strength <= 1.0:
            raise ValueError("vignetting strength must be in [0, 1].")
        if self.power <= 0:
            raise ValueError("vignetting power must be positive.")

    def illumination_map(self, shape: tuple[int, int]) -> NDArray[np.float64]:
        """Relative illumination in ``[0, 1]`` for a frame of ``(height, width)``."""
        height, width = shape
        cy, cx = (height - 1) / 2.0, (width - 1) / 2.0
        yy, xx = np.mgrid[0:height, 0:width]
        r = np.hypot(xx - cx, yy - cy)
        r_corner = math.hypot(max(cx, width - 1 - cx), max(cy, height - 1 - cy))
        if r_corner == 0:
            return np.ones(shape, dtype=np.float64)
        rel = 1.0 - self.strength * (r / r_corner) ** self.power
        clipped: NDArray[np.float64] = np.clip(rel, 0.0, 1.0).astype(np.float64)
        return clipped

illumination_map(shape)

Relative illumination in [0, 1] for a frame of (height, width).

Source code in src/getframes/scene/optics.py
40
41
42
43
44
45
46
47
48
49
50
51
def illumination_map(self, shape: tuple[int, int]) -> NDArray[np.float64]:
    """Relative illumination in ``[0, 1]`` for a frame of ``(height, width)``."""
    height, width = shape
    cy, cx = (height - 1) / 2.0, (width - 1) / 2.0
    yy, xx = np.mgrid[0:height, 0:width]
    r = np.hypot(xx - cx, yy - cy)
    r_corner = math.hypot(max(cx, width - 1 - cx), max(cy, height - 1 - cy))
    if r_corner == 0:
        return np.ones(shape, dtype=np.float64)
    rel = 1.0 - self.strength * (r / r_corner) ** self.power
    clipped: NDArray[np.float64] = np.clip(rel, 0.0, 1.0).astype(np.float64)
    return clipped

getframes.scene.optics.RadialDistortion dataclass

A simple radial (barrel/pincushion) distortion about the field centre.

A source at pixel distance r from the centre is displaced to r * (1 + k1 r^2 + k2 r^4). k1 < 0 gives barrel distortion, k1 > 0 pincushion; both coefficients carry inverse-pixel-power units (k1 is small, e.g. 1e-7 per pixel^2 for a 2k detector).

Source code in src/getframes/scene/optics.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
@dataclass(frozen=True)
class RadialDistortion:
    """A simple radial (barrel/pincushion) distortion about the field centre.

    A source at pixel distance ``r`` from the centre is displaced to
    ``r * (1 + k1 r^2 + k2 r^4)``. ``k1 < 0`` gives barrel distortion, ``k1 > 0``
    pincushion; both coefficients carry inverse-pixel-power units (``k1`` is small,
    e.g. ``1e-7`` per pixel^2 for a 2k detector).
    """

    k1: float
    k2: float = 0.0

    def apply(self, x: float, y: float, cx: float, cy: float) -> tuple[float, float]:
        """Map pixel ``(x, y)`` to its distorted position about centre ``(cx, cy)``."""
        dx, dy = x - cx, y - cy
        r2 = dx * dx + dy * dy
        factor = 1.0 + self.k1 * r2 + self.k2 * r2 * r2
        return cx + dx * factor, cy + dy * factor

apply(x, y, cx, cy)

Map pixel (x, y) to its distorted position about centre (cx, cy).

Source code in src/getframes/scene/optics.py
67
68
69
70
71
72
def apply(self, x: float, y: float, cx: float, cy: float) -> tuple[float, float]:
    """Map pixel ``(x, y)`` to its distorted position about centre ``(cx, cy)``."""
    dx, dy = x - cx, y - cy
    r2 = dx * dx + dy * dy
    factor = 1.0 + self.k1 * r2 + self.k2 * r2 * r2
    return cx + dx * factor, cy + dy * factor

getframes.scene.photometry.Bandpass dataclass

A photometric band, summarised by its photon zero point.

Parameters:

Name Type Description Default
name str

Human-readable label, e.g. "Johnson V".

required
photon_zeropoint float

Photons per second per square metre, above the atmosphere, from a magnitude-0 source integrated over the band.

required
response SpectralBandpass | None

Optional spectral transmission curve for the band. Enables spectral mode (colour-dependent effective QE); None keeps the band-integrated model.

None
Source code in src/getframes/scene/photometry.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
@dataclass(frozen=True)
class Bandpass:
    """A photometric band, summarised by its photon zero point.

    Parameters
    ----------
    name:
        Human-readable label, e.g. ``"Johnson V"``.
    photon_zeropoint:
        Photons per second per square metre, above the atmosphere, from a
        magnitude-0 source integrated over the band.
    response:
        Optional spectral transmission curve for the band. Enables spectral mode
        (colour-dependent effective QE); ``None`` keeps the band-integrated model.
    """

    name: str
    photon_zeropoint: float
    response: SpectralBandpass | None = None

    def __post_init__(self) -> None:
        if self.photon_zeropoint <= 0:
            raise ValueError("photon_zeropoint must be positive.")

    @classmethod
    def johnson(cls, band: str, *, spectral: bool = True) -> Bandpass:
        """Return a standard Johnson-Cousins band (one of U, B, V, R, I).

        By default the band also carries a tophat spectral ``response`` so spectral
        mode works out of the box; pass ``spectral=False`` for the bare zero point.
        """
        key = band.strip().upper()
        if key not in _JOHNSON_PHOTON_ZEROPOINTS:
            valid = ", ".join(_JOHNSON_PHOTON_ZEROPOINTS)
            raise ValueError(f"Unknown Johnson band {band!r}. Expected one of: {valid}.")
        response = SpectralBandpass.johnson(key) if spectral else None
        return cls(
            name=f"Johnson {key}",
            photon_zeropoint=_JOHNSON_PHOTON_ZEROPOINTS[key],
            response=response,
        )

    @classmethod
    def ab(cls, band: str) -> Bandpass:
        """Return an **AB-system** band for a common survey filter.

        The AB system references every band to a flat :math:`f_\\nu = 3631`
        Jy source, so the zero point is *computed* from the band's transmission
        shape (see :func:`_ab_photon_zeropoint`) rather than tabulated. Supported
        ``band`` names (case-insensitive): SDSS ``u g r i z``, Gaia
        ``gaia_g gaia_bp gaia_rp`` (also ``G BP RP``), and 2MASS ``J H Ks``. Each
        carries a tophat spectral response, so spectral mode works out of the box;
        supply a measured curve via :meth:`SpectralBandpass.from_file` for rigour.

        Gaia bands are ``gaia_g``, ``gaia_bp``, ``gaia_rp`` (``bp``/``rp`` also
        accepted); ``g`` is SDSS g. Use :meth:`johnson` for the Vega system instead.
        """
        key = _canonical_band(band)
        if key not in _AB_BANDS:
            valid = ", ".join(sorted(_AB_BANDS))
            raise ValueError(f"Unknown AB band {band!r}. Expected one of: {valid}.")
        label, center, width = _AB_BANDS[key]
        response = SpectralBandpass.tophat(center, width)
        return cls(
            name=f"AB {label}",
            photon_zeropoint=_ab_photon_zeropoint(response),
            response=response,
        )

    def photon_flux(self, magnitude: float) -> float:
        """Photons/s/m^2 above the atmosphere for a source of the given magnitude."""
        return float(self.photon_zeropoint * 10.0 ** (-0.4 * magnitude))

    def photon_flux_from_sed(self, sed: SED) -> float:
        """Photons/s/m^2 above the atmosphere from an *absolute* SED through this band.

        Integrates ``int S(lambda) T(lambda) dlambda`` over the band's spectral
        response, where ``S`` is the absolute photon flux density
        (``photons/s/m^2/nm``) of an SED built with
        :meth:`getframes.spectral.SED.from_flux_density`. This is the "true spectral
        flux integration" path: the spectrum itself sets the rate, rather than a
        magnitude. Requires a spectral :attr:`response`.
        """
        if self.response is None:
            raise ValueError(
                f"Bandpass {self.name!r} has no spectral response; "
                "photon_flux_from_sed needs one to integrate the SED over the band."
            )
        if not sed.is_absolute:
            raise ValueError(
                "photon_flux_from_sed needs an absolute SED (build it with SED.from_flux_density)."
            )
        return float(overlap_integral(sed, self.response.response))

    def effective_qe(self, qe: QE, sed: SED | None = None) -> float:
        """Photon-weighted effective QE for a source of SED ``sed`` seen through this band.

        Requires a spectral :attr:`response`. ``sed`` defaults to a flat photon
        spectrum (the bandpass-weighted mean QE). See
        :func:`getframes.spectral.effective_qe`.
        """
        if self.response is None:
            raise ValueError(
                f"Bandpass {self.name!r} has no spectral response; "
                "construct it with a response to use spectral mode."
            )
        return effective_qe(qe, self.response, sed)

johnson(band, *, spectral=True) classmethod

Return a standard Johnson-Cousins band (one of U, B, V, R, I).

By default the band also carries a tophat spectral response so spectral mode works out of the box; pass spectral=False for the bare zero point.

Source code in src/getframes/scene/photometry.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
@classmethod
def johnson(cls, band: str, *, spectral: bool = True) -> Bandpass:
    """Return a standard Johnson-Cousins band (one of U, B, V, R, I).

    By default the band also carries a tophat spectral ``response`` so spectral
    mode works out of the box; pass ``spectral=False`` for the bare zero point.
    """
    key = band.strip().upper()
    if key not in _JOHNSON_PHOTON_ZEROPOINTS:
        valid = ", ".join(_JOHNSON_PHOTON_ZEROPOINTS)
        raise ValueError(f"Unknown Johnson band {band!r}. Expected one of: {valid}.")
    response = SpectralBandpass.johnson(key) if spectral else None
    return cls(
        name=f"Johnson {key}",
        photon_zeropoint=_JOHNSON_PHOTON_ZEROPOINTS[key],
        response=response,
    )

ab(band) classmethod

Return an AB-system band for a common survey filter.

The AB system references every band to a flat :math:f_\nu = 3631 Jy source, so the zero point is computed from the band's transmission shape (see :func:_ab_photon_zeropoint) rather than tabulated. Supported band names (case-insensitive): SDSS u g r i z, Gaia gaia_g gaia_bp gaia_rp (also G BP RP), and 2MASS J H Ks. Each carries a tophat spectral response, so spectral mode works out of the box; supply a measured curve via :meth:SpectralBandpass.from_file for rigour.

Gaia bands are gaia_g, gaia_bp, gaia_rp (bp/rp also accepted); g is SDSS g. Use :meth:johnson for the Vega system instead.

Source code in src/getframes/scene/photometry.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
@classmethod
def ab(cls, band: str) -> Bandpass:
    """Return an **AB-system** band for a common survey filter.

    The AB system references every band to a flat :math:`f_\\nu = 3631`
    Jy source, so the zero point is *computed* from the band's transmission
    shape (see :func:`_ab_photon_zeropoint`) rather than tabulated. Supported
    ``band`` names (case-insensitive): SDSS ``u g r i z``, Gaia
    ``gaia_g gaia_bp gaia_rp`` (also ``G BP RP``), and 2MASS ``J H Ks``. Each
    carries a tophat spectral response, so spectral mode works out of the box;
    supply a measured curve via :meth:`SpectralBandpass.from_file` for rigour.

    Gaia bands are ``gaia_g``, ``gaia_bp``, ``gaia_rp`` (``bp``/``rp`` also
    accepted); ``g`` is SDSS g. Use :meth:`johnson` for the Vega system instead.
    """
    key = _canonical_band(band)
    if key not in _AB_BANDS:
        valid = ", ".join(sorted(_AB_BANDS))
        raise ValueError(f"Unknown AB band {band!r}. Expected one of: {valid}.")
    label, center, width = _AB_BANDS[key]
    response = SpectralBandpass.tophat(center, width)
    return cls(
        name=f"AB {label}",
        photon_zeropoint=_ab_photon_zeropoint(response),
        response=response,
    )

photon_flux(magnitude)

Photons/s/m^2 above the atmosphere for a source of the given magnitude.

Source code in src/getframes/scene/photometry.py
154
155
156
def photon_flux(self, magnitude: float) -> float:
    """Photons/s/m^2 above the atmosphere for a source of the given magnitude."""
    return float(self.photon_zeropoint * 10.0 ** (-0.4 * magnitude))

photon_flux_from_sed(sed)

Photons/s/m^2 above the atmosphere from an absolute SED through this band.

Integrates int S(lambda) T(lambda) dlambda over the band's spectral response, where S is the absolute photon flux density (photons/s/m^2/nm) of an SED built with :meth:getframes.spectral.SED.from_flux_density. This is the "true spectral flux integration" path: the spectrum itself sets the rate, rather than a magnitude. Requires a spectral :attr:response.

Source code in src/getframes/scene/photometry.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def photon_flux_from_sed(self, sed: SED) -> float:
    """Photons/s/m^2 above the atmosphere from an *absolute* SED through this band.

    Integrates ``int S(lambda) T(lambda) dlambda`` over the band's spectral
    response, where ``S`` is the absolute photon flux density
    (``photons/s/m^2/nm``) of an SED built with
    :meth:`getframes.spectral.SED.from_flux_density`. This is the "true spectral
    flux integration" path: the spectrum itself sets the rate, rather than a
    magnitude. Requires a spectral :attr:`response`.
    """
    if self.response is None:
        raise ValueError(
            f"Bandpass {self.name!r} has no spectral response; "
            "photon_flux_from_sed needs one to integrate the SED over the band."
        )
    if not sed.is_absolute:
        raise ValueError(
            "photon_flux_from_sed needs an absolute SED (build it with SED.from_flux_density)."
        )
    return float(overlap_integral(sed, self.response.response))

effective_qe(qe, sed=None)

Photon-weighted effective QE for a source of SED sed seen through this band.

Requires a spectral :attr:response. sed defaults to a flat photon spectrum (the bandpass-weighted mean QE). See :func:getframes.spectral.effective_qe.

Source code in src/getframes/scene/photometry.py
179
180
181
182
183
184
185
186
187
188
189
190
191
def effective_qe(self, qe: QE, sed: SED | None = None) -> float:
    """Photon-weighted effective QE for a source of SED ``sed`` seen through this band.

    Requires a spectral :attr:`response`. ``sed`` defaults to a flat photon
    spectrum (the bandpass-weighted mean QE). See
    :func:`getframes.spectral.effective_qe`.
    """
    if self.response is None:
        raise ValueError(
            f"Bandpass {self.name!r} has no spectral response; "
            "construct it with a response to use spectral mode."
        )
    return effective_qe(qe, self.response, sed)

getframes.scene.photometry.Extinction dataclass

Interstellar extinction (reddening) by intervening dust.

A Cardelli, Clayton & Mathis (1989) extinction curve, parameterised by the visual extinction a_v (magnitudes of attenuation in V) and the total-to- selective ratio r_v (3.1 for the diffuse Galactic ISM). It dims and reddens a source: redder dust passes more light, so a blue source is attenuated more.

Use :meth:transmission for the wavelength-dependent throughput 10**(-0.4 A(lambda)), :meth:redden to apply it to an :class:~getframes.spectral.SED, or :meth:band_attenuation_mag for the band-integrated magnitude shift to add to a source magnitude.

Parameters:

Name Type Description Default
a_v float

Visual extinction A_V in magnitudes (non-negative).

required
r_v float

Total-to-selective extinction ratio R_V = A_V / E(B-V) (default 3.1).

3.1
Source code in src/getframes/scene/photometry.py
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
@dataclass(frozen=True)
class Extinction:
    """Interstellar extinction (reddening) by intervening dust.

    A Cardelli, Clayton & Mathis (1989) extinction curve, parameterised by the
    visual extinction ``a_v`` (magnitudes of attenuation in V) and the total-to-
    selective ratio ``r_v`` (3.1 for the diffuse Galactic ISM). It dims and reddens a
    source: redder dust passes more light, so a blue source is attenuated more.

    Use :meth:`transmission` for the wavelength-dependent throughput
    ``10**(-0.4 A(lambda))``, :meth:`redden` to apply it to an
    :class:`~getframes.spectral.SED`, or :meth:`band_attenuation_mag` for the
    band-integrated magnitude shift to add to a source magnitude.

    Parameters
    ----------
    a_v:
        Visual extinction ``A_V`` in magnitudes (non-negative).
    r_v:
        Total-to-selective extinction ratio ``R_V = A_V / E(B-V)`` (default 3.1).
    """

    a_v: float
    r_v: float = 3.1

    def __post_init__(self) -> None:
        if self.a_v < 0:
            raise ValueError("a_v must be non-negative.")
        if self.r_v <= 0:
            raise ValueError("r_v must be positive.")

    def attenuation_mag(self, wavelength_nm: ArrayLike) -> NDArray[np.float64]:
        """Extinction ``A(lambda)`` in magnitudes at each wavelength (nm).

        Wavelengths outside the CCM89 range (~303--3333 nm) are clamped to the
        nearest valid value.
        """
        wl_um = np.asarray(wavelength_nm, dtype=np.float64) / 1000.0
        x = np.clip(1.0 / wl_um, 0.3, 3.3)
        a, b = _ccm89_ab(x)
        return self.a_v * (a + b / self.r_v)

    def transmission(self, wavelength_nm: ArrayLike) -> NDArray[np.float64]:
        """Fractional transmission ``10**(-0.4 A(lambda))`` at each wavelength (nm)."""
        return np.asarray(10.0 ** (-0.4 * self.attenuation_mag(wavelength_nm)), dtype=np.float64)

    def transmission_curve(self, wavelength_nm: ArrayLike) -> Spectrum:
        """The transmission as a :class:`~getframes.spectral.Spectrum` (for :func:`product`)."""
        wl = np.asarray(wavelength_nm, dtype=np.float64)
        return Spectrum(wl, self.transmission(wl))

    def redden(self, sed: SED) -> SED:
        """Apply extinction to ``sed``, returning a reddened copy (units preserved)."""
        reddened = sed.value * self.transmission(sed.wavelength_nm)
        return SED(sed.wavelength_nm.copy(), reddened, is_absolute=sed.is_absolute)

    def band_attenuation_mag(self, band: Bandpass, sed: SED | None = None) -> float:
        """Band-integrated extinction in magnitudes through ``band`` for a source ``sed``.

        The photon-weighted mean attenuation,
        ``-2.5 log10(int S T 10^{-0.4 A} dl / int S T dl)``, evaluated on the band's
        response grid. ``sed`` defaults to a flat photon spectrum. Add the result to
        a source magnitude to dim it by the dust column. Requires a spectral
        :attr:`~Bandpass.response`.
        """
        if band.response is None:
            raise ValueError("band_attenuation_mag requires a band with a spectral response.")
        wl = band.response.response.wavelength_nm
        weight = band.response.response.value.astype(np.float64)
        if sed is not None:
            weight = weight * sed(wl)
        denom = float(_trapezoid(weight, wl))
        if denom <= 0:
            raise ValueError("band response (times SED) integrates to zero; cannot weight.")
        numer = float(_trapezoid(weight * self.transmission(wl), wl))
        return float(-2.5 * np.log10(numer / denom))

attenuation_mag(wavelength_nm)

Extinction A(lambda) in magnitudes at each wavelength (nm).

Wavelengths outside the CCM89 range (~303--3333 nm) are clamped to the nearest valid value.

Source code in src/getframes/scene/photometry.py
267
268
269
270
271
272
273
274
275
276
def attenuation_mag(self, wavelength_nm: ArrayLike) -> NDArray[np.float64]:
    """Extinction ``A(lambda)`` in magnitudes at each wavelength (nm).

    Wavelengths outside the CCM89 range (~303--3333 nm) are clamped to the
    nearest valid value.
    """
    wl_um = np.asarray(wavelength_nm, dtype=np.float64) / 1000.0
    x = np.clip(1.0 / wl_um, 0.3, 3.3)
    a, b = _ccm89_ab(x)
    return self.a_v * (a + b / self.r_v)

transmission(wavelength_nm)

Fractional transmission 10**(-0.4 A(lambda)) at each wavelength (nm).

Source code in src/getframes/scene/photometry.py
278
279
280
def transmission(self, wavelength_nm: ArrayLike) -> NDArray[np.float64]:
    """Fractional transmission ``10**(-0.4 A(lambda))`` at each wavelength (nm)."""
    return np.asarray(10.0 ** (-0.4 * self.attenuation_mag(wavelength_nm)), dtype=np.float64)

transmission_curve(wavelength_nm)

The transmission as a :class:~getframes.spectral.Spectrum (for :func:product).

Source code in src/getframes/scene/photometry.py
282
283
284
285
def transmission_curve(self, wavelength_nm: ArrayLike) -> Spectrum:
    """The transmission as a :class:`~getframes.spectral.Spectrum` (for :func:`product`)."""
    wl = np.asarray(wavelength_nm, dtype=np.float64)
    return Spectrum(wl, self.transmission(wl))

redden(sed)

Apply extinction to sed, returning a reddened copy (units preserved).

Source code in src/getframes/scene/photometry.py
287
288
289
290
def redden(self, sed: SED) -> SED:
    """Apply extinction to ``sed``, returning a reddened copy (units preserved)."""
    reddened = sed.value * self.transmission(sed.wavelength_nm)
    return SED(sed.wavelength_nm.copy(), reddened, is_absolute=sed.is_absolute)

band_attenuation_mag(band, sed=None)

Band-integrated extinction in magnitudes through band for a source sed.

The photon-weighted mean attenuation, -2.5 log10(int S T 10^{-0.4 A} dl / int S T dl), evaluated on the band's response grid. sed defaults to a flat photon spectrum. Add the result to a source magnitude to dim it by the dust column. Requires a spectral :attr:~Bandpass.response.

Source code in src/getframes/scene/photometry.py
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
def band_attenuation_mag(self, band: Bandpass, sed: SED | None = None) -> float:
    """Band-integrated extinction in magnitudes through ``band`` for a source ``sed``.

    The photon-weighted mean attenuation,
    ``-2.5 log10(int S T 10^{-0.4 A} dl / int S T dl)``, evaluated on the band's
    response grid. ``sed`` defaults to a flat photon spectrum. Add the result to
    a source magnitude to dim it by the dust column. Requires a spectral
    :attr:`~Bandpass.response`.
    """
    if band.response is None:
        raise ValueError("band_attenuation_mag requires a band with a spectral response.")
    wl = band.response.response.wavelength_nm
    weight = band.response.response.value.astype(np.float64)
    if sed is not None:
        weight = weight * sed(wl)
    denom = float(_trapezoid(weight, wl))
    if denom <= 0:
        raise ValueError("band response (times SED) integrates to zero; cannot weight.")
    numer = float(_trapezoid(weight * self.transmission(wl), wl))
    return float(-2.5 * np.log10(numer / denom))

getframes.scene.wcs.WCSInfo dataclass

A tangent-plane (TAN) world coordinate system for a detector frame.

Parameters:

Name Type Description Default
crval_ra_deg float

Sky coordinates (degrees) of the reference point.

required
crval_dec_deg float

Sky coordinates (degrees) of the reference point.

required
crpix_x float

Pixel coordinates of the reference point, in 0-based array convention (crpix_x is the column, crpix_y the row). They are written to the FITS header in the 1-based convention the standard requires.

required
crpix_y float

Pixel coordinates of the reference point, in 0-based array convention (crpix_x is the column, crpix_y the row). They are written to the FITS header in the 1-based convention the standard requires.

required
plate_scale_arcsec_per_pixel float

Angular pixel size, matching the telescope's plate scale.

required
rotation_deg float

Position angle of the y-axis east of north, in degrees (0 puts north up and east left, the usual astronomical orientation).

0.0
Source code in src/getframes/scene/wcs.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
@dataclass(frozen=True)
class WCSInfo:
    """A tangent-plane (TAN) world coordinate system for a detector frame.

    Parameters
    ----------
    crval_ra_deg, crval_dec_deg:
        Sky coordinates (degrees) of the reference point.
    crpix_x, crpix_y:
        Pixel coordinates of the reference point, in 0-based array convention
        (``crpix_x`` is the column, ``crpix_y`` the row). They are written to the
        FITS header in the 1-based convention the standard requires.
    plate_scale_arcsec_per_pixel:
        Angular pixel size, matching the telescope's plate scale.
    rotation_deg:
        Position angle of the y-axis east of north, in degrees (``0`` puts north up
        and east left, the usual astronomical orientation).
    """

    crval_ra_deg: float
    crval_dec_deg: float
    crpix_x: float
    crpix_y: float
    plate_scale_arcsec_per_pixel: float
    rotation_deg: float = 0.0

    def __post_init__(self) -> None:
        if self.plate_scale_arcsec_per_pixel <= 0:
            raise ValueError("plate_scale_arcsec_per_pixel must be positive.")
        if not -90.0 <= self.crval_dec_deg <= 90.0:
            raise ValueError("crval_dec_deg must be in [-90, 90].")

    @property
    def _cdelt_deg(self) -> float:
        return self.plate_scale_arcsec_per_pixel / 3600.0

    def header_cards(self) -> dict[str, Any]:
        """FITS WCS header cards for a TAN projection (8-char keywords, no astropy).

        RA increases to the left (east), so ``CD1_1`` carries the sign flip. The
        rotation is folded into the CD matrix.
        """
        cd = self._cdelt_deg
        theta = math.radians(self.rotation_deg)
        cos_t, sin_t = math.cos(theta), math.sin(theta)
        # RA runs east (to smaller pixel-x for north-up), hence the leading minus.
        return {
            "CTYPE1": "RA---TAN",
            "CTYPE2": "DEC--TAN",
            "CUNIT1": "deg",
            "CUNIT2": "deg",
            "CRVAL1": float(self.crval_ra_deg),
            "CRVAL2": float(self.crval_dec_deg),
            "CRPIX1": float(self.crpix_x) + 1.0,
            "CRPIX2": float(self.crpix_y) + 1.0,
            "CD1_1": -cd * cos_t,
            "CD1_2": cd * sin_t,
            "CD2_1": cd * sin_t,
            "CD2_2": cd * cos_t,
        }

    def to_astropy(self) -> WCS:
        """Build an :class:`astropy.wcs.WCS` (requires ``astropy``)."""
        try:
            from astropy.wcs import WCS
        except ImportError as exc:  # pragma: no cover - astropy is a core dependency
            raise ImportError(
                "WCS pixel/world conversion requires astropy (a core dependency of "
                "getframes); reinstall with: pip install getframes"
            ) from exc
        wcs = WCS(naxis=2)
        cards = self.header_cards()
        wcs.wcs.ctype = [cards["CTYPE1"], cards["CTYPE2"]]
        wcs.wcs.crval = [cards["CRVAL1"], cards["CRVAL2"]]
        wcs.wcs.crpix = [cards["CRPIX1"], cards["CRPIX2"]]
        wcs.wcs.cd = [[cards["CD1_1"], cards["CD1_2"]], [cards["CD2_1"], cards["CD2_2"]]]
        return wcs

    def pixel_to_world(self, x: float, y: float) -> tuple[float, float]:
        """Convert a 0-based pixel ``(x, y)`` to ``(ra_deg, dec_deg)`` (needs astropy)."""
        ra, dec = self.to_astropy().all_pix2world([[x, y]], 0)[0]
        return float(ra), float(dec)

    def world_to_pixel(self, ra_deg: float, dec_deg: float) -> tuple[float, float]:
        """Convert ``(ra_deg, dec_deg)`` to a 0-based pixel ``(x, y)`` (needs astropy)."""
        x, y = self.to_astropy().all_world2pix([[ra_deg, dec_deg]], 0)[0]
        return float(x), float(y)

header_cards()

FITS WCS header cards for a TAN projection (8-char keywords, no astropy).

RA increases to the left (east), so CD1_1 carries the sign flip. The rotation is folded into the CD matrix.

Source code in src/getframes/scene/wcs.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def header_cards(self) -> dict[str, Any]:
    """FITS WCS header cards for a TAN projection (8-char keywords, no astropy).

    RA increases to the left (east), so ``CD1_1`` carries the sign flip. The
    rotation is folded into the CD matrix.
    """
    cd = self._cdelt_deg
    theta = math.radians(self.rotation_deg)
    cos_t, sin_t = math.cos(theta), math.sin(theta)
    # RA runs east (to smaller pixel-x for north-up), hence the leading minus.
    return {
        "CTYPE1": "RA---TAN",
        "CTYPE2": "DEC--TAN",
        "CUNIT1": "deg",
        "CUNIT2": "deg",
        "CRVAL1": float(self.crval_ra_deg),
        "CRVAL2": float(self.crval_dec_deg),
        "CRPIX1": float(self.crpix_x) + 1.0,
        "CRPIX2": float(self.crpix_y) + 1.0,
        "CD1_1": -cd * cos_t,
        "CD1_2": cd * sin_t,
        "CD2_1": cd * sin_t,
        "CD2_2": cd * cos_t,
    }

to_astropy()

Build an :class:astropy.wcs.WCS (requires astropy).

Source code in src/getframes/scene/wcs.py
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def to_astropy(self) -> WCS:
    """Build an :class:`astropy.wcs.WCS` (requires ``astropy``)."""
    try:
        from astropy.wcs import WCS
    except ImportError as exc:  # pragma: no cover - astropy is a core dependency
        raise ImportError(
            "WCS pixel/world conversion requires astropy (a core dependency of "
            "getframes); reinstall with: pip install getframes"
        ) from exc
    wcs = WCS(naxis=2)
    cards = self.header_cards()
    wcs.wcs.ctype = [cards["CTYPE1"], cards["CTYPE2"]]
    wcs.wcs.crval = [cards["CRVAL1"], cards["CRVAL2"]]
    wcs.wcs.crpix = [cards["CRPIX1"], cards["CRPIX2"]]
    wcs.wcs.cd = [[cards["CD1_1"], cards["CD1_2"]], [cards["CD2_1"], cards["CD2_2"]]]
    return wcs

pixel_to_world(x, y)

Convert a 0-based pixel (x, y) to (ra_deg, dec_deg) (needs astropy).

Source code in src/getframes/scene/wcs.py
 99
100
101
102
def pixel_to_world(self, x: float, y: float) -> tuple[float, float]:
    """Convert a 0-based pixel ``(x, y)`` to ``(ra_deg, dec_deg)`` (needs astropy)."""
    ra, dec = self.to_astropy().all_pix2world([[x, y]], 0)[0]
    return float(ra), float(dec)

world_to_pixel(ra_deg, dec_deg)

Convert (ra_deg, dec_deg) to a 0-based pixel (x, y) (needs astropy).

Source code in src/getframes/scene/wcs.py
104
105
106
107
def world_to_pixel(self, ra_deg: float, dec_deg: float) -> tuple[float, float]:
    """Convert ``(ra_deg, dec_deg)`` to a 0-based pixel ``(x, y)`` (needs astropy)."""
    x, y = self.to_astropy().all_world2pix([[ra_deg, dec_deg]], 0)[0]
    return float(x), float(y)

getframes.scene.psf

Point-spread functions: how a point source's flux is spread over pixels.

Each PSF knows how to add a source of a given total flux at a sub-pixel position into an image, conserving flux. Models are evaluated on a small stamp around the source for efficiency. The Gaussian uses the exact per-pixel integral (via the error function) so it is flux-conserving to machine precision; the Moffat is sampled on a stamp and normalised.

PSF

Base class for point-spread functions.

Source code in src/getframes/scene/psf.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
class PSF:
    """Base class for point-spread functions."""

    def add_source(
        self,
        image: NDArray[np.float64],
        x: float,
        y: float,
        flux: float,
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        """Add ``flux`` photons/s of a point source at sub-pixel ``(x, y)`` into ``image``."""
        raise NotImplementedError

    def add_sources(
        self,
        image: NDArray[np.float64],
        xs: NDArray[np.float64],
        ys: NDArray[np.float64],
        fluxes: NDArray[np.float64],
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        """Add many point sources at once (vectorised where the PSF supports it).

        ``xs``, ``ys``, ``fluxes`` are equal-length 1-D arrays of sub-pixel column,
        row, and total flux. The generic implementation loops over
        :meth:`add_source`; subclasses (e.g. :class:`GaussianPSF`) override it with a
        batched, chunked evaluation so a large :class:`~getframes.scene.sources.Catalog`
        does not pay a Python-level per-source loop.
        """
        for x, y, flux in zip(xs, ys, fluxes):
            self.add_source(image, float(x), float(y), float(flux), plate_scale_arcsec_per_pixel)

add_source(image, x, y, flux, plate_scale_arcsec_per_pixel)

Add flux photons/s of a point source at sub-pixel (x, y) into image.

Source code in src/getframes/scene/psf.py
43
44
45
46
47
48
49
50
51
52
def add_source(
    self,
    image: NDArray[np.float64],
    x: float,
    y: float,
    flux: float,
    plate_scale_arcsec_per_pixel: float,
) -> None:
    """Add ``flux`` photons/s of a point source at sub-pixel ``(x, y)`` into ``image``."""
    raise NotImplementedError

add_sources(image, xs, ys, fluxes, plate_scale_arcsec_per_pixel)

Add many point sources at once (vectorised where the PSF supports it).

xs, ys, fluxes are equal-length 1-D arrays of sub-pixel column, row, and total flux. The generic implementation loops over :meth:add_source; subclasses (e.g. :class:GaussianPSF) override it with a batched, chunked evaluation so a large :class:~getframes.scene.sources.Catalog does not pay a Python-level per-source loop.

Source code in src/getframes/scene/psf.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def add_sources(
    self,
    image: NDArray[np.float64],
    xs: NDArray[np.float64],
    ys: NDArray[np.float64],
    fluxes: NDArray[np.float64],
    plate_scale_arcsec_per_pixel: float,
) -> None:
    """Add many point sources at once (vectorised where the PSF supports it).

    ``xs``, ``ys``, ``fluxes`` are equal-length 1-D arrays of sub-pixel column,
    row, and total flux. The generic implementation loops over
    :meth:`add_source`; subclasses (e.g. :class:`GaussianPSF`) override it with a
    batched, chunked evaluation so a large :class:`~getframes.scene.sources.Catalog`
    does not pay a Python-level per-source loop.
    """
    for x, y, flux in zip(xs, ys, fluxes):
        self.add_source(image, float(x), float(y), float(flux), plate_scale_arcsec_per_pixel)

GaussianPSF dataclass

Bases: PSF

A circular Gaussian PSF specified by its full width at half maximum.

Source code in src/getframes/scene/psf.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
@dataclass(frozen=True)
class GaussianPSF(PSF):
    """A circular Gaussian PSF specified by its full width at half maximum."""

    fwhm_arcsec: float

    def add_source(
        self,
        image: NDArray[np.float64],
        x: float,
        y: float,
        flux: float,
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        if flux <= 0:
            return
        sigma = self.fwhm_arcsec / _FWHM_PER_SIGMA / plate_scale_arcsec_per_pixel
        if sigma <= 0:
            raise ValueError("PSF FWHM and plate scale must be positive.")

        radius = int(np.ceil(5.0 * sigma)) + 1
        x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
        if x0 >= x1 or y0 >= y1:
            return  # source falls entirely off the frame

        # Exact per-pixel integral: pixel i spans [i-0.5, i+0.5]; integrate the
        # Gaussian over each pixel using the error-function CDF at the edges.
        scale = sigma * np.sqrt(2.0)
        edges_x = np.arange(x0, x1 + 1) - 0.5
        edges_y = np.arange(y0, y1 + 1) - 0.5
        cdf_x = 0.5 * (1.0 + erf((edges_x - x) / scale))
        cdf_y = 0.5 * (1.0 + erf((edges_y - y) / scale))
        px = np.diff(cdf_x)
        py = np.diff(cdf_y)
        image[y0:y1, x0:x1] += flux * np.outer(py, px)

    def add_sources(
        self,
        image: NDArray[np.float64],
        xs: NDArray[np.float64],
        ys: NDArray[np.float64],
        fluxes: NDArray[np.float64],
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        """Vectorised, chunked deposition of many Gaussian point sources.

        Builds every source's exact per-pixel error-function integral on a common
        stamp in one batched NumPy expression and scatter-adds it into ``image``,
        replacing the Python per-source loop. Identical pixel values to repeated
        :meth:`add_source` calls (flux off the frame is clipped the same way). Work
        is chunked over sources to keep the intermediate ``(chunk, stamp, stamp)``
        buffer bounded for very large catalogues.
        """
        xs = np.asarray(xs, dtype=np.float64)
        ys = np.asarray(ys, dtype=np.float64)
        fluxes = np.asarray(fluxes, dtype=np.float64)
        sigma = self.fwhm_arcsec / _FWHM_PER_SIGMA / plate_scale_arcsec_per_pixel
        if sigma <= 0:
            raise ValueError("PSF FWHM and plate scale must be positive.")
        keep = fluxes > 0
        if not keep.any():
            return
        xs, ys, fluxes = xs[keep], ys[keep], fluxes[keep]

        radius = int(np.ceil(5.0 * sigma)) + 1
        span = 2 * radius + 1
        scale = sigma * np.sqrt(2.0)
        height, width = image.shape
        # Process in chunks so the (n, span, span) stamp buffer stays bounded.
        chunk = max(1, _STAMP_BUDGET // (span * span))
        offsets = np.arange(span)
        edge_offsets = np.arange(span + 1) - 0.5
        for start in range(0, xs.shape[0], chunk):
            cx = xs[start : start + chunk]
            cy = ys[start : start + chunk]
            cf = fluxes[start : start + chunk]
            ix = np.round(cx).astype(np.intp)
            iy = np.round(cy).astype(np.intp)
            # Exact per-pixel integral on the common stamp, per source (separable).
            edges_x = (ix[:, None] - radius) + edge_offsets[None, :]
            edges_y = (iy[:, None] - radius) + edge_offsets[None, :]
            px = np.diff(0.5 * (1.0 + erf((edges_x - cx[:, None]) / scale)), axis=1)
            py = np.diff(0.5 * (1.0 + erf((edges_y - cy[:, None]) / scale)), axis=1)
            stamps = cf[:, None, None] * py[:, :, None] * px[:, None, :]
            cols = (ix[:, None] - radius) + offsets[None, :]  # (n, span)
            rows = (iy[:, None] - radius) + offsets[None, :]  # (n, span)
            rr = rows[:, :, None]
            cc = cols[:, None, :]
            inb = (rr >= 0) & (rr < height) & (cc >= 0) & (cc < width)
            r_full = np.broadcast_to(rr, stamps.shape)[inb]
            c_full = np.broadcast_to(cc, stamps.shape)[inb]
            np.add.at(image, (r_full, c_full), stamps[inb])

add_sources(image, xs, ys, fluxes, plate_scale_arcsec_per_pixel)

Vectorised, chunked deposition of many Gaussian point sources.

Builds every source's exact per-pixel error-function integral on a common stamp in one batched NumPy expression and scatter-adds it into image, replacing the Python per-source loop. Identical pixel values to repeated :meth:add_source calls (flux off the frame is clipped the same way). Work is chunked over sources to keep the intermediate (chunk, stamp, stamp) buffer bounded for very large catalogues.

Source code in src/getframes/scene/psf.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
def add_sources(
    self,
    image: NDArray[np.float64],
    xs: NDArray[np.float64],
    ys: NDArray[np.float64],
    fluxes: NDArray[np.float64],
    plate_scale_arcsec_per_pixel: float,
) -> None:
    """Vectorised, chunked deposition of many Gaussian point sources.

    Builds every source's exact per-pixel error-function integral on a common
    stamp in one batched NumPy expression and scatter-adds it into ``image``,
    replacing the Python per-source loop. Identical pixel values to repeated
    :meth:`add_source` calls (flux off the frame is clipped the same way). Work
    is chunked over sources to keep the intermediate ``(chunk, stamp, stamp)``
    buffer bounded for very large catalogues.
    """
    xs = np.asarray(xs, dtype=np.float64)
    ys = np.asarray(ys, dtype=np.float64)
    fluxes = np.asarray(fluxes, dtype=np.float64)
    sigma = self.fwhm_arcsec / _FWHM_PER_SIGMA / plate_scale_arcsec_per_pixel
    if sigma <= 0:
        raise ValueError("PSF FWHM and plate scale must be positive.")
    keep = fluxes > 0
    if not keep.any():
        return
    xs, ys, fluxes = xs[keep], ys[keep], fluxes[keep]

    radius = int(np.ceil(5.0 * sigma)) + 1
    span = 2 * radius + 1
    scale = sigma * np.sqrt(2.0)
    height, width = image.shape
    # Process in chunks so the (n, span, span) stamp buffer stays bounded.
    chunk = max(1, _STAMP_BUDGET // (span * span))
    offsets = np.arange(span)
    edge_offsets = np.arange(span + 1) - 0.5
    for start in range(0, xs.shape[0], chunk):
        cx = xs[start : start + chunk]
        cy = ys[start : start + chunk]
        cf = fluxes[start : start + chunk]
        ix = np.round(cx).astype(np.intp)
        iy = np.round(cy).astype(np.intp)
        # Exact per-pixel integral on the common stamp, per source (separable).
        edges_x = (ix[:, None] - radius) + edge_offsets[None, :]
        edges_y = (iy[:, None] - radius) + edge_offsets[None, :]
        px = np.diff(0.5 * (1.0 + erf((edges_x - cx[:, None]) / scale)), axis=1)
        py = np.diff(0.5 * (1.0 + erf((edges_y - cy[:, None]) / scale)), axis=1)
        stamps = cf[:, None, None] * py[:, :, None] * px[:, None, :]
        cols = (ix[:, None] - radius) + offsets[None, :]  # (n, span)
        rows = (iy[:, None] - radius) + offsets[None, :]  # (n, span)
        rr = rows[:, :, None]
        cc = cols[:, None, :]
        inb = (rr >= 0) & (rr < height) & (cc >= 0) & (cc < width)
        r_full = np.broadcast_to(rr, stamps.shape)[inb]
        c_full = np.broadcast_to(cc, stamps.shape)[inb]
        np.add.at(image, (r_full, c_full), stamps[inb])

MoffatPSF dataclass

Bases: PSF

A Moffat PSF, a better match to seeing-limited stars than a Gaussian.

The beta parameter controls the wings: smaller beta gives broader wings (beta -> infinity approaches a Gaussian). beta ~ 3 is typical for atmospheric seeing.

Source code in src/getframes/scene/psf.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
@dataclass(frozen=True)
class MoffatPSF(PSF):
    """A Moffat PSF, a better match to seeing-limited stars than a Gaussian.

    The ``beta`` parameter controls the wings: smaller ``beta`` gives broader wings
    (``beta -> infinity`` approaches a Gaussian). ``beta ~ 3`` is typical for
    atmospheric seeing.
    """

    fwhm_arcsec: float
    beta: float = 3.0

    def add_source(
        self,
        image: NDArray[np.float64],
        x: float,
        y: float,
        flux: float,
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        if flux <= 0:
            return
        if self.beta <= 1.0:
            raise ValueError("Moffat beta must be > 1.")
        fwhm_pix = self.fwhm_arcsec / plate_scale_arcsec_per_pixel
        alpha = fwhm_pix / (2.0 * np.sqrt(2.0 ** (1.0 / self.beta) - 1.0))

        radius = int(np.ceil(6.0 * alpha)) + 1
        x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
        if x0 >= x1 or y0 >= y1:
            return

        xs = np.arange(x0, x1) - x
        ys = np.arange(y0, y1) - y
        rr = xs[None, :] ** 2 + ys[:, None] ** 2
        profile = (1.0 + rr / alpha**2) ** (-self.beta)
        total = profile.sum()
        if total > 0:
            image[y0:y1, x0:x1] += flux * profile / total

EllipticalGaussianPSF dataclass

Bases: PSF

An elliptical Gaussian PSF with independent major/minor widths and an angle.

position_angle_deg is the angle of the major axis, measured counter-clockwise from the +x axis. The profile is sampled on a stamp and normalised (not the exact error-function integral the circular :class:GaussianPSF uses), so flux is conserved to the sampling accuracy.

Source code in src/getframes/scene/psf.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
@dataclass(frozen=True)
class EllipticalGaussianPSF(PSF):
    """An elliptical Gaussian PSF with independent major/minor widths and an angle.

    ``position_angle_deg`` is the angle of the major axis, measured counter-clockwise
    from the +x axis. The profile is sampled on a stamp and normalised (not the exact
    error-function integral the circular :class:`GaussianPSF` uses), so flux is
    conserved to the sampling accuracy.
    """

    fwhm_major_arcsec: float
    fwhm_minor_arcsec: float
    position_angle_deg: float = 0.0

    def add_source(
        self,
        image: NDArray[np.float64],
        x: float,
        y: float,
        flux: float,
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        if flux <= 0:
            return
        if self.fwhm_minor_arcsec > self.fwhm_major_arcsec:
            raise ValueError("fwhm_minor_arcsec must not exceed fwhm_major_arcsec.")
        sigma_major = self.fwhm_major_arcsec / _FWHM_PER_SIGMA / plate_scale_arcsec_per_pixel
        sigma_minor = self.fwhm_minor_arcsec / _FWHM_PER_SIGMA / plate_scale_arcsec_per_pixel
        if sigma_minor <= 0:
            raise ValueError("PSF FWHM and plate scale must be positive.")

        radius = int(np.ceil(5.0 * sigma_major)) + 1
        x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
        if x0 >= x1 or y0 >= y1:
            return

        xs = np.arange(x0, x1) - x
        ys = np.arange(y0, y1) - y
        theta = math.radians(self.position_angle_deg)
        cos_t, sin_t = math.cos(theta), math.sin(theta)
        u = xs[None, :] * cos_t + ys[:, None] * sin_t
        v = -xs[None, :] * sin_t + ys[:, None] * cos_t
        profile = np.exp(-0.5 * ((u / sigma_major) ** 2 + (v / sigma_minor) ** 2))
        total = profile.sum()
        if total > 0:
            image[y0:y1, x0:x1] += flux * profile / total

AiryPSF dataclass

Bases: PSF

The diffraction-limited Airy pattern of a circular aperture.

Models a space- or AO-corrected diffraction-limited core: the intensity is [2 J1(x)/x]^2 with x = pi * D * theta / lambda, optionally including a central obstruction of fractional diameter obstruction. The first dark ring sits at theta = 1.22 lambda / D. Sampled on a stamp and normalised.

Parameters:

Name Type Description Default
aperture_diameter_m float

Aperture diameter in metres (sets the angular scale of the pattern).

required
wavelength_m float

Observing wavelength in metres.

required
obstruction float

Central-obstruction diameter as a fraction of the aperture, in [0, 1).

0.0
Source code in src/getframes/scene/psf.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
@dataclass(frozen=True)
class AiryPSF(PSF):
    """The diffraction-limited Airy pattern of a circular aperture.

    Models a space- or AO-corrected diffraction-limited core: the intensity is
    ``[2 J1(x)/x]^2`` with ``x = pi * D * theta / lambda``, optionally including a
    central obstruction of fractional diameter ``obstruction``. The first dark ring
    sits at ``theta = 1.22 lambda / D``. Sampled on a stamp and normalised.

    Parameters
    ----------
    aperture_diameter_m:
        Aperture diameter in metres (sets the angular scale of the pattern).
    wavelength_m:
        Observing wavelength in metres.
    obstruction:
        Central-obstruction diameter as a fraction of the aperture, in ``[0, 1)``.
    """

    aperture_diameter_m: float
    wavelength_m: float
    obstruction: float = 0.0

    def add_source(
        self,
        image: NDArray[np.float64],
        x: float,
        y: float,
        flux: float,
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        if flux <= 0:
            return
        if self.aperture_diameter_m <= 0 or self.wavelength_m <= 0:
            raise ValueError("AiryPSF aperture_diameter_m and wavelength_m must be positive.")
        if not 0.0 <= self.obstruction < 1.0:
            raise ValueError("AiryPSF obstruction must be in [0, 1).")

        # Radians per pixel, then the argument scale x = pi D theta / lambda.
        rad_per_pixel = plate_scale_arcsec_per_pixel * (math.pi / 180.0 / 3600.0)
        arg_per_pixel = math.pi * self.aperture_diameter_m / self.wavelength_m * rad_per_pixel
        # First null at 1.22 lambda / D; size the stamp to a few Airy rings.
        first_null_pix = 1.22 / (arg_per_pixel / math.pi) if arg_per_pixel > 0 else 1.0
        radius = int(np.ceil(5.0 * first_null_pix)) + 1
        x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
        if x0 >= x1 or y0 >= y1:
            return

        xs = np.arange(x0, x1) - x
        ys = np.arange(y0, y1) - y
        rr = np.sqrt(xs[None, :] ** 2 + ys[:, None] ** 2)
        arg = arg_per_pixel * rr
        profile = _airy_intensity(arg, self.obstruction)
        total = profile.sum()
        if total > 0:
            image[y0:y1, x0:x1] += flux * profile / total

ArrayPSF dataclass

Bases: PSF

A user-supplied PSF kernel, e.g. straight from an AO/optics simulation.

The kernel is a 2D array sampled at detector-pixel resolution; it is normalised to unit sum on construction. Sub-pixel source positions are handled by a first-order (bilinear) shift of the kernel before it is pasted, so the centroid lands at the requested location. Flux falling off the frame is clipped.

Source code in src/getframes/scene/psf.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
@dataclass(frozen=True)
class ArrayPSF(PSF):
    """A user-supplied PSF kernel, e.g. straight from an AO/optics simulation.

    The ``kernel`` is a 2D array sampled at detector-pixel resolution; it is
    normalised to unit sum on construction. Sub-pixel source positions are handled by
    a first-order (bilinear) shift of the kernel before it is pasted, so the centroid
    lands at the requested location. Flux falling off the frame is clipped.
    """

    kernel: NDArray[np.float64]

    def __post_init__(self) -> None:
        arr = np.asarray(self.kernel, dtype=np.float64)
        if arr.ndim != 2 or arr.size == 0:
            raise ValueError("ArrayPSF kernel must be a non-empty 2D array.")
        total = float(arr.sum())
        if total <= 0:
            raise ValueError("ArrayPSF kernel must have a positive sum.")
        object.__setattr__(self, "kernel", arr / total)

    def add_source(
        self,
        image: NDArray[np.float64],
        x: float,
        y: float,
        flux: float,
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        if flux <= 0:
            return
        kh, kw = self.kernel.shape
        ix, iy = round(x), round(y)
        fx, fy = x - ix, y - iy
        stamp = _ndimage_shift(self.kernel, (fy, fx), order=1, mode="constant", cval=0.0)

        top, left = iy - kh // 2, ix - kw // 2
        height, width = image.shape
        y0, y1 = max(0, top), min(height, top + kh)
        x0, x1 = max(0, left), min(width, left + kw)
        if y0 >= y1 or x0 >= x1:
            return
        image[y0:y1, x0:x1] += flux * stamp[y0 - top : y1 - top, x0 - left : x1 - left]

Time series

getframes.scene.sources.LightCurve dataclass

A time-varying brightness multiplier for a source.

A light curve maps a time t (seconds, measured from the start of an observation) to a dimensionless factor that multiplies the source's baseline brightness. A constant 1.0 leaves the source unchanged; 0.99 during a transit dims it by 1%.

Time variability is owned by the source (see :attr:PointSource.brightness): :meth:getframes.Camera.observe_series samples the curve at each frame's timestamp, so the injected signal is reproducible and recorded in the observation's per-frame truth.

Construct one with a factory (:meth:box, :meth:sinusoidal, :meth:constant) or wrap any callable with :meth:from_function. The instance itself is callable: lc(t) returns the multiplier.

Parameters:

Name Type Description Default
func Callable[[float], float]

Callable mapping time in seconds to a non-negative brightness multiplier.

required
Source code in src/getframes/scene/sources.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
@dataclass(frozen=True)
class LightCurve:
    """A time-varying brightness multiplier for a source.

    A light curve maps a time ``t`` (seconds, measured from the start of an
    observation) to a dimensionless factor that multiplies the source's baseline
    brightness. A constant ``1.0`` leaves the source unchanged; ``0.99`` during a
    transit dims it by 1%.

    Time variability is *owned by the source* (see :attr:`PointSource.brightness`):
    :meth:`getframes.Camera.observe_series` samples the curve at each frame's
    timestamp, so the injected signal is reproducible and recorded in the
    observation's per-frame truth.

    Construct one with a factory (:meth:`box`, :meth:`sinusoidal`,
    :meth:`constant`) or wrap any callable with :meth:`from_function`. The instance
    itself is callable: ``lc(t)`` returns the multiplier.

    Parameters
    ----------
    func:
        Callable mapping time in seconds to a non-negative brightness multiplier.
    """

    func: Callable[[float], float]

    def __call__(self, time_s: float) -> float:
        value = float(self.func(time_s))
        if value < 0.0:
            raise ValueError("LightCurve produced a negative brightness multiplier.")
        return value

    @classmethod
    def constant(cls, level: float = 1.0) -> LightCurve:
        """A flat light curve at ``level`` (default ``1.0``, i.e. no variation)."""
        return cls(lambda _t: level)

    @classmethod
    def box(cls, depth: float, t0: float, t1: float, baseline: float = 1.0) -> LightCurve:
        """A box-shaped dip of fractional ``depth`` between times ``t0`` and ``t1``.

        Outside ``[t0, t1)`` the multiplier is ``baseline``; inside it is
        ``baseline * (1 - depth)``. A simple model of a flat-bottomed transit
        (``depth=0.01`` for a 1% transit).
        """
        if not 0.0 <= depth <= 1.0:
            raise ValueError("box depth must be in [0, 1].")
        if t1 < t0:
            raise ValueError("box requires t1 >= t0.")

        def curve(t: float) -> float:
            return baseline * (1.0 - depth) if t0 <= t < t1 else baseline

        return cls(curve)

    @classmethod
    def sinusoidal(
        cls,
        amplitude: float,
        period_s: float,
        *,
        phase: float = 0.0,
        baseline: float = 1.0,
    ) -> LightCurve:
        """A sinusoid: ``baseline + amplitude * sin(2*pi*t/period + phase)``.

        Models a pulsating or rotating variable. ``amplitude`` is in the same units
        as ``baseline`` (i.e. a fraction of the unit baseline); keep
        ``amplitude <= baseline`` to stay non-negative.
        """
        if period_s <= 0:
            raise ValueError("sinusoidal period_s must be positive.")
        omega = 2.0 * math.pi / period_s

        def curve(t: float) -> float:
            return baseline + amplitude * math.sin(omega * t + phase)

        return cls(curve)

    @classmethod
    def from_function(cls, func: Callable[[float], float]) -> LightCurve:
        """Wrap an arbitrary ``t -> multiplier`` callable as a light curve."""
        return cls(func)

constant(level=1.0) classmethod

A flat light curve at level (default 1.0, i.e. no variation).

Source code in src/getframes/scene/sources.py
119
120
121
122
@classmethod
def constant(cls, level: float = 1.0) -> LightCurve:
    """A flat light curve at ``level`` (default ``1.0``, i.e. no variation)."""
    return cls(lambda _t: level)

box(depth, t0, t1, baseline=1.0) classmethod

A box-shaped dip of fractional depth between times t0 and t1.

Outside [t0, t1) the multiplier is baseline; inside it is baseline * (1 - depth). A simple model of a flat-bottomed transit (depth=0.01 for a 1% transit).

Source code in src/getframes/scene/sources.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
@classmethod
def box(cls, depth: float, t0: float, t1: float, baseline: float = 1.0) -> LightCurve:
    """A box-shaped dip of fractional ``depth`` between times ``t0`` and ``t1``.

    Outside ``[t0, t1)`` the multiplier is ``baseline``; inside it is
    ``baseline * (1 - depth)``. A simple model of a flat-bottomed transit
    (``depth=0.01`` for a 1% transit).
    """
    if not 0.0 <= depth <= 1.0:
        raise ValueError("box depth must be in [0, 1].")
    if t1 < t0:
        raise ValueError("box requires t1 >= t0.")

    def curve(t: float) -> float:
        return baseline * (1.0 - depth) if t0 <= t < t1 else baseline

    return cls(curve)

sinusoidal(amplitude, period_s, *, phase=0.0, baseline=1.0) classmethod

A sinusoid: baseline + amplitude * sin(2*pi*t/period + phase).

Models a pulsating or rotating variable. amplitude is in the same units as baseline (i.e. a fraction of the unit baseline); keep amplitude <= baseline to stay non-negative.

Source code in src/getframes/scene/sources.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
@classmethod
def sinusoidal(
    cls,
    amplitude: float,
    period_s: float,
    *,
    phase: float = 0.0,
    baseline: float = 1.0,
) -> LightCurve:
    """A sinusoid: ``baseline + amplitude * sin(2*pi*t/period + phase)``.

    Models a pulsating or rotating variable. ``amplitude`` is in the same units
    as ``baseline`` (i.e. a fraction of the unit baseline); keep
    ``amplitude <= baseline`` to stay non-negative.
    """
    if period_s <= 0:
        raise ValueError("sinusoidal period_s must be positive.")
    omega = 2.0 * math.pi / period_s

    def curve(t: float) -> float:
        return baseline + amplitude * math.sin(omega * t + phase)

    return cls(curve)

from_function(func) classmethod

Wrap an arbitrary t -> multiplier callable as a light curve.

Source code in src/getframes/scene/sources.py
166
167
168
169
@classmethod
def from_function(cls, func: Callable[[float], float]) -> LightCurve:
    """Wrap an arbitrary ``t -> multiplier`` callable as a light curve."""
    return cls(func)

getframes.observation.Observation dataclass

A reproducible stack of frames of one scene over time.

Returned by :meth:getframes.Camera.observe_series. It is iterable and indexable over its :attr:frames, so existing for frame in obs: style code keeps working, while :attr:truth, :attr:times_s, and :attr:offsets_pixels expose the time and pointing information.

Attributes:

Name Type Description
frames list[Frame]

The realised science :class:~getframes.frame.Frame stack, in time order.

times_s NDArray[float64]

Frame timestamps in seconds, shape (n_frames,).

offsets_pixels NDArray[float64]

The realised pointing offset (dx, dy) applied to each frame, in pixels, shape (n_frames, 2).

truth ObservationTruth | None

The :class:ObservationTruth light curve, or None when truth was not requested.

Source code in src/getframes/observation.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
@dataclass(frozen=True)
class Observation:
    """A reproducible stack of frames of one scene over time.

    Returned by :meth:`getframes.Camera.observe_series`. It is iterable and
    indexable over its :attr:`frames`, so existing ``for frame in obs:`` style code
    keeps working, while :attr:`truth`, :attr:`times_s`, and :attr:`offsets_pixels`
    expose the time and pointing information.

    Attributes
    ----------
    frames:
        The realised science :class:`~getframes.frame.Frame` stack, in time order.
    times_s:
        Frame timestamps in seconds, shape ``(n_frames,)``.
    offsets_pixels:
        The realised pointing offset ``(dx, dy)`` applied to each frame, in pixels,
        shape ``(n_frames, 2)``.
    truth:
        The :class:`ObservationTruth` light curve, or ``None`` when truth was not
        requested.
    """

    frames: list[Frame]
    times_s: NDArray[np.float64]
    offsets_pixels: NDArray[np.float64]
    truth: ObservationTruth | None = field(default=None)

    def __iter__(self) -> Iterator[Frame]:
        return iter(self.frames)

    def __len__(self) -> int:
        return len(self.frames)

    def __getitem__(self, index: int) -> Frame:
        return self.frames[index]

    def __repr__(self) -> str:
        cam = self.frames[0].metadata.get("camera", "?") if self.frames else "?"
        return f"Observation(n_frames={len(self.frames)}, camera={cam!r})"

getframes.observation.ObservationTruth dataclass

The noise-free ground truth of an :class:Observation.

Attributes:

Name Type Description
times_s NDArray[float64]

The frame timestamps, in seconds from the start of the observation, shape (n_frames,).

light_curve dict[str, NDArray[float64]]

Per-source injected signal: a mapping from source name to an array of the noise-free incident photons collected from that source in each frame (photon rate x exposure, post-optics, pre-quantum-efficiency), shape (n_frames,). This is the true light curve to validate measured photometry against. Unnamed sources are keyed "source_{index}".

Source code in src/getframes/observation.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
@dataclass(frozen=True)
class ObservationTruth:
    """The noise-free ground truth of an :class:`Observation`.

    Attributes
    ----------
    times_s:
        The frame timestamps, in seconds from the start of the observation,
        shape ``(n_frames,)``.
    light_curve:
        Per-source injected signal: a mapping from source name to an array of the
        noise-free incident photons collected from that source in each frame
        (photon rate x exposure, post-optics, pre-quantum-efficiency), shape
        ``(n_frames,)``. This is the true light curve to validate measured
        photometry against. Unnamed sources are keyed ``"source_{index}"``.
    """

    times_s: NDArray[np.float64]
    light_curve: dict[str, NDArray[np.float64]]

getframes.observation.Pointing dataclass

A per-frame pointing model: jitter, slow drift, and a programmed dither.

The three components combine additively into a whole-field offset applied to every source in the scene at each frame. Offsets are specified in arcseconds (converted to pixels with the scene's plate scale) so the model is independent of the detector sampling.

Parameters:

Name Type Description Default
jitter_arcsec float

RMS of a per-frame Gaussian offset drawn independently for each axis and each frame. Models random tracking jitter and atmospheric tip-tilt / image motion (e.g. for AO sub-apertures). 0 disables it.

0.0
drift_arcsec_per_s tuple[float, float]

A constant (vx, vy) velocity giving a slow linear drift; the offset at time t is (vx * t, vy * t). Models tracking error / field rotation creep.

(0.0, 0.0)
dither_arcsec Sequence[tuple[float, float]] | None

An optional sequence of programmed (dx, dy) offsets, cycled by frame index (frame i uses entry i % len). Models a deliberate dither pattern. None for no dither.

None
Source code in src/getframes/observation.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
@dataclass(frozen=True)
class Pointing:
    """A per-frame pointing model: jitter, slow drift, and a programmed dither.

    The three components combine additively into a whole-field offset applied to
    every source in the scene at each frame. Offsets are specified in arcseconds
    (converted to pixels with the scene's plate scale) so the model is independent
    of the detector sampling.

    Parameters
    ----------
    jitter_arcsec:
        RMS of a per-frame Gaussian offset drawn independently for each axis and
        each frame. Models random tracking jitter and atmospheric tip-tilt / image
        motion (e.g. for AO sub-apertures). ``0`` disables it.
    drift_arcsec_per_s:
        A constant ``(vx, vy)`` velocity giving a slow linear drift; the offset at
        time ``t`` is ``(vx * t, vy * t)``. Models tracking error / field rotation
        creep.
    dither_arcsec:
        An optional sequence of programmed ``(dx, dy)`` offsets, cycled by frame
        index (frame ``i`` uses entry ``i % len``). Models a deliberate dither
        pattern. ``None`` for no dither.
    """

    jitter_arcsec: float = 0.0
    drift_arcsec_per_s: tuple[float, float] = (0.0, 0.0)
    dither_arcsec: Sequence[tuple[float, float]] | None = None

    def __post_init__(self) -> None:
        if self.jitter_arcsec < 0:
            raise ValueError("jitter_arcsec must be non-negative.")

    @property
    def is_static(self) -> bool:
        """Whether this model never moves the field (a no-op pointing)."""
        return (
            self.jitter_arcsec == 0.0
            and self.drift_arcsec_per_s == (0.0, 0.0)
            and not self.dither_arcsec
        )

    def offset_pixels(
        self,
        frame_index: int,
        time_s: float,
        plate_scale_arcsec_per_pixel: float,
        rng: np.random.Generator,
    ) -> tuple[float, float]:
        """The realised ``(dx, dy)`` offset in pixels for one frame.

        Combines drift (deterministic in ``time_s``), the cycled dither entry, and a
        fresh Gaussian jitter draw, then converts arcseconds to pixels.
        """
        dx_as = self.drift_arcsec_per_s[0] * time_s
        dy_as = self.drift_arcsec_per_s[1] * time_s
        if self.dither_arcsec:
            ddx, ddy = self.dither_arcsec[frame_index % len(self.dither_arcsec)]
            dx_as += ddx
            dy_as += ddy
        if self.jitter_arcsec > 0:
            dx_as += float(rng.normal(0.0, self.jitter_arcsec))
            dy_as += float(rng.normal(0.0, self.jitter_arcsec))
        return dx_as / plate_scale_arcsec_per_pixel, dy_as / plate_scale_arcsec_per_pixel

is_static property

Whether this model never moves the field (a no-op pointing).

offset_pixels(frame_index, time_s, plate_scale_arcsec_per_pixel, rng)

The realised (dx, dy) offset in pixels for one frame.

Combines drift (deterministic in time_s), the cycled dither entry, and a fresh Gaussian jitter draw, then converts arcseconds to pixels.

Source code in src/getframes/observation.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def offset_pixels(
    self,
    frame_index: int,
    time_s: float,
    plate_scale_arcsec_per_pixel: float,
    rng: np.random.Generator,
) -> tuple[float, float]:
    """The realised ``(dx, dy)`` offset in pixels for one frame.

    Combines drift (deterministic in ``time_s``), the cycled dither entry, and a
    fresh Gaussian jitter draw, then converts arcseconds to pixels.
    """
    dx_as = self.drift_arcsec_per_s[0] * time_s
    dy_as = self.drift_arcsec_per_s[1] * time_s
    if self.dither_arcsec:
        ddx, ddy = self.dither_arcsec[frame_index % len(self.dither_arcsec)]
        dx_as += ddx
        dy_as += ddy
    if self.jitter_arcsec > 0:
        dx_as += float(rng.normal(0.0, self.jitter_arcsec))
        dy_as += float(rng.normal(0.0, self.jitter_arcsec))
    return dx_as / plate_scale_arcsec_per_pixel, dy_as / plate_scale_arcsec_per_pixel

Spectral mode

getframes.spectral

Wavelength-resolved primitives for the opt-in spectral mode.

The band-integrated model (a scalar quantum efficiency and a single photon zero point per band) is accurate enough for exposure planning, but it cannot capture how a detector's response colour interacts with a source's spectral energy distribution (SED). Spectral mode adds that, additively, through three tabulated curves on a shared wavelength axis (nanometres):

  • :class:SED --- a source's spectral photon flux density (shape only; the absolute level is still set by the source magnitude),
  • :class:SpectralBandpass --- a filter/optics transmission response in [0, 1],
  • :class:QE --- a detector quantum-efficiency curve in [0, 1].

The single physical quantity spectral mode computes is the effective quantum efficiency a source sees,

.. math::

\mathrm{QE}_\mathrm{eff} =
    \frac{\int S(\lambda)\,T(\lambda)\,\mathrm{QE}(\lambda)\,d\lambda}
         {\int S(\lambda)\,T(\lambda)\,d\lambda},

a photon-weighted average of :math:\mathrm{QE}(\lambda) over the band. It is a ratio, so it is invariant to the absolute normalisation of both the SED and the bandpass --- which is why spectral mode needs no absolute reference spectrum and leaves the magnitude-to-photon-rate conversion (governed by the band zero point) untouched. Only the photon-to-electron conversion is refined.

Everything here is pure NumPy and free of randomness.

Spectrum dataclass

A tabulated, non-negative curve value(wavelength_nm).

Values are linearly interpolated within the sampled range and treated as zero outside it. The wavelength axis is in nanometres and must be strictly increasing. This base class carries the shared sampling/integration machinery; :class:SED, :class:SpectralBandpass, and :class:QE add units and constructors.

Source code in src/getframes/spectral.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
@dataclass(frozen=True)
class Spectrum:
    """A tabulated, non-negative curve ``value(wavelength_nm)``.

    Values are linearly interpolated within the sampled range and treated as zero
    outside it. The wavelength axis is in nanometres and must be strictly
    increasing. This base class carries the shared sampling/integration machinery;
    :class:`SED`, :class:`SpectralBandpass`, and :class:`QE` add units and
    constructors.
    """

    wavelength_nm: NDArray[np.float64]
    value: NDArray[np.float64]

    def __post_init__(self) -> None:
        wl, val = _as_grid(self.wavelength_nm, self.value)
        if np.any(val < 0):
            raise ValueError("spectrum values must be non-negative.")
        object.__setattr__(self, "wavelength_nm", wl)
        object.__setattr__(self, "value", val)

    def __call__(self, wavelength_nm: ArrayLike) -> NDArray[np.float64]:
        """Interpolate the curve at ``wavelength_nm`` (zero outside the sampled range)."""
        wl = np.asarray(wavelength_nm, dtype=np.float64)
        return np.interp(wl, self.wavelength_nm, self.value, left=0.0, right=0.0)

    @classmethod
    def from_file(
        cls,
        path: str,
        *,
        wavelength_to_nm: float = 1.0,
        delimiter: str | None = None,
        skiprows: int = 0,
        usecols: tuple[int, int] = (0, 1),
    ) -> Spectrum:
        """Load a two-column ``(wavelength, value)`` curve from a text file.

        Reads ``path`` with :func:`numpy.loadtxt`. The first column is scaled by
        ``wavelength_to_nm`` to nanometres (e.g. ``0.1`` for angstroms, ``1000`` for
        microns); the second is taken verbatim. Handy for measured filter, QE, or
        atmospheric-transmission curves --- combine several with :func:`product` or
        :meth:`SpectralBandpass.from_product`.
        """
        data = np.loadtxt(path, delimiter=delimiter, skiprows=skiprows, usecols=usecols)
        wl = np.asarray(data[:, 0], dtype=np.float64) * float(wavelength_to_nm)
        val = np.asarray(data[:, 1], dtype=np.float64)
        return cls(wl, val)

    def integrate(self) -> float:
        """Trapezoidal integral of the curve over wavelength (nm)."""
        return float(_trapezoid(self.value, self.wavelength_nm))

    @property
    def wavelength_min_nm(self) -> float:
        return float(self.wavelength_nm[0])

    @property
    def wavelength_max_nm(self) -> float:
        return float(self.wavelength_nm[-1])

__call__(wavelength_nm)

Interpolate the curve at wavelength_nm (zero outside the sampled range).

Source code in src/getframes/spectral.py
112
113
114
115
def __call__(self, wavelength_nm: ArrayLike) -> NDArray[np.float64]:
    """Interpolate the curve at ``wavelength_nm`` (zero outside the sampled range)."""
    wl = np.asarray(wavelength_nm, dtype=np.float64)
    return np.interp(wl, self.wavelength_nm, self.value, left=0.0, right=0.0)

from_file(path, *, wavelength_to_nm=1.0, delimiter=None, skiprows=0, usecols=(0, 1)) classmethod

Load a two-column (wavelength, value) curve from a text file.

Reads path with :func:numpy.loadtxt. The first column is scaled by wavelength_to_nm to nanometres (e.g. 0.1 for angstroms, 1000 for microns); the second is taken verbatim. Handy for measured filter, QE, or atmospheric-transmission curves --- combine several with :func:product or :meth:SpectralBandpass.from_product.

Source code in src/getframes/spectral.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
@classmethod
def from_file(
    cls,
    path: str,
    *,
    wavelength_to_nm: float = 1.0,
    delimiter: str | None = None,
    skiprows: int = 0,
    usecols: tuple[int, int] = (0, 1),
) -> Spectrum:
    """Load a two-column ``(wavelength, value)`` curve from a text file.

    Reads ``path`` with :func:`numpy.loadtxt`. The first column is scaled by
    ``wavelength_to_nm`` to nanometres (e.g. ``0.1`` for angstroms, ``1000`` for
    microns); the second is taken verbatim. Handy for measured filter, QE, or
    atmospheric-transmission curves --- combine several with :func:`product` or
    :meth:`SpectralBandpass.from_product`.
    """
    data = np.loadtxt(path, delimiter=delimiter, skiprows=skiprows, usecols=usecols)
    wl = np.asarray(data[:, 0], dtype=np.float64) * float(wavelength_to_nm)
    val = np.asarray(data[:, 1], dtype=np.float64)
    return cls(wl, val)

integrate()

Trapezoidal integral of the curve over wavelength (nm).

Source code in src/getframes/spectral.py
140
141
142
def integrate(self) -> float:
    """Trapezoidal integral of the curve over wavelength (nm)."""
    return float(_trapezoid(self.value, self.wavelength_nm))

SED dataclass

Bases: Spectrum

A source's spectral photon flux density.

Two flavours, distinguished by :attr:is_absolute:

  • Relative (the default; :meth:from_arrays and the parametric shapes). Only the shape matters: spectral mode uses it to colour-weight the quantum efficiency, a calculation invariant to overall scale (the source magnitude still sets the absolute photon rate).
  • Absolute (:meth:from_flux_density): values are a true photon flux density in photons/s/m^2/nm above the atmosphere. Such an SED can set the integrated photon rate directly --- pass it to a source as flux_sed and the telescope integrates it over the band (see :meth:getframes.scene.photometry.Bandpass.photon_flux_from_sed), instead of deriving the rate from a magnitude.
Source code in src/getframes/spectral.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
@dataclass(frozen=True)
class SED(Spectrum):
    """A source's spectral *photon* flux density.

    Two flavours, distinguished by :attr:`is_absolute`:

    * **Relative** (the default; :meth:`from_arrays` and the parametric shapes).
      Only the *shape* matters: spectral mode uses it to colour-weight the quantum
      efficiency, a calculation invariant to overall scale (the source magnitude
      still sets the absolute photon rate).
    * **Absolute** (:meth:`from_flux_density`): values are a true photon flux density
      in ``photons/s/m^2/nm`` above the atmosphere. Such an SED can *set the
      integrated photon rate* directly --- pass it to a source as ``flux_sed`` and
      the telescope integrates it over the band (see
      :meth:`getframes.scene.photometry.Bandpass.photon_flux_from_sed`), instead of
      deriving the rate from a magnitude.
    """

    is_absolute: bool = False

    @classmethod
    def from_arrays(cls, wavelength_nm: ArrayLike, photon_flux: ArrayLike) -> SED:
        """A *relative* SED sampled at ``wavelength_nm`` with photon flux density (shape only)."""
        return cls(_to_nm(wavelength_nm), _strip_units(photon_flux))

    @classmethod
    def from_flux_density(cls, wavelength_nm: ArrayLike, photon_flux_density: ArrayLike) -> SED:
        """An *absolute* SED: ``photon_flux_density`` in ``photons/s/m^2/nm``.

        Unlike :meth:`from_arrays`, the absolute scale is meaningful: integrated over
        a band it yields a photon rate, so a source carrying this as ``flux_sed`` has
        its brightness set by the spectrum itself (no magnitude needed). Wavelengths
        and flux may be plain arrays (nm and photons/s/m^2/nm) or ``astropy.units``
        quantities, which are converted.
        """
        return cls(_to_nm(wavelength_nm), _strip_units(photon_flux_density), is_absolute=True)

    @classmethod
    def flat(
        cls,
        wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
        wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
    ) -> SED:
        """A flat photon spectrum (equal photons per unit wavelength).

        The neutral default: with a flat SED the effective QE is simply the
        bandpass-weighted mean of ``QE(lambda)``.
        """
        wl = np.array([wavelength_min_nm, wavelength_max_nm], dtype=np.float64)
        return cls(wl, np.ones_like(wl))

    @classmethod
    def blackbody(
        cls,
        temperature_k: float,
        wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
        wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
        n_samples: int = 256,
    ) -> SED:
        """A blackbody photon spectrum at ``temperature_k`` (relative units).

        Photon spectral radiance ``~ lambda**-4 / (exp(hc / lambda k T) - 1)`` --- the
        Planck law expressed per photon rather than per unit energy. Good for giving
        a star a colour (e.g. ``5800`` K for a sun-like source, ``3500`` K for a cool
        M dwarf, ``10000`` K for a hot blue star).
        """
        if temperature_k <= 0:
            raise ValueError("temperature_k must be positive.")
        wl_nm = np.linspace(wavelength_min_nm, wavelength_max_nm, n_samples)
        wl_m = wl_nm * 1e-9
        x = _H_PLANCK * _C_LIGHT / (wl_m * _K_BOLTZMANN * temperature_k)
        # Photon radiance density: ~ lambda^-4 / (exp(x) - 1). Constants drop out.
        photons = wl_m**-4 / np.expm1(x)
        return cls(wl_nm, photons / photons.max())

    @classmethod
    def power_law(
        cls,
        index: float,
        reference_wavelength_nm: float = 550.0,
        wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
        wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
        n_samples: int = 64,
    ) -> SED:
        """A power-law photon spectrum ``(lambda / lambda_ref)**index`` (relative)."""
        wl = np.linspace(wavelength_min_nm, wavelength_max_nm, n_samples)
        return cls(wl, (wl / reference_wavelength_nm) ** index)

from_arrays(wavelength_nm, photon_flux) classmethod

A relative SED sampled at wavelength_nm with photon flux density (shape only).

Source code in src/getframes/spectral.py
223
224
225
226
@classmethod
def from_arrays(cls, wavelength_nm: ArrayLike, photon_flux: ArrayLike) -> SED:
    """A *relative* SED sampled at ``wavelength_nm`` with photon flux density (shape only)."""
    return cls(_to_nm(wavelength_nm), _strip_units(photon_flux))

from_flux_density(wavelength_nm, photon_flux_density) classmethod

An absolute SED: photon_flux_density in photons/s/m^2/nm.

Unlike :meth:from_arrays, the absolute scale is meaningful: integrated over a band it yields a photon rate, so a source carrying this as flux_sed has its brightness set by the spectrum itself (no magnitude needed). Wavelengths and flux may be plain arrays (nm and photons/s/m^2/nm) or astropy.units quantities, which are converted.

Source code in src/getframes/spectral.py
228
229
230
231
232
233
234
235
236
237
238
@classmethod
def from_flux_density(cls, wavelength_nm: ArrayLike, photon_flux_density: ArrayLike) -> SED:
    """An *absolute* SED: ``photon_flux_density`` in ``photons/s/m^2/nm``.

    Unlike :meth:`from_arrays`, the absolute scale is meaningful: integrated over
    a band it yields a photon rate, so a source carrying this as ``flux_sed`` has
    its brightness set by the spectrum itself (no magnitude needed). Wavelengths
    and flux may be plain arrays (nm and photons/s/m^2/nm) or ``astropy.units``
    quantities, which are converted.
    """
    return cls(_to_nm(wavelength_nm), _strip_units(photon_flux_density), is_absolute=True)

flat(wavelength_min_nm=_DEFAULT_WL_MIN_NM, wavelength_max_nm=_DEFAULT_WL_MAX_NM) classmethod

A flat photon spectrum (equal photons per unit wavelength).

The neutral default: with a flat SED the effective QE is simply the bandpass-weighted mean of QE(lambda).

Source code in src/getframes/spectral.py
240
241
242
243
244
245
246
247
248
249
250
251
252
@classmethod
def flat(
    cls,
    wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
    wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
) -> SED:
    """A flat photon spectrum (equal photons per unit wavelength).

    The neutral default: with a flat SED the effective QE is simply the
    bandpass-weighted mean of ``QE(lambda)``.
    """
    wl = np.array([wavelength_min_nm, wavelength_max_nm], dtype=np.float64)
    return cls(wl, np.ones_like(wl))

blackbody(temperature_k, wavelength_min_nm=_DEFAULT_WL_MIN_NM, wavelength_max_nm=_DEFAULT_WL_MAX_NM, n_samples=256) classmethod

A blackbody photon spectrum at temperature_k (relative units).

Photon spectral radiance ~ lambda**-4 / (exp(hc / lambda k T) - 1) --- the Planck law expressed per photon rather than per unit energy. Good for giving a star a colour (e.g. 5800 K for a sun-like source, 3500 K for a cool M dwarf, 10000 K for a hot blue star).

Source code in src/getframes/spectral.py
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
@classmethod
def blackbody(
    cls,
    temperature_k: float,
    wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
    wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
    n_samples: int = 256,
) -> SED:
    """A blackbody photon spectrum at ``temperature_k`` (relative units).

    Photon spectral radiance ``~ lambda**-4 / (exp(hc / lambda k T) - 1)`` --- the
    Planck law expressed per photon rather than per unit energy. Good for giving
    a star a colour (e.g. ``5800`` K for a sun-like source, ``3500`` K for a cool
    M dwarf, ``10000`` K for a hot blue star).
    """
    if temperature_k <= 0:
        raise ValueError("temperature_k must be positive.")
    wl_nm = np.linspace(wavelength_min_nm, wavelength_max_nm, n_samples)
    wl_m = wl_nm * 1e-9
    x = _H_PLANCK * _C_LIGHT / (wl_m * _K_BOLTZMANN * temperature_k)
    # Photon radiance density: ~ lambda^-4 / (exp(x) - 1). Constants drop out.
    photons = wl_m**-4 / np.expm1(x)
    return cls(wl_nm, photons / photons.max())

power_law(index, reference_wavelength_nm=550.0, wavelength_min_nm=_DEFAULT_WL_MIN_NM, wavelength_max_nm=_DEFAULT_WL_MAX_NM, n_samples=64) classmethod

A power-law photon spectrum (lambda / lambda_ref)**index (relative).

Source code in src/getframes/spectral.py
278
279
280
281
282
283
284
285
286
287
288
289
@classmethod
def power_law(
    cls,
    index: float,
    reference_wavelength_nm: float = 550.0,
    wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
    wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
    n_samples: int = 64,
) -> SED:
    """A power-law photon spectrum ``(lambda / lambda_ref)**index`` (relative)."""
    wl = np.linspace(wavelength_min_nm, wavelength_max_nm, n_samples)
    return cls(wl, (wl / reference_wavelength_nm) ** index)

QE dataclass

Bases: Spectrum

A detector quantum-efficiency curve, QE(lambda) in [0, 1].

Source code in src/getframes/spectral.py
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
class QE(Spectrum):
    """A detector quantum-efficiency curve, ``QE(lambda)`` in ``[0, 1]``."""

    def __post_init__(self) -> None:
        super().__post_init__()
        if np.any(self.value > 1.0):
            raise ValueError("QE values must be in [0, 1].")

    @classmethod
    def from_arrays(cls, wavelength_nm: ArrayLike, qe: ArrayLike) -> QE:
        """A QE curve sampled at ``wavelength_nm`` with values in ``[0, 1]``."""
        return cls(_to_nm(wavelength_nm), _strip_units(qe))

    @classmethod
    def constant(
        cls,
        value: float,
        wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
        wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
    ) -> QE:
        """A flat QE curve --- equivalent to the scalar ``quantum_efficiency``."""
        if not 0.0 <= value <= 1.0:
            raise ValueError("QE value must be in [0, 1].")
        wl = np.array([wavelength_min_nm, wavelength_max_nm], dtype=np.float64)
        return cls(wl, np.full_like(wl, value))

from_arrays(wavelength_nm, qe) classmethod

A QE curve sampled at wavelength_nm with values in [0, 1].

Source code in src/getframes/spectral.py
300
301
302
303
@classmethod
def from_arrays(cls, wavelength_nm: ArrayLike, qe: ArrayLike) -> QE:
    """A QE curve sampled at ``wavelength_nm`` with values in ``[0, 1]``."""
    return cls(_to_nm(wavelength_nm), _strip_units(qe))

constant(value, wavelength_min_nm=_DEFAULT_WL_MIN_NM, wavelength_max_nm=_DEFAULT_WL_MAX_NM) classmethod

A flat QE curve --- equivalent to the scalar quantum_efficiency.

Source code in src/getframes/spectral.py
305
306
307
308
309
310
311
312
313
314
315
316
@classmethod
def constant(
    cls,
    value: float,
    wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
    wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
) -> QE:
    """A flat QE curve --- equivalent to the scalar ``quantum_efficiency``."""
    if not 0.0 <= value <= 1.0:
        raise ValueError("QE value must be in [0, 1].")
    wl = np.array([wavelength_min_nm, wavelength_max_nm], dtype=np.float64)
    return cls(wl, np.full_like(wl, value))

SpectralBandpass dataclass

A filter/optics transmission response T(lambda) in [0, 1].

Carries the spectral shape of a band, used to colour-weight the effective QE. It does not replace a :class:~getframes.scene.photometry.Bandpass's scalar photon zero point (which still sets the magnitude-to-photon conversion); it refines the photon-to-electron step.

Source code in src/getframes/spectral.py
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
@dataclass(frozen=True)
class SpectralBandpass:
    """A filter/optics transmission response ``T(lambda)`` in ``[0, 1]``.

    Carries the spectral *shape* of a band, used to colour-weight the effective QE.
    It does not replace a :class:`~getframes.scene.photometry.Bandpass`'s scalar
    photon zero point (which still sets the magnitude-to-photon conversion); it
    refines the photon-to-electron step.
    """

    response: Spectrum

    @classmethod
    def from_arrays(cls, wavelength_nm: ArrayLike, throughput: ArrayLike) -> SpectralBandpass:
        """A response curve sampled at ``wavelength_nm`` with throughput in ``[0, 1]``."""
        spec = Spectrum(_to_nm(wavelength_nm), _strip_units(throughput))
        if np.any(spec.value > 1.0):
            raise ValueError("bandpass throughput must be in [0, 1].")
        return cls(spec)

    @classmethod
    def from_file(cls, path: str, **kwargs: Any) -> SpectralBandpass:
        """Load a two-column ``(wavelength, throughput)`` response from a text file.

        Thin wrapper over :meth:`Spectrum.from_file` (same ``wavelength_to_nm``,
        ``delimiter``, ``skiprows``, ``usecols`` options); throughput must be in
        ``[0, 1]``.
        """
        spec = Spectrum.from_file(path, **kwargs)
        if np.any(spec.value > 1.0):
            raise ValueError("bandpass throughput must be in [0, 1].")
        return cls(spec)

    @classmethod
    def from_product(cls, *items: SpectralBandpass | Spectrum) -> SpectralBandpass:
        """Fold several transmission curves into one combined band response.

        Each item is a :class:`SpectralBandpass` or a bare :class:`Spectrum` (e.g. a
        :class:`QE` curve or an atmospheric-transmission curve); their pointwise
        product over the common wavelength support becomes the new response. This is
        how a *real* filter x QE x atmosphere transmission product is assembled.
        """
        specs = [it.response if isinstance(it, SpectralBandpass) else it for it in items]
        combined = product(*specs)
        if np.any(combined.value > 1.0):
            raise ValueError("combined throughput exceeds 1; check the input curves.")
        return cls(combined)

    @classmethod
    def tophat(cls, center_nm: float, width_nm: float, peak: float = 1.0) -> SpectralBandpass:
        """A flat-topped band of full width ``width_nm`` centred on ``center_nm``.

        Soft (one-sample) shoulders keep the curve continuous for integration.
        """
        if width_nm <= 0:
            raise ValueError("width_nm must be positive.")
        if not 0.0 < peak <= 1.0:
            raise ValueError("peak must be in (0, 1].")
        lo = center_nm - 0.5 * width_nm
        hi = center_nm + 0.5 * width_nm
        edge = max(width_nm * 1e-3, 1e-6)
        wl = np.array([lo - edge, lo, hi, hi + edge], dtype=np.float64)
        val = np.array([0.0, peak, peak, 0.0], dtype=np.float64)
        return cls(Spectrum(wl, val))

    # Representative Johnson-Cousins effective wavelengths and widths (nm). These
    # are coarse tophat stand-ins for the real filter curves --- enough to give a
    # sensible colour term; supply measured curves via ``from_arrays`` for rigour.
    _JOHNSON_NM: ClassVar[dict[str, tuple[float, float]]] = {
        "U": (365.0, 66.0),
        "B": (445.0, 94.0),
        "V": (551.0, 88.0),
        "R": (658.0, 138.0),
        "I": (806.0, 149.0),
    }

    @classmethod
    def johnson(cls, band: str) -> SpectralBandpass:
        """A tophat approximation of a Johnson-Cousins band (one of U, B, V, R, I)."""
        key = band.strip().upper()
        if key not in cls._JOHNSON_NM:
            valid = ", ".join(cls._JOHNSON_NM)
            raise ValueError(f"Unknown Johnson band {band!r}. Expected one of: {valid}.")
        center, width = cls._JOHNSON_NM[key]
        return cls.tophat(center, width)

    @property
    def pivot_wavelength_nm(self) -> float:
        """The pivot wavelength: ``sqrt(int T dl / int T l^-2 dl)`` (nm)."""
        wl = self.response.wavelength_nm
        t = self.response.value
        num = float(_trapezoid(t, wl))
        den = float(_trapezoid(t / wl**2, wl))
        return math.sqrt(num / den)

    @property
    def mean_wavelength_nm(self) -> float:
        """The throughput-weighted mean wavelength (nm)."""
        wl = self.response.wavelength_nm
        t = self.response.value
        return float(_trapezoid(t * wl, wl) / _trapezoid(t, wl))

pivot_wavelength_nm property

The pivot wavelength: sqrt(int T dl / int T l^-2 dl) (nm).

mean_wavelength_nm property

The throughput-weighted mean wavelength (nm).

from_arrays(wavelength_nm, throughput) classmethod

A response curve sampled at wavelength_nm with throughput in [0, 1].

Source code in src/getframes/spectral.py
331
332
333
334
335
336
337
@classmethod
def from_arrays(cls, wavelength_nm: ArrayLike, throughput: ArrayLike) -> SpectralBandpass:
    """A response curve sampled at ``wavelength_nm`` with throughput in ``[0, 1]``."""
    spec = Spectrum(_to_nm(wavelength_nm), _strip_units(throughput))
    if np.any(spec.value > 1.0):
        raise ValueError("bandpass throughput must be in [0, 1].")
    return cls(spec)

from_file(path, **kwargs) classmethod

Load a two-column (wavelength, throughput) response from a text file.

Thin wrapper over :meth:Spectrum.from_file (same wavelength_to_nm, delimiter, skiprows, usecols options); throughput must be in [0, 1].

Source code in src/getframes/spectral.py
339
340
341
342
343
344
345
346
347
348
349
350
@classmethod
def from_file(cls, path: str, **kwargs: Any) -> SpectralBandpass:
    """Load a two-column ``(wavelength, throughput)`` response from a text file.

    Thin wrapper over :meth:`Spectrum.from_file` (same ``wavelength_to_nm``,
    ``delimiter``, ``skiprows``, ``usecols`` options); throughput must be in
    ``[0, 1]``.
    """
    spec = Spectrum.from_file(path, **kwargs)
    if np.any(spec.value > 1.0):
        raise ValueError("bandpass throughput must be in [0, 1].")
    return cls(spec)

from_product(*items) classmethod

Fold several transmission curves into one combined band response.

Each item is a :class:SpectralBandpass or a bare :class:Spectrum (e.g. a :class:QE curve or an atmospheric-transmission curve); their pointwise product over the common wavelength support becomes the new response. This is how a real filter x QE x atmosphere transmission product is assembled.

Source code in src/getframes/spectral.py
352
353
354
355
356
357
358
359
360
361
362
363
364
365
@classmethod
def from_product(cls, *items: SpectralBandpass | Spectrum) -> SpectralBandpass:
    """Fold several transmission curves into one combined band response.

    Each item is a :class:`SpectralBandpass` or a bare :class:`Spectrum` (e.g. a
    :class:`QE` curve or an atmospheric-transmission curve); their pointwise
    product over the common wavelength support becomes the new response. This is
    how a *real* filter x QE x atmosphere transmission product is assembled.
    """
    specs = [it.response if isinstance(it, SpectralBandpass) else it for it in items]
    combined = product(*specs)
    if np.any(combined.value > 1.0):
        raise ValueError("combined throughput exceeds 1; check the input curves.")
    return cls(combined)

tophat(center_nm, width_nm, peak=1.0) classmethod

A flat-topped band of full width width_nm centred on center_nm.

Soft (one-sample) shoulders keep the curve continuous for integration.

Source code in src/getframes/spectral.py
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
@classmethod
def tophat(cls, center_nm: float, width_nm: float, peak: float = 1.0) -> SpectralBandpass:
    """A flat-topped band of full width ``width_nm`` centred on ``center_nm``.

    Soft (one-sample) shoulders keep the curve continuous for integration.
    """
    if width_nm <= 0:
        raise ValueError("width_nm must be positive.")
    if not 0.0 < peak <= 1.0:
        raise ValueError("peak must be in (0, 1].")
    lo = center_nm - 0.5 * width_nm
    hi = center_nm + 0.5 * width_nm
    edge = max(width_nm * 1e-3, 1e-6)
    wl = np.array([lo - edge, lo, hi, hi + edge], dtype=np.float64)
    val = np.array([0.0, peak, peak, 0.0], dtype=np.float64)
    return cls(Spectrum(wl, val))

johnson(band) classmethod

A tophat approximation of a Johnson-Cousins band (one of U, B, V, R, I).

Source code in src/getframes/spectral.py
395
396
397
398
399
400
401
402
403
@classmethod
def johnson(cls, band: str) -> SpectralBandpass:
    """A tophat approximation of a Johnson-Cousins band (one of U, B, V, R, I)."""
    key = band.strip().upper()
    if key not in cls._JOHNSON_NM:
        valid = ", ".join(cls._JOHNSON_NM)
        raise ValueError(f"Unknown Johnson band {band!r}. Expected one of: {valid}.")
    center, width = cls._JOHNSON_NM[key]
    return cls.tophat(center, width)

overlap_integral(*spectra)

Integrate the pointwise product of several spectra over their common range.

Returns 0.0 when the spectra do not overlap (the product is zero there).

Source code in src/getframes/spectral.py
171
172
173
174
175
176
177
178
179
180
181
182
def overlap_integral(*spectra: Spectrum) -> float:
    """Integrate the pointwise product of several spectra over their common range.

    Returns ``0.0`` when the spectra do not overlap (the product is zero there).
    """
    grid = _common_grid(*spectra)
    if grid.size < 2:
        return 0.0
    values = np.ones_like(grid)
    for s in spectra:
        values = values * s(grid)
    return float(_trapezoid(values, grid))

product(*spectra)

Pointwise product of several spectra as a new :class:Spectrum.

The result is sampled on the union of the inputs' knots within their common wavelength support, where the product of piecewise-linear curves is exact --- outside that support at least one factor is zero. The natural way to fold a measured filter transmission, detector QE, and atmospheric transmission into a single response curve. Raises if the inputs do not overlap.

Source code in src/getframes/spectral.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def product(*spectra: Spectrum) -> Spectrum:
    """Pointwise product of several spectra as a new :class:`Spectrum`.

    The result is sampled on the union of the inputs' knots within their common
    wavelength support, where the product of piecewise-linear curves is exact ---
    outside that support at least one factor is zero. The natural way to fold a
    measured filter transmission, detector QE, and atmospheric transmission into a
    single response curve. Raises if the inputs do not overlap.
    """
    grid = _common_grid(*spectra)
    if grid.size < 2:
        raise ValueError("spectra do not overlap; their product is empty.")
    values = np.ones_like(grid)
    for s in spectra:
        values = values * s(grid)
    return Spectrum(grid, values)

effective_qe(qe, bandpass, sed=None)

Photon-weighted effective quantum efficiency a source sees through a band.

Computes int S T QE dl / int S T dl over the wavelength range common to the SED, bandpass, and QE curve. sed defaults to a flat photon spectrum, giving the bandpass-weighted mean QE. The result is a dimensionless number in [0, 1] and is invariant to the absolute scale of both S and T.

Source code in src/getframes/spectral.py
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
def effective_qe(qe: QE, bandpass: SpectralBandpass, sed: SED | None = None) -> float:
    """Photon-weighted effective quantum efficiency a source sees through a band.

    Computes ``int S T QE dl / int S T dl`` over the wavelength range common to the
    SED, bandpass, and QE curve. ``sed`` defaults to a flat photon spectrum, giving
    the bandpass-weighted mean QE. The result is a dimensionless number in
    ``[0, 1]`` and is invariant to the absolute scale of both ``S`` and ``T``.
    """
    source = sed if sed is not None else SED.flat()
    response = bandpass.response
    denom = overlap_integral(source, response)
    if denom <= 0:
        raise ValueError(
            "SED and bandpass do not overlap the QE curve; cannot compute effective QE."
        )
    numer = overlap_integral(source, response, qe)
    return numer / denom

Analysis helpers

getframes.analysis.apertures

Lightweight photometry helpers used by the examples and for quick analysis.

These are intentionally minimal (pure NumPy, no extra dependencies). For serious photometry on real pipelines, reach for photutils; these exist so the bundled examples stay self-contained and readable.

aperture_sum(image, center, r, *, annulus=None)

Background-subtracted sum within radius r of center = (x, y).

The background level is the median of a surrounding annulus (default: from r + 2 to r + 5 pixels), scaled to the number of aperture pixels. Pass annulus=(inner, outer) to control it, or annulus=(0, 0) to skip background subtraction.

Source code in src/getframes/analysis/apertures.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def aperture_sum(
    image: NDArray[np.floating[Any] | np.integer[Any]],
    center: tuple[float, float],
    r: float,
    *,
    annulus: tuple[float, float] | None = None,
) -> float:
    """Background-subtracted sum within radius ``r`` of ``center = (x, y)``.

    The background level is the median of a surrounding annulus (default: from
    ``r + 2`` to ``r + 5`` pixels), scaled to the number of aperture pixels. Pass
    ``annulus=(inner, outer)`` to control it, or ``annulus=(0, 0)`` to skip
    background subtraction.
    """
    data = np.asarray(image, dtype=np.float64)
    cx, cy = center
    dist2 = _radial_grid(data.shape, cx, cy)
    in_aperture = dist2 <= r**2
    total = float(data[in_aperture].sum())

    inner, outer = annulus if annulus is not None else (r + 2.0, r + 5.0)
    if outer > inner:
        ring = (dist2 > inner**2) & (dist2 <= outer**2)
        if np.any(ring):
            background = float(np.median(data[ring]))
            total -= background * float(in_aperture.sum())
    return total

centroid(image, *, center=None, r=None, background=None)

Intensity-weighted centroid (x, y) of image (or a region of it).

Parameters:

Name Type Description Default
center tuple[float, float] | None

If both are given, only pixels within radius r of center are used (useful for isolating one spot). Otherwise the whole image is used.

None
r tuple[float, float] | None

If both are given, only pixels within radius r of center are used (useful for isolating one spot). Otherwise the whole image is used.

None
background float | None

Level subtracted before weighting, so the pedestal doesn't bias the centroid. Defaults to the image median, which works well for a small spot on a flat background.

None

Returns:

Type Description
(x, y):

Sub-pixel centroid. Returns the geometric centre if there is no positive signal after background subtraction.

Source code in src/getframes/analysis/apertures.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def centroid(
    image: NDArray[np.floating[Any] | np.integer[Any]],
    *,
    center: tuple[float, float] | None = None,
    r: float | None = None,
    background: float | None = None,
) -> tuple[float, float]:
    """Intensity-weighted centroid ``(x, y)`` of ``image`` (or a region of it).

    Parameters
    ----------
    center, r:
        If both are given, only pixels within radius ``r`` of ``center`` are used
        (useful for isolating one spot). Otherwise the whole image is used.
    background:
        Level subtracted before weighting, so the pedestal doesn't bias the
        centroid. Defaults to the image median, which works well for a small spot
        on a flat background.

    Returns
    -------
    (x, y):
        Sub-pixel centroid. Returns the geometric centre if there is no positive
        signal after background subtraction.
    """
    data = np.asarray(image, dtype=np.float64)
    bg = float(np.median(data)) if background is None else background
    weights = np.clip(data - bg, 0.0, None)

    if center is not None and r is not None:
        mask = _radial_grid(data.shape, *center) <= r**2
        weights = weights * mask

    total = float(weights.sum())
    yy, xx = np.mgrid[0 : data.shape[0], 0 : data.shape[1]]
    if total <= 0:
        return (data.shape[1] - 1) / 2.0, (data.shape[0] - 1) / 2.0
    cx = float((weights * xx).sum() / total)
    cy = float((weights * yy).sum() / total)
    return cx, cy

getframes.analysis.ptc

Photon transfer curve (PTC): characterise a camera from synthetic flats.

The PTC is the standard way to measure a detector's conversion gain. This module generates flat pairs at a range of light levels, builds the variance-vs-mean curve, and fits the gain --- turning the workflow in examples/06_photon_transfer_curve.py into a one-liner.

PTCResult dataclass

The outcome of :func:photon_transfer_curve.

Attributes:

Name Type Description
mean_adu, variance_adu2

The measured photon transfer curve: per-level mean signal and noise variance, both in ADU.

gain_e_per_adu float

Conversion gain fitted from the shot-noise-limited region (slope = 1/gain).

read_noise_e float

Read noise measured from a pair of bias frames.

full_well_adu float | None

Mean signal at which the variance peaks (onset of saturation), or None if the curve never rolls over within the sampled levels.

Source code in src/getframes/analysis/ptc.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@dataclass(frozen=True)
class PTCResult:
    """The outcome of :func:`photon_transfer_curve`.

    Attributes
    ----------
    mean_adu, variance_adu2:
        The measured photon transfer curve: per-level mean signal and noise
        variance, both in ADU.
    gain_e_per_adu:
        Conversion gain fitted from the shot-noise-limited region (slope = 1/gain).
    read_noise_e:
        Read noise measured from a pair of bias frames.
    full_well_adu:
        Mean signal at which the variance peaks (onset of saturation), or ``None``
        if the curve never rolls over within the sampled levels.
    """

    mean_adu: NDArray[np.float64]
    variance_adu2: NDArray[np.float64]
    gain_e_per_adu: float
    read_noise_e: float
    full_well_adu: float | None

photon_transfer_curve(camera, levels, exposure=1.0, *, temperature=None, seed=0)

Measure a photon transfer curve for camera over the given flux levels.

Parameters:

Name Type Description Default
camera Camera

The camera to characterise.

required
levels NDArray[float64]

Incident photon rates (photons/s/pixel) to sample, ascending. Span from a few electrons up past saturation to capture the full curve.

required
exposure float

Exposure time for each flat, in seconds.

1.0
temperature float | None

Sensor temperature; defaults to the camera's operating temperature.

None
seed int

Base seed; each flat uses a distinct derived seed for reproducibility.

0
Source code in src/getframes/analysis/ptc.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def photon_transfer_curve(
    camera: Camera,
    levels: NDArray[np.float64],
    exposure: float = 1.0,
    *,
    temperature: float | None = None,
    seed: int = 0,
) -> PTCResult:
    """Measure a photon transfer curve for ``camera`` over the given flux ``levels``.

    Parameters
    ----------
    camera:
        The camera to characterise.
    levels:
        Incident photon rates (photons/s/pixel) to sample, ascending. Span from a
        few electrons up past saturation to capture the full curve.
    exposure:
        Exposure time for each flat, in seconds.
    temperature:
        Sensor temperature; defaults to the camera's operating temperature.
    seed:
        Base seed; each flat uses a distinct derived seed for reproducibility.
    """
    levels = np.asarray(levels, dtype=np.float64)
    means = np.empty(levels.size)
    variances = np.empty(levels.size)
    for i, flux in enumerate(levels):
        # Two independent flats; differencing cancels fixed-pattern noise so the
        # variance reflects shot + read noise only.
        a = np.asarray(camera.flat_frame(flux, exposure, temperature, seed=seed + 2 * i), float)
        b = np.asarray(camera.flat_frame(flux, exposure, temperature, seed=seed + 2 * i + 1), float)
        means[i] = 0.5 * (a.mean() + b.mean())
        variances[i] = 0.5 * (a - b).var()

    gain = _fit_gain(camera, means, variances)

    # Read noise from two bias frames: var(b1 - b2) = 2 * read_noise^2.
    b1 = np.asarray(camera.bias_frame(temperature, seed=seed + 99991), float)
    b2 = np.asarray(camera.bias_frame(temperature, seed=seed + 99992), float)
    read_noise = float(np.sqrt(0.5 * (b1 - b2).var()) * gain)

    full_well = _full_well(means, variances)
    return PTCResult(means, variances, gain, read_noise, full_well)

Datasets & scale

getframes.dataset

Scalable raw + ground-truth dataset generation (roadmap phase 1.6).

The library's reason to exist is paired data: a realistic raw frame and the noise-free signal it was drawn from. :func:pairs turns a camera and a stream of :class:~getframes.scene.scene.Scene objects into a reproducible sequence of {"raw": ADU, "truth": electrons} pairs — training data for denoising, deconvolution, or calibration networks — and streams it to disk in float32 without ever holding the whole set in memory.

:func:random_star_fields is a convenience generator of random star-field scenes to feed it, but any iterable of scenes (matching the camera's resolution) works.

import getframes as gf cam = gf.Camera.from_preset("andor_ikon_m934", precision="float32") scenes = gf.dataset.random_star_fields(n=4, shape=cam.resolution, seed=0) ds = gf.dataset.pairs(camera=cam, scenes=scenes, exposure=10.0, seed=1) pair = next(iter(ds)) sorted(pair) ['raw', 'truth']

RandomStarFields

A reproducible, re-iterable stream of random star-field :class:Scene objects.

Each scene is a field of uniformly placed point sources with magnitudes drawn uniformly from mag_range and an optional uniform sky. The number of stars per field is fixed (int) or drawn per field from a (low, high) range. The stream is deterministic for a given seed (each field gets its own derived seed) and can be iterated more than once.

Construct via :func:random_star_fields.

Parameters:

Name Type Description Default
n int

Number of scenes in the stream.

required
shape tuple[int, int]

Scene size (height, width); must match the camera it is observed with.

required
optics Telescope | None

The :class:~getframes.scene.optics.Telescope and :class:~getframes.scene.psf.PSF shared by every field. Sensible generic defaults are used when omitted.

None
psf Telescope | None

The :class:~getframes.scene.optics.Telescope and :class:~getframes.scene.psf.PSF shared by every field. Sensible generic defaults are used when omitted.

None
n_stars int | tuple[int, int]

Stars per field — a fixed count, or a (low, high) range sampled per field.

(20, 200)
mag_range tuple[float, float]

(bright, faint) magnitude bounds for the uniform brightness draw.

(16.0, 22.0)
sky_mag_arcsec2 float | None

Optional uniform sky surface brightness (mag/arcsec^2); None for no sky.

21.0
seed int | None

Base seed; field i uses a distinct derived seed so the stream repeats.

None
Source code in src/getframes/dataset.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
class RandomStarFields:
    """A reproducible, re-iterable stream of random star-field :class:`Scene` objects.

    Each scene is a field of uniformly placed point sources with magnitudes drawn
    uniformly from ``mag_range`` and an optional uniform sky. The number of stars per
    field is fixed (``int``) or drawn per field from a ``(low, high)`` range. The
    stream is deterministic for a given ``seed`` (each field gets its own derived
    seed) and can be iterated more than once.

    Construct via :func:`random_star_fields`.

    Parameters
    ----------
    n:
        Number of scenes in the stream.
    shape:
        Scene size ``(height, width)``; must match the camera it is observed with.
    optics, psf:
        The :class:`~getframes.scene.optics.Telescope` and
        :class:`~getframes.scene.psf.PSF` shared by every field. Sensible generic
        defaults are used when omitted.
    n_stars:
        Stars per field — a fixed count, or a ``(low, high)`` range sampled per field.
    mag_range:
        ``(bright, faint)`` magnitude bounds for the uniform brightness draw.
    sky_mag_arcsec2:
        Optional uniform sky surface brightness (mag/arcsec^2); ``None`` for no sky.
    seed:
        Base seed; field ``i`` uses a distinct derived seed so the stream repeats.
    """

    def __init__(
        self,
        n: int,
        shape: tuple[int, int],
        *,
        optics: Telescope | None = None,
        psf: PSF | None = None,
        n_stars: int | tuple[int, int] = (20, 200),
        mag_range: tuple[float, float] = (16.0, 22.0),
        sky_mag_arcsec2: float | None = 21.0,
        seed: int | None = None,
    ) -> None:
        if n < 0:
            raise ValueError("n must be non-negative.")
        if len(shape) != 2 or any(s <= 0 for s in shape):
            raise ValueError(f"shape must be two positive ints, got {shape!r}.")
        self.n = int(n)
        self.shape = (int(shape[0]), int(shape[1]))
        self.optics = optics if optics is not None else _default_optics()
        self.psf = psf if psf is not None else GaussianPSF(fwhm_arcsec=2.5)
        self.n_stars = n_stars
        self.mag_range = (float(mag_range[0]), float(mag_range[1]))
        self.sky_mag_arcsec2 = sky_mag_arcsec2
        self.seed = seed

    def __len__(self) -> int:
        return self.n

    def _field_count(self, rng: np.random.Generator) -> int:
        if isinstance(self.n_stars, tuple):
            low, high = self.n_stars
            return int(rng.integers(low, high + 1))
        return int(self.n_stars)

    def _scene(self, rng: np.random.Generator) -> Scene:
        height, width = self.shape
        k = self._field_count(rng)
        xs = rng.uniform(0.0, width - 1, size=k)
        ys = rng.uniform(0.0, height - 1, size=k)
        mags = rng.uniform(self.mag_range[0], self.mag_range[1], size=k)
        sources = [
            PointSource(x=float(x), y=float(y), magnitude=float(m)) for x, y, m in zip(xs, ys, mags)
        ]
        sky = None if self.sky_mag_arcsec2 is None else Sky(self.sky_mag_arcsec2)
        return Scene(shape=self.shape, optics=self.optics, psf=self.psf, sources=sources, sky=sky)

    def __iter__(self) -> Iterator[Scene]:
        seeds: Iterable[np.random.SeedSequence | None]
        if self.seed is None:
            seeds = [None] * self.n
        else:
            seeds = np.random.SeedSequence(self.seed).spawn(self.n)
        for ss in seeds:
            yield self._scene(np.random.default_rng(ss))

PairDataset

A lazy, reproducible sequence of raw + truth pairs (see :func:pairs).

Iterating yields {"raw": ADU, "truth": electrons} dicts, one per input scene, each cast to :attr:dtype. The stream is single-pass when its scenes are a one-shot iterator; pass a re-iterable scene source (e.g. :class:RandomStarFields) to iterate more than once. Materialise to disk with :meth:to_npz or into stacked arrays with :meth:to_arrays.

Source code in src/getframes/dataset.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
class PairDataset:
    """A lazy, reproducible sequence of raw + truth pairs (see :func:`pairs`).

    Iterating yields ``{"raw": ADU, "truth": electrons}`` dicts, one per input
    scene, each cast to :attr:`dtype`. The stream is single-pass when its scenes are
    a one-shot iterator; pass a re-iterable scene source (e.g.
    :class:`RandomStarFields`) to iterate more than once. Materialise to disk with
    :meth:`to_npz` or into stacked arrays with :meth:`to_arrays`.
    """

    def __init__(
        self,
        camera: Camera,
        scenes: Iterable[Scene],
        exposure: float,
        *,
        temperature: float | None = None,
        dtype: DTypeLike = np.float32,
        seed: int | None = None,
    ) -> None:
        self.camera = camera
        self.scenes = scenes
        self.exposure = float(exposure)
        self.temperature = temperature
        self.dtype = np.dtype(dtype)
        self.seed = seed

    def __len__(self) -> int:
        try:
            return len(self.scenes)  # type: ignore[arg-type]
        except TypeError as exc:  # pragma: no cover - depends on the scene source
            raise TypeError("This PairDataset's scene source has no length.") from exc

    def _frame_seed(self, index: int) -> int | None:
        if self.seed is None:
            return None
        ss = np.random.SeedSequence([int(self.seed), _DATASET_STREAM, index])
        return int(ss.generate_state(1)[0])

    def __iter__(self) -> Iterator[Pair]:
        for i, scene in enumerate(self.scenes):
            frame = self.camera.observe(
                scene,
                self.exposure,
                self.temperature,
                seed=self._frame_seed(i),
                include_truth=True,
            )
            assert frame.truth is not None  # include_truth=True
            yield {
                "raw": np.asarray(frame.data, dtype=self.dtype),
                "truth": np.asarray(frame.truth.mean_electrons, dtype=self.dtype),
            }

    def to_npz(self, directory: str, *, prefix: str = "pair", compress: bool = False) -> list[str]:
        """Write each pair to ``{directory}/{prefix}_{i:06d}.npz`` and return the paths.

        Each archive holds ``raw`` (ADU) and ``truth`` (electrons) arrays in
        :attr:`dtype`. Streams pair by pair, so the whole set is never resident in
        memory. ``compress`` uses :func:`numpy.savez_compressed`.
        """
        out = Path(directory)
        out.mkdir(parents=True, exist_ok=True)
        writer = np.savez_compressed if compress else np.savez
        paths: list[str] = []
        for i, pair in enumerate(self):
            path = out / f"{prefix}_{i:06d}.npz"
            writer(path, raw=pair["raw"], truth=pair["truth"])
            paths.append(str(path))
        return paths

    def to_arrays(self) -> tuple[NDArray[np.floating[Any]], NDArray[np.floating[Any]]]:
        """Stack the whole dataset into ``(raw, truth)`` arrays of shape ``(N, H, W)``.

        Convenient for small sets; holds everything in memory, unlike :meth:`to_npz`.
        """
        raws: list[NDArray[np.floating[Any]]] = []
        truths: list[NDArray[np.floating[Any]]] = []
        for pair in self:
            raws.append(pair["raw"])
            truths.append(pair["truth"])
        if not raws:
            raise ValueError("Dataset is empty; nothing to stack.")
        return np.stack(raws, axis=0), np.stack(truths, axis=0)

to_npz(directory, *, prefix='pair', compress=False)

Write each pair to {directory}/{prefix}_{i:06d}.npz and return the paths.

Each archive holds raw (ADU) and truth (electrons) arrays in :attr:dtype. Streams pair by pair, so the whole set is never resident in memory. compress uses :func:numpy.savez_compressed.

Source code in src/getframes/dataset.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def to_npz(self, directory: str, *, prefix: str = "pair", compress: bool = False) -> list[str]:
    """Write each pair to ``{directory}/{prefix}_{i:06d}.npz`` and return the paths.

    Each archive holds ``raw`` (ADU) and ``truth`` (electrons) arrays in
    :attr:`dtype`. Streams pair by pair, so the whole set is never resident in
    memory. ``compress`` uses :func:`numpy.savez_compressed`.
    """
    out = Path(directory)
    out.mkdir(parents=True, exist_ok=True)
    writer = np.savez_compressed if compress else np.savez
    paths: list[str] = []
    for i, pair in enumerate(self):
        path = out / f"{prefix}_{i:06d}.npz"
        writer(path, raw=pair["raw"], truth=pair["truth"])
        paths.append(str(path))
    return paths

to_arrays()

Stack the whole dataset into (raw, truth) arrays of shape (N, H, W).

Convenient for small sets; holds everything in memory, unlike :meth:to_npz.

Source code in src/getframes/dataset.py
241
242
243
244
245
246
247
248
249
250
251
252
253
def to_arrays(self) -> tuple[NDArray[np.floating[Any]], NDArray[np.floating[Any]]]:
    """Stack the whole dataset into ``(raw, truth)`` arrays of shape ``(N, H, W)``.

    Convenient for small sets; holds everything in memory, unlike :meth:`to_npz`.
    """
    raws: list[NDArray[np.floating[Any]]] = []
    truths: list[NDArray[np.floating[Any]]] = []
    for pair in self:
        raws.append(pair["raw"])
        truths.append(pair["truth"])
    if not raws:
        raise ValueError("Dataset is empty; nothing to stack.")
    return np.stack(raws, axis=0), np.stack(truths, axis=0)

random_star_fields(n, shape, *, optics=None, psf=None, n_stars=(20, 200), mag_range=(16.0, 22.0), sky_mag_arcsec2=21.0, seed=None)

Build a reproducible :class:RandomStarFields stream of n star-field scenes.

A convenience source of scenes for :func:pairs; see :class:RandomStarFields for the parameters.

Source code in src/getframes/dataset.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def random_star_fields(
    n: int,
    shape: tuple[int, int],
    *,
    optics: Telescope | None = None,
    psf: PSF | None = None,
    n_stars: int | tuple[int, int] = (20, 200),
    mag_range: tuple[float, float] = (16.0, 22.0),
    sky_mag_arcsec2: float | None = 21.0,
    seed: int | None = None,
) -> RandomStarFields:
    """Build a reproducible :class:`RandomStarFields` stream of ``n`` star-field scenes.

    A convenience source of scenes for :func:`pairs`; see :class:`RandomStarFields`
    for the parameters.
    """
    return RandomStarFields(
        n,
        shape,
        optics=optics,
        psf=psf,
        n_stars=n_stars,
        mag_range=mag_range,
        sky_mag_arcsec2=sky_mag_arcsec2,
        seed=seed,
    )

pairs(*, camera, scenes, exposure, temperature=None, dtype=np.float32, seed=None)

Build a :class:PairDataset of raw + truth pairs from a camera and scenes.

Parameters:

Name Type Description Default
camera Camera

The :class:~getframes.camera.Camera that observes each scene. Construct it with precision="float32" to render the signal chain in the fast path too.

required
scenes Iterable[Scene]

Any iterable of :class:~getframes.scene.scene.Scene matching the camera's resolution (e.g. :func:random_star_fields).

required
exposure float

Integration time in seconds for every frame.

required
temperature float | None

Sensor temperature (deg C); defaults to the camera's.

None
dtype DTypeLike

Storage dtype for the raw/truth arrays (float32 by default to halve on-disk size; the ADU are exact integers either way).

float32
seed int | None

Base seed; frame i draws a distinct derived seed, so the whole dataset is reproducible yet the frames are independent.

None
Source code in src/getframes/dataset.py
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def pairs(
    *,
    camera: Camera,
    scenes: Iterable[Scene],
    exposure: float,
    temperature: float | None = None,
    dtype: DTypeLike = np.float32,
    seed: int | None = None,
) -> PairDataset:
    """Build a :class:`PairDataset` of raw + truth pairs from a camera and scenes.

    Parameters
    ----------
    camera:
        The :class:`~getframes.camera.Camera` that observes each scene. Construct it
        with ``precision="float32"`` to render the signal chain in the fast path too.
    scenes:
        Any iterable of :class:`~getframes.scene.scene.Scene` matching the camera's
        resolution (e.g. :func:`random_star_fields`).
    exposure:
        Integration time in seconds for every frame.
    temperature:
        Sensor temperature (deg C); defaults to the camera's.
    dtype:
        Storage dtype for the ``raw``/``truth`` arrays (``float32`` by default to
        halve on-disk size; the ADU are exact integers either way).
    seed:
        Base seed; frame ``i`` draws a distinct derived seed, so the whole dataset is
        reproducible yet the frames are independent.
    """
    return PairDataset(camera, scenes, exposure, temperature=temperature, dtype=dtype, seed=seed)

Command line

getframes.cli

The getframes command-line interface (roadmap phase 1.6).

A thin wrapper that turns a TOML configuration file into frames or an ML dataset, so an experiment is a file you can share and run without writing Python. Three subcommands:

  • getframes presets — list the built-in camera presets.
  • getframes generate config.toml -o frame.fits — generate one frame (or a short series) of a given type (dark/bias/flat/light).
  • getframes dataset config.toml -o train/ — stream raw + truth pairs to disk.

See :func:main. Run getframes --help for the full usage.

build_parser()

Construct the getframes argument parser.

Source code in src/getframes/cli.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
def build_parser() -> argparse.ArgumentParser:
    """Construct the ``getframes`` argument parser."""
    parser = argparse.ArgumentParser(
        prog="getframes",
        description="Generate physically realistic synthetic camera frames from a config file.",
    )
    parser.add_argument("--version", action="version", version=f"getframes {__version__}")
    sub = parser.add_subparsers(dest="command", required=True)

    p_presets = sub.add_parser("presets", help="List the built-in camera presets.")
    p_presets.set_defaults(func=_cmd_presets)

    p_gen = sub.add_parser("generate", help="Generate a frame (or series) from a config file.")
    p_gen.add_argument("config", help="Path to a TOML config file.")
    p_gen.add_argument(
        "-o", "--output", default=None, help="Output path (.fits/.npy/.npz); omit to print stats."
    )
    p_gen.set_defaults(func=_cmd_generate)

    p_ds = sub.add_parser("dataset", help="Generate a raw+truth dataset from a config file.")
    p_ds.add_argument("config", help="Path to a TOML config file.")
    p_ds.add_argument("-o", "--output", required=True, help="Output directory for the .npz pairs.")
    p_ds.set_defaults(func=_cmd_dataset)
    return parser

main(argv=None)

Entry point for the getframes command. Returns a process exit code.

Source code in src/getframes/cli.py
202
203
204
205
206
207
208
209
210
def main(argv: list[str] | None = None) -> int:
    """Entry point for the ``getframes`` command. Returns a process exit code."""
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        exit_code: int = args.func(args)
    except (ValueError, KeyError, FileNotFoundError) as exc:
        parser.error(str(exc))
    return exit_code

Noise models

getframes.noise

Physical noise models that turn a :class:CameraConfig into pixel values.

The models here are deliberately small, composable, and well-documented so that the physics is auditable. Each function takes a configuration, exposure, and a seeded :class:numpy.random.Generator, and returns electrons or ADU.

Signal chain (:func:simulate_frame)
  1. Mean photo signal: (photon_rate + background) * t_exp * QE electrons, modulated per pixel by photo-response non-uniformity (PRNU).
  2. Mean dark signal: D(T) * t_exp electrons (temperature-scaled), modulated by dark-signal non-uniformity (DSNU) and hot pixels.
  3. Shot noise: the total electrons are Poisson-distributed about that mean.
  4. Clock-induced charge (EMCCD) adds a small Poisson term.
  5. Cosmic rays (single pixels or extended tracks).
  6. Charge-transport artifacts: blooming along saturated columns, CCD charge-transfer inefficiency (CTI), and inter-pixel capacitance (IPC).
  7. Detector nonlinearity (single-parameter or polynomial).
  8. EM register / avalanche multiplication with its stochastic excess noise.
  9. kTC/reset noise and read noise: Gaussian in electrons, at the output amplifier.
  10. Conversion to ADU via (optionally per-amplifier) gain, plus the bias pedestal and any structured-bias pattern; dead pixels/columns read as defects.
  11. Saturation at full well / ADC range and quantisation to integers.

A dark frame is simply the special case photon_rate = 0.

SimulationResult

Bases: NamedTuple

The output of :func:simulate_frame: the digitised frame plus ground truth.

Source code in src/getframes/noise.py
488
489
490
491
492
493
494
class SimulationResult(NamedTuple):
    """The output of :func:`simulate_frame`: the digitised frame plus ground truth."""

    adu: NDArray[np.uint32]
    mean_photoelectrons: NDArray[np.float64]
    mean_dark_electrons: NDArray[np.float64]
    photon_rate: PhotonRate

dark_signal_map(config, exposure_s, temperature_c, float_dtype=DEFAULT_FLOAT_DTYPE)

Per-pixel mean dark signal in electrons, including fixed-pattern structure.

This is the noise-free expectation per pixel; shot noise is applied separately. The fixed-pattern structure (DSNU and hot pixels) is deterministic for a given sensor (keyed on :attr:~getframes.config.CameraConfig.fixed_pattern_seed), so it repeats across frames and can be calibrated out with a master dark. A uniform detector-glow term (detector_glow_e_per_s) is added on top, also exposure-scaled and dark-removable.

float_dtype selects the working precision (float64 exact default, or float32 for the memory-light fast path).

Source code in src/getframes/noise.py
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def dark_signal_map(
    config: CameraConfig,
    exposure_s: float,
    temperature_c: float,
    float_dtype: DTypeLike = DEFAULT_FLOAT_DTYPE,
) -> NDArray[np.float64]:
    """Per-pixel *mean* dark signal in electrons, including fixed-pattern structure.

    This is the noise-free expectation per pixel; shot noise is applied separately.
    The fixed-pattern structure (DSNU and hot pixels) is deterministic for a given
    sensor (keyed on :attr:`~getframes.config.CameraConfig.fixed_pattern_seed`), so
    it repeats across frames and can be calibrated out with a master dark. A uniform
    detector-glow term (``detector_glow_e_per_s``) is added on top, also
    exposure-scaled and dark-removable.

    ``float_dtype`` selects the working precision (``float64`` exact default, or
    ``float32`` for the memory-light fast path).
    """
    height, width = config.resolution
    mean_dark = config.dark_current_at(temperature_c) * exposure_s
    signal = np.full((height, width), mean_dark, dtype=float_dtype)

    # Dark-signal non-uniformity: log-normal so the per-pixel gain stays positive
    # with unit mean. Drawn from the fixed-pattern stream (same every frame).
    if config.dark_current_nonuniformity > 0 and mean_dark > 0:
        sigma = config.dark_current_nonuniformity
        rng = _fixed_pattern_rng(config, _FPN_STREAM_DSNU)
        dsnu = rng.lognormal(mean=-0.5 * sigma**2, sigma=sigma, size=signal.shape)
        signal *= dsnu

    # Hot pixels: a sparse, *fixed* population with strongly elevated dark current.
    if config.hot_pixel_fraction > 0 and mean_dark > 0:
        rng = _fixed_pattern_rng(config, _FPN_STREAM_HOT)
        hot_mask = rng.random(signal.shape) < config.hot_pixel_fraction
        signal[hot_mask] *= config.hot_pixel_factor

    # Detector glow: a uniform self-emission term that scales with exposure (and so
    # is removed by an exposure-matched master dark). Added after DSNU/hot pixels,
    # which describe the dark *current*, not the glow. In place to preserve dtype.
    if config.detector_glow_e_per_s > 0 and exposure_s > 0:
        signal += config.detector_glow_e_per_s * exposure_s

    return signal

photo_signal_map(config, photon_rate, exposure_s, background_photon_rate, quantum_efficiency=None, float_dtype=DEFAULT_FLOAT_DTYPE)

Per-pixel mean photo-generated signal in electrons (noise-free).

Converts an incident photon rate (photons/s/pixel, plus an additive background) to photoelectrons via the quantum efficiency, then imprints a fixed multiplicative PRNU pattern. photon_rate may be a scalar (uniform illumination) or a 2-D array matching the sensor resolution.

The PRNU pattern is deterministic for a given sensor (keyed on :attr:~getframes.config.CameraConfig.fixed_pattern_seed), so it repeats across frames and is removable with a master flat.

quantum_efficiency overrides config.quantum_efficiency when given. The spectral path uses this with a pre-multiplied (already-photoelectron) map and quantum_efficiency = 1.0. float_dtype selects the working precision (float64 default, or float32 for the memory-light fast path).

Source code in src/getframes/noise.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def photo_signal_map(
    config: CameraConfig,
    photon_rate: PhotonRate,
    exposure_s: float,
    background_photon_rate: PhotonRate,
    quantum_efficiency: float | None = None,
    float_dtype: DTypeLike = DEFAULT_FLOAT_DTYPE,
) -> NDArray[np.float64]:
    """Per-pixel *mean* photo-generated signal in electrons (noise-free).

    Converts an incident photon rate (photons/s/pixel, plus an additive
    background) to photoelectrons via the quantum efficiency, then imprints a
    fixed multiplicative PRNU pattern. ``photon_rate`` may be a scalar (uniform
    illumination) or a 2-D array matching the sensor resolution.

    The PRNU pattern is deterministic for a given sensor (keyed on
    :attr:`~getframes.config.CameraConfig.fixed_pattern_seed`), so it repeats across
    frames and is removable with a master flat.

    ``quantum_efficiency`` overrides ``config.quantum_efficiency`` when given. The
    spectral path uses this with a pre-multiplied (already-photoelectron) map and
    ``quantum_efficiency = 1.0``. ``float_dtype`` selects the working precision
    (``float64`` default, or ``float32`` for the memory-light fast path).
    """
    height, width = config.resolution
    qe = config.quantum_efficiency if quantum_efficiency is None else quantum_efficiency
    rate = np.asarray(photon_rate, dtype=np.float64)
    background = np.asarray(background_photon_rate, dtype=np.float64)
    if rate.ndim not in (0, 2) or background.ndim not in (0, 2):
        raise ValueError("photon_rate/background must be a scalar or a 2-D array.")

    mean_photo = np.zeros((height, width), dtype=float_dtype)
    # Broadcasts a scalar or an (h, w) array; a mismatched array shape raises here.
    mean_photo += (rate + background) * exposure_s * qe

    # Photo-response non-uniformity: a fixed log-normal multiplier with unit mean,
    # applied only where there is light. Drawn from the fixed-pattern stream so it is
    # the same pattern in every frame (a master flat can remove it).
    if config.prnu > 0 and np.any(mean_photo > 0):
        sigma = config.prnu
        rng = _fixed_pattern_rng(config, _FPN_STREAM_PRNU)
        prnu = rng.lognormal(mean=-0.5 * sigma**2, sigma=sigma, size=mean_photo.shape)
        mean_photo *= prnu

    return mean_photo

apply_gain_stage(electrons, gain, excess_noise_factor, rng)

Apply a stochastic multiplication stage (EM register or APD avalanche).

A single model covers both EMCCDs and avalanche photodiodes, parameterised by the mean gain G and the excess noise factor F. For n input electrons the multiplied output is drawn from a Gamma distribution:

.. math::

\text{out} \sim \mathrm{Gamma}(\text{shape}=n\alpha,\ \text{scale}=\theta),
\quad \alpha = \frac{1}{F^2 - 1}, \quad \theta = G\,(F^2 - 1).

Then :math:E[\text{out}] = nG and, with Poisson input of mean :math:\mu, the total output variance is :math:G^2 F^2 \mu --- i.e. the model reproduces the requested excess noise factor exactly. Special cases:

  • F = sqrt(2) gives alpha = 1 --- the classic EMCCD Gamma(n, G) model.
  • F -> 1 is noiseless multiplication (deterministic n * G).

Pixels with zero input electrons produce zero output.

Source code in src/getframes/noise.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def apply_gain_stage(
    electrons: NDArray[np.float64],
    gain: float,
    excess_noise_factor: float,
    rng: np.random.Generator,
) -> NDArray[np.float64]:
    r"""Apply a stochastic multiplication stage (EM register or APD avalanche).

    A single model covers both EMCCDs and avalanche photodiodes, parameterised by
    the mean gain ``G`` and the excess noise factor ``F``. For ``n`` input
    electrons the multiplied output is drawn from a Gamma distribution:

    .. math::

        \text{out} \sim \mathrm{Gamma}(\text{shape}=n\alpha,\ \text{scale}=\theta),
        \quad \alpha = \frac{1}{F^2 - 1}, \quad \theta = G\,(F^2 - 1).

    Then :math:`E[\text{out}] = nG` and, with Poisson input of mean :math:`\mu`, the
    total output variance is :math:`G^2 F^2 \mu` --- i.e. the model reproduces the
    requested excess noise factor exactly. Special cases:

    * ``F = sqrt(2)`` gives ``alpha = 1`` --- the classic EMCCD ``Gamma(n, G)`` model.
    * ``F -> 1`` is noiseless multiplication (deterministic ``n * G``).

    Pixels with zero input electrons produce zero output.
    """
    if gain <= 1.0:
        return electrons
    if excess_noise_factor <= 1.0:
        return electrons * gain  # noiseless multiplication

    f2 = excess_noise_factor**2
    alpha = 1.0 / (f2 - 1.0)
    theta = gain * (f2 - 1.0)
    out = np.zeros_like(electrons)
    nonzero = electrons > 0
    if np.any(nonzero):
        out[nonzero] = rng.gamma(shape=electrons[nonzero] * alpha, scale=theta)
    return out

apply_em_gain(electrons, em_gain, rng)

Backwards-compatible EMCCD multiplication (F = sqrt(2) gain stage).

Thin wrapper over :func:apply_gain_stage; prefer that for new code.

Source code in src/getframes/noise.py
204
205
206
207
208
209
210
211
212
213
def apply_em_gain(
    electrons: NDArray[np.float64],
    em_gain: float,
    rng: np.random.Generator,
) -> NDArray[np.float64]:
    """Backwards-compatible EMCCD multiplication (``F = sqrt(2)`` gain stage).

    Thin wrapper over :func:`apply_gain_stage`; prefer that for new code.
    """
    return apply_gain_stage(electrons, em_gain, np.sqrt(2.0), rng)

apply_nonlinearity(electrons, config)

Bend the charge response near full well (detector nonlinearity).

Two models, both deterministic (no randomness):

  • Polynomial (when config.nonlinearity_coeffs is set): with u = q / full_well and coefficients (c1, c2, ...), the response multiplier is 1 + c1 u + c2 u**2 + ..., so an arbitrary measured curve or look-up can be reproduced.
  • Single-parameter (the default): q -> q * (1 - nonlinearity * q / full_well), a smooth, monotonic compression so a pixel near full well reads slightly low.

The polynomial model takes precedence when both are configured.

Source code in src/getframes/noise.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
def apply_nonlinearity(
    electrons: NDArray[np.float64],
    config: CameraConfig,
) -> NDArray[np.float64]:
    """Bend the charge response near full well (detector nonlinearity).

    Two models, both deterministic (no randomness):

    * **Polynomial** (when ``config.nonlinearity_coeffs`` is set): with
      ``u = q / full_well`` and coefficients ``(c1, c2, ...)``, the response
      multiplier is ``1 + c1 u + c2 u**2 + ...``, so an arbitrary measured curve or
      look-up can be reproduced.
    * **Single-parameter** (the default): ``q -> q * (1 - nonlinearity * q /
      full_well)``, a smooth, monotonic compression so a pixel near full well reads
      slightly low.

    The polynomial model takes precedence when both are configured.
    """
    if config.nonlinearity_coeffs is not None:
        u = np.clip(electrons, 0.0, None) / config.full_well_e
        factor = np.ones_like(u)
        for power, coeff in enumerate(config.nonlinearity_coeffs, start=1):
            factor = factor + coeff * u**power
        bent: NDArray[np.float64] = electrons * np.clip(factor, 0.0, None)
        return bent
    if config.nonlinearity <= 0:
        return electrons
    factor = 1.0 - config.nonlinearity * np.clip(electrons, 0.0, None) / config.full_well_e
    return electrons * np.clip(factor, 0.0, None)

apply_blooming(electrons, full_well_e)

Bleed charge above full well along columns (CCD blooming).

Charge exceeding full_well_e in a pixel floods symmetrically into the vacant pixels of the same column (axis=0): half the excess sweeps toward higher rows and half toward lower rows, each filling successive pixels up to full well until the charge is absorbed or runs off the array edge. Deterministic and charge-conserving except for charge that bleeds off the top/bottom edge.

Source code in src/getframes/noise.py
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def apply_blooming(
    electrons: NDArray[np.float64],
    full_well_e: float,
) -> NDArray[np.float64]:
    """Bleed charge above full well along columns (CCD blooming).

    Charge exceeding ``full_well_e`` in a pixel floods symmetrically into the
    vacant pixels of the same column (``axis=0``): half the excess sweeps toward
    higher rows and half toward lower rows, each filling successive pixels up to
    full well until the charge is absorbed or runs off the array edge. Deterministic
    and charge-conserving except for charge that bleeds off the top/bottom edge.
    """
    out = np.array(electrons, copy=True)
    excess = np.clip(out - full_well_e, 0.0, None)
    if not excess.any():
        return out
    n_rows, width = out.shape
    out = np.minimum(out, full_well_e)
    # Split the overflow and flood it outward, each direction in a single sweep with
    # a per-column carry; a vacant pixel can only ever be filled up to full well, so
    # charge never flows back into an already-saturated pixel (no oscillation).
    down_share = 0.5 * excess
    up_share = excess - down_share
    for source, rows in ((down_share, range(n_rows)), (up_share, range(n_rows - 1, -1, -1))):
        carry = np.zeros(width, dtype=out.dtype)
        for r in rows:
            incoming = carry + source[r]
            room = full_well_e - out[r]
            fill = np.minimum(incoming, room)
            out[r] += fill
            carry = incoming - fill
        # Any charge still carried past the edge bleeds off the array.
    return out

apply_cti(electrons, cti)

Smear charge by charge-transfer inefficiency (CTI) during readout.

A first-order, charge-conserving model: the readout register is row 0, so a pixel r rows away undergoes r transfers and defers a fraction cti * r of its charge into the trailing pixel one row farther from the register (axis=0), producing the characteristic CTI tail. Charge deferred past the final row is lost into overscan. Deterministic.

Source code in src/getframes/noise.py
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
def apply_cti(
    electrons: NDArray[np.float64],
    cti: float,
) -> NDArray[np.float64]:
    """Smear charge by charge-transfer inefficiency (CTI) during readout.

    A first-order, charge-conserving model: the readout register is row 0, so a
    pixel ``r`` rows away undergoes ``r`` transfers and defers a fraction
    ``cti * r`` of its charge into the trailing pixel one row farther from the
    register (``axis=0``), producing the characteristic CTI tail. Charge deferred
    past the final row is lost into overscan. Deterministic.
    """
    if cti <= 0:
        return electrons
    out = np.array(electrons, copy=True)
    n_rows = out.shape[0]
    transfers = np.arange(n_rows, dtype=np.float64).reshape(n_rows, 1)
    deferred = np.minimum(cti * transfers * out, out)
    out -= deferred
    out[1:] += deferred[:-1]
    return out

apply_ipc(electrons, coupling)

Couple a fraction of each pixel into its four neighbours (inter-pixel capacitance).

Convolves with the charge-conserving 3x3 kernel whose centre is 1 - 4*coupling and whose four edge-adjacent taps are coupling each (corners zero). Models the capacitive crosstalk of CMOS / IR hybrid arrays. Charge coupling past the array boundary is lost. Deterministic.

Source code in src/getframes/noise.py
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
def apply_ipc(
    electrons: NDArray[np.float64],
    coupling: float,
) -> NDArray[np.float64]:
    """Couple a fraction of each pixel into its four neighbours (inter-pixel capacitance).

    Convolves with the charge-conserving 3x3 kernel whose centre is
    ``1 - 4*coupling`` and whose four edge-adjacent taps are ``coupling`` each
    (corners zero). Models the capacitive crosstalk of CMOS / IR hybrid arrays.
    Charge coupling past the array boundary is lost. Deterministic.
    """
    if coupling <= 0:
        return electrons
    kernel = np.array(
        [[0.0, coupling, 0.0], [coupling, 1.0 - 4.0 * coupling, coupling], [0.0, coupling, 0.0]],
        dtype=np.float64,
    )
    convolved = ndimage.convolve(electrons, kernel, mode="constant", cval=0.0)
    # Preserve the input dtype (the float32 fast path) — convolve upcasts to float64.
    result: NDArray[np.float64] = convolved.astype(electrons.dtype, copy=False)
    return result

add_cosmic_rays(electrons, config, exposure_s, rng)

Deposit cosmic-ray charge bursts into random pixels.

The number of hits is Poisson with mean rate * area * exposure; each hit carries a broad charge burst of order ten thousand electrons. When config.cosmic_ray_track_length_px is zero the charge lands in a single pixel; when positive, each hit draws an exponential track length and a random in-plane direction (a glancing muon) and spreads its charge evenly along the track --- the extended morphology a real rejection pipeline must handle.

Source code in src/getframes/noise.py
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
def add_cosmic_rays(
    electrons: NDArray[np.float64],
    config: CameraConfig,
    exposure_s: float,
    rng: np.random.Generator,
) -> NDArray[np.float64]:
    """Deposit cosmic-ray charge bursts into random pixels.

    The number of hits is Poisson with mean ``rate * area * exposure``; each hit
    carries a broad charge burst of order ten thousand electrons. When
    ``config.cosmic_ray_track_length_px`` is zero the charge lands in a single
    pixel; when positive, each hit draws an exponential track length and a random
    in-plane direction (a glancing muon) and spreads its charge evenly along the
    track --- the extended morphology a real rejection pipeline must handle.
    """
    height, width = electrons.shape
    pixel_cm = config.pixel_size_um * 1e-4
    area_cm2 = height * width * pixel_cm**2
    expected = config.cosmic_ray_rate_per_cm2_s * area_cm2 * exposure_s
    n_hits = int(rng.poisson(expected))
    if n_hits == 0:
        return electrons
    ys = rng.integers(0, height, n_hits)
    xs = rng.integers(0, width, n_hits)
    # Charge per hit: a broad distribution centred on ~10,000 e-.
    charges = rng.gamma(shape=2.0, scale=5000.0, size=n_hits)

    if config.cosmic_ray_track_length_px <= 0:
        np.add.at(electrons, (ys, xs), charges)
        return electrons

    lengths = rng.exponential(config.cosmic_ray_track_length_px, size=n_hits)
    angles = rng.uniform(0.0, 2.0 * np.pi, size=n_hits)
    for x0, y0, charge, length, angle in zip(xs, ys, charges, lengths, angles):
        n_steps = max(1, round(float(length)))
        steps = np.arange(n_steps)
        tx = np.clip(np.round(x0 + np.cos(angle) * steps).astype(int), 0, width - 1)
        ty = np.clip(np.round(y0 + np.sin(angle) * steps).astype(int), 0, height - 1)
        np.add.at(electrons, (ty, tx), charge / n_steps)
    return electrons

digitize(electrons, config, rng)

Add read/reset noise, convert electrons to ADU, then saturate and quantise.

Read noise is referenced to the sensor output amplifier. When read_noise_nonuniformity is set (sCMOS), each pixel gets its own read-noise RMS drawn from a log-normal distribution about read_noise_e.

Detector-depth structure is folded in here: dead pixels/columns collect no charge; kTC/reset noise adds a per-pixel Gaussian; a multi-amplifier layout applies per-block conversion gain and offset; and a fixed structured-bias pattern rides on the flat pedestal.

Source code in src/getframes/noise.py
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
def digitize(
    electrons: NDArray[np.float64],
    config: CameraConfig,
    rng: np.random.Generator,
) -> NDArray[np.uint32]:
    """Add read/reset noise, convert electrons to ADU, then saturate and quantise.

    Read noise is referenced to the sensor output amplifier. When
    ``read_noise_nonuniformity`` is set (sCMOS), each pixel gets its own read-noise
    RMS drawn from a log-normal distribution about ``read_noise_e``.

    Detector-depth structure is folded in here: dead pixels/columns collect no
    charge; kTC/reset noise adds a per-pixel Gaussian; a multi-amplifier layout
    applies per-block conversion gain and offset; and a fixed structured-bias
    pattern rides on the flat pedestal.
    """
    signal = np.clip(electrons, 0.0, None)
    signal = np.minimum(signal, config.full_well_e)

    # Dead pixels/columns: a fixed defect map that collects no charge (they still
    # carry read/reset noise and the bias pedestal, so they read as dark defects).
    defects = _defect_mask(config)
    if defects is not None:
        signal[defects] = 0.0

    # kTC / reset noise: an independent per-pixel, per-frame Gaussian (electrons).
    # Added in place so the working dtype (e.g. the float32 fast path) is preserved.
    if config.reset_noise_e > 0:
        signal += rng.normal(0.0, config.reset_noise_e, size=signal.shape)

    # Read noise in electrons, added at the amplifier.
    if config.read_noise_e > 0:
        if config.read_noise_nonuniformity > 0:
            spread = config.read_noise_nonuniformity
            sigma_map = config.read_noise_e * rng.lognormal(
                mean=-0.5 * spread**2, sigma=spread, size=signal.shape
            )
            signal += rng.standard_normal(signal.shape) * sigma_map
        else:
            signal += rng.normal(0.0, config.read_noise_e, size=signal.shape)

    gain_map, amp_offset = _amplifier_maps(config)
    adu = signal / gain_map + config.bias_offset_adu + amp_offset + _bias_structure_map(config)
    adu = np.clip(np.round(adu), 0, config.max_adu)
    return adu.astype(np.uint32)

frame_electrons(config, mean_electrons, rng, exposure_s=0.0)

Apply shot noise, CIC, cosmic rays, nonlinearity, and any gain stage.

Takes the noise-free expected electrons per pixel and returns a realised electron frame prior to read noise and digitisation. exposure_s is needed only to scale the cosmic-ray rate. The working dtype follows mean_electrons (float64 exact, or float32 for the memory-light fast path).

Source code in src/getframes/noise.py
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
def frame_electrons(
    config: CameraConfig,
    mean_electrons: NDArray[np.float64],
    rng: np.random.Generator,
    exposure_s: float = 0.0,
) -> NDArray[np.float64]:
    """Apply shot noise, CIC, cosmic rays, nonlinearity, and any gain stage.

    Takes the noise-free expected electrons per pixel and returns a realised
    electron frame prior to read noise and digitisation. ``exposure_s`` is needed
    only to scale the cosmic-ray rate. The working dtype follows ``mean_electrons``
    (``float64`` exact, or ``float32`` for the memory-light fast path).
    """
    electrons = rng.poisson(mean_electrons).astype(mean_electrons.dtype)

    if config.clock_induced_charge_e > 0:
        electrons += rng.poisson(config.clock_induced_charge_e, size=electrons.shape)

    if config.cosmic_ray_rate_per_cm2_s > 0 and exposure_s > 0:
        electrons = add_cosmic_rays(electrons, config, exposure_s, rng)

    if config.blooming:
        electrons = apply_blooming(electrons, config.full_well_e)

    if config.cti > 0:
        electrons = apply_cti(electrons, config.cti)

    if config.ipc_coupling > 0:
        electrons = apply_ipc(electrons, config.ipc_coupling)

    if config.nonlinearity > 0 or config.nonlinearity_coeffs is not None:
        electrons = apply_nonlinearity(electrons, config)

    if config.has_gain_stage:
        electrons = apply_gain_stage(
            electrons, config.em_gain, config.gain_excess_noise_factor, rng
        )

    return electrons

simulate_frame(config, photon_rate, exposure_s, *, temperature_c, background_photon_rate=0.0, quantum_efficiency=None, extra_electrons=0.0, rng=None, seed=None, float_dtype=DEFAULT_FLOAT_DTYPE)

Simulate one frame end-to-end, returning ADU and the noise-free truth.

Parameters:

Name Type Description Default
config CameraConfig

The detector configuration.

required
photon_rate PhotonRate

Incident photon rate in photons/s/pixel, as a scalar (uniform) or a 2-D array. Use 0.0 for a dark/bias frame.

required
exposure_s float

Integration time in seconds (0 for a bias frame).

required
temperature_c float

Sensor temperature in degrees Celsius.

required
background_photon_rate PhotonRate

Additive background (sky/thermal) photon rate in photons/s/pixel.

0.0
quantum_efficiency float | None

Overrides config.quantum_efficiency for the photon-to-electron step (used by spectral mode with a pre-converted electron map and 1.0).

None
extra_electrons PhotonRate

Additive noise-free signal already in electrons (scalar or 2-D array), injected before shot noise and the gain stage. Used to carry latent charge from image persistence across the frames of an observation; it is real charge in the well, so it picks up shot noise and any EM/avalanche gain.

0.0
rng Generator | None

Provide an existing generator, or a seed to build a fresh one.

None
seed Generator | None

Provide an existing generator, or a seed to build a fresh one.

None
float_dtype DTypeLike

Working floating-point precision of the per-pixel arrays: float64 (the exact default) or float32 for the memory-light fast path used for large detectors and bulk dataset generation. The digitised ADU stay integer regardless; only the floating-point signal chain and the truth arrays change.

DEFAULT_FLOAT_DTYPE
Source code in src/getframes/noise.py
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
def simulate_frame(
    config: CameraConfig,
    photon_rate: PhotonRate,
    exposure_s: float,
    *,
    temperature_c: float,
    background_photon_rate: PhotonRate = 0.0,
    quantum_efficiency: float | None = None,
    extra_electrons: PhotonRate = 0.0,
    rng: np.random.Generator | None = None,
    seed: int | None = None,
    float_dtype: DTypeLike = DEFAULT_FLOAT_DTYPE,
) -> SimulationResult:
    """Simulate one frame end-to-end, returning ADU and the noise-free truth.

    Parameters
    ----------
    config:
        The detector configuration.
    photon_rate:
        Incident photon rate in photons/s/pixel, as a scalar (uniform) or a 2-D
        array. Use ``0.0`` for a dark/bias frame.
    exposure_s:
        Integration time in seconds (``0`` for a bias frame).
    temperature_c:
        Sensor temperature in degrees Celsius.
    background_photon_rate:
        Additive background (sky/thermal) photon rate in photons/s/pixel.
    quantum_efficiency:
        Overrides ``config.quantum_efficiency`` for the photon-to-electron step
        (used by spectral mode with a pre-converted electron map and ``1.0``).
    extra_electrons:
        Additive noise-free signal already in electrons (scalar or 2-D array),
        injected before shot noise and the gain stage. Used to carry latent charge
        from image persistence across the frames of an observation; it is real
        charge in the well, so it picks up shot noise and any EM/avalanche gain.
    rng, seed:
        Provide an existing generator, or a seed to build a fresh one.
    float_dtype:
        Working floating-point precision of the per-pixel arrays: ``float64`` (the
        exact default) or ``float32`` for the memory-light fast path used for large
        detectors and bulk dataset generation. The digitised ADU stay integer
        regardless; only the floating-point signal chain and the truth arrays change.
    """
    if exposure_s < 0:
        raise ValueError("exposure_s must be non-negative.")
    if rng is None:
        rng = np.random.default_rng(seed)

    mean_photo = photo_signal_map(
        config, photon_rate, exposure_s, background_photon_rate, quantum_efficiency, float_dtype
    )
    mean_dark = dark_signal_map(config, exposure_s, temperature_c, float_dtype)
    mean_total = mean_photo + mean_dark + np.asarray(extra_electrons, dtype=float_dtype)
    electrons = frame_electrons(config, mean_total, rng, exposure_s)
    adu = digitize(electrons, config, rng)
    return SimulationResult(adu, mean_photo, mean_dark, photon_rate)

generate_dark_frame(config, exposure_s, temperature_c, rng=None, seed=None)

End-to-end dark frame in ADU (the photon_rate = 0 case of simulate_frame).

Source code in src/getframes/noise.py
597
598
599
600
601
602
603
604
605
606
607
def generate_dark_frame(
    config: CameraConfig,
    exposure_s: float,
    temperature_c: float,
    rng: np.random.Generator | None = None,
    seed: int | None = None,
) -> NDArray[np.uint32]:
    """End-to-end dark frame in ADU (the ``photon_rate = 0`` case of ``simulate_frame``)."""
    return simulate_frame(
        config, 0.0, exposure_s, temperature_c=temperature_c, rng=rng, seed=seed
    ).adu

dark_frame_electrons(config, exposure_s, temperature_c, rng)

Electron-domain dark frame prior to digitisation (kept for convenience).

Source code in src/getframes/noise.py
610
611
612
613
614
615
616
617
618
def dark_frame_electrons(
    config: CameraConfig,
    exposure_s: float,
    temperature_c: float,
    rng: np.random.Generator,
) -> NDArray[np.float64]:
    """Electron-domain dark frame prior to digitisation (kept for convenience)."""
    mean_dark = dark_signal_map(config, exposure_s, temperature_c)
    return frame_electrons(config, mean_dark, rng, exposure_s)