getframes 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. getframes/__about__.py +4 -0
  2. getframes/__init__.py +91 -0
  3. getframes/analysis/__init__.py +18 -0
  4. getframes/analysis/apertures.py +92 -0
  5. getframes/analysis/ptc.py +109 -0
  6. getframes/calibrate.py +182 -0
  7. getframes/camera.py +649 -0
  8. getframes/cli.py +214 -0
  9. getframes/config.py +420 -0
  10. getframes/dataset.py +294 -0
  11. getframes/frame.py +107 -0
  12. getframes/noise.py +637 -0
  13. getframes/observation.py +162 -0
  14. getframes/presets/__init__.py +90 -0
  15. getframes/presets/data/__init__.py +3 -0
  16. getframes/presets/data/andor_ikon_m934.toml +22 -0
  17. getframes/presets/data/andor_ixon_ultra_888.toml +22 -0
  18. getframes/presets/data/generic_ccd.toml +18 -0
  19. getframes/presets/data/generic_cmos.toml +18 -0
  20. getframes/presets/data/generic_eapd.toml +20 -0
  21. getframes/presets/data/generic_emccd.toml +20 -0
  22. getframes/presets/data/generic_scmos.toml +21 -0
  23. getframes/presets/data/hamamatsu_orca_fusion.toml +25 -0
  24. getframes/presets/data/leonardo_saphira.toml +32 -0
  25. getframes/presets/data/zwo_asi2600mm.toml +20 -0
  26. getframes/py.typed +0 -0
  27. getframes/scene/__init__.py +51 -0
  28. getframes/scene/optics.py +180 -0
  29. getframes/scene/photometry.py +311 -0
  30. getframes/scene/psf.py +371 -0
  31. getframes/scene/scene.py +205 -0
  32. getframes/scene/sources.py +683 -0
  33. getframes/scene/thermal.py +114 -0
  34. getframes/scene/wcs.py +110 -0
  35. getframes/spectral.py +449 -0
  36. getframes-2.0.0.dist-info/METADATA +218 -0
  37. getframes-2.0.0.dist-info/RECORD +40 -0
  38. getframes-2.0.0.dist-info/WHEEL +4 -0
  39. getframes-2.0.0.dist-info/entry_points.txt +2 -0
  40. getframes-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,683 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Astronomical sources placed into a :class:`~getframes.scene.scene.Scene`.
