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.
@@ -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)