eclipse 0.13.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 (50) hide show
  1. eclipse/__init__.py +95 -0
  2. eclipse/analysis.py +853 -0
  3. eclipse/atmosphere.py +859 -0
  4. eclipse/band.py +477 -0
  5. eclipse/cli.py +138 -0
  6. eclipse/config.py +1710 -0
  7. eclipse/continuum.py +413 -0
  8. eclipse/data/eis_response/EIS_EffArea_A.004 +61 -0
  9. eclipse/data/eis_response/EIS_EffArea_B.004 +60 -0
  10. eclipse/data/eis_response/EIS_EffArea_B.005 +64 -0
  11. eclipse/data/eis_response/README_dz2025 +50 -0
  12. eclipse/data/eis_response/fit_eis_ea_2023-05-04_smooth.sav +0 -0
  13. eclipse/data/eis_response/source.txt +33 -0
  14. eclipse/data/science_cases.yaml +365 -0
  15. eclipse/data/throughput/grating_reflection_efficiency.dat +25 -0
  16. eclipse/data/throughput/primary_mirror_coating_reflectance.dat +25 -0
  17. eclipse/data/throughput/source.txt +6 -0
  18. eclipse/data/throughput/throughput_aluminium_1000_angstrom.dat +503 -0
  19. eclipse/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +503 -0
  20. eclipse/data/throughput/throughput_carbon_1000_angstrom.dat +503 -0
  21. eclipse/data_processing.py +904 -0
  22. eclipse/deprecated.py +161 -0
  23. eclipse/eis_calibration.py +573 -0
  24. eclipse/extern/__init__.py +0 -0
  25. eclipse/extern/mpfit.py +2388 -0
  26. eclipse/fitting.py +1734 -0
  27. eclipse/frame.py +594 -0
  28. eclipse/full_frame.py +944 -0
  29. eclipse/main.py +1646 -0
  30. eclipse/monte_carlo.py +660 -0
  31. eclipse/pinhole_diffraction.py +409 -0
  32. eclipse/psf.py +6 -0
  33. eclipse/radiometric.py +1328 -0
  34. eclipse/raster.py +869 -0
  35. eclipse/readout.py +1002 -0
  36. eclipse/resolution_budget.py +108 -0
  37. eclipse/results_file.py +1226 -0
  38. eclipse/sampling.py +351 -0
  39. eclipse/science_cases.py +364 -0
  40. eclipse/synthesis.py +2492 -0
  41. eclipse/synthesis_cli.py +12 -0
  42. eclipse/synthesis_file.py +882 -0
  43. eclipse/utils.py +1145 -0
  44. eclipse-0.13.0.dist-info/METADATA +70 -0
  45. eclipse-0.13.0.dist-info/RECORD +50 -0
  46. eclipse-0.13.0.dist-info/WHEEL +5 -0
  47. eclipse-0.13.0.dist-info/entry_points.txt +7 -0
  48. eclipse-0.13.0.dist-info/licenses/LICENSE +3 -0
  49. eclipse-0.13.0.dist-info/top_level.txt +2 -0
  50. euvst_response/__init__.py +129 -0