3
+
4
+ Every source knows how to *deposit* its incident signal-rate into an image
5
+ (:meth:`Source.deposit`) given a :class:`RenderContext` describing the optics, PSF,
6
+ pointing, and (optionally) a world coordinate system. The same context carries the
7
+ ``qe_scale`` hook the scene uses to fold a colour-dependent effective QE in spectral
8
+ mode, so sources stay agnostic about photon- vs. electron-rate rendering.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from collections.abc import Callable, Mapping, Sequence
15
+ from dataclasses import dataclass, field
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ import numpy as np
19
+ from numpy.typing import NDArray
20
+
21
+ if TYPE_CHECKING:
22
+ from ..spectral import SED
23
+ from .optics import Telescope
24
+ from .psf import PSF
25
+ from .wcs import WCSInfo
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class RenderContext:
30
+ """Everything a :class:`Source` needs to deposit itself into an image.
31
+
32
+ Built by the :class:`~getframes.scene.scene.Scene` for each render pass.
33
+
34
+ Parameters
35
+ ----------
36
+ optics:
37
+ The telescope providing the plate scale and magnitude conversion.
38
+ psf:
39
+ The point-spread function used to spread compact sources.
40
+ wcs:
41
+ Optional world coordinate system; required for sources placed by RA/Dec.
42
+ time_s:
43
+ Observation time in seconds (``None`` for the static, baseline scene).
44
+ offset_xy:
45
+ Whole-field pointing offset ``(dx, dy)`` in pixels (jitter / drift / dither).
46
+ qe_scale:
47
+ Maps a source SED to a multiplicative scale on its rate. The scene passes
48
+ ``lambda sed: 1.0`` for photon-rate rendering and a band-weighted effective
49
+ QE for spectral (electron-rate) rendering.
50
+ pixel_transform:
51
+ Optional ``(x, y) -> (x, y)`` map applied after the pointing offset to model
52
+ optical distortion; ``None`` leaves positions unchanged.
53
+ """
54
+
55
+ optics: Telescope
56
+ psf: PSF
57
+ wcs: WCSInfo | None
58
+ time_s: float | None
59
+ offset_xy: tuple[float, float]
60
+ qe_scale: Callable[[SED | None], float]
61
+ pixel_transform: Callable[[float, float], tuple[float, float]] | None = None
62
+
63
+ def place(
64
+ self, x: float | None, y: float | None, ra_deg: float | None, dec_deg: float | None
65
+ ) -> tuple[float, float]:
66
+ """Resolve a source position to detector pixels, applying offset + distortion.
67
+
68
+ Give either pixel ``(x, y)`` or sky ``(ra_deg, dec_deg)`` (the latter needs
69
+ :attr:`wcs`). The pointing offset is added and the distortion transform (if
70
+ any) applied, in that order.
71
+ """
72
+ if ra_deg is not None and dec_deg is not None:
73
+ if self.wcs is None:
74
+ raise ValueError("A source placed by RA/Dec requires the scene to carry a `wcs`.")
75
+ px, py = self.wcs.world_to_pixel(ra_deg, dec_deg)
76
+ elif x is not None and y is not None:
77
+ px, py = float(x), float(y)
78
+ else:
79
+ raise ValueError("A source needs either pixel (x, y) or sky (ra, dec) coordinates.")
80
+ px += self.offset_xy[0]
81
+ py += self.offset_xy[1]
82
+ if self.pixel_transform is not None:
83
+ px, py = self.pixel_transform(px, py)
84
+ return px, py
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class LightCurve:
89
+ """A time-varying brightness multiplier for a source.
90
+
91
+ A light curve maps a time ``t`` (seconds, measured from the start of an
92
+ observation) to a dimensionless factor that multiplies the source's baseline
93
+ brightness. A constant ``1.0`` leaves the source unchanged; ``0.99`` during a
94
+ transit dims it by 1%.
95
+
96
+ Time variability is *owned by the source* (see :attr:`PointSource.brightness`):
97
+ :meth:`getframes.Camera.observe_series` samples the curve at each frame's
98
+ timestamp, so the injected signal is reproducible and recorded in the
99
+ observation's per-frame truth.
100
+
101
+ Construct one with a factory (:meth:`box`, :meth:`sinusoidal`,
102
+ :meth:`constant`) or wrap any callable with :meth:`from_function`. The instance
103
+ itself is callable: ``lc(t)`` returns the multiplier.
104
+
105
+ Parameters
106
+ ----------
107
+ func:
108
+ Callable mapping time in seconds to a non-negative brightness multiplier.
109
+ """
110
+
111
+ func: Callable[[float], float]
112
+
113
+ def __call__(self, time_s: float) -> float:
114
+ value = float(self.func(time_s))
115
+ if value < 0.0:
116
+ raise ValueError("LightCurve produced a negative brightness multiplier.")
117
+ return value
118
+
119
+ @classmethod
120
+ def constant(cls, level: float = 1.0) -> LightCurve:
121
+ """A flat light curve at ``level`` (default ``1.0``, i.e. no variation)."""
122
+ return cls(lambda _t: level)
123
+
124
+ @classmethod
125
+ def box(cls, depth: float, t0: float, t1: float, baseline: float = 1.0) -> LightCurve:
126
+ """A box-shaped dip of fractional ``depth`` between times ``t0`` and ``t1``.
127
+
128
+ Outside ``[t0, t1)`` the multiplier is ``baseline``; inside it is
129
+ ``baseline * (1 - depth)``. A simple model of a flat-bottomed transit
130
+ (``depth=0.01`` for a 1% transit).
131
+ """
132
+ if not 0.0 <= depth <= 1.0:
133
+ raise ValueError("box depth must be in [0, 1].")
134
+ if t1 < t0:
135
+ raise ValueError("box requires t1 >= t0.")
136
+
137
+ def curve(t: float) -> float:
138
+ return baseline * (1.0 - depth) if t0 <= t < t1 else baseline
139
+
140
+ return cls(curve)
141
+
142
+ @classmethod
143
+ def sinusoidal(
144
+ cls,
145
+ amplitude: float,
146
+ period_s: float,
147
+ *,
148
+ phase: float = 0.0,
149
+ baseline: float = 1.0,
150
+ ) -> LightCurve:
151
+ """A sinusoid: ``baseline + amplitude * sin(2*pi*t/period + phase)``.
152
+
153
+ Models a pulsating or rotating variable. ``amplitude`` is in the same units
154
+ as ``baseline`` (i.e. a fraction of the unit baseline); keep
155
+ ``amplitude <= baseline`` to stay non-negative.
156
+ """
157
+ if period_s <= 0:
158
+ raise ValueError("sinusoidal period_s must be positive.")
159
+ omega = 2.0 * math.pi / period_s
160
+
161
+ def curve(t: float) -> float:
162
+ return baseline + amplitude * math.sin(omega * t + phase)
163
+
164
+ return cls(curve)
165
+
166
+ @classmethod
167
+ def from_function(cls, func: Callable[[float], float]) -> LightCurve:
168
+ """Wrap an arbitrary ``t -> multiplier`` callable as a light curve."""
169
+ return cls(func)
170
+
171
+
172
+ def _brightness_scale(brightness: LightCurve | None, time_s: float | None) -> float:
173
+ """The light-curve multiplier at ``time_s`` (``1.0`` if static or unset)."""
174
+ if time_s is not None and brightness is not None:
175
+ return brightness(time_s)
176
+ return 1.0
177
+
178
+
179
+ def _color_sed(source: Source) -> SED | None:
180
+ """The SED used to colour-weight the effective QE: ``flux_sed`` if set, else ``sed``."""
181
+ flux_sed = getattr(source, "flux_sed", None)
182
+ return flux_sed if flux_sed is not None else source.sed
183
+
184
+
185
+ def _resolve_rate(
186
+ magnitude: float | None,
187
+ photon_rate: float | None,
188
+ optics: Telescope,
189
+ flux_sed: SED | None = None,
190
+ ) -> float:
191
+ """Baseline photons/s reaching the detector.
192
+
193
+ From an explicit ``photon_rate``, an absolute ``flux_sed`` integrated over the
194
+ band (spectral flux integration), or a ``magnitude`` via the band zero point.
195
+ """
196
+ if photon_rate is not None:
197
+ return photon_rate
198
+ if flux_sed is not None:
199
+ return optics.photon_rate_from_sed(flux_sed)
200
+ assert magnitude is not None # guaranteed by the source's validation
201
+ return optics.photon_rate_from_magnitude(magnitude)
202
+
203
+
204
+ def _check_one_brightness(
205
+ magnitude: float | None,
206
+ photon_rate: float | None,
207
+ flux_sed: SED | None = None,
208
+ ) -> None:
209
+ if sum(x is not None for x in (magnitude, photon_rate, flux_sed)) != 1:
210
+ raise ValueError("Specify exactly one of `magnitude`, `photon_rate`, or `flux_sed`.")
211
+ if photon_rate is not None and photon_rate < 0:
212
+ raise ValueError("photon_rate must be non-negative.")
213
+ if flux_sed is not None and not flux_sed.is_absolute:
214
+ raise ValueError("flux_sed must be an absolute SED (build it with SED.from_flux_density).")
215
+
216
+
217
+ class Source:
218
+ """Base class for things placed into a :class:`~getframes.scene.scene.Scene`.
219
+
220
+ Subclasses carry the optional :attr:`sed`, :attr:`brightness`, and :attr:`name`
221
+ attributes and implement :meth:`deposit` (render into an image) and
222
+ :meth:`total_photon_rate` (the integrated rate, used for ground-truth light
223
+ curves).
224
+ """
225
+
226
+ sed: SED | None
227
+ brightness: LightCurve | None
228
+ name: str | None
229
+
230
+ def deposit(self, image: NDArray[np.float64], ctx: RenderContext) -> None:
231
+ """Add this source's incident rate (per s per pixel) into ``image``."""
232
+ raise NotImplementedError
233
+
234
+ def total_photon_rate(self, optics: Telescope, time_s: float | None = None) -> float:
235
+ """Total photons/s this source contributes (light curve applied)."""
236
+ raise NotImplementedError
237
+
238
+
239
+ @dataclass(frozen=True)
240
+ class PointSource(Source):
241
+ """An unresolved point source (e.g. a star) at pixel position ``(x, y)``.
242
+
243
+ Specify the brightness in exactly one of two ways:
244
+
245
+ * ``magnitude`` --- converted to a photon rate by the telescope's bandpass, or
246
+ * ``photon_rate`` --- photons/s already arriving at the detector (post-optics,
247
+ pre-quantum-efficiency), handy when you know the flux directly (e.g. an AO
248
+ sub-aperture).
249
+
250
+ ``x`` is the column and ``y`` the row, in pixels; sub-pixel positions are fine.
251
+
252
+ ``sed`` is an optional spectral energy distribution
253
+ (:class:`~getframes.spectral.SED`). It is used only in spectral mode, to give
254
+ the source a colour-dependent effective QE; it has no effect on the integrated
255
+ photon rate (the magnitude sets that). Defaults to a flat photon spectrum.
256
+
257
+ ``brightness`` is an optional :class:`LightCurve`. When set, the source's
258
+ photon rate is multiplied by ``brightness(t)`` at each timestamp sampled by
259
+ :meth:`getframes.Camera.observe_series`, making the source variable in time.
260
+ A static :meth:`getframes.Camera.observe` (no time) ignores it.
261
+
262
+ ``name`` is an optional label used to key the source in an observation's
263
+ per-frame truth light curve.
264
+
265
+ ``flux_sed`` is an alternative to ``magnitude``/``photon_rate``: an *absolute*
266
+ :class:`~getframes.spectral.SED` (``SED.from_flux_density``) whose integral over
267
+ the band sets the photon rate directly (true spectral flux integration). When
268
+ given it also serves as the colour SED for spectral mode.
269
+ """
270
+
271
+ x: float
272
+ y: float
273
+ magnitude: float | None = None
274
+ photon_rate: float | None = None
275
+ sed: SED | None = None
276
+ brightness: LightCurve | None = None
277
+ name: str | None = None
278
+ flux_sed: SED | None = None
279
+
280
+ def __post_init__(self) -> None:
281
+ _check_one_brightness(self.magnitude, self.photon_rate, self.flux_sed)
282
+
283
+ def total_photon_rate(self, optics: Telescope, time_s: float | None = None) -> float:
284
+ rate = _resolve_rate(self.magnitude, self.photon_rate, optics, self.flux_sed)
285
+ return rate * _brightness_scale(self.brightness, time_s)
286
+
287
+ def deposit(self, image: NDArray[np.float64], ctx: RenderContext) -> None:
288
+ rate = self.total_photon_rate(ctx.optics, ctx.time_s) * ctx.qe_scale(_color_sed(self))
289
+ if rate <= 0:
290
+ return
291
+ px, py = ctx.place(self.x, self.y, None, None)
292
+ ctx.psf.add_source(image, px, py, rate, ctx.optics.plate_scale_arcsec_per_pixel)
293
+
294
+
295
+ @dataclass(frozen=True)
296
+ class ExtendedSource(Source):
297
+ """A resolved source rendered from an analytic Sersic profile or a pixel array.
298
+
299
+ Place it by pixel ``(x, y)`` or, with a scene :class:`~getframes.scene.wcs.WCSInfo`,
300
+ by sky ``(ra_deg, dec_deg)``. Total brightness is set by ``magnitude`` or an
301
+ explicit ``photon_rate`` (exactly one), as for :class:`PointSource`; the profile
302
+ distributes that total flux over pixels and is normalised to conserve it.
303
+
304
+ Construct via :meth:`sersic` (a Sersic surface-brightness profile, optionally
305
+ elliptical) or :meth:`from_array` (an arbitrary normalised image, e.g. a galaxy
306
+ cutout). The profile is rendered directly to the focal plane and is *not*
307
+ additionally convolved with the scene PSF --- supply a pre-convolved array, or
308
+ rely on the profile being broad compared with the PSF.
309
+ """
310
+
311
+ x: float | None = None
312
+ y: float | None = None
313
+ ra_deg: float | None = None
314
+ dec_deg: float | None = None
315
+ magnitude: float | None = None
316
+ photon_rate: float | None = None
317
+ profile: NDArray[np.float64] | None = None
318
+ sersic_n: float | None = None
319
+ r_eff_arcsec: float | None = None
320
+ ellipticity: float = 0.0
321
+ position_angle_deg: float = 0.0
322
+ sed: SED | None = None
323
+ brightness: LightCurve | None = None
324
+ name: str | None = None
325
+ flux_sed: SED | None = None
326
+
327
+ def __post_init__(self) -> None:
328
+ _check_one_brightness(self.magnitude, self.photon_rate, self.flux_sed)
329
+ if (self.profile is None) == (self.sersic_n is None):
330
+ raise ValueError("ExtendedSource needs exactly one of a `profile` or Sersic params.")
331
+ if not 0.0 <= self.ellipticity < 1.0:
332
+ raise ValueError("ellipticity must be in [0, 1).")
333
+ if self.sersic_n is not None:
334
+ if self.sersic_n <= 0:
335
+ raise ValueError("Sersic index n must be positive.")
336
+ if self.r_eff_arcsec is None or self.r_eff_arcsec <= 0:
337
+ raise ValueError("Sersic profile requires a positive r_eff_arcsec.")
338
+
339
+ @classmethod
340
+ def sersic(
341
+ cls,
342
+ *,
343
+ x: float | None = None,
344
+ y: float | None = None,
345
+ ra: float | None = None,
346
+ dec: float | None = None,
347
+ magnitude: float | None = None,
348
+ photon_rate: float | None = None,
349
+ n: float = 1.0,
350
+ r_eff_arcsec: float,
351
+ ellipticity: float = 0.0,
352
+ position_angle_deg: float = 0.0,
353
+ sed: SED | None = None,
354
+ brightness: LightCurve | None = None,
355
+ name: str | None = None,
356
+ flux_sed: SED | None = None,
357
+ ) -> ExtendedSource:
358
+ """A Sersic profile ``I(r) ~ exp(-b_n[(r/r_eff)^(1/n) - 1])``.
359
+
360
+ ``n=1`` is an exponential disk, ``n=4`` a de Vaucouleurs bulge. ``ellipticity``
361
+ (``1 - b/a``) and ``position_angle_deg`` (of the major axis, measured
362
+ counter-clockwise from the +x axis) shape an elliptical isophote.
363
+ """
364
+ return cls(
365
+ x=x,
366
+ y=y,
367
+ ra_deg=ra,
368
+ dec_deg=dec,
369
+ magnitude=magnitude,
370
+ photon_rate=photon_rate,
371
+ sersic_n=n,
372
+ r_eff_arcsec=r_eff_arcsec,
373
+ ellipticity=ellipticity,
374
+ position_angle_deg=position_angle_deg,
375
+ sed=sed,
376
+ brightness=brightness,
377
+ name=name,
378
+ flux_sed=flux_sed,
379
+ )
380
+
381
+ @classmethod
382
+ def from_array(
383
+ cls,
384
+ image: NDArray[np.float64],
385
+ *,
386
+ x: float | None = None,
387
+ y: float | None = None,
388
+ ra: float | None = None,
389
+ dec: float | None = None,
390
+ magnitude: float | None = None,
391
+ photon_rate: float | None = None,
392
+ sed: SED | None = None,
393
+ brightness: LightCurve | None = None,
394
+ name: str | None = None,
395
+ flux_sed: SED | None = None,
396
+ ) -> ExtendedSource:
397
+ """An arbitrary 2D ``image`` (e.g. a galaxy cutout) used as the profile.
398
+
399
+ The array is normalised to unit sum and pasted centred on the source
400
+ position at detector-pixel resolution, then scaled to the total flux.
401
+ """
402
+ arr = np.asarray(image, dtype=np.float64)
403
+ if arr.ndim != 2 or arr.size == 0:
404
+ raise ValueError("ExtendedSource.from_array needs a non-empty 2D image.")
405
+ total = float(arr.sum())
406
+ if total <= 0:
407
+ raise ValueError("ExtendedSource.from_array image must have a positive sum.")
408
+ return cls(
409
+ x=x,
410
+ y=y,
411
+ ra_deg=ra,
412
+ dec_deg=dec,
413
+ magnitude=magnitude,
414
+ photon_rate=photon_rate,
415
+ profile=arr / total,
416
+ sed=sed,
417
+ brightness=brightness,
418
+ name=name,
419
+ flux_sed=flux_sed,
420
+ )
421
+
422
+ def total_photon_rate(self, optics: Telescope, time_s: float | None = None) -> float:
423
+ rate = _resolve_rate(self.magnitude, self.photon_rate, optics, self.flux_sed)
424
+ return rate * _brightness_scale(self.brightness, time_s)
425
+
426
+ def deposit(self, image: NDArray[np.float64], ctx: RenderContext) -> None:
427
+ flux = self.total_photon_rate(ctx.optics, ctx.time_s) * ctx.qe_scale(_color_sed(self))
428
+ if flux <= 0:
429
+ return
430
+ px, py = ctx.place(self.x, self.y, self.ra_deg, self.dec_deg)
431
+ if self.profile is not None:
432
+ _paste_centered(image, self.profile * flux, px, py)
433
+ else:
434
+ self._deposit_sersic(image, px, py, flux, ctx.optics.plate_scale_arcsec_per_pixel)
435
+
436
+ def _deposit_sersic(
437
+ self,
438
+ image: NDArray[np.float64],
439
+ x: float,
440
+ y: float,
441
+ flux: float,
442
+ plate_scale_arcsec_per_pixel: float,
443
+ ) -> None:
444
+ assert self.sersic_n is not None and self.r_eff_arcsec is not None
445
+ r_eff_pix = self.r_eff_arcsec / plate_scale_arcsec_per_pixel
446
+ if r_eff_pix <= 0:
447
+ raise ValueError("plate scale and r_eff must yield a positive r_eff in pixels.")
448
+ n = self.sersic_n
449
+ # Ciotti & Bertin (1999) approximation to b_n.
450
+ b_n = 2.0 * n - 1.0 / 3.0 + 4.0 / (405.0 * n) + 46.0 / (25515.0 * n * n)
451
+ q = 1.0 - self.ellipticity # minor/major axis ratio
452
+
453
+ # Stamp out to ~8 effective radii along the major axis captures the flux.
454
+ radius = int(np.ceil(8.0 * r_eff_pix)) + 1
455
+ height, width = image.shape
456
+ ix, iy = round(x), round(y)
457
+ x0, x1 = max(0, ix - radius), min(width, ix + radius + 1)
458
+ y0, y1 = max(0, iy - radius), min(height, iy + radius + 1)
459
+ if x0 >= x1 or y0 >= y1:
460
+ return
461
+
462
+ xs = np.arange(x0, x1) - x
463
+ ys = np.arange(y0, y1) - y
464
+ theta = math.radians(self.position_angle_deg)
465
+ cos_t, sin_t = math.cos(theta), math.sin(theta)
466
+ # Coordinates along (major, minor) axes of the ellipse.
467
+ u = xs[None, :] * cos_t + ys[:, None] * sin_t
468
+ v = -xs[None, :] * sin_t + ys[:, None] * cos_t
469
+ r = np.sqrt(u**2 + (v / q) ** 2) / r_eff_pix
470
+ profile = np.exp(-b_n * (np.power(r, 1.0 / n) - 1.0))
471
+ total = profile.sum()
472
+ if total > 0:
473
+ image[y0:y1, x0:x1] += flux * profile / total
474
+
475
+ @property
476
+ def position_angle(self) -> float:
477
+ """Position angle of the major axis, in degrees (alias of the field)."""
478
+ return self.position_angle_deg
479
+
480
+
481
+ @dataclass(frozen=True)
482
+ class UniformIllumination(Source):
483
+ """A spatially flat illumination of ``photon_rate`` photons/s/pixel.
484
+
485
+ A clean, PSF-free flat field --- the natural input for a photon-transfer curve
486
+ (PTC) or for building synthetic flats. ``brightness`` and ``sed`` behave as for
487
+ other sources (time variability and spectral effective QE).
488
+ """
489
+
490
+ photon_rate: float
491
+ sed: SED | None = None
492
+ brightness: LightCurve | None = None
493
+ name: str | None = None
494
+
495
+ def __post_init__(self) -> None:
496
+ if self.photon_rate < 0:
497
+ raise ValueError("photon_rate must be non-negative.")
498
+
499
+ def total_photon_rate(self, optics: Telescope, time_s: float | None = None) -> float:
500
+ return self.photon_rate * _brightness_scale(self.brightness, time_s)
501
+
502
+ def deposit(self, image: NDArray[np.float64], ctx: RenderContext) -> None:
503
+ rate = self.total_photon_rate(ctx.optics, ctx.time_s) * ctx.qe_scale(self.sed)
504
+ if rate != 0.0:
505
+ image += rate
506
+
507
+
508
+ @dataclass(frozen=True)
509
+ class CatalogEntry:
510
+ """One row of a :class:`Catalog`: a position and a brightness."""
511
+
512
+ magnitude: float | None = None
513
+ photon_rate: float | None = None
514
+ x: float | None = None
515
+ y: float | None = None
516
+ ra_deg: float | None = None
517
+ dec_deg: float | None = None
518
+
519
+ def __post_init__(self) -> None:
520
+ _check_one_brightness(self.magnitude, self.photon_rate)
521
+
522
+
523
+ @dataclass(frozen=True)
524
+ class Catalog(Source):
525
+ """Many point sources sharing a PSF, SED, and optional light curve.
526
+
527
+ Build one from a table with :meth:`from_table`. Entries may be placed by pixel
528
+ ``(x, y)`` or by sky ``(ra, dec)``; sky coordinates are projected to pixels
529
+ through the scene's :class:`~getframes.scene.wcs.WCSInfo`, so a Gaia/2MASS-style
530
+ catalogue drops straight into a WCS-tagged scene. The whole catalogue is keyed
531
+ by a single :attr:`name` in observation truth (its summed flux).
532
+ """
533
+
534
+ entries: tuple[CatalogEntry, ...] = field(default_factory=tuple)
535
+ sed: SED | None = None
536
+ brightness: LightCurve | None = None
537
+ name: str | None = None
538
+
539
+ @classmethod
540
+ def from_table(
541
+ cls,
542
+ table: Mapping[str, Sequence[Any]] | Any,
543
+ *,
544
+ magnitude: str | None = None,
545
+ photon_rate: str | None = None,
546
+ x: str | None = None,
547
+ y: str | None = None,
548
+ ra: str | None = None,
549
+ dec: str | None = None,
550
+ sed: SED | None = None,
551
+ brightness: LightCurve | None = None,
552
+ name: str | None = None,
553
+ ) -> Catalog:
554
+ """Build a catalogue from column names of ``table``.
555
+
556
+ ``table`` is anything column-indexable by name (an ``astropy`` ``Table``, a
557
+ pandas ``DataFrame``, or a ``dict`` of arrays). Give the brightness column as
558
+ ``magnitude`` or ``photon_rate``, and the position columns as either
559
+ (``x``, ``y``) pixels or (``ra``, ``dec``) degrees.
560
+ """
561
+ if (magnitude is None) == (photon_rate is None):
562
+ raise ValueError("Specify exactly one of `magnitude` or `photon_rate` column.")
563
+ use_radec = ra is not None and dec is not None
564
+ use_xy = x is not None and y is not None
565
+ if use_radec == use_xy:
566
+ raise ValueError("Specify position columns as either (ra, dec) or (x, y).")
567
+
568
+ bright_col = magnitude if magnitude is not None else photon_rate
569
+ assert bright_col is not None
570
+ bright_vals = np.asarray(table[bright_col], dtype=np.float64)
571
+ if use_radec:
572
+ assert ra is not None and dec is not None
573
+ pos_a = np.asarray(table[ra], dtype=np.float64)
574
+ pos_b = np.asarray(table[dec], dtype=np.float64)
575
+ else:
576
+ assert x is not None and y is not None
577
+ pos_a = np.asarray(table[x], dtype=np.float64)
578
+ pos_b = np.asarray(table[y], dtype=np.float64)
579
+
580
+ entries: list[CatalogEntry] = []
581
+ is_mag = magnitude is not None
582
+ for i in range(len(bright_vals)):
583
+ mag = float(bright_vals[i]) if is_mag else None
584
+ rate = None if is_mag else float(bright_vals[i])
585
+ if use_radec:
586
+ entries.append(
587
+ CatalogEntry(
588
+ magnitude=mag,
589
+ photon_rate=rate,
590
+ ra_deg=float(pos_a[i]),
591
+ dec_deg=float(pos_b[i]),
592
+ )
593
+ )
594
+ else:
595
+ entries.append(
596
+ CatalogEntry(
597
+ magnitude=mag,
598
+ photon_rate=rate,
599
+ x=float(pos_a[i]),
600
+ y=float(pos_b[i]),
601
+ )
602
+ )
603
+ return cls(entries=tuple(entries), sed=sed, brightness=brightness, name=name)
604
+
605
+ def __len__(self) -> int:
606
+ return len(self.entries)
607
+
608
+ def total_photon_rate(self, optics: Telescope, time_s: float | None = None) -> float:
609
+ scale = _brightness_scale(self.brightness, time_s)
610
+ return scale * sum(_resolve_rate(e.magnitude, e.photon_rate, optics) for e in self.entries)
611
+
612
+ def deposit(self, image: NDArray[np.float64], ctx: RenderContext) -> None:
613
+ scale = _brightness_scale(self.brightness, ctx.time_s) * ctx.qe_scale(self.sed)
614
+ if scale <= 0 or not self.entries:
615
+ return
616
+ plate_scale = ctx.optics.plate_scale_arcsec_per_pixel
617
+ rates = np.array(
618
+ [_resolve_rate(e.magnitude, e.photon_rate, ctx.optics) for e in self.entries],
619
+ dtype=np.float64,
620
+ )
621
+ rates *= scale
622
+ xs, ys = self._placed_positions(ctx)
623
+ # One batched (vectorised, chunked) deposit instead of a Python per-star loop.
624
+ ctx.psf.add_sources(image, xs, ys, rates, plate_scale)
625
+
626
+ def _placed_positions(
627
+ self, ctx: RenderContext
628
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
629
+ """Resolve every entry to detector pixels, vectorising the common path.
630
+
631
+ Pixel-placed catalogues with no optical distortion are projected in one
632
+ array op; RA/Dec or distortion fall back to the per-entry
633
+ :meth:`RenderContext.place` so WCS and distortion behave exactly as for a
634
+ single source.
635
+ """
636
+ pixel_only = all(e.ra_deg is None and e.x is not None for e in self.entries)
637
+ if pixel_only and ctx.pixel_transform is None:
638
+ xs = np.array([e.x for e in self.entries], dtype=np.float64)
639
+ ys = np.array([e.y for e in self.entries], dtype=np.float64)
640
+ return xs + ctx.offset_xy[0], ys + ctx.offset_xy[1]
641
+ placed = [ctx.place(e.x, e.y, e.ra_deg, e.dec_deg) for e in self.entries]
642
+ arr = np.asarray(placed, dtype=np.float64).reshape(len(placed), 2)
643
+ return arr[:, 0], arr[:, 1]
644
+
645
+
646
+ def _paste_centered(
647
+ image: NDArray[np.float64], stamp: NDArray[np.float64], x: float, y: float
648
+ ) -> None:
649
+ """Add ``stamp`` into ``image`` centred on the rounded pixel ``(x, y)``.
650
+
651
+ Flux that falls off the frame is clipped (lost), matching the PSF behaviour.
652
+ """
653
+ sh, sw = stamp.shape
654
+ iy, ix = round(y), round(x)
655
+ top = iy - sh // 2
656
+ left = ix - sw // 2
657
+ height, width = image.shape
658
+ y0, y1 = max(0, top), min(height, top + sh)
659
+ x0, x1 = max(0, left), min(width, left + sw)
660
+ if y0 >= y1 or x0 >= x1:
661
+ return
662
+ image[y0:y1, x0:x1] += stamp[y0 - top : y1 - top, x0 - left : x1 - left]
663
+
664
+
665
+ @dataclass(frozen=True)
666
+ class Sky:
667
+ """A uniform sky background of a given surface brightness.
668
+
669
+ The :class:`~getframes.scene.scene.Scene` treats the sky specially: it is added
670
+ by the camera as a uniform background rather than deposited into the rendered
671
+ source map, and is therefore *not* affected by vignetting.
672
+
673
+ Parameters
674
+ ----------
675
+ surface_brightness_mag_arcsec2:
676
+ Sky brightness in magnitudes per square arcsecond (fainter = larger).
677
+ sed:
678
+ Optional spectral energy distribution for the sky, used only in spectral
679
+ mode for the sky's effective QE. Defaults to a flat photon spectrum.
680
+ """
681
+
682
+ surface_brightness_mag_arcsec2: float
683
+ sed: SED | None = None