solarc-eclipse 0.7.0__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. solarc_eclipse-0.8.0/LICENSE +5 -0
  2. solarc_eclipse-0.8.0/PKG-INFO +75 -0
  3. solarc_eclipse-0.8.0/README.md +17 -0
  4. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/__init__.py +1 -1
  5. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/data_processing.py +40 -7
  6. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/main.py +11 -5
  7. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/monte_carlo.py +63 -12
  8. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/radiometric.py +111 -20
  9. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/utils.py +5 -0
  10. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/pyproject.toml +12 -1
  11. solarc_eclipse-0.8.0/solarc_eclipse.egg-info/PKG-INFO +75 -0
  12. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/solarc_eclipse.egg-info/SOURCES.txt +0 -1
  13. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/solarc_eclipse.egg-info/requires.txt +6 -0
  14. solarc_eclipse-0.7.0/LICENSE +0 -1
  15. solarc_eclipse-0.7.0/PKG-INFO +0 -443
  16. solarc_eclipse-0.7.0/README.md +0 -395
  17. solarc_eclipse-0.7.0/euvst_response/data/throughput/source.txt +0 -3
  18. solarc_eclipse-0.7.0/solarc_eclipse.egg-info/PKG-INFO +0 -443
  19. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/MANIFEST.in +0 -0
  20. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/analysis.py +0 -0
  21. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/cli.py +0 -0
  22. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/config.py +0 -0
  23. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/data/throughput/grating_reflection_efficiency.dat +0 -0
  24. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/data/throughput/primary_mirror_coating_reflectance.dat +0 -0
  25. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/data/throughput/throughput_aluminium_1000_angstrom.dat +0 -0
  26. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +0 -0
  27. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/data/throughput/throughput_carbon_1000_angstrom.dat +0 -0
  28. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/extern/__init__.py +0 -0
  29. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/extern/mpfit.py +0 -0
  30. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/fitting.py +0 -0
  31. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/pinhole_diffraction.py +0 -0
  32. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/psf.py +0 -0
  33. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/synthesis.py +0 -0
  34. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/euvst_response/synthesis_cli.py +0 -0
  35. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/setup.cfg +0 -0
  36. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/setup.py +0 -0
  37. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/solarc_eclipse.egg-info/dependency_links.txt +0 -0
  38. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/solarc_eclipse.egg-info/entry_points.txt +0 -0
  39. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/solarc_eclipse.egg-info/not-zip-safe +0 -0
  40. {solarc_eclipse-0.7.0 → solarc_eclipse-0.8.0}/solarc_eclipse.egg-info/top_level.txt +0 -0
