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/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()