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.
- eclipse/__init__.py +95 -0
- eclipse/analysis.py +853 -0
- eclipse/atmosphere.py +859 -0
- eclipse/band.py +477 -0
- eclipse/cli.py +138 -0
- eclipse/config.py +1710 -0
- eclipse/continuum.py +413 -0
- eclipse/data/eis_response/EIS_EffArea_A.004 +61 -0
- eclipse/data/eis_response/EIS_EffArea_B.004 +60 -0
- eclipse/data/eis_response/EIS_EffArea_B.005 +64 -0
- eclipse/data/eis_response/README_dz2025 +50 -0
- eclipse/data/eis_response/fit_eis_ea_2023-05-04_smooth.sav +0 -0
- eclipse/data/eis_response/source.txt +33 -0
- eclipse/data/science_cases.yaml +365 -0
- eclipse/data/throughput/grating_reflection_efficiency.dat +25 -0
- eclipse/data/throughput/primary_mirror_coating_reflectance.dat +25 -0
- eclipse/data/throughput/source.txt +6 -0
- eclipse/data/throughput/throughput_aluminium_1000_angstrom.dat +503 -0
- eclipse/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +503 -0
- eclipse/data/throughput/throughput_carbon_1000_angstrom.dat +503 -0
- eclipse/data_processing.py +904 -0
- eclipse/deprecated.py +161 -0
- eclipse/eis_calibration.py +573 -0
- eclipse/extern/__init__.py +0 -0
- eclipse/extern/mpfit.py +2388 -0
- eclipse/fitting.py +1734 -0
- eclipse/frame.py +594 -0
- eclipse/full_frame.py +944 -0
- eclipse/main.py +1646 -0
- eclipse/monte_carlo.py +660 -0
- eclipse/pinhole_diffraction.py +409 -0
- eclipse/psf.py +6 -0
- eclipse/radiometric.py +1328 -0
- eclipse/raster.py +869 -0
- eclipse/readout.py +1002 -0
- eclipse/resolution_budget.py +108 -0
- eclipse/results_file.py +1226 -0
- eclipse/sampling.py +351 -0
- eclipse/science_cases.py +364 -0
- eclipse/synthesis.py +2492 -0
- eclipse/synthesis_cli.py +12 -0
- eclipse/synthesis_file.py +882 -0
- eclipse/utils.py +1145 -0
- eclipse-0.13.0.dist-info/METADATA +70 -0
- eclipse-0.13.0.dist-info/RECORD +50 -0
- eclipse-0.13.0.dist-info/WHEEL +5 -0
- eclipse-0.13.0.dist-info/entry_points.txt +7 -0
- eclipse-0.13.0.dist-info/licenses/LICENSE +3 -0
- eclipse-0.13.0.dist-info/top_level.txt +2 -0
- 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
|
+
)
|