@@ -0,0 +1,5 @@
1
+ Copyright (c) James McKevitt
2
+
3
+ Publishing any results produced by this software, or any work derived from it, requires permission from the author. Contact James McKevitt (jm2@mssl.ucl.ac.uk) before publication.
4
+
5
+ The SOLAR-C SWG are also required to approve the publication of forward modelling results for SOLAR-C/EUVST. Contact James McKevitt (jm2@mssl.ucl.ac.uk) for more information.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: solarc-eclipse
3
+ Version: 0.8.0
4
+ Summary: ECLIPSE: Emission Calculation and Line Prediction for SOLAR-C EUVST
5
+ Home-page: https://github.com/jamesmckevitt/eclipse
6
+ Author: James McKevitt
7
+ Author-email: James McKevitt <jm2@mssl.ucl.ac.uk>
8
+ License: Copyright (c) James McKevitt
9
+
10
+ Publishing any results produced by this software, or any work derived from it, requires permission from the author. Contact James McKevitt (jm2@mssl.ucl.ac.uk) before publication.
11
+
12
+ The SOLAR-C SWG are also required to approve the publication of forward modelling results for SOLAR-C/EUVST. Contact James McKevitt (jm2@mssl.ucl.ac.uk) for more information.
13
+ Project-URL: Homepage, https://github.com/jamesmckevitt/eclipse
14
+ Project-URL: Repository, https://github.com/jamesmckevitt/eclipse
15
+ Project-URL: Documentation, https://solarc-eclipse.readthedocs.io
16
+ Classifier: Development Status :: 4 - Beta
17
+ Classifier: Intended Audience :: Science/Research
18
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: numpy
26
+ Requires-Dist: astropy
27
+ Requires-Dist: ndcube
28
+ Requires-Dist: specutils
29
+ Requires-Dist: scipy
30
+ Requires-Dist: matplotlib
31
+ Requires-Dist: joblib
32
+ Requires-Dist: tqdm
33
+ Requires-Dist: dill
34
+ Requires-Dist: pyyaml
35
+ Requires-Dist: reproject
36
+ Requires-Dist: sunpy[all]
37
+ Requires-Dist: dask
38
+ Requires-Dist: psutil
39
+ Requires-Dist: mendeleev
40
+ Requires-Dist: h5py
41
+ Requires-Dist: fiasco
42
+ Provides-Extra: mpi
43
+ Requires-Dist: mpi4py; extra == "mpi"
44
+ Provides-Extra: dev
45
+ Requires-Dist: pytest; extra == "dev"
46
+ Requires-Dist: black; extra == "dev"
47
+ Requires-Dist: flake8; extra == "dev"
48
+ Requires-Dist: mypy; extra == "dev"
49
+ Requires-Dist: ipywidgets; extra == "dev"
50
+ Provides-Extra: docs
51
+ Requires-Dist: mkdocs-material; extra == "docs"
52
+ Requires-Dist: mkdocstrings[python]; extra == "docs"
53
+ Requires-Dist: mkdocs-jupyter; extra == "docs"
54
+ Dynamic: author
55
+ Dynamic: home-page
56
+ Dynamic: license-file
57
+ Dynamic: requires-python
58
+
59
+ # ECLIPSE: Emission Calculation and Line Prediction for SOLAR-C EUVST
60
+
61
+ The ECLIPSE code (Emission Calculation and Line Prediction for SOLAR-C EUVST) is used to forward model the performance of the EUV spectrograph EUVST onboard the SOLAR-C spacecraft.
62
+
63
+ Contact: James McKevitt (jm2@mssl.ucl.ac.uk). See [LICENSE](LICENSE) for usage terms.
64
+
65
+ The instrument response generated by this code will be updated during instrument development, testing, and commissioning.
66
+
67
+ [![Documentation Status](https://readthedocs.org/projects/solarc-eclipse/badge/?version=latest)](https://solarc-eclipse.readthedocs.io/en/latest/)
68
+ [![PyPI](https://img.shields.io/pypi/v/solarc-eclipse.svg)](https://pypi.org/project/solarc-eclipse/)
69
+ [![DOI](https://img.shields.io/badge/DOI-10.5281%2Fzenodo.17543844-blue.svg)](https://doi.org/10.5281/zenodo.17543844)
70
+
71
+ Full documentation - installation, quick start, the synthesis and instrument response guides, and the analysis tutorial - is at **[solarc-eclipse.readthedocs.io](https://solarc-eclipse.readthedocs.io)**.
72
+
73
+ ## Acknowledgements
74
+
75
+ The SOLAR-C/EUVST-SW instrument is an ESA-funded contribution to the JAXA-led SOLAR-C mission. The EUVST-LW (long wavelength) instrument is contributed by NASA. The ECLIPSE code is developed and maintained at Mullard Space Science Laboratory (UCL), and was made using Austrian Super Computing (ASC) infrastructure in collaboration with the University of Vienna.
@@ -0,0 +1,17 @@
1
+ # ECLIPSE: Emission Calculation and Line Prediction for SOLAR-C EUVST
2
+
3
+ The ECLIPSE code (Emission Calculation and Line Prediction for SOLAR-C EUVST) is used to forward model the performance of the EUV spectrograph EUVST onboard the SOLAR-C spacecraft.
4
+
5
+ Contact: James McKevitt (jm2@mssl.ucl.ac.uk). See [LICENSE](LICENSE) for usage terms.
6
+
7
+ The instrument response generated by this code will be updated during instrument development, testing, and commissioning.
8
+
9
+ [![Documentation Status](https://readthedocs.org/projects/solarc-eclipse/badge/?version=latest)](https://solarc-eclipse.readthedocs.io/en/latest/)
10
+ [![PyPI](https://img.shields.io/pypi/v/solarc-eclipse.svg)](https://pypi.org/project/solarc-eclipse/)
11
+ [![DOI](https://img.shields.io/badge/DOI-10.5281%2Fzenodo.17543844-blue.svg)](https://doi.org/10.5281/zenodo.17543844)
12
+
13
+ Full documentation - installation, quick start, the synthesis and instrument response guides, and the analysis tutorial - is at **[solarc-eclipse.readthedocs.io](https://solarc-eclipse.readthedocs.io)**.
14
+
15
+ ## Acknowledgements
16
+
17
+ The SOLAR-C/EUVST-SW instrument is an ESA-funded contribution to the JAXA-led SOLAR-C mission. The EUVST-LW (long wavelength) instrument is contributed by NASA. The ECLIPSE code is developed and maintained at Mullard Space Science Laboratory (UCL), and was made using Austrian Super Computing (ASC) infrastructure in collaboration with the University of Vienna.
@@ -4,7 +4,7 @@ ECLIPSE: Emission Calculation and Line Prediction for SOLAR-C EUVST
4
4
  This package provides tools for modeling the performance of the EUV spectrograph EUVST, on SOLAR-C.
5
5
  """
6
6
 
7
- __version__ = "0.7.0"
7
+ __version__ = "0.8.0"
8
8
  __author__ = "James McKevitt"
9
9
  __email__ = "jm2@mssl.ucl.ac.uk"
10
10
 
@@ -14,7 +14,7 @@ from specutils import Spectrum
14
14
  from specutils.manipulation import FluxConservingResampler
15
15
  from joblib import Parallel, delayed
16
16
  from tqdm import tqdm
17
- from .utils import tqdm_joblib, distance_to_angle
17
+ from .utils import tqdm_joblib, distance_to_angle, _fwhm_to_sigma
18
18
 
19
19
 
20
20
  def _resample_batch(flat_chunk, unit, spectral_world, new_spec_grid, n_spec):
@@ -331,9 +331,11 @@ def create_uniform_intensity_cube(
331
331
  det,
332
332
  sim,
333
333
  n_sigma_extent: float = 8.0,
334
+ n_slit_pixels: int = 1,
335
+ tel=None,
334
336
  ) -> NDCube:
335
337
  """
336
- Create a 1x1 pixel NDCube containing a Gaussian emission line.
338
+ Create a 1 x ``n_slit_pixels`` pixel NDCube containing a Gaussian emission line.
337
339
 
338
340
  The cube is built directly at the detector's spectral resolution and
339
341
  assigned a helioprojective WCS consistent with the output of
@@ -354,14 +356,30 @@ def create_uniform_intensity_cube(
354
356
  Simulation configuration (provides ``slit_width``).
355
357
  n_sigma_extent : float, optional
356
358
  Number of sigma either side of line centre to include in the
357
- wavelength grid (default: 8).
359
+ wavelength grid (default: 8). Measured on the width the line will have
360
+ once the spectral PSF has been applied, if *tel* is given.
361
+ n_slit_pixels : int, optional
362
+ Number of (uniform) slit pixels to generate. Set to the
363
+ ``offchip_bin_slit`` value so that subsequent ``rebin_slit_offchip``
364
+ sums ``n_slit_pixels`` independent noise realisations into a single
365
+ binned pixel (default: 1).
366
+ tel : Telescope_EUVST or Telescope_EIS, optional
367
+ Telescope configuration. When given, the grid is widened to hold the
368
+ line after spectral PSF broadening, adding the PSF width to the thermal
369
+ width in quadrature. Without this a narrow line gets a grid only a
370
+ couple of pixels wide, and convolving it with a PSF wider than the line
371
+ pushes flux off the ends of the grid. Default None, which sizes the
372
+ grid on the thermal width alone.
358
373
 
359
374
  Returns
360
375
  -------
361
376
  NDCube
362
- Shape ``(1, 1, n_lambda)`` with unit ``erg / (s cm2 sr cm)`` and a
377
+ Shape ``(1, n_slit_pixels, n_lambda)`` with unit ``erg / (s cm2 sr cm)`` and a
363
378
  helioprojective + wavelength WCS.
364
379
  """
380
+ if n_slit_pixels < 1:
381
+ raise ValueError(f"n_slit_pixels must be >= 1, got {n_slit_pixels}")
382
+
365
383
  # --- Spectral grid --------------------------------------------------
366
384
  lam0 = rest_wavelength.to(u.cm)
367
385
 
@@ -371,7 +389,19 @@ def create_uniform_intensity_cube(
371
389
  # Detector pixel pitch in cm
372
390
  dlam = det.wvl_res.to(u.cm / u.pix) * u.pix # strip per-pixel to cm
373
391
 
374
- half_range = n_sigma_extent * sigma_lam
392
+ # The grid has to hold the line as it will be *measured*, not as it leaves
393
+ # the Sun, so add the spectral PSF to the thermal width in quadrature.
394
+ # Widths add that way for Gaussians, and the PSF is often the broader of
395
+ # the two: at the default 20 km/s the line is 0.77 pixels against a PSF of
396
+ # 1.08. Always widening, rather than only when psf is set, keeps the grid
397
+ # independent of a value that is swept and is not known when the cube is
398
+ # built and cached.
399
+ sigma_total = sigma_lam
400
+ if tel is not None:
401
+ sigma_psf = _fwhm_to_sigma(tel.psf_params[1].to(u.pixel).value) * dlam
402
+ sigma_total = np.sqrt(sigma_lam**2 + sigma_psf**2)
403
+
404
+ half_range = n_sigma_extent * sigma_total
375
405
  n_pix_half = int(np.ceil((half_range / dlam).decompose().value))
376
406
  n_lam = 2 * n_pix_half + 1 # always odd, centred on rest wavelength
377
407
 
@@ -384,14 +414,17 @@ def create_uniform_intensity_cube(
384
414
  u.erg / (u.s * u.cm**2 * u.sr * u.cm)
385
415
  )
386
416
  profile = A * np.exp(-0.5 * ((lam_grid - lam0) / sigma_lam) ** 2)
387
- data = profile.value[np.newaxis, np.newaxis, :] # shape (1, 1, n_lam)
417
+ # Tile the profile along the slit axis. Every slit pixel holds the same
418
+ # intensity, but each is noised independently downstream, which is what
419
+ # rebin_slit_offchip needs in order to sum them.
420
+ data = np.tile(profile.value, (1, n_slit_pixels, 1)) # shape (1, n_slit_pixels, n_lam)
388
421
 
389
422
  # --- WCS (matches reproject_ndcube output format) --------------------
390
423
  # Axes: WAVE (cm), HPLT-TAN (arcsec), HPLN-TAN (arcsec)
391
424
  wcs = WCS(naxis=3)
392
425
  wcs.wcs.ctype = ["WAVE", "HPLT-TAN", "HPLN-TAN"]
393
426
  wcs.wcs.cunit = ["cm", "arcsec", "arcsec"]
394
- wcs.wcs.crpix = [(n_lam + 1) / 2, 1.0, 1.0]
427
+ wcs.wcs.crpix = [(n_lam + 1) / 2, (n_slit_pixels + 1) / 2, 1.0]
395
428
  wcs.wcs.crval = [lam0.value, 0.0, 0.0]
396
429
  wcs.wcs.cdelt = [
397
430
  dlam.to_value(u.cm),
@@ -432,14 +432,18 @@ def main() -> None:
432
432
  DET = Detector_EIS(**all_det) if all_det else Detector_EIS()
433
433
 
434
434
  # Two-level rebinning cache: rebin_atmosphere does not depend on offchip_bin_slit,
435
- # so cube_reb_cache is keyed by the 3-tuple to avoid redundant rebin calls when
436
- # sweeping multiple binning values at fixed spatial/spectral sampling.
437
- cube_reb_key = (
435
+ # so cube_reb_cache is keyed by the sampling alone to avoid redundant rebin calls
436
+ # when sweeping multiple binning values at fixed spatial/spectral sampling.
437
+ sampling_key = (
438
438
  slit_width.to_value(u.arcsec),
439
439
  DET.plate_scale_angle.to_value(u.arcsec / u.pixel),
440
440
  DET.wvl_res.to_value(u.cm / u.pixel),
441
441
  )
442
- rebin_cache_key = (*cube_reb_key, offchip_bin_slit)
442
+ # In uniform-intensity mode the cube is built with one slit pixel per binning
443
+ # factor, so that rebin_slit_offchip has independent noise realisations to sum.
444
+ # The cube therefore does depend on offchip_bin_slit, and the key must say so.
445
+ cube_reb_key = (*sampling_key, offchip_bin_slit) if uniform_intensity_mode else sampling_key
446
+ rebin_cache_key = (*sampling_key, offchip_bin_slit)
443
447
 
444
448
  if cube_reb_key not in cube_reb_cache:
445
449
  print(
@@ -463,6 +467,8 @@ def main() -> None:
463
467
  thermal_width=uniform_thermal_width,
464
468
  det=DET,
465
469
  sim=SIM_rebin,
470
+ n_slit_pixels=offchip_bin_slit,
471
+ tel=TEL,
466
472
  )
467
473
  else:
468
474
  cube_reb_cache[cube_reb_key] = rebin_atmosphere(cube_sim, DET, SIM_rebin)
@@ -479,7 +485,7 @@ def main() -> None:
479
485
  # Key by (slit_width_arcsec, offchip_bin_slit) so that sweeps over
480
486
  # multiple binning factors at fixed slit width all retain their cubes
481
487
  # (a single-key dict would silently keep only the first one).
482
- cube_reb_dict.setdefault((cube_reb_key[0], offchip_bin_slit), cube_reb_binned)
488
+ cube_reb_dict.setdefault((sampling_key[0], offchip_bin_slit), cube_reb_binned)
483
489
 
484
490
  cube_reb_binned, fit_truth_data, fit_truth_units = rebin_cache[rebin_cache_key]
485
491
 
@@ -18,7 +18,17 @@ from .fitting import fit_cube_gauss
18
18
  from .utils import angle_to_distance, rebin_slit_offchip, _get_mpi_info
19
19
 
20
20
 
21
- def simulate_once(I_cube: NDCube, t_exp: u.Quantity, det, tel, sim) -> Tuple[NDCube, ...]:
21
+ def simulate_once(
22
+ I_cube: NDCube,
23
+ t_exp: u.Quantity,
24
+ det,
25
+ tel,
26
+ sim,
27
+ *,
28
+ uniform_mode: bool = False,
29
+ photon_shot_inverse_transform: bool = False,
30
+ dark_current_inverse_transform: bool = False,
31
+ ) -> Tuple[NDCube, ...]:
22
32
  """
23
33
  Run a single Monte Carlo simulation of the instrument response.
24
34
 
@@ -34,6 +44,18 @@ def simulate_once(I_cube: NDCube, t_exp: u.Quantity, det, tel, sim) -> Tuple[NDC
34
44
  Telescope configuration
35
45
  sim : Simulation
36
46
  Simulation configuration
47
+ uniform_mode : bool, optional
48
+ If True the input cube is uniform along the slit, so the PSF is
49
+ convolved in the spectral direction only. See
50
+ :func:`~euvst_response.radiometric.apply_focusing_optics_psf`.
51
+ Default False.
52
+ photon_shot_inverse_transform : bool, optional
53
+ Use inverse-transform Poisson sampling for photon shot noise, so that
54
+ common random numbers survive a change in photon flux. Default False.
55
+ dark_current_inverse_transform : bool, optional
56
+ Use inverse-transform Poisson sampling for dark-current shot noise, so
57
+ that common random numbers survive a change in dark-current level.
58
+ Default False.
37
59
 
38
60
  Returns
39
61
  -------
@@ -57,7 +79,9 @@ def simulate_once(I_cube: NDCube, t_exp: u.Quantity, det, tel, sim) -> Tuple[NDC
57
79
 
58
80
  # Apply focusing optics PSF (primary mirror + diffraction grating)
59
81
  if sim.psf:
60
- photons_focused = apply_focusing_optics_psf(photons_pixels, tel)
82
+ photons_focused = apply_focusing_optics_psf(
83
+ photons_pixels, tel, convolve_spatial=not uniform_mode
84
+ )
61
85
  else:
62
86
  photons_focused = photons_pixels
63
87
 
@@ -68,10 +92,16 @@ def simulate_once(I_cube: NDCube, t_exp: u.Quantity, det, tel, sim) -> Tuple[NDC
68
92
  photons_euv_pinholes = photons_focused
69
93
 
70
94
  # Sample discrete photon arrivals (photon shot noise)
71
- photon_arrivals = sample_photon_arrivals(photons_euv_pinholes)
95
+ photon_arrivals = sample_photon_arrivals(
96
+ photons_euv_pinholes,
97
+ photon_shot_inverse_transform=photon_shot_inverse_transform,
98
+ )
72
99
 
73
100
  # Convert to electrons (detector response: QE, Fano noise, dark current, read noise)
74
- electrons = to_electrons(photon_arrivals, t_exp, det)
101
+ electrons = to_electrons(
102
+ photon_arrivals, t_exp, det,
103
+ dark_current_inverse_transform=dark_current_inverse_transform,
104
+ )
75
105
 
76
106
  # Add visible stray light (with filter throughput)
77
107
  electrons_stray = add_visible_stray_light(electrons, t_exp, det, sim, tel)
@@ -92,7 +122,10 @@ def simulate_once(I_cube: NDCube, t_exp: u.Quantity, det, tel, sim) -> Tuple[NDC
92
122
 
93
123
  def monte_carlo(I_cube: NDCube, t_exp: u.Quantity, det, tel, sim, n_iter: int = 5,
94
124
  fit_config=None, offchip_bin_slit: int = 1,
95
- fit_signals: str = "both", uniform_mode: bool = False) -> Tuple[NDCube, dict | None, NDCube, dict | None]:
125
+ fit_signals: str = "both", uniform_mode: bool = False,
126
+ *,
127
+ photon_shot_inverse_transform: bool = False,
128
+ dark_current_inverse_transform: bool = False) -> Tuple[NDCube, dict | None, NDCube, dict | None]:
96
129
  """
97
130
  Run Monte Carlo simulations and fit results.
98
131
 
@@ -121,11 +154,19 @@ def monte_carlo(I_cube: NDCube, t_exp: u.Quantity, det, tel, sim, n_iter: int =
121
154
  ``"photon"``. Fitting is the most expensive step, so
122
155
  selecting only the signal of interest roughly halves runtime.
123
156
  uniform_mode : bool, optional
124
- If True the input cube is assumed to be a single 1x1 spatial pixel
125
- (uniform-intensity mode). All MC simulations are run first and
126
- the resulting spectra are stacked so that fitting is parallelised
127
- over the n_iter iterations rather than over the spatial dimension.
128
- Default: False.
157
+ If True the input cube is taken to be uniform-intensity mode: one scan
158
+ position and ``offchip_bin_slit`` identical slit pixels, which
159
+ *offchip_bin_slit* then sums back down to a single spatial pixel. All
160
+ MC simulations are run first and the resulting spectra are stacked so
161
+ that fitting is parallelised over the n_iter iterations rather than
162
+ over the spatial dimension. Default: False.
163
+ photon_shot_inverse_transform : bool, optional
164
+ Use inverse-transform Poisson sampling for photon shot noise, so that
165
+ common random numbers survive a change in photon flux. Default False.
166
+ dark_current_inverse_transform : bool, optional
167
+ Use inverse-transform Poisson sampling for dark-current shot noise, so
168
+ that common random numbers survive a change in dark-current level.
169
+ Default False.
129
170
 
130
171
  Returns
131
172
  -------
@@ -165,7 +206,12 @@ def monte_carlo(I_cube: NDCube, t_exp: u.Quantity, det, tel, sim, n_iter: int =
165
206
  disable=not show_progress):
166
207
  (intensity_exp, photons_total, photons_throughput, photons_pixels,
167
208
  photons_focused, photon_arrivals, electrons, electrons_stray,
168
- electrons_pinholes, dn) = simulate_once(I_cube, t_exp, det, tel, sim)
209
+ electrons_pinholes, dn) = simulate_once(
210
+ I_cube, t_exp, det, tel, sim,
211
+ uniform_mode=uniform_mode,
212
+ photon_shot_inverse_transform=photon_shot_inverse_transform,
213
+ dark_current_inverse_transform=dark_current_inverse_transform,
214
+ )
169
215
 
170
216
  if i == 0 and rank == 0:
171
217
  first_dn_signal = rebin_slit_offchip(dn, offchip_bin_slit)
@@ -244,7 +290,12 @@ def monte_carlo(I_cube: NDCube, t_exp: u.Quantity, det, tel, sim, n_iter: int =
244
290
  # Simulate one run
245
291
  (intensity_exp, photons_total, photons_throughput, photons_pixels,
246
292
  photons_focused, photon_arrivals, electrons, electrons_stray,
247
- electrons_pinholes, dn) = simulate_once(I_cube, t_exp, det, tel, sim)
293
+ electrons_pinholes, dn) = simulate_once(
294
+ I_cube, t_exp, det, tel, sim,
295
+ uniform_mode=uniform_mode,
296
+ photon_shot_inverse_transform=photon_shot_inverse_transform,
297
+ dark_current_inverse_transform=dark_current_inverse_transform,
298
+ )
248
299
 
249
300
  # Store first iteration signals only on rank 0 (binned, to match fit shapes)
250
301
  if i == 0 and rank == 0:
@@ -7,8 +7,46 @@ import numpy as np
7
7
  import astropy.units as u
8
8
  import astropy.constants as const
9
9
  from ndcube import NDCube
10
+ from scipy.ndimage import convolve1d
10
11
  from scipy.signal import convolve2d
11
- from .utils import wl_to_vel, vel_to_wl, debug_break
12
+ from scipy.stats import poisson
13
+ from .utils import wl_to_vel, vel_to_wl, debug_break, _fwhm_to_sigma
14
+
15
+
16
+ def _poisson_inverse_transform(mean_counts, size=None) -> np.ndarray:
17
+ """
18
+ Draw Poisson counts by inverse-transform (quantile) sampling.
19
+
20
+ One uniform draw per element is passed through the Poisson inverse-CDF.
21
+ The marginal distribution is identical to ``np.random.poisson``, but this
22
+ consumes a fixed number of RNG draws per element, where the rejection
23
+ sampling in ``np.random.poisson`` consumes a variable number. That keeps
24
+ the random stream synchronised across runs whose only difference is the
25
+ Poisson mean, which is what makes common-random-number variance reduction
26
+ possible.
27
+
28
+ Parameters
29
+ ----------
30
+ mean_counts : float or np.ndarray
31
+ Poisson mean, either scalar or per element.
32
+ size : tuple of int, optional
33
+ Shape to draw. Defaults to the shape of *mean_counts*.
34
+
35
+ Returns
36
+ -------
37
+ np.ndarray
38
+ Sampled counts as int64.
39
+ """
40
+ if size is None:
41
+ size = np.shape(mean_counts)
42
+
43
+ u_draw = np.random.random(size=size)
44
+ # np.random.random() draws from [0, 1) and scipy's ppf returns -1 at
45
+ # exactly 0, which would give a negative count. Clamp to the smallest
46
+ # positive double, which leaves the CRN property intact.
47
+ np.maximum(u_draw, np.nextafter(0.0, 1.0), out=u_draw)
48
+
49
+ return poisson.ppf(u_draw, mean_counts).astype(np.int64)
12
50
 
13
51
 
14
52
  def _vectorized_fano_noise(photon_counts: np.ndarray, rest_wavelength: u.Quantity, det) -> np.ndarray:
@@ -126,12 +164,12 @@ def photons_to_pixel_counts(ph_flux: NDCube, wl_pitch: u.Quantity, plate_scale:
126
164
  )
127
165
 
128
166
 
129
- def _fwhm_to_sigma(fwhm: float) -> float:
130
- """Convert FWHM to Gaussian sigma: sigma = FWHM / (2 * sqrt(2 * ln2))."""
131
- return fwhm / (2.0 * np.sqrt(2.0 * np.log(2.0)))
132
-
133
-
134
- def apply_focusing_optics_psf(signal: NDCube, tel) -> NDCube:
167
+ def apply_focusing_optics_psf(
168
+ signal: NDCube,
169
+ tel,
170
+ *,
171
+ convolve_spatial: bool = True,
172
+ ) -> NDCube:
135
173
  """
136
174
  Convolve each detector frame (n_slit, n_lambda) of an NDCube with an
137
175
  anisotropic 2-D PSF from the focusing optics.
@@ -147,6 +185,14 @@ def apply_focusing_optics_psf(signal: NDCube, tel) -> NDCube:
147
185
  tel : Telescope_EUVST or Telescope_EIS
148
186
  Telescope configuration containing PSF parameters.
149
187
  psf_params = [spatial_fwhm, spectral_fwhm] in pixel units.
188
+ convolve_spatial : bool, optional
189
+ When False, convolve the spectral axis only and leave the slit axis
190
+ alone. This is for a field that is uniform along the slit, where
191
+ convolving a constant with a normalised kernel returns the same
192
+ constant and the spatial pass is an identity operation. Doing it
193
+ anyway would not be harmless: the convolution treats everything
194
+ outside the array as dark, so a uniform field would lose flux off the
195
+ ends of the slit that it really does have. Default True.
150
196
 
151
197
  Returns
152
198
  -------
@@ -160,6 +206,11 @@ def apply_focusing_optics_psf(signal: NDCube, tel) -> NDCube:
160
206
  psf_type = tel.psf_type.lower()
161
207
  psf_params = tel.psf_params
162
208
 
209
+ if psf_type != "gaussian":
210
+ raise ValueError(
211
+ f"Unsupported PSF type: {psf_type}. Supported: 'gaussian'."
212
+ )
213
+
163
214
  if len(psf_params) < 2:
164
215
  raise ValueError(
165
216
  "psf_params must contain two elements: "
@@ -182,6 +233,20 @@ def apply_focusing_optics_psf(signal: NDCube, tel) -> NDCube:
182
233
  if kx % 2 == 0:
183
234
  kx += 1
184
235
 
236
+ if not convolve_spatial:
237
+ # Spectral axis only. Zero padding is correct here: the wavelength
238
+ # grid extends several sigma past the line, so there is no flux at its
239
+ # edges to lose.
240
+ x_1d = np.arange(kx) - kx // 2
241
+ psf_1d = np.exp(-0.5 * (x_1d / sigma_spectral) ** 2)
242
+ psf_1d /= psf_1d.sum()
243
+ return NDCube(
244
+ data=convolve1d(data_in, psf_1d, axis=2, mode="constant", cval=0.0),
245
+ wcs=signal.wcs.deepcopy(),
246
+ unit=unit,
247
+ meta=signal.meta,
248
+ )
249
+
185
250
  # Coordinate grids centred at zero
186
251
  cy, cx = ky // 2, kx // 2
187
252
  y, x = np.mgrid[:ky, :kx]
@@ -189,13 +254,8 @@ def apply_focusing_optics_psf(signal: NDCube, tel) -> NDCube:
189
254
  x = (x - cx).astype(float)
190
255
 
191
256
  # Build PSF
192
- if psf_type == "gaussian":
193
- psf = np.exp(-0.5 * ((y / sigma_spatial) ** 2
194
- + (x / sigma_spectral) ** 2))
195
- else:
196
- raise ValueError(
197
- f"Unsupported PSF type: {psf_type}. Supported: 'gaussian'."
198
- )
257
+ psf = np.exp(-0.5 * ((y / sigma_spatial) ** 2
258
+ + (x / sigma_spectral) ** 2))
199
259
 
200
260
  # Normalise
201
261
  psf /= psf.sum()
@@ -213,7 +273,13 @@ def apply_focusing_optics_psf(signal: NDCube, tel) -> NDCube:
213
273
  )
214
274
 
215
275
 
216
- def to_electrons(photon_counts: NDCube, t_exp: u.Quantity, det) -> NDCube:
276
+ def to_electrons(
277
+ photon_counts: NDCube,
278
+ t_exp: u.Quantity,
279
+ det,
280
+ *,
281
+ dark_current_inverse_transform: bool = False,
282
+ ) -> NDCube:
217
283
  """
218
284
  Convert a photon-count NDCube to an electron-count NDCube.
219
285
 
@@ -225,6 +291,12 @@ def to_electrons(photon_counts: NDCube, t_exp: u.Quantity, det) -> NDCube:
225
291
  Exposure time (used for dark current and read noise).
226
292
  det : Detector_SWC or Detector_EIS
227
293
  Detector description.
294
+ dark_current_inverse_transform : bool, optional
295
+ When True, draw dark-current shot noise with
296
+ :func:`_poisson_inverse_transform` rather than ``np.random.poisson``.
297
+ The distribution is unchanged, but the random stream stays synchronised
298
+ across runs that differ only in dark-current level, which is what
299
+ common-random-number variance reduction needs. Default False.
228
300
 
229
301
  Returns
230
302
  -------
@@ -242,10 +314,16 @@ def to_electrons(photon_counts: NDCube, t_exp: u.Quantity, det) -> NDCube:
242
314
 
243
315
  e = electron_counts * (u.electron / u.pixel)
244
316
 
245
- # Add dark current with Poisson noise (per pixel)
317
+ # Add dark current with Poisson shot noise (per pixel)
246
318
  dark_current_mean = (det.dark_current * t_exp).to(u.electron / u.pixel).value
247
- dark_current_poisson = np.random.poisson(dark_current_mean, size=photon_counts.data.shape) * (u.electron / u.pixel)
248
- e += dark_current_poisson
319
+ if dark_current_inverse_transform:
320
+ dark_current_counts = _poisson_inverse_transform(
321
+ dark_current_mean, size=photon_counts.data.shape
322
+ )
323
+ else:
324
+ dark_current_counts = np.random.poisson(dark_current_mean, size=photon_counts.data.shape)
325
+ dark_current_signal = dark_current_counts * (u.electron / u.pixel)
326
+ e += dark_current_signal
249
327
 
250
328
  # Add read noise
251
329
  e += np.random.normal(0, det.read_noise_rms.value,
@@ -317,7 +395,11 @@ def add_poisson(cube: NDCube) -> NDCube:
317
395
  )
318
396
 
319
397
 
320
- def sample_photon_arrivals(photon_counts: NDCube) -> NDCube:
398
+ def sample_photon_arrivals(
399
+ photon_counts: NDCube,
400
+ *,
401
+ photon_shot_inverse_transform: bool = False,
402
+ ) -> NDCube:
321
403
  """
322
404
  Sample a discrete Poisson realisation of photon arrivals per pixel.
323
405
 
@@ -331,6 +413,12 @@ def sample_photon_arrivals(photon_counts: NDCube) -> NDCube:
331
413
  ----------
332
414
  photon_counts : NDCube
333
415
  Expected (mean) photon counts per pixel.
416
+ photon_shot_inverse_transform : bool, optional
417
+ When True, draw photon shot noise with
418
+ :func:`_poisson_inverse_transform` rather than ``np.random.poisson``.
419
+ The distribution is unchanged, but the random stream stays synchronised
420
+ across runs that differ only in photon flux, which is what
421
+ common-random-number variance reduction needs. Default False.
334
422
 
335
423
  Returns
336
424
  -------
@@ -344,7 +432,10 @@ def sample_photon_arrivals(photon_counts: NDCube) -> NDCube:
344
432
  mean_counts = q.to(canonical_units).value
345
433
  mean_counts = np.maximum(mean_counts, 0)
346
434
 
347
- sampled = np.random.poisson(mean_counts)
435
+ if photon_shot_inverse_transform:
436
+ sampled = _poisson_inverse_transform(mean_counts)
437
+ else:
438
+ sampled = np.random.poisson(mean_counts)
348
439
 
349
440
  return NDCube(
350
441
  data=sampled.astype(np.int64),
@@ -457,3 +457,8 @@ def tqdm_joblib(tqdm_object):
457
457
  finally:
458
458
  joblib.parallel.BatchCompletionCallBack = old_callback
459
459
  tqdm_object.close()
460
+
461
+
462
+ def _fwhm_to_sigma(fwhm: float) -> float:
463
+ """Convert FWHM to Gaussian sigma: sigma = FWHM / (2 * sqrt(2 * ln2))."""
464
+ return fwhm / (2.0 * np.sqrt(2.0 * np.log(2.0)))
@@ -10,7 +10,7 @@ authors = [
10
10
  ]
11
11
  description = "ECLIPSE: Emission Calculation and Line Prediction for SOLAR-C EUVST"
12
12
  readme = "README.md"
13
- license = {text = "Contact for permission"}
13
+ license = {file = "LICENSE"}
14
14
  requires-python = ">=3.10"
15
15
  classifiers = [
16
16
  "Development Status :: 4 - Beta",
@@ -49,6 +49,16 @@ dev = [
49
49
  "black",
50
50
  "flake8",
51
51
  "mypy",
52
+ # Needed when re-running the tutorial notebook, not when building the docs
53
+ # (mkdocs-jupyter renders the stored outputs without executing anything).
54
+ # Without it tqdm emits a TqdmWarning, which gets saved into the notebook
55
+ # outputs and then shows up as an error block on the docs site.
56
+ "ipywidgets",
57
+ ]
58
+ docs = [
59
+ "mkdocs-material",
60
+ "mkdocstrings[python]",
61
+ "mkdocs-jupyter",
52
62
  ]
53
63
 
54
64
  [project.scripts]
@@ -60,6 +70,7 @@ synthesise_spectra = "euvst_response.synthesis_cli:main"
60
70
  [project.urls]
61
71
  Homepage = "https://github.com/jamesmckevitt/eclipse"
62
72
  Repository = "https://github.com/jamesmckevitt/eclipse"
73
+ Documentation = "https://solarc-eclipse.readthedocs.io"
63
74
 
64
75
  [tool.setuptools.packages.find]
65
76
  include = ["euvst_response*"]