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/band.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Contribution functions of every line in a band, for full-CCD frames.
|
|
3
|
+
|
|
4
|
+
G(T, n_e) of every CHIANTI line in the band, on a synthesis's temperature
|
|
5
|
+
and density grids, with n_H / n_e included as elsewhere in ECLIPSE. Levels
|
|
6
|
+
that nothing populates, as in a few ions such as N IV in CHIANTI 10.1, are
|
|
7
|
+
left out and the rest solved exactly. Where an ion's level-resolved
|
|
8
|
+
recombination and ionisation rates stop short of the hottest temperatures,
|
|
9
|
+
or it has none, fiasco's single-ion model is used. Ions without data or an
|
|
10
|
+
abundance are skipped and named. The whole SW band takes about two hours on
|
|
11
|
+
32 cores, so the result is written to a file.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import os
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
21
|
+
|
|
22
|
+
import h5py
|
|
23
|
+
import numpy as np
|
|
24
|
+
import astropy.units as u
|
|
25
|
+
|
|
26
|
+
FORMAT_NAME = "eclipse-band-lines"
|
|
27
|
+
FORMAT_VERSION = 1
|
|
28
|
+
G_UNIT = u.erg * u.cm**3 / u.s
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class IonLines:
|
|
33
|
+
"""
|
|
34
|
+
One ion's lines in a band.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
name : str
|
|
39
|
+
Ion, such as `"Fe 12"`.
|
|
40
|
+
atom, stage : int
|
|
41
|
+
Atomic number and ionisation stage, 26 and 12 for Fe XII.
|
|
42
|
+
mass : float
|
|
43
|
+
Ion mass in atomic mass units, for the thermal width of its lines.
|
|
44
|
+
wavelength : np.ndarray
|
|
45
|
+
Wavelength of each line in Angstrom: CHIANTI's observed value where
|
|
46
|
+
it has one, and the theoretical value otherwise.
|
|
47
|
+
observed : np.ndarray of bool
|
|
48
|
+
Whether each wavelength is observed.
|
|
49
|
+
temperatures : np.ndarray of int
|
|
50
|
+
Indices of the grid temperatures where the ion exists.
|
|
51
|
+
g : np.ndarray
|
|
52
|
+
G at those temperatures and every grid density, shape
|
|
53
|
+
(n_lines, nN, n_temperatures), in erg cm3 / s.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
name: str
|
|
57
|
+
atom: int
|
|
58
|
+
stage: int
|
|
59
|
+
mass: float
|
|
60
|
+
wavelength: np.ndarray
|
|
61
|
+
observed: np.ndarray
|
|
62
|
+
temperatures: np.ndarray
|
|
63
|
+
g: np.ndarray
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class BandLines:
|
|
68
|
+
"""
|
|
69
|
+
Every line in a band, ion by ion, on one temperature and density grid.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
band : tuple of float
|
|
74
|
+
Band edges in Angstrom.
|
|
75
|
+
logT_grid, logN_grid : np.ndarray
|
|
76
|
+
Grids as log10(T / K) and log10(n_e / cm-3).
|
|
77
|
+
abundance : str
|
|
78
|
+
CHIANTI abundance set.
|
|
79
|
+
hdf5_dbase_root : str
|
|
80
|
+
CHIANTI database the lines came from.
|
|
81
|
+
ions : list of IonLines
|
|
82
|
+
Ions with lines in the band.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
band: Tuple[float, float]
|
|
86
|
+
logT_grid: np.ndarray
|
|
87
|
+
logN_grid: np.ndarray
|
|
88
|
+
abundance: str
|
|
89
|
+
hdf5_dbase_root: str
|
|
90
|
+
ions: List[IonLines]
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def n_lines(self) -> int:
|
|
94
|
+
return sum(ion.wavelength.size for ion in self.ions)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _solve_dropping_unfed_levels(matrix: np.ndarray, rhs: np.ndarray) -> Tuple[np.ndarray, int]:
|
|
98
|
+
"""
|
|
99
|
+
One ion's level populations, solved without the levels nothing populates.
|
|
100
|
+
|
|
101
|
+
In fiasco's rate matrix, element [i, j] is the rate from level j into
|
|
102
|
+
level i, and each column sums to zero. The last row is replaced by ones
|
|
103
|
+
so that the populations sum to one. A level whose row has no rate from
|
|
104
|
+
any other level gains nothing, so it is empty in a steady state. Such
|
|
105
|
+
levels are left out, the rest solved exactly, and the left-out
|
|
106
|
+
populations set to zero. The ground level is always kept.
|
|
107
|
+
"""
|
|
108
|
+
matrix = np.asarray(matrix, dtype=float)
|
|
109
|
+
# Rebuild the last row, which the ones replaced, from each column summing
|
|
110
|
+
# to zero; values within the rounding error of the sums are taken as zero.
|
|
111
|
+
rebuilt = -matrix[:-1].sum(axis=0)
|
|
112
|
+
rounding = matrix.shape[0] * np.finfo(float).eps * np.abs(matrix[:-1]).sum(axis=0)
|
|
113
|
+
rebuilt[np.abs(rebuilt) <= rounding] = 0.0
|
|
114
|
+
inflow = np.vstack([matrix[:-1], rebuilt])
|
|
115
|
+
np.fill_diagonal(inflow, 0.0)
|
|
116
|
+
unfed = ~np.any(inflow != 0, axis=1)
|
|
117
|
+
unfed[0] = False
|
|
118
|
+
keep = np.flatnonzero(~unfed)
|
|
119
|
+
if keep.size == matrix.shape[0]:
|
|
120
|
+
raise np.linalg.LinAlgError("the matrix is singular, with no unpopulated level to drop")
|
|
121
|
+
reduced = matrix[np.ix_(keep, keep)].copy()
|
|
122
|
+
reduced[-1, :] = 1.0
|
|
123
|
+
rhs = np.asarray(rhs, dtype=float)
|
|
124
|
+
reduced_rhs = np.zeros((keep.size,) + rhs.shape[1:])
|
|
125
|
+
reduced_rhs[-1] = rhs[-1]
|
|
126
|
+
solution = np.linalg.solve(reduced, reduced_rhs)
|
|
127
|
+
populations = np.zeros((matrix.shape[0],) + rhs.shape[1:])
|
|
128
|
+
populations[keep] = solution
|
|
129
|
+
return populations, int(unfed.sum())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@contextlib.contextmanager
|
|
133
|
+
def _unfed_levels_dropped(dropped: Dict[int, int]):
|
|
134
|
+
"""
|
|
135
|
+
Context in which a singular level-population solve drops the levels nothing populates.
|
|
136
|
+
|
|
137
|
+
fiasco solves all temperatures at once with numpy's solve. This replaces
|
|
138
|
+
it, solving each matrix as numpy would and falling back to
|
|
139
|
+
:func:`_solve_dropping_unfed_levels` for a singular one. `dropped`
|
|
140
|
+
collects the number of levels left out, keyed by matrix index. numpy's
|
|
141
|
+
solve is restored on exit.
|
|
142
|
+
"""
|
|
143
|
+
from unittest import mock
|
|
144
|
+
|
|
145
|
+
original = np.linalg.solve
|
|
146
|
+
|
|
147
|
+
def solve(a, b):
|
|
148
|
+
try:
|
|
149
|
+
return original(a, b)
|
|
150
|
+
except np.linalg.LinAlgError:
|
|
151
|
+
pass
|
|
152
|
+
a, b = np.asarray(a, dtype=float), np.asarray(b, dtype=float)
|
|
153
|
+
stacked = a.ndim == 3
|
|
154
|
+
solutions = []
|
|
155
|
+
for k, matrix in enumerate(a if stacked else [a]):
|
|
156
|
+
# fiasco 0.6 gives one right-hand side for every matrix, fiasco 0.8
|
|
157
|
+
# one for each, shaped (n_temperature, n_levels, 1).
|
|
158
|
+
rhs = b[k] if stacked and b.ndim > 1 else b
|
|
159
|
+
try:
|
|
160
|
+
solutions.append(original(matrix, rhs))
|
|
161
|
+
except np.linalg.LinAlgError:
|
|
162
|
+
populations, n = _solve_dropping_unfed_levels(matrix, rhs)
|
|
163
|
+
dropped[k] = n
|
|
164
|
+
solutions.append(populations)
|
|
165
|
+
return np.stack(solutions) if stacked else solutions[0]
|
|
166
|
+
|
|
167
|
+
with mock.patch("numpy.linalg.solve", new=solve):
|
|
168
|
+
yield
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _beyond_the_rates(error: ValueError) -> bool:
|
|
172
|
+
"""Whether fiasco stopped at a temperature beyond the rates CHIANTI tabulates."""
|
|
173
|
+
return "interpolation range" in str(error)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _contribution_function(ion, density, dropped: Dict[int, int], **kwargs):
|
|
177
|
+
"""
|
|
178
|
+
fiasco's contribution function of an ion, leaving out levels nothing populates if needed.
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
return ion.contribution_function(density, **kwargs)
|
|
182
|
+
except np.linalg.LinAlgError:
|
|
183
|
+
pass
|
|
184
|
+
try:
|
|
185
|
+
with _unfed_levels_dropped(dropped):
|
|
186
|
+
return ion.contribution_function(density, **kwargs)
|
|
187
|
+
except np.linalg.LinAlgError as error:
|
|
188
|
+
raise RuntimeError(
|
|
189
|
+
f"The level populations of {ion.ion_name} cannot be solved, even with the "
|
|
190
|
+
f"levels nothing populates left out: {error}") from None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _ion_band_lines(args):
|
|
194
|
+
"""One ion's lines in the band, for a worker process; None if it has none there."""
|
|
195
|
+
import logging
|
|
196
|
+
|
|
197
|
+
# fiasco creates its logger on first import, so import it before looking
|
|
198
|
+
# the logger up.
|
|
199
|
+
import fiasco # noqa: F401
|
|
200
|
+
|
|
201
|
+
# fiasco warns about its fallbacks once per density; the run reports them
|
|
202
|
+
# once instead.
|
|
203
|
+
fiasco_logger = logging.getLogger("fiasco")
|
|
204
|
+
previous = fiasco_logger.level
|
|
205
|
+
fiasco_logger.setLevel(logging.ERROR)
|
|
206
|
+
try:
|
|
207
|
+
return _band_lines_of_ion(*args)
|
|
208
|
+
finally:
|
|
209
|
+
fiasco_logger.setLevel(previous)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _band_lines_of_ion(atom, stage, temperature_K, densities_cm3, band, abundance, dbase_root):
|
|
213
|
+
"""The work of `_ion_band_lines`."""
|
|
214
|
+
import fiasco
|
|
215
|
+
from fiasco.util.exceptions import MissingDatasetException
|
|
216
|
+
|
|
217
|
+
kwargs = {} if dbase_root is None else {"hdf5_dbase_root": dbase_root}
|
|
218
|
+
try:
|
|
219
|
+
ion = fiasco.Ion((atom, stage), temperature_K * u.K, abundance=abundance, **kwargs)
|
|
220
|
+
transitions = ion.transitions
|
|
221
|
+
except MissingDatasetException:
|
|
222
|
+
# CHIANTI has no levels or transitions for the ion, so no lines.
|
|
223
|
+
return None
|
|
224
|
+
bound = transitions.is_bound_bound
|
|
225
|
+
wavelength = transitions.wavelength[bound].to_value(u.AA)
|
|
226
|
+
observed = np.asarray(transitions.is_observed[bound])
|
|
227
|
+
columns = np.flatnonzero((wavelength >= band[0]) & (wavelength <= band[1]))
|
|
228
|
+
if columns.size == 0:
|
|
229
|
+
return None
|
|
230
|
+
try:
|
|
231
|
+
fraction = np.nan_to_num(np.asarray(ion.ionization_fraction, dtype=float))
|
|
232
|
+
except MissingDatasetException as error:
|
|
233
|
+
return {"skipped": f"{ion.ion_name} ({error})"}
|
|
234
|
+
temperatures = np.flatnonzero(fraction > 0)
|
|
235
|
+
if temperatures.size == 0:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
present = fiasco.Ion((atom, stage), temperature_K[temperatures] * u.K, abundance=abundance,
|
|
240
|
+
**kwargs)
|
|
241
|
+
density = densities_cm3 * u.cm**-3
|
|
242
|
+
dropped: Dict[int, int] = {}
|
|
243
|
+
single_ion = None
|
|
244
|
+
try:
|
|
245
|
+
g = _contribution_function(present, density, dropped)
|
|
246
|
+
except ValueError as error:
|
|
247
|
+
if not _beyond_the_rates(error):
|
|
248
|
+
raise
|
|
249
|
+
# For some ions CHIANTI's level-resolved recombination and
|
|
250
|
+
# ionisation rates stop short of the hottest temperatures. Use
|
|
251
|
+
# fiasco's single-ion model there, as for ions with no such rates.
|
|
252
|
+
first = None
|
|
253
|
+
for index, temperature in enumerate(present.temperature):
|
|
254
|
+
one = fiasco.Ion((atom, stage), temperature[np.newaxis], abundance=abundance,
|
|
255
|
+
**kwargs)
|
|
256
|
+
try:
|
|
257
|
+
_contribution_function(one, density[:1], {})
|
|
258
|
+
except ValueError as error:
|
|
259
|
+
if not _beyond_the_rates(error):
|
|
260
|
+
raise
|
|
261
|
+
first = index
|
|
262
|
+
break
|
|
263
|
+
if first is None:
|
|
264
|
+
raise
|
|
265
|
+
# Recount the dropped levels, keyed by temperature index in the
|
|
266
|
+
# full array, without those of the failed attempt.
|
|
267
|
+
dropped.clear()
|
|
268
|
+
parts = []
|
|
269
|
+
if first > 0:
|
|
270
|
+
below = fiasco.Ion((atom, stage), present.temperature[:first], abundance=abundance,
|
|
271
|
+
**kwargs)
|
|
272
|
+
parts.append(_contribution_function(below, density, dropped))
|
|
273
|
+
above = fiasco.Ion((atom, stage), present.temperature[first:], abundance=abundance,
|
|
274
|
+
**kwargs)
|
|
275
|
+
dropped_above: Dict[int, int] = {}
|
|
276
|
+
parts.append(_contribution_function(above, density, dropped_above,
|
|
277
|
+
use_two_ion_model=False))
|
|
278
|
+
dropped.update({first + k: n for k, n in dropped_above.items()})
|
|
279
|
+
g = np.concatenate(parts)
|
|
280
|
+
single_ion = float(np.log10(present.temperature[first].to_value(u.K)))
|
|
281
|
+
g = g * present.proton_electron_ratio[:, np.newaxis, np.newaxis]
|
|
282
|
+
except MissingDatasetException as error:
|
|
283
|
+
# CHIANTI or the abundance set lacks data the lines need.
|
|
284
|
+
return {"skipped": f"{ion.ion_name} ({error})"}
|
|
285
|
+
# Reorder (temperature, density, transition) to (line, density,
|
|
286
|
+
# temperature), as in compute_goft_fiasco, with non-finite values set to 0.
|
|
287
|
+
g = np.nan_to_num(g.to_value(G_UNIT)[..., columns], nan=0.0, posinf=0.0, neginf=0.0)
|
|
288
|
+
return {"atom": atom, "stage": stage, "name": present.ion_name,
|
|
289
|
+
"mass": float(ion.mass.to_value(u.u)),
|
|
290
|
+
"wavelength": wavelength[columns], "observed": observed[columns],
|
|
291
|
+
"temperatures": temperatures, "g": np.ascontiguousarray(np.transpose(g, (2, 1, 0))),
|
|
292
|
+
"dropped": len(dropped), "single_ion": single_ion,
|
|
293
|
+
"hdf5_dbase_root": str(ion.hdf5_dbase_root)}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def band_contribution_functions(
|
|
297
|
+
band: Sequence[float],
|
|
298
|
+
logT_grid: np.ndarray,
|
|
299
|
+
logN_grid: np.ndarray,
|
|
300
|
+
abundance: str = "sun_coronal_2021_chianti",
|
|
301
|
+
n_workers: int = 0,
|
|
302
|
+
hdf5_dbase_root=None,
|
|
303
|
+
elements: Optional[Sequence[str]] = None,
|
|
304
|
+
) -> BandLines:
|
|
305
|
+
"""
|
|
306
|
+
Contribution function of every CHIANTI line in a band.
|
|
307
|
+
|
|
308
|
+
Parameters
|
|
309
|
+
----------
|
|
310
|
+
band : sequence of two floats
|
|
311
|
+
Band edges in Angstrom. For a frame, leave room beyond the detector's
|
|
312
|
+
own range for Doppler shifts and the spectral PSF.
|
|
313
|
+
logT_grid, logN_grid : np.ndarray
|
|
314
|
+
Synthesis grids as log10(T / K) and log10(n_e / cm-3).
|
|
315
|
+
abundance : str, optional
|
|
316
|
+
CHIANTI abundance set. Default `"sun_coronal_2021_chianti"`.
|
|
317
|
+
n_workers : int, optional
|
|
318
|
+
Number of processes, each working on one ion at a time. Default 0
|
|
319
|
+
uses every CPU available to this process. The workers are spawned,
|
|
320
|
+
so a calling script needs an `if __name__ == "__main__":` guard. The
|
|
321
|
+
whole SW band on 32 workers needs about 150 GB of memory in total;
|
|
322
|
+
use fewer workers where there is less.
|
|
323
|
+
hdf5_dbase_root : str or Path, optional
|
|
324
|
+
CHIANTI database to read. Default fiasco's own.
|
|
325
|
+
elements : sequence of str, optional
|
|
326
|
+
Elements by symbol. Default every element in CHIANTI.
|
|
327
|
+
|
|
328
|
+
Returns
|
|
329
|
+
-------
|
|
330
|
+
BandLines
|
|
331
|
+
"""
|
|
332
|
+
import fiasco
|
|
333
|
+
|
|
334
|
+
from .atmosphere import _offer_database_build
|
|
335
|
+
from .utils import element_data
|
|
336
|
+
|
|
337
|
+
low, high = (float(value) for value in band)
|
|
338
|
+
if not low < high:
|
|
339
|
+
raise ValueError(f"The band must run from a shorter wavelength to a longer one, "
|
|
340
|
+
f"got {band}.")
|
|
341
|
+
dbase_root = None if hdf5_dbase_root is None else str(hdf5_dbase_root)
|
|
342
|
+
_offer_database_build(dbase_root)
|
|
343
|
+
logT_grid = np.asarray(logT_grid, dtype=float)
|
|
344
|
+
logN_grid = np.asarray(logN_grid, dtype=float)
|
|
345
|
+
temperature_K = 10.0 ** logT_grid
|
|
346
|
+
densities_cm3 = 10.0 ** logN_grid
|
|
347
|
+
if elements is None:
|
|
348
|
+
elements = fiasco.list_elements(dbase_root)
|
|
349
|
+
jobs = []
|
|
350
|
+
for symbol in elements:
|
|
351
|
+
atom, _ = element_data(symbol)
|
|
352
|
+
jobs += [(atom, stage, temperature_K, densities_cm3, (low, high), abundance, dbase_root)
|
|
353
|
+
for stage in range(1, atom + 1)]
|
|
354
|
+
# The ions with the most levels take longest, so they are started first.
|
|
355
|
+
jobs.sort(key=lambda job: -job[0] * (job[0] - job[1] + 1))
|
|
356
|
+
|
|
357
|
+
if n_workers <= 0:
|
|
358
|
+
n_workers = (len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity")
|
|
359
|
+
else os.cpu_count() or 1)
|
|
360
|
+
if n_workers > 1 and len(jobs) > 1:
|
|
361
|
+
import multiprocessing as mp
|
|
362
|
+
|
|
363
|
+
# A new worker for each ion, so its memory is freed afterwards.
|
|
364
|
+
with mp.get_context("spawn").Pool(min(n_workers, len(jobs)), maxtasksperchild=1) as pool:
|
|
365
|
+
results = pool.map(_ion_band_lines, jobs, chunksize=1)
|
|
366
|
+
else:
|
|
367
|
+
results = [_ion_band_lines(job) for job in jobs]
|
|
368
|
+
|
|
369
|
+
ions, used, single_ion, skipped = [], None, [], []
|
|
370
|
+
for result in results:
|
|
371
|
+
if result is None:
|
|
372
|
+
continue
|
|
373
|
+
if "skipped" in result:
|
|
374
|
+
skipped.append(result["skipped"])
|
|
375
|
+
continue
|
|
376
|
+
used = result["hdf5_dbase_root"]
|
|
377
|
+
if dbase_root is not None and used != dbase_root:
|
|
378
|
+
raise RuntimeError(f"An ion was worked out from the CHIANTI database at {used} "
|
|
379
|
+
f"instead of the requested {dbase_root}.")
|
|
380
|
+
if result["dropped"]:
|
|
381
|
+
print(f" {result['name']}: levels nothing populates were left out at "
|
|
382
|
+
f"{result['dropped']} temperatures, and the rest solved exactly")
|
|
383
|
+
if result["single_ion"] is not None:
|
|
384
|
+
single_ion.append(f"{result['name']} from log T {result['single_ion']:.2f}")
|
|
385
|
+
ions.append(IonLines(name=result["name"], atom=result["atom"], stage=result["stage"],
|
|
386
|
+
mass=result["mass"],
|
|
387
|
+
wavelength=result["wavelength"], observed=result["observed"],
|
|
388
|
+
temperatures=result["temperatures"], g=result["g"]))
|
|
389
|
+
if single_ion:
|
|
390
|
+
print(f" CHIANTI's recombination and ionisation rates of each level stop short of "
|
|
391
|
+
f"the hottest temperatures for {len(single_ion)} ions, whose level populations "
|
|
392
|
+
f"there are worked out with fiasco's single-ion model, as for ions that have "
|
|
393
|
+
f"no such rates: {', '.join(single_ion)}")
|
|
394
|
+
if skipped:
|
|
395
|
+
print(f" Left out, for want of data in CHIANTI or the abundance set: "
|
|
396
|
+
f"{'; '.join(skipped)}")
|
|
397
|
+
ions.sort(key=lambda ion: (ion.atom, ion.stage))
|
|
398
|
+
if used is None:
|
|
399
|
+
# No ion was computed, so record the requested database.
|
|
400
|
+
used = str(fiasco.defaults["hdf5_dbase_root"] if dbase_root is None else dbase_root)
|
|
401
|
+
return BandLines(band=(low, high), logT_grid=logT_grid, logN_grid=logN_grid,
|
|
402
|
+
abundance=abundance, hdf5_dbase_root=used, ions=ions)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def write_band_lines(lines: BandLines, path: str | Path) -> Path:
|
|
406
|
+
"""
|
|
407
|
+
Write a band's lines to an HDF5 file readable by `read_band_lines`.
|
|
408
|
+
|
|
409
|
+
Parameters
|
|
410
|
+
----------
|
|
411
|
+
lines : BandLines
|
|
412
|
+
Lines to write.
|
|
413
|
+
path : str or Path
|
|
414
|
+
Output file. An existing file is replaced.
|
|
415
|
+
|
|
416
|
+
Returns
|
|
417
|
+
-------
|
|
418
|
+
Path
|
|
419
|
+
File written.
|
|
420
|
+
"""
|
|
421
|
+
from .atmosphere import _replacing
|
|
422
|
+
|
|
423
|
+
path = Path(path).expanduser()
|
|
424
|
+
with _replacing(path) as partial, h5py.File(partial, "w") as f:
|
|
425
|
+
f.attrs["format"] = FORMAT_NAME
|
|
426
|
+
f.attrs["version"] = FORMAT_VERSION
|
|
427
|
+
f.attrs["band"] = np.asarray(lines.band, dtype=float)
|
|
428
|
+
f.attrs["abundance"] = lines.abundance
|
|
429
|
+
f.attrs["hdf5_dbase_root"] = lines.hdf5_dbase_root
|
|
430
|
+
f["logT_grid"] = lines.logT_grid
|
|
431
|
+
f["logN_grid"] = lines.logN_grid
|
|
432
|
+
group = f.create_group("ions")
|
|
433
|
+
for ion in lines.ions:
|
|
434
|
+
entry = group.create_group(f"{ion.atom}_{ion.stage}")
|
|
435
|
+
entry.attrs["name"] = ion.name
|
|
436
|
+
entry.attrs["atom"] = ion.atom
|
|
437
|
+
entry.attrs["stage"] = ion.stage
|
|
438
|
+
entry.attrs["mass"] = ion.mass
|
|
439
|
+
entry["wavelength"] = ion.wavelength
|
|
440
|
+
entry["observed"] = ion.observed
|
|
441
|
+
entry["temperatures"] = ion.temperatures
|
|
442
|
+
entry.create_dataset("g", data=ion.g, compression="gzip")
|
|
443
|
+
entry["g"].attrs["unit"] = str(G_UNIT)
|
|
444
|
+
return path
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def read_band_lines(path: str | Path) -> BandLines:
|
|
448
|
+
"""
|
|
449
|
+
Read a band's lines from a file written by `write_band_lines`.
|
|
450
|
+
|
|
451
|
+
Parameters
|
|
452
|
+
----------
|
|
453
|
+
path : str or Path
|
|
454
|
+
File to read.
|
|
455
|
+
|
|
456
|
+
Returns
|
|
457
|
+
-------
|
|
458
|
+
BandLines
|
|
459
|
+
"""
|
|
460
|
+
path = Path(path).expanduser()
|
|
461
|
+
with h5py.File(path, "r") as f:
|
|
462
|
+
if f.attrs.get("format") != FORMAT_NAME:
|
|
463
|
+
raise ValueError(f"{path} is not a file of a band's lines.")
|
|
464
|
+
if int(f.attrs["version"]) != FORMAT_VERSION:
|
|
465
|
+
raise ValueError(f"{path} is version {f.attrs['version']} of the band's lines "
|
|
466
|
+
f"file; this ECLIPSE reads version {FORMAT_VERSION}.")
|
|
467
|
+
ions = [IonLines(name=str(entry.attrs["name"]), atom=int(entry.attrs["atom"]),
|
|
468
|
+
stage=int(entry.attrs["stage"]),
|
|
469
|
+
mass=float(entry.attrs["mass"]), wavelength=entry["wavelength"][()],
|
|
470
|
+
observed=entry["observed"][()], temperatures=entry["temperatures"][()],
|
|
471
|
+
g=entry["g"][()])
|
|
472
|
+
for entry in f["ions"].values()]
|
|
473
|
+
ions.sort(key=lambda ion: (ion.atom, ion.stage))
|
|
474
|
+
return BandLines(band=tuple(float(v) for v in f.attrs["band"]),
|
|
475
|
+
logT_grid=f["logT_grid"][()], logN_grid=f["logN_grid"][()],
|
|
476
|
+
abundance=str(f.attrs["abundance"]),
|
|
477
|
+
hdf5_dbase_root=str(f.attrs["hdf5_dbase_root"]), ions=ions)
|
eclipse/cli.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command line interface for ECLIPSE.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import traceback
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .main import main as run_simulation
|
|
14
|
+
from .synthesis import main as run_synthesis
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
ASCII_LOGO = r"""
|
|
18
|
+
______ _____ _ _____ _____ _____ ______
|
|
19
|
+
| ____/ ____| | |_ _| __ \ / ____| ____|
|
|
20
|
+
| |__ | | | | | | | |__) | (___ | |__
|
|
21
|
+
| __|| | | | | | | ___/ \___ \| __|
|
|
22
|
+
| |___| |____| |____ _| |_| | ____) | |____
|
|
23
|
+
|______\_____|______|_____|_| |_____/|______|
|
|
24
|
+
|
|
25
|
+
ECLIPSE: Emission Calculation and Line Prediction for SOLAR-C EUVST
|
|
26
|
+
|
|
27
|
+
Contact: James McKevitt (jm2@mssl.ucl.ac.uk). See LICENSE for usage terms.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def print_logo():
|
|
32
|
+
"""Print the ECLIPSE ASCII logo and info."""
|
|
33
|
+
print(ASCII_LOGO)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main():
|
|
37
|
+
"""Main CLI entry point."""
|
|
38
|
+
parser = argparse.ArgumentParser(
|
|
39
|
+
prog="eclipse",
|
|
40
|
+
description="ECLIPSE: Emission Calculation and Line Prediction for SOLAR-C EUVST",
|
|
41
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--version",
|
|
46
|
+
action="version",
|
|
47
|
+
version=f"ECLIPSE {__version__}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--config",
|
|
52
|
+
type=str,
|
|
53
|
+
help="YAML config file for instrument response simulation",
|
|
54
|
+
required=False
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--logo-only",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="Just print the logo and exit"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--debug",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="Enable debug mode - drops to IPython on errors"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
args = parser.parse_args()
|
|
70
|
+
|
|
71
|
+
# Always print the logo first
|
|
72
|
+
print_logo()
|
|
73
|
+
|
|
74
|
+
if args.logo_only:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
if not args.config:
|
|
78
|
+
print("Usage: eclipse --config <config.yaml>")
|
|
79
|
+
print("\nTo run instrument response simulation, provide a YAML config file.")
|
|
80
|
+
print("The documentation says how to write one: https://solarc-eclipse.readthedocs.io")
|
|
81
|
+
print("\nFor more help: eclipse --help")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# Validate config file exists
|
|
85
|
+
config_path = Path(args.config)
|
|
86
|
+
if not config_path.exists():
|
|
87
|
+
print(f"Error: Config file '{args.config}' not found.")
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
|
|
90
|
+
print(f"Running instrument response simulation with config: {args.config}")
|
|
91
|
+
print("-" * 60)
|
|
92
|
+
|
|
93
|
+
# Set up sys.argv for the main function (it expects argparse format)
|
|
94
|
+
if args.debug:
|
|
95
|
+
sys.argv = ["eclipse", "--config", args.config, "--debug"]
|
|
96
|
+
else:
|
|
97
|
+
sys.argv = ["eclipse", "--config", args.config]
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
run_simulation()
|
|
101
|
+
except KeyboardInterrupt:
|
|
102
|
+
print("\nSimulation interrupted by user.")
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
# A run ECLIPSE cannot do, or a missing file, raises an error that says why.
|
|
106
|
+
# Anything else is unexpected, so print its traceback.
|
|
107
|
+
if not isinstance(e, (ValueError, FileNotFoundError)):
|
|
108
|
+
traceback.print_exc()
|
|
109
|
+
_fail(f"Error during simulation: {type(e).__name__}: {e}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _fail(message: str) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Print `message` and end the run on every MPI rank.
|
|
115
|
+
|
|
116
|
+
Ranks other than the first have their output silenced, so the message
|
|
117
|
+
goes to the stderr the rank started with. An MPI run is aborted, as the
|
|
118
|
+
other ranks would otherwise wait for this one until the job's time runs
|
|
119
|
+
out.
|
|
120
|
+
"""
|
|
121
|
+
from .main import _STARTING_STDERR
|
|
122
|
+
from .utils import _get_mpi_info
|
|
123
|
+
|
|
124
|
+
comm, rank, size = _get_mpi_info()
|
|
125
|
+
if size > 1:
|
|
126
|
+
os.write(_STARTING_STDERR or 2, f"MPI rank {rank}: {message}\n".encode())
|
|
127
|
+
comm.Abort(1)
|
|
128
|
+
print(message)
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
main()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def synthesis_main():
|
|
137
|
+
"""Entry point for the synthesis command line script."""
|
|
138
|
+
run_synthesis()
|