solarc-eclipse 0.6.2__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 (42) 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.6.2 → solarc_eclipse-0.8.0}/euvst_response/__init__.py +5 -4
  5. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/analysis.py +66 -27
  6. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/data_processing.py +57 -17
  7. solarc_eclipse-0.8.0/euvst_response/extern/__init__.py +0 -0
  8. solarc_eclipse-0.8.0/euvst_response/extern/mpfit.py +2388 -0
  9. solarc_eclipse-0.8.0/euvst_response/fitting.py +639 -0
  10. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/main.py +240 -83
  11. solarc_eclipse-0.8.0/euvst_response/monte_carlo.py +350 -0
  12. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/radiometric.py +160 -38
  13. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/synthesis.py +182 -56
  14. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/utils.py +118 -14
  15. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/pyproject.toml +21 -4
  16. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/setup.py +2 -27
  17. solarc_eclipse-0.8.0/solarc_eclipse.egg-info/PKG-INFO +75 -0
  18. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/solarc_eclipse.egg-info/SOURCES.txt +2 -1
  19. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/solarc_eclipse.egg-info/requires.txt +14 -0
  20. solarc_eclipse-0.6.2/LICENSE +0 -1
  21. solarc_eclipse-0.6.2/PKG-INFO +0 -412
  22. solarc_eclipse-0.6.2/README.md +0 -369
  23. solarc_eclipse-0.6.2/euvst_response/data/throughput/source.txt +0 -3
  24. solarc_eclipse-0.6.2/euvst_response/fitting.py +0 -144
  25. solarc_eclipse-0.6.2/euvst_response/monte_carlo.py +0 -207
  26. solarc_eclipse-0.6.2/solarc_eclipse.egg-info/PKG-INFO +0 -412
  27. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/MANIFEST.in +0 -0
  28. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/cli.py +0 -0
  29. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/config.py +0 -0
  30. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/data/throughput/grating_reflection_efficiency.dat +0 -0
  31. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/data/throughput/primary_mirror_coating_reflectance.dat +0 -0
  32. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/data/throughput/throughput_aluminium_1000_angstrom.dat +0 -0
  33. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +0 -0
  34. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/data/throughput/throughput_carbon_1000_angstrom.dat +0 -0
  35. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/pinhole_diffraction.py +0 -0
  36. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/psf.py +0 -0
  37. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/euvst_response/synthesis_cli.py +0 -0
  38. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/setup.cfg +0 -0
  39. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/solarc_eclipse.egg-info/dependency_links.txt +0 -0
  40. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/solarc_eclipse.egg-info/entry_points.txt +0 -0
  41. {solarc_eclipse-0.6.2 → solarc_eclipse-0.8.0}/solarc_eclipse.egg-info/not-zip-safe +0 -0
  42. {solarc_eclipse-0.6.2 → 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.6.2"
7
+ __version__ = "0.8.0"
8
8
  __author__ = "James McKevitt"
9
9
  __email__ = "jm2@mssl.ucl.ac.uk"
10
10
 
@@ -13,11 +13,11 @@ from .config import Detector_SWC, Detector_EIS, Telescope_EUVST, Telescope_EIS,
13
13
  from .utils import wl_to_vel, vel_to_wl, angle_to_distance, distance_to_angle
14
14
  from .radiometric import (
15
15
  intensity_to_photons, add_telescope_throughput, photons_to_pixel_counts,
16
- apply_exposure_and_poisson, add_poisson, apply_focusing_optics_psf, to_electrons,
16
+ apply_exposure, sample_photon_arrivals, add_poisson, apply_focusing_optics_psf, to_electrons,
17
17
  to_dn, add_visible_stray_light, add_pinhole_visible_light
18
18
  )
19
19
  from .pinhole_diffraction import apply_euv_pinhole_diffraction, airy_disk_pattern
20
- from .fitting import fit_cube_gauss, velocity_from_fit, width_from_fit, analyse
20
+ from .fitting import fit_cube_gauss, velocity_from_fit, width_from_fit, analyse, FitConfig, FitComponent
21
21
  from .monte_carlo import simulate_once, monte_carlo
22
22
  from .main import main
23
23
  from .data_processing import load_atmosphere, create_uniform_intensity_cube
@@ -36,9 +36,10 @@ __all__ = [
36
36
  "Simulation", "AluminiumFilter",
37
37
  "wl_to_vel", "vel_to_wl", "angle_to_distance", "distance_to_angle",
38
38
  "intensity_to_photons", "add_telescope_throughput", "photons_to_pixel_counts",
39
- "apply_exposure_and_poisson", "add_poisson", "apply_focusing_optics_psf", "to_electrons",
39
+ "apply_exposure", "sample_photon_arrivals", "add_poisson", "apply_focusing_optics_psf", "to_electrons",
40
40
  "to_dn", "add_visible_stray_light", "add_pinhole_visible_light",
41
41
  "fit_cube_gauss", "velocity_from_fit", "width_from_fit", "analyse",
42
+ "FitConfig", "FitComponent",
42
43
  "simulate_once", "monte_carlo",
43
44
  "main",
44
45
  "load_atmosphere",
@@ -119,7 +119,8 @@ def get_parameter_combinations(results: Dict[str, Any]) -> List[Dict]:
119
119
  def analyse_fit_statistics(
120
120
  combination_results: Dict[str, Any],
121
121
  rest_wavelength: u.Quantity,
122
- data_type: str = "dn"
122
+ data_type: str = "dn",
123
+ fit_config=None,
123
124
  ) -> Dict[str, Any]:
124
125
  """
125
126
  Analyze fit statistics to compute velocity and line width statistics.
@@ -132,35 +133,52 @@ def analyse_fit_statistics(
132
133
  Rest wavelength for velocity conversion.
133
134
  data_type : str, optional
134
135
  Either "dn" or "photon" to specify which fit statistics to analyze.
136
+ fit_config : FitConfig, optional
137
+ Multi-component fitting configuration. When provided the primary-
138
+ component indices are used; otherwise indices 1 (centre) and
139
+ 2 (sigma) are assumed (single-component default).
135
140
 
136
141
  Returns
137
142
  -------
138
143
  dict
139
144
  Dictionary containing velocity and width statistics.
140
145
  """
146
+ # Determine parameter indices for the primary component
147
+ if fit_config is not None and not fit_config.is_single:
148
+ idx_center = fit_config.idx_center
149
+ idx_sigma = fit_config.idx_sigma
150
+ else:
151
+ idx_center = 1
152
+ idx_sigma = 2
153
+
141
154
  # Get fit statistics
142
155
  fit_stats_key = f"{data_type}_fit_stats"
143
156
  if fit_stats_key not in combination_results:
144
157
  raise ValueError(f"No {fit_stats_key} found in combination results")
145
158
 
146
159
  fit_stats = combination_results[fit_stats_key]
160
+ if fit_stats is None:
161
+ raise ValueError(
162
+ f"'{data_type}' signal was not fitted for this combination. "
163
+ f"Check the 'fit_signals' setting in your YAML config."
164
+ )
147
165
  fit_truth_data = combination_results["ground_truth"]["fit_truth_data"]
148
166
  fit_truth_units = combination_results["ground_truth"]["fit_truth_units"]
149
167
 
150
168
  # Extract data and units
151
- mean_data = fit_stats["mean_data"] # Shape: (nx, ny, 4)
152
- std_data = fit_stats["std_data"] # Shape: (nx, ny, 4)
153
- units = fit_stats["units"] # List of 4 astropy units
169
+ mean_data = fit_stats["mean_data"] # Shape: (nx, ny, n_params)
170
+ std_data = fit_stats["std_data"] # Shape: (nx, ny, n_params)
171
+ units = fit_stats["units"] # List of n_params astropy units
154
172
 
155
- # Get center statistics (parameter index 1)
156
- center_mean_data = mean_data[..., 1] # (nx, ny) - values only
157
- center_std_data = std_data[..., 1] # (nx, ny) - values only
158
- center_unit = units[1] # wavelength unit
173
+ # Get center statistics for the primary component
174
+ center_mean_data = mean_data[..., idx_center]
175
+ center_std_data = std_data[..., idx_center]
176
+ center_unit = units[idx_center]
159
177
 
160
- # Get width statistics (parameter index 2)
161
- width_mean_data = mean_data[..., 2] # (nx, ny) - values only
162
- width_std_data = std_data[..., 2] # (nx, ny) - values only
163
- width_unit = units[2] # wavelength unit
178
+ # Get width statistics for the primary component
179
+ width_mean_data = mean_data[..., idx_sigma]
180
+ width_std_data = std_data[..., idx_sigma]
181
+ width_unit = units[idx_sigma]
164
182
 
165
183
  # Create quantities
166
184
  center_mean_q = center_mean_data * center_unit
@@ -177,7 +195,7 @@ def analyse_fit_statistics(
177
195
 
178
196
  # Convert to velocities
179
197
  v_mean = centers_to_velocity(center_mean_q, rest_wavelength)
180
- v_true = centers_to_velocity(fit_truth_data[..., 1] * fit_truth_units[1], rest_wavelength)
198
+ v_true = centers_to_velocity(fit_truth_data[..., idx_center] * fit_truth_units[idx_center], rest_wavelength)
181
199
  v_err = v_true - v_mean
182
200
 
183
201
  # Convert center std to velocity std using differential: dv/dlambda = c/lambda
@@ -377,11 +395,12 @@ def summary_table(results: Dict[str, Any]) -> None:
377
395
 
378
396
  def create_sunpy_maps_from_combo(
379
397
  combination_results: Dict[str, Any],
380
- cube_reb,
398
+ cube_reb=None,
381
399
  rest_wavelength: u.Quantity = 195.119 * u.AA,
382
400
  data_type: str = "dn",
383
401
  precision_requirement: u.Quantity = 2.0 * u.km / u.s,
384
- exposure_time_results: List[Dict[str, Any]] | None = None
402
+ exposure_time_results: List[Dict[str, Any]] | None = None,
403
+ fit_config=None,
385
404
  ) -> Dict[str, Any]:
386
405
  """
387
406
  Create SunPy maps from combination results using the new fit statistics structure.
@@ -390,8 +409,9 @@ def create_sunpy_maps_from_combo(
390
409
  ----------
391
410
  combination_results : dict
392
411
  Results for a specific parameter combination from get_results_for_combination().
393
- cube_reb : NDCube
412
+ cube_reb : NDCube, optional
394
413
  NDCube with helioprojective WCS to use for all maps.
414
+ If not provided, the WCS stored in the combination results is used.
395
415
  rest_wavelength : u.Quantity, optional
396
416
  Rest wavelength for velocity conversion (default: 195.119 A for Fe XII).
397
417
  data_type : str, optional
@@ -401,6 +421,9 @@ def create_sunpy_maps_from_combo(
401
421
  exposure_time_results : list of dict, optional
402
422
  List of results from get_results_for_combination() for different exposure times.
403
423
  If provided, will create an exposure time map showing minimum exposure needed.
424
+ fit_config : FitConfig, optional
425
+ Multi-component fitting configuration. When provided, the primary-
426
+ component indices are used to extract centre and width parameters.
404
427
 
405
428
  Returns
406
429
  -------
@@ -426,19 +449,27 @@ def create_sunpy_maps_from_combo(
426
449
  # Extract exposure time from parameters
427
450
  exposure_time = result["parameters"]["simulation.expos"].to_value(u.s)
428
451
  # Create analysis for this exposure
429
- analysis = analyse_fit_statistics(result, rest_wavelength, data_type)
452
+ analysis = analyse_fit_statistics(result, rest_wavelength, data_type, fit_config=fit_config)
430
453
  analysis_per_exp[exposure_time] = analysis
431
454
  else:
432
455
  analysis_per_exp = None
433
456
 
434
- # Extract 2D helioprojective WCS from the cube
435
- wcs_2d = cube_reb.wcs.celestial.swapaxes(0, 1)
457
+ # Extract 2D helioprojective WCS from the cube or stored signal WCS
458
+ if cube_reb is not None:
459
+ wcs_2d = cube_reb.wcs.celestial.swapaxes(0, 1)
460
+ else:
461
+ wcs_2d = combination_results["first_signal_wcs"].celestial.swapaxes(0, 1)
436
462
 
437
463
  # Get the data arrays - now only first iteration is saved
438
464
  first_photon_signal = combination_results["first_photon_signal"] # Shape: (nx, ny, nwave)
439
465
  first_dn_signal = combination_results["first_dn_signal"] # Shape: (nx, ny, nwave)
440
466
  fit_stats_key = f"{data_type}_fit_stats"
441
467
  fit_stats = combination_results[fit_stats_key] # Contains first_fit_data, mean_data, std_data, units
468
+ if fit_stats is None:
469
+ raise ValueError(
470
+ f"'{data_type}' signal was not fitted for this combination. "
471
+ f"Check the 'fit_signals' setting in your YAML config."
472
+ )
442
473
 
443
474
  maps = {}
444
475
 
@@ -457,14 +488,22 @@ def create_sunpy_maps_from_combo(
457
488
  maps['total_dn'] = sunpy.map.Map(total_dn_data.T, wcs_2d)
458
489
  maps['total_dn'].meta['bunit'] = str(total_dn_unit)
459
490
 
491
+ # Determine parameter indices for the primary component
492
+ if fit_config is not None and not fit_config.is_single:
493
+ idx_center = fit_config.idx_center
494
+ idx_sigma = fit_config.idx_sigma
495
+ else:
496
+ idx_center = 1
497
+ idx_sigma = 2
498
+
460
499
  # --- Get velocity and width analysis for this combination ---
461
- analysis = analyse_fit_statistics(combination_results, rest_wavelength, data_type)
500
+ analysis = analyse_fit_statistics(combination_results, rest_wavelength, data_type, fit_config=fit_config)
462
501
 
463
502
  # --- Velocity maps ---
464
- # Velocity from first fit (parameter 1 = center)
465
- first_fit_data = fit_stats["first_fit_data"] # Shape: (nx, ny, 4)
466
- center_first_data = first_fit_data[..., 1] # Extract center parameter
467
- center_first_unit = fit_stats["units"][1] # Get units for center parameter
503
+ # Velocity from first fit (primary component center)
504
+ first_fit_data = fit_stats["first_fit_data"] # Shape: (nx, ny, n_params)
505
+ center_first_data = first_fit_data[..., idx_center]
506
+ center_first_unit = fit_stats["units"][idx_center]
468
507
 
469
508
  def centers_to_velocity(centers_data, centers_unit, lambda0):
470
509
  """Convert wavelength centers to velocities"""
@@ -492,9 +531,9 @@ def create_sunpy_maps_from_combo(
492
531
  maps['velocity_err'].meta['bunit'] = str(analysis["v_err"].unit)
493
532
 
494
533
  # --- Line width maps ---
495
- # Line width from first fit (parameter 2 = width)
496
- width_first_data = first_fit_data[..., 2] # Extract width parameter data
497
- width_first_unit = fit_stats["units"][2] # Get units for width parameter
534
+ # Line width from first fit (primary component sigma)
535
+ width_first_data = first_fit_data[..., idx_sigma]
536
+ width_first_unit = fit_stats["units"][idx_sigma]
498
537
 
499
538
  # Create quantity with proper units
500
539
  width_quantity = width_first_data * width_first_unit
@@ -14,7 +14,18 @@ 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
+
19
+
20
+ def _resample_batch(flat_chunk, unit, spectral_world, new_spec_grid, n_spec):
21
+ """Resample a chunk of pixels (module-level for efficient pickling)."""
22
+ resampler = FluxConservingResampler(extrapolation_treatment="zero_fill")
23
+ batch_results = np.empty((flat_chunk.shape[0], n_spec))
24
+ for i in range(flat_chunk.shape[0]):
25
+ spec = Spectrum(flux=flat_chunk[i] * unit, spectral_axis=spectral_world)
26
+ res = resampler(spec, new_spec_grid)
27
+ batch_results[i] = res.flux.value
28
+ return batch_results
18
29
 
19
30
 
20
31
  def load_atmosphere(pkl_file: str, metadata_line: str = None) -> tuple:
@@ -171,19 +182,15 @@ def resample_ndcube_spectral_axis(ndcube, spectral_axis, output_resolution, ncpu
171
182
  # Create batch indices
172
183
  batch_indices = [(i, min(i + batch_size, n_pixels)) for i in range(0, n_pixels, batch_size)]
173
184
 
174
- def _resample_batch(start_idx, end_idx):
175
- """Resample a batch of pixels."""
176
- resampler = FluxConservingResampler(extrapolation_treatment="zero_fill")
177
- batch_results = np.empty((end_idx - start_idx, n_spec))
178
- for i, pixel_idx in enumerate(range(start_idx, end_idx)):
179
- spec = Spectrum(flux=flat_data[pixel_idx] * ndcube.unit, spectral_axis=spectral_world)
180
- res = resampler(spec, new_spec_grid)
181
- batch_results[i] = res.flux.value
182
- return batch_results
185
+ # Pass .copy() slices so loky pickles only the small chunk, not the full array
186
+ unit = ndcube.unit
183
187
 
184
188
  with tqdm_joblib(tqdm(total=len(batch_indices), desc="Resampling spectral axis", unit="batch", leave=False)):
185
189
  results = Parallel(n_jobs=ncpu)(
186
- delayed(_resample_batch)(start, end) for start, end in batch_indices
190
+ delayed(_resample_batch)(
191
+ flat_data[start:end].copy(), unit, spectral_world, new_spec_grid, n_spec
192
+ )
193
+ for start, end in batch_indices
187
194
  )
188
195
  resampled = np.vstack(results)
189
196
 
@@ -324,9 +331,11 @@ def create_uniform_intensity_cube(
324
331
  det,
325
332
  sim,
326
333
  n_sigma_extent: float = 8.0,
334
+ n_slit_pixels: int = 1,
335
+ tel=None,
327
336
  ) -> NDCube:
328
337
  """
329
- 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.
330
339
 
331
340
  The cube is built directly at the detector's spectral resolution and
332
341
  assigned a helioprojective WCS consistent with the output of
@@ -347,14 +356,30 @@ def create_uniform_intensity_cube(
347
356
  Simulation configuration (provides ``slit_width``).
348
357
  n_sigma_extent : float, optional
349
358
  Number of sigma either side of line centre to include in the
350
- 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.
351
373
 
352
374
  Returns
353
375
  -------
354
376
  NDCube
355
- 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
356
378
  helioprojective + wavelength WCS.
357
379
  """
380
+ if n_slit_pixels < 1:
381
+ raise ValueError(f"n_slit_pixels must be >= 1, got {n_slit_pixels}")
382
+
358
383
  # --- Spectral grid --------------------------------------------------
359
384
  lam0 = rest_wavelength.to(u.cm)
360
385
 
@@ -364,7 +389,19 @@ def create_uniform_intensity_cube(
364
389
  # Detector pixel pitch in cm
365
390
  dlam = det.wvl_res.to(u.cm / u.pix) * u.pix # strip per-pixel to cm
366
391
 
367
- 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
368
405
  n_pix_half = int(np.ceil((half_range / dlam).decompose().value))
369
406
  n_lam = 2 * n_pix_half + 1 # always odd, centred on rest wavelength
370
407
 
@@ -377,14 +414,17 @@ def create_uniform_intensity_cube(
377
414
  u.erg / (u.s * u.cm**2 * u.sr * u.cm)
378
415
  )
379
416
  profile = A * np.exp(-0.5 * ((lam_grid - lam0) / sigma_lam) ** 2)
380
- 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)
381
421
 
382
422
  # --- WCS (matches reproject_ndcube output format) --------------------
383
423
  # Axes: WAVE (cm), HPLT-TAN (arcsec), HPLN-TAN (arcsec)
384
424
  wcs = WCS(naxis=3)
385
425
  wcs.wcs.ctype = ["WAVE", "HPLT-TAN", "HPLN-TAN"]
386
426
  wcs.wcs.cunit = ["cm", "arcsec", "arcsec"]
387
- 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]
388
428
  wcs.wcs.crval = [lam0.value, 0.0, 0.0]
389
429
  wcs.wcs.cdelt = [
390
430
  dlam.to_value(u.cm),
File without changes