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/noise.py ADDED
@@ -0,0 +1,637 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Physical noise models that turn a :class:`CameraConfig` into pixel values.
3
+
4
+ The models here are deliberately small, composable, and well-documented so that
5
+ the physics is auditable. Each function takes a configuration, exposure, and a
6
+ seeded :class:`numpy.random.Generator`, and returns electrons or ADU.
7
+
8
+ Signal chain (:func:`simulate_frame`)
9
+ -------------------------------------
10
+ 1. Mean photo signal: ``(photon_rate + background) * t_exp * QE`` electrons,
11
+ modulated per pixel by photo-response non-uniformity (PRNU).
12
+ 2. Mean dark signal: ``D(T) * t_exp`` electrons (temperature-scaled), modulated by
13
+ dark-signal non-uniformity (DSNU) and hot pixels.
14
+ 3. Shot noise: the total electrons are Poisson-distributed about that mean.
15
+ 4. Clock-induced charge (EMCCD) adds a small Poisson term.
16
+ 5. Cosmic rays (single pixels or extended tracks).
17
+ 6. Charge-transport artifacts: blooming along saturated columns, CCD
18
+ charge-transfer inefficiency (CTI), and inter-pixel capacitance (IPC).
19
+ 7. Detector nonlinearity (single-parameter or polynomial).
20
+ 8. EM register / avalanche multiplication with its stochastic excess noise.
21
+ 9. kTC/reset noise and read noise: Gaussian in electrons, at the output amplifier.
22
+ 10. Conversion to ADU via (optionally per-amplifier) gain, plus the bias pedestal
23
+ and any structured-bias pattern; dead pixels/columns read as defects.
24
+ 11. Saturation at full well / ADC range and quantisation to integers.
25
+
26
+ A dark frame is simply the special case ``photon_rate = 0``.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from typing import TYPE_CHECKING, NamedTuple
32
+
33
+ import numpy as np
34
+ from scipy import ndimage
35
+
36
+ if TYPE_CHECKING:
37
+ from numpy.typing import DTypeLike, NDArray
38
+
39
+ from .config import CameraConfig
40
+
41
+ PhotonRate = float | NDArray[np.float64]
42
+
43
+ # The working floating-point dtype of the signal chain. ``float64`` is the exact
44
+ # default; ``float32`` halves the memory of the per-pixel arrays (the "fast path"
45
+ # used for large detectors and bulk dataset generation) at a small precision cost.
46
+ DEFAULT_FLOAT_DTYPE = np.float64
47
+
48
+
49
+ # Independent sub-streams of the fixed-pattern generator, so PRNU, DSNU, and the
50
+ # hot-pixel map are mutually independent yet each stable for a given sensor.
51
+ _FPN_STREAM_PRNU = 0
52
+ _FPN_STREAM_DSNU = 1
53
+ _FPN_STREAM_HOT = 2
54
+ _FPN_STREAM_AMP_GAIN = 3
55
+ _FPN_STREAM_AMP_OFFSET = 4
56
+ _FPN_STREAM_BIAS = 5
57
+ _FPN_STREAM_DEFECT = 6
58
+
59
+
60
+ def _fixed_pattern_rng(config: CameraConfig, stream: int) -> np.random.Generator:
61
+ """A deterministic generator for the sensor's fixed-pattern noise.
62
+
63
+ Seeded only by ``config.fixed_pattern_seed`` (not the per-frame seed), so the
64
+ pattern is identical in every frame this camera produces --- which is what makes
65
+ it removable by a master flat or dark.
66
+ """
67
+ seq = np.random.SeedSequence([int(config.fixed_pattern_seed), stream])
68
+ return np.random.default_rng(seq)
69
+
70
+
71
+ def dark_signal_map(
72
+ config: CameraConfig,
73
+ exposure_s: float,
74
+ temperature_c: float,
75
+ float_dtype: DTypeLike = DEFAULT_FLOAT_DTYPE,
76
+ ) -> NDArray[np.float64]:
77
+ """Per-pixel *mean* dark signal in electrons, including fixed-pattern structure.
78
+
79
+ This is the noise-free expectation per pixel; shot noise is applied separately.
80
+ The fixed-pattern structure (DSNU and hot pixels) is deterministic for a given
81
+ sensor (keyed on :attr:`~getframes.config.CameraConfig.fixed_pattern_seed`), so
82
+ it repeats across frames and can be calibrated out with a master dark. A uniform
83
+ detector-glow term (``detector_glow_e_per_s``) is added on top, also
84
+ exposure-scaled and dark-removable.
85
+
86
+ ``float_dtype`` selects the working precision (``float64`` exact default, or
87
+ ``float32`` for the memory-light fast path).
88
+ """
89
+ height, width = config.resolution
90
+ mean_dark = config.dark_current_at(temperature_c) * exposure_s
91
+ signal = np.full((height, width), mean_dark, dtype=float_dtype)
92
+
93
+ # Dark-signal non-uniformity: log-normal so the per-pixel gain stays positive
94
+ # with unit mean. Drawn from the fixed-pattern stream (same every frame).
95
+ if config.dark_current_nonuniformity > 0 and mean_dark > 0:
96
+ sigma = config.dark_current_nonuniformity
97
+ rng = _fixed_pattern_rng(config, _FPN_STREAM_DSNU)
98
+ dsnu = rng.lognormal(mean=-0.5 * sigma**2, sigma=sigma, size=signal.shape)
99
+ signal *= dsnu
100
+
101
+ # Hot pixels: a sparse, *fixed* population with strongly elevated dark current.
102
+ if config.hot_pixel_fraction > 0 and mean_dark > 0:
103
+ rng = _fixed_pattern_rng(config, _FPN_STREAM_HOT)
104
+ hot_mask = rng.random(signal.shape) < config.hot_pixel_fraction
105
+ signal[hot_mask] *= config.hot_pixel_factor
106
+
107
+ # Detector glow: a uniform self-emission term that scales with exposure (and so
108
+ # is removed by an exposure-matched master dark). Added after DSNU/hot pixels,
109
+ # which describe the dark *current*, not the glow. In place to preserve dtype.
110
+ if config.detector_glow_e_per_s > 0 and exposure_s > 0:
111
+ signal += config.detector_glow_e_per_s * exposure_s
112
+
113
+ return signal
114
+
115
+
116
+ def photo_signal_map(
117
+ config: CameraConfig,
118
+ photon_rate: PhotonRate,
119
+ exposure_s: float,
120
+ background_photon_rate: PhotonRate,
121
+ quantum_efficiency: float | None = None,
122
+ float_dtype: DTypeLike = DEFAULT_FLOAT_DTYPE,
123
+ ) -> NDArray[np.float64]:
124
+ """Per-pixel *mean* photo-generated signal in electrons (noise-free).
125
+
126
+ Converts an incident photon rate (photons/s/pixel, plus an additive
127
+ background) to photoelectrons via the quantum efficiency, then imprints a
128
+ fixed multiplicative PRNU pattern. ``photon_rate`` may be a scalar (uniform
129
+ illumination) or a 2-D array matching the sensor resolution.
130
+
131
+ The PRNU pattern is deterministic for a given sensor (keyed on
132
+ :attr:`~getframes.config.CameraConfig.fixed_pattern_seed`), so it repeats across
133
+ frames and is removable with a master flat.
134
+
135
+ ``quantum_efficiency`` overrides ``config.quantum_efficiency`` when given. The
136
+ spectral path uses this with a pre-multiplied (already-photoelectron) map and
137
+ ``quantum_efficiency = 1.0``. ``float_dtype`` selects the working precision
138
+ (``float64`` default, or ``float32`` for the memory-light fast path).
139
+ """
140
+ height, width = config.resolution
141
+ qe = config.quantum_efficiency if quantum_efficiency is None else quantum_efficiency
142
+ rate = np.asarray(photon_rate, dtype=np.float64)
143
+ background = np.asarray(background_photon_rate, dtype=np.float64)
144
+ if rate.ndim not in (0, 2) or background.ndim not in (0, 2):
145
+ raise ValueError("photon_rate/background must be a scalar or a 2-D array.")
146
+
147
+ mean_photo = np.zeros((height, width), dtype=float_dtype)
148
+ # Broadcasts a scalar or an (h, w) array; a mismatched array shape raises here.
149
+ mean_photo += (rate + background) * exposure_s * qe
150
+
151
+ # Photo-response non-uniformity: a fixed log-normal multiplier with unit mean,
152
+ # applied only where there is light. Drawn from the fixed-pattern stream so it is
153
+ # the same pattern in every frame (a master flat can remove it).
154
+ if config.prnu > 0 and np.any(mean_photo > 0):
155
+ sigma = config.prnu
156
+ rng = _fixed_pattern_rng(config, _FPN_STREAM_PRNU)
157
+ prnu = rng.lognormal(mean=-0.5 * sigma**2, sigma=sigma, size=mean_photo.shape)
158
+ mean_photo *= prnu
159
+
160
+ return mean_photo
161
+
162
+
163
+ def apply_gain_stage(
164
+ electrons: NDArray[np.float64],
165
+ gain: float,
166
+ excess_noise_factor: float,
167
+ rng: np.random.Generator,
168
+ ) -> NDArray[np.float64]:
169
+ r"""Apply a stochastic multiplication stage (EM register or APD avalanche).
170
+
171
+ A single model covers both EMCCDs and avalanche photodiodes, parameterised by
172
+ the mean gain ``G`` and the excess noise factor ``F``. For ``n`` input
173
+ electrons the multiplied output is drawn from a Gamma distribution:
174
+
175
+ .. math::
176
+
177
+ \text{out} \sim \mathrm{Gamma}(\text{shape}=n\alpha,\ \text{scale}=\theta),
178
+ \quad \alpha = \frac{1}{F^2 - 1}, \quad \theta = G\,(F^2 - 1).
179
+
180
+ Then :math:`E[\text{out}] = nG` and, with Poisson input of mean :math:`\mu`, the
181
+ total output variance is :math:`G^2 F^2 \mu` --- i.e. the model reproduces the
182
+ requested excess noise factor exactly. Special cases:
183
+
184
+ * ``F = sqrt(2)`` gives ``alpha = 1`` --- the classic EMCCD ``Gamma(n, G)`` model.
185
+ * ``F -> 1`` is noiseless multiplication (deterministic ``n * G``).
186
+
187
+ Pixels with zero input electrons produce zero output.
188
+ """
189
+ if gain <= 1.0:
190
+ return electrons
191
+ if excess_noise_factor <= 1.0:
192
+ return electrons * gain # noiseless multiplication
193
+
194
+ f2 = excess_noise_factor**2
195
+ alpha = 1.0 / (f2 - 1.0)
196
+ theta = gain * (f2 - 1.0)
197
+ out = np.zeros_like(electrons)
198
+ nonzero = electrons > 0
199
+ if np.any(nonzero):
200
+ out[nonzero] = rng.gamma(shape=electrons[nonzero] * alpha, scale=theta)
201
+ return out
202
+
203
+
204
+ def apply_em_gain(
205
+ electrons: NDArray[np.float64],
206
+ em_gain: float,
207
+ rng: np.random.Generator,
208
+ ) -> NDArray[np.float64]:
209
+ """Backwards-compatible EMCCD multiplication (``F = sqrt(2)`` gain stage).
210
+
211
+ Thin wrapper over :func:`apply_gain_stage`; prefer that for new code.
212
+ """
213
+ return apply_gain_stage(electrons, em_gain, np.sqrt(2.0), rng)
214
+
215
+
216
+ def apply_nonlinearity(
217
+ electrons: NDArray[np.float64],
218
+ config: CameraConfig,
219
+ ) -> NDArray[np.float64]:
220
+ """Bend the charge response near full well (detector nonlinearity).
221
+
222
+ Two models, both deterministic (no randomness):
223
+
224
+ * **Polynomial** (when ``config.nonlinearity_coeffs`` is set): with
225
+ ``u = q / full_well`` and coefficients ``(c1, c2, ...)``, the response
226
+ multiplier is ``1 + c1 u + c2 u**2 + ...``, so an arbitrary measured curve or
227
+ look-up can be reproduced.
228
+ * **Single-parameter** (the default): ``q -> q * (1 - nonlinearity * q /
229
+ full_well)``, a smooth, monotonic compression so a pixel near full well reads
230
+ slightly low.
231
+
232
+ The polynomial model takes precedence when both are configured.
233
+ """
234
+ if config.nonlinearity_coeffs is not None:
235
+ u = np.clip(electrons, 0.0, None) / config.full_well_e
236
+ factor = np.ones_like(u)
237
+ for power, coeff in enumerate(config.nonlinearity_coeffs, start=1):
238
+ factor = factor + coeff * u**power
239
+ bent: NDArray[np.float64] = electrons * np.clip(factor, 0.0, None)
240
+ return bent
241
+ if config.nonlinearity <= 0:
242
+ return electrons
243
+ factor = 1.0 - config.nonlinearity * np.clip(electrons, 0.0, None) / config.full_well_e
244
+ return electrons * np.clip(factor, 0.0, None)
245
+
246
+
247
+ def apply_blooming(
248
+ electrons: NDArray[np.float64],
249
+ full_well_e: float,
250
+ ) -> NDArray[np.float64]:
251
+ """Bleed charge above full well along columns (CCD blooming).
252
+
253
+ Charge exceeding ``full_well_e`` in a pixel floods symmetrically into the
254
+ vacant pixels of the same column (``axis=0``): half the excess sweeps toward
255
+ higher rows and half toward lower rows, each filling successive pixels up to
256
+ full well until the charge is absorbed or runs off the array edge. Deterministic
257
+ and charge-conserving except for charge that bleeds off the top/bottom edge.
258
+ """
259
+ out = np.array(electrons, copy=True)
260
+ excess = np.clip(out - full_well_e, 0.0, None)
261
+ if not excess.any():
262
+ return out
263
+ n_rows, width = out.shape
264
+ out = np.minimum(out, full_well_e)
265
+ # Split the overflow and flood it outward, each direction in a single sweep with
266
+ # a per-column carry; a vacant pixel can only ever be filled up to full well, so
267
+ # charge never flows back into an already-saturated pixel (no oscillation).
268
+ down_share = 0.5 * excess
269
+ up_share = excess - down_share
270
+ for source, rows in ((down_share, range(n_rows)), (up_share, range(n_rows - 1, -1, -1))):
271
+ carry = np.zeros(width, dtype=out.dtype)
272
+ for r in rows:
273
+ incoming = carry + source[r]
274
+ room = full_well_e - out[r]
275
+ fill = np.minimum(incoming, room)
276
+ out[r] += fill
277
+ carry = incoming - fill
278
+ # Any charge still carried past the edge bleeds off the array.
279
+ return out
280
+
281
+
282
+ def apply_cti(
283
+ electrons: NDArray[np.float64],
284
+ cti: float,
285
+ ) -> NDArray[np.float64]:
286
+ """Smear charge by charge-transfer inefficiency (CTI) during readout.
287
+
288
+ A first-order, charge-conserving model: the readout register is row 0, so a
289
+ pixel ``r`` rows away undergoes ``r`` transfers and defers a fraction
290
+ ``cti * r`` of its charge into the trailing pixel one row farther from the
291
+ register (``axis=0``), producing the characteristic CTI tail. Charge deferred
292
+ past the final row is lost into overscan. Deterministic.
293
+ """
294
+ if cti <= 0:
295
+ return electrons
296
+ out = np.array(electrons, copy=True)
297
+ n_rows = out.shape[0]
298
+ transfers = np.arange(n_rows, dtype=np.float64).reshape(n_rows, 1)
299
+ deferred = np.minimum(cti * transfers * out, out)
300
+ out -= deferred
301
+ out[1:] += deferred[:-1]
302
+ return out
303
+
304
+
305
+ def apply_ipc(
306
+ electrons: NDArray[np.float64],
307
+ coupling: float,
308
+ ) -> NDArray[np.float64]:
309
+ """Couple a fraction of each pixel into its four neighbours (inter-pixel capacitance).
310
+
311
+ Convolves with the charge-conserving 3x3 kernel whose centre is
312
+ ``1 - 4*coupling`` and whose four edge-adjacent taps are ``coupling`` each
313
+ (corners zero). Models the capacitive crosstalk of CMOS / IR hybrid arrays.
314
+ Charge coupling past the array boundary is lost. Deterministic.
315
+ """
316
+ if coupling <= 0:
317
+ return electrons
318
+ kernel = np.array(
319
+ [[0.0, coupling, 0.0], [coupling, 1.0 - 4.0 * coupling, coupling], [0.0, coupling, 0.0]],
320
+ dtype=np.float64,
321
+ )
322
+ convolved = ndimage.convolve(electrons, kernel, mode="constant", cval=0.0)
323
+ # Preserve the input dtype (the float32 fast path) — convolve upcasts to float64.
324
+ result: NDArray[np.float64] = convolved.astype(electrons.dtype, copy=False)
325
+ return result
326
+
327
+
328
+ def _amplifier_maps(config: CameraConfig) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
329
+ """Per-pixel conversion-gain (e-/ADU) and extra bias-offset (ADU) maps.
330
+
331
+ Tiles the sensor into ``amplifier_layout`` blocks, each with its own small,
332
+ *fixed* gain and offset error drawn from the fixed-pattern streams. With a
333
+ single amplifier (or no spread configured) the maps are uniform.
334
+ """
335
+ height, width = config.resolution
336
+ n_r, n_c = config.amplifier_layout
337
+ gain = np.full((height, width), config.gain_e_per_adu, dtype=np.float64)
338
+ offset = np.zeros((height, width), dtype=np.float64)
339
+ if n_r * n_c <= 1 or (config.amp_gain_nonuniformity <= 0 and config.amp_offset_spread_adu <= 0):
340
+ return gain, offset
341
+ g_rng = _fixed_pattern_rng(config, _FPN_STREAM_AMP_GAIN)
342
+ o_rng = _fixed_pattern_rng(config, _FPN_STREAM_AMP_OFFSET)
343
+ g_dev = g_rng.normal(0.0, config.amp_gain_nonuniformity, size=(n_r, n_c))
344
+ o_dev = o_rng.normal(0.0, config.amp_offset_spread_adu, size=(n_r, n_c))
345
+ row_blocks = np.array_split(np.arange(height), n_r)
346
+ col_blocks = np.array_split(np.arange(width), n_c)
347
+ for i, rows in enumerate(row_blocks):
348
+ for j, cols in enumerate(col_blocks):
349
+ block = np.ix_(rows, cols)
350
+ gain[block] = config.gain_e_per_adu * (1.0 + g_dev[i, j])
351
+ offset[block] = o_dev[i, j]
352
+ return gain, offset
353
+
354
+
355
+ def _bias_structure_map(config: CameraConfig) -> NDArray[np.float64]:
356
+ """A fixed, structured bias pattern in ADU (a gradient plus per-column offsets).
357
+
358
+ Zero everywhere when ``bias_structure_amplitude_adu`` is zero. Otherwise a
359
+ deterministic pattern (keyed on ``fixed_pattern_seed``) scaled so its peak
360
+ magnitude is ``bias_structure_amplitude_adu``.
361
+ """
362
+ height, width = config.resolution
363
+ if config.bias_structure_amplitude_adu <= 0:
364
+ return np.zeros((height, width), dtype=np.float64)
365
+ rng = _fixed_pattern_rng(config, _FPN_STREAM_BIAS)
366
+ yy = np.linspace(-1.0, 1.0, height).reshape(height, 1)
367
+ xx = np.linspace(-1.0, 1.0, width).reshape(1, width)
368
+ a, b = rng.uniform(-1.0, 1.0, size=2)
369
+ plane = a * xx + b * yy
370
+ col_offsets = rng.normal(0.0, 1.0, size=width).reshape(1, width)
371
+ pattern = 0.6 * plane + 0.4 * col_offsets
372
+ peak = float(np.max(np.abs(pattern)))
373
+ if peak == 0.0:
374
+ return np.zeros((height, width), dtype=np.float64)
375
+ scaled: NDArray[np.float64] = pattern / peak * config.bias_structure_amplitude_adu
376
+ return np.broadcast_to(scaled, (height, width)).astype(np.float64)
377
+
378
+
379
+ def _defect_mask(config: CameraConfig) -> NDArray[np.bool_] | None:
380
+ """A fixed boolean map of dead pixels/columns (``True`` = no response), or ``None``.
381
+
382
+ Deterministic (keyed on ``fixed_pattern_seed``): a fraction of whole columns and
383
+ a fraction of individual pixels are marked dead. ``None`` when neither defect is
384
+ configured.
385
+ """
386
+ height, width = config.resolution
387
+ if config.bad_column_fraction <= 0 and config.dead_pixel_fraction <= 0:
388
+ return None
389
+ rng = _fixed_pattern_rng(config, _FPN_STREAM_DEFECT)
390
+ mask = np.zeros((height, width), dtype=np.bool_)
391
+ if config.dead_pixel_fraction > 0:
392
+ mask |= rng.random((height, width)) < config.dead_pixel_fraction
393
+ if config.bad_column_fraction > 0:
394
+ bad_cols = rng.random(width) < config.bad_column_fraction
395
+ mask[:, bad_cols] = True
396
+ return mask
397
+
398
+
399
+ def add_cosmic_rays(
400
+ electrons: NDArray[np.float64],
401
+ config: CameraConfig,
402
+ exposure_s: float,
403
+ rng: np.random.Generator,
404
+ ) -> NDArray[np.float64]:
405
+ """Deposit cosmic-ray charge bursts into random pixels.
406
+
407
+ The number of hits is Poisson with mean ``rate * area * exposure``; each hit
408
+ carries a broad charge burst of order ten thousand electrons. When
409
+ ``config.cosmic_ray_track_length_px`` is zero the charge lands in a single
410
+ pixel; when positive, each hit draws an exponential track length and a random
411
+ in-plane direction (a glancing muon) and spreads its charge evenly along the
412
+ track --- the extended morphology a real rejection pipeline must handle.
413
+ """
414
+ height, width = electrons.shape
415
+ pixel_cm = config.pixel_size_um * 1e-4
416
+ area_cm2 = height * width * pixel_cm**2
417
+ expected = config.cosmic_ray_rate_per_cm2_s * area_cm2 * exposure_s
418
+ n_hits = int(rng.poisson(expected))
419
+ if n_hits == 0:
420
+ return electrons
421
+ ys = rng.integers(0, height, n_hits)
422
+ xs = rng.integers(0, width, n_hits)
423
+ # Charge per hit: a broad distribution centred on ~10,000 e-.
424
+ charges = rng.gamma(shape=2.0, scale=5000.0, size=n_hits)
425
+
426
+ if config.cosmic_ray_track_length_px <= 0:
427
+ np.add.at(electrons, (ys, xs), charges)
428
+ return electrons
429
+
430
+ lengths = rng.exponential(config.cosmic_ray_track_length_px, size=n_hits)
431
+ angles = rng.uniform(0.0, 2.0 * np.pi, size=n_hits)
432
+ for x0, y0, charge, length, angle in zip(xs, ys, charges, lengths, angles):
433
+ n_steps = max(1, round(float(length)))
434
+ steps = np.arange(n_steps)
435
+ tx = np.clip(np.round(x0 + np.cos(angle) * steps).astype(int), 0, width - 1)
436
+ ty = np.clip(np.round(y0 + np.sin(angle) * steps).astype(int), 0, height - 1)
437
+ np.add.at(electrons, (ty, tx), charge / n_steps)
438
+ return electrons
439
+
440
+
441
+ def digitize(
442
+ electrons: NDArray[np.float64],
443
+ config: CameraConfig,
444
+ rng: np.random.Generator,
445
+ ) -> NDArray[np.uint32]:
446
+ """Add read/reset noise, convert electrons to ADU, then saturate and quantise.
447
+
448
+ Read noise is referenced to the sensor output amplifier. When
449
+ ``read_noise_nonuniformity`` is set (sCMOS), each pixel gets its own read-noise
450
+ RMS drawn from a log-normal distribution about ``read_noise_e``.
451
+
452
+ Detector-depth structure is folded in here: dead pixels/columns collect no
453
+ charge; kTC/reset noise adds a per-pixel Gaussian; a multi-amplifier layout
454
+ applies per-block conversion gain and offset; and a fixed structured-bias
455
+ pattern rides on the flat pedestal.
456
+ """
457
+ signal = np.clip(electrons, 0.0, None)
458
+ signal = np.minimum(signal, config.full_well_e)
459
+
460
+ # Dead pixels/columns: a fixed defect map that collects no charge (they still
461
+ # carry read/reset noise and the bias pedestal, so they read as dark defects).
462
+ defects = _defect_mask(config)
463
+ if defects is not None:
464
+ signal[defects] = 0.0
465
+
466
+ # kTC / reset noise: an independent per-pixel, per-frame Gaussian (electrons).
467
+ # Added in place so the working dtype (e.g. the float32 fast path) is preserved.
468
+ if config.reset_noise_e > 0:
469
+ signal += rng.normal(0.0, config.reset_noise_e, size=signal.shape)
470
+
471
+ # Read noise in electrons, added at the amplifier.
472
+ if config.read_noise_e > 0:
473
+ if config.read_noise_nonuniformity > 0:
474
+ spread = config.read_noise_nonuniformity
475
+ sigma_map = config.read_noise_e * rng.lognormal(
476
+ mean=-0.5 * spread**2, sigma=spread, size=signal.shape
477
+ )
478
+ signal += rng.standard_normal(signal.shape) * sigma_map
479
+ else:
480
+ signal += rng.normal(0.0, config.read_noise_e, size=signal.shape)
481
+
482
+ gain_map, amp_offset = _amplifier_maps(config)
483
+ adu = signal / gain_map + config.bias_offset_adu + amp_offset + _bias_structure_map(config)
484
+ adu = np.clip(np.round(adu), 0, config.max_adu)
485
+ return adu.astype(np.uint32)
486
+
487
+
488
+ class SimulationResult(NamedTuple):
489
+ """The output of :func:`simulate_frame`: the digitised frame plus ground truth."""
490
+
491
+ adu: NDArray[np.uint32]
492
+ mean_photoelectrons: NDArray[np.float64]
493
+ mean_dark_electrons: NDArray[np.float64]
494
+ photon_rate: PhotonRate
495
+
496
+
497
+ def frame_electrons(
498
+ config: CameraConfig,
499
+ mean_electrons: NDArray[np.float64],
500
+ rng: np.random.Generator,
501
+ exposure_s: float = 0.0,
502
+ ) -> NDArray[np.float64]:
503
+ """Apply shot noise, CIC, cosmic rays, nonlinearity, and any gain stage.
504
+
505
+ Takes the noise-free expected electrons per pixel and returns a realised
506
+ electron frame prior to read noise and digitisation. ``exposure_s`` is needed
507
+ only to scale the cosmic-ray rate. The working dtype follows ``mean_electrons``
508
+ (``float64`` exact, or ``float32`` for the memory-light fast path).
509
+ """
510
+ electrons = rng.poisson(mean_electrons).astype(mean_electrons.dtype)
511
+
512
+ if config.clock_induced_charge_e > 0:
513
+ electrons += rng.poisson(config.clock_induced_charge_e, size=electrons.shape)
514
+
515
+ if config.cosmic_ray_rate_per_cm2_s > 0 and exposure_s > 0:
516
+ electrons = add_cosmic_rays(electrons, config, exposure_s, rng)
517
+
518
+ if config.blooming:
519
+ electrons = apply_blooming(electrons, config.full_well_e)
520
+
521
+ if config.cti > 0:
522
+ electrons = apply_cti(electrons, config.cti)
523
+
524
+ if config.ipc_coupling > 0:
525
+ electrons = apply_ipc(electrons, config.ipc_coupling)
526
+
527
+ if config.nonlinearity > 0 or config.nonlinearity_coeffs is not None:
528
+ electrons = apply_nonlinearity(electrons, config)
529
+
530
+ if config.has_gain_stage:
531
+ electrons = apply_gain_stage(
532
+ electrons, config.em_gain, config.gain_excess_noise_factor, rng
533
+ )
534
+
535
+ return electrons
536
+
537
+
538
+ def simulate_frame(
539
+ config: CameraConfig,
540
+ photon_rate: PhotonRate,
541
+ exposure_s: float,
542
+ *,
543
+ temperature_c: float,
544
+ background_photon_rate: PhotonRate = 0.0,
545
+ quantum_efficiency: float | None = None,
546
+ extra_electrons: PhotonRate = 0.0,
547
+ rng: np.random.Generator | None = None,
548
+ seed: int | None = None,
549
+ float_dtype: DTypeLike = DEFAULT_FLOAT_DTYPE,
550
+ ) -> SimulationResult:
551
+ """Simulate one frame end-to-end, returning ADU and the noise-free truth.
552
+
553
+ Parameters
554
+ ----------
555
+ config:
556
+ The detector configuration.
557
+ photon_rate:
558
+ Incident photon rate in photons/s/pixel, as a scalar (uniform) or a 2-D
559
+ array. Use ``0.0`` for a dark/bias frame.
560
+ exposure_s:
561
+ Integration time in seconds (``0`` for a bias frame).
562
+ temperature_c:
563
+ Sensor temperature in degrees Celsius.
564
+ background_photon_rate:
565
+ Additive background (sky/thermal) photon rate in photons/s/pixel.
566
+ quantum_efficiency:
567
+ Overrides ``config.quantum_efficiency`` for the photon-to-electron step
568
+ (used by spectral mode with a pre-converted electron map and ``1.0``).
569
+ extra_electrons:
570
+ Additive noise-free signal already in electrons (scalar or 2-D array),
571
+ injected before shot noise and the gain stage. Used to carry latent charge
572
+ from image persistence across the frames of an observation; it is real
573
+ charge in the well, so it picks up shot noise and any EM/avalanche gain.
574
+ rng, seed:
575
+ Provide an existing generator, or a seed to build a fresh one.
576
+ float_dtype:
577
+ Working floating-point precision of the per-pixel arrays: ``float64`` (the
578
+ exact default) or ``float32`` for the memory-light fast path used for large
579
+ detectors and bulk dataset generation. The digitised ADU stay integer
580
+ regardless; only the floating-point signal chain and the truth arrays change.
581
+ """
582
+ if exposure_s < 0:
583
+ raise ValueError("exposure_s must be non-negative.")
584
+ if rng is None:
585
+ rng = np.random.default_rng(seed)
586
+
587
+ mean_photo = photo_signal_map(
588
+ config, photon_rate, exposure_s, background_photon_rate, quantum_efficiency, float_dtype
589
+ )
590
+ mean_dark = dark_signal_map(config, exposure_s, temperature_c, float_dtype)
591
+ mean_total = mean_photo + mean_dark + np.asarray(extra_electrons, dtype=float_dtype)
592
+ electrons = frame_electrons(config, mean_total, rng, exposure_s)
593
+ adu = digitize(electrons, config, rng)
594
+ return SimulationResult(adu, mean_photo, mean_dark, photon_rate)
595
+
596
+
597
+ def generate_dark_frame(
598
+ config: CameraConfig,
599
+ exposure_s: float,
600
+ temperature_c: float,
601
+ rng: np.random.Generator | None = None,
602
+ seed: int | None = None,
603
+ ) -> NDArray[np.uint32]:
604
+ """End-to-end dark frame in ADU (the ``photon_rate = 0`` case of ``simulate_frame``)."""
605
+ return simulate_frame(
606
+ config, 0.0, exposure_s, temperature_c=temperature_c, rng=rng, seed=seed
607
+ ).adu
608
+
609
+
610
+ def dark_frame_electrons(
611
+ config: CameraConfig,
612
+ exposure_s: float,
613
+ temperature_c: float,
614
+ rng: np.random.Generator,
615
+ ) -> NDArray[np.float64]:
616
+ """Electron-domain dark frame prior to digitisation (kept for convenience)."""
617
+ mean_dark = dark_signal_map(config, exposure_s, temperature_c)
618
+ return frame_electrons(config, mean_dark, rng, exposure_s)
619
+
620
+
621
+ __all__ = [
622
+ "SimulationResult",
623
+ "add_cosmic_rays",
624
+ "apply_blooming",
625
+ "apply_cti",
626
+ "apply_em_gain",
627
+ "apply_gain_stage",
628
+ "apply_ipc",
629
+ "apply_nonlinearity",
630
+ "dark_frame_electrons",
631
+ "dark_signal_map",
632
+ "digitize",
633
+ "frame_electrons",
634
+ "generate_dark_frame",
635
+ "photo_signal_map",
636
+ "simulate_frame",
637
+ ]