solarc-eclipse 0.5.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.
- euvst_response/__init__.py +52 -0
- euvst_response/analysis.py +680 -0
- euvst_response/cli.py +113 -0
- euvst_response/config.py +396 -0
- euvst_response/data/throughput/grating_reflection_efficiency.dat +25 -0
- euvst_response/data/throughput/primary_mirror_coating_reflectance.dat +25 -0
- euvst_response/data/throughput/source.txt +3 -0
- euvst_response/data/throughput/throughput_aluminium_1000_angstrom.dat +503 -0
- euvst_response/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +503 -0
- euvst_response/data/throughput/throughput_carbon_1000_angstrom.dat +503 -0
- euvst_response/data_processing.py +269 -0
- euvst_response/fitting.py +144 -0
- euvst_response/main.py +424 -0
- euvst_response/monte_carlo.py +159 -0
- euvst_response/pinhole_diffraction.py +260 -0
- euvst_response/psf.py +46 -0
- euvst_response/radiometric.py +512 -0
- euvst_response/synthesis.py +911 -0
- euvst_response/synthesis_cli.py +12 -0
- euvst_response/utils.py +176 -0
- solarc_eclipse-0.5.0.dist-info/METADATA +354 -0
- solarc_eclipse-0.5.0.dist-info/RECORD +26 -0
- solarc_eclipse-0.5.0.dist-info/WHEEL +5 -0
- solarc_eclipse-0.5.0.dist-info/entry_points.txt +5 -0
- solarc_eclipse-0.5.0.dist-info/licenses/LICENSE +1 -0
- solarc_eclipse-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Result analysis functions for ECLIPSE instrument response simulations.
|
|
3
|
+
|
|
4
|
+
This module provides functions for loading, analyzing, and visualizing
|
|
5
|
+
instrument response simulation results.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import dill
|
|
9
|
+
import numpy as np
|
|
10
|
+
import astropy.units as u
|
|
11
|
+
import astropy.constants as const
|
|
12
|
+
import sunpy.map
|
|
13
|
+
import h5py
|
|
14
|
+
import matplotlib.pyplot as plt
|
|
15
|
+
from matplotlib.colors import ListedColormap, BoundaryNorm
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Dict, List, Tuple, Any
|
|
18
|
+
from ndcube import NDCube
|
|
19
|
+
from tqdm import tqdm
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _reconstruct_signal_with_units(signal_data, signal_unit, signal_wcs) -> NDCube:
|
|
23
|
+
"""
|
|
24
|
+
Reconstruct NDCube signal with units from stripped data.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
signal_data : numpy.ndarray
|
|
29
|
+
Signal data array
|
|
30
|
+
signal_unit : astropy.units.Unit
|
|
31
|
+
Unit (astropy unit object)
|
|
32
|
+
signal_wcs : WCS
|
|
33
|
+
World coordinate system
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
NDCube
|
|
38
|
+
Reconstructed NDCube with units
|
|
39
|
+
"""
|
|
40
|
+
signal_quantity = signal_data * signal_unit
|
|
41
|
+
return NDCube(signal_quantity, wcs=signal_wcs)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_instrument_response_results(filepath: str | Path) -> Dict[str, Any]:
|
|
45
|
+
"""
|
|
46
|
+
Load instrument response results and reconstruct signals for compatibility.
|
|
47
|
+
Fit statistics are kept with units separated.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
filepath : str or Path
|
|
52
|
+
Path to the pickled results file.
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
dict
|
|
57
|
+
Dictionary containing all results and metadata with reconstructed signals.
|
|
58
|
+
"""
|
|
59
|
+
with open(filepath, "rb") as f:
|
|
60
|
+
data = dill.load(f)
|
|
61
|
+
|
|
62
|
+
for param_key, combination_results in tqdm(data["results"]["all_combinations"].items(), desc="Reconstructing results", leave=False):
|
|
63
|
+
# Reconstruct signal NDCubes
|
|
64
|
+
combination_results["first_dn_signal"] = _reconstruct_signal_with_units(
|
|
65
|
+
combination_results["first_dn_signal_data"],
|
|
66
|
+
combination_results["first_dn_signal_unit"],
|
|
67
|
+
combination_results["first_signal_wcs"]
|
|
68
|
+
)
|
|
69
|
+
combination_results["first_photon_signal"] = _reconstruct_signal_with_units(
|
|
70
|
+
combination_results["first_photon_signal_data"],
|
|
71
|
+
combination_results["first_photon_signal_unit"],
|
|
72
|
+
combination_results["first_signal_wcs"]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return data
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_parameter_combinations(results: Dict[str, Any]) -> List[Tuple]:
|
|
79
|
+
"""
|
|
80
|
+
Get all parameter combinations that were simulated.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
results : dict
|
|
85
|
+
Results dictionary from load_instrument_response_results.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
list of tuples
|
|
90
|
+
List of parameter combination keys.
|
|
91
|
+
"""
|
|
92
|
+
return list(results["results"]["all_combinations"].keys())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_parameter_combinations(results: Dict[str, Any]) -> List[Tuple]:
|
|
96
|
+
"""
|
|
97
|
+
Get all parameter combinations that were simulated.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
results : dict
|
|
102
|
+
Results dictionary from load_instrument_response_results.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
list of tuples
|
|
107
|
+
List of parameter combination keys.
|
|
108
|
+
"""
|
|
109
|
+
return list(results["results"]["all_combinations"].keys())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def analyse_fit_statistics(
|
|
113
|
+
combination_results: Dict[str, Any],
|
|
114
|
+
rest_wavelength: u.Quantity,
|
|
115
|
+
data_type: str = "dn"
|
|
116
|
+
) -> Dict[str, Any]:
|
|
117
|
+
"""
|
|
118
|
+
Analyze fit statistics to compute velocity and line width statistics.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
combination_results : dict
|
|
123
|
+
Results for a specific parameter combination.
|
|
124
|
+
rest_wavelength : u.Quantity
|
|
125
|
+
Rest wavelength for velocity conversion.
|
|
126
|
+
data_type : str, optional
|
|
127
|
+
Either "dn" or "photon" to specify which fit statistics to analyze.
|
|
128
|
+
|
|
129
|
+
Returns
|
|
130
|
+
-------
|
|
131
|
+
dict
|
|
132
|
+
Dictionary containing velocity and width statistics.
|
|
133
|
+
"""
|
|
134
|
+
# Get fit statistics
|
|
135
|
+
fit_stats_key = f"{data_type}_fit_stats"
|
|
136
|
+
if fit_stats_key not in combination_results:
|
|
137
|
+
raise ValueError(f"No {fit_stats_key} found in combination results")
|
|
138
|
+
|
|
139
|
+
fit_stats = combination_results[fit_stats_key]
|
|
140
|
+
fit_truth_data = combination_results["ground_truth"]["fit_truth_data"]
|
|
141
|
+
fit_truth_units = combination_results["ground_truth"]["fit_truth_units"]
|
|
142
|
+
|
|
143
|
+
# Extract data and units
|
|
144
|
+
mean_data = fit_stats["mean_data"] # Shape: (nx, ny, 4)
|
|
145
|
+
std_data = fit_stats["std_data"] # Shape: (nx, ny, 4)
|
|
146
|
+
units = fit_stats["units"] # List of 4 astropy units
|
|
147
|
+
|
|
148
|
+
# Get center statistics (parameter index 1)
|
|
149
|
+
center_mean_data = mean_data[..., 1] # (nx, ny) - values only
|
|
150
|
+
center_std_data = std_data[..., 1] # (nx, ny) - values only
|
|
151
|
+
center_unit = units[1] # wavelength unit
|
|
152
|
+
|
|
153
|
+
# Get width statistics (parameter index 2)
|
|
154
|
+
width_mean_data = mean_data[..., 2] # (nx, ny) - values only
|
|
155
|
+
width_std_data = std_data[..., 2] # (nx, ny) - values only
|
|
156
|
+
width_unit = units[2] # wavelength unit
|
|
157
|
+
|
|
158
|
+
# Create quantities
|
|
159
|
+
center_mean_q = center_mean_data * center_unit
|
|
160
|
+
center_std_q = center_std_data * center_unit
|
|
161
|
+
width_mean_q = width_mean_data * width_unit
|
|
162
|
+
width_std_q = width_std_data * width_unit
|
|
163
|
+
|
|
164
|
+
# Convert centers to velocities using simple formula
|
|
165
|
+
# v = (lambda - lambda0) / lambda0 * c
|
|
166
|
+
def centers_to_velocity(centers_q, lambda0):
|
|
167
|
+
"""Convert wavelength centers to velocities"""
|
|
168
|
+
velocity = ((centers_q - lambda0) / lambda0 * const.c).to(u.km / u.s)
|
|
169
|
+
return velocity
|
|
170
|
+
|
|
171
|
+
# Convert to velocities
|
|
172
|
+
v_mean = centers_to_velocity(center_mean_q, rest_wavelength)
|
|
173
|
+
v_true = centers_to_velocity(fit_truth_data[..., 1] * fit_truth_units[1], rest_wavelength)
|
|
174
|
+
v_err = v_true - v_mean
|
|
175
|
+
|
|
176
|
+
# Convert center std to velocity std using differential: dv/dlambda = c/lambda
|
|
177
|
+
c = const.c.to(u.km / u.s)
|
|
178
|
+
v_std = (c * center_std_q / rest_wavelength).to(u.km / u.s)
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
"v_mean": v_mean,
|
|
182
|
+
"v_std": v_std,
|
|
183
|
+
"v_err": v_err,
|
|
184
|
+
"v_true": v_true,
|
|
185
|
+
"w_mean": width_mean_q,
|
|
186
|
+
"w_std": width_std_q,
|
|
187
|
+
"fit_stats": fit_stats,
|
|
188
|
+
"fit_truth_data": fit_truth_data,
|
|
189
|
+
"fit_truth_units": fit_truth_units,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_results_for_combination(
|
|
194
|
+
results: Dict[str, Any],
|
|
195
|
+
slit_width: u.Quantity = None,
|
|
196
|
+
oxide_thickness: u.Quantity = None,
|
|
197
|
+
c_thickness: u.Quantity = None,
|
|
198
|
+
aluminium_thickness: u.Quantity = None,
|
|
199
|
+
ccd_temperature: u.Quantity = None,
|
|
200
|
+
vis_sl: u.Quantity = None,
|
|
201
|
+
exposure: u.Quantity = None,
|
|
202
|
+
psf: bool = None,
|
|
203
|
+
enable_pinholes: bool = None,
|
|
204
|
+
debug: bool = False
|
|
205
|
+
) -> Dict[str, Any]:
|
|
206
|
+
"""
|
|
207
|
+
Get results for a specific parameter combination.
|
|
208
|
+
|
|
209
|
+
Parameters
|
|
210
|
+
----------
|
|
211
|
+
results : dict
|
|
212
|
+
Results dictionary from load_instrument_response_results.
|
|
213
|
+
slit_width : u.Quantity, optional
|
|
214
|
+
Slit width with units (e.g., 0.2 * u.arcsec). If None, uses first available.
|
|
215
|
+
oxide_thickness : u.Quantity, optional
|
|
216
|
+
Oxide thickness with units (e.g., 95 * u.nm). If None, uses first available.
|
|
217
|
+
c_thickness : u.Quantity, optional
|
|
218
|
+
Carbon thickness with units (e.g., 20 * u.nm). If None, uses first available.
|
|
219
|
+
aluminium_thickness : u.Quantity, optional
|
|
220
|
+
Aluminium thickness with units (e.g., 1485 * u.AA). If None, uses first available.
|
|
221
|
+
ccd_temperature : u.Quantity, optional
|
|
222
|
+
CCD temperature with units (e.g., -40.0 * u.deg_C). If None, uses first available.
|
|
223
|
+
vis_sl : u.Quantity, optional
|
|
224
|
+
Stray light level with units (e.g., 0 * u.photon / (u.s * u.pixel)). If None, uses first available.
|
|
225
|
+
exposure : u.Quantity, optional
|
|
226
|
+
Exposure time with units (e.g., 80 * u.s). If None, uses first available.
|
|
227
|
+
psf : bool, optional
|
|
228
|
+
PSF setting (True or False). If None, uses first available.
|
|
229
|
+
enable_pinholes : bool, optional
|
|
230
|
+
Pinhole effects setting (True or False). If None, uses first available.
|
|
231
|
+
debug : bool, optional
|
|
232
|
+
If True, print debugging information about available keys.
|
|
233
|
+
|
|
234
|
+
Returns
|
|
235
|
+
-------
|
|
236
|
+
dict
|
|
237
|
+
Results for the specified parameter combination.
|
|
238
|
+
"""
|
|
239
|
+
all_combinations = results["results"]["all_combinations"]
|
|
240
|
+
param_ranges = results["results"]["parameter_ranges"]
|
|
241
|
+
|
|
242
|
+
if debug:
|
|
243
|
+
print(f"Available param_ranges keys: {list(param_ranges.keys())}")
|
|
244
|
+
print(f"Sample combination keys: {list(all_combinations.keys())[:3]}")
|
|
245
|
+
print(f"Key lengths: {[len(k) for k in list(all_combinations.keys())[:3]]}")
|
|
246
|
+
print(f"Available slit widths: {[sw.to_value(u.arcsec) for sw in param_ranges['slit_widths']]}")
|
|
247
|
+
print(f"Available oxide thicknesses: {[ot.to_value(u.nm) for ot in param_ranges['oxide_thicknesses']]}")
|
|
248
|
+
print(f"Available carbon thicknesses: {[ct.to_value(u.nm) for ct in param_ranges['c_thicknesses']]}")
|
|
249
|
+
print(f"Available aluminium thicknesses: {[at.to_value(u.AA) for at in param_ranges['aluminium_thicknesses']]}")
|
|
250
|
+
print(f"Available CCD temperatures: {param_ranges['ccd_temperatures']} deg C")
|
|
251
|
+
print(f"Available stray light values: {[vs.to_value() if hasattr(vs, 'to_value') else vs for vs in param_ranges['vis_sl_vals']]}")
|
|
252
|
+
print(f"Available exposures: {[ex.to_value(u.s) for ex in param_ranges['exposures']]}")
|
|
253
|
+
print(f"Available PSF settings: {param_ranges.get('psf_settings', [])}")
|
|
254
|
+
print(f"Available pinhole settings: {param_ranges.get('enable_pinholes_vals', [])}")
|
|
255
|
+
|
|
256
|
+
# Check if no parameters were specified at all
|
|
257
|
+
no_params_specified = all(param is None for param in [slit_width, oxide_thickness, c_thickness,
|
|
258
|
+
aluminium_thickness, ccd_temperature, vis_sl, exposure, psf, enable_pinholes])
|
|
259
|
+
|
|
260
|
+
if no_params_specified and len(all_combinations) > 1:
|
|
261
|
+
print(f"Error: No parameters specified, but {len(all_combinations)} combinations are available!")
|
|
262
|
+
print(f"Please specify at least one parameter to select a unique combination.")
|
|
263
|
+
print(f"Use summary_table(results) to see all available parameter combinations.")
|
|
264
|
+
raise ValueError("No parameters specified but multiple combinations exist. Please specify parameters to select a unique combination.")
|
|
265
|
+
|
|
266
|
+
# Store original None values before filling defaults
|
|
267
|
+
original_params = {
|
|
268
|
+
'slit_width': slit_width,
|
|
269
|
+
'oxide_thickness': oxide_thickness,
|
|
270
|
+
'c_thickness': c_thickness,
|
|
271
|
+
'aluminium_thickness': aluminium_thickness,
|
|
272
|
+
'ccd_temperature': ccd_temperature,
|
|
273
|
+
'vis_sl': vis_sl,
|
|
274
|
+
'exposure': exposure,
|
|
275
|
+
'psf': psf,
|
|
276
|
+
'enable_pinholes': enable_pinholes
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# FIRST: Check if the specified parameters match multiple combinations
|
|
280
|
+
# before filling in any defaults
|
|
281
|
+
matching_combinations = []
|
|
282
|
+
|
|
283
|
+
for key in all_combinations.keys():
|
|
284
|
+
key_slit, key_oxide, key_carbon, key_aluminium, key_ccd, key_vis_sl, key_exposure, key_psf, key_enable_pinholes = key
|
|
285
|
+
|
|
286
|
+
# Check if this combination matches all specified (non-None) parameters
|
|
287
|
+
matches = True
|
|
288
|
+
if slit_width is not None and abs(key_slit - slit_width.to_value(u.arcsec)) > 1e-10:
|
|
289
|
+
matches = False
|
|
290
|
+
if oxide_thickness is not None and abs(key_oxide - oxide_thickness.to_value(u.nm)) > 1e-10:
|
|
291
|
+
matches = False
|
|
292
|
+
if c_thickness is not None and abs(key_carbon - c_thickness.to_value(u.nm)) > 1e-10:
|
|
293
|
+
matches = False
|
|
294
|
+
if aluminium_thickness is not None and abs(key_aluminium - aluminium_thickness.to_value(u.AA)) > 1e-10:
|
|
295
|
+
matches = False
|
|
296
|
+
if ccd_temperature is not None and abs(key_ccd - ccd_temperature.to_value(u.deg_C, equivalencies=u.temperature())) > 1e-10:
|
|
297
|
+
matches = False
|
|
298
|
+
if vis_sl is not None:
|
|
299
|
+
vis_sl_val = vis_sl.to_value() if hasattr(vis_sl, 'to_value') else vis_sl
|
|
300
|
+
if abs(key_vis_sl - vis_sl_val) > 1e-10:
|
|
301
|
+
matches = False
|
|
302
|
+
if exposure is not None and abs(key_exposure - exposure.to_value(u.s)) > 1e-10:
|
|
303
|
+
matches = False
|
|
304
|
+
if psf is not None and key_psf != psf:
|
|
305
|
+
matches = False
|
|
306
|
+
if enable_pinholes is not None and key_enable_pinholes != enable_pinholes:
|
|
307
|
+
matches = False
|
|
308
|
+
|
|
309
|
+
if matches:
|
|
310
|
+
matching_combinations.append(key)
|
|
311
|
+
|
|
312
|
+
if len(matching_combinations) > 1:
|
|
313
|
+
print(f"Error: Your parameters match {len(matching_combinations)} different combinations!")
|
|
314
|
+
print(f"Please be more specific to select only one combination.")
|
|
315
|
+
print(f"Use summary_table(results) to see all available parameter combinations.")
|
|
316
|
+
print(f"Matching combinations found:")
|
|
317
|
+
for i, combo in enumerate(matching_combinations[:5]): # Show first 5
|
|
318
|
+
slit, oxide, carbon, aluminium, ccd, vis_sl, exp, psf_val, enable_pinholes_val = combo
|
|
319
|
+
print(f" {i+1}: slit={slit:.2f}arcsec, oxide={oxide:.1f}nm, carbon={carbon:.1f}nm, "
|
|
320
|
+
f"Al={aluminium:.0f}A, CCD={ccd:.1f}C, stray={vis_sl:.2g}, exp={exp:.1f}s, psf={psf_val}, pinholes={enable_pinholes_val}")
|
|
321
|
+
if len(matching_combinations) > 5:
|
|
322
|
+
print(f" ... and {len(matching_combinations) - 5} more")
|
|
323
|
+
raise ValueError(f"Multiple combinations match your parameters. Please specify more parameters to select a unique combination.")
|
|
324
|
+
elif len(matching_combinations) == 1:
|
|
325
|
+
return all_combinations[matching_combinations[0]]
|
|
326
|
+
|
|
327
|
+
# If we get here, either no matches or need to use defaults and try exact match
|
|
328
|
+
# Use defaults if not specified, keeping units throughout
|
|
329
|
+
if slit_width is None:
|
|
330
|
+
slit_width = param_ranges["slit_widths"][0]
|
|
331
|
+
if oxide_thickness is None:
|
|
332
|
+
oxide_thickness = param_ranges["oxide_thicknesses"][0]
|
|
333
|
+
if c_thickness is None:
|
|
334
|
+
c_thickness = param_ranges["c_thicknesses"][0]
|
|
335
|
+
if aluminium_thickness is None:
|
|
336
|
+
aluminium_thickness = param_ranges["aluminium_thicknesses"][0]
|
|
337
|
+
if ccd_temperature is None:
|
|
338
|
+
ccd_temperature = param_ranges["ccd_temperatures"][0] # This should already have units
|
|
339
|
+
if vis_sl is None:
|
|
340
|
+
vis_sl = param_ranges["vis_sl_vals"][0]
|
|
341
|
+
if exposure is None:
|
|
342
|
+
exposure = param_ranges["exposures"][0]
|
|
343
|
+
if psf is None:
|
|
344
|
+
psf = param_ranges["psf_settings"][0]
|
|
345
|
+
if enable_pinholes is None:
|
|
346
|
+
enable_pinholes = param_ranges["enable_pinholes_vals"][0]
|
|
347
|
+
|
|
348
|
+
# Convert units to the same format as stored in keys (without units)
|
|
349
|
+
slit_width_val = slit_width.to_value(u.arcsec)
|
|
350
|
+
oxide_thickness_val = oxide_thickness.to_value(u.nm)
|
|
351
|
+
c_thickness_val = c_thickness.to_value(u.nm)
|
|
352
|
+
aluminium_thickness_val = aluminium_thickness.to_value(u.AA)
|
|
353
|
+
ccd_temperature_val = ccd_temperature.to_value(u.deg_C, equivalencies=u.temperature()) # Convert to Celsius value
|
|
354
|
+
vis_sl_val = vis_sl.to_value() if hasattr(vis_sl, 'to_value') else vis_sl
|
|
355
|
+
exposure_val = exposure.to_value(u.s)
|
|
356
|
+
|
|
357
|
+
# Find matching combination (9-element key format)
|
|
358
|
+
target_key = (slit_width_val, oxide_thickness_val, c_thickness_val,
|
|
359
|
+
aluminium_thickness_val, ccd_temperature_val, vis_sl_val, exposure_val, psf, enable_pinholes)
|
|
360
|
+
|
|
361
|
+
if debug:
|
|
362
|
+
print(f"Target key: {target_key}")
|
|
363
|
+
|
|
364
|
+
# Check for exact matches - if multiple exist, warn user
|
|
365
|
+
exact_matches = [key for key in all_combinations.keys() if key == target_key]
|
|
366
|
+
|
|
367
|
+
if len(exact_matches) > 1:
|
|
368
|
+
print(f"Warning: Found {len(exact_matches)} exact matches for the same parameter combination!")
|
|
369
|
+
print(f"This suggests duplicate entries in the results. Using the first match.")
|
|
370
|
+
return all_combinations[exact_matches[0]]
|
|
371
|
+
elif len(exact_matches) == 1:
|
|
372
|
+
return all_combinations[exact_matches[0]]
|
|
373
|
+
|
|
374
|
+
# If no exact match, find closest
|
|
375
|
+
closest_key = None
|
|
376
|
+
min_distance = float('inf')
|
|
377
|
+
|
|
378
|
+
for key in all_combinations.keys():
|
|
379
|
+
distance = sum((a - b)**2 for a, b in zip(key, target_key))
|
|
380
|
+
if distance < min_distance:
|
|
381
|
+
min_distance = distance
|
|
382
|
+
closest_key = key
|
|
383
|
+
|
|
384
|
+
if closest_key is not None:
|
|
385
|
+
print(f"No exact match found. Using closest combination: {closest_key}")
|
|
386
|
+
print(f"Target was: {target_key}")
|
|
387
|
+
return all_combinations[closest_key]
|
|
388
|
+
else:
|
|
389
|
+
raise ValueError("No matching parameter combination found")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def get_dem_data_from_results(results: Dict[str, Any]) -> Dict[str, Any]:
|
|
393
|
+
"""
|
|
394
|
+
Extract DEM data from loaded instrument response results.
|
|
395
|
+
|
|
396
|
+
Parameters
|
|
397
|
+
----------
|
|
398
|
+
results : dict
|
|
399
|
+
Results dictionary from load_instrument_response_results.
|
|
400
|
+
|
|
401
|
+
Returns
|
|
402
|
+
-------
|
|
403
|
+
dict
|
|
404
|
+
Dictionary containing DEM data with keys:
|
|
405
|
+
- 'dem_map': DEM(T) map (numpy array, shape nx, ny, nT)
|
|
406
|
+
- 'em_tv': EM(T,v) map (numpy array, shape nx, ny, nT, nv)
|
|
407
|
+
- 'logT_centres': Temperature bin centers (numpy array)
|
|
408
|
+
- 'v_edges': Velocity bin edges (numpy array)
|
|
409
|
+
- 'goft': Contribution function data (dict)
|
|
410
|
+
- 'logT_grid': Temperature grid used for interpolation (numpy array)
|
|
411
|
+
- 'logN_grid': Density grid used for interpolation (numpy array)
|
|
412
|
+
|
|
413
|
+
Raises
|
|
414
|
+
------
|
|
415
|
+
KeyError
|
|
416
|
+
If DEM data is not found in the results (older format).
|
|
417
|
+
"""
|
|
418
|
+
if "dem_data" not in results:
|
|
419
|
+
raise KeyError(
|
|
420
|
+
"DEM data not found in results. This appears to be from an older "
|
|
421
|
+
"simulation that didn't include DEM data. Please re-run the simulation "
|
|
422
|
+
"with the updated package to include DEM data in the results."
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
return results["dem_data"]
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def summary_table(results: Dict[str, Any]) -> None:
|
|
429
|
+
"""
|
|
430
|
+
Print a summary table of all parameter combinations and their results.
|
|
431
|
+
|
|
432
|
+
Parameters
|
|
433
|
+
----------
|
|
434
|
+
results : dict
|
|
435
|
+
Results dictionary from load_instrument_response_results.
|
|
436
|
+
"""
|
|
437
|
+
all_combinations = results["results"]["all_combinations"]
|
|
438
|
+
param_ranges = results["results"]["parameter_ranges"]
|
|
439
|
+
|
|
440
|
+
print("Parameter Combination Summary")
|
|
441
|
+
print("=" * 155)
|
|
442
|
+
print(f"{'Slit (arcsec)':<12} {'Oxide (nm)':<12} {'Carbon (nm)':<12} {'Al (A)':<10} {'CCD (C)':<10} {'Stray Light':<12} {'Exp (s)':<10} {'PSF':<5} {'Pinholes':<8}")
|
|
443
|
+
print("-" * 155)
|
|
444
|
+
|
|
445
|
+
for key, combo_results in all_combinations.items():
|
|
446
|
+
slit, oxide, carbon, aluminium, ccd_temp, vis_sl, exposure, psf, enable_pinholes = key
|
|
447
|
+
params = combo_results["parameters"]
|
|
448
|
+
|
|
449
|
+
print(f"{slit:<12.2f} {oxide:<12.1f} {carbon:<12.1f} {aluminium:<10.0f} {ccd_temp:<10.1f} {vis_sl:<12.2g} {exposure:<10.1f} {str(psf):<5} {str(enable_pinholes):<8}")
|
|
450
|
+
|
|
451
|
+
print("-" * 155)
|
|
452
|
+
print(f"Total combinations: {len(all_combinations)}")
|
|
453
|
+
print(f"Exposure times: {[exp.to_value(u.s) for exp in param_ranges['exposures']]}")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def create_sunpy_maps_from_combo(
|
|
457
|
+
combination_results: Dict[str, Any],
|
|
458
|
+
cube_reb,
|
|
459
|
+
rest_wavelength: u.Quantity = 195.119 * u.AA,
|
|
460
|
+
data_type: str = "dn",
|
|
461
|
+
precision_requirement: u.Quantity = 2.0 * u.km / u.s,
|
|
462
|
+
exposure_time_results: List[Dict[str, Any]] | None = None
|
|
463
|
+
) -> Dict[str, Any]:
|
|
464
|
+
"""
|
|
465
|
+
Create SunPy maps from combination results using the new fit statistics structure.
|
|
466
|
+
|
|
467
|
+
Parameters
|
|
468
|
+
----------
|
|
469
|
+
combination_results : dict
|
|
470
|
+
Results for a specific parameter combination from get_results_for_combination().
|
|
471
|
+
cube_reb : NDCube
|
|
472
|
+
NDCube with helioprojective WCS to use for all maps.
|
|
473
|
+
rest_wavelength : u.Quantity, optional
|
|
474
|
+
Rest wavelength for velocity conversion (default: 195.119 A for Fe XII).
|
|
475
|
+
data_type : str, optional
|
|
476
|
+
Either "dn" or "photon" to specify which fit statistics to use for velocity/width maps.
|
|
477
|
+
precision_requirement : u.Quantity, optional
|
|
478
|
+
Velocity precision requirement for exposure time map (default: 2.0 km/s).
|
|
479
|
+
exposure_time_results : list of dict, optional
|
|
480
|
+
List of results from get_results_for_combination() for different exposure times.
|
|
481
|
+
If provided, will create an exposure time map showing minimum exposure needed.
|
|
482
|
+
|
|
483
|
+
Returns
|
|
484
|
+
-------
|
|
485
|
+
dict
|
|
486
|
+
Dictionary of SunPy maps with keys:
|
|
487
|
+
- 'total_photons': Total photons (summed along wavelength) from first MC iteration
|
|
488
|
+
- 'total_dn': Total DN (summed along wavelength) from first MC iteration
|
|
489
|
+
- 'velocity_from_fit': Velocity from first fit of first MC iteration
|
|
490
|
+
- 'velocity_mean': Mean velocity across all MC iterations
|
|
491
|
+
- 'velocity_std': Velocity uncertainty (standard deviation)
|
|
492
|
+
- 'velocity_err': Velocity error (truth - mean)
|
|
493
|
+
- 'line_width_from_fit': Line width from first fit of first MC iteration
|
|
494
|
+
- 'line_width_mean': Mean line width across all MC iterations
|
|
495
|
+
- 'line_width_std': Line width uncertainty (standard deviation)
|
|
496
|
+
- 'exposure_time': Minimum exposure time required to reach precision (if exposure_time_results provided)
|
|
497
|
+
"""
|
|
498
|
+
|
|
499
|
+
# Handle optional exposure time analysis
|
|
500
|
+
if exposure_time_results is not None:
|
|
501
|
+
# Create analysis_per_exp from the list
|
|
502
|
+
analysis_per_exp = {}
|
|
503
|
+
for result in exposure_time_results:
|
|
504
|
+
# Extract exposure time from parameters
|
|
505
|
+
exposure_time = result["parameters"]["exposure"].to_value(u.s)
|
|
506
|
+
# Create analysis for this exposure
|
|
507
|
+
analysis = analyse_fit_statistics(result, rest_wavelength, data_type)
|
|
508
|
+
analysis_per_exp[exposure_time] = analysis
|
|
509
|
+
else:
|
|
510
|
+
analysis_per_exp = None
|
|
511
|
+
|
|
512
|
+
# Extract 2D helioprojective WCS from the cube
|
|
513
|
+
wcs_2d = cube_reb.wcs.celestial.swapaxes(0, 1)
|
|
514
|
+
|
|
515
|
+
# Get the data arrays - now only first iteration is saved
|
|
516
|
+
first_photon_signal = combination_results["first_photon_signal"] # Shape: (nx, ny, nwave)
|
|
517
|
+
first_dn_signal = combination_results["first_dn_signal"] # Shape: (nx, ny, nwave)
|
|
518
|
+
fit_stats_key = f"{data_type}_fit_stats"
|
|
519
|
+
fit_stats = combination_results[fit_stats_key] # Contains first_fit_data, mean_data, std_data, units
|
|
520
|
+
|
|
521
|
+
maps = {}
|
|
522
|
+
|
|
523
|
+
# --- Total photons map (before detector effects) ---
|
|
524
|
+
total_photons_data = first_photon_signal.data.sum(axis=2) # Sum along wavelength
|
|
525
|
+
total_photons_unit = first_photon_signal.unit * u.pix
|
|
526
|
+
|
|
527
|
+
maps['total_photons'] = sunpy.map.Map(total_photons_data.T, wcs_2d)
|
|
528
|
+
# 25/07/2025 SunPy failing to pass "unit" keyword to give the map units, so performing manually throughout this function.
|
|
529
|
+
maps['total_photons'].meta['bunit'] = str(total_photons_unit)
|
|
530
|
+
|
|
531
|
+
# --- Total DN map (after detector effects) ---
|
|
532
|
+
total_dn_data = first_dn_signal.data.sum(axis=2) # Sum along wavelength
|
|
533
|
+
total_dn_unit = first_dn_signal.unit * u.pix
|
|
534
|
+
|
|
535
|
+
maps['total_dn'] = sunpy.map.Map(total_dn_data.T, wcs_2d)
|
|
536
|
+
maps['total_dn'].meta['bunit'] = str(total_dn_unit)
|
|
537
|
+
|
|
538
|
+
# --- Get velocity and width analysis for this combination ---
|
|
539
|
+
analysis = analyse_fit_statistics(combination_results, rest_wavelength, data_type)
|
|
540
|
+
|
|
541
|
+
# --- Velocity maps ---
|
|
542
|
+
# Velocity from first fit (parameter 1 = center)
|
|
543
|
+
first_fit_data = fit_stats["first_fit_data"] # Shape: (nx, ny, 4)
|
|
544
|
+
center_first_data = first_fit_data[..., 1] # Extract center parameter
|
|
545
|
+
center_first_unit = fit_stats["units"][1] # Get units for center parameter
|
|
546
|
+
|
|
547
|
+
def centers_to_velocity(centers_data, centers_unit, lambda0):
|
|
548
|
+
"""Convert wavelength centers to velocities"""
|
|
549
|
+
centers_quantity = centers_data * centers_unit
|
|
550
|
+
|
|
551
|
+
velocity = ((centers_quantity - lambda0) / lambda0 * const.c).to(u.km / u.s)
|
|
552
|
+
return velocity
|
|
553
|
+
|
|
554
|
+
v_first = centers_to_velocity(center_first_data, center_first_unit, rest_wavelength)
|
|
555
|
+
|
|
556
|
+
maps['velocity_from_fit'] = sunpy.map.Map(v_first.value.T, wcs_2d)
|
|
557
|
+
maps['velocity_from_fit'].meta['bunit'] = str(v_first.unit)
|
|
558
|
+
|
|
559
|
+
maps['velocity_mean'] = sunpy.map.Map(analysis["v_mean"].value.T, wcs_2d)
|
|
560
|
+
maps['velocity_mean'].meta['bunit'] = str(analysis["v_mean"].unit)
|
|
561
|
+
|
|
562
|
+
maps['velocity_std'] = sunpy.map.Map(analysis["v_std"].value.T, wcs_2d)
|
|
563
|
+
maps['velocity_std'].meta['bunit'] = str(analysis["v_std"].unit)
|
|
564
|
+
|
|
565
|
+
# Velocity error (truth - mean)
|
|
566
|
+
maps['velocity_err'] = sunpy.map.Map(analysis["v_err"].value.T, wcs_2d)
|
|
567
|
+
maps['velocity_err'].meta['bunit'] = str(analysis["v_err"].unit)
|
|
568
|
+
|
|
569
|
+
# --- Line width maps ---
|
|
570
|
+
# Line width from first fit (parameter 2 = width)
|
|
571
|
+
width_first_data = first_fit_data[..., 2] # Extract width parameter data
|
|
572
|
+
width_first_unit = fit_stats["units"][2] # Get units for width parameter
|
|
573
|
+
|
|
574
|
+
# Create quantity with proper units
|
|
575
|
+
width_quantity = width_first_data * width_first_unit
|
|
576
|
+
# Convert to Angstroms and extract value for SunPy Map
|
|
577
|
+
width_data_clean = width_quantity.to(u.AA).value
|
|
578
|
+
|
|
579
|
+
maps['line_width_from_fit'] = sunpy.map.Map(width_data_clean.T, wcs_2d)
|
|
580
|
+
maps['line_width_from_fit'].meta['bunit'] = str(u.AA)
|
|
581
|
+
|
|
582
|
+
# Mean line width across all iterations
|
|
583
|
+
# Handle line width data properly
|
|
584
|
+
w_mean = analysis["w_mean"]
|
|
585
|
+
w_mean_data_clean = w_mean.to(u.AA).value
|
|
586
|
+
|
|
587
|
+
maps['line_width_mean'] = sunpy.map.Map(w_mean_data_clean.T, wcs_2d)
|
|
588
|
+
maps['line_width_mean'].meta['bunit'] = str(u.AA)
|
|
589
|
+
|
|
590
|
+
# Line width standard deviation (uncertainty)
|
|
591
|
+
w_std = analysis["w_std"]
|
|
592
|
+
w_std_data_clean = w_std.to(u.AA).value
|
|
593
|
+
maps['line_width_std'] = sunpy.map.Map(w_std_data_clean.T, wcs_2d)
|
|
594
|
+
maps['line_width_std'].meta['bunit'] = str(u.AA)
|
|
595
|
+
|
|
596
|
+
# --- Exposure time map (minimum required for precision) ---
|
|
597
|
+
if analysis_per_exp is not None:
|
|
598
|
+
exp_times = sorted(analysis_per_exp.keys())
|
|
599
|
+
nlevels = len(exp_times)
|
|
600
|
+
shape = next(iter(analysis_per_exp.values()))["v_std"].shape
|
|
601
|
+
best_exp = np.full(shape, np.nan)
|
|
602
|
+
|
|
603
|
+
# Find minimum exposure time that meets precision requirement for each pixel
|
|
604
|
+
for i, s in enumerate(exp_times):
|
|
605
|
+
vstd = analysis_per_exp[s]["v_std"].to_value(u.km / u.s)
|
|
606
|
+
msk = (vstd <= precision_requirement.to_value(u.km / u.s)) & np.isnan(best_exp)
|
|
607
|
+
best_exp[msk] = i # Use index instead of actual exposure time
|
|
608
|
+
|
|
609
|
+
# For pixels that don't meet the precision requirement even at max exposure,
|
|
610
|
+
# assign them a value above the valid range so they show as "over" values
|
|
611
|
+
still_nan = np.isnan(best_exp)
|
|
612
|
+
best_exp[still_nan] = nlevels # This will be above the valid range (0 to nlevels-1)
|
|
613
|
+
|
|
614
|
+
# Create discrete colormap for exposure times
|
|
615
|
+
cmap = ListedColormap(plt.get_cmap("viridis")(np.linspace(0, 1, nlevels)))
|
|
616
|
+
cmap.set_over("white")
|
|
617
|
+
cmap.set_bad("gray") # Change bad color so we can distinguish from over
|
|
618
|
+
# Create normalization with proper boundaries to handle values 0 to nlevels-1, with nlevels as "over"
|
|
619
|
+
norm = BoundaryNorm(np.arange(-0.5, nlevels + 0.5, 1), nlevels)
|
|
620
|
+
|
|
621
|
+
maps['exposure_time'] = sunpy.map.Map(best_exp.T, wcs_2d)
|
|
622
|
+
maps['exposure_time'].meta['bunit'] = 's'
|
|
623
|
+
maps['exposure_time'].plot_settings.update(dict(cmap=cmap, norm=norm))
|
|
624
|
+
|
|
625
|
+
# Store exposure time information for custom colorbar formatting
|
|
626
|
+
maps['exposure_time']._exposure_times = exp_times
|
|
627
|
+
maps['exposure_time']._exposure_indices = list(range(nlevels))
|
|
628
|
+
|
|
629
|
+
# Set appropriate visualization settings for common map types
|
|
630
|
+
# Also ensure correct aspect ratio for all maps
|
|
631
|
+
map_names = list(maps.keys())
|
|
632
|
+
|
|
633
|
+
# Set aspect ratio metadata for all maps to ensure correct plotting
|
|
634
|
+
cdelt_x = wcs_2d.wcs.cdelt[0]
|
|
635
|
+
cdelt_y = wcs_2d.wcs.cdelt[1]
|
|
636
|
+
aspect_ratio = cdelt_y / cdelt_x
|
|
637
|
+
for map_name in map_names:
|
|
638
|
+
maps[map_name].plot_settings.update({
|
|
639
|
+
'aspect': aspect_ratio,
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
# Set specific color maps and ranges
|
|
643
|
+
maps['total_photons'].plot_settings.update(dict(cmap="afmhot", norm="log"))
|
|
644
|
+
maps['total_dn'].plot_settings.update(dict(cmap="afmhot", norm="log"))
|
|
645
|
+
maps['velocity_from_fit'].plot_settings.update(dict(cmap="RdBu_r", vmin=-15, vmax=15))
|
|
646
|
+
maps['velocity_mean'].plot_settings.update(dict(cmap="RdBu_r", vmin=-15, vmax=15))
|
|
647
|
+
maps['velocity_std'].plot_settings.update(dict(cmap="magma", vmin=0))
|
|
648
|
+
maps['line_width_from_fit'].plot_settings.update(dict(cmap="Purples"))
|
|
649
|
+
maps['line_width_mean'].plot_settings.update(dict(cmap="Purples"))
|
|
650
|
+
maps['line_width_std'].plot_settings.update(dict(cmap="Purples"))
|
|
651
|
+
if 'exposure_time' in maps:
|
|
652
|
+
maps['exposure_time'].plot_settings.update(dict(origin="lower"))
|
|
653
|
+
|
|
654
|
+
return maps
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def format_exposure_time_colorbar(map_obj, colorbar, precision_requirement: u.Quantity = 2.0 * u.km / u.s):
|
|
658
|
+
"""
|
|
659
|
+
Format the colorbar for an exposure time map with proper tick labels.
|
|
660
|
+
|
|
661
|
+
Parameters
|
|
662
|
+
----------
|
|
663
|
+
map_obj : sunpy.map.Map
|
|
664
|
+
The exposure time map object (should have _exposure_times attribute).
|
|
665
|
+
colorbar : matplotlib.colorbar.Colorbar
|
|
666
|
+
The colorbar object to format.
|
|
667
|
+
precision_requirement : u.Quantity, optional
|
|
668
|
+
Velocity precision requirement for the title (default: 2.0 km/s).
|
|
669
|
+
"""
|
|
670
|
+
# Set tick positions at the center of each color segment
|
|
671
|
+
tick_positions = map_obj._exposure_indices
|
|
672
|
+
tick_labels = [f"{exp_time:.1f}" for exp_time in map_obj._exposure_times]
|
|
673
|
+
|
|
674
|
+
colorbar.set_ticks(tick_positions)
|
|
675
|
+
colorbar.set_ticklabels(tick_labels)
|
|
676
|
+
|
|
677
|
+
# Create title with precision requirement
|
|
678
|
+
precision_val = precision_requirement.to_value(u.km / u.s)
|
|
679
|
+
title = f"Minimum exposure time to reach $\\sigma_v \\leq {precision_val:.1f}$ km/s [s]"
|
|
680
|
+
colorbar.set_label(title)
|