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/monte_carlo.py
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Monte Carlo simulation functions for instrument response analysis.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import dataclasses
|
|
7
|
+
import warnings
|
|
8
|
+
from typing import Tuple
|
|
9
|
+
import numpy as np
|
|
10
|
+
import astropy.units as u
|
|
11
|
+
from ndcube import NDCube
|
|
12
|
+
from tqdm import tqdm
|
|
13
|
+
from .radiometric import (
|
|
14
|
+
apply_exposure, sample_photon_arrivals, intensity_to_photons, add_telescope_throughput,
|
|
15
|
+
photons_to_pixel_counts, apply_focusing_optics_psf, to_electrons, add_visible_stray_light, to_dn,
|
|
16
|
+
add_pinhole_visible_light, dn_variance, _digitise, _spectral_response
|
|
17
|
+
)
|
|
18
|
+
from .config import Detector_SWC
|
|
19
|
+
from .readout import (FocalPlane_SWC, ReadoutSequence, dark_current_time,
|
|
20
|
+
windows_from_wavelengths)
|
|
21
|
+
from .pinhole_diffraction import (_pinholes_on, _with_filter_transmission,
|
|
22
|
+
apply_euv_pinhole_diffraction)
|
|
23
|
+
from .fitting import fit_cube_gauss, spectral_pixel_width, summarise_fits
|
|
24
|
+
from .utils import angle_to_distance, binned_photon_wavelengths, rebin_slit_offchip, _get_mpi_info
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _fit_results(label: str, fit_data: np.ndarray, failed: np.ndarray,
|
|
28
|
+
units: list, signal: NDCube, rest_wavelength: u.Quantity,
|
|
29
|
+
fit_config) -> dict:
|
|
30
|
+
"""Statistics of one signal's fits, printing how many fits failed."""
|
|
31
|
+
results = summarise_fits(fit_data, failed, units,
|
|
32
|
+
spectral_pixel_width(signal), rest_wavelength,
|
|
33
|
+
fit_config)
|
|
34
|
+
failed_fits = results["failed_fits"]
|
|
35
|
+
n_failed = np.count_nonzero(failed)
|
|
36
|
+
if n_failed:
|
|
37
|
+
line = (f" {label} fits: {n_failed} of {failed.size} failed, in "
|
|
38
|
+
f"{np.count_nonzero(failed_fits)} of {failed_fits.size} pixels, "
|
|
39
|
+
f"and are left out of the statistics")
|
|
40
|
+
no_fit = np.count_nonzero(failed_fits == results["n_iterations"])
|
|
41
|
+
if no_fit:
|
|
42
|
+
line += f"; no fit succeeded in {no_fit} of them"
|
|
43
|
+
else:
|
|
44
|
+
line = f" {label} fits: none of {failed.size} failed"
|
|
45
|
+
print(line)
|
|
46
|
+
return results
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _with_photon_wavelength(cube: NDCube) -> NDCube:
|
|
50
|
+
"""
|
|
51
|
+
Cube with its photon wavelengths in the meta.
|
|
52
|
+
|
|
53
|
+
`meta["photon_wavelength"]` and `meta["photon_rms_wavelength"]` are the
|
|
54
|
+
wavelengths of the mean and root mean square energies of each pixel's
|
|
55
|
+
photons. The cube's own are kept; otherwise each pixel's wavelength is
|
|
56
|
+
used, as for photons of one energy.
|
|
57
|
+
"""
|
|
58
|
+
meta = dict(cube.meta or {})
|
|
59
|
+
if "photon_wavelength" not in meta:
|
|
60
|
+
meta["photon_wavelength"] = np.broadcast_to(cube.axis_world_coords_values(2)[0],
|
|
61
|
+
cube.data.shape, subok=True)
|
|
62
|
+
meta.setdefault("photon_rms_wavelength", meta["photon_wavelength"])
|
|
63
|
+
for key in ("photon_wavelength", "photon_rms_wavelength"):
|
|
64
|
+
if np.shape(meta[key]) != cube.data.shape:
|
|
65
|
+
raise ValueError(f"The cube's {key} is shaped {np.shape(meta[key])}, not as its "
|
|
66
|
+
f"data, {cube.data.shape}.")
|
|
67
|
+
return NDCube(cube.data, wcs=cube.wcs, unit=cube.unit, meta=meta)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _photon_wavelengths(I_cube: NDCube, t_exp: u.Quantity, det, tel, sim,
|
|
71
|
+
offchip_bin_slit: int, uniform_mode: bool) -> tuple:
|
|
72
|
+
"""
|
|
73
|
+
Wavelengths of the mean and root mean square photon energies in each
|
|
74
|
+
pixel, binned as the DN are, for `dn_variance`.
|
|
75
|
+
"""
|
|
76
|
+
photons = _photons_on_the_detector(I_cube, t_exp, det, tel, sim, uniform_mode)[-1]
|
|
77
|
+
return binned_photon_wavelengths(photons, offchip_bin_slit)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _weighted(fit_config) -> bool:
|
|
81
|
+
"""Whether to weight the fits: yes by default, otherwise as the fitting block says."""
|
|
82
|
+
return fit_config is None or fit_config.weighted
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _visible_electrons(I_cube: NDCube, t_exp: u.Quantity, det, tel, sim,
|
|
86
|
+
offchip_bin_slit: int) -> np.ndarray:
|
|
87
|
+
"""Mean electrons from visible light in each pixel, binned as the DN are."""
|
|
88
|
+
nothing = NDCube(np.zeros(I_cube.data.shape), wcs=I_cube.wcs,
|
|
89
|
+
unit=u.electron / u.pixel, meta=I_cube.meta)
|
|
90
|
+
visible = add_visible_stray_light(nothing, t_exp, det, sim, tel, noise=False)
|
|
91
|
+
visible = add_pinhole_visible_light(visible, t_exp, det, sim, tel, noise=False)
|
|
92
|
+
return rebin_slit_offchip(visible, offchip_bin_slit).data
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def expected_dn_uncertainty(I_cube: NDCube, t_exp: u.Quantity, det, tel, sim,
|
|
96
|
+
offchip_bin_slit: int = 1, uniform_mode: bool = False) -> np.ndarray:
|
|
97
|
+
"""
|
|
98
|
+
Expected uncertainty of each pixel's DN, from the noise-free signal.
|
|
99
|
+
|
|
100
|
+
The measured spectra are weighted by uncertainties estimated from their
|
|
101
|
+
own signal (`dn_variance`). The ground truth, a fit to the noise-free
|
|
102
|
+
spectra, is weighted by these instead, so it is the same fit of the
|
|
103
|
+
same line and its difference from a measurement is due to the noise.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
I_cube : NDCube
|
|
108
|
+
Spectral radiance on the detector's pixels, per second, as given to
|
|
109
|
+
the Monte Carlo.
|
|
110
|
+
t_exp : u.Quantity
|
|
111
|
+
Exposure time.
|
|
112
|
+
det, tel, sim
|
|
113
|
+
Detector, telescope and simulation settings.
|
|
114
|
+
offchip_bin_slit : int, optional
|
|
115
|
+
Number of pixels along the slit summed after read-out. Default 1.
|
|
116
|
+
uniform_mode : bool, optional
|
|
117
|
+
As for `simulate_once`. Default False.
|
|
118
|
+
|
|
119
|
+
Returns
|
|
120
|
+
-------
|
|
121
|
+
np.ndarray
|
|
122
|
+
Uncertainty in DN, same shape as the binned DN.
|
|
123
|
+
"""
|
|
124
|
+
_check_blur(I_cube, det, tel, sim, stacklevel=2)
|
|
125
|
+
quiet = dataclasses.replace(sim, noise=False)
|
|
126
|
+
steps = _simulate_once(I_cube, t_exp, det, tel, quiet, uniform_mode=uniform_mode)
|
|
127
|
+
electrons = steps[8]
|
|
128
|
+
# Clip each pixel at the digitiser's maximum before binning, as to_dn
|
|
129
|
+
# does, but do not round, as the mean DN is not rounded.
|
|
130
|
+
dn = _digitise(electrons.unit.to(u.electron / u.pixel) * electrons.data, det, rounded=False)
|
|
131
|
+
dn = rebin_slit_offchip(NDCube(dn, wcs=electrons.wcs, meta=electrons.meta),
|
|
132
|
+
offchip_bin_slit).data
|
|
133
|
+
visible = _visible_electrons(I_cube, t_exp, det, tel, sim, offchip_bin_slit)
|
|
134
|
+
mean, rms = binned_photon_wavelengths(steps[5], offchip_bin_slit)
|
|
135
|
+
return np.sqrt(dn_variance(dn, mean, t_exp, det, visible, offchip_bin_slit,
|
|
136
|
+
rms_wavelength=rms, dark_time=_dark_time(I_cube, t_exp, det, sim)))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _check_blur(I_cube: NDCube, det, tel, sim, stacklevel: int) -> None:
|
|
140
|
+
"""
|
|
141
|
+
Refuse a line made through another spectral response than `sim` gives,
|
|
142
|
+
and warn, on one rank, if `simulate_once` will blur `I_cube` on its pixels.
|
|
143
|
+
"""
|
|
144
|
+
meta = I_cube.meta or {}
|
|
145
|
+
made = meta.get("spectral_response")
|
|
146
|
+
if made is not None:
|
|
147
|
+
if not sim.psf:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
"This cube was made with the PSF on, but the simulation has psf off. Make it "
|
|
150
|
+
"again with create_uniform_intensity_cube for this simulation.")
|
|
151
|
+
observed = _spectral_response(tel, det, sim.slit_width, sim.spectral_psf)
|
|
152
|
+
if not np.allclose(made, observed, rtol=1e-12, atol=0.0):
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"This cube was made through the spectral response for another slit width, "
|
|
155
|
+
"spectral_psf or telescope than this simulation's. Make it again with "
|
|
156
|
+
"create_uniform_intensity_cube for this simulation and telescope.")
|
|
157
|
+
if sim.psf and not meta.get("psf_applied", False) and _get_mpi_info()[1] == 0:
|
|
158
|
+
warnings.warn(
|
|
159
|
+
"The PSF blurs this cube on the detector's pixels, which pulls a line narrower than "
|
|
160
|
+
"a pixel toward the pixel's centre. To avoid this, blur the scene by the PSF, in "
|
|
161
|
+
"wavelength and along the slit, before averaging it onto the pixels, and set "
|
|
162
|
+
"meta['psf_applied'] = True, as rebin_atmosphere, rebin_spectra and "
|
|
163
|
+
"create_uniform_intensity_cube do given the telescope.",
|
|
164
|
+
UserWarning, stacklevel=stacklevel + 1)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _photons_on_the_detector(I_cube: NDCube, t_exp: u.Quantity, det, tel, sim,
|
|
168
|
+
uniform_mode: bool = False) -> Tuple[NDCube, ...]:
|
|
169
|
+
"""
|
|
170
|
+
Noise-free cubes of `simulate_once`, up to the photons at the detector.
|
|
171
|
+
|
|
172
|
+
Returns the radiance over the exposure, the photons, the photons
|
|
173
|
+
collected by the telescope, the photons per pixel, those after the PSF,
|
|
174
|
+
and those with the pinholes' light.
|
|
175
|
+
"""
|
|
176
|
+
# The slit's ends and fiducials are cut out as the scene is laid onto the
|
|
177
|
+
# pixels, so a cube laid on without them, or a uniform intensity, which
|
|
178
|
+
# has no position along the slit, would silently be left unmasked.
|
|
179
|
+
if getattr(sim, "slit_mask", False) and not (I_cube.meta or {}).get("slit_mask_applied"):
|
|
180
|
+
raise ValueError(
|
|
181
|
+
"simulation.slit_mask is on, but the slit's ends and fiducials were not cut out of "
|
|
182
|
+
"this cube. They are cut out as a scene is laid onto the pixels by rebin_atmosphere "
|
|
183
|
+
"or rebin_spectra, given the telescope and this simulation. A uniform intensity has "
|
|
184
|
+
"no position along the slit, so it cannot have them.")
|
|
185
|
+
# The blur comes before the slit, so a masked cube blurred afterwards would
|
|
186
|
+
# spread light back into the fiducials and past the slit's ends.
|
|
187
|
+
if (getattr(sim, "slit_mask", False) and sim.psf
|
|
188
|
+
and not (I_cube.meta or {}).get("psf_applied")):
|
|
189
|
+
raise ValueError(
|
|
190
|
+
"simulation.slit_mask and psf are both on, but this cube was masked without the "
|
|
191
|
+
"PSF, so blurring it now would put light back behind the fiducials. Lay the scene "
|
|
192
|
+
"onto the pixels again with rebin_atmosphere or rebin_spectra, with both on.")
|
|
193
|
+
|
|
194
|
+
# Apply exposure time
|
|
195
|
+
intensity_exp = apply_exposure(I_cube, t_exp)
|
|
196
|
+
|
|
197
|
+
# Convert to total photons at each pixel's wavelength, or at the
|
|
198
|
+
# wavelength of the photons' mean energy if the cube gives it, as it does
|
|
199
|
+
# once laid onto the pixels through the telescope.
|
|
200
|
+
photons_total = _with_photon_wavelength(intensity_to_photons(intensity_exp))
|
|
201
|
+
|
|
202
|
+
# Apply telescope optical throughput
|
|
203
|
+
photons_throughput = add_telescope_throughput(photons_total, tel)
|
|
204
|
+
|
|
205
|
+
# Convert to pixel counts
|
|
206
|
+
photons_pixels = photons_to_pixel_counts(photons_throughput, det.wvl_res,
|
|
207
|
+
det.plate_scale_length,
|
|
208
|
+
angle_to_distance(sim.slit_width))
|
|
209
|
+
|
|
210
|
+
# The pinholes pass the light the filter blocked from each photon, which
|
|
211
|
+
# must be known before a blur mixes pixels. A cube laid onto the pixels
|
|
212
|
+
# through the telescope with the pinholes on gives it at each photon's
|
|
213
|
+
# own wavelength. Photons counted at their pixel's wavelength passed the
|
|
214
|
+
# filter at that wavelength.
|
|
215
|
+
meta = I_cube.meta or {}
|
|
216
|
+
if _pinholes_on(sim) and "photon_filter_transmission" not in meta:
|
|
217
|
+
if "photon_wavelength" in meta:
|
|
218
|
+
raise ValueError(
|
|
219
|
+
"The cube's photons were counted at their own wavelengths, but it does not say "
|
|
220
|
+
"how much of their light the filter passed, which the pinholes need. Lay it "
|
|
221
|
+
"onto the pixels with rebin_atmosphere or rebin_spectra, given the telescope "
|
|
222
|
+
"and a simulation with the pinholes on.")
|
|
223
|
+
photons_pixels = _with_filter_transmission(photons_pixels, tel)
|
|
224
|
+
|
|
225
|
+
# Apply focusing optics PSF (primary mirror + diffraction grating),
|
|
226
|
+
# unless the cube was laid onto the pixels through it. That is exact;
|
|
227
|
+
# blurring the pixels is not.
|
|
228
|
+
if sim.psf and not (I_cube.meta or {}).get("psf_applied", False):
|
|
229
|
+
photons_focused = apply_focusing_optics_psf(
|
|
230
|
+
photons_pixels, tel, det, sim, convolve_spatial=not uniform_mode,
|
|
231
|
+
boundary=getattr(sim, "psf_boundary", "replicate"),
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
photons_focused = photons_pixels
|
|
235
|
+
|
|
236
|
+
# Apply EUV pinhole diffraction effects (after focusing optics, if enabled)
|
|
237
|
+
if _pinholes_on(sim):
|
|
238
|
+
photons_euv_pinholes = apply_euv_pinhole_diffraction(photons_focused, det, sim, tel)
|
|
239
|
+
else:
|
|
240
|
+
photons_euv_pinholes = photons_focused
|
|
241
|
+
return (intensity_exp, photons_total, photons_throughput, photons_pixels, photons_focused,
|
|
242
|
+
photons_euv_pinholes)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _dark_time(I_cube: NDCube, t_exp: u.Quantity, det, sim) -> u.Quantity:
|
|
246
|
+
"""
|
|
247
|
+
Time each wavelength pixel of `I_cube` collects dark current.
|
|
248
|
+
|
|
249
|
+
With `sim.readout_dark_current` on and the SWC detector, this is the
|
|
250
|
+
time :func:`~eclipse.readout.dark_current_time` gives the row that
|
|
251
|
+
records the pixel's wavelength: the clear before the exposure, the
|
|
252
|
+
exposure, and the wait to be read out. The rows of the cube's
|
|
253
|
+
wavelengths are taken to be the only ones read, and the rest are
|
|
254
|
+
dumped. Otherwise it is `t_exp`.
|
|
255
|
+
"""
|
|
256
|
+
if not sim.readout_dark_current or not isinstance(det, Detector_SWC):
|
|
257
|
+
return u.Quantity(t_exp)
|
|
258
|
+
focal_plane = FocalPlane_SWC.from_detector(det)
|
|
259
|
+
n_rows = focal_plane.n_rows
|
|
260
|
+
wavelength = I_cube.axis_world_coords_values(2)[0].to_value(u.Angstrom)
|
|
261
|
+
rows = focal_plane._nearest_rows(wavelength)
|
|
262
|
+
# The window holds every row a pixel of the cube reaches, half a pixel
|
|
263
|
+
# either side of its centre. Wavelengths all in the gap reach no row, and
|
|
264
|
+
# take the last, at the gap's edge.
|
|
265
|
+
half = 0.5 * np.abs(np.diff(wavelength)).mean() if wavelength.size > 1 else 0.0
|
|
266
|
+
try:
|
|
267
|
+
windows = windows_from_wavelengths(
|
|
268
|
+
focal_plane, [((wavelength.min() - half) * u.AA, (wavelength.max() + half) * u.AA)])
|
|
269
|
+
except ValueError:
|
|
270
|
+
windows = [(n_rows - 1, n_rows - 1)]
|
|
271
|
+
sequence = ReadoutSequence.from_detector(det, windows=windows)
|
|
272
|
+
if sequence.cte_parallel != 1 or sequence.cte_serial != 1:
|
|
273
|
+
warnings.warn("A run does not model charge transfer, so the detector's cte_parallel "
|
|
274
|
+
"and cte_serial are taken as 1 here. Charge left behind as a frame is "
|
|
275
|
+
"read is modelled by eclipse.readout.", UserWarning, stacklevel=3)
|
|
276
|
+
sequence = dataclasses.replace(sequence, cte_parallel=1.0, cte_serial=1.0)
|
|
277
|
+
per_row = dark_current_time(t_exp, sequence, n_rows)[:n_rows]
|
|
278
|
+
return np.interp(rows, np.arange(n_rows), per_row.to_value(u.s)) * u.s
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def simulate_once(
|
|
282
|
+
I_cube: NDCube,
|
|
283
|
+
t_exp: u.Quantity,
|
|
284
|
+
det,
|
|
285
|
+
tel,
|
|
286
|
+
sim,
|
|
287
|
+
*,
|
|
288
|
+
uniform_mode: bool = False,
|
|
289
|
+
photon_shot_inverse_transform: bool = False,
|
|
290
|
+
dark_current_inverse_transform: bool = False,
|
|
291
|
+
) -> Tuple[NDCube, ...]:
|
|
292
|
+
"""
|
|
293
|
+
Simulate one observation of a cube of spectra, with noise, without fitting.
|
|
294
|
+
|
|
295
|
+
The steps, in order, are the exposure, telescope, pixels, PSF, pinholes,
|
|
296
|
+
photon arrivals, detector, stray light and digitiser.
|
|
297
|
+
|
|
298
|
+
With `sim.psf` on, a cube whose `meta["psf_applied"]` is True is not
|
|
299
|
+
blurred again. This is set by :func:`~eclipse.data_processing.rebin_atmosphere`
|
|
300
|
+
and :func:`~eclipse.data_processing.rebin_spectra` given the telescope,
|
|
301
|
+
and by :func:`~eclipse.data_processing.create_uniform_intensity_cube`
|
|
302
|
+
given the telescope and `sim.psf` on, which blur the spectrum before
|
|
303
|
+
laying it onto the pixels. Any other cube is blurred on its pixels.
|
|
304
|
+
This pulls a line narrower than a pixel toward the pixel's centre, and
|
|
305
|
+
a UserWarning says so. A cube from `create_uniform_intensity_cube` made
|
|
306
|
+
through another spectral response than `sim` gives is refused with a
|
|
307
|
+
ValueError.
|
|
308
|
+
|
|
309
|
+
Parameters
|
|
310
|
+
----------
|
|
311
|
+
I_cube : NDCube
|
|
312
|
+
Spectral radiance on the detector's pixels, per second, e.g. from
|
|
313
|
+
`create_uniform_intensity_cube`, with the line's rest wavelength in
|
|
314
|
+
`meta["rest_wav"]`.
|
|
315
|
+
t_exp : u.Quantity
|
|
316
|
+
Exposure time.
|
|
317
|
+
det : Detector_SWC or Detector_EIS
|
|
318
|
+
Detector.
|
|
319
|
+
tel : Telescope_EUVST or Telescope_EIS
|
|
320
|
+
Telescope.
|
|
321
|
+
sim : Simulation
|
|
322
|
+
Simulation settings.
|
|
323
|
+
uniform_mode : bool, optional
|
|
324
|
+
If True, the cube is the same all along the slit, so the PSF blurs it
|
|
325
|
+
in wavelength only. Default False.
|
|
326
|
+
photon_shot_inverse_transform : bool, optional
|
|
327
|
+
Draw the photons by inverse-transform sampling, for common random
|
|
328
|
+
numbers between runs that differ in photon flux. Default False.
|
|
329
|
+
dark_current_inverse_transform : bool, optional
|
|
330
|
+
Draw the dark current by inverse-transform sampling, for common random
|
|
331
|
+
numbers between runs that differ in dark current. Default False.
|
|
332
|
+
|
|
333
|
+
Returns
|
|
334
|
+
-------
|
|
335
|
+
tuple of NDCube
|
|
336
|
+
Cube after each step: radiance over the exposure, photons, photons
|
|
337
|
+
collected by the telescope, photons per pixel, photons after the PSF,
|
|
338
|
+
photons arriving, electrons, electrons with stray light, electrons
|
|
339
|
+
with the pinholes' visible light, and DN.
|
|
340
|
+
"""
|
|
341
|
+
_check_blur(I_cube, det, tel, sim, stacklevel=2)
|
|
342
|
+
return _simulate_once(I_cube, t_exp, det, tel, sim, uniform_mode=uniform_mode,
|
|
343
|
+
photon_shot_inverse_transform=photon_shot_inverse_transform,
|
|
344
|
+
dark_current_inverse_transform=dark_current_inverse_transform)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _simulate_once(I_cube: NDCube, t_exp: u.Quantity, det, tel, sim, *,
|
|
348
|
+
uniform_mode: bool = False, photon_shot_inverse_transform: bool = False,
|
|
349
|
+
dark_current_inverse_transform: bool = False) -> Tuple[NDCube, ...]:
|
|
350
|
+
""":func:`simulate_once` without the warning."""
|
|
351
|
+
(intensity_exp, photons_total, photons_throughput, photons_pixels, photons_focused,
|
|
352
|
+
photons_euv_pinholes) = _photons_on_the_detector(I_cube, t_exp, det, tel, sim,
|
|
353
|
+
uniform_mode)
|
|
354
|
+
|
|
355
|
+
# This flag controls every random draw below. With sim.noise False the
|
|
356
|
+
# run returns the mean measured signal rather than one realisation.
|
|
357
|
+
noise = getattr(sim, "noise", True)
|
|
358
|
+
|
|
359
|
+
# Sample discrete photon arrivals (photon shot noise)
|
|
360
|
+
photon_arrivals = sample_photon_arrivals(
|
|
361
|
+
photons_euv_pinholes,
|
|
362
|
+
photon_shot_inverse_transform=photon_shot_inverse_transform,
|
|
363
|
+
noise=noise,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Convert to electrons (detector response: QE, Fano noise, dark current, read noise)
|
|
367
|
+
electrons = to_electrons(
|
|
368
|
+
photon_arrivals, t_exp, det,
|
|
369
|
+
dark_current_inverse_transform=dark_current_inverse_transform,
|
|
370
|
+
noise=noise,
|
|
371
|
+
photon_shot_inverse_transform=photon_shot_inverse_transform,
|
|
372
|
+
dark_time=_dark_time(I_cube, t_exp, det, sim),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Add visible stray light (with filter throughput)
|
|
376
|
+
electrons_stray = add_visible_stray_light(electrons, t_exp, det, sim, tel,
|
|
377
|
+
noise=noise)
|
|
378
|
+
|
|
379
|
+
# Add visible light pinhole effects (if enabled)
|
|
380
|
+
if sim.enable_pinholes and len(sim.pinhole_sizes) > 0:
|
|
381
|
+
electrons_pinholes = add_pinhole_visible_light(electrons_stray, t_exp,
|
|
382
|
+
det, sim, tel, noise=noise)
|
|
383
|
+
else:
|
|
384
|
+
electrons_pinholes = electrons_stray
|
|
385
|
+
|
|
386
|
+
# Convert to digital numbers
|
|
387
|
+
dn = to_dn(electrons_pinholes, det)
|
|
388
|
+
|
|
389
|
+
return (intensity_exp, photons_total, photons_throughput, photons_pixels,
|
|
390
|
+
photons_focused, photon_arrivals, electrons, electrons_stray,
|
|
391
|
+
electrons_pinholes, dn)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _own_stream(rank: int) -> None:
|
|
395
|
+
"""
|
|
396
|
+
Reseed NumPy's generator from its current state and the MPI rank.
|
|
397
|
+
|
|
398
|
+
With the same seed on every rank, as in the docs' recipe for comparing
|
|
399
|
+
runs, the ranks would repeat each other's iterations and the spread
|
|
400
|
+
would come out too narrow by the square root of the number of ranks.
|
|
401
|
+
Mixing in the rank avoids this, and a run still repeats with the same
|
|
402
|
+
seed and number of ranks.
|
|
403
|
+
"""
|
|
404
|
+
entropy = int(np.random.randint(0, 2**31 - 1))
|
|
405
|
+
np.random.seed(np.random.SeedSequence([entropy, rank]).generate_state(8))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def monte_carlo(I_cube: NDCube, t_exp: u.Quantity, det, tel, sim, n_iter: int = 5,
|
|
409
|
+
fit_config=None, offchip_bin_slit: int = 1,
|
|
410
|
+
fit_signals: str = "both", uniform_mode: bool = False,
|
|
411
|
+
*,
|
|
412
|
+
photon_shot_inverse_transform: bool = False,
|
|
413
|
+
dark_current_inverse_transform: bool = False) -> Tuple[NDCube, dict | None, NDCube, dict | None]:
|
|
414
|
+
"""
|
|
415
|
+
Simulate an observation many times, each with new noise, and fit every one.
|
|
416
|
+
|
|
417
|
+
`eclipse` runs this for each combination of settings. Under MPI the
|
|
418
|
+
iterations are split between the ranks and the results gathered on the
|
|
419
|
+
first.
|
|
420
|
+
|
|
421
|
+
Parameters
|
|
422
|
+
----------
|
|
423
|
+
I_cube : NDCube
|
|
424
|
+
Spectral radiance on the detector's pixels, per second, as for
|
|
425
|
+
`simulate_once`.
|
|
426
|
+
t_exp : u.Quantity
|
|
427
|
+
Exposure time.
|
|
428
|
+
det : Detector_SWC or Detector_EIS
|
|
429
|
+
Detector.
|
|
430
|
+
tel : Telescope_EUVST or Telescope_EIS
|
|
431
|
+
Telescope.
|
|
432
|
+
sim : Simulation
|
|
433
|
+
Simulation settings.
|
|
434
|
+
n_iter : int, optional
|
|
435
|
+
Number of iterations. Default 5.
|
|
436
|
+
fit_config : FitConfig, optional
|
|
437
|
+
Fit settings. Default one Gaussian.
|
|
438
|
+
offchip_bin_slit : int, optional
|
|
439
|
+
Number of pixels along the slit summed after read-out, each with its
|
|
440
|
+
own noise. Default 1.
|
|
441
|
+
fit_signals : str, optional
|
|
442
|
+
Which signals to fit: `"both"` (default), `"dn"` or `"photon"`.
|
|
443
|
+
Fitting only one takes about half the time.
|
|
444
|
+
uniform_mode : bool, optional
|
|
445
|
+
If True, the cube is a single line of known intensity, as made by
|
|
446
|
+
`create_uniform_intensity_cube`, with `offchip_bin_slit` pixels along
|
|
447
|
+
the slit. Default False.
|
|
448
|
+
photon_shot_inverse_transform : bool, optional
|
|
449
|
+
Draw the photons by inverse-transform sampling, for common random
|
|
450
|
+
numbers between runs that differ in photon flux. Default False.
|
|
451
|
+
dark_current_inverse_transform : bool, optional
|
|
452
|
+
Draw the dark current by inverse-transform sampling, for common random
|
|
453
|
+
numbers between runs that differ in dark current. Default False.
|
|
454
|
+
|
|
455
|
+
Returns
|
|
456
|
+
-------
|
|
457
|
+
first_dn_signal : NDCube
|
|
458
|
+
First iteration's signal, in DN.
|
|
459
|
+
dn_fit_results : dict or None
|
|
460
|
+
Fits to the DN signal, or None if not fitted. `"components"` holds,
|
|
461
|
+
for each fitted component by name, the intensity, velocity and
|
|
462
|
+
width, each as `"first"`, `"mean"` and `"std"` maps.
|
|
463
|
+
`"primary_component"` names the primary component, and
|
|
464
|
+
`"failed_fits"` counts the failed fits in each pixel.
|
|
465
|
+
first_photon_signal : NDCube
|
|
466
|
+
First iteration's photons arriving at the detector.
|
|
467
|
+
photon_fit_results : dict or None
|
|
468
|
+
Fits to the photons, as for the DN, or None.
|
|
469
|
+
|
|
470
|
+
Under MPI only the first rank gets these; the other ranks get four Nones.
|
|
471
|
+
"""
|
|
472
|
+
if fit_signals not in ("both", "dn", "photon"):
|
|
473
|
+
raise ValueError(f"fit_signals must be 'both', 'dn', or 'photon', got '{fit_signals}'")
|
|
474
|
+
|
|
475
|
+
_check_blur(I_cube, det, tel, sim, stacklevel=2)
|
|
476
|
+
do_dn = fit_signals in ("both", "dn")
|
|
477
|
+
do_photon = fit_signals in ("both", "photon")
|
|
478
|
+
rest_wavelength = I_cube.meta["rest_wav"]
|
|
479
|
+
|
|
480
|
+
# DN spectra are weighted by uncertainties estimated from their own
|
|
481
|
+
# signal, with the visible light's share of it known. Photons have no
|
|
482
|
+
# read noise, so a pixel with no photons would have no uncertainty; they
|
|
483
|
+
# are fitted by their Poisson likelihood instead.
|
|
484
|
+
visible = (_visible_electrons(I_cube, t_exp, det, tel, sim, offchip_bin_slit)
|
|
485
|
+
if do_dn and _weighted(fit_config) else None)
|
|
486
|
+
# Each photon frees electrons according to its energy, so a pixel's DN
|
|
487
|
+
# vary with the mean and root mean square energies of its photons.
|
|
488
|
+
photon_wavelengths = (None if visible is None else _photon_wavelengths(
|
|
489
|
+
I_cube, t_exp, det, tel, sim, offchip_bin_slit, uniform_mode))
|
|
490
|
+
dark_time = _dark_time(I_cube, t_exp, det, sim)
|
|
491
|
+
|
|
492
|
+
def _dn_uncertainty(dn_data: np.ndarray) -> np.ndarray | None:
|
|
493
|
+
if visible is None:
|
|
494
|
+
return None
|
|
495
|
+
mean, rms = photon_wavelengths
|
|
496
|
+
return np.sqrt(dn_variance(dn_data, mean, t_exp, det, visible, offchip_bin_slit,
|
|
497
|
+
rms_wavelength=rms, dark_time=dark_time))
|
|
498
|
+
|
|
499
|
+
# --- MPI distribution: split iterations across ranks -----------------
|
|
500
|
+
comm, rank, world_size = _get_mpi_info()
|
|
501
|
+
if world_size > 1:
|
|
502
|
+
_own_stream(rank)
|
|
503
|
+
base, remainder = divmod(n_iter, world_size)
|
|
504
|
+
local_n_iter = base + (1 if rank < remainder else 0)
|
|
505
|
+
if rank == 0:
|
|
506
|
+
print(f"MPI: distributing {n_iter} MC iterations across "
|
|
507
|
+
f"{world_size} ranks ({local_n_iter} per rank)")
|
|
508
|
+
else:
|
|
509
|
+
local_n_iter = n_iter
|
|
510
|
+
|
|
511
|
+
if uniform_mode:
|
|
512
|
+
# Uniform intensity mode: batch all MC simulations, then fit in parallel
|
|
513
|
+
first_dn_signal, first_photon_signal = None, None
|
|
514
|
+
dn_data_list, photon_data_list = [], []
|
|
515
|
+
|
|
516
|
+
show_progress = (rank == 0)
|
|
517
|
+
desc = "Monte-Carlo (simulate)" if world_size == 1 else f"MC sim (rank 0/{world_size})"
|
|
518
|
+
|
|
519
|
+
for i in tqdm(range(local_n_iter), desc=desc, unit="iter", leave=False,
|
|
520
|
+
disable=not show_progress):
|
|
521
|
+
(intensity_exp, photons_total, photons_throughput, photons_pixels,
|
|
522
|
+
photons_focused, photon_arrivals, electrons, electrons_stray,
|
|
523
|
+
electrons_pinholes, dn) = _simulate_once(
|
|
524
|
+
I_cube, t_exp, det, tel, sim,
|
|
525
|
+
uniform_mode=uniform_mode,
|
|
526
|
+
photon_shot_inverse_transform=photon_shot_inverse_transform,
|
|
527
|
+
dark_current_inverse_transform=dark_current_inverse_transform,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
if i == 0 and rank == 0:
|
|
531
|
+
first_dn_signal = rebin_slit_offchip(dn, offchip_bin_slit)
|
|
532
|
+
first_photon_signal = rebin_slit_offchip(photon_arrivals, offchip_bin_slit)
|
|
533
|
+
|
|
534
|
+
# Off-chip binning before batching
|
|
535
|
+
dn_binned = rebin_slit_offchip(dn, offchip_bin_slit)
|
|
536
|
+
photon_binned = rebin_slit_offchip(photon_arrivals, offchip_bin_slit)
|
|
537
|
+
|
|
538
|
+
# .data shape is (n_slit, n_scan, n_lam); after binning both spatial
|
|
539
|
+
# axes are length 1, so [0] leaves the one spectrum as (1, n_lam)
|
|
540
|
+
if do_dn:
|
|
541
|
+
dn_data_list.append(dn_binned.data[0])
|
|
542
|
+
if do_photon:
|
|
543
|
+
photon_data_list.append(photon_binned.data[0])
|
|
544
|
+
|
|
545
|
+
# --- MPI gather: collect data from all ranks on root -----------
|
|
546
|
+
if world_size > 1:
|
|
547
|
+
if do_dn:
|
|
548
|
+
all_dn_lists = comm.gather(dn_data_list, root=0)
|
|
549
|
+
if rank == 0:
|
|
550
|
+
dn_data_list = [v for sublist in all_dn_lists for v in sublist]
|
|
551
|
+
if do_photon:
|
|
552
|
+
all_photon_lists = comm.gather(photon_data_list, root=0)
|
|
553
|
+
if rank == 0:
|
|
554
|
+
photon_data_list = [v for sublist in all_photon_lists for v in sublist]
|
|
555
|
+
|
|
556
|
+
# --- Fit (only on rank 0) --------------------------------------
|
|
557
|
+
dn_fit_results = None
|
|
558
|
+
photon_fit_results = None
|
|
559
|
+
|
|
560
|
+
if rank == 0:
|
|
561
|
+
if do_dn and dn_data_list:
|
|
562
|
+
# Stack: each element is (1, n_lam), result is (n_iter, 1, n_lam).
|
|
563
|
+
dn_stacked = np.stack(dn_data_list, axis=0)
|
|
564
|
+
dn_batch = NDCube(data=dn_stacked, wcs=first_dn_signal.wcs,
|
|
565
|
+
unit=first_dn_signal.unit)
|
|
566
|
+
|
|
567
|
+
print(f" Fitting {len(dn_data_list)} DN MC spectra in parallel...")
|
|
568
|
+
dn_fit_values, dn_fit_units, dn_failed = fit_cube_gauss(
|
|
569
|
+
dn_batch, n_jobs=sim.ncpu, fit_config=fit_config,
|
|
570
|
+
return_failed=True, uncertainty=_dn_uncertainty(dn_stacked))
|
|
571
|
+
# Reshape from (n_iter, 1, ...) to (n_iter, 1, 1, ...)
|
|
572
|
+
dn_fit_results = _fit_results(
|
|
573
|
+
"DN", dn_fit_values[:, np.newaxis, :, :],
|
|
574
|
+
dn_failed[:, np.newaxis, :], dn_fit_units,
|
|
575
|
+
first_dn_signal, rest_wavelength, fit_config)
|
|
576
|
+
|
|
577
|
+
if do_photon and photon_data_list:
|
|
578
|
+
photon_stacked = np.stack(photon_data_list, axis=0)
|
|
579
|
+
photon_batch = NDCube(data=photon_stacked, wcs=first_photon_signal.wcs,
|
|
580
|
+
unit=first_photon_signal.unit)
|
|
581
|
+
|
|
582
|
+
print(f" Fitting {len(photon_data_list)} photon MC spectra in parallel...")
|
|
583
|
+
photon_fit_values, photon_fit_units, photon_failed = fit_cube_gauss(
|
|
584
|
+
photon_batch, n_jobs=sim.ncpu, fit_config=fit_config,
|
|
585
|
+
return_failed=True, poisson=_weighted(fit_config))
|
|
586
|
+
photon_fit_results = _fit_results(
|
|
587
|
+
"Photon", photon_fit_values[:, np.newaxis, :, :],
|
|
588
|
+
photon_failed[:, np.newaxis, :], photon_fit_units,
|
|
589
|
+
first_photon_signal, rest_wavelength, fit_config)
|
|
590
|
+
|
|
591
|
+
else:
|
|
592
|
+
# -- Normal mode: fit each MC iteration separately ---------------
|
|
593
|
+
first_dn_signal, first_photon_signal = None, None
|
|
594
|
+
dn_fit_values_list, photon_fit_values_list = [], []
|
|
595
|
+
|
|
596
|
+
show_progress = (rank == 0)
|
|
597
|
+
desc = "Monte-Carlo" if world_size == 1 else f"MC (rank 0/{world_size})"
|
|
598
|
+
|
|
599
|
+
for i in tqdm(range(local_n_iter), desc=desc, unit="iter", leave=False,
|
|
600
|
+
disable=not show_progress):
|
|
601
|
+
# Simulate one run
|
|
602
|
+
(intensity_exp, photons_total, photons_throughput, photons_pixels,
|
|
603
|
+
photons_focused, photon_arrivals, electrons, electrons_stray,
|
|
604
|
+
electrons_pinholes, dn) = _simulate_once(
|
|
605
|
+
I_cube, t_exp, det, tel, sim,
|
|
606
|
+
uniform_mode=uniform_mode,
|
|
607
|
+
photon_shot_inverse_transform=photon_shot_inverse_transform,
|
|
608
|
+
dark_current_inverse_transform=dark_current_inverse_transform,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
# Store first iteration signals only on rank 0 (binned, to match fit shapes)
|
|
612
|
+
if i == 0 and rank == 0:
|
|
613
|
+
first_dn_signal = rebin_slit_offchip(dn, offchip_bin_slit)
|
|
614
|
+
first_photon_signal = rebin_slit_offchip(photon_arrivals, offchip_bin_slit)
|
|
615
|
+
|
|
616
|
+
# Off-chip slit binning (sum already noisy pixels)
|
|
617
|
+
if do_dn:
|
|
618
|
+
dn_binned = rebin_slit_offchip(dn, offchip_bin_slit)
|
|
619
|
+
dn_fit_values, dn_fit_units, dn_failed = fit_cube_gauss(
|
|
620
|
+
dn_binned, n_jobs=sim.ncpu, fit_config=fit_config,
|
|
621
|
+
return_failed=True, uncertainty=_dn_uncertainty(dn_binned.data))
|
|
622
|
+
dn_fit_values_list.append((dn_fit_values, dn_failed))
|
|
623
|
+
|
|
624
|
+
if do_photon:
|
|
625
|
+
photon_binned = rebin_slit_offchip(photon_arrivals, offchip_bin_slit)
|
|
626
|
+
photon_fit_values, photon_fit_units, photon_failed = fit_cube_gauss(
|
|
627
|
+
photon_binned, n_jobs=sim.ncpu, fit_config=fit_config,
|
|
628
|
+
return_failed=True, poisson=_weighted(fit_config))
|
|
629
|
+
photon_fit_values_list.append((photon_fit_values, photon_failed))
|
|
630
|
+
|
|
631
|
+
# --- MPI gather: collect fit arrays from all ranks on root -----------
|
|
632
|
+
if world_size > 1:
|
|
633
|
+
if do_dn:
|
|
634
|
+
all_dn_lists = comm.gather(dn_fit_values_list, root=0)
|
|
635
|
+
if rank == 0:
|
|
636
|
+
dn_fit_values_list = [v for sublist in all_dn_lists for v in sublist]
|
|
637
|
+
if do_photon:
|
|
638
|
+
all_photon_lists = comm.gather(photon_fit_values_list, root=0)
|
|
639
|
+
if rank == 0:
|
|
640
|
+
photon_fit_values_list = [v for sublist in all_photon_lists for v in sublist]
|
|
641
|
+
|
|
642
|
+
# --- Compute statistics (only meaningful on rank 0) ------------------
|
|
643
|
+
dn_fit_results = None
|
|
644
|
+
photon_fit_results = None
|
|
645
|
+
|
|
646
|
+
if rank == 0:
|
|
647
|
+
if do_dn and dn_fit_values_list:
|
|
648
|
+
dn_fit_results = _fit_results(
|
|
649
|
+
"DN", np.stack([v for v, _ in dn_fit_values_list]),
|
|
650
|
+
np.stack([f for _, f in dn_fit_values_list]),
|
|
651
|
+
dn_fit_units, first_dn_signal, rest_wavelength, fit_config)
|
|
652
|
+
|
|
653
|
+
if do_photon and photon_fit_values_list:
|
|
654
|
+
photon_fit_results = _fit_results(
|
|
655
|
+
"Photon", np.stack([v for v, _ in photon_fit_values_list]),
|
|
656
|
+
np.stack([f for _, f in photon_fit_values_list]),
|
|
657
|
+
photon_fit_units, first_photon_signal, rest_wavelength,
|
|
658
|
+
fit_config)
|
|
659
|
+
|
|
660
|
+
return first_dn_signal, dn_fit_results, first_photon_signal, photon_fit_results
|