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/analysis.py ADDED
@@ -0,0 +1,853 @@
1
+ """
2
+ Result analysis functions for ECLIPSE instrument response simulations.
3
+ """
4
+
5
+ import warnings
6
+
7
+ import numpy as np
8
+ import astropy.units as u
9
+ import astropy.constants as const
10
+ import sunpy.map
11
+ import h5py
12
+ import matplotlib.pyplot as plt
13
+ from matplotlib.colors import ListedColormap, BoundaryNorm
14
+ from datetime import date, datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Dict, List, Tuple, Any
17
+ from ndcube import NDCube
18
+ from tqdm import tqdm
19
+
20
+ from .config import Detector_SWC, _old_names_used
21
+ from .deprecated import get_dem_data_from_results # noqa: F401 (deprecated)
22
+ from .results_file import load_results
23
+ from .utils import _to_canonical_scalar, has_wrong_velocity_sign
24
+
25
+
26
+ def load_instrument_response_results(filepath: str | Path,
27
+ allow_wrong_velocity_sign: bool = False,
28
+ ) -> Dict[str, Any]:
29
+ """
30
+ Read a results file written by `eclipse`.
31
+
32
+ Parameters
33
+ ----------
34
+ filepath : str or Path
35
+ Results file, `run/result/<config name>.h5`. Results pickles from
36
+ ECLIPSE 0.11.0 and earlier are also read, with a warning, and a
37
+ `.pkl` name that no longer exists reads the `.h5` file beside it.
38
+ Reading a pickle runs any code it holds, so only read pickles you
39
+ trust.
40
+ allow_wrong_velocity_sign : bool, optional
41
+ Warn instead of raising for results made from a synthesis that an
42
+ older ECLIPSE wrote, under the older velocity convention, for a view
43
+ along x or z. Their velocities have the opposite sign to this
44
+ version's. Default False.
45
+
46
+ Returns
47
+ -------
48
+ dict
49
+ Results. `results["results"]["all_combinations"]` holds those of
50
+ each combination, and `get_results_for_combination` picks one out.
51
+ """
52
+ data = load_results(filepath, _stacklevel=3)
53
+ # An old pickle can hold anything.
54
+ if not isinstance(data, dict):
55
+ raise ValueError(f"{filepath} does not hold the results of an instrument simulation.")
56
+
57
+ # Refuse results made from synthesis files written under the older
58
+ # velocity convention, for the views whose sign it changed. Uniform
59
+ # intensity runs have no synthesis file and no velocities, so are
60
+ # unaffected.
61
+ cube_sim = data.get("cube_sim")
62
+ if cube_sim is not None and has_wrong_velocity_sign(cube_sim.meta):
63
+ axis = (cube_sim.meta or {}).get("integration_axis", "z")
64
+ message = (
65
+ f"{filepath} was made from a synthesis file written by an older "
66
+ "ECLIPSE, which took the line-of-sight velocity straight from the "
67
+ "simulation's velocity component. ECLIPSE now uses the velocity "
68
+ f"away from the observer. For this view along {axis}, the two "
69
+ "conventions have opposite signs, so the velocities in it are "
70
+ "opposite in sign to this version's."
71
+ )
72
+ if not allow_wrong_velocity_sign:
73
+ raise ValueError(
74
+ message + " Re-run the synthesis and then the simulation with "
75
+ "this version, or pass allow_wrong_velocity_sign=True to load "
76
+ "it anyway."
77
+ )
78
+ warnings.warn(message, stacklevel=2)
79
+
80
+ # A malformed file is refused with an error naming it. A signal's unit
81
+ # must be a unit, since two arrays could multiply to far more than
82
+ # either. A signal shared by several combinations is multiplied once.
83
+ signals = {}
84
+ try:
85
+ combinations = data["results"]["all_combinations"]
86
+ for param_key, combination_results in tqdm(combinations.items(), desc="Reconstructing results", leave=False):
87
+ # Refuse files written before the cube axis order changed (issue
88
+ # #12). They store signals as (x, y, wavelength) with an HPLT-first
89
+ # WCS, so their maps would come out transposed.
90
+ wcs_ctype = combination_results["first_signal_wcs"].wcs.ctype
91
+ if str(wcs_ctype[1]).startswith("HPLT"):
92
+ raise ValueError(
93
+ f"{filepath} was written by an older ECLIPSE that stored "
94
+ "cubes as (x, y, wavelength). Cubes are now "
95
+ "(y, x, wavelength). Re-run the simulation with this "
96
+ "version to regenerate the file."
97
+ )
98
+ # Reconstruct signal NDCubes
99
+ for kind in ("dn", "photon"):
100
+ signal = combination_results[f"first_{kind}_signal_data"]
101
+ unit = combination_results[f"first_{kind}_signal_unit"]
102
+ if not isinstance(unit, u.UnitBase):
103
+ raise ValueError(f"{filepath}: the {kind} signal's unit is of type "
104
+ f"{type(unit).__name__}, not a unit.")
105
+ if (id(signal), unit) not in signals:
106
+ signals[id(signal), unit] = signal * unit
107
+ combination_results[f"first_{kind}_signal"] = NDCube(
108
+ signals[id(signal), unit], wcs=combination_results["first_signal_wcs"])
109
+ except (KeyError, TypeError, AttributeError, IndexError) as error:
110
+ raise ValueError(f"{filepath} does not hold the results of an instrument simulation: "
111
+ f"{type(error).__name__}: {error}") from error
112
+
113
+ return data
114
+
115
+
116
+ def get_parameter_combinations(results: Dict[str, Any]) -> List[Dict]:
117
+ """
118
+ Settings of every simulated combination.
119
+
120
+ Parameters
121
+ ----------
122
+ results : dict
123
+ Results from `load_instrument_response_results`.
124
+
125
+ Returns
126
+ -------
127
+ list of dict
128
+ One dict per combination, of its settings keyed by section and key,
129
+ such as `"simulation.expos"`.
130
+ """
131
+ return [combo["parameters"] for combo in results["results"]["all_combinations"].values()]
132
+
133
+
134
+ # Results files written before fitted components were stored by name hold
135
+ # only the per-parameter arrays.
136
+ _UNNAMED_FITS = (
137
+ "This results file was written before fitted components were stored by "
138
+ "name, so it only holds per-parameter statistics. Re-run the simulation "
139
+ "with this version to get them."
140
+ )
141
+
142
+
143
+ def _get_fit_stats(combination_results: Dict[str, Any], data_type: str) -> Dict[str, Any]:
144
+ """Fit statistics of one signal; raises ValueError if there are none."""
145
+ fit_stats_key = f"{data_type}_fit_stats"
146
+ if fit_stats_key not in combination_results:
147
+ raise ValueError(f"No {fit_stats_key} found in combination results")
148
+ fit_stats = combination_results[fit_stats_key]
149
+ if fit_stats is None:
150
+ raise ValueError(
151
+ f"'{data_type}' signal was not fitted for this combination. "
152
+ f"Check the 'fit_signals' setting in your YAML config."
153
+ )
154
+ return fit_stats
155
+
156
+
157
+ def list_fit_components(combination_results: Dict[str, Any],
158
+ data_type: str = "dn") -> List[str]:
159
+ """
160
+ Names of the fitted components, in the order they were fitted.
161
+
162
+ Parameters
163
+ ----------
164
+ combination_results : dict
165
+ Results of one combination, from `get_results_for_combination`.
166
+ data_type : str, optional
167
+ Fits to use: `"dn"` for the signal in DN (default), or `"photon"`
168
+ for the photons.
169
+
170
+ Returns
171
+ -------
172
+ list of str
173
+ Names to pass as `component` to `analyse_fit_statistics` and
174
+ `create_sunpy_maps_from_combo`.
175
+ """
176
+ fit_stats = _get_fit_stats(combination_results, data_type)
177
+ if "components" not in fit_stats:
178
+ raise ValueError(_UNNAMED_FITS)
179
+ return list(fit_stats["components"])
180
+
181
+
182
+ def analyse_fit_statistics(
183
+ combination_results: Dict[str, Any],
184
+ rest_wavelength: u.Quantity | None = None,
185
+ data_type: str = "dn",
186
+ fit_config=None,
187
+ component: str | None = None,
188
+ ) -> Dict[str, Any]:
189
+ """
190
+ Per-pixel precision of one fitted line's velocity, width and intensity.
191
+
192
+ Parameters
193
+ ----------
194
+ combination_results : dict
195
+ Results of one combination, from `get_results_for_combination`.
196
+ rest_wavelength : u.Quantity, optional
197
+ Rest wavelength the velocities are measured from. Results files store
198
+ each component's own, so this is only needed for files written
199
+ before components were stored by name. If given, it must match.
200
+ data_type : str, optional
201
+ Fits to use: `"dn"` for the signal in DN (default), or `"photon"`
202
+ for the photons.
203
+ fit_config : FitConfig, optional
204
+ Only needed for files written before components were stored by name.
205
+ component : str, optional
206
+ Component name, from `list_fit_components`. Default the primary
207
+ component.
208
+
209
+ Returns
210
+ -------
211
+ dict
212
+ Mostly maps, with one value per pixel. `v_first`, `w_first` and
213
+ `i_first`: velocity, width and intensity from the first Monte Carlo
214
+ iteration. `v_mean`, `w_mean` and `i_mean`: their means over the
215
+ iterations. `v_std`, `w_std` and `i_std`: their standard deviations.
216
+ `v_true` and `w_true`: true velocity and width. `v_err`: true
217
+ velocity minus the mean. `failed_fits` and `n_iterations`: number of
218
+ failed fits, and of iterations. `component`, `rest_wavelength` and
219
+ `tied`: component name, its rest wavelength and what it is tied to.
220
+ `fit_stats`, `fit_truth_data` and `fit_truth_units`: fit statistics
221
+ and fit to the truth, as stored in the results. The width is the
222
+ Gaussian sigma and the intensity the fitted line's counts. Files
223
+ written before components were stored by name give only `v_first`,
224
+ `v_mean`, `v_std`, `v_true`, `v_err`, `w_first`, `w_mean`, `w_std`,
225
+ `fit_stats`, `fit_truth_data` and `fit_truth_units`.
226
+ """
227
+ fit_stats = _get_fit_stats(combination_results, data_type)
228
+ ground_truth = combination_results["ground_truth"]
229
+ fit_truth_data = ground_truth["fit_truth_data"]
230
+ fit_truth_units = ground_truth["fit_truth_units"]
231
+
232
+ if "components" in fit_stats:
233
+ name = fit_stats["primary_component"] if component is None else component
234
+ if name not in fit_stats["components"]:
235
+ raise ValueError(
236
+ f"No fitted component is named {name!r}. The components are "
237
+ f"{list(fit_stats['components'])}."
238
+ )
239
+ comp = fit_stats["components"][name]
240
+ rest = comp["rest_wavelength"]
241
+ if rest_wavelength is not None and not u.isclose(rest_wavelength, rest,
242
+ rtol=1e-6):
243
+ raise ValueError(
244
+ f"rest_wavelength is {rest_wavelength}, but component "
245
+ f"{name!r} was fitted at {rest}, and its velocities are "
246
+ f"measured from that. Leave rest_wavelength out, or choose a "
247
+ f"different component with component=."
248
+ )
249
+ truth = ground_truth["components"][name]
250
+ velocity, width, intensity = comp["velocity"], comp["width"], comp["intensity"]
251
+ return {
252
+ "component": name,
253
+ "rest_wavelength": rest,
254
+ "tied": comp["tied"],
255
+ "v_first": velocity["first"],
256
+ "v_mean": velocity["mean"],
257
+ "v_std": velocity["std"],
258
+ "v_err": truth["velocity"] - velocity["mean"],
259
+ "v_true": truth["velocity"],
260
+ "w_first": width["first"],
261
+ "w_mean": width["mean"],
262
+ "w_std": width["std"],
263
+ "w_true": truth["width"],
264
+ "i_first": intensity["first"],
265
+ "i_mean": intensity["mean"],
266
+ "i_std": intensity["std"],
267
+ "failed_fits": fit_stats["failed_fits"],
268
+ "n_iterations": fit_stats["n_iterations"],
269
+ "fit_stats": fit_stats,
270
+ "fit_truth_data": fit_truth_data,
271
+ "fit_truth_units": fit_truth_units,
272
+ }
273
+
274
+ if component is not None:
275
+ raise ValueError(f"component={component!r} cannot be chosen. {_UNNAMED_FITS}")
276
+ if rest_wavelength is None:
277
+ raise ValueError(f"rest_wavelength is needed for this file. {_UNNAMED_FITS}")
278
+
279
+ # Determine parameter indices for the primary component
280
+ if fit_config is not None and not fit_config.is_single:
281
+ idx_center = fit_config.idx_center
282
+ idx_sigma = fit_config.idx_sigma
283
+ else:
284
+ idx_center = 1
285
+ idx_sigma = 2
286
+
287
+ # Extract data and units
288
+ first_data = fit_stats["first_fit_data"] # Shape: (ny, nx, n_params)
289
+ mean_data = fit_stats["mean_data"] # Shape: (ny, nx, n_params)
290
+ std_data = fit_stats["std_data"] # Shape: (ny, nx, n_params)
291
+ units = fit_stats["units"] # List of n_params astropy units
292
+
293
+ # Get center statistics for the primary component
294
+ center_unit = units[idx_center]
295
+ center_first_q = first_data[..., idx_center] * center_unit
296
+ center_mean_q = mean_data[..., idx_center] * center_unit
297
+ center_std_q = std_data[..., idx_center] * center_unit
298
+
299
+ # Get width statistics for the primary component
300
+ width_unit = units[idx_sigma]
301
+ width_first_q = first_data[..., idx_sigma] * width_unit
302
+ width_mean_q = mean_data[..., idx_sigma] * width_unit
303
+ width_std_q = std_data[..., idx_sigma] * width_unit
304
+
305
+ # Convert centers to velocities using simple formula
306
+ # v = (lambda - lambda0) / lambda0 * c
307
+ def centers_to_velocity(centers_q, lambda0):
308
+ """Convert wavelength centers to velocities"""
309
+ velocity = ((centers_q - lambda0) / lambda0 * const.c).to(u.km / u.s)
310
+ return velocity
311
+
312
+ # Convert to velocities
313
+ v_first = centers_to_velocity(center_first_q, rest_wavelength)
314
+ v_mean = centers_to_velocity(center_mean_q, rest_wavelength)
315
+ v_true = centers_to_velocity(fit_truth_data[..., idx_center] * fit_truth_units[idx_center], rest_wavelength)
316
+ v_err = v_true - v_mean
317
+
318
+ # Convert center std to velocity std using differential: dv/dlambda = c/lambda
319
+ c = const.c.to(u.km / u.s)
320
+ v_std = (c * center_std_q / rest_wavelength).to(u.km / u.s)
321
+
322
+ return {
323
+ "v_first": v_first,
324
+ "v_mean": v_mean,
325
+ "v_std": v_std,
326
+ "v_err": v_err,
327
+ "v_true": v_true,
328
+ "w_first": width_first_q,
329
+ "w_mean": width_mean_q,
330
+ "w_std": width_std_q,
331
+ "fit_stats": fit_stats,
332
+ "fit_truth_data": fit_truth_data,
333
+ "fit_truth_units": fit_truth_units,
334
+ }
335
+
336
+
337
+ def _same_parameter(name: str, asked, stored) -> bool:
338
+ """
339
+ Whether the value `asked` for parameter `name` matches the `stored` one.
340
+
341
+ Quantities are compared in the stored value's unit, to a part in 1e9.
342
+ Compared in SI to 1e-8 of at least one, every length under 10 nm, such
343
+ as a microroughness or a thickness, would equal every other. A quantity
344
+ of a different kind raises ValueError rather than matching (5 m would
345
+ otherwise match 5 s), as does a quantity with a unit for a setting run
346
+ as a plain number. A number with no unit is compared with the stored
347
+ value in SI. A list, tuple or array matches a stored list or tuple item
348
+ by item, so a list from YAML matches a tuple the run stored.
349
+ """
350
+ if isinstance(stored, (list, tuple)):
351
+ if isinstance(asked, np.ndarray) and asked.ndim > 0:
352
+ asked = list(asked)
353
+ return (isinstance(asked, (list, tuple)) and len(asked) == len(stored)
354
+ and all(_same_parameter(name, one, other) for one, other in zip(asked, stored)))
355
+ numbers = (int, float, np.integer, np.floating)
356
+ if isinstance(asked, u.Quantity) and isinstance(stored, (bool, np.bool_)):
357
+ raise ValueError(f"{name} is a switch, true or false; {asked} is not.")
358
+ if isinstance(asked, u.Quantity) and isinstance(stored, numbers):
359
+ if not asked.unit.is_equivalent(u.dimensionless_unscaled):
360
+ raise ValueError(f"{name} was run as a number with no unit; {asked} is a "
361
+ f"{asked.unit.physical_type}.")
362
+ asked = asked.to_value(u.dimensionless_unscaled)
363
+ if isinstance(asked, u.Quantity) and isinstance(stored, u.Quantity):
364
+ try:
365
+ asked = asked.to_value(stored.unit, equivalencies=u.temperature())
366
+ except u.UnitConversionError:
367
+ raise ValueError(f"{name} was run in {stored.unit}, a unit of "
368
+ f"{stored.unit.physical_type}; {asked} is not.") from None
369
+ stored = stored.value
370
+ else:
371
+ asked, stored = _to_canonical_scalar(asked), _to_canonical_scalar(stored)
372
+ if (isinstance(asked, numbers) and isinstance(stored, numbers)
373
+ and not isinstance(asked, bool) and not isinstance(stored, bool)):
374
+ return bool(np.isclose(asked, stored, rtol=1e-9, atol=0.0))
375
+ return asked == stored
376
+
377
+
378
+ def get_results_for_combination(results: Dict[str, Any], **kwargs) -> Dict[str, Any]:
379
+ """
380
+ Results of one combination, selected by its settings.
381
+
382
+ Settings are named by section and key, as `summary_table` prints them.
383
+ Keyword arguments cannot contain a dot, so pass them as a dictionary:
384
+
385
+ get_results_for_combination(results, **{"simulation.expos": 40 * u.s,
386
+ "simulation.slit_width": 0.2 * u.arcsec})
387
+
388
+ Parameters
389
+ ----------
390
+ results : dict
391
+ Results from `load_instrument_response_results`.
392
+ **kwargs
393
+ Settings to match, with units where they have them. Not needed if
394
+ there is only one combination.
395
+
396
+ Returns
397
+ -------
398
+ dict
399
+ Results of the matching combination.
400
+
401
+ Raises
402
+ ------
403
+ ValueError
404
+ If no combination matches, or more than one does.
405
+ """
406
+ all_combinations = results["results"]["all_combinations"]
407
+
408
+ if not kwargs:
409
+ if len(all_combinations) == 1:
410
+ return next(iter(all_combinations.values()))
411
+ raise ValueError(
412
+ f"No parameters specified but {len(all_combinations)} combinations exist. "
413
+ "Use summary_table(results) to list all combinations, then specify "
414
+ "enough parameters to select a unique one."
415
+ )
416
+
417
+ detector = {key.partition(".")[2] for key in kwargs if key.startswith("detector.")}
418
+ renamed = _old_names_used(Detector_SWC, detector, "detector.", error=ValueError)
419
+ query = {}
420
+ for key, value in kwargs.items():
421
+ section, _, name = key.partition(".")
422
+ if section == "detector" and name in renamed:
423
+ key = f"detector.{renamed[name]}"
424
+ query[key] = value
425
+
426
+ matches = [combo_results for combo_results in all_combinations.values()
427
+ if all(key in combo_results["parameters"]
428
+ and _same_parameter(key, value, combo_results["parameters"][key])
429
+ for key, value in query.items())]
430
+
431
+ if len(matches) == 1:
432
+ return matches[0]
433
+ elif len(matches) == 0:
434
+ raise ValueError(
435
+ f"No combination matches: {kwargs}\n"
436
+ "Use summary_table(results) to see all available combinations."
437
+ )
438
+ else:
439
+ raise ValueError(
440
+ f"{len(matches)} combinations match the specified parameters {kwargs}. "
441
+ "Add more parameters to narrow the selection. "
442
+ "Use summary_table(results) to see all available combinations."
443
+ )
444
+
445
+
446
+ def summary_table(results: Dict[str, Any]) -> None:
447
+ """
448
+ Print a summary of the results.
449
+
450
+ Prints the ECLIPSE version and git commit that made the results, the
451
+ settings of every combination, the swept settings and the names of the
452
+ fitted components.
453
+
454
+ Parameters
455
+ ----------
456
+ results : dict
457
+ Results from `load_instrument_response_results`.
458
+ """
459
+ all_combinations = results["results"]["all_combinations"]
460
+
461
+ if not all_combinations:
462
+ print("No parameter combinations found.")
463
+ return
464
+
465
+ # Print run metadata if available
466
+ if "software_version" in results:
467
+ print(f"Software version : {results['software_version']}")
468
+ if "git_commit_id" in results:
469
+ print(f"Git commit : {results['git_commit_id']}")
470
+ if "software_version" in results or "git_commit_id" in results:
471
+ print()
472
+
473
+ # Discover all parameter names (excluding non-display fields)
474
+ _skip = {"simulation.pinhole_sizes", "simulation.pinhole_positions"}
475
+ param_names: List[str] = []
476
+ for combo in all_combinations.values():
477
+ for k in combo["parameters"]:
478
+ if k not in param_names and k not in _skip:
479
+ param_names.append(k)
480
+ param_names.sort()
481
+
482
+ def _fmt(val) -> str:
483
+ if hasattr(val, "unit"):
484
+ return f"{val.value:.4g} {val.unit}"
485
+ return str(val)
486
+
487
+ col_w = 24
488
+ header = " | ".join(f"{n:<{col_w}}" for n in param_names)
489
+ sep = "-" * len(header)
490
+ print(header)
491
+ print(sep)
492
+ for combo in all_combinations.values():
493
+ params = combo["parameters"]
494
+ row = [f"{_fmt(params.get(n, 'N/A')):<{col_w}}" for n in param_names]
495
+ print(" | ".join(row))
496
+ print(sep)
497
+ print(f"Total combinations: {len(all_combinations)}")
498
+
499
+ sweep_dims = results.get("results", {}).get("sweep_dimensions", {})
500
+ if sweep_dims:
501
+ print("\nSwept dimensions:")
502
+ for dim, vals in sweep_dims.items():
503
+ print(f" {dim}: {vals}")
504
+
505
+ # The components are the same in every combination and for both signals.
506
+ first_combo = next(iter(all_combinations.values()))
507
+ fit_stats = first_combo.get("dn_fit_stats") or first_combo.get("photon_fit_stats")
508
+ if fit_stats and "components" in fit_stats:
509
+ print("\nFitted components:")
510
+ for name, comp in fit_stats["components"].items():
511
+ notes = ["primary"] if name == fit_stats["primary_component"] else []
512
+ notes += [f"{key} tied to {source}"
513
+ for key, source in comp["tied"].items() if source is not None]
514
+ print(f" {name}" + (f" ({', '.join(notes)})" if notes else ""))
515
+
516
+
517
+ def _utc_date(value, what: str) -> str:
518
+ """
519
+ `value` as an ISO date and time in UTC, a form sunpy can read.
520
+
521
+ sunpy cannot read a date with an offset, such as YAML gives for an
522
+ unquoted time ending in Z, and would stamp the maps with the time they
523
+ were made instead, different on every call.
524
+ """
525
+ if isinstance(value, datetime):
526
+ moment = value
527
+ elif isinstance(value, date):
528
+ moment = datetime(value.year, value.month, value.day)
529
+ else:
530
+ text = str(value).strip().replace("Z", "+00:00")
531
+ try:
532
+ moment = datetime.fromisoformat(text)
533
+ except ValueError:
534
+ raise ValueError(f"{what} {value!r} is not a date and time sunpy can read; give "
535
+ f"it in ISO form, such as 2024-03-20T00:00:00.") from None
536
+ if moment.tzinfo is not None:
537
+ moment = moment.astimezone(timezone.utc).replace(tzinfo=None)
538
+ return moment.isoformat()
539
+
540
+
541
+ def _resolve_date_obs(combination_results: Dict[str, Any], date_obs) -> str:
542
+ """
543
+ Observation date to write into the maps.
544
+
545
+ A synthetic scene has no date of its own. The date is either given by
546
+ the caller or taken from the calibration date of an EIS run, a real
547
+ observing date the user chose. Without either, sunpy would use the
548
+ current time, making the maps differ on every run and silently wrong
549
+ for anything that uses the date.
550
+
551
+ Parameters
552
+ ----------
553
+ combination_results : dict
554
+ Results for one parameter combination.
555
+ date_obs : str, datetime.datetime, datetime.date or None
556
+ Date given by the caller, if any.
557
+
558
+ Returns
559
+ -------
560
+ str
561
+ ISO-8601 date string for the `DATE-OBS` keyword.
562
+ """
563
+ if date_obs is not None:
564
+ return _utc_date(date_obs, "date_obs")
565
+
566
+ telescope = combination_results.get("config_objects", {}).get("telescope")
567
+ calibration_date = getattr(telescope, "date", None)
568
+ if calibration_date:
569
+ return _utc_date(calibration_date, "The EIS calibration date")
570
+
571
+ raise ValueError(
572
+ "These maps have no observation date. A synthesised scene does not "
573
+ "carry one, and sunpy would otherwise stamp the maps with the time "
574
+ "the code happened to run, so they would differ between runs and "
575
+ "mislead anything that uses the date. Pass date_obs, for example "
576
+ "date_obs='2024-03-20T00:00:00'. An EIS run configured with a "
577
+ "time-dependent calibration uses that calibration date instead."
578
+ )
579
+
580
+
581
+ def _map_header(wcs_2d, date_obs: str, bunit: str):
582
+ """
583
+ FITS header for one map: WCS, unit and observer position.
584
+
585
+ The radiometric chain works at exactly one astronomical unit
586
+ (`photons_to_pixel_counts` divides the pixel area by `const.au ** 2`),
587
+ and the cubes are centred on disc centre, so that is the observer
588
+ position the data describe. Writing it in the header stops sunpy from
589
+ silently assuming an Earth-based observer. That is close for a
590
+ low-Earth-orbit mission, but would not match the data if the chain's
591
+ distance changed.
592
+
593
+ Parameters
594
+ ----------
595
+ wcs_2d : astropy.wcs.WCS
596
+ 2D celestial WCS of the map.
597
+ date_obs : str
598
+ Observation date, from :func:`_resolve_date_obs`.
599
+ bunit : str
600
+ Unit string for the map data.
601
+
602
+ Returns
603
+ -------
604
+ astropy.io.fits.Header
605
+ """
606
+ header = wcs_2d.to_header()
607
+ header["DATE-OBS"] = date_obs
608
+ header["BUNIT"] = bunit
609
+ # Observer in Heliographic Stonyhurst coordinates, on the line through
610
+ # disc centre. The radiometric chain puts it one au from the emitting
611
+ # surface, so DSUN_OBS, measured from the Sun's centre, is one au plus a
612
+ # solar radius. Latitude and longitude are zero, and the WCS reference
613
+ # is disc centre, because ECLIPSE models no B0 angle.
614
+ header["HGLN_OBS"] = 0.0
615
+ header["HGLT_OBS"] = 0.0
616
+ header["DSUN_OBS"] = (const.au + const.R_sun).to_value(u.m)
617
+ header["RSUN_REF"] = const.R_sun.to_value(u.m)
618
+ return header
619
+
620
+
621
+ def create_sunpy_maps_from_combo(
622
+ combination_results: Dict[str, Any],
623
+ cube_reb=None,
624
+ rest_wavelength: u.Quantity | None = None,
625
+ data_type: str = "dn",
626
+ precision_requirement: u.Quantity = 2.0 * u.km / u.s,
627
+ exposure_time_results: List[Dict[str, Any]] | None = None,
628
+ fit_config=None,
629
+ date_obs=None,
630
+ component: str | None = None,
631
+ ) -> Dict[str, Any]:
632
+ """
633
+ Make SunPy maps of one combination's results.
634
+
635
+ Parameters
636
+ ----------
637
+ combination_results : dict
638
+ Results of one combination, from `get_results_for_combination`.
639
+ cube_reb : NDCube, optional
640
+ Cube whose coordinates the maps take. Default the coordinates stored
641
+ in the results.
642
+ rest_wavelength : u.Quantity, optional
643
+ Rest wavelength the velocities are measured from. Only needed for
644
+ files written before components were stored by name. If given, it
645
+ must match.
646
+ data_type : str, optional
647
+ Fits to use: `"dn"` for the signal in DN (default), or `"photon"`
648
+ for the photons.
649
+ precision_requirement : u.Quantity, optional
650
+ Velocity precision for the `exposure_time` map. Default 2 km/s.
651
+ exposure_time_results : list of dict, optional
652
+ Combinations at each exposure time. If given, the maps include
653
+ `exposure_time`: the shortest of these exposures at which each
654
+ pixel's velocity is measured to within `precision_requirement`.
655
+ fit_config : FitConfig, optional
656
+ Only needed for files written before components were stored by name.
657
+ date_obs : str or datetime, optional
658
+ Observation date of the maps. A synthesised scene has no date of its
659
+ own, so this is needed, except for an EIS run with a dated
660
+ calibration, whose date is used.
661
+ component : str, optional
662
+ Component to map, by name from `list_fit_components`. Default the
663
+ primary component.
664
+
665
+ Returns
666
+ -------
667
+ dict
668
+ Maps by name. `total_dn` and `total_photons`: first iteration's
669
+ signal, summed over wavelength. `velocity_from_fit`,
670
+ `line_width_from_fit` and `intensity_from_fit`: first iteration's
671
+ fit. `velocity_mean`, `line_width_mean` and `intensity_mean`: means
672
+ over the iterations. `velocity_std`, `line_width_std` and
673
+ `intensity_std`: standard deviations. `velocity_true` and
674
+ `velocity_err`: true velocity, and true velocity minus the mean.
675
+ `failed_fits`: number of failed fits in each pixel. `exposure_time`:
676
+ shortest exposure that reaches `precision_requirement`, if
677
+ `exposure_time_results` is given. Files written before components
678
+ were stored by name have no intensity maps and no `failed_fits`.
679
+ """
680
+
681
+ date_obs = _resolve_date_obs(combination_results, date_obs)
682
+
683
+ if rest_wavelength is None and "components" not in _get_fit_stats(
684
+ combination_results, data_type):
685
+ # Older files do not record the rest wavelength, and this was the
686
+ # default when they were written.
687
+ rest_wavelength = 195.119 * u.AA
688
+
689
+ # Handle optional exposure time analysis
690
+ if exposure_time_results is not None:
691
+ # Create analysis_per_exp from the list
692
+ analysis_per_exp = {}
693
+ for result in exposure_time_results:
694
+ # Extract exposure time from parameters
695
+ exposure_time = result["parameters"]["simulation.expos"].to_value(u.s)
696
+ # Create analysis for this exposure
697
+ analysis = analyse_fit_statistics(result, rest_wavelength, data_type,
698
+ fit_config=fit_config, component=component)
699
+ analysis_per_exp[exposure_time] = analysis
700
+ else:
701
+ analysis_per_exp = None
702
+
703
+ # 2D helioprojective WCS from the cube or the stored signal WCS. The cubes
704
+ # are (ny, nx, nwave) with a (WAVE, HPLN, HPLT) WCS, so the celestial part
705
+ # is already in SunPy's (HPLN, HPLT) order and the data need no transpose.
706
+ if cube_reb is not None:
707
+ wcs_2d = cube_reb.wcs.celestial
708
+ else:
709
+ wcs_2d = combination_results["first_signal_wcs"].celestial
710
+
711
+ # Get the data arrays; only the first iteration is saved
712
+ first_photon_signal = combination_results["first_photon_signal"] # Shape: (ny, nx, nwave)
713
+ first_dn_signal = combination_results["first_dn_signal"] # Shape: (ny, nx, nwave)
714
+ maps = {}
715
+
716
+ # --- Total photons map (before detector effects) ---
717
+ total_photons_data = first_photon_signal.data.sum(axis=2) # Sum along wavelength
718
+ total_photons_unit = first_photon_signal.unit * u.pix
719
+
720
+ maps['total_photons'] = sunpy.map.Map(
721
+ total_photons_data, _map_header(wcs_2d, date_obs, str(total_photons_unit)))
722
+
723
+ # --- Total DN map (after detector effects) ---
724
+ total_dn_data = first_dn_signal.data.sum(axis=2) # Sum along wavelength
725
+ total_dn_unit = first_dn_signal.unit * u.pix
726
+
727
+ maps['total_dn'] = sunpy.map.Map(
728
+ total_dn_data, _map_header(wcs_2d, date_obs, str(total_dn_unit)))
729
+
730
+ # --- Get velocity, width and intensity analysis for this combination ---
731
+ analysis = analyse_fit_statistics(combination_results, rest_wavelength, data_type,
732
+ fit_config=fit_config, component=component)
733
+
734
+ # --- Velocity maps ---
735
+ for key, name in [("v_first", "velocity_from_fit"), ("v_mean", "velocity_mean"),
736
+ ("v_std", "velocity_std"), ("v_true", "velocity_true"),
737
+ ("v_err", "velocity_err")]:
738
+ velocity = analysis[key].to(u.km / u.s)
739
+ maps[name] = sunpy.map.Map(
740
+ velocity.value, _map_header(wcs_2d, date_obs, str(velocity.unit)))
741
+
742
+ # --- Line width maps, in Angstrom ---
743
+ for key, name in [("w_first", "line_width_from_fit"), ("w_mean", "line_width_mean"),
744
+ ("w_std", "line_width_std")]:
745
+ maps[name] = sunpy.map.Map(
746
+ analysis[key].to_value(u.AA), _map_header(wcs_2d, date_obs, str(u.AA)))
747
+
748
+ # --- Intensity and failed-fit maps (files with components stored by name) ---
749
+ if "i_mean" in analysis:
750
+ for key, name in [("i_first", "intensity_from_fit"), ("i_mean", "intensity_mean"),
751
+ ("i_std", "intensity_std")]:
752
+ maps[name] = sunpy.map.Map(
753
+ analysis[key].value, _map_header(wcs_2d, date_obs, str(analysis[key].unit)))
754
+ maps['failed_fits'] = sunpy.map.Map(
755
+ analysis["failed_fits"].astype(float), _map_header(wcs_2d, date_obs, ""))
756
+
757
+ # --- Exposure time map (minimum required for precision) ---
758
+ if analysis_per_exp is not None:
759
+ exp_times = sorted(analysis_per_exp.keys())
760
+ nlevels = len(exp_times)
761
+ shape = next(iter(analysis_per_exp.values()))["v_std"].shape
762
+ best_exp = np.full(shape, np.nan)
763
+
764
+ # Shortest exposure time, in seconds, that meets the precision
765
+ # requirement in each pixel.
766
+ for s in exp_times:
767
+ vstd = analysis_per_exp[s]["v_std"].to_value(u.km / u.s)
768
+ msk = (vstd <= precision_requirement.to_value(u.km / u.s)) & np.isnan(best_exp)
769
+ best_exp[msk] = s
770
+
771
+ # Pixels that miss the requirement even at the longest exposure need
772
+ # longer than any run, so they go above the scale.
773
+ best_exp[np.isnan(best_exp)] = np.inf
774
+
775
+ # One colour per exposure time, each spanning halfway to the next.
776
+ times = np.asarray(exp_times, dtype=float)
777
+ if nlevels > 1:
778
+ middles = 0.5 * (times[1:] + times[:-1])
779
+ bounds = np.concatenate([[times[0] - (middles[0] - times[0])], middles,
780
+ [times[-1] + (times[-1] - middles[-1])]])
781
+ else:
782
+ bounds = np.array([0.5 * times[0], 1.5 * times[0]])
783
+ cmap = ListedColormap(plt.get_cmap("viridis")(np.linspace(0, 1, nlevels)))
784
+ cmap.set_over("white")
785
+ cmap.set_bad("gray") # Change bad color so we can distinguish from over
786
+ norm = BoundaryNorm(bounds, nlevels)
787
+
788
+ maps['exposure_time'] = sunpy.map.Map(
789
+ best_exp, _map_header(wcs_2d, date_obs, 's'))
790
+ maps['exposure_time'].plot_settings.update(dict(cmap=cmap, norm=norm))
791
+
792
+ # Store exposure time information for custom colorbar formatting
793
+ maps['exposure_time']._exposure_times = exp_times
794
+ maps['exposure_time']._exposure_indices = list(range(nlevels))
795
+
796
+ # Set appropriate visualization settings for common map types
797
+ # Also ensure correct aspect ratio for all maps
798
+ map_names = list(maps.keys())
799
+
800
+ # Set aspect ratio metadata for all maps to ensure correct plotting
801
+ cdelt_x = wcs_2d.wcs.cdelt[0]
802
+ cdelt_y = wcs_2d.wcs.cdelt[1]
803
+ aspect_ratio = cdelt_y / cdelt_x
804
+ for map_name in map_names:
805
+ maps[map_name].plot_settings.update({
806
+ 'aspect': aspect_ratio,
807
+ })
808
+
809
+ # Set specific color maps and ranges
810
+ maps['total_photons'].plot_settings.update(dict(cmap="afmhot", norm="log"))
811
+ maps['total_dn'].plot_settings.update(dict(cmap="afmhot", norm="log"))
812
+ maps['velocity_from_fit'].plot_settings.update(dict(cmap="RdBu_r", vmin=-15, vmax=15))
813
+ maps['velocity_mean'].plot_settings.update(dict(cmap="RdBu_r", vmin=-15, vmax=15))
814
+ maps['velocity_std'].plot_settings.update(dict(cmap="magma", vmin=0))
815
+ maps['line_width_from_fit'].plot_settings.update(dict(cmap="Purples"))
816
+ maps['line_width_mean'].plot_settings.update(dict(cmap="Purples"))
817
+ maps['line_width_std'].plot_settings.update(dict(cmap="Purples"))
818
+ if 'intensity_mean' in maps:
819
+ maps['intensity_from_fit'].plot_settings.update(dict(cmap="afmhot"))
820
+ maps['intensity_mean'].plot_settings.update(dict(cmap="afmhot"))
821
+ maps['intensity_std'].plot_settings.update(dict(cmap="magma", vmin=0))
822
+ maps['failed_fits'].plot_settings.update(dict(cmap="Greys", vmin=0))
823
+ if 'exposure_time' in maps:
824
+ maps['exposure_time'].plot_settings.update(dict(origin="lower"))
825
+
826
+ return maps
827
+
828
+
829
+ def format_exposure_time_colorbar(map_obj, colorbar, precision_requirement: u.Quantity = 2.0 * u.km / u.s):
830
+ """
831
+ Label the colorbar of an exposure time map with its exposure times.
832
+
833
+ Parameters
834
+ ----------
835
+ map_obj : sunpy.map.Map
836
+ Exposure time map, with an `_exposure_times` attribute.
837
+ colorbar : matplotlib.colorbar.Colorbar
838
+ Colorbar to format.
839
+ precision_requirement : u.Quantity, optional
840
+ Velocity precision requirement for the colorbar label. Default 2.0 km/s.
841
+ """
842
+ # One tick per exposure time, in the middle of its colour, labelled as
843
+ # given so that 0.25 s reads as 0.25.
844
+ tick_positions = list(map_obj._exposure_times)
845
+ tick_labels = [f"{exp_time:g}" for exp_time in map_obj._exposure_times]
846
+
847
+ colorbar.set_ticks(tick_positions)
848
+ colorbar.set_ticklabels(tick_labels)
849
+
850
+ # Create title with precision requirement
851
+ precision_val = precision_requirement.to_value(u.km / u.s)
852
+ title = f"Minimum exposure time to reach $\\sigma_v \\leq {precision_val:.1f}$ km/s [s]"
853
+ colorbar.set_label(title)