getframes 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. getframes/__about__.py +4 -0
  2. getframes/__init__.py +91 -0
  3. getframes/analysis/__init__.py +18 -0
  4. getframes/analysis/apertures.py +92 -0
  5. getframes/analysis/ptc.py +109 -0
  6. getframes/calibrate.py +182 -0
  7. getframes/camera.py +649 -0
  8. getframes/cli.py +214 -0
  9. getframes/config.py +420 -0
  10. getframes/dataset.py +294 -0
  11. getframes/frame.py +107 -0
  12. getframes/noise.py +637 -0
  13. getframes/observation.py +162 -0
  14. getframes/presets/__init__.py +90 -0
  15. getframes/presets/data/__init__.py +3 -0
  16. getframes/presets/data/andor_ikon_m934.toml +22 -0
  17. getframes/presets/data/andor_ixon_ultra_888.toml +22 -0
  18. getframes/presets/data/generic_ccd.toml +18 -0
  19. getframes/presets/data/generic_cmos.toml +18 -0
  20. getframes/presets/data/generic_eapd.toml +20 -0
  21. getframes/presets/data/generic_emccd.toml +20 -0
  22. getframes/presets/data/generic_scmos.toml +21 -0
  23. getframes/presets/data/hamamatsu_orca_fusion.toml +25 -0
  24. getframes/presets/data/leonardo_saphira.toml +32 -0
  25. getframes/presets/data/zwo_asi2600mm.toml +20 -0
  26. getframes/py.typed +0 -0
  27. getframes/scene/__init__.py +51 -0
  28. getframes/scene/optics.py +180 -0
  29. getframes/scene/photometry.py +311 -0
  30. getframes/scene/psf.py +371 -0
  31. getframes/scene/scene.py +205 -0
  32. getframes/scene/sources.py +683 -0
  33. getframes/scene/thermal.py +114 -0
  34. getframes/scene/wcs.py +110 -0
  35. getframes/spectral.py +449 -0
  36. getframes-2.0.0.dist-info/METADATA +218 -0
  37. getframes-2.0.0.dist-info/RECORD +40 -0
  38. getframes-2.0.0.dist-info/WHEEL +4 -0
  39. getframes-2.0.0.dist-info/entry_points.txt +2 -0
  40. getframes-2.0.0.dist-info/licenses/LICENSE +21 -0
getframes/scene/psf.py ADDED
@@ -0,0 +1,371 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Point-spread functions: how a point source's flux is spread over pixels.
3
+
4
+ Each PSF knows how to *add* a source of a given total flux at a sub-pixel position
5
+ into an image, conserving flux. Models are evaluated on a small stamp around the
6
+ source for efficiency. The Gaussian uses the exact per-pixel integral (via the
7
+ error function) so it is flux-conserving to machine precision; the Moffat is
8
+ sampled on a stamp and normalised.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from dataclasses import dataclass
15
+
16
+ import numpy as np
17
+ from numpy.typing import NDArray
18
+ from scipy.ndimage import shift as _ndimage_shift
19
+ from scipy.special import erf, j1
20
+
21
+ # FWHM = 2 * sqrt(2 ln 2) * sigma for a Gaussian.
22
+ _FWHM_PER_SIGMA = 2.3548200450309493
23
+
24
+ # Target number of stamp elements held in a single batched-deposit chunk
25
+ # (~4M float64 ≈ 32 MB), so vectorised multi-source rendering stays memory-bounded.
26
+ _STAMP_BUDGET = 4_000_000
27
+
28
+
29
+ def _stamp_bounds(
30
+ x: float, y: float, radius: int, shape: tuple[int, int]
31
+ ) -> tuple[int, int, int, int]:
32
+ """Pixel index bounds of a stamp of half-size ``radius`` centred near (x, y)."""
33
+ height, width = shape
34
+ ix, iy = round(x), round(y)
35
+ x0, x1 = max(0, ix - radius), min(width, ix + radius + 1)
36
+ y0, y1 = max(0, iy - radius), min(height, iy + radius + 1)
37
+ return x0, x1, y0, y1
38
+
39
+
40
+ class PSF:
41
+ """Base class for point-spread functions."""
42
+
43
+ def add_source(
44
+ self,
45
+ image: NDArray[np.float64],
46
+ x: float,
47
+ y: float,
48
+ flux: float,
49
+ plate_scale_arcsec_per_pixel: float,
50
+ ) -> None:
51
+ """Add ``flux`` photons/s of a point source at sub-pixel ``(x, y)`` into ``image``."""
52
+ raise NotImplementedError
53
+
54
+ def add_sources(
55
+ self,
56
+ image: NDArray[np.float64],
57
+ xs: NDArray[np.float64],
58
+ ys: NDArray[np.float64],
59
+ fluxes: NDArray[np.float64],
60
+ plate_scale_arcsec_per_pixel: float,
61
+ ) -> None:
62
+ """Add many point sources at once (vectorised where the PSF supports it).
63
+
64
+ ``xs``, ``ys``, ``fluxes`` are equal-length 1-D arrays of sub-pixel column,
65
+ row, and total flux. The generic implementation loops over
66
+ :meth:`add_source`; subclasses (e.g. :class:`GaussianPSF`) override it with a
67
+ batched, chunked evaluation so a large :class:`~getframes.scene.sources.Catalog`
68
+ does not pay a Python-level per-source loop.
69
+ """
70
+ for x, y, flux in zip(xs, ys, fluxes):
71
+ self.add_source(image, float(x), float(y), float(flux), plate_scale_arcsec_per_pixel)
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class GaussianPSF(PSF):
76
+ """A circular Gaussian PSF specified by its full width at half maximum."""
77
+
78
+ fwhm_arcsec: float
79
+
80
+ def add_source(
81
+ self,
82
+ image: NDArray[np.float64],
83
+ x: float,
84
+ y: float,
85
+ flux: float,
86
+ plate_scale_arcsec_per_pixel: float,
87
+ ) -> None:
88
+ if flux <= 0:
89
+ return
90
+ sigma = self.fwhm_arcsec / _FWHM_PER_SIGMA / plate_scale_arcsec_per_pixel
91
+ if sigma <= 0:
92
+ raise ValueError("PSF FWHM and plate scale must be positive.")
93
+
94
+ radius = int(np.ceil(5.0 * sigma)) + 1
95
+ x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
96
+ if x0 >= x1 or y0 >= y1:
97
+ return # source falls entirely off the frame
98
+
99
+ # Exact per-pixel integral: pixel i spans [i-0.5, i+0.5]; integrate the
100
+ # Gaussian over each pixel using the error-function CDF at the edges.
101
+ scale = sigma * np.sqrt(2.0)
102
+ edges_x = np.arange(x0, x1 + 1) - 0.5
103
+ edges_y = np.arange(y0, y1 + 1) - 0.5
104
+ cdf_x = 0.5 * (1.0 + erf((edges_x - x) / scale))
105
+ cdf_y = 0.5 * (1.0 + erf((edges_y - y) / scale))
106
+ px = np.diff(cdf_x)
107
+ py = np.diff(cdf_y)
108
+ image[y0:y1, x0:x1] += flux * np.outer(py, px)
109
+
110
+ def add_sources(
111
+ self,
112
+ image: NDArray[np.float64],
113
+ xs: NDArray[np.float64],
114
+ ys: NDArray[np.float64],
115
+ fluxes: NDArray[np.float64],
116
+ plate_scale_arcsec_per_pixel: float,
117
+ ) -> None:
118
+ """Vectorised, chunked deposition of many Gaussian point sources.
119
+
120
+ Builds every source's exact per-pixel error-function integral on a common
121
+ stamp in one batched NumPy expression and scatter-adds it into ``image``,
122
+ replacing the Python per-source loop. Identical pixel values to repeated
123
+ :meth:`add_source` calls (flux off the frame is clipped the same way). Work
124
+ is chunked over sources to keep the intermediate ``(chunk, stamp, stamp)``
125
+ buffer bounded for very large catalogues.
126
+ """
127
+ xs = np.asarray(xs, dtype=np.float64)
128
+ ys = np.asarray(ys, dtype=np.float64)
129
+ fluxes = np.asarray(fluxes, dtype=np.float64)
130
+ sigma = self.fwhm_arcsec / _FWHM_PER_SIGMA / plate_scale_arcsec_per_pixel
131
+ if sigma <= 0:
132
+ raise ValueError("PSF FWHM and plate scale must be positive.")
133
+ keep = fluxes > 0
134
+ if not keep.any():
135
+ return
136
+ xs, ys, fluxes = xs[keep], ys[keep], fluxes[keep]
137
+
138
+ radius = int(np.ceil(5.0 * sigma)) + 1
139
+ span = 2 * radius + 1
140
+ scale = sigma * np.sqrt(2.0)
141
+ height, width = image.shape
142
+ # Process in chunks so the (n, span, span) stamp buffer stays bounded.
143
+ chunk = max(1, _STAMP_BUDGET // (span * span))
144
+ offsets = np.arange(span)
145
+ edge_offsets = np.arange(span + 1) - 0.5
146
+ for start in range(0, xs.shape[0], chunk):
147
+ cx = xs[start : start + chunk]
148
+ cy = ys[start : start + chunk]
149
+ cf = fluxes[start : start + chunk]
150
+ ix = np.round(cx).astype(np.intp)
151
+ iy = np.round(cy).astype(np.intp)
152
+ # Exact per-pixel integral on the common stamp, per source (separable).
153
+ edges_x = (ix[:, None] - radius) + edge_offsets[None, :]
154
+ edges_y = (iy[:, None] - radius) + edge_offsets[None, :]
155
+ px = np.diff(0.5 * (1.0 + erf((edges_x - cx[:, None]) / scale)), axis=1)
156
+ py = np.diff(0.5 * (1.0 + erf((edges_y - cy[:, None]) / scale)), axis=1)
157
+ stamps = cf[:, None, None] * py[:, :, None] * px[:, None, :]
158
+ cols = (ix[:, None] - radius) + offsets[None, :] # (n, span)
159
+ rows = (iy[:, None] - radius) + offsets[None, :] # (n, span)
160
+ rr = rows[:, :, None]
161
+ cc = cols[:, None, :]
162
+ inb = (rr >= 0) & (rr < height) & (cc >= 0) & (cc < width)
163
+ r_full = np.broadcast_to(rr, stamps.shape)[inb]
164
+ c_full = np.broadcast_to(cc, stamps.shape)[inb]
165
+ np.add.at(image, (r_full, c_full), stamps[inb])
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class MoffatPSF(PSF):
170
+ """A Moffat PSF, a better match to seeing-limited stars than a Gaussian.
171
+
172
+ The ``beta`` parameter controls the wings: smaller ``beta`` gives broader wings
173
+ (``beta -> infinity`` approaches a Gaussian). ``beta ~ 3`` is typical for
174
+ atmospheric seeing.
175
+ """
176
+
177
+ fwhm_arcsec: float
178
+ beta: float = 3.0
179
+
180
+ def add_source(
181
+ self,
182
+ image: NDArray[np.float64],
183
+ x: float,
184
+ y: float,
185
+ flux: float,
186
+ plate_scale_arcsec_per_pixel: float,
187
+ ) -> None:
188
+ if flux <= 0:
189
+ return
190
+ if self.beta <= 1.0:
191
+ raise ValueError("Moffat beta must be > 1.")
192
+ fwhm_pix = self.fwhm_arcsec / plate_scale_arcsec_per_pixel
193
+ alpha = fwhm_pix / (2.0 * np.sqrt(2.0 ** (1.0 / self.beta) - 1.0))
194
+
195
+ radius = int(np.ceil(6.0 * alpha)) + 1
196
+ x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
197
+ if x0 >= x1 or y0 >= y1:
198
+ return
199
+
200
+ xs = np.arange(x0, x1) - x
201
+ ys = np.arange(y0, y1) - y
202
+ rr = xs[None, :] ** 2 + ys[:, None] ** 2
203
+ profile = (1.0 + rr / alpha**2) ** (-self.beta)
204
+ total = profile.sum()
205
+ if total > 0:
206
+ image[y0:y1, x0:x1] += flux * profile / total
207
+
208
+
209
+ @dataclass(frozen=True)
210
+ class EllipticalGaussianPSF(PSF):
211
+ """An elliptical Gaussian PSF with independent major/minor widths and an angle.
212
+
213
+ ``position_angle_deg`` is the angle of the major axis, measured counter-clockwise
214
+ from the +x axis. The profile is sampled on a stamp and normalised (not the exact
215
+ error-function integral the circular :class:`GaussianPSF` uses), so flux is
216
+ conserved to the sampling accuracy.
217
+ """
218
+
219
+ fwhm_major_arcsec: float
220
+ fwhm_minor_arcsec: float
221
+ position_angle_deg: float = 0.0
222
+
223
+ def add_source(
224
+ self,
225
+ image: NDArray[np.float64],
226
+ x: float,
227
+ y: float,
228
+ flux: float,
229
+ plate_scale_arcsec_per_pixel: float,
230
+ ) -> None:
231
+ if flux <= 0:
232
+ return
233
+ if self.fwhm_minor_arcsec > self.fwhm_major_arcsec:
234
+ raise ValueError("fwhm_minor_arcsec must not exceed fwhm_major_arcsec.")
235
+ sigma_major = self.fwhm_major_arcsec / _FWHM_PER_SIGMA / plate_scale_arcsec_per_pixel
236
+ sigma_minor = self.fwhm_minor_arcsec / _FWHM_PER_SIGMA / plate_scale_arcsec_per_pixel
237
+ if sigma_minor <= 0:
238
+ raise ValueError("PSF FWHM and plate scale must be positive.")
239
+
240
+ radius = int(np.ceil(5.0 * sigma_major)) + 1
241
+ x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
242
+ if x0 >= x1 or y0 >= y1:
243
+ return
244
+
245
+ xs = np.arange(x0, x1) - x
246
+ ys = np.arange(y0, y1) - y
247
+ theta = math.radians(self.position_angle_deg)
248
+ cos_t, sin_t = math.cos(theta), math.sin(theta)
249
+ u = xs[None, :] * cos_t + ys[:, None] * sin_t
250
+ v = -xs[None, :] * sin_t + ys[:, None] * cos_t
251
+ profile = np.exp(-0.5 * ((u / sigma_major) ** 2 + (v / sigma_minor) ** 2))
252
+ total = profile.sum()
253
+ if total > 0:
254
+ image[y0:y1, x0:x1] += flux * profile / total
255
+
256
+
257
+ @dataclass(frozen=True)
258
+ class AiryPSF(PSF):
259
+ """The diffraction-limited Airy pattern of a circular aperture.
260
+
261
+ Models a space- or AO-corrected diffraction-limited core: the intensity is
262
+ ``[2 J1(x)/x]^2`` with ``x = pi * D * theta / lambda``, optionally including a
263
+ central obstruction of fractional diameter ``obstruction``. The first dark ring
264
+ sits at ``theta = 1.22 lambda / D``. Sampled on a stamp and normalised.
265
+
266
+ Parameters
267
+ ----------
268
+ aperture_diameter_m:
269
+ Aperture diameter in metres (sets the angular scale of the pattern).
270
+ wavelength_m:
271
+ Observing wavelength in metres.
272
+ obstruction:
273
+ Central-obstruction diameter as a fraction of the aperture, in ``[0, 1)``.
274
+ """
275
+
276
+ aperture_diameter_m: float
277
+ wavelength_m: float
278
+ obstruction: float = 0.0
279
+
280
+ def add_source(
281
+ self,
282
+ image: NDArray[np.float64],
283
+ x: float,
284
+ y: float,
285
+ flux: float,
286
+ plate_scale_arcsec_per_pixel: float,
287
+ ) -> None:
288
+ if flux <= 0:
289
+ return
290
+ if self.aperture_diameter_m <= 0 or self.wavelength_m <= 0:
291
+ raise ValueError("AiryPSF aperture_diameter_m and wavelength_m must be positive.")
292
+ if not 0.0 <= self.obstruction < 1.0:
293
+ raise ValueError("AiryPSF obstruction must be in [0, 1).")
294
+
295
+ # Radians per pixel, then the argument scale x = pi D theta / lambda.
296
+ rad_per_pixel = plate_scale_arcsec_per_pixel * (math.pi / 180.0 / 3600.0)
297
+ arg_per_pixel = math.pi * self.aperture_diameter_m / self.wavelength_m * rad_per_pixel
298
+ # First null at 1.22 lambda / D; size the stamp to a few Airy rings.
299
+ first_null_pix = 1.22 / (arg_per_pixel / math.pi) if arg_per_pixel > 0 else 1.0
300
+ radius = int(np.ceil(5.0 * first_null_pix)) + 1
301
+ x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
302
+ if x0 >= x1 or y0 >= y1:
303
+ return
304
+
305
+ xs = np.arange(x0, x1) - x
306
+ ys = np.arange(y0, y1) - y
307
+ rr = np.sqrt(xs[None, :] ** 2 + ys[:, None] ** 2)
308
+ arg = arg_per_pixel * rr
309
+ profile = _airy_intensity(arg, self.obstruction)
310
+ total = profile.sum()
311
+ if total > 0:
312
+ image[y0:y1, x0:x1] += flux * profile / total
313
+
314
+
315
+ def _airy_intensity(arg: NDArray[np.float64], obstruction: float) -> NDArray[np.float64]:
316
+ """Airy intensity ``[2 J1(x)/x]^2`` (with optional central obstruction)."""
317
+
318
+ def core(z: NDArray[np.float64]) -> NDArray[np.float64]:
319
+ out = np.ones_like(z)
320
+ nz = z != 0.0
321
+ out[nz] = 2.0 * j1(z[nz]) / z[nz]
322
+ return out
323
+
324
+ eps = obstruction
325
+ amp = core(arg) if eps <= 0.0 else (core(arg) - eps**2 * core(eps * arg)) / (1.0 - eps**2)
326
+ return np.asarray(amp**2, dtype=np.float64)
327
+
328
+
329
+ @dataclass(frozen=True)
330
+ class ArrayPSF(PSF):
331
+ """A user-supplied PSF kernel, e.g. straight from an AO/optics simulation.
332
+
333
+ The ``kernel`` is a 2D array sampled at detector-pixel resolution; it is
334
+ normalised to unit sum on construction. Sub-pixel source positions are handled by
335
+ a first-order (bilinear) shift of the kernel before it is pasted, so the centroid
336
+ lands at the requested location. Flux falling off the frame is clipped.
337
+ """
338
+
339
+ kernel: NDArray[np.float64]
340
+
341
+ def __post_init__(self) -> None:
342
+ arr = np.asarray(self.kernel, dtype=np.float64)
343
+ if arr.ndim != 2 or arr.size == 0:
344
+ raise ValueError("ArrayPSF kernel must be a non-empty 2D array.")
345
+ total = float(arr.sum())
346
+ if total <= 0:
347
+ raise ValueError("ArrayPSF kernel must have a positive sum.")
348
+ object.__setattr__(self, "kernel", arr / total)
349
+
350
+ def add_source(
351
+ self,
352
+ image: NDArray[np.float64],
353
+ x: float,
354
+ y: float,
355
+ flux: float,
356
+ plate_scale_arcsec_per_pixel: float,
357
+ ) -> None:
358
+ if flux <= 0:
359
+ return
360
+ kh, kw = self.kernel.shape
361
+ ix, iy = round(x), round(y)
362
+ fx, fy = x - ix, y - iy
363
+ stamp = _ndimage_shift(self.kernel, (fy, fx), order=1, mode="constant", cval=0.0)
364
+
365
+ top, left = iy - kh // 2, ix - kw // 2
366
+ height, width = image.shape
367
+ y0, y1 = max(0, top), min(height, top + kh)
368
+ x0, x1 = max(0, left), min(width, left + kw)
369
+ if y0 >= y1 or x0 >= x1:
370
+ return
371
+ image[y0:y1, x0:x1] += flux * stamp[y0 - top : y1 - top, x0 - left : x1 - left]
@@ -0,0 +1,205 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """A :class:`Scene` ties sources, a PSF, and optics into a photon-rate map."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING
9
+
10
+ import numpy as np
11
+ from numpy.typing import NDArray
12
+
13
+ from .optics import Telescope
14
+ from .psf import PSF
15
+ from .sources import RenderContext, Sky, Source
16
+ from .thermal import Thermal
17
+ from .wcs import WCSInfo
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+
22
+ from numpy.typing import DTypeLike
23
+
24
+ from ..spectral import QE, SED
25
+
26
+
27
+ @dataclass
28
+ class Scene:
29
+ """A focal-plane scene that renders to an incident photon-rate map.
30
+
31
+ Parameters
32
+ ----------
33
+ shape:
34
+ Output size as ``(height, width)`` in pixels; should match the camera you
35
+ intend to observe it with.
36
+ optics:
37
+ The :class:`~getframes.scene.optics.Telescope` providing collecting area,
38
+ throughput, plate scale, and the magnitude conversion.
39
+ psf:
40
+ The :class:`~getframes.scene.psf.PSF` used to spread each source.
41
+ sources:
42
+ The sources in the field (point, extended, catalog, or uniform).
43
+ sky:
44
+ Optional uniform sky background.
45
+ thermal:
46
+ Optional :class:`~getframes.scene.thermal.Thermal` graybody background (warm
47
+ optics / enclosure emission), added as a uniform background like the sky.
48
+ Dominant in the thermal infrared; needs a band with a spectral response.
49
+ wcs:
50
+ Optional :class:`~getframes.scene.wcs.WCSInfo` tagging the frame with sky
51
+ coordinates; its FITS header cards are copied into the observed frame's
52
+ metadata, and sources placed by RA/Dec are projected through it.
53
+ """
54
+
55
+ shape: tuple[int, int]
56
+ optics: Telescope
57
+ psf: PSF
58
+ sources: Sequence[Source] = field(default_factory=tuple)
59
+ sky: Sky | None = None
60
+ thermal: Thermal | None = None
61
+ wcs: WCSInfo | None = None
62
+
63
+ def __post_init__(self) -> None:
64
+ self.shape = tuple(int(n) for n in self.shape) # type: ignore[assignment]
65
+ if len(self.shape) != 2 or any(n <= 0 for n in self.shape):
66
+ raise ValueError(f"shape must be two positive ints, got {self.shape!r}.")
67
+
68
+ def add(self, *sources: Source) -> None:
69
+ """Append one or more sources to the scene."""
70
+ self.sources = [*self.sources, *sources]
71
+
72
+ def _source_photon_rate(self, source: Source, time_s: float | None = None) -> float:
73
+ """Total photons/s reaching the detector from a single source.
74
+
75
+ When ``time_s`` is given and the source carries a
76
+ :class:`~getframes.scene.sources.LightCurve`, the baseline rate is scaled by
77
+ ``brightness(time_s)`` so the source varies in time.
78
+ """
79
+ return source.total_photon_rate(self.optics, time_s)
80
+
81
+ def _pixel_transform(self) -> Callable[[float, float], tuple[float, float]] | None:
82
+ """The distortion remap for source positions (``None`` if no distortion)."""
83
+ distortion = self.optics.distortion
84
+ if distortion is None:
85
+ return None
86
+ height, width = self.shape
87
+ cx, cy = (width - 1) / 2.0, (height - 1) / 2.0
88
+ return lambda x, y: distortion.apply(x, y, cx, cy)
89
+
90
+ def _render(
91
+ self,
92
+ qe_scale: Callable[[SED | None], float],
93
+ time_s: float | None,
94
+ offset_xy: tuple[float, float],
95
+ dtype: DTypeLike = np.float64,
96
+ ) -> NDArray[np.float64]:
97
+ """Deposit every source into a fresh map and apply vignetting."""
98
+ ctx = RenderContext(
99
+ optics=self.optics,
100
+ psf=self.psf,
101
+ wcs=self.wcs,
102
+ time_s=time_s,
103
+ offset_xy=offset_xy,
104
+ qe_scale=qe_scale,
105
+ pixel_transform=self._pixel_transform(),
106
+ )
107
+ image = np.zeros(self.shape, dtype=dtype)
108
+ for source in self.sources:
109
+ source.deposit(image, ctx)
110
+ illumination = self.optics.illumination_map(self.shape)
111
+ if illumination is not None:
112
+ image *= illumination
113
+ return image
114
+
115
+ def photon_rate_map(
116
+ self,
117
+ time_s: float | None = None,
118
+ offset_xy: tuple[float, float] = (0.0, 0.0),
119
+ dtype: DTypeLike = np.float64,
120
+ ) -> NDArray[np.float64]:
121
+ """Render the sources through the PSF into a photons/s/pixel map.
122
+
123
+ This is the incident rate at the detector *before* quantum efficiency; the
124
+ camera applies QE, dark current, and noise when it exposes the scene.
125
+
126
+ Parameters
127
+ ----------
128
+ time_s:
129
+ Optional observation time in seconds. When set, sources carrying a
130
+ :class:`~getframes.scene.sources.LightCurve` are sampled at this time.
131
+ ``None`` (the default) renders the static, baseline scene.
132
+ offset_xy:
133
+ A whole-field pointing offset ``(dx, dy)`` in pixels added to every
134
+ source position (models jitter / drift / dither). Defaults to no shift.
135
+ dtype:
136
+ Output (and working) floating-point dtype. ``float64`` is the exact
137
+ default; ``float32`` halves the map's memory for the fast path.
138
+ """
139
+ return self._render(lambda _sed: 1.0, time_s, offset_xy, dtype)
140
+
141
+ def sky_photon_rate(self) -> float:
142
+ """Uniform sky background in photons/s/pixel (``0`` if no sky is set)."""
143
+ if self.sky is None:
144
+ return 0.0
145
+ return self.optics.surface_brightness_photon_rate(self.sky.surface_brightness_mag_arcsec2)
146
+
147
+ def thermal_photon_rate(self) -> float:
148
+ """Uniform thermal (graybody) background in photons/s/pixel (``0`` if unset)."""
149
+ if self.thermal is None:
150
+ return 0.0
151
+ return self.thermal.photon_rate(self.optics)
152
+
153
+ @property
154
+ def is_spectral_capable(self) -> bool:
155
+ """Whether this scene's band carries a spectral response for spectral mode."""
156
+ return self.optics.band is not None and self.optics.band.response is not None
157
+
158
+ def photoelectron_rate_map(
159
+ self,
160
+ qe_curve: QE,
161
+ time_s: float | None = None,
162
+ offset_xy: tuple[float, float] = (0.0, 0.0),
163
+ dtype: DTypeLike = np.float64,
164
+ ) -> NDArray[np.float64]:
165
+ """Render sources to a *photoelectron*-rate map (e-/s/pixel) in spectral mode.
166
+
167
+ Like :meth:`photon_rate_map`, but each source's incident photon rate is
168
+ multiplied by the colour-dependent effective QE for its SED (folding the
169
+ detector ``qe_curve`` with the band's spectral response). The result is
170
+ already in photoelectrons, so the camera applies a unit QE downstream.
171
+
172
+ ``time_s`` and ``offset_xy`` behave as in :meth:`photon_rate_map`.
173
+
174
+ Requires a band with a spectral response (see :attr:`is_spectral_capable`).
175
+ """
176
+ band = self.optics.band
177
+ if band is None or band.response is None:
178
+ raise ValueError("photoelectron_rate_map requires a band with a spectral response.")
179
+ return self._render(lambda sed: band.effective_qe(qe_curve, sed), time_s, offset_xy, dtype)
180
+
181
+ def sky_electron_rate(self, qe_curve: QE) -> float:
182
+ """Uniform sky background in photoelectrons/s/pixel for spectral mode."""
183
+ if self.sky is None:
184
+ return 0.0
185
+ band = self.optics.band
186
+ if band is None or band.response is None:
187
+ raise ValueError("sky_electron_rate requires a band with a spectral response.")
188
+ return self.sky_photon_rate() * band.effective_qe(qe_curve, self.sky.sed)
189
+
190
+ def thermal_electron_rate(self, qe_curve: QE) -> float:
191
+ """Uniform thermal background in photoelectrons/s/pixel for spectral mode."""
192
+ if self.thermal is None:
193
+ return 0.0
194
+ band = self.optics.band
195
+ if band is None or band.response is None:
196
+ raise ValueError("thermal_electron_rate requires a band with a spectral response.")
197
+ return self.thermal_photon_rate() * band.effective_qe(qe_curve, self.thermal.photon_sed())
198
+
199
+ def background_photon_rate(self) -> float:
200
+ """Total uniform background (sky + thermal) in photons/s/pixel."""
201
+ return self.sky_photon_rate() + self.thermal_photon_rate()
202
+
203
+ def background_electron_rate(self, qe_curve: QE) -> float:
204
+ """Total uniform background (sky + thermal) in photoelectrons/s/pixel (spectral)."""
205
+ return self.sky_electron_rate(qe_curve) + self.thermal_electron_rate(qe_curve)