eclipse 0.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eclipse/__init__.py +95 -0
- eclipse/analysis.py +853 -0
- eclipse/atmosphere.py +859 -0
- eclipse/band.py +477 -0
- eclipse/cli.py +138 -0
- eclipse/config.py +1710 -0
- eclipse/continuum.py +413 -0
- eclipse/data/eis_response/EIS_EffArea_A.004 +61 -0
- eclipse/data/eis_response/EIS_EffArea_B.004 +60 -0
- eclipse/data/eis_response/EIS_EffArea_B.005 +64 -0
- eclipse/data/eis_response/README_dz2025 +50 -0
- eclipse/data/eis_response/fit_eis_ea_2023-05-04_smooth.sav +0 -0
- eclipse/data/eis_response/source.txt +33 -0
- eclipse/data/science_cases.yaml +365 -0
- eclipse/data/throughput/grating_reflection_efficiency.dat +25 -0
- eclipse/data/throughput/primary_mirror_coating_reflectance.dat +25 -0
- eclipse/data/throughput/source.txt +6 -0
- eclipse/data/throughput/throughput_aluminium_1000_angstrom.dat +503 -0
- eclipse/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +503 -0
- eclipse/data/throughput/throughput_carbon_1000_angstrom.dat +503 -0
- eclipse/data_processing.py +904 -0
- eclipse/deprecated.py +161 -0
- eclipse/eis_calibration.py +573 -0
- eclipse/extern/__init__.py +0 -0
- eclipse/extern/mpfit.py +2388 -0
- eclipse/fitting.py +1734 -0
- eclipse/frame.py +594 -0
- eclipse/full_frame.py +944 -0
- eclipse/main.py +1646 -0
- eclipse/monte_carlo.py +660 -0
- eclipse/pinhole_diffraction.py +409 -0
- eclipse/psf.py +6 -0
- eclipse/radiometric.py +1328 -0
- eclipse/raster.py +869 -0
- eclipse/readout.py +1002 -0
- eclipse/resolution_budget.py +108 -0
- eclipse/results_file.py +1226 -0
- eclipse/sampling.py +351 -0
- eclipse/science_cases.py +364 -0
- eclipse/synthesis.py +2492 -0
- eclipse/synthesis_cli.py +12 -0
- eclipse/synthesis_file.py +882 -0
- eclipse/utils.py +1145 -0
- eclipse-0.13.0.dist-info/METADATA +70 -0
- eclipse-0.13.0.dist-info/RECORD +50 -0
- eclipse-0.13.0.dist-info/WHEEL +5 -0
- eclipse-0.13.0.dist-info/entry_points.txt +7 -0
- eclipse-0.13.0.dist-info/licenses/LICENSE +3 -0
- eclipse-0.13.0.dist-info/top_level.txt +2 -0
- euvst_response/__init__.py +129 -0
eclipse/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)
|