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/utils.py
ADDED
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for coordinate transformations, unit conversions, and general helpers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import contextlib
|
|
7
|
+
import functools
|
|
8
|
+
import difflib
|
|
9
|
+
import dataclasses
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import warnings
|
|
14
|
+
import numpy as np
|
|
15
|
+
import yaml
|
|
16
|
+
import astropy.units as u
|
|
17
|
+
import astropy.constants as const
|
|
18
|
+
import joblib
|
|
19
|
+
from scipy import sparse
|
|
20
|
+
from tqdm import tqdm
|
|
21
|
+
from mendeleev import element
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Global debug flag - can be set by command line or configuration
|
|
25
|
+
DEBUG_MODE = False
|
|
26
|
+
|
|
27
|
+
# Recorded in every synthesised line cube. Cubes without it took the velocity
|
|
28
|
+
# from the simulation's component along the integration axis, which is
|
|
29
|
+
# opposite in sign to the velocity away from the observer for views along x
|
|
30
|
+
# and z, and the same for views along y.
|
|
31
|
+
VELOCITY_CONVENTION = "line-of-sight velocity, positive away from the observer"
|
|
32
|
+
|
|
33
|
+
# Observer side for a view given only by its axis: +1 on the side of
|
|
34
|
+
# increasing coordinate, -1 on the other. The observer is above the box (+z)
|
|
35
|
+
# for the top view, and at +x and -y for the two side views.
|
|
36
|
+
OBSERVER_SIDE = {"x": +1, "y": -1, "z": +1}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _warn_outside(message: str, category=FutureWarning) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Warn, attributed to the first caller outside ECLIPSE.
|
|
42
|
+
|
|
43
|
+
Frames in `dataclasses` and `copy` are skipped too, so a warning raised
|
|
44
|
+
while `dataclasses.replace` makes an object names the line that called it.
|
|
45
|
+
"""
|
|
46
|
+
frame, level = sys._getframe(1), 2
|
|
47
|
+
package = __name__.split(".")[0] + "."
|
|
48
|
+
while frame is not None:
|
|
49
|
+
module = frame.f_globals.get("__name__", "")
|
|
50
|
+
if not (module.startswith(package) or module in ("dataclasses", "copy")):
|
|
51
|
+
break
|
|
52
|
+
frame, level = frame.f_back, level + 1
|
|
53
|
+
warnings.warn(message, category, stacklevel=level)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def view_axis_and_side(view: str) -> tuple[str, int]:
|
|
57
|
+
"""
|
|
58
|
+
Axis of a view and the side of the box the observer is on.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
view : str
|
|
63
|
+
`"x"`, `"y"` or `"z"`, seen from the side in :data:`OBSERVER_SIDE`,
|
|
64
|
+
or an axis with a sign for the side, such as `"-x"` for the view
|
|
65
|
+
from -x.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
axis : str
|
|
70
|
+
`"x"`, `"y"` or `"z"`.
|
|
71
|
+
side : int
|
|
72
|
+
+1 if the observer is on the side of increasing coordinate, -1
|
|
73
|
+
otherwise.
|
|
74
|
+
"""
|
|
75
|
+
if isinstance(view, str) and view in OBSERVER_SIDE:
|
|
76
|
+
return view, OBSERVER_SIDE[view]
|
|
77
|
+
if isinstance(view, str) and len(view) == 2 and view[0] in "+-" and view[1] in OBSERVER_SIDE:
|
|
78
|
+
return view[1], +1 if view[0] == "+" else -1
|
|
79
|
+
raise ValueError(f"A view is 'x', 'y' or 'z', or one of them with + or - for the side "
|
|
80
|
+
f"of the box to look from, such as '-x'; got {view!r}.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def view_name(view: str) -> str:
|
|
84
|
+
"""
|
|
85
|
+
Name a view is recorded under.
|
|
86
|
+
|
|
87
|
+
A view from the default side in :data:`OBSERVER_SIDE` is named by its
|
|
88
|
+
axis alone, as in files written before other sides could be chosen. A
|
|
89
|
+
view from the other side is named by its axis with the sign of its side,
|
|
90
|
+
such as `"-x"`.
|
|
91
|
+
"""
|
|
92
|
+
axis, side = view_axis_and_side(view)
|
|
93
|
+
return axis if side == OBSERVER_SIDE[axis] else ("+" if side > 0 else "-") + axis
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def has_wrong_velocity_sign(meta) -> bool:
|
|
97
|
+
"""
|
|
98
|
+
Whether a synthesised cube's Doppler shifts use the old sign convention.
|
|
99
|
+
|
|
100
|
+
A cube that records :data:`VELOCITY_CONVENTION` uses the current one. A
|
|
101
|
+
cube without it predates the switch to velocity away from the observer,
|
|
102
|
+
which reversed the sign for views along x and z but not y. A cube with
|
|
103
|
+
no integration axis predates the side views, so it is a view along z.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
meta : dict or None
|
|
108
|
+
Cube metadata.
|
|
109
|
+
|
|
110
|
+
Returns
|
|
111
|
+
-------
|
|
112
|
+
bool
|
|
113
|
+
"""
|
|
114
|
+
meta = meta or {}
|
|
115
|
+
if meta.get("velocity_convention") == VELOCITY_CONVENTION:
|
|
116
|
+
return False
|
|
117
|
+
return meta.get("integration_axis", "z") != "y"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _get_mpi_info():
|
|
121
|
+
"""Return (comm, rank, world_size) if MPI is active with multiple ranks.
|
|
122
|
+
|
|
123
|
+
MPI is used if `mpi4py` imports and the MPI world has more than one
|
|
124
|
+
process (launched with `srun` or `mpirun`). Otherwise this returns
|
|
125
|
+
`(None, 0, 1)` for a single process.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
from mpi4py import MPI
|
|
129
|
+
comm = MPI.COMM_WORLD
|
|
130
|
+
size = comm.Get_size()
|
|
131
|
+
if size > 1:
|
|
132
|
+
return comm, comm.Get_rank(), size
|
|
133
|
+
except (ImportError, RuntimeError):
|
|
134
|
+
# ImportError: mpi4py not installed.
|
|
135
|
+
# RuntimeError: mpi4py installed but MPI library not loaded (e.g. on a
|
|
136
|
+
# login node before 'module load intel-mpi'). Falls back to serial mode.
|
|
137
|
+
pass
|
|
138
|
+
return None, 0, 1
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def set_debug_mode(enabled: bool):
|
|
142
|
+
"""Set global debug mode."""
|
|
143
|
+
global DEBUG_MODE
|
|
144
|
+
DEBUG_MODE = enabled
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def debug_break(message: str = "Debug break triggered", locals_dict=None, globals_dict=None,
|
|
148
|
+
traceback=None):
|
|
149
|
+
"""
|
|
150
|
+
Break into IPython debugger if debug mode is enabled.
|
|
151
|
+
|
|
152
|
+
Without IPython, pdb opens `traceback` post mortem at the frame that
|
|
153
|
+
raised, if given, and otherwise stops here.
|
|
154
|
+
|
|
155
|
+
Usage:
|
|
156
|
+
debug_break("Check values here", locals(), globals())
|
|
157
|
+
or:
|
|
158
|
+
debug_break("Error occurred")
|
|
159
|
+
"""
|
|
160
|
+
if not DEBUG_MODE:
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
print(f"\n=== DEBUG BREAK: {message} ===")
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
# Try to import and start IPython
|
|
167
|
+
from IPython import embed
|
|
168
|
+
|
|
169
|
+
# Prepare namespace for IPython
|
|
170
|
+
# Globals first, so a local of the same name wins.
|
|
171
|
+
user_ns = {}
|
|
172
|
+
if globals_dict:
|
|
173
|
+
user_ns.update(globals_dict)
|
|
174
|
+
if locals_dict:
|
|
175
|
+
user_ns.update(locals_dict)
|
|
176
|
+
|
|
177
|
+
print("Starting IPython session...")
|
|
178
|
+
print("Available variables:", list(user_ns.keys()) if user_ns else "None provided")
|
|
179
|
+
print("Type 'exit()' or Ctrl+D to continue execution")
|
|
180
|
+
|
|
181
|
+
# Start IPython with the provided namespace
|
|
182
|
+
embed(user_ns=user_ns)
|
|
183
|
+
|
|
184
|
+
except ImportError:
|
|
185
|
+
print("IPython not available. Using standard Python debugger...")
|
|
186
|
+
import pdb
|
|
187
|
+
if traceback is not None:
|
|
188
|
+
pdb.post_mortem(traceback)
|
|
189
|
+
else:
|
|
190
|
+
pdb.set_trace()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def debug_on_error(func):
|
|
194
|
+
"""
|
|
195
|
+
Decorator to automatically break into debugger on exceptions when debug mode is enabled.
|
|
196
|
+
|
|
197
|
+
Usage:
|
|
198
|
+
@debug_on_error
|
|
199
|
+
def my_function():
|
|
200
|
+
# your code here
|
|
201
|
+
"""
|
|
202
|
+
@functools.wraps(func)
|
|
203
|
+
def wrapper(*args, **kwargs):
|
|
204
|
+
try:
|
|
205
|
+
return func(*args, **kwargs)
|
|
206
|
+
except Exception as e:
|
|
207
|
+
if DEBUG_MODE:
|
|
208
|
+
print(f"\n=== EXCEPTION IN {func.__name__}: {type(e).__name__}: {e} ===")
|
|
209
|
+
# Open the session, with the exception as `exception`, in the
|
|
210
|
+
# innermost ECLIPSE frame: the decorated function (the second
|
|
211
|
+
# frame of the traceback, after this wrapper) or a function
|
|
212
|
+
# it called. Frames in numpy or astropy say little about the
|
|
213
|
+
# run. Without IPython, pdb opens where the exception was
|
|
214
|
+
# raised and can go up.
|
|
215
|
+
package = __name__.split(".")[0]
|
|
216
|
+
frame = (e.__traceback__.tb_next or e.__traceback__).tb_frame
|
|
217
|
+
entry = e.__traceback__.tb_next
|
|
218
|
+
while entry is not None:
|
|
219
|
+
if entry.tb_frame.f_globals.get("__name__", "").split(".")[0] == package:
|
|
220
|
+
frame = entry.tb_frame
|
|
221
|
+
entry = entry.tb_next
|
|
222
|
+
debug_break(f"Exception in {func.__name__}: {e}",
|
|
223
|
+
{**frame.f_locals, "exception": e}, frame.f_globals,
|
|
224
|
+
traceback=e.__traceback__)
|
|
225
|
+
raise
|
|
226
|
+
return wrapper
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# Cache for element_data, keyed by symbol, atomic number and the lookup key.
|
|
230
|
+
_ELEMENTS: dict = {}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def element_data(symbol_or_number: str | int) -> tuple[int, float]:
|
|
234
|
+
"""
|
|
235
|
+
Atomic number and atomic weight (atomic mass units) of an element.
|
|
236
|
+
|
|
237
|
+
The element is given by its symbol, such as `"Fe"`, or its atomic
|
|
238
|
+
number. A lookup in mendeleev's database takes a fraction of a second,
|
|
239
|
+
so each element is cached under both its symbol and its atomic number.
|
|
240
|
+
|
|
241
|
+
Raises
|
|
242
|
+
------
|
|
243
|
+
ValueError
|
|
244
|
+
If there is no such element.
|
|
245
|
+
"""
|
|
246
|
+
if symbol_or_number not in _ELEMENTS:
|
|
247
|
+
atom = element(symbol_or_number)
|
|
248
|
+
data = (atom.atomic_number, atom.atomic_weight)
|
|
249
|
+
for key in (atom.symbol, atom.atomic_number, symbol_or_number):
|
|
250
|
+
_ELEMENTS[key] = data
|
|
251
|
+
return _ELEMENTS[symbol_or_number]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def wl_to_vel(wl: u.Quantity, wl0: u.Quantity) -> u.Quantity:
|
|
255
|
+
"""
|
|
256
|
+
Line-of-sight velocity that shifts a line from `wl0` to `wl`.
|
|
257
|
+
|
|
258
|
+
A positive velocity is a redshift, away from the observer.
|
|
259
|
+
|
|
260
|
+
Parameters
|
|
261
|
+
----------
|
|
262
|
+
wl : u.Quantity
|
|
263
|
+
Observed wavelength.
|
|
264
|
+
wl0 : u.Quantity
|
|
265
|
+
Rest wavelength.
|
|
266
|
+
|
|
267
|
+
Returns
|
|
268
|
+
-------
|
|
269
|
+
u.Quantity
|
|
270
|
+
Velocity.
|
|
271
|
+
"""
|
|
272
|
+
return (wl - wl0) / wl0 * const.c
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def vel_to_wl(v: u.Quantity, wl0: u.Quantity) -> u.Quantity:
|
|
276
|
+
"""
|
|
277
|
+
Wavelength of a line shifted by a line-of-sight velocity.
|
|
278
|
+
|
|
279
|
+
A positive velocity is a redshift, away from the observer.
|
|
280
|
+
|
|
281
|
+
Parameters
|
|
282
|
+
----------
|
|
283
|
+
v : u.Quantity
|
|
284
|
+
Velocity.
|
|
285
|
+
wl0 : u.Quantity
|
|
286
|
+
Rest wavelength.
|
|
287
|
+
|
|
288
|
+
Returns
|
|
289
|
+
-------
|
|
290
|
+
u.Quantity
|
|
291
|
+
Shifted wavelength.
|
|
292
|
+
"""
|
|
293
|
+
return wl0 * (1 + v / const.c)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def gaussian(wave, peak, centre, sigma, back):
|
|
297
|
+
"""Gaussian function for spectral line fitting."""
|
|
298
|
+
return peak * np.exp(-0.5 * ((wave - centre) / sigma) ** 2) + back
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def multi_gaussian(wave, *params, n_components=1):
|
|
302
|
+
"""Multi-component Gaussian plus constant background.
|
|
303
|
+
|
|
304
|
+
Parameters are ordered as::
|
|
305
|
+
|
|
306
|
+
[peak_0, centre_0, sigma_0, peak_1, centre_1, sigma_1, ..., background]
|
|
307
|
+
|
|
308
|
+
Total number of parameters = 3 * n_components + 1.
|
|
309
|
+
"""
|
|
310
|
+
result = np.zeros_like(wave, dtype=float)
|
|
311
|
+
for i in range(n_components):
|
|
312
|
+
peak = params[3 * i]
|
|
313
|
+
centre = params[3 * i + 1]
|
|
314
|
+
sigma = params[3 * i + 2]
|
|
315
|
+
if sigma == 0:
|
|
316
|
+
continue
|
|
317
|
+
result += peak * np.exp(-0.5 * ((wave - centre) / sigma) ** 2)
|
|
318
|
+
result += params[-1] # background
|
|
319
|
+
return result
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def pixel_mean_gaussians(wave, *params, n_components=1, pixel=1.0):
|
|
323
|
+
"""
|
|
324
|
+
:func:`multi_gaussian` averaged over pixels `pixel` wide centred on `wave`.
|
|
325
|
+
|
|
326
|
+
This is what a detector pixel records. For a line narrower than a pixel
|
|
327
|
+
it differs from the Gaussian at the pixel centre. The parameters are as
|
|
328
|
+
for :func:`multi_gaussian`.
|
|
329
|
+
"""
|
|
330
|
+
from scipy.special import erf
|
|
331
|
+
|
|
332
|
+
result = np.zeros_like(wave, dtype=float)
|
|
333
|
+
half = pixel / 2
|
|
334
|
+
for i in range(n_components):
|
|
335
|
+
peak = params[3 * i]
|
|
336
|
+
centre = params[3 * i + 1]
|
|
337
|
+
sigma = params[3 * i + 2]
|
|
338
|
+
if sigma == 0:
|
|
339
|
+
continue
|
|
340
|
+
scale = np.sqrt(2.0) * sigma
|
|
341
|
+
result += (peak * sigma * np.sqrt(np.pi / 2) / pixel
|
|
342
|
+
* (erf((wave + half - centre) / scale) - erf((wave - half - centre) / scale)))
|
|
343
|
+
result += params[-1] # background
|
|
344
|
+
return result
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _bin_edges(centres: np.ndarray) -> np.ndarray:
|
|
348
|
+
"""Bin edges halfway between `centres`, the outer ones mirrored about the end centres."""
|
|
349
|
+
inner = 0.5 * (centres[1:] + centres[:-1])
|
|
350
|
+
return np.concatenate([[centres[0] - (inner[0] - centres[0])], inner,
|
|
351
|
+
[centres[-1] + (centres[-1] - inner[-1])]])
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def onto_wavelength_bins(spectra: np.ndarray, wavelength: np.ndarray,
|
|
355
|
+
reference: np.ndarray) -> np.ndarray:
|
|
356
|
+
"""
|
|
357
|
+
Spectra averaged onto the wavelength bins of another grid.
|
|
358
|
+
|
|
359
|
+
Each sample covers the bin halfway to its neighbours, and each reference
|
|
360
|
+
bin gets the mean of the samples overlapping it, taken as zero outside
|
|
361
|
+
the sampled range. This keeps the integral, so a line narrower than a
|
|
362
|
+
bin is not lost as it would be by interpolation. Spectra already on the
|
|
363
|
+
reference grid are returned unchanged.
|
|
364
|
+
|
|
365
|
+
`wavelength` and `reference` are increasing, in the same unit, and
|
|
366
|
+
`spectra` has wavelength on its last axis.
|
|
367
|
+
"""
|
|
368
|
+
spectra = np.asarray(spectra, dtype=float)
|
|
369
|
+
if wavelength.shape == reference.shape and np.array_equal(wavelength, reference):
|
|
370
|
+
return spectra
|
|
371
|
+
source, target = _bin_edges(wavelength), _bin_edges(reference)
|
|
372
|
+
# Only overlapping bins are used, to save memory and time and to stop a
|
|
373
|
+
# NaN in one sample spreading to every bin.
|
|
374
|
+
last_bin = reference.size - 1
|
|
375
|
+
first = np.clip(np.searchsorted(target, source[:-1], side="right") - 1, 0, last_bin)
|
|
376
|
+
last = np.clip(np.searchsorted(target, source[1:], side="left") - 1, 0, last_bin)
|
|
377
|
+
count = last - first + 1
|
|
378
|
+
rows = np.repeat(np.arange(wavelength.size), count)
|
|
379
|
+
cols = np.repeat(first, count) + np.arange(count.sum()) - np.repeat(np.cumsum(count) - count, count)
|
|
380
|
+
overlap = np.minimum(source[rows + 1], target[cols + 1]) - np.maximum(source[rows], target[cols])
|
|
381
|
+
kept = overlap > 0
|
|
382
|
+
weights = sparse.csr_matrix(
|
|
383
|
+
(overlap[kept] / np.diff(target)[cols[kept]], (rows[kept], cols[kept])),
|
|
384
|
+
shape=(wavelength.size, reference.size))
|
|
385
|
+
flat = spectra.reshape(-1, wavelength.size)
|
|
386
|
+
return np.asarray((weights.T @ flat.T).T).reshape(spectra.shape[:-1] + reference.shape)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def angle_to_distance(angle: u.Quantity) -> u.Quantity:
|
|
390
|
+
"""
|
|
391
|
+
Length on the Sun subtended by an angle seen from 1 AU.
|
|
392
|
+
|
|
393
|
+
Parameters
|
|
394
|
+
----------
|
|
395
|
+
angle : u.Quantity
|
|
396
|
+
Angle.
|
|
397
|
+
|
|
398
|
+
Returns
|
|
399
|
+
-------
|
|
400
|
+
u.Quantity
|
|
401
|
+
Length.
|
|
402
|
+
"""
|
|
403
|
+
if angle.unit.physical_type != "angle":
|
|
404
|
+
raise ValueError("Input must be an angle")
|
|
405
|
+
return 2 * const.au * np.tan(angle.to(u.rad) / 2)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def rebin_slit_offchip(cube, n_bin: int):
|
|
409
|
+
"""Sum adjacent pixels along the slit axis to simulate off-chip binning.
|
|
410
|
+
|
|
411
|
+
Off-chip (ground-based) binning sums pixels after read-out, so each
|
|
412
|
+
carries its own independent noise (read noise, dark current, etc.). The
|
|
413
|
+
signal grows by `n_bin` and uncorrelated noise adds in quadrature, so
|
|
414
|
+
the SNR improves by sqrt(n_bin).
|
|
415
|
+
|
|
416
|
+
Parameters
|
|
417
|
+
----------
|
|
418
|
+
cube : NDCube
|
|
419
|
+
Data cube with shape `(n_slit, n_scan, n_lambda)`.
|
|
420
|
+
n_bin : int
|
|
421
|
+
Number of slit pixels to sum, >= 1. Pixels at the slit edge that do
|
|
422
|
+
not fill a whole bin are discarded.
|
|
423
|
+
|
|
424
|
+
Returns
|
|
425
|
+
-------
|
|
426
|
+
NDCube
|
|
427
|
+
Rebinned cube with shape `(n_slit // n_bin, n_scan, n_lambda)`. The
|
|
428
|
+
slit pixel scale (CDELT) in the WCS is multiplied by `n_bin`.
|
|
429
|
+
"""
|
|
430
|
+
from ndcube import NDCube
|
|
431
|
+
|
|
432
|
+
if n_bin < 1:
|
|
433
|
+
raise ValueError(f"n_bin must be >= 1, got {n_bin}")
|
|
434
|
+
if n_bin == 1:
|
|
435
|
+
return cube
|
|
436
|
+
|
|
437
|
+
data = cube.data
|
|
438
|
+
n_slit, n_scan, n_lam = data.shape
|
|
439
|
+
if n_bin > n_slit:
|
|
440
|
+
raise ValueError(f"offchip_bin_slit {n_bin} bins more rows than the {n_slit} along the "
|
|
441
|
+
f"slit, so there would be nothing left; bin at most {n_slit}.")
|
|
442
|
+
n_keep = (n_slit // n_bin) * n_bin
|
|
443
|
+
trimmed = data[:n_keep, :, :]
|
|
444
|
+
rebinned = trimmed.reshape(n_keep // n_bin, n_bin, n_scan, n_lam).sum(axis=1)
|
|
445
|
+
|
|
446
|
+
# Update WCS for the slit axis.
|
|
447
|
+
# Numpy axis 0 (slit) corresponds to WCS axis 2 (HPLT) in the
|
|
448
|
+
# reversed FITS convention (naxis-1-numpy_axis for a 3-axis WCS).
|
|
449
|
+
new_wcs = cube.wcs.deepcopy()
|
|
450
|
+
slit_wcs_axis = 2 # HPLT-TAN
|
|
451
|
+
new_wcs.wcs.cdelt[slit_wcs_axis] *= n_bin
|
|
452
|
+
# Map the original reference pixel to the new grid.
|
|
453
|
+
# FITS crpix is 1-based; the center-preserving mapping for binning
|
|
454
|
+
# anchored at pixel 1 is: crpix_new = (crpix_old - 0.5) / n_bin + 0.5
|
|
455
|
+
new_wcs.wcs.crpix[slit_wcs_axis] = (
|
|
456
|
+
new_wcs.wcs.crpix[slit_wcs_axis] - 0.5
|
|
457
|
+
) / n_bin + 0.5
|
|
458
|
+
|
|
459
|
+
# Drop the per-pixel photon metadata: binned_photon_wavelengths works out
|
|
460
|
+
# the binned pixels' photon wavelengths from the photons of their rows.
|
|
461
|
+
meta = cube.meta
|
|
462
|
+
carried = ("photon_wavelength", "photon_rms_wavelength", "photon_filter_transmission",
|
|
463
|
+
"blocked_photon_wavelength", "blocked_photon_rms_wavelength")
|
|
464
|
+
if isinstance(meta, dict) and any(key in meta for key in carried):
|
|
465
|
+
meta = {key: value for key, value in meta.items() if key not in carried}
|
|
466
|
+
return NDCube(data=rebinned, wcs=new_wcs, unit=cube.unit, meta=meta)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def binned_photon_wavelengths(photons, n_bin: int) -> tuple:
|
|
470
|
+
"""
|
|
471
|
+
Wavelengths of the mean and RMS photon energies in off-chip binned pixels.
|
|
472
|
+
|
|
473
|
+
Parameters
|
|
474
|
+
----------
|
|
475
|
+
photons : NDCube
|
|
476
|
+
Photons in each pixel, with the wavelengths of the mean and RMS
|
|
477
|
+
photon energies in `meta["photon_wavelength"]` and
|
|
478
|
+
`meta["photon_rms_wavelength"]`, as from `simulate_once`. Without the
|
|
479
|
+
second, each pixel's photons are taken to have a single energy.
|
|
480
|
+
n_bin : int
|
|
481
|
+
Number of pixels summed along the slit, as in
|
|
482
|
+
:func:`rebin_slit_offchip`.
|
|
483
|
+
|
|
484
|
+
Returns
|
|
485
|
+
-------
|
|
486
|
+
tuple of u.Quantity
|
|
487
|
+
`(mean, rms)`, each with the shape of the binned pixels. A binned
|
|
488
|
+
pixel with no photons takes the mean-energy wavelength of its first
|
|
489
|
+
row for both.
|
|
490
|
+
"""
|
|
491
|
+
from ndcube import NDCube
|
|
492
|
+
from .sampling import _carry_photon_wavelengths
|
|
493
|
+
|
|
494
|
+
mean = u.Quantity(photons.meta["photon_wavelength"])
|
|
495
|
+
rms = u.Quantity(photons.meta.get("photon_rms_wavelength", mean)).to(mean.unit)
|
|
496
|
+
if n_bin == 1:
|
|
497
|
+
return mean, rms
|
|
498
|
+
n_keep = (photons.data.shape[0] // n_bin) * n_bin
|
|
499
|
+
|
|
500
|
+
def binned(values):
|
|
501
|
+
return rebin_slit_offchip(NDCube(values, wcs=photons.wcs), n_bin).data
|
|
502
|
+
|
|
503
|
+
_, binned_mean, binned_rms = _carry_photon_wavelengths(
|
|
504
|
+
binned, photons.data, mean.value, rms.value, mean.value[:n_keep:n_bin])
|
|
505
|
+
return binned_mean * mean.unit, binned_rms * mean.unit
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def distance_to_angle(distance: u.Quantity) -> u.Quantity:
|
|
509
|
+
"""
|
|
510
|
+
Angle subtended by a length on the Sun seen from 1 AU.
|
|
511
|
+
|
|
512
|
+
Parameters
|
|
513
|
+
----------
|
|
514
|
+
distance : u.Quantity
|
|
515
|
+
Length.
|
|
516
|
+
|
|
517
|
+
Returns
|
|
518
|
+
-------
|
|
519
|
+
u.Quantity
|
|
520
|
+
Angle in arcsec.
|
|
521
|
+
"""
|
|
522
|
+
if distance.unit.physical_type != "length":
|
|
523
|
+
raise ValueError("Input must be a length")
|
|
524
|
+
return (2 * np.arctan(distance / (2 * const.au))).to(u.arcsec)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def parse_yaml_input(val):
|
|
528
|
+
"""Parse YAML input values - handle both single values and lists."""
|
|
529
|
+
if isinstance(val, str):
|
|
530
|
+
return u.Quantity(val)
|
|
531
|
+
elif isinstance(val, (list, tuple)):
|
|
532
|
+
# Handle list of values
|
|
533
|
+
if all(isinstance(v, str) for v in val):
|
|
534
|
+
return [u.Quantity(v) for v in val]
|
|
535
|
+
else:
|
|
536
|
+
return list(val)
|
|
537
|
+
else:
|
|
538
|
+
return val
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def ensure_list(val):
|
|
542
|
+
"""Ensure input is a list (for parameter sweeps)."""
|
|
543
|
+
if not isinstance(val, (list, tuple)):
|
|
544
|
+
return [val]
|
|
545
|
+
return list(val)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def save_maps(path: str | Path, log_intensity: np.ndarray, v_map: u.Quantity,
|
|
549
|
+
x_pix_size: float, y_pix_size: float) -> None:
|
|
550
|
+
"""Save intensity and velocity maps for later comparison."""
|
|
551
|
+
np.savez(
|
|
552
|
+
path,
|
|
553
|
+
log_si=log_intensity,
|
|
554
|
+
v_map=v_map.to(u.km / u.s).value,
|
|
555
|
+
x_pix_size=x_pix_size,
|
|
556
|
+
y_pix_size=y_pix_size,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def load_maps(path: str | Path) -> dict:
|
|
561
|
+
"""Load previously saved intensity and velocity maps."""
|
|
562
|
+
dat = np.load(path)
|
|
563
|
+
return dict(
|
|
564
|
+
log_si=dat["log_si"],
|
|
565
|
+
v_map=dat["v_map"],
|
|
566
|
+
x_pix_size=float(dat["x_pix_size"]),
|
|
567
|
+
y_pix_size=float(dat["y_pix_size"]),
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def get_git_commit_id() -> str:
|
|
572
|
+
"""
|
|
573
|
+
Get the last git commit ID from the package's git repository.
|
|
574
|
+
|
|
575
|
+
Only a git checkout of ECLIPSE, with the package in its `src` folder,
|
|
576
|
+
has one. An environment inside another repository, such as a project's
|
|
577
|
+
.venv, is not part of it, and that repository's commit says nothing
|
|
578
|
+
about ECLIPSE.
|
|
579
|
+
"""
|
|
580
|
+
try:
|
|
581
|
+
from importlib.resources import files
|
|
582
|
+
package = Path(str(files("eclipse"))).resolve()
|
|
583
|
+
pkg_path = package.parent.parent
|
|
584
|
+
if pkg_path / "src" / "eclipse" != package:
|
|
585
|
+
return "unknown (not installed from a git checkout of ECLIPSE)"
|
|
586
|
+
top = subprocess.run(
|
|
587
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
588
|
+
capture_output=True, text=True, cwd=pkg_path, timeout=5,
|
|
589
|
+
)
|
|
590
|
+
if top.returncode != 0:
|
|
591
|
+
return "unknown (not a git repository)"
|
|
592
|
+
if Path(top.stdout.strip()).resolve() != pkg_path.resolve():
|
|
593
|
+
return "unknown (not installed from a git checkout of ECLIPSE)"
|
|
594
|
+
result = subprocess.run(
|
|
595
|
+
["git", "rev-parse", "HEAD"],
|
|
596
|
+
capture_output=True, text=True, cwd=pkg_path, timeout=5,
|
|
597
|
+
)
|
|
598
|
+
if result.returncode == 0:
|
|
599
|
+
return result.stdout.strip()
|
|
600
|
+
return "unknown (not a git repository)"
|
|
601
|
+
except Exception as e:
|
|
602
|
+
return f"unknown ({e})"
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _get_software_version() -> str:
|
|
606
|
+
"""Get the installed package version."""
|
|
607
|
+
try:
|
|
608
|
+
import eclipse
|
|
609
|
+
return eclipse.__version__
|
|
610
|
+
except Exception:
|
|
611
|
+
return "unknown"
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def deduplicate_list(param_list, param_name):
|
|
615
|
+
"""
|
|
616
|
+
Remove duplicates from a parameter list and warn if duplicates were found.
|
|
617
|
+
|
|
618
|
+
Parameters
|
|
619
|
+
----------
|
|
620
|
+
param_list : list
|
|
621
|
+
List of parameter values that may contain duplicates.
|
|
622
|
+
param_name : str
|
|
623
|
+
Name of the parameter for warning messages.
|
|
624
|
+
|
|
625
|
+
Returns
|
|
626
|
+
-------
|
|
627
|
+
list
|
|
628
|
+
List with duplicates removed, preserving original order.
|
|
629
|
+
"""
|
|
630
|
+
seen = set()
|
|
631
|
+
deduplicated = []
|
|
632
|
+
duplicates_found = False
|
|
633
|
+
|
|
634
|
+
for item in param_list:
|
|
635
|
+
if hasattr(item, "unit"):
|
|
636
|
+
try:
|
|
637
|
+
key = float(item.si.value)
|
|
638
|
+
except Exception:
|
|
639
|
+
try:
|
|
640
|
+
key = float(item.to(u.K, equivalencies=u.temperature()).value)
|
|
641
|
+
except Exception:
|
|
642
|
+
key = float(item.value)
|
|
643
|
+
else:
|
|
644
|
+
key = item
|
|
645
|
+
|
|
646
|
+
if key not in seen:
|
|
647
|
+
seen.add(key)
|
|
648
|
+
deduplicated.append(item)
|
|
649
|
+
else:
|
|
650
|
+
duplicates_found = True
|
|
651
|
+
|
|
652
|
+
if duplicates_found:
|
|
653
|
+
warnings.warn(
|
|
654
|
+
f"Duplicate values found in '{param_name}' parameter list. "
|
|
655
|
+
f"Removed duplicates: {len(param_list)} -> {len(deduplicated)} unique values.",
|
|
656
|
+
UserWarning,
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
return deduplicated
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
# List-type dataclass fields: treated as a single fixed value (not a sweep dimension)
|
|
663
|
+
# even when the YAML value is a list.
|
|
664
|
+
_SECTION_LIST_FIELDS = {
|
|
665
|
+
"simulation": [],
|
|
666
|
+
"detector": ["dispersion", "lit_band", "slit_image_tilt"],
|
|
667
|
+
"telescope": ["psf_params", "fiducial_centres", "fiducial_lengths"],
|
|
668
|
+
"filter": [],
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
# String-valued dataclass fields. These bypass parse_yaml_input, which reads
|
|
672
|
+
# every string as a Quantity and would raise on "gaussian" or "2012-06-03".
|
|
673
|
+
# They can still be swept: a list of strings becomes a sweep dimension.
|
|
674
|
+
_SECTION_STRING_FIELDS = {
|
|
675
|
+
# 'instrument' is left out on purpose. It is a Simulation field, but the
|
|
676
|
+
# top-level key chooses the instrument and main() builds both Simulation
|
|
677
|
+
# objects from it, so a value here would be parsed, swept and discarded.
|
|
678
|
+
# main() rejects it rather than let it appear to take effect.
|
|
679
|
+
"simulation": ["psf_boundary", "spectral_psf"],
|
|
680
|
+
"detector": ["material"],
|
|
681
|
+
"telescope": ["psf_type", "calibration", "date", "pm_table", "grating_table",
|
|
682
|
+
"field_of_view", "resolution_budget"],
|
|
683
|
+
"filter": ["al_table", "oxide_table", "c_table"],
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
class _UniqueKeyLoader(yaml.SafeLoader):
|
|
688
|
+
"""PyYAML's safe loader, refusing repeated keys in a mapping."""
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _construct_mapping_once(loader, node, deep=False):
|
|
692
|
+
# YAML forbids repeated keys, but PyYAML keeps the last, so a section
|
|
693
|
+
# written twice would lose its first block and fall back to defaults.
|
|
694
|
+
seen = set()
|
|
695
|
+
for key_node, _ in node.value:
|
|
696
|
+
# Skip merge keys ("<<: *anchor"): construct_mapping brings in the
|
|
697
|
+
# anchor's keys, which keys beside it may override, as YAML allows.
|
|
698
|
+
if key_node.tag == "tag:yaml.org,2002:merge":
|
|
699
|
+
continue
|
|
700
|
+
key = loader.construct_object(key_node, deep=deep)
|
|
701
|
+
if key in seen:
|
|
702
|
+
raise yaml.constructor.ConstructorError(
|
|
703
|
+
None, None, f"the key {key!r} is given twice in one mapping", key_node.start_mark)
|
|
704
|
+
seen.add(key)
|
|
705
|
+
return loader.construct_mapping(node, deep=deep)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
_UniqueKeyLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
|
|
709
|
+
_construct_mapping_once)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def load_yaml_config(text: str):
|
|
713
|
+
"""Parse configuration YAML as yaml.safe_load does, but refuse repeated keys."""
|
|
714
|
+
return yaml.load(text, Loader=_UniqueKeyLoader)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _parse_section(section_dict: dict, class_name: str) -> tuple:
|
|
718
|
+
"""
|
|
719
|
+
Parse a YAML config section into fixed and sweep parameters.
|
|
720
|
+
|
|
721
|
+
Any field whose value is a list of more than one element becomes a sweep
|
|
722
|
+
dimension. Fields in `_SECTION_LIST_FIELDS` are always one fixed
|
|
723
|
+
(list-valued) parameter, and fields in `_SECTION_STRING_FIELDS` are taken
|
|
724
|
+
verbatim rather than parsed as quantities.
|
|
725
|
+
|
|
726
|
+
Parameters
|
|
727
|
+
----------
|
|
728
|
+
section_dict : dict
|
|
729
|
+
YAML section content, e.g. `config["detector"]`.
|
|
730
|
+
class_name : str
|
|
731
|
+
Section name: `"simulation"`, `"detector"`, `"telescope"` or
|
|
732
|
+
`"filter"`.
|
|
733
|
+
|
|
734
|
+
Returns
|
|
735
|
+
-------
|
|
736
|
+
fixed_params : dict
|
|
737
|
+
`{attr: value}`: single values used for every combination.
|
|
738
|
+
sweep_params : dict
|
|
739
|
+
`{attr: [values]}`: lists of values to sweep over.
|
|
740
|
+
"""
|
|
741
|
+
list_fields = _SECTION_LIST_FIELDS.get(class_name, [])
|
|
742
|
+
string_fields = _SECTION_STRING_FIELDS.get(class_name, [])
|
|
743
|
+
fixed = {}
|
|
744
|
+
sweep = {}
|
|
745
|
+
|
|
746
|
+
for key, val in section_dict.items():
|
|
747
|
+
# An empty key would read as None: noise off, or no exposure times and
|
|
748
|
+
# so no run at all, reported as a success.
|
|
749
|
+
if val is None or (isinstance(val, (list, tuple)) and len(val) == 0):
|
|
750
|
+
raise ValueError(f"'{class_name}.{key}' is empty. Give it a value, or leave it "
|
|
751
|
+
f"out for its default.")
|
|
752
|
+
if key in list_fields:
|
|
753
|
+
parsed = parse_yaml_input(val)
|
|
754
|
+
fixed[key] = parsed if isinstance(parsed, list) else [parsed]
|
|
755
|
+
elif key.endswith("_table") and isinstance(val, (list, tuple)):
|
|
756
|
+
# Result keys leave out the tables, which are files, so a sweep
|
|
757
|
+
# over tables would give every table's results the same key.
|
|
758
|
+
raise ValueError(f"'{class_name}.{key}' names one table; to compare tables, "
|
|
759
|
+
f"run each in its own configuration.")
|
|
760
|
+
else:
|
|
761
|
+
if key in string_fields:
|
|
762
|
+
parsed = list(val) if isinstance(val, (list, tuple)) else val
|
|
763
|
+
else:
|
|
764
|
+
parsed = parse_yaml_input(val)
|
|
765
|
+
if isinstance(parsed, list):
|
|
766
|
+
if len(parsed) == 1:
|
|
767
|
+
fixed[key] = parsed[0]
|
|
768
|
+
else:
|
|
769
|
+
sweep[key] = deduplicate_list(parsed, f"{class_name}.{key}")
|
|
770
|
+
else:
|
|
771
|
+
fixed[key] = parsed
|
|
772
|
+
|
|
773
|
+
return fixed, sweep
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _to_canonical_scalar(val):
|
|
777
|
+
"""
|
|
778
|
+
Value as a plain scalar, for comparing parameters.
|
|
779
|
+
|
|
780
|
+
Quantities become their SI value, so `40 * u.s` and `40000 * u.ms` both
|
|
781
|
+
give 40.0. Offset units such as Celsius are converted to Kelvin first.
|
|
782
|
+
"""
|
|
783
|
+
if not hasattr(val, "unit"):
|
|
784
|
+
return val
|
|
785
|
+
try:
|
|
786
|
+
return float(val.si.value)
|
|
787
|
+
except Exception:
|
|
788
|
+
try:
|
|
789
|
+
return float(val.to(u.K, equivalencies=u.temperature()).value)
|
|
790
|
+
except Exception:
|
|
791
|
+
return float(val.value)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _params_to_key(params: dict) -> tuple:
|
|
795
|
+
"""
|
|
796
|
+
Convert a parameters dict to a hashable tuple key.
|
|
797
|
+
|
|
798
|
+
Quantities become canonical SI scalars, so the key does not depend on
|
|
799
|
+
the units in the YAML file. Parameters are sorted by name for a fixed
|
|
800
|
+
order.
|
|
801
|
+
|
|
802
|
+
`simulation.pinhole_sizes` and `simulation.pinhole_positions` are left
|
|
803
|
+
out because they are not sweep dimensions.
|
|
804
|
+
"""
|
|
805
|
+
_skip = {"simulation.pinhole_sizes", "simulation.pinhole_positions"}
|
|
806
|
+
items = {}
|
|
807
|
+
for name, val in params.items():
|
|
808
|
+
if name in _skip:
|
|
809
|
+
continue
|
|
810
|
+
if isinstance(val, (list, tuple)):
|
|
811
|
+
items[name] = tuple(_to_canonical_scalar(v) for v in val)
|
|
812
|
+
else:
|
|
813
|
+
items[name] = _to_canonical_scalar(val)
|
|
814
|
+
return tuple(sorted(items.items()))
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _extract_config_params(obj, section: str) -> dict:
|
|
818
|
+
"""
|
|
819
|
+
Extract all user-facing field values from a dataclass instance.
|
|
820
|
+
|
|
821
|
+
Skips private fields (name starts with '_'), Path-valued fields (data file
|
|
822
|
+
paths), and fields whose value is itself a dataclass (nested objects are
|
|
823
|
+
stored under their own section).
|
|
824
|
+
"""
|
|
825
|
+
params = {}
|
|
826
|
+
for f in dataclasses.fields(obj):
|
|
827
|
+
if f.name.startswith("_"):
|
|
828
|
+
continue
|
|
829
|
+
val = getattr(obj, f.name)
|
|
830
|
+
if isinstance(val, Path):
|
|
831
|
+
continue
|
|
832
|
+
if dataclasses.is_dataclass(val):
|
|
833
|
+
continue
|
|
834
|
+
params[f"{section}.{f.name}"] = val
|
|
835
|
+
return params
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
@contextlib.contextmanager
|
|
840
|
+
def tqdm_joblib(tqdm_object):
|
|
841
|
+
"""
|
|
842
|
+
Context manager that patches joblib so it uses the supplied tqdm
|
|
843
|
+
instance to report progress.
|
|
844
|
+
"""
|
|
845
|
+
class TqdmBatchCompletionCallback(joblib.parallel.BatchCompletionCallBack): # type: ignore[attr-defined]
|
|
846
|
+
def __call__(self, *args, **kwargs):
|
|
847
|
+
tqdm_object.update(n=self.batch_size)
|
|
848
|
+
return super().__call__(*args, **kwargs)
|
|
849
|
+
|
|
850
|
+
old_callback = joblib.parallel.BatchCompletionCallBack # type: ignore[attr-defined]
|
|
851
|
+
joblib.parallel.BatchCompletionCallBack = TqdmBatchCompletionCallback
|
|
852
|
+
try:
|
|
853
|
+
yield
|
|
854
|
+
finally:
|
|
855
|
+
joblib.parallel.BatchCompletionCallBack = old_callback
|
|
856
|
+
tqdm_object.close()
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _fwhm_to_sigma(fwhm: float) -> float:
|
|
860
|
+
"""Convert FWHM to Gaussian sigma: sigma = FWHM / (2 * sqrt(2 * ln2))."""
|
|
861
|
+
return fwhm / (2.0 * np.sqrt(2.0 * np.log(2.0)))
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
# Keys that moved or were renamed, so an old config is told where the setting
|
|
865
|
+
# went, not just that the key is unknown.
|
|
866
|
+
_RENAMED_CONFIG_KEYS = {
|
|
867
|
+
"aluminium_thickness": "filter.al_thickness",
|
|
868
|
+
"slit_bin_pairs": "offchip_bin_slit",
|
|
869
|
+
"exposure": "simulation.expos",
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _describe_key(key: str, where: str) -> str:
|
|
874
|
+
"""Phrase naming `key` in section `where` ('' for the top level)."""
|
|
875
|
+
if where:
|
|
876
|
+
return f"'{key}' in the '{where}:' section"
|
|
877
|
+
return f"'{key}' at the top level"
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def suggest_config_key(key: str, allowed, elsewhere: dict | None = None):
|
|
881
|
+
"""
|
|
882
|
+
Best guess at what an unrecognised config key was meant to be.
|
|
883
|
+
|
|
884
|
+
Looks for a rename, then the same name in another section, then a near
|
|
885
|
+
miss. The section lookup matters most in practice: the old flat layout
|
|
886
|
+
put keys such as `expos` and `ccd_temperature` at the top level, where
|
|
887
|
+
they are valid names at the wrong depth.
|
|
888
|
+
|
|
889
|
+
Parameters
|
|
890
|
+
----------
|
|
891
|
+
key : str
|
|
892
|
+
Unrecognised key.
|
|
893
|
+
allowed : iterable of str
|
|
894
|
+
Keys that are valid where this one appeared.
|
|
895
|
+
elsewhere : dict, optional
|
|
896
|
+
`{section_name: valid_keys}` for the other places a key could live.
|
|
897
|
+
A section name of `''` means the top level.
|
|
898
|
+
|
|
899
|
+
Returns
|
|
900
|
+
-------
|
|
901
|
+
str or None
|
|
902
|
+
A phrase naming the suggestion, or None if nothing looks close.
|
|
903
|
+
"""
|
|
904
|
+
if key in _RENAMED_CONFIG_KEYS:
|
|
905
|
+
return f"'{_RENAMED_CONFIG_KEYS[key]}'"
|
|
906
|
+
|
|
907
|
+
for where, fields in (elsewhere or {}).items():
|
|
908
|
+
if key in fields:
|
|
909
|
+
return _describe_key(key, where)
|
|
910
|
+
|
|
911
|
+
close = difflib.get_close_matches(key, sorted(allowed), n=1, cutoff=0.7)
|
|
912
|
+
if close:
|
|
913
|
+
return f"'{close[0]}'"
|
|
914
|
+
|
|
915
|
+
for where, fields in (elsewhere or {}).items():
|
|
916
|
+
close = difflib.get_close_matches(key, sorted(fields), n=1, cutoff=0.8)
|
|
917
|
+
if close:
|
|
918
|
+
return _describe_key(close[0], where)
|
|
919
|
+
|
|
920
|
+
return None
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def check_config_keys(provided, allowed, context: str,
|
|
924
|
+
elsewhere: dict | None = None) -> None:
|
|
925
|
+
"""
|
|
926
|
+
Raise if `provided` has any key not in `allowed`.
|
|
927
|
+
|
|
928
|
+
An unread key is not a harmless typo: the run uses the default, gives
|
|
929
|
+
plausible output and says nothing. A sweep at the wrong depth is worse,
|
|
930
|
+
as it returns results that are identical for every combination.
|
|
931
|
+
|
|
932
|
+
Parameters
|
|
933
|
+
----------
|
|
934
|
+
provided : iterable of str
|
|
935
|
+
Keys found in the config.
|
|
936
|
+
allowed : iterable of str
|
|
937
|
+
Keys that are valid here.
|
|
938
|
+
context : str
|
|
939
|
+
Where the keys are, for the error message, e.g. `"top-level"`.
|
|
940
|
+
elsewhere : dict, optional
|
|
941
|
+
Passed to :func:`suggest_config_key`.
|
|
942
|
+
|
|
943
|
+
Raises
|
|
944
|
+
------
|
|
945
|
+
ValueError
|
|
946
|
+
If any key is unrecognised, listing each one with a suggestion.
|
|
947
|
+
"""
|
|
948
|
+
allowed = set(allowed)
|
|
949
|
+
unknown = [k for k in provided if k not in allowed]
|
|
950
|
+
if not unknown:
|
|
951
|
+
return
|
|
952
|
+
|
|
953
|
+
lines = []
|
|
954
|
+
for key in sorted(unknown, key=str):
|
|
955
|
+
guess = suggest_config_key(str(key), allowed, elsewhere)
|
|
956
|
+
if guess:
|
|
957
|
+
lines.append(f" {key!r}: did you mean {guess}?")
|
|
958
|
+
else:
|
|
959
|
+
lines.append(f" {key!r}")
|
|
960
|
+
|
|
961
|
+
visible = sorted(k for k in allowed if not k.startswith("_"))
|
|
962
|
+
plural = "keys" if len(unknown) > 1 else "key"
|
|
963
|
+
raise ValueError(
|
|
964
|
+
f"Unrecognised {context} config {plural}:\n"
|
|
965
|
+
+ "\n".join(lines)
|
|
966
|
+
+ f"\n\nECLIPSE never reads these, so the run would have used the "
|
|
967
|
+
f"default for whatever each was meant to set.\n"
|
|
968
|
+
f"Valid {context} keys: {', '.join(visible)}"
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
# Units in the last place of single precision that an evenly spaced grid may
|
|
973
|
+
# be off by. MURaM's float32 heights are up to 1.4 ULP from even (7 m at
|
|
974
|
+
# 42 Mm), and edges computed from such centres add their own rounding.
|
|
975
|
+
_SINGLE_PRECISION_ULPS = 4
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def require_uniform_grid(values, name: str, rtol: float = 1e-6) -> float:
|
|
979
|
+
"""
|
|
980
|
+
Check that `values` is a finite, increasing, evenly spaced 1D grid.
|
|
981
|
+
|
|
982
|
+
The velocity binning and the wavelength WCS both take one spacing
|
|
983
|
+
and apply it everywhere, so an uneven grid is silently misread. A
|
|
984
|
+
decreasing grid is worse: the bin edges come out in descending order,
|
|
985
|
+
every `>= low & < high` test fails, and the emission measure is zero
|
|
986
|
+
everywhere.
|
|
987
|
+
|
|
988
|
+
Parameters
|
|
989
|
+
----------
|
|
990
|
+
values : np.ndarray or u.Quantity
|
|
991
|
+
1D grid of bin centres.
|
|
992
|
+
name : str
|
|
993
|
+
Name to use in the error message.
|
|
994
|
+
rtol : float, optional
|
|
995
|
+
Largest allowed offset of any value from the evenly spaced grid
|
|
996
|
+
through the first and last, relative to the spacing. The default
|
|
997
|
+
allows the rounding in `np.arange` and `np.linspace` but not a grid
|
|
998
|
+
built unevenly on purpose. Single-precision rounding, common in
|
|
999
|
+
simulation output, is allowed whatever `rtol`, as long as it is
|
|
1000
|
+
under half a spacing.
|
|
1001
|
+
|
|
1002
|
+
Returns
|
|
1003
|
+
-------
|
|
1004
|
+
float
|
|
1005
|
+
Mean spacing, in the units of `values`, which single-precision
|
|
1006
|
+
rounding in one cell does not skew.
|
|
1007
|
+
"""
|
|
1008
|
+
plain = np.asarray(getattr(values, "value", values), dtype=float)
|
|
1009
|
+
|
|
1010
|
+
if plain.ndim != 1:
|
|
1011
|
+
raise ValueError(f"{name} must be 1D, got {plain.ndim} dimensions.")
|
|
1012
|
+
if plain.size < 2:
|
|
1013
|
+
raise ValueError(f"{name} must have at least 2 elements, "
|
|
1014
|
+
f"got {plain.size}.")
|
|
1015
|
+
|
|
1016
|
+
# Comparisons with NaN are always false, so a NaN or inf in the grid can
|
|
1017
|
+
# slip past the spacing checks below and come back as the spacing.
|
|
1018
|
+
non_finite = np.flatnonzero(~np.isfinite(plain))
|
|
1019
|
+
if non_finite.size:
|
|
1020
|
+
first_bad = int(non_finite[0])
|
|
1021
|
+
raise ValueError(f"{name} must be finite, got {plain[first_bad]} "
|
|
1022
|
+
f"at index {first_bad}.")
|
|
1023
|
+
|
|
1024
|
+
if plain[1] <= plain[0] or plain[-1] <= plain[0]:
|
|
1025
|
+
raise ValueError(
|
|
1026
|
+
f"{name} must increase. Bin edges are built by stepping out from "
|
|
1027
|
+
f"the first value, so a decreasing grid produces edges in "
|
|
1028
|
+
f"descending order and every bin ends up empty."
|
|
1029
|
+
)
|
|
1030
|
+
step = float(plain[-1] - plain[0]) / (plain.size - 1)
|
|
1031
|
+
|
|
1032
|
+
# Single-precision values are off by up to a few units in the last place,
|
|
1033
|
+
# about a part in 1e7 of the largest grid value, however fine the
|
|
1034
|
+
# spacing. If that reaches half a spacing, single precision cannot hold
|
|
1035
|
+
# the grid, as a value that far off lies in its neighbour's cell. Below
|
|
1036
|
+
# that, every value stays in its own cell and the grid still increases.
|
|
1037
|
+
rounding = _SINGLE_PRECISION_ULPS * np.finfo(np.float32).eps * np.abs(plain).max()
|
|
1038
|
+
tolerance = max(rtol * step, rounding if rounding < step / 2 else 0.0)
|
|
1039
|
+
offsets = plain - (plain[0] + np.arange(plain.size) * step)
|
|
1040
|
+
worst = int(np.argmax(np.abs(offsets)))
|
|
1041
|
+
if abs(offsets[worst]) > tolerance:
|
|
1042
|
+
raise ValueError(
|
|
1043
|
+
f"{name} must be evenly spaced. Element {worst} is {plain[worst]:.10g}, "
|
|
1044
|
+
f"{offsets[worst] / step:+.3g} of a spacing ({step:.6g}) from where an evenly "
|
|
1045
|
+
f"spaced grid with the same first and last elements puts it. ECLIPSE uses "
|
|
1046
|
+
f"one spacing for every bin edge and for the wavelength CDELT, so an uneven "
|
|
1047
|
+
f"grid puts emission in the wrong bins and writes wrong wavelength "
|
|
1048
|
+
f"coordinates. Resample onto a uniform grid first."
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
return step
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def velocity_grid(vel_res: u.Quantity, vel_lim: u.Quantity,
|
|
1055
|
+
names: tuple = ("vel_res", "vel_lim")) -> u.Quantity:
|
|
1056
|
+
"""
|
|
1057
|
+
Velocity bin centres `vel_res` apart, to at least `vel_lim` each way, in cm/s.
|
|
1058
|
+
|
|
1059
|
+
The bins step out from zero both ways, so a static plasma is centred on
|
|
1060
|
+
a bin whatever the two values. A grid built from -vel_lim has no bin at
|
|
1061
|
+
zero when the limit is not a whole number of steps, and a static line
|
|
1062
|
+
would come out Doppler shifted. `names` are the names used in the error
|
|
1063
|
+
for a value that is not a positive velocity.
|
|
1064
|
+
"""
|
|
1065
|
+
values = []
|
|
1066
|
+
for value, name in zip((vel_res, vel_lim), names):
|
|
1067
|
+
value = u.Quantity(value)
|
|
1068
|
+
if not value.unit.is_equivalent(u.km / u.s) or not np.isfinite(value) or value <= 0:
|
|
1069
|
+
raise ValueError(f"{name} must be a positive velocity, got {value}.")
|
|
1070
|
+
values.append(value.to_value(u.cm / u.s))
|
|
1071
|
+
res, lim = values
|
|
1072
|
+
# Whole steps to reach lim, rounding up only when lim is not a whole
|
|
1073
|
+
# number of steps, whatever rounding error the division has.
|
|
1074
|
+
steps = int(np.ceil(np.round(lim / res, 9)))
|
|
1075
|
+
return np.arange(-steps, steps + 1) * res * (u.cm / u.s)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def velocity_centers_to_edges(vel_grid: np.ndarray) -> np.ndarray:
|
|
1079
|
+
"""
|
|
1080
|
+
Convert velocity grid centers to bin edges.
|
|
1081
|
+
|
|
1082
|
+
Parameters
|
|
1083
|
+
----------
|
|
1084
|
+
vel_grid : np.ndarray
|
|
1085
|
+
1D array of velocity centers. Must be evenly spaced and increasing.
|
|
1086
|
+
|
|
1087
|
+
Returns
|
|
1088
|
+
-------
|
|
1089
|
+
np.ndarray
|
|
1090
|
+
1D array of velocity bin edges (length = len(vel_grid) + 1).
|
|
1091
|
+
"""
|
|
1092
|
+
dv = require_uniform_grid(vel_grid, "vel_grid")
|
|
1093
|
+
|
|
1094
|
+
return np.concatenate([
|
|
1095
|
+
[vel_grid[0] - 0.5 * dv],
|
|
1096
|
+
vel_grid[:-1] + 0.5 * dv,
|
|
1097
|
+
[vel_grid[-1] + 0.5 * dv]
|
|
1098
|
+
])
|
|
1099
|
+
|
|
1100
|
+
def require_downsample_divides(shape: tuple[int, ...], downsample: int) -> None:
|
|
1101
|
+
"""
|
|
1102
|
+
Check that `downsample` divides every dimension of `shape`.
|
|
1103
|
+
|
|
1104
|
+
Downsampling keeps every `downsample`-th cell and gives each kept cell
|
|
1105
|
+
`downsample` times the voxel size. If a dimension is not a multiple of
|
|
1106
|
+
the factor, the last kept cell stands for fewer cells than that, so the
|
|
1107
|
+
domain comes out too large, as does the emission measure when that axis
|
|
1108
|
+
is the line of sight.
|
|
1109
|
+
|
|
1110
|
+
Parameters
|
|
1111
|
+
----------
|
|
1112
|
+
shape : tuple of int
|
|
1113
|
+
Cube dimensions, in any order.
|
|
1114
|
+
downsample : int
|
|
1115
|
+
Downsampling factor.
|
|
1116
|
+
"""
|
|
1117
|
+
if (isinstance(downsample, bool) or not isinstance(downsample, (int, np.integer))
|
|
1118
|
+
or downsample < 1):
|
|
1119
|
+
raise ValueError(f"The downsampling factor must be a whole number of 1 or "
|
|
1120
|
+
f"more, got {downsample!r}.")
|
|
1121
|
+
uneven = [n for n in shape if n % downsample]
|
|
1122
|
+
if uneven:
|
|
1123
|
+
raise ValueError(
|
|
1124
|
+
f"--downsample {downsample} does not divide the cube shape "
|
|
1125
|
+
f"{tuple(shape)}: {uneven} not a multiple of {downsample}. Choose "
|
|
1126
|
+
f"a factor that divides every dimension."
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
class FormerNameWarning(FutureWarning):
|
|
1131
|
+
"""Warning given on importing the package under its former name, `euvst_response`."""
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _unpickle(handle):
|
|
1135
|
+
"""
|
|
1136
|
+
Object from an older version's pickle, which may name the package's former modules.
|
|
1137
|
+
|
|
1138
|
+
Those load through `euvst_response`, whose warning to change an import is
|
|
1139
|
+
not for the reader of a file, so it is not shown here.
|
|
1140
|
+
"""
|
|
1141
|
+
import dill
|
|
1142
|
+
|
|
1143
|
+
with warnings.catch_warnings():
|
|
1144
|
+
warnings.simplefilter("ignore", FormerNameWarning)
|
|
1145
|
+
return dill.load(handle)
|