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
|
@@ -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
|