eclipse/radiometric.py ADDED
@@ -0,0 +1,1328 @@
1
+ """
2
+ Radiometric pipeline functions for converting intensities to detector signals.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ import numpy as np
7
+ import astropy.units as u
8
+ import astropy.constants as const
9
+ from ndcube import NDCube
10
+ from scipy.ndimage import convolve1d
11
+ from scipy.signal import convolve2d
12
+ from scipy.special import erf
13
+ from scipy.stats import binom, poisson
14
+ from . import resolution_budget
15
+ from .config import _pair_energy
16
+ from .sampling import _carry_photon_wavelengths
17
+ from .utils import wl_to_vel, vel_to_wl, debug_break, _fwhm_to_sigma
18
+
19
+
20
+ def _poisson_inverse_transform(mean_counts, size=None) -> np.ndarray:
21
+ """
22
+ Draw Poisson counts by inverse-transform (quantile) sampling.
23
+
24
+ Each element takes one uniform draw through the Poisson inverse CDF. The
25
+ distribution is the same as `np.random.poisson`, but each element uses a
26
+ fixed number of random draws, where its rejection sampling uses a
27
+ variable number. This keeps the random stream synchronised across runs
28
+ that differ only in the Poisson mean, which common-random-number
29
+ variance reduction needs.
30
+
31
+ Parameters
32
+ ----------
33
+ mean_counts : float or np.ndarray
34
+ Poisson mean, scalar or per element.
35
+ size : tuple of int, optional
36
+ Shape to draw. Defaults to the shape of `mean_counts`.
37
+
38
+ Returns
39
+ -------
40
+ np.ndarray
41
+ Sampled counts as int64.
42
+ """
43
+ if size is None:
44
+ size = np.shape(mean_counts)
45
+
46
+ u_draw = np.random.random(size=size)
47
+ # np.random.random() draws from [0, 1), and scipy's ppf gives -1 at
48
+ # exactly 0, a negative count. Clamping to the smallest positive double
49
+ # keeps the CRN property.
50
+ np.maximum(u_draw, np.nextafter(0.0, 1.0), out=u_draw)
51
+
52
+ return poisson.ppf(u_draw, mean_counts).astype(np.int64)
53
+
54
+
55
+ def _binomial_inverse_transform(trials, probability: float) -> np.ndarray:
56
+ """
57
+ Draw binomial counts by inverse-transform sampling, one uniform per element.
58
+
59
+ As in :func:`_poisson_inverse_transform`, the distribution is that of
60
+ `np.random.binomial`, but the number of random draws does not depend on
61
+ the number of trials. Runs that differ only in photon flux then use the
62
+ same random numbers for the quantum efficiency.
63
+ """
64
+ u_draw = np.random.random(size=np.shape(trials))
65
+ np.maximum(u_draw, np.nextafter(0.0, 1.0), out=u_draw)
66
+ return binom.ppf(u_draw, trials, probability).astype(np.int64)
67
+
68
+
69
+ def electrons_per_photon(rest_wavelength: u.Quantity, det) -> np.ndarray:
70
+ """
71
+ Mean number of electrons a detected photon frees in the detector silicon.
72
+
73
+ This is the photon energy over the detector's `pair_energy`, the energy
74
+ per electron-hole pair, which depends on the CCD temperature.
75
+
76
+ Parameters
77
+ ----------
78
+ rest_wavelength : u.Quantity
79
+ Photon wavelength, one value or an array.
80
+ det : Detector_SWC or Detector_EIS
81
+ Detector. Another object needs a `ccd_temperature`, and is taken as
82
+ silicon unless it gives a `material` or `pair_energy_300k` and
83
+ `pair_energy_slope`.
84
+
85
+ Returns
86
+ -------
87
+ np.ndarray
88
+ Electrons per photon, same shape as `rest_wavelength`.
89
+ """
90
+ pair_energy = getattr(det, "pair_energy", None)
91
+ if pair_energy is None:
92
+ if not hasattr(det, "ccd_temperature"):
93
+ raise ValueError("The detector has no ccd_temperature, which sets the energy to "
94
+ "free an electron-hole pair.")
95
+ pair_energy = _pair_energy(det)
96
+ photon_energy_ev = (const.h * const.c / (rest_wavelength.to(u.angstrom))).to(u.eV).value
97
+ return np.asarray(photon_energy_ev / pair_energy.to_value(u.eV), dtype=float)
98
+
99
+
100
+ def dn_variance(dn: np.ndarray, wavelength: u.Quantity, t_exp: u.Quantity, det,
101
+ visible_electrons: np.ndarray | float = 0.0, n_binned: int = 1, *,
102
+ rms_wavelength: u.Quantity | None = None,
103
+ dark_time: u.Quantity | None = None) -> np.ndarray:
104
+ """
105
+ Variance of each pixel's DN estimated from the DN.
106
+
107
+ The dark current and visible light are known, and the rest of the
108
+ signal is taken as EUV. The variance is the sum of:
109
+
110
+ - EUV: (electrons per photon + Fano factor) times the EUV electrons.
111
+ For photons of different energies, the electrons per photon is the
112
+ mean of its square over its mean.
113
+ - Mean dark current and visible light (Poisson).
114
+ - Read noise: `read_noise_rms` squared.
115
+ - Rounding to whole DN: 1/12 DN^2.
116
+
117
+ A signal at or below the dark current and visible light is taken to
118
+ hold no EUV. Each of the `n_binned` pixels summed after read-out adds
119
+ its own dark current, read noise and rounding.
120
+
121
+ Parameters
122
+ ----------
123
+ dn : np.ndarray
124
+ DN in each pixel, or summed over `n_binned` pixels along the slit.
125
+ wavelength : u.Quantity
126
+ Wavelength of the mean photon energy in each pixel, same shape as
127
+ `dn`, or one value for all, such as the line's rest wavelength. For
128
+ summed pixels, that of all their photons, from
129
+ `binned_photon_wavelengths`. Sets the electrons per photon.
130
+ t_exp : u.Quantity
131
+ Exposure time, for the dark current unless `dark_time` is given.
132
+ det : Detector_SWC or Detector_EIS
133
+ Detector.
134
+ visible_electrons : np.ndarray or float, optional
135
+ Mean electrons from visible light in each pixel, as added by
136
+ `add_visible_stray_light` and `add_pinhole_visible_light` with
137
+ `noise=False`, summed over the same pixels as `dn`. Default 0.
138
+ n_binned : int, optional
139
+ Number of pixels summed in each value of `dn`. Default 1.
140
+ rms_wavelength : u.Quantity, optional
141
+ Wavelength of the root mean square energy of the same photons, in
142
+ the same form as `wavelength`. Sets how far the electrons of photons
143
+ of different energies spread. Default `wavelength` (photons all of
144
+ one energy).
145
+ dark_time : u.Quantity, optional
146
+ Time each pixel collects dark current, one value or an array that
147
+ broadcasts against `dn`. Default `t_exp`.
148
+
149
+ Returns
150
+ -------
151
+ np.ndarray
152
+ Variance in DN^2, same shape as `dn`.
153
+ """
154
+ gain = det.gain_e_per_dn.to_value(u.electron / u.DN)
155
+ dark_time = t_exp if dark_time is None else dark_time
156
+ dark = n_binned * (det.dark_current * dark_time).to_value(u.electron / u.pixel)
157
+ read = n_binned * det.read_noise_rms.to_value(u.electron / u.pixel) ** 2
158
+ visible = np.asarray(visible_electrons, dtype=float)
159
+ electrons = np.asarray(dn, dtype=float) * gain
160
+ euv = np.maximum(electrons - dark - visible, 0.0)
161
+ mean = electrons_per_photon(wavelength, det)
162
+ square = mean**2 if rms_wavelength is None else electrons_per_photon(rms_wavelength, det)**2
163
+ per_electron = square / mean + det.si_fano
164
+ variance_electrons = per_electron * euv + dark + visible + read
165
+ return variance_electrons / gain ** 2 + n_binned / 12.0
166
+
167
+
168
+ def _vectorized_fano_noise(photon_counts: np.ndarray, rest_wavelength: u.Quantity, det,
169
+ *, noise: bool = True, every_pixel: bool = False,
170
+ rms_wavelength: u.Quantity | None = None) -> np.ndarray:
171
+ """
172
+ Electrons from photon counts with Fano noise, vectorized for speed.
173
+
174
+ Parameters
175
+ ----------
176
+ photon_counts : np.ndarray
177
+ Photon counts (unitless).
178
+ rest_wavelength : u.Quantity
179
+ Wavelength of the mean energy of each pixel's photons, one value or
180
+ an array that broadcasts against `photon_counts`.
181
+ det : Detector_SWC or Detector_EIS
182
+ Detector, with the Fano noise parameters.
183
+ noise : bool, optional
184
+ If False, each photon frees its mean number of electrons rather
185
+ than a draw around it. The conversion gain is unchanged; only its
186
+ spread is dropped. Default True.
187
+ every_pixel : bool, optional
188
+ Draw one standard normal for every pixel, with or without photons,
189
+ rather than one per pixel with photons. The random numbers then do
190
+ not depend on how many pixels have photons, so runs that differ only
191
+ in photon flux use the same ones. Same distribution. Default False.
192
+ rms_wavelength : u.Quantity, optional
193
+ Wavelength of the root mean square energy of each pixel's photons,
194
+ in the same form as `rest_wavelength`. Photons of different energies
195
+ free different numbers of electrons, which widens a pixel's spread.
196
+ Default `rest_wavelength` (photons all of one energy).
197
+
198
+ Returns
199
+ -------
200
+ np.ndarray
201
+ Electron counts with Fano noise applied.
202
+ """
203
+ # Handle zero or negative photon counts
204
+ mask_positive = photon_counts > 0
205
+ electron_counts = np.zeros_like(photon_counts)
206
+ standard = (np.random.standard_normal(photon_counts.shape) if noise and every_pixel
207
+ else None)
208
+
209
+ if not np.any(mask_positive):
210
+ return electron_counts
211
+
212
+ # Mean electrons per photon for each pixel. The wavelength may be one
213
+ # value or an array that broadcasts against the counts, such as one per
214
+ # row for a frame that spans the band.
215
+ mean_electrons_per_photon = np.broadcast_to(
216
+ electrons_per_photon(rest_wavelength, det), photon_counts.shape)[mask_positive]
217
+
218
+ # Variance per photon: the Fano variance plus, for photons of different
219
+ # energies, the variance of their mean electrons. That is the mean
220
+ # square, from the root mean square energy, less the squared mean; it is
221
+ # zero for one energy, but rounding can take it slightly below.
222
+ variance_per_photon = det.si_fano * mean_electrons_per_photon
223
+ if rms_wavelength is not None:
224
+ rms_electrons_per_photon = np.broadcast_to(
225
+ electrons_per_photon(rms_wavelength, det), photon_counts.shape)[mask_positive]
226
+ variance_per_photon = variance_per_photon + np.maximum(
227
+ rms_electrons_per_photon**2 - mean_electrons_per_photon**2, 0.0)
228
+ sigma_per_photon = np.sqrt(variance_per_photon)
229
+
230
+ # Work only with positive photon counts
231
+ positive_photons = photon_counts[mask_positive]
232
+
233
+ # Draw the total for N photons from Normal(N*mean_e, sqrt(N)*sigma_per_photon)
234
+ # rather than each photon's electrons from its own energy. This has the
235
+ # same mean and variance, is equivalent for large N and is much faster.
236
+
237
+ mean_total_electrons = positive_photons * mean_electrons_per_photon
238
+
239
+ if noise:
240
+ std_total_electrons = np.sqrt(positive_photons) * sigma_per_photon
241
+
242
+ # Sample total electrons per pixel
243
+ if standard is None:
244
+ total_electrons = np.random.normal(
245
+ loc=mean_total_electrons,
246
+ scale=std_total_electrons
247
+ )
248
+ else:
249
+ total_electrons = mean_total_electrons + std_total_electrons * standard[mask_positive]
250
+
251
+ # Ensure non-negative
252
+ total_electrons = np.maximum(total_electrons, 0)
253
+ else:
254
+ total_electrons = mean_total_electrons
255
+
256
+ # Map back to full array
257
+ electron_counts[mask_positive] = total_electrons
258
+
259
+ return electron_counts
260
+
261
+
262
+ def _detected_photons(photons: np.ndarray, det, *, noise: bool = True,
263
+ inverse_transform: bool = False) -> np.ndarray:
264
+ """
265
+ Photons detected in each pixel, given the quantum efficiency `det.qe_euv`.
266
+
267
+ With `noise` on, each pixel is a binomial draw over its whole photons,
268
+ and fractions of a photon are dropped. With `noise` off, it is the mean,
269
+ `qe_euv` times the photons. `inverse_transform` draws by
270
+ inverse-transform sampling, for common random numbers.
271
+ """
272
+ if not noise:
273
+ return (np.asarray(photons) * det.qe_euv).astype(float, copy=False)
274
+ trials = np.asarray(photons).astype(np.int64)
275
+ if inverse_transform:
276
+ detected = _binomial_inverse_transform(trials, det.qe_euv)
277
+ else:
278
+ detected = np.random.binomial(trials, det.qe_euv)
279
+ return detected.astype(float)
280
+
281
+
282
+ def _dark_electrons(mean, shape: tuple, *, noise: bool = True,
283
+ inverse_transform: bool = False) -> np.ndarray:
284
+ """
285
+ Dark current electrons in each pixel.
286
+
287
+ `mean` is in electrons per pixel, one value for all pixels or an array
288
+ that broadcasts to `shape`. With `noise` on, each pixel is a Poisson
289
+ draw. With `noise` off, it is the mean. `inverse_transform` draws by
290
+ inverse-transform sampling, for common random numbers. Raises
291
+ ValueError for a `mean` that does not broadcast to `shape`.
292
+ """
293
+ mean = np.asarray(mean, dtype=float)
294
+ try:
295
+ full = np.broadcast_to(mean, shape)
296
+ except ValueError:
297
+ raise ValueError(f"The dark mean, shaped {np.shape(mean)}, does not broadcast to "
298
+ f"the pixels, shaped {tuple(shape)}.") from None
299
+ if not noise:
300
+ return np.array(full)
301
+ if inverse_transform:
302
+ return _poisson_inverse_transform(mean, size=shape)
303
+ return np.random.poisson(mean, size=shape)
304
+
305
+
306
+ def _read_noise(shape: tuple, det) -> np.ndarray:
307
+ """Read noise in electrons per pixel, a normal draw of rms `det.read_noise_rms`."""
308
+ return np.random.normal(0.0, det.read_noise_rms.to_value(u.electron / u.pixel), shape)
309
+
310
+
311
+ def _digitise(electrons: np.ndarray, det, *, rounded: bool = True) -> np.ndarray:
312
+ """
313
+ DN per pixel in each pixel.
314
+
315
+ The electrons are divided by the gain, rounded to the nearest DN and
316
+ clipped at `det.max_dn`. With `rounded` False they are not rounded, as
317
+ for the mean DN.
318
+ """
319
+ dn = np.asarray(electrons, dtype=float) / det.gain_e_per_dn.to_value(u.electron / u.DN)
320
+ if rounded:
321
+ dn = np.round(dn)
322
+ return np.minimum(dn, det.max_dn.to_value(u.DN / u.pixel))
323
+
324
+
325
+ def photons_per_energy(tel, wavelength: u.Quantity) -> u.Quantity:
326
+ """
327
+ Photons the telescope collects per erg of incident light, at each wavelength.
328
+
329
+ This is the collecting area times the throughput, over the photon
330
+ energy. ECLIPSE takes it at each photon's own wavelength, before the
331
+ spectrograph blur, since the blur moves where a photon lands but not
332
+ its wavelength.
333
+
334
+ Parameters
335
+ ----------
336
+ tel : Telescope_EUVST or Telescope_EIS
337
+ Telescope, queried for all wavelengths at once.
338
+ wavelength : u.Quantity
339
+ Wavelengths.
340
+
341
+ Returns
342
+ -------
343
+ u.Quantity
344
+ In cm2 / erg, one value per wavelength. Beyond the throughput tables
345
+ EUVST's telescope gives NaN and EIS's raises a ValueError.
346
+ """
347
+ wavelength = np.atleast_1d(u.Quantity(wavelength))
348
+ area = np.broadcast_to(u.Quantity(tel.ea_and_throughput(wavelength)).to(u.cm**2),
349
+ wavelength.shape, subok=True)
350
+ return (area * wavelength / (const.h * const.c)).to(u.cm**2 / u.erg)
351
+
352
+
353
+ def intensity_to_photons(I: NDCube) -> NDCube:
354
+ """
355
+ Convert energy to photons by dividing by the photon energy at each wavelength.
356
+
357
+ Parameters
358
+ ----------
359
+ I : NDCube
360
+ Spectral radiance over the exposure, from `apply_exposure`, with
361
+ wavelength on the last axis.
362
+
363
+ Returns
364
+ -------
365
+ NDCube
366
+ Photons, in photon / (cm2 sr cm).
367
+ """
368
+ wl_axis = I.axis_world_coords_values(2)[0]
369
+ E_ph = (const.h * const.c / wl_axis).to("erg") * (1 / u.photon)
370
+
371
+ photon_data = (I.data * I.unit / E_ph).to(u.photon / u.cm**2 / u.sr / u.cm)
372
+
373
+ return NDCube(
374
+ data=photon_data.value,
375
+ wcs=I.wcs.deepcopy(),
376
+ unit=photon_data.unit,
377
+ meta=I.meta,
378
+ )
379
+
380
+
381
+ def add_telescope_throughput(ph_flux: NDCube, tel) -> NDCube:
382
+ """
383
+ Multiply by the telescope's effective area at each wavelength, before the detector.
384
+
385
+ Parameters
386
+ ----------
387
+ ph_flux : NDCube
388
+ Photons, from `intensity_to_photons`.
389
+ tel : Telescope_EUVST or Telescope_EIS
390
+ Telescope; its `ea_and_throughput` is used.
391
+
392
+ Returns
393
+ -------
394
+ NDCube
395
+ Photons collected, in photon / (sr cm).
396
+
397
+ Raises
398
+ ------
399
+ ValueError
400
+ If the cube's wavelengths reach beyond the telescope's throughput
401
+ tables.
402
+ """
403
+ wl0 = ph_flux.meta['rest_wav']
404
+ wl_axis = ph_flux.axis_world_coords_values(2)[0]
405
+ throughput = np.array([tel.ea_and_throughput(wl).cgs.value for wl in wl_axis]) * u.cm**2
406
+ # Beyond the throughput tables there is no detector, so a window pixel
407
+ # there is one the instrument does not have. Filled with zero it would
408
+ # pull the fit; left as NaN it spreads through the blur, or makes the fit
409
+ # wrong with no warning.
410
+ outside = ~np.isfinite(throughput.value)
411
+ if outside.any():
412
+ missing = wl_axis[outside].to_value(u.AA)
413
+ raise ValueError(
414
+ f"The window of the line at {u.Quantity(wl0).to(u.AA):.4f} reaches "
415
+ f"{missing.min():.4f} to {missing.max():.4f} Angstrom, where the telescope's "
416
+ f"throughput tables, and so its band, end. Observe a line further inside the band.")
417
+
418
+ out_data = (ph_flux.data * ph_flux.unit * throughput)
419
+
420
+ return NDCube(
421
+ data=out_data.value,
422
+ wcs=ph_flux.wcs.deepcopy(),
423
+ unit=out_data.unit,
424
+ meta=ph_flux.meta,
425
+ )
426
+
427
+
428
+ def photons_to_pixel_counts(ph_flux: NDCube, wl_pitch: u.Quantity, plate_scale: u.Quantity, slit_width: u.Quantity) -> NDCube:
429
+ """
430
+ Convert the photons collected to photons per detector pixel.
431
+
432
+ Each pixel sees a patch of Sun one pixel along the slit and one slit
433
+ width across, over one pixel in wavelength.
434
+
435
+ Parameters
436
+ ----------
437
+ ph_flux : NDCube
438
+ Photons collected, from `add_telescope_throughput`.
439
+ wl_pitch : u.Quantity
440
+ Wavelength step per pixel, `det.wvl_res`.
441
+ plate_scale : u.Quantity
442
+ Length on the Sun of one pixel along the slit,
443
+ `det.plate_scale_length`.
444
+ slit_width : u.Quantity
445
+ Slit width as a length on the Sun, such as
446
+ `angle_to_distance(sim.slit_width)`.
447
+
448
+ Returns
449
+ -------
450
+ NDCube
451
+ Photons per pixel.
452
+ """
453
+ pixel_solid_angle = ((plate_scale * u.pixel * slit_width).cgs / const.au.cgs ** 2) * u.sr
454
+
455
+ out_data = (ph_flux.data * ph_flux.unit * pixel_solid_angle * wl_pitch.to(u.cm/u.pix))
456
+
457
+ return NDCube(
458
+ data=out_data.value,
459
+ wcs=ph_flux.wcs.deepcopy(),
460
+ unit=out_data.unit,
461
+ meta=ph_flux.meta,
462
+ )
463
+
464
+
465
+ def slit_image_width(slit_width: u.Quantity, det) -> float:
466
+ """
467
+ Width of the slit's image along the dispersion, in detector pixels.
468
+
469
+ RSC-2022021C gives widths along the dispersion in the same arcsec as
470
+ along the slit, converting both with the spatial scale (0.0118 arcsec
471
+ per micron for SW), and adds the slit to the optics in those units. A
472
+ slit `slit_width` wide therefore covers `slit_width` over the plate
473
+ scale in spectral pixels, as it would along the slit.
474
+ """
475
+ return (slit_width / (det.plate_scale_angle * u.pixel)).to_value(u.dimensionless_unscaled)
476
+
477
+
478
+ def _budget(tel):
479
+ """`resolution_budget` and `pointing_jitter` in arcsec, for the resolution_budget functions."""
480
+ jitter = getattr(tel, "pointing_jitter", None)
481
+ return (getattr(tel, "resolution_budget", "none"),
482
+ None if jitter is None else jitter.to_value(u.arcsec))
483
+
484
+
485
+ def _budget_pixels(tel, det, part) -> float:
486
+ """FWHM in pixels of the resolution budget's blur, `part` one of its functions."""
487
+ return part(*_budget(tel)) / (det.plate_scale_angle * u.pixel).to_value(u.arcsec)
488
+
489
+
490
+ def spatial_psf_fwhm(tel, det) -> float:
491
+ """
492
+ FWHM of the PSF along the slit, in detector pixels.
493
+
494
+ `tel.psf_params[0]`, the optical design, and the resolution budget's
495
+ other terms (:func:`~eclipse.resolution_budget.along_slit_fwhm`)
496
+ added in quadrature.
497
+ """
498
+ optics = tel.psf_params[0].to_value(u.pixel)
499
+ return float(np.hypot(optics, _budget_pixels(tel, det, resolution_budget.along_slit_fwhm)))
500
+
501
+
502
+ def across_slit_fwhm(tel) -> u.Quantity | None:
503
+ """
504
+ FWHM of the telescope's blur across the slit, or None for none.
505
+
506
+ `tel.psf_across_slit` and the resolution budget's terms before the slit
507
+ (:func:`~eclipse.resolution_budget.across_slit_fwhm`) added in
508
+ quadrature.
509
+ """
510
+ optics = getattr(tel, "psf_across_slit", None)
511
+ optics = 0.0 if optics is None else optics.to_value(u.arcsec)
512
+ fwhm = np.hypot(optics, resolution_budget.across_slit_fwhm(*_budget(tel)))
513
+ return None if fwhm == 0 else float(fwhm) * u.arcsec
514
+
515
+
516
+ def spectral_blur_fwhm(tel, det) -> float:
517
+ """
518
+ FWHM of the spectral PSF without the slit, in detector pixels.
519
+
520
+ The optics (:func:`spectral_optics_fwhm`) and the resolution budget's
521
+ other terms (:func:`~eclipse.resolution_budget.spectral_fwhm`)
522
+ added in quadrature. This is the Gaussian convolved with the slit's
523
+ image for `spectral_psf="convolution"`.
524
+ """
525
+ return float(np.hypot(spectral_optics_fwhm(tel, det),
526
+ _budget_pixels(tel, det, resolution_budget.spectral_fwhm)))
527
+
528
+
529
+ def spectral_optics_fwhm(tel, det) -> float:
530
+ """
531
+ FWHM of the optics alone along the dispersion, in detector pixels.
532
+
533
+ `tel.psf_params[1]` is the spectral FWHM with a slit `tel.psf_slit_width`
534
+ wide: the optics FWHM and the slit width added in quadrature. The optics
535
+ FWHM is what remains when the slit is removed in quadrature. For the
536
+ default SWC values it is 2.207 pixels, or 0.351 arcsec, against 0.352
537
+ arcsec in RSC-2022021C; the two agree to the rounding of 43.00 mA to
538
+ 2.54 pixels and of the plate scale to 0.159 arcsec per pixel.
539
+ """
540
+ reference = getattr(tel, "psf_slit_width", None)
541
+ if reference is None:
542
+ raise ValueError(
543
+ f"{type(tel).__name__} has no psf_slit_width, so its spectral PSF "
544
+ f"is not separated into the optics and the slit and cannot be "
545
+ f"convolved with a slit. Give psf_slit_width, the slit width "
546
+ f"psf_params was measured with, or use spectral_psf: quadrature.")
547
+ measured = tel.psf_params[1].to_value(u.pixel)
548
+ slit = slit_image_width(reference, det)
549
+ optics_squared = measured**2 - slit**2
550
+ if optics_squared <= 0:
551
+ raise ValueError(
552
+ f"The spectral FWHM of {measured} pixels is no wider than the "
553
+ f"{slit:.3f}-pixel image of the {reference} slit it is quoted for, "
554
+ f"so it leaves nothing for the optics.")
555
+ return float(np.sqrt(optics_squared))
556
+
557
+
558
+ def spectral_psf_fwhm(tel, det, slit_width: u.Quantity) -> float:
559
+ """
560
+ FWHM of the spectral PSF for a given slit, in detector pixels.
561
+
562
+ The slit's image is part of the spectral PSF, so the PSF widens with the
563
+ slit. The optics FWHM and the slit image width add in quadrature, and
564
+ `tel.psf_params[1]` is the FWHM for the slit `tel.psf_slit_width`. The
565
+ resolution budget's other terms
566
+ (:func:`~eclipse.resolution_budget.spectral_fwhm`) are added in
567
+ quadrature too. For EUVST-SW's optics alone this gives 2.54, 3.35, 5.49
568
+ and 10.30 pixels for the 0.2, 0.4, 0.8 and 1.6 arcsec slits. A
569
+ telescope without `psf_slit_width` has the same optics FWHM for every
570
+ slit.
571
+
572
+ Parameters
573
+ ----------
574
+ tel : Telescope_EUVST or Telescope_EIS
575
+ Telescope.
576
+ det : Detector_SWC or Detector_EIS
577
+ Detector, whose plate scale sets the width of the slit's image.
578
+ slit_width : u.Quantity
579
+ Slit width.
580
+
581
+ Returns
582
+ -------
583
+ float
584
+ FWHM in pixels.
585
+ """
586
+ # In quadrature, FWHM(w)**2 = FWHM(w0)**2 + s(w)**2 - s(w0)**2, with w0
587
+ # the reference slit and s the slit image width (slit_image_width), as
588
+ # RSC-2022021C combines them for its quoted spectral resolution.
589
+ measured = tel.psf_params[1].to_value(u.pixel)
590
+ budget = _budget_pixels(tel, det, resolution_budget.spectral_fwhm)
591
+ reference = getattr(tel, "psf_slit_width", None)
592
+ if reference is None:
593
+ return float(np.hypot(measured, budget))
594
+ # Raises if the FWHM leaves nothing for the optics.
595
+ spectral_optics_fwhm(tel, det)
596
+ # Take the difference of squares first so it is exactly zero for the
597
+ # reference slit, which then gets psf_params[1] to the last bit.
598
+ difference = slit_image_width(slit_width, det)**2 - slit_image_width(reference, det)**2
599
+ return float(np.sqrt(measured**2 + difference + budget**2))
600
+
601
+
602
+ def _reach(fwhm: float) -> int:
603
+ """Pixels a kernel six sigma wide reaches either side of its centre, at least three."""
604
+ n = max(7, int(np.ceil(6 * _fwhm_to_sigma(fwhm))))
605
+ if n % 2 == 0:
606
+ n += 1
607
+ return n // 2
608
+
609
+
610
+ def spectral_psf_reach(tel, det, slit_width: u.Quantity) -> int:
611
+ """
612
+ Pixels the spectral PSF kernel reaches either side of its centre.
613
+
614
+ For either `spectral_psf`, the kernel spans six sigma of the Gaussian of
615
+ :func:`spectral_psf_fwhm`, rounded up to an odd number of pixels and at
616
+ least seven. The slit convolved with the optics is no wider than that
617
+ Gaussian, and the pixels left out hold at most a few parts in ten
618
+ thousand of it, for the narrowest slit.
619
+ """
620
+ return _reach(spectral_psf_fwhm(tel, det, slit_width))
621
+
622
+
623
+ def spectral_psf_margin(tel, det, slit_width: u.Quantity) -> int:
624
+ """
625
+ Pixels by which the spectral PSF reaches beyond that of the optics alone with the reference slit.
626
+
627
+ The wavelength window of a synthesis has some margin around its lines,
628
+ enough for the optics' PSF, `tel.psf_params[1]`. A wider slit, the
629
+ resolution budget and the pointing jitter spread the lines further, and
630
+ light spread past the ends of the window is lost. The window is
631
+ therefore widened by this many pixels at each end. Zero when the PSF is
632
+ the optics' alone with the reference slit.
633
+ """
634
+ optics = _reach(tel.psf_params[1].to_value(u.pixel))
635
+ return max(0, spectral_psf_reach(tel, det, slit_width) - optics)
636
+
637
+
638
+ def _spectral_response(tel, det, slit_width: u.Quantity, spectral_psf: str) -> tuple:
639
+ """
640
+ Spectral response as a Gaussian convolved with a rectangle, in pixels.
641
+
642
+ Returns the Gaussian's standard deviation and the rectangle's width. With
643
+ `spectral_psf="quadrature"` the slit is in the Gaussian, of FWHM
644
+ :func:`spectral_psf_fwhm`, and the width is 0. With
645
+ `spectral_psf="convolution"` the Gaussian is the blur without the slit,
646
+ of FWHM :func:`spectral_blur_fwhm`, and the rectangle is the slit's
647
+ image.
648
+
649
+ Raises ValueError for any other `spectral_psf`, and wherever
650
+ :func:`spectral_psf_fwhm` or :func:`spectral_blur_fwhm` does, such as
651
+ for `"convolution"` with a telescope that has no `psf_slit_width`.
652
+ """
653
+ if spectral_psf == "quadrature":
654
+ return _fwhm_to_sigma(spectral_psf_fwhm(tel, det, slit_width)), 0.0
655
+ if spectral_psf == "convolution":
656
+ return _fwhm_to_sigma(spectral_blur_fwhm(tel, det)), slit_image_width(slit_width, det)
657
+ raise ValueError(
658
+ f"spectral_psf must be 'quadrature' or 'convolution', got {spectral_psf!r}."
659
+ )
660
+
661
+
662
+ def _spectral_kernel(tel, det, slit_width: u.Quantity, spectral_psf: str) -> np.ndarray:
663
+ """
664
+ Spectral response at whole pixels out to :func:`spectral_psf_reach`.
665
+
666
+ With `spectral_psf="quadrature"` a Gaussian, not normalised. With
667
+ `spectral_psf="convolution"` :func:`spectral_line_spread`, which sums to
668
+ one. Raises ValueError as :func:`_spectral_response` does.
669
+ """
670
+ if spectral_psf == "convolution":
671
+ return spectral_line_spread(tel, det, slit_width)
672
+ sigma, _ = _spectral_response(tel, det, slit_width, spectral_psf)
673
+ reach = spectral_psf_reach(tel, det, slit_width)
674
+ offsets = np.arange(-reach, reach + 1)
675
+ return np.exp(-0.5 * (offsets / sigma) ** 2)
676
+
677
+
678
+ def spectral_line_spread(tel, det, slit_width: u.Quantity) -> np.ndarray:
679
+ """
680
+ Spectral PSF as a Gaussian convolved with the slit's image, per pixel.
681
+
682
+ The Gaussian's FWHM is :func:`spectral_blur_fwhm`. This is the PSF
683
+ used with `spectral_psf="convolution"`. A wide slit
684
+ gives it a flat top. For the 0.2 arcsec slit it is narrower than the
685
+ Gaussian of `spectral_psf_fwhm`, because a rectangle adds less to a
686
+ width than a Gaussian of the same FWHM.
687
+
688
+ Parameters
689
+ ----------
690
+ tel : Telescope_EUVST or Telescope_EIS
691
+ Telescope.
692
+ det : Detector_SWC or Detector_EIS
693
+ Detector.
694
+ slit_width : u.Quantity
695
+ Slit width.
696
+
697
+ Returns
698
+ -------
699
+ np.ndarray
700
+ PSF at whole pixels either side of its centre, summing to one.
701
+ """
702
+ # RSC-2022021C defines the line spread function as the optics PSF after
703
+ # the slit, convolved with a rectangle the width of the slit. Like the
704
+ # Gaussian kernel, it is taken at pixel centres over the same pixels
705
+ # (spectral_psf_reach).
706
+ sigma, width = _spectral_response(tel, det, slit_width, "convolution")
707
+ half = 0.5 * width
708
+ reach = spectral_psf_reach(tel, det, slit_width)
709
+ x = np.arange(-reach, reach + 1)
710
+ scale = np.sqrt(2.0) * sigma
711
+ kernel = 0.5 * (erf((x + half) / scale) - erf((x - half) / scale))
712
+ return kernel / kernel.sum()
713
+
714
+
715
+ def apply_focusing_optics_psf(
716
+ signal: NDCube,
717
+ tel,
718
+ det,
719
+ sim,
720
+ *,
721
+ convolve_spatial: bool = True,
722
+ boundary: str = "replicate",
723
+ ) -> NDCube:
724
+ """
725
+ Blur a cube on the detector pixels with the PSF, along the slit and in wavelength.
726
+
727
+ `eclipse` blurs the spectra before laying them onto the pixels, so a
728
+ line narrower than a pixel keeps its place within it. This function
729
+ instead blurs a cube already on the pixels, which pulls such a line
730
+ toward the pixel's centre. `simulate_once` uses it for a cube without
731
+ `meta["psf_applied"]`.
732
+
733
+ Along the slit the PSF is a Gaussian of FWHM `spatial_psf_fwhm`. In
734
+ wavelength it depends on `sim.spectral_psf`: a Gaussian of
735
+ `spectral_psf_fwhm` for `"quadrature"`, or `spectral_line_spread` for
736
+ `"convolution"`.
737
+
738
+ Parameters
739
+ ----------
740
+ signal : NDCube
741
+ Cube shaped (along the slit, slit positions, wavelength).
742
+ tel : Telescope_EUVST or Telescope_EIS
743
+ Telescope, with its PSF.
744
+ det : Detector_SWC or Detector_EIS
745
+ Detector, whose plate scale sets the width of the slit's image.
746
+ sim : Simulation
747
+ Gives the slit width and `spectral_psf`.
748
+ convolve_spatial : bool, optional
749
+ If False, blur in wavelength only, for a scene uniform along the
750
+ slit, where blurring along it would change nothing. Default True.
751
+ boundary : {"replicate", "zero"}, optional
752
+ What lies beyond the ends of the slit. `"replicate"` extends the end
753
+ rows outward. `"zero"` takes it as dark, so rows within a PSF width
754
+ of an end come out darker. Default `"replicate"`.
755
+
756
+ Returns
757
+ -------
758
+ NDCube
759
+ Blurred cube, with the same coordinates, unit and metadata.
760
+ """
761
+ # A uniform scene is blurred in wavelength only: along the slit,
762
+ # "replicate" changes nothing and "zero" removes light from the ends that
763
+ # the scene does have. Zero fill is wrong for a raster too, as the Sun
764
+ # continues past the field of view. With the default SWC PSF, nine rows
765
+ # wide, it costs the end row about a third of the kernel's weight, the
766
+ # next row 13 per cent and the one after 3 per cent.
767
+ if boundary not in ("replicate", "zero"):
768
+ raise ValueError(
769
+ f"boundary must be 'replicate' or 'zero', got {boundary!r}."
770
+ )
771
+ n_slit, n_scan, n_lambda = signal.data.shape
772
+
773
+ psf_type = tel.psf_type.lower()
774
+ psf_params = tel.psf_params
775
+
776
+ if psf_type != "gaussian":
777
+ raise ValueError(
778
+ f"Unsupported PSF type: {psf_type}. Supported: 'gaussian'."
779
+ )
780
+
781
+ if len(psf_params) < 2:
782
+ raise ValueError(
783
+ "psf_params must contain two elements: "
784
+ "[spatial_fwhm, spectral_fwhm] in pixel units."
785
+ )
786
+
787
+ # Along lambda (axis 1 of each frame) the profile depends on the slit.
788
+ spectral_1d = _spectral_kernel(tel, det, sim.slit_width, sim.spectral_psf)
789
+ kx = spectral_1d.size
790
+ quadrature = sim.spectral_psf == "quadrature"
791
+
792
+ # FWHM in pixels along the slit (axis 0 of each frame)
793
+ spatial_fwhm = spatial_psf_fwhm(tel, det)
794
+ sigma_spatial = _fwhm_to_sigma(spatial_fwhm)
795
+ ky = 2 * _reach(spatial_fwhm) + 1
796
+
797
+ if not convolve_spatial:
798
+ # Spectral axis only. Zero padding is correct here: the wavelength
799
+ # grid extends several sigma past the line, so no flux is lost at its
800
+ # edges.
801
+ psf_1d = spectral_1d / spectral_1d.sum()
802
+
803
+ def blur(cube):
804
+ return convolve1d(cube, psf_1d, axis=2, mode="constant", cval=0.0)
805
+
806
+ return _blurred_cube(signal, blur)
807
+
808
+ if quadrature:
809
+ sigma_spectral, _ = _spectral_response(tel, det, sim.slit_width, "quadrature")
810
+ # Coordinate grids centred at zero
811
+ cy, cx = ky // 2, kx // 2
812
+ y, x = np.mgrid[:ky, :kx]
813
+ y = (y - cy).astype(float)
814
+ x = (x - cx).astype(float)
815
+
816
+ # Build PSF
817
+ psf = np.exp(-0.5 * ((y / sigma_spatial) ** 2
818
+ + (x / sigma_spectral) ** 2))
819
+ else:
820
+ # The optics blur along the slit does not depend on the slit, so the
821
+ # PSF is the spatial Gaussian times the spectral profile.
822
+ y_1d = np.arange(ky) - ky // 2
823
+ psf = np.outer(np.exp(-0.5 * (y_1d / sigma_spatial) ** 2), spectral_1d)
824
+
825
+ # Normalise
826
+ psf /= psf.sum()
827
+
828
+ # Convolve each scan position's frame (n_slit, n_lambda). Padding the slit
829
+ # axis by half a kernel is just enough for every real row to see
830
+ # replicated rows rather than the zeros convolve2d assumes outside the
831
+ # array. The spectral axis is zero-filled, which is correct there.
832
+ pad = (ky // 2) if boundary == "replicate" else 0
833
+
834
+ def blur(cube):
835
+ blurred = np.empty(cube.shape)
836
+ for i in range(n_scan):
837
+ frame = cube[:, i, :]
838
+ if pad:
839
+ frame = np.pad(frame, ((pad, pad), (0, 0)), mode="edge")
840
+ convolved = convolve2d(frame, psf, mode="same")
841
+ blurred[:, i, :] = convolved[pad:pad + n_slit, :] if pad else convolved
842
+ return blurred
843
+
844
+ return _blurred_cube(signal, blur)
845
+
846
+
847
+ def _blurred_cube(signal: NDCube, blur) -> NDCube:
848
+ """
849
+ `signal`, photons on the pixels, blurred by `blur`, with the per-pixel
850
+ photon values in its `meta` carried along (:func:`_carry_photon_meta`).
851
+ """
852
+ blurred, meta = _carry_photon_meta(blur, signal)
853
+ return NDCube(data=blurred, wcs=signal.wcs.deepcopy(), unit=signal.unit, meta=meta)
854
+
855
+
856
+ def _carry_photon_meta(carry, cube: NDCube) -> tuple:
857
+ """
858
+ Photons of `cube` moved by `carry`, and its `meta` with the per-pixel
859
+ photon values moved with them.
860
+
861
+ `carry` is a linear map that keeps the wavelength axis. The values are
862
+ the wavelengths of the mean and root mean square photon energies,
863
+ `photon_wavelength` and `photon_rms_wavelength`. For the pinholes they
864
+ also include the fraction of the light the filter passed,
865
+ `photon_filter_transmission`, and the wavelengths of the mean and root
866
+ mean square energies of the light it blocked,
867
+ `blocked_photon_wavelength` and `blocked_photon_rms_wavelength`.
868
+
869
+ The photons and the blocked light are each moved as photons, so these
870
+ values are exact wherever they land. A pixel no photons reach keeps its
871
+ own wavelength and blocks nothing.
872
+
873
+ Returns `(moved, meta)`.
874
+ """
875
+ photons = np.asarray(cube.data, dtype=float)
876
+ meta = cube.meta
877
+ if "photon_wavelength" not in (meta or {}):
878
+ return carry(photons), meta
879
+ mean = u.Quantity(meta["photon_wavelength"])
880
+ unit = mean.unit
881
+ rms = u.Quantity(meta.get("photon_rms_wavelength", mean)).to_value(unit)
882
+ own = cube.axis_world_coords_values(2)[0].to_value(unit)
883
+ moved, moved_mean, moved_rms = _carry_photon_wavelengths(carry, photons, mean.value, rms,
884
+ own)
885
+ meta = {**meta, "photon_wavelength": moved_mean * unit,
886
+ "photon_rms_wavelength": moved_rms * unit}
887
+ if "photon_filter_transmission" in meta:
888
+ passed = np.asarray(meta["photon_filter_transmission"], dtype=float)
889
+ blocked = photons * np.divide(1.0 - passed, passed, out=np.zeros(photons.shape),
890
+ where=passed > 0)
891
+ moved_blocked, blocked_mean, blocked_rms = _carry_photon_wavelengths(
892
+ carry, blocked, u.Quantity(meta["blocked_photon_wavelength"]).to_value(unit),
893
+ u.Quantity(meta["blocked_photon_rms_wavelength"]).to_value(unit), own)
894
+ before = moved + moved_blocked
895
+ meta.update({
896
+ "photon_filter_transmission": np.divide(moved, before, out=np.ones(moved.shape),
897
+ where=before > 0),
898
+ "blocked_photon_wavelength": blocked_mean * unit,
899
+ "blocked_photon_rms_wavelength": blocked_rms * unit})
900
+ return moved, meta
901
+
902
+
903
+ def to_electrons(
904
+ photon_counts: NDCube,
905
+ t_exp: u.Quantity,
906
+ det,
907
+ *,
908
+ dark_current_inverse_transform: bool = False,
909
+ noise: bool = True,
910
+ photon_shot_inverse_transform: bool = False,
911
+ dark_time: u.Quantity | None = None,
912
+ ) -> NDCube:
913
+ """
914
+ Convert the photons arriving in each pixel to the electrons read out.
915
+
916
+ Each photon is detected with probability `det.qe_euv` and frees a number
917
+ of electrons that depends on its wavelength, with a spread set by the
918
+ Fano factor. Dark current and read noise are then added. Pixels can go
919
+ below zero, as read noise takes a dark pixel below the bias level as
920
+ often as above it.
921
+
922
+ Parameters
923
+ ----------
924
+ photon_counts : NDCube
925
+ Photons in each pixel, from `sample_photon_arrivals`: whole numbers,
926
+ or with `noise=False` their expected, fractional values. Its
927
+ `meta["photon_wavelength"]`, the wavelength of the mean energy of
928
+ each pixel's photons, sets the electrons per photon. Its
929
+ `meta["photon_rms_wavelength"]`, that of their root mean square
930
+ energy, sets how far the electrons of photons of different energies
931
+ spread. Without them, `meta["rest_wav"]` sets both.
932
+ t_exp : u.Quantity
933
+ Exposure time, for the dark current unless `dark_time` is given.
934
+ det : Detector_SWC or Detector_EIS
935
+ Detector.
936
+ dark_current_inverse_transform : bool, optional
937
+ Draw the dark current by inverse-transform sampling, for common
938
+ random numbers between runs that differ in dark current. Default
939
+ False.
940
+ noise : bool, optional
941
+ If False, every random draw is replaced by its mean. Default True.
942
+ photon_shot_inverse_transform : bool, optional
943
+ Draw the detected photons by inverse-transform sampling, and the Fano
944
+ spread for every pixel, for common random numbers between runs that
945
+ differ in photon flux. Default False.
946
+ dark_time : u.Quantity, optional
947
+ Time each pixel collects dark current, one value or an array that
948
+ broadcasts against the pixels, such as one per wavelength pixel from
949
+ the read-out. Default `t_exp`.
950
+
951
+ Returns
952
+ -------
953
+ NDCube
954
+ Electrons in each pixel.
955
+ """
956
+ # Each photon frees electrons by its own energy. The cube gives the
957
+ # wavelengths of the mean and root mean square photon energies in each
958
+ # pixel, as kept by `simulate_once`; otherwise the line's rest wavelength
959
+ # is used for all.
960
+ meta = photon_counts.meta or {}
961
+ if "photon_wavelength" in meta:
962
+ rest_wavelength = meta["photon_wavelength"]
963
+ rms_wavelength = meta.get("photon_rms_wavelength", rest_wavelength)
964
+ else:
965
+ rest_wavelength = rms_wavelength = meta["rest_wav"]
966
+
967
+ incident = photon_counts.to(u.photon / u.pix).data
968
+ photons_detected = _detected_photons(incident, det, noise=noise,
969
+ inverse_transform=photon_shot_inverse_transform)
970
+
971
+ # Apply Fano noise per pixel, vectorized
972
+ electron_counts = _vectorized_fano_noise(photons_detected,
973
+ rest_wavelength, det, noise=noise,
974
+ every_pixel=photon_shot_inverse_transform,
975
+ rms_wavelength=rms_wavelength)
976
+
977
+ e = electron_counts * (u.electron / u.pixel)
978
+
979
+ # Add dark current with Poisson shot noise (per pixel)
980
+ dark_time = t_exp if dark_time is None else dark_time
981
+ dark_current_mean = (det.dark_current * dark_time).to(u.electron / u.pixel).value
982
+ dark_current_counts = _dark_electrons(dark_current_mean, photon_counts.data.shape,
983
+ noise=noise,
984
+ inverse_transform=dark_current_inverse_transform)
985
+ e += dark_current_counts * (u.electron / u.pixel)
986
+
987
+ # Add read noise. It has zero mean, so without noise nothing is added.
988
+ if noise:
989
+ e += _read_noise(photon_counts.data.shape, det) * (u.electron / u.pixel)
990
+
991
+ # Not clipped at zero: a CCD reads out above a bias level, so read noise
992
+ # takes a dark pixel below it as often as above, and ECLIPSE's
993
+ # bias-subtracted signal goes negative. Clipping would raise the mean of
994
+ # faint pixels, so noise on would no longer average to noise off.
995
+ e = e.to(u.electron / u.pixel)
996
+ e_val = e.value
997
+
998
+ return NDCube(
999
+ data=e_val,
1000
+ wcs=photon_counts.wcs.deepcopy(),
1001
+ unit=e.unit,
1002
+ meta=photon_counts.meta,
1003
+ )
1004
+
1005
+
1006
+ def to_dn(electrons: NDCube, det) -> NDCube:
1007
+ """
1008
+ Convert electrons to DN, rounded to the nearest DN and clipped at the digitiser maximum.
1009
+
1010
+ Nothing is clipped at the CCD full well, `Detector_SWC.full_well`,
1011
+ though it is lower than the digitiser maximum.
1012
+
1013
+ Parameters
1014
+ ----------
1015
+ electrons : NDCube
1016
+ Electrons in each pixel, from `to_electrons`.
1017
+ det : Detector_SWC or Detector_EIS
1018
+ Detector, with its gain and `max_dn`.
1019
+
1020
+ Returns
1021
+ -------
1022
+ NDCube
1023
+ DN in each pixel, in DN per pixel.
1024
+ """
1025
+ return NDCube(
1026
+ data=_digitise(electrons.unit.to(u.electron / u.pixel) * electrons.data, det),
1027
+ wcs=electrons.wcs.deepcopy(),
1028
+ unit=u.DN / u.pixel,
1029
+ meta=electrons.meta,
1030
+ )
1031
+
1032
+
1033
+ def add_poisson(cube: NDCube) -> NDCube:
1034
+ """
1035
+ Replace each value of a cube with a Poisson draw with that mean.
1036
+
1037
+ Parameters
1038
+ ----------
1039
+ cube : NDCube
1040
+ Mean in each pixel.
1041
+
1042
+ Returns
1043
+ -------
1044
+ NDCube
1045
+ Draws, with the same coordinates, unit and metadata.
1046
+ """
1047
+ noisy = np.random.poisson(cube.data) * cube.unit
1048
+ return NDCube(
1049
+ data=noisy.value,
1050
+ wcs=cube.wcs.deepcopy(),
1051
+ unit=noisy.unit,
1052
+ meta=cube.meta,
1053
+ )
1054
+
1055
+
1056
+ def sample_photon_arrivals(
1057
+ photon_counts: NDCube,
1058
+ *,
1059
+ photon_shot_inverse_transform: bool = False,
1060
+ noise: bool = True,
1061
+ ) -> NDCube:
1062
+ """
1063
+ Poisson draw of the photons arriving in each pixel, around the expected number.
1064
+
1065
+ Parameters
1066
+ ----------
1067
+ photon_counts : NDCube
1068
+ Expected photons in each pixel.
1069
+ photon_shot_inverse_transform : bool, optional
1070
+ Draw by inverse-transform sampling, for common random numbers between
1071
+ runs that differ in photon flux. Default False.
1072
+ noise : bool, optional
1073
+ If False, return the expected numbers instead, not rounded to whole
1074
+ photons. Default True.
1075
+
1076
+ Returns
1077
+ -------
1078
+ NDCube
1079
+ Photons in each pixel, in photon per pixel.
1080
+ """
1081
+ q = photon_counts.data * photon_counts.unit
1082
+
1083
+ canonical_units = u.photon / u.pix
1084
+
1085
+ mean_counts = q.to(canonical_units).value
1086
+ mean_counts = np.maximum(mean_counts, 0)
1087
+
1088
+ # Without noise, keep floats: rounding to whole photons would add back
1089
+ # quantisation where the aim is to remove randomness.
1090
+ if not noise:
1091
+ return NDCube(
1092
+ data=mean_counts,
1093
+ wcs=photon_counts.wcs.deepcopy(),
1094
+ unit=canonical_units,
1095
+ meta=photon_counts.meta,
1096
+ )
1097
+
1098
+ if photon_shot_inverse_transform:
1099
+ sampled = _poisson_inverse_transform(mean_counts)
1100
+ else:
1101
+ sampled = np.random.poisson(mean_counts)
1102
+
1103
+ return NDCube(
1104
+ data=sampled.astype(np.int64),
1105
+ wcs=photon_counts.wcs.deepcopy(),
1106
+ unit=canonical_units,
1107
+ meta=photon_counts.meta,
1108
+ )
1109
+
1110
+
1111
+ def apply_exposure(I: NDCube, t_exp: u.Quantity) -> NDCube:
1112
+ """
1113
+ Multiply a cube of spectral radiance by the exposure time.
1114
+
1115
+ Parameters
1116
+ ----------
1117
+ I : NDCube
1118
+ Spectral radiance, per second.
1119
+ t_exp : u.Quantity
1120
+ Exposure time.
1121
+
1122
+ Returns
1123
+ -------
1124
+ NDCube
1125
+ Spectral radiance over the exposure.
1126
+ """
1127
+ # Convert intensity rate to total intensity over exposure
1128
+ total_intensity = (I.data * I.unit * t_exp)
1129
+
1130
+ return NDCube(
1131
+ data=total_intensity.value,
1132
+ wcs=I.wcs.deepcopy(),
1133
+ unit=total_intensity.unit,
1134
+ meta=I.meta,
1135
+ )
1136
+
1137
+
1138
+ def add_visible_stray_light(electrons: NDCube, t_exp: u.Quantity, det, sim, tel=None,
1139
+ *, noise: bool = True) -> NDCube:
1140
+ """
1141
+ Add the electrons freed by visible stray light, the same in every pixel.
1142
+
1143
+ `sim.vis_sl` is in photons per second per cm2. If the telescope has a
1144
+ filter, as EUVST does, this is the light at the filter, which passes
1145
+ `AluminiumFilter.visible_light_throughput` of it; otherwise it is the
1146
+ light at the CCD. Each photon reaching the detector is detected with
1147
+ probability `det.qe_vis` and frees one electron.
1148
+
1149
+ Parameters
1150
+ ----------
1151
+ electrons : NDCube
1152
+ Electrons in each pixel, from `to_electrons`.
1153
+ t_exp : u.Quantity
1154
+ Exposure time.
1155
+ det : Detector_SWC or Detector_EIS
1156
+ Detector.
1157
+ sim : Simulation
1158
+ Simulation, with `vis_sl`.
1159
+ tel : Telescope_EUVST or Telescope_EIS, optional
1160
+ Telescope. Its filter is applied if it has one. EIS's is not, so for
1161
+ EIS `vis_sl` is the light reaching the CCD.
1162
+ noise : bool, optional
1163
+ If False, add the expected electrons rather than a draw. Default
1164
+ True.
1165
+
1166
+ Returns
1167
+ -------
1168
+ NDCube
1169
+ Electrons in each pixel with the stray light added, in electron per
1170
+ pixel.
1171
+ """
1172
+ # Convert vis_sl from photon/s/cm2 to photon/s/pixel using detector pixel area
1173
+ pixel_area = ((det.pix_size*1*u.pix)**2)/u.pix # cm/pix -> cm2/pixel
1174
+ vis_sl_per_pixel = (sim.vis_sl * pixel_area).to(u.photon / (u.s * u.pixel))
1175
+
1176
+ # Apply filter throughput if telescope with filter is available
1177
+ if tel is not None and hasattr(tel, 'filter'):
1178
+ filter_throughput = tel.filter.visible_light_throughput()
1179
+ vis_sl_per_pixel *= filter_throughput
1180
+
1181
+ # Draw Poisson realisation of stray-light photons
1182
+ vis_mean = (vis_sl_per_pixel * t_exp).to_value(u.photon / u.pixel)
1183
+ if noise:
1184
+ n_vis_ph = np.random.poisson(
1185
+ vis_mean, size=electrons.data.shape) * (u.photon / u.pixel)
1186
+ else:
1187
+ n_vis_ph = np.full(electrons.data.shape, vis_mean) * (u.photon / u.pixel)
1188
+
1189
+ # Apply the quantum efficiency. A visible photon absorbed in silicon frees
1190
+ # exactly one electron: its couple of eV is below the 3.7 eV per
1191
+ # electron-hole pair, so the EUV model of many electrons with a Fano
1192
+ # spread does not apply (it would give 0.55).
1193
+ vis_incident = n_vis_ph.to_value(u.photon / u.pixel)
1194
+ if noise:
1195
+ vis_photons_detected = np.random.binomial(
1196
+ vis_incident.astype(int), det.qe_vis)
1197
+ else:
1198
+ vis_photons_detected = vis_incident * det.qe_vis
1199
+ stray_electrons = vis_photons_detected.astype(float) * (u.electron / u.pixel)
1200
+
1201
+ # Add to original signal
1202
+ out_q = electrons.data * electrons.unit + stray_electrons
1203
+ out_q = out_q.to(electrons.unit)
1204
+
1205
+ return NDCube(
1206
+ data=out_q.value,
1207
+ wcs=electrons.wcs.deepcopy(),
1208
+ unit=out_q.unit,
1209
+ meta=electrons.meta,
1210
+ )
1211
+
1212
+
1213
+ def add_pinhole_visible_light(electrons: NDCube, t_exp: u.Quantity, det, sim, tel,
1214
+ *, noise: bool = True) -> NDCube:
1215
+ """
1216
+ Add the electrons freed by visible light through pinholes in the filter.
1217
+
1218
+ The light through each pinhole spreads over the detector in the
1219
+ pinhole's diffraction pattern. Does nothing unless `sim.enable_pinholes`
1220
+ is set and there are pinholes.
1221
+
1222
+ Parameters
1223
+ ----------
1224
+ electrons : NDCube
1225
+ Electrons in each pixel.
1226
+ t_exp : u.Quantity
1227
+ Exposure time.
1228
+ det : Detector_SWC
1229
+ EUVST-SW detector, whose `filter_distance` sets the diffraction.
1230
+ sim : Simulation
1231
+ Simulation, with `vis_sl` and the pinholes.
1232
+ tel : Telescope_EUVST
1233
+ Telescope.
1234
+ noise : bool, optional
1235
+ If False, add the expected electrons rather than a draw. Default
1236
+ True.
1237
+
1238
+ Returns
1239
+ -------
1240
+ NDCube
1241
+ Electrons in each pixel with the pinholes' light added, in electron
1242
+ per pixel.
1243
+ """
1244
+ if not (sim.enable_pinholes and len(sim.pinhole_sizes) > 0):
1245
+ return electrons # No pinholes enabled
1246
+
1247
+ # Import here to avoid circular imports
1248
+ from .pinhole_diffraction import (
1249
+ airy_peak_fraction_per_pixel, calculate_pinhole_diffraction_pattern)
1250
+
1251
+ # Get detector and data properties
1252
+ data_shape = electrons.data.shape # Should be (n_slit, n_scan, n_spectral)
1253
+
1254
+ visible_wavelength = sim.pinhole_visible_wavelength
1255
+
1256
+ # Initialize additional electron contributions
1257
+ additional_electrons = np.zeros_like(electrons.data)
1258
+
1259
+ # Spectral positions are optional; without them every pinhole projects to
1260
+ # the centre of the spectral window.
1261
+ spectral_positions = (list(sim.pinhole_positions_spectral)
1262
+ or [None] * len(sim.pinhole_sizes))
1263
+
1264
+ for pinhole_diameter, pinhole_position, pinhole_spectral in zip(
1265
+ sim.pinhole_sizes, sim.pinhole_positions, spectral_positions):
1266
+ # Calculate pinhole area
1267
+ pinhole_area = np.pi * (pinhole_diameter / 2)**2
1268
+
1269
+ # === Visible Light Contribution Through Pinhole ===
1270
+ # Calculate total photons incident on the pinhole area (unfiltered)
1271
+ # sim.vis_sl is photon/s/cm^2, pinhole_area is in cm^2
1272
+ vis_photons_per_sec_through_pinhole = sim.vis_sl * pinhole_area
1273
+ vis_photons_total_through_pinhole = (vis_photons_per_sec_through_pinhole * t_exp).to(u.photon)
1274
+
1275
+ # Visible diffraction pattern: how the pinhole photons spread
1276
+ n_slit, n_scan, n_spectral = data_shape
1277
+ vis_pattern = calculate_pinhole_diffraction_pattern(
1278
+ detector_shape=(n_slit, n_spectral),
1279
+ pixel_size=det.pix_size*u.pix,
1280
+ pinhole_diameter=pinhole_diameter,
1281
+ pinhole_position_slit=pinhole_position,
1282
+ slit_width=sim.slit_width,
1283
+ plate_scale=det.plate_scale_angle,
1284
+ distance=det.filter_distance,
1285
+ wavelength=visible_wavelength,
1286
+ pinhole_position_spectral=pinhole_spectral,
1287
+ )
1288
+
1289
+ # Scale the pattern by its absolute normalisation, not its sum over the
1290
+ # detector. Dividing by the sum would put every transmitted photon on
1291
+ # the detector, but a 1 micron hole at 250 mm has its first Airy
1292
+ # minimum 183 mm out, so nearly all its light misses a detector tens
1293
+ # of mm across and must be lost, not redistributed. vis_pattern peaks
1294
+ # at 1.0, so multiplying by the peak per-pixel fraction gives the
1295
+ # right fraction everywhere, and the array sums to less than 1 when
1296
+ # light falls off the detector.
1297
+ peak_fraction = airy_peak_fraction_per_pixel(
1298
+ pinhole_diameter=pinhole_diameter,
1299
+ distance=det.filter_distance,
1300
+ wavelength=visible_wavelength,
1301
+ pixel_size=det.pix_size * u.pix,
1302
+ )
1303
+ vis_pattern_normalized = vis_pattern * peak_fraction
1304
+
1305
+ vis_photons_distributed = vis_photons_total_through_pinhole.to(u.photon).value * vis_pattern_normalized
1306
+
1307
+ # The pinhole does not move, so the light is the same at every scan
1308
+ # position, but each exposure gets its own draw.
1309
+ expected = np.broadcast_to(vis_photons_distributed[:, np.newaxis, :], data_shape)
1310
+ if noise:
1311
+ vis_photons_detected = np.random.binomial(np.random.poisson(expected), det.qe_vis)
1312
+ else:
1313
+ vis_photons_detected = expected * det.qe_vis
1314
+
1315
+ # One electron per visible photon; see add_visible_stray_light.
1316
+ additional_electrons += vis_photons_detected
1317
+
1318
+ # Add pinhole contributions to original signal
1319
+ additional_electrons_quantity = additional_electrons * (u.electron / u.pixel)
1320
+ out_q = electrons.data * electrons.unit + additional_electrons_quantity
1321
+ out_q = out_q.to(electrons.unit)
1322
+
1323
+ return NDCube(
1324
+ data=out_q.value,
1325
+ wcs=electrons.wcs.deepcopy(),
1326
+ unit=out_q.unit,
1327
+ meta=electrons.meta,
1328
+ )