eclipse 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. eclipse/__init__.py +95 -0
  2. eclipse/analysis.py +853 -0
  3. eclipse/atmosphere.py +859 -0
  4. eclipse/band.py +477 -0
  5. eclipse/cli.py +138 -0
  6. eclipse/config.py +1710 -0
  7. eclipse/continuum.py +413 -0
  8. eclipse/data/eis_response/EIS_EffArea_A.004 +61 -0
  9. eclipse/data/eis_response/EIS_EffArea_B.004 +60 -0
  10. eclipse/data/eis_response/EIS_EffArea_B.005 +64 -0
  11. eclipse/data/eis_response/README_dz2025 +50 -0
  12. eclipse/data/eis_response/fit_eis_ea_2023-05-04_smooth.sav +0 -0
  13. eclipse/data/eis_response/source.txt +33 -0
  14. eclipse/data/science_cases.yaml +365 -0
  15. eclipse/data/throughput/grating_reflection_efficiency.dat +25 -0
  16. eclipse/data/throughput/primary_mirror_coating_reflectance.dat +25 -0
  17. eclipse/data/throughput/source.txt +6 -0
  18. eclipse/data/throughput/throughput_aluminium_1000_angstrom.dat +503 -0
  19. eclipse/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +503 -0
  20. eclipse/data/throughput/throughput_carbon_1000_angstrom.dat +503 -0
  21. eclipse/data_processing.py +904 -0
  22. eclipse/deprecated.py +161 -0
  23. eclipse/eis_calibration.py +573 -0
  24. eclipse/extern/__init__.py +0 -0
  25. eclipse/extern/mpfit.py +2388 -0
  26. eclipse/fitting.py +1734 -0
  27. eclipse/frame.py +594 -0
  28. eclipse/full_frame.py +944 -0
  29. eclipse/main.py +1646 -0
  30. eclipse/monte_carlo.py +660 -0
  31. eclipse/pinhole_diffraction.py +409 -0
  32. eclipse/psf.py +6 -0
  33. eclipse/radiometric.py +1328 -0
  34. eclipse/raster.py +869 -0
  35. eclipse/readout.py +1002 -0
  36. eclipse/resolution_budget.py +108 -0
  37. eclipse/results_file.py +1226 -0
  38. eclipse/sampling.py +351 -0
  39. eclipse/science_cases.py +364 -0
  40. eclipse/synthesis.py +2492 -0
  41. eclipse/synthesis_cli.py +12 -0
  42. eclipse/synthesis_file.py +882 -0
  43. eclipse/utils.py +1145 -0
  44. eclipse-0.13.0.dist-info/METADATA +70 -0
  45. eclipse-0.13.0.dist-info/RECORD +50 -0
  46. eclipse-0.13.0.dist-info/WHEEL +5 -0
  47. eclipse-0.13.0.dist-info/entry_points.txt +7 -0
  48. eclipse-0.13.0.dist-info/licenses/LICENSE +3 -0
  49. eclipse-0.13.0.dist-info/top_level.txt +2 -0
  50. euvst_response/__init__.py +129 -0
eclipse/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