eclipse 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. eclipse/__init__.py +95 -0
  2. eclipse/analysis.py +853 -0
  3. eclipse/atmosphere.py +859 -0
  4. eclipse/band.py +477 -0
  5. eclipse/cli.py +138 -0
  6. eclipse/config.py +1710 -0
  7. eclipse/continuum.py +413 -0
  8. eclipse/data/eis_response/EIS_EffArea_A.004 +61 -0
  9. eclipse/data/eis_response/EIS_EffArea_B.004 +60 -0
  10. eclipse/data/eis_response/EIS_EffArea_B.005 +64 -0
  11. eclipse/data/eis_response/README_dz2025 +50 -0
  12. eclipse/data/eis_response/fit_eis_ea_2023-05-04_smooth.sav +0 -0
  13. eclipse/data/eis_response/source.txt +33 -0
  14. eclipse/data/science_cases.yaml +365 -0
  15. eclipse/data/throughput/grating_reflection_efficiency.dat +25 -0
  16. eclipse/data/throughput/primary_mirror_coating_reflectance.dat +25 -0
  17. eclipse/data/throughput/source.txt +6 -0
  18. eclipse/data/throughput/throughput_aluminium_1000_angstrom.dat +503 -0
  19. eclipse/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +503 -0
  20. eclipse/data/throughput/throughput_carbon_1000_angstrom.dat +503 -0
  21. eclipse/data_processing.py +904 -0
  22. eclipse/deprecated.py +161 -0
  23. eclipse/eis_calibration.py +573 -0
  24. eclipse/extern/__init__.py +0 -0
  25. eclipse/extern/mpfit.py +2388 -0
  26. eclipse/fitting.py +1734 -0
  27. eclipse/frame.py +594 -0
  28. eclipse/full_frame.py +944 -0
  29. eclipse/main.py +1646 -0
  30. eclipse/monte_carlo.py +660 -0
  31. eclipse/pinhole_diffraction.py +409 -0
  32. eclipse/psf.py +6 -0
  33. eclipse/radiometric.py +1328 -0
  34. eclipse/raster.py +869 -0
  35. eclipse/readout.py +1002 -0
  36. eclipse/resolution_budget.py +108 -0
  37. eclipse/results_file.py +1226 -0
  38. eclipse/sampling.py +351 -0
  39. eclipse/science_cases.py +364 -0
  40. eclipse/synthesis.py +2492 -0
  41. eclipse/synthesis_cli.py +12 -0
  42. eclipse/synthesis_file.py +882 -0
  43. eclipse/utils.py +1145 -0
  44. eclipse-0.13.0.dist-info/METADATA +70 -0
  45. eclipse-0.13.0.dist-info/RECORD +50 -0
  46. eclipse-0.13.0.dist-info/WHEEL +5 -0
  47. eclipse-0.13.0.dist-info/entry_points.txt +7 -0
  48. eclipse-0.13.0.dist-info/licenses/LICENSE +3 -0
  49. eclipse-0.13.0.dist-info/top_level.txt +2 -0
  50. euvst_response/__init__.py +129 -0
eclipse/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)