getframes 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- getframes/__about__.py +4 -0
- getframes/__init__.py +91 -0
- getframes/analysis/__init__.py +18 -0
- getframes/analysis/apertures.py +92 -0
- getframes/analysis/ptc.py +109 -0
- getframes/calibrate.py +182 -0
- getframes/camera.py +649 -0
- getframes/cli.py +214 -0
- getframes/config.py +420 -0
- getframes/dataset.py +294 -0
- getframes/frame.py +107 -0
- getframes/noise.py +637 -0
- getframes/observation.py +162 -0
- getframes/presets/__init__.py +90 -0
- getframes/presets/data/__init__.py +3 -0
- getframes/presets/data/andor_ikon_m934.toml +22 -0
- getframes/presets/data/andor_ixon_ultra_888.toml +22 -0
- getframes/presets/data/generic_ccd.toml +18 -0
- getframes/presets/data/generic_cmos.toml +18 -0
- getframes/presets/data/generic_eapd.toml +20 -0
- getframes/presets/data/generic_emccd.toml +20 -0
- getframes/presets/data/generic_scmos.toml +21 -0
- getframes/presets/data/hamamatsu_orca_fusion.toml +25 -0
- getframes/presets/data/leonardo_saphira.toml +32 -0
- getframes/presets/data/zwo_asi2600mm.toml +20 -0
- getframes/py.typed +0 -0
- getframes/scene/__init__.py +51 -0
- getframes/scene/optics.py +180 -0
- getframes/scene/photometry.py +311 -0
- getframes/scene/psf.py +371 -0
- getframes/scene/scene.py +205 -0
- getframes/scene/sources.py +683 -0
- getframes/scene/thermal.py +114 -0
- getframes/scene/wcs.py +110 -0
- getframes/spectral.py +449 -0
- getframes-2.0.0.dist-info/METADATA +218 -0
- getframes-2.0.0.dist-info/RECORD +40 -0
- getframes-2.0.0.dist-info/WHEEL +4 -0
- getframes-2.0.0.dist-info/entry_points.txt +2 -0
- getframes-2.0.0.dist-info/licenses/LICENSE +21 -0
getframes/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]
|
getframes/scene/scene.py
ADDED
|
@@ -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)
|