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/atmosphere.py ADDED
@@ -0,0 +1,859 @@
1
+ """
2
+ Atmosphere input for the synthesis, and its HDF5 file.
3
+
4
+ An `Atmosphere` holds the temperature, density and velocity of every cell
5
+ and the cell edges, from any simulation code. This module reads and writes
6
+ it, and converts mass density to electron density with the synthesis's
7
+ abundances. The file layout is described on the Files page of the docs.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import secrets
15
+ import sys
16
+ from contextlib import contextmanager
17
+ from dataclasses import dataclass, replace
18
+ from functools import lru_cache
19
+ from pathlib import Path
20
+ from typing import Dict, Optional, Sequence
21
+
22
+ import astropy.constants as const
23
+ import astropy.units as u
24
+ import h5py
25
+ import numpy as np
26
+ from astropy.wcs import WCS
27
+ from ndcube import NDCube
28
+
29
+ from .utils import element_data, require_downsample_divides, require_uniform_grid
30
+
31
+ __all__ = [
32
+ "FORMAT_NAME",
33
+ "FORMAT_VERSION",
34
+ "AXES",
35
+ "Atmosphere",
36
+ "edges_from_centres",
37
+ "read_atmosphere",
38
+ "read_time",
39
+ "read_edges",
40
+ "write_atmosphere",
41
+ "describe_atmosphere_file",
42
+ "mass_per_electron",
43
+ "mass_per_electron_from_abundances",
44
+ "require_mass_per_electron",
45
+ ]
46
+
47
+ FORMAT_NAME = "eclipse-atmosphere"
48
+ FORMAT_VERSION = 1
49
+
50
+ # The cubes are (z, y, x), so numpy axis 0 is z.
51
+ AXES = ("x", "y", "z")
52
+ NUMPY_AXIS = {"x": 2, "y": 1, "z": 0}
53
+ CTYPE = {"x": "SOLX", "y": "SOLY", "z": "SOLZ"}
54
+ EDGES = {axis: f"{axis}_edges" for axis in AXES}
55
+
56
+ CUBES = ("temperature", "mass_density", "electron_density",
57
+ "velocity_x", "velocity_y", "velocity_z")
58
+ UNITS = {
59
+ "temperature": u.K,
60
+ "mass_density": u.g / u.cm**3,
61
+ "electron_density": u.cm**-3,
62
+ "velocity_x": u.cm / u.s,
63
+ "velocity_y": u.cm / u.s,
64
+ "velocity_z": u.cm / u.s,
65
+ "x_edges": u.Mm, "y_edges": u.Mm, "z_edges": u.Mm,
66
+ "time": u.s,
67
+ }
68
+
69
+ # fiasco needs a temperature to build an Ion, but Ion.abundance reads only
70
+ # the abundance table, so any fixed value will do.
71
+ _ABUNDANCE_PROBE_TEMPERATURE = 1.0e6 * u.K
72
+
73
+
74
+ def _quantity(value, name: str, ndim: Optional[int] = None) -> u.Quantity:
75
+ """
76
+ `value`, checked to be a finite Quantity of the right kind for `name`.
77
+
78
+ `ndim`, if given, is the number of axes it must have.
79
+ """
80
+ unit = UNITS[name]
81
+ if not isinstance(value, u.Quantity):
82
+ raise TypeError(f"{name} must be an astropy Quantity in a unit "
83
+ f"convertible to {unit}, got {type(value).__name__}.")
84
+ if not value.unit.is_equivalent(unit):
85
+ raise u.UnitConversionError(
86
+ f"{name} must be in a unit convertible to {unit}, got {value.unit}.")
87
+ if ndim is not None and value.ndim != ndim:
88
+ raise ValueError(f"{name} must have {ndim} dimensions, got {value.ndim}.")
89
+ if not np.all(np.isfinite(value.value)):
90
+ raise ValueError(f"{name} contains NaN or infinite values.")
91
+ return value
92
+
93
+
94
+ def _check_edges(edges, name: str) -> u.Quantity:
95
+ """Check that cell edges increase and bound at least one cell."""
96
+ edges = _quantity(edges, name, ndim=1)
97
+ if edges.size < 2:
98
+ raise ValueError(f"{name} must bound at least 1 cell, so have "
99
+ f"at least 2 values, got {edges.size}.")
100
+ if np.any(np.diff(edges.value) <= 0):
101
+ raise ValueError(f"{name} must increase; ECLIPSE's cubes are "
102
+ f"(z, y, x) with every axis ascending.")
103
+ return edges
104
+
105
+
106
+ def edges_from_centres(centres: u.Quantity) -> u.Quantity:
107
+ """
108
+ Cell edges from cell centres.
109
+
110
+ Each inner edge is halfway between two centres. Each outer edge is as
111
+ far from the end centre as that cell's inner edge. On an even grid every
112
+ cell then has the grid spacing. On an uneven grid this is only one
113
+ reasonable choice, so give the true edges if your code knows them.
114
+
115
+ Parameters
116
+ ----------
117
+ centres : u.Quantity
118
+ Cell centres along one axis, two or more.
119
+
120
+ Returns
121
+ -------
122
+ u.Quantity
123
+ Edges, one more than the centres.
124
+ """
125
+ centres = np.atleast_1d(centres)
126
+ if centres.size < 2:
127
+ raise ValueError("At least 2 centres are needed to place edges "
128
+ "between them.")
129
+ values = centres.value
130
+ inner = 0.5 * (values[1:] + values[:-1])
131
+ first = values[0] - (inner[0] - values[0])
132
+ last = values[-1] + (values[-1] - inner[-1])
133
+ return np.concatenate([[first], inner, [last]]) * centres.unit
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class Atmosphere:
138
+ """
139
+ One snapshot of a simulated atmosphere, for the synthesis.
140
+
141
+ Every cube is `(nz, ny, nx)`. The edge arrays `z_edges`, `y_edges` and
142
+ `x_edges` give the cell boundaries and are one longer than the cubes
143
+ along their axis. Velocities are positive towards increasing coordinate.
144
+ Either density may be given; if only the mass density is, the synthesis
145
+ derives the electron density from it.
146
+
147
+ Parameters
148
+ ----------
149
+ temperature : u.Quantity
150
+ Temperature of every cell, `(nz, ny, nx)`.
151
+ x_edges, y_edges, z_edges : u.Quantity
152
+ Cell boundaries along each axis, 1D and increasing.
153
+ mass_density, electron_density : u.Quantity, optional
154
+ At least one is needed.
155
+ velocity_x, velocity_y, velocity_z : u.Quantity, optional
156
+ Velocity components. The synthesis needs the one along its line of
157
+ sight.
158
+ time : u.Quantity, optional
159
+ Simulation time of this snapshot.
160
+ source : str, optional
161
+ Free text naming the simulation, kept in the file and the results.
162
+ """
163
+
164
+ temperature: u.Quantity
165
+ x_edges: u.Quantity
166
+ y_edges: u.Quantity
167
+ z_edges: u.Quantity
168
+ mass_density: Optional[u.Quantity] = None
169
+ electron_density: Optional[u.Quantity] = None
170
+ velocity_x: Optional[u.Quantity] = None
171
+ velocity_y: Optional[u.Quantity] = None
172
+ velocity_z: Optional[u.Quantity] = None
173
+ time: Optional[u.Quantity] = None
174
+ source: str = ""
175
+
176
+ def __post_init__(self):
177
+ if self.temperature is None:
178
+ raise ValueError("An atmosphere needs a temperature.")
179
+ for axis in AXES:
180
+ _check_edges(getattr(self, EDGES[axis]), EDGES[axis])
181
+ shape = tuple(getattr(self, EDGES[axis]).size - 1 for axis in ("z", "y", "x"))
182
+ for name in CUBES:
183
+ cube = getattr(self, name)
184
+ if cube is None:
185
+ continue
186
+ _quantity(cube, name, ndim=3)
187
+ if cube.shape != shape:
188
+ raise ValueError(
189
+ f"{name} has shape {cube.shape} but the edges bound "
190
+ f"(nz, ny, nx) = {shape} cells. The cubes are (z, y, x): "
191
+ f"the first axis is height.")
192
+ # Cells at or below zero temperature would otherwise be dropped
193
+ # silently from the synthesis, as log10 is undefined there. An empty
194
+ # cell may have zero density.
195
+ if np.any(self.temperature.value <= 0):
196
+ raise ValueError("temperature must be above zero in every cell, got "
197
+ f"{self.temperature.min()} at least once.")
198
+ for name in ("mass_density", "electron_density"):
199
+ cube = getattr(self, name)
200
+ if cube is not None and np.any(cube.value < 0):
201
+ raise ValueError(f"{name} cannot be negative, got {cube.min()} at least once.")
202
+ if self.mass_density is None and self.electron_density is None:
203
+ raise ValueError("An atmosphere needs a mass_density or an "
204
+ "electron_density.")
205
+ if self.time is not None:
206
+ _quantity(self.time, "time", ndim=0)
207
+ if not isinstance(self.source, str):
208
+ raise TypeError(f"source must be a string, got "
209
+ f"{type(self.source).__name__}.")
210
+
211
+ # ------------------------------------------------------------------
212
+ # Geometry
213
+ # ------------------------------------------------------------------
214
+ @property
215
+ def shape(self) -> tuple[int, int, int]:
216
+ """Shape of the cubes, `(nz, ny, nx)`."""
217
+ return self.temperature.shape
218
+
219
+ def edges(self, axis: str) -> u.Quantity:
220
+ """Cell boundaries along `axis`."""
221
+ return getattr(self, EDGES[_check_axis(axis)])
222
+
223
+ def coordinate(self, axis: str) -> u.Quantity:
224
+ """Cell centres along `axis`."""
225
+ edges = self.edges(axis)
226
+ return 0.5 * (edges[1:] + edges[:-1])
227
+
228
+ def cell_thickness(self, axis: str) -> u.Quantity:
229
+ """Size of every cell along `axis`."""
230
+ return np.diff(self.edges(axis))
231
+
232
+ def is_uniform(self, axis: str) -> bool:
233
+ """Whether the cells along `axis` all have the same size."""
234
+ try:
235
+ require_uniform_grid(self.edges(axis), EDGES[axis])
236
+ except ValueError:
237
+ return False
238
+ return True
239
+
240
+ def spacing(self, axis: str) -> u.Quantity:
241
+ """Cell size along `axis`, which must be evenly spaced."""
242
+ edges = self.edges(axis)
243
+ return require_uniform_grid(edges, EDGES[axis]) * edges.unit
244
+
245
+ def velocity(self, axis: str) -> u.Quantity:
246
+ """Velocity component along `axis`."""
247
+ velocity = getattr(self, f"velocity_{_check_axis(axis)}")
248
+ if velocity is None:
249
+ raise ValueError(f"The atmosphere has no velocity_{axis}, which a "
250
+ f"line of sight along {axis} needs.")
251
+ return velocity
252
+
253
+ # ------------------------------------------------------------------
254
+ # Density
255
+ # ------------------------------------------------------------------
256
+ def electron_density_from(self, mass_per_electron_amu: float) -> u.Quantity:
257
+ """
258
+ Electron density, derived from the mass density if not given.
259
+
260
+ Parameters
261
+ ----------
262
+ mass_per_electron_amu : float
263
+ Plasma mass per free electron, in atomic mass units, about 1.16
264
+ for coronal abundances. Not used if the atmosphere has an
265
+ electron density.
266
+
267
+ Returns
268
+ -------
269
+ u.Quantity
270
+ Electron density in every cell, in cm-3.
271
+ """
272
+ if self.electron_density is not None:
273
+ return self.electron_density
274
+ require_mass_per_electron(mass_per_electron_amu)
275
+ return (self.mass_density / (mass_per_electron_amu * const.u)).to(u.cm**-3)
276
+
277
+ # ------------------------------------------------------------------
278
+ # Selecting part of the box
279
+ # ------------------------------------------------------------------
280
+ def cropped(self, x=None, y=None, z=None) -> "Atmosphere":
281
+ """
282
+ Part of the box within the given ranges.
283
+
284
+ Each range is `(low, high)`, with units, in the atmosphere's own
285
+ coordinates. A cell is kept if any part of it is inside the range. A
286
+ bound on a cell edge does not keep the cell beyond it.
287
+ """
288
+ item = [slice(None)] * 3
289
+ edges = {}
290
+ for axis, bounds in (("x", x), ("y", y), ("z", z)):
291
+ if bounds is None:
292
+ continue
293
+ all_edges = self.edges(axis)
294
+ low, high = (u.Quantity(b).to_value(all_edges.unit) for b in bounds)
295
+ if not low < high:
296
+ raise ValueError(f"The {axis} range must have low < high, got "
297
+ f"{bounds}.")
298
+ values = all_edges.value
299
+ # Round-number bounds often land on cell boundaries. So that
300
+ # rounding does not decide whether the cell beyond is kept, a cell
301
+ # counts as inside only if more than a millionth of it is.
302
+ tolerance = 1e-6 * np.diff(values).min()
303
+ inside = np.flatnonzero((values[1:] - low > tolerance)
304
+ & (high - values[:-1] > tolerance))
305
+ if inside.size == 0:
306
+ raise ValueError(
307
+ f"No cells lie within {axis} = {bounds}; the atmosphere "
308
+ f"covers {all_edges[0]:.6g} to {all_edges[-1]:.6g}.")
309
+ first, last = int(inside[0]), int(inside[-1]) + 1
310
+ item[NUMPY_AXIS[axis]] = slice(first, last)
311
+ edges[EDGES[axis]] = all_edges[first:last + 1]
312
+ item = tuple(item)
313
+ cubes = {name: None if getattr(self, name) is None
314
+ else getattr(self, name)[item] for name in CUBES}
315
+ return replace(self, **edges, **cubes)
316
+
317
+ def downsampled(self, factor: int) -> "Atmosphere":
318
+ """
319
+ Atmosphere with every `factor`-th cell along each axis.
320
+
321
+ Each kept cell grows to cover the cells it replaces, so the box keeps
322
+ its size. `factor` must divide every dimension.
323
+ """
324
+ require_downsample_divides(self.shape, factor)
325
+ if factor == 1:
326
+ return self
327
+ item = (slice(None, None, factor),) * 3
328
+ edges = {EDGES[axis]: self.edges(axis)[::factor] for axis in AXES}
329
+ cubes = {name: None if getattr(self, name) is None
330
+ else getattr(self, name)[item] for name in CUBES}
331
+ return replace(self, **edges, **cubes)
332
+
333
+ # ------------------------------------------------------------------
334
+ # Cubes for the synthesis
335
+ # ------------------------------------------------------------------
336
+ def nonuniform_axes(self) -> list[str]:
337
+ """Axes whose cells are not all the same size."""
338
+ return [axis for axis in AXES if not self.is_uniform(axis)]
339
+
340
+ def to_ndcube(self, quantity: u.Quantity) -> NDCube:
341
+ """
342
+ `quantity` as an NDCube with the atmosphere's coordinates.
343
+
344
+ `quantity` must have the atmosphere's shape. The coordinates are
345
+ linear. An axis whose cells are not all the same size gets their mean
346
+ size, and is listed in the cube's `meta["nonuniform_axes"]`.
347
+ """
348
+ # Only the line of sight may be uneven: it is integrated out with the
349
+ # true size of every cell, and its coordinates are never used.
350
+ if quantity.shape != self.shape:
351
+ raise ValueError(f"Expected a cube of shape {self.shape}, got "
352
+ f"{quantity.shape}.")
353
+ wcs = WCS(naxis=3)
354
+ wcs.wcs.ctype = [CTYPE[axis] for axis in AXES]
355
+ wcs.wcs.cunit = ["Mm"] * 3
356
+ wcs.wcs.crpix = [1, 1, 1]
357
+ wcs.wcs.crval = [self.coordinate(axis)[0].to_value(u.Mm) for axis in AXES]
358
+ wcs.wcs.cdelt = [self.cell_thickness(axis).mean().to_value(u.Mm) for axis in AXES]
359
+ meta = {"source": self.source, "nonuniform_axes": self.nonuniform_axes()}
360
+ return NDCube(quantity.value, wcs=wcs, unit=quantity.unit, meta=meta)
361
+
362
+ def describe(self) -> str:
363
+ """A few lines describing the atmosphere."""
364
+ present = [name for name in CUBES if getattr(self, name) is not None]
365
+ return _describe({axis: self.edges(axis) for axis in AXES}, present,
366
+ self.time, self.source)
367
+
368
+
369
+ def _check_axis(axis: str) -> str:
370
+ if axis not in AXES:
371
+ raise ValueError(f"axis must be one of {AXES}, got {axis!r}.")
372
+ return axis
373
+
374
+
375
+ def _describe(edges: Dict[str, u.Quantity], cubes: Sequence[str],
376
+ time: Optional[u.Quantity], source: str) -> str:
377
+ """Description shared by an Atmosphere and a file on disk."""
378
+ shape = tuple(edges[axis].size - 1 for axis in ("z", "y", "x"))
379
+ lines = [f" Shape (nz, ny, nx): {shape}"]
380
+ for axis in AXES:
381
+ axis_edges = edges[axis].to(u.Mm)
382
+ extent = f"{axis_edges[0]:.3f} to {axis_edges[-1]:.3f}"
383
+ thickness = np.diff(axis_edges)
384
+ try:
385
+ spacing = require_uniform_grid(axis_edges, EDGES[axis]) * u.Mm
386
+ except ValueError:
387
+ lines.append(f" {axis}: {extent}, cells from {thickness.min():.4f} "
388
+ f"to {thickness.max():.4f}")
389
+ else:
390
+ lines.append(f" {axis}: {extent}, cells of {spacing:.4f}")
391
+ densities = [name for name in ("mass_density", "electron_density") if name in cubes]
392
+ lines.append(f" Density: {' and '.join(densities)}")
393
+ velocities = [axis for axis in AXES if f"velocity_{axis}" in cubes]
394
+ lines.append(f" Velocities: {', '.join(velocities) if velocities else 'none'}")
395
+ if time is not None:
396
+ lines.append(f" Time: {time.to(u.s):.3f}")
397
+ if source:
398
+ lines.append(f" Source: {source}")
399
+ return "\n".join(lines)
400
+
401
+
402
+ def require_mass_per_electron(value) -> float:
403
+ """`value` as a mass per free electron in atomic mass units; must be finite and positive."""
404
+ try:
405
+ number = float(value)
406
+ except (TypeError, ValueError):
407
+ number = np.nan
408
+ if not np.isfinite(number) or number <= 0.0:
409
+ raise ValueError(f"The mass per electron must be a finite, positive number "
410
+ f"of atomic mass units, got {value!r}.")
411
+ return number
412
+
413
+
414
+ # ----------------------------------------------------------------------
415
+ # HDF5
416
+ # ----------------------------------------------------------------------
417
+ def _new_partial(path: Path) -> Path:
418
+ """
419
+ New empty file with a unique name beside `path`, to write `path` into.
420
+
421
+ It is created normally, so the umask and the directory's default
422
+ permissions apply to it, and so to `path`, as colleagues sharing a
423
+ project directory expect.
424
+ """
425
+ while True:
426
+ suffix = f".{secrets.token_hex(4)}.part"
427
+ # Truncate the stem so the name fits the 255 bytes most filesystems allow.
428
+ stem = path.name.encode()[:255 - len(suffix)].decode(errors="ignore")
429
+ partial = path.with_name(stem + suffix)
430
+ try:
431
+ os.close(os.open(partial, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o666))
432
+ except FileExistsError:
433
+ continue
434
+ return partial
435
+
436
+
437
+ @contextmanager
438
+ def _replacing(path: Path):
439
+ """
440
+ New file beside `path` to write, which replaces `path` once written.
441
+
442
+ A write cut short by an error, a full disk or the job being killed
443
+ leaves any file already at `path` unchanged. Writing over it directly
444
+ would leave a broken file and lose the old one.
445
+ """
446
+ path.parent.mkdir(parents=True, exist_ok=True)
447
+ # Follow a symlink to its target, as writing to the link would.
448
+ destination = Path(os.path.realpath(path)) if path.is_symlink() else path
449
+ # Refuse to replace a read-only file, as writing into it would fail.
450
+ if destination.exists() and not os.access(destination, os.W_OK):
451
+ raise PermissionError(f"{destination} is read-only, so it is not written over.")
452
+ partial = _new_partial(destination)
453
+ try:
454
+ yield partial
455
+ os.replace(partial, destination)
456
+ except BaseException:
457
+ partial.unlink(missing_ok=True)
458
+ raise
459
+
460
+
461
+ def _is_pickle(path: Path) -> bool:
462
+ """Whether `path` starts with dill's protocol marker, as ECLIPSE's pickles do."""
463
+ with open(path, "rb") as handle:
464
+ return handle.read(1) == b"\x80"
465
+
466
+
467
+ def write_atmosphere(atmosphere: Atmosphere, path: str | Path,
468
+ compression: Optional[str] = None) -> Path:
469
+ """
470
+ Write an atmosphere file.
471
+
472
+ The file is written under a temporary name beside `path` and then moved
473
+ into place, so a run stopped part way leaves any existing file unchanged.
474
+
475
+ Parameters
476
+ ----------
477
+ atmosphere : Atmosphere
478
+ Atmosphere to write.
479
+ path : str or Path
480
+ File to write. An existing file is replaced.
481
+ compression : str, optional
482
+ HDF5 compression filter for the cubes, such as `"gzip"`. Default
483
+ None, uncompressed, which reads fastest.
484
+
485
+ Returns
486
+ -------
487
+ Path
488
+ Path of the file written.
489
+ """
490
+ path = Path(path)
491
+ with _replacing(path) as partial, h5py.File(partial, "w") as f:
492
+ f.attrs["format"] = FORMAT_NAME
493
+ f.attrs["version"] = FORMAT_VERSION
494
+ f.attrs["source"] = atmosphere.source
495
+ for axis in AXES:
496
+ _write_dataset(f, EDGES[axis], atmosphere.edges(axis))
497
+ for name in CUBES:
498
+ cube = getattr(atmosphere, name)
499
+ if cube is not None:
500
+ _write_dataset(f, name, cube, compression=compression)
501
+ if atmosphere.time is not None:
502
+ _write_dataset(f, "time", atmosphere.time)
503
+ return path
504
+
505
+
506
+ def _write_dataset(f: h5py.File, name: str, quantity: u.Quantity,
507
+ compression: Optional[str] = None) -> None:
508
+ dataset = f.create_dataset(name, data=quantity.value, compression=compression)
509
+ dataset.attrs["unit"] = quantity.unit.to_string()
510
+
511
+
512
+ def read_atmosphere(path: str | Path,
513
+ velocities: Optional[Sequence[str]] = None,
514
+ columns: Optional[slice] = None) -> Atmosphere:
515
+ """
516
+ Read an atmosphere file.
517
+
518
+ Parameters
519
+ ----------
520
+ path : str or Path
521
+ Atmosphere file.
522
+ velocities : sequence of str, optional
523
+ Velocity components to read, such as `("z",)` for a view along z.
524
+ Default None, for every component in the file. Asking for one the
525
+ file lacks is an error.
526
+ columns : slice, optional
527
+ Cells along x to read, as a slice of their index. Only those columns
528
+ are read from disk, so reading a few columns of a large box is quick.
529
+ Default None, for all of them.
530
+
531
+ Returns
532
+ -------
533
+ Atmosphere
534
+ Atmosphere read from the file.
535
+ """
536
+ path = Path(path)
537
+ wanted = AXES if velocities is None else tuple(_check_axis(axis) for axis in velocities)
538
+ with _open_to_read(path) as f:
539
+ _check_format(f, path)
540
+ # Check for missing velocities before reading any cube, since the
541
+ # cubes of a large simulation take gigabytes.
542
+ if velocities is not None:
543
+ for axis in wanted:
544
+ if f"velocity_{axis}" not in f:
545
+ raise ValueError(f"{path} has no velocity_{axis}, which a line "
546
+ f"of sight along {axis} needs.")
547
+ fields = {EDGES[axis]: _read_dataset(f, EDGES[axis]) for axis in AXES}
548
+ if columns is not None:
549
+ nx = fields[EDGES["x"]].size - 1
550
+ first, last, step = columns.indices(nx)
551
+ if step != 1 or last <= first:
552
+ raise ValueError(f"columns must select a contiguous, non-empty "
553
+ f"range of the {nx} cells along x, got {columns}.")
554
+ fields[EDGES["x"]] = fields[EDGES["x"]][first:last + 1]
555
+ columns = slice(first, last)
556
+ fields["temperature"] = _read_dataset(f, "temperature", columns)
557
+ for name in ("mass_density", "electron_density"):
558
+ if name in f:
559
+ fields[name] = _read_dataset(f, name, columns)
560
+ if "time" in f:
561
+ fields["time"] = _read_dataset(f, "time")
562
+ for axis in wanted:
563
+ name = f"velocity_{axis}"
564
+ if name in f:
565
+ fields[name] = _read_dataset(f, name, columns)
566
+ source = _text_attribute(f.attrs, "source", "")
567
+ return Atmosphere(source=str(source), **fields)
568
+
569
+
570
+ def read_time(path: str | Path) -> Optional[u.Quantity]:
571
+ """
572
+ Snapshot time in an atmosphere file, or None if it has none.
573
+
574
+ The time gets the same checks as when reading the whole file, so a time
575
+ that is NaN, infinite or not a single value is refused here too.
576
+ """
577
+ path = Path(path)
578
+ with _open_to_read(path) as f:
579
+ _check_format(f, path)
580
+ if "time" not in f:
581
+ return None
582
+ time = _read_dataset(f, "time")
583
+ try:
584
+ return _quantity(time, "time", ndim=0)
585
+ except ValueError as error:
586
+ raise ValueError(f"{path}: {error}") from None
587
+
588
+
589
+ def read_edges(path: str | Path) -> Dict[str, u.Quantity]:
590
+ """Cell boundaries in an atmosphere file, keyed by axis, without reading the cubes."""
591
+ path = Path(path)
592
+ with _open_to_read(path) as f:
593
+ _check_format(f, path)
594
+ return {axis: _read_dataset(f, EDGES[axis]) for axis in AXES}
595
+
596
+
597
+ def describe_atmosphere_file(path: str | Path) -> str:
598
+ """
599
+ Description of an atmosphere file, without reading its cubes.
600
+
601
+ This is what `eclipse-atmosphere info` prints. Only the attributes, the
602
+ edges, the time and the cubes' names, shapes and units are read, so it
603
+ is quick for any size of file. A file whose layout or units cannot be
604
+ read is refused. The values in the cubes are checked only when they are
605
+ read.
606
+
607
+ Parameters
608
+ ----------
609
+ path : str or Path
610
+ Atmosphere file.
611
+
612
+ Returns
613
+ -------
614
+ str
615
+ A few lines describing the file.
616
+ """
617
+ path = Path(path)
618
+ with _open_to_read(path) as f:
619
+ _check_format(f, path)
620
+ edges = {axis: _read_dataset(f, EDGES[axis]) for axis in AXES}
621
+ try:
622
+ for axis in AXES:
623
+ _check_edges(edges[axis], EDGES[axis])
624
+ except ValueError as error:
625
+ raise ValueError(f"{path}: {error}") from None
626
+ shape = tuple(edges[axis].size - 1 for axis in ("z", "y", "x"))
627
+ cubes = [name for name in CUBES if name in f]
628
+ for name in cubes:
629
+ if f[name].shape != shape:
630
+ raise ValueError(f"{name} in {path} has shape {f[name].shape} but "
631
+ f"the edges bound (nz, ny, nx) = {shape} cells.")
632
+ unit = _dataset_unit(f, name)
633
+ if not unit.is_equivalent(UNITS[name]):
634
+ raise u.UnitConversionError(f"{name} in {path} must be in a unit convertible "
635
+ f"to {UNITS[name]}, got {unit}.")
636
+ if "temperature" not in cubes:
637
+ raise ValueError(f"{path} has no 'temperature' dataset.")
638
+ if "mass_density" not in cubes and "electron_density" not in cubes:
639
+ raise ValueError(f"{path} has no density: an atmosphere needs a "
640
+ f"mass_density or an electron_density.")
641
+ time = _read_dataset(f, "time") if "time" in f else None
642
+ if time is not None:
643
+ try:
644
+ time = _quantity(time, "time", ndim=0)
645
+ except ValueError as error:
646
+ raise ValueError(f"{path}: {error}") from None
647
+ source = _text_attribute(f.attrs, "source", "")
648
+ return _describe(edges, cubes, time, str(source))
649
+
650
+
651
+ def _text_attribute(attrs, name: str, default=None):
652
+ """
653
+ Text attribute as a string.
654
+
655
+ It may be stored as a string, as bytes, or, as IDL and some other
656
+ writers do, as a one-element array of either.
657
+ """
658
+ value = attrs.get(name, default)
659
+ if isinstance(value, np.ndarray) and value.size == 1:
660
+ value = value.reshape(()).item()
661
+ if isinstance(value, bytes):
662
+ value = value.decode()
663
+ return value
664
+
665
+
666
+ def _open_to_read(path: Path, kind: str = "atmosphere") -> h5py.File:
667
+ """
668
+ Open `path` for reading.
669
+
670
+ Every ECLIPSE `kind` file is HDF5. An existing file that is not is
671
+ refused with a plain message, rather than h5py's "file signature not
672
+ found".
673
+ """
674
+ path = Path(path)
675
+ if path.is_file() and not h5py.is_hdf5(path):
676
+ raise ValueError(f"{path} is not an HDF5 file, so it is not an ECLIPSE {kind} file.")
677
+ return h5py.File(path, "r")
678
+
679
+
680
+ def _check_format(f: h5py.File, path: Path, kind: str = "atmosphere",
681
+ format_name: str = FORMAT_NAME,
682
+ format_version: int = FORMAT_VERSION,
683
+ documented: bool = True) -> None:
684
+ """
685
+ Check that `f` is an ECLIPSE `kind` file of the version this ECLIPSE reads.
686
+
687
+ `documented` says whether the docs give the layout, as they do for a
688
+ file users may write themselves.
689
+ """
690
+ name = _text_attribute(f.attrs, "format")
691
+ if name != format_name:
692
+ layout = (f" See ECLIPSE's documentation of the {kind} file for the layout."
693
+ if documented else "")
694
+ raise ValueError(
695
+ f"{path} is not an ECLIPSE {kind} file: its 'format' "
696
+ f"attribute is {name!r}, not {format_name!r}.{layout}")
697
+ version = f.attrs.get("version")
698
+ if version is None:
699
+ raise ValueError(f"{path} has no 'version' attribute; an ECLIPSE "
700
+ f"{kind} file has version {format_version}.")
701
+ # The version must be an integer, which some HDF5 writers store as a
702
+ # one-element array. A string or float is refused even if it reads as 1.
703
+ value = np.asarray(version)
704
+ if value.size != 1 or not np.issubdtype(value.dtype, np.integer):
705
+ raise ValueError(f"{path} has a 'version' attribute of {version} "
706
+ f"({type(version).__name__}); it must be the integer "
707
+ f"{format_version}.")
708
+ if value.item() != format_version:
709
+ raise ValueError(f"{path} is {kind} format version {value.item()}; "
710
+ f"this ECLIPSE reads version {format_version}.")
711
+
712
+
713
+ def _read_dataset(f: h5py.Group, name: str,
714
+ columns: Optional[slice] = None,
715
+ units: Dict[str, u.Unit] = UNITS,
716
+ axis: int = -1) -> u.Quantity:
717
+ """
718
+ Dataset of a file or group, with its unit.
719
+
720
+ If given, `columns` reads only that slice of the x axis, `axis`.
721
+ """
722
+ unit = _dataset_unit(f, name, units)
723
+ dataset = f[name]
724
+ if columns is None:
725
+ values = dataset[()]
726
+ else:
727
+ index = [slice(None)] * dataset.ndim
728
+ index[axis] = columns
729
+ values = dataset[tuple(index)]
730
+ return u.Quantity(values, unit)
731
+
732
+
733
+ def _dataset_unit(f: h5py.Group, name: str, units: Dict[str, u.Unit] = UNITS) -> u.Unit:
734
+ """Unit of a dataset in a file or group, from its 'unit' attribute, without reading it."""
735
+ where = f.file.filename if f.name == "/" else f"{f.file.filename}, {f.name}"
736
+ if name not in f:
737
+ raise ValueError(f"{where} has no '{name}' dataset.")
738
+ unit = _text_attribute(f[name].attrs, "unit")
739
+ if unit is None:
740
+ raise ValueError(f"'{name}' in {where} has no 'unit' attribute. "
741
+ f"Every dataset needs one, for instance "
742
+ f"'{units[name]}'.")
743
+ return u.Unit(unit)
744
+
745
+
746
+ # ----------------------------------------------------------------------
747
+ # From mass density to electron density
748
+ # ----------------------------------------------------------------------
749
+ def mass_per_electron_from_abundances(abundances: Dict[str, float]) -> float:
750
+ """
751
+ Mass per free electron of a fully ionised plasma, in atomic mass units.
752
+
753
+ This is the sum over elements of abundance times atomic weight, divided
754
+ by the sum of abundance times atomic number.
755
+
756
+ Parameters
757
+ ----------
758
+ abundances : dict
759
+ Atoms of each element per hydrogen atom, keyed by symbol, including
760
+ hydrogen itself.
761
+ """
762
+ if "H" not in abundances:
763
+ raise ValueError("The abundances must include hydrogen ('H').")
764
+ mass = 0.0
765
+ electrons = 0.0
766
+ for symbol, per_hydrogen in abundances.items():
767
+ atomic_number, atomic_weight = element_data(symbol)
768
+ mass += per_hydrogen * atomic_weight
769
+ electrons += per_hydrogen * atomic_number
770
+ return mass / electrons
771
+
772
+
773
+ def _offer_database_build(hdf5_dbase_root: Optional[str] = None) -> None:
774
+ """
775
+ Offer to build fiasco's CHIANTI database if it is missing.
776
+
777
+ A new user's first run needs this. fiasco asks only when an `Ion` is
778
+ made, so code that reads the database before that, or makes its ions in
779
+ worker processes with no terminal to answer from, calls this first in
780
+ the process the user runs.
781
+ """
782
+ import fiasco
783
+ from fiasco.util import check_database
784
+
785
+ root = fiasco.defaults["hdf5_dbase_root"] if hdf5_dbase_root is None else hdf5_dbase_root
786
+ check_database(root)
787
+ # The user declined: raise here rather than have each worker ask again.
788
+ if not Path(root).is_file():
789
+ raise FileNotFoundError(
790
+ f"There is no CHIANTI database for fiasco at {root}, and it was not built. "
791
+ f"Answer yes when asked to build it, or build it as fiasco's documentation "
792
+ f"describes.")
793
+
794
+
795
+ @lru_cache(maxsize=None)
796
+ def mass_per_electron(abundance: str, hdf5_dbase_root: Optional[str] = None) -> float:
797
+ """
798
+ Mass per free electron for a CHIANTI abundance set, in atomic mass units.
799
+
800
+ It converts a simulation's mass density to the electron density the
801
+ contribution functions need, when the simulation gives none. Every
802
+ element is assumed fully ionised. Hydrogen and helium, which give almost
803
+ all the electrons, are fully ionised above about log T 5.2, so there the
804
+ electron density is within about 1 per cent even where the metals are
805
+ not. Cooler cells get too high an electron density, but emit few of the
806
+ lines ECLIPSE synthesises. The value is about 1.16 for coronal
807
+ abundances, against 1.29 for a neutral gas, so an atmosphere read with
808
+ the neutral value has about 20 per cent too little emission measure.
809
+
810
+ Parameters
811
+ ----------
812
+ abundance : str
813
+ CHIANTI abundance set, as given to `--abundance`.
814
+ hdf5_dbase_root : str, optional
815
+ fiasco database to read it from; None uses fiasco's default.
816
+ """
817
+ import fiasco
818
+ from fiasco.util.exceptions import MissingDatasetException
819
+
820
+ _offer_database_build(hdf5_dbase_root)
821
+ ion_kwargs = {} if hdf5_dbase_root is None else {"hdf5_dbase_root": hdf5_dbase_root}
822
+ ions = fiasco.list_ions(hdf5_dbase_root)
823
+ abundances = {}
824
+ for symbol in fiasco.list_elements(hdf5_dbase_root):
825
+ # The abundance belongs to the element but is read through an ion,
826
+ # so any existing ion of it will do.
827
+ ion_name = next(name for name in ions if name.startswith(f"{symbol} "))
828
+ ion = fiasco.Ion(ion_name, _ABUNDANCE_PROBE_TEMPERATURE,
829
+ abundance=abundance, **ion_kwargs)
830
+ try:
831
+ abundances[symbol] = float(ion.abundance)
832
+ except MissingDatasetException:
833
+ # The set does not list this element, so it has none.
834
+ continue
835
+ if not abundances:
836
+ raise ValueError(f"No element has an abundance in the set "
837
+ f"{abundance!r}; is the name right?")
838
+ return mass_per_electron_from_abundances(abundances)
839
+
840
+
841
+ def build_parser() -> argparse.ArgumentParser:
842
+ """Argument parser for eclipse-atmosphere."""
843
+ parser = argparse.ArgumentParser(
844
+ prog="eclipse-atmosphere", description="Inspect ECLIPSE atmosphere files.")
845
+ commands = parser.add_subparsers(dest="command", required=True)
846
+ info = commands.add_parser("info", help="Describe an atmosphere file")
847
+ info.add_argument("atmosphere", type=str, help="The file to describe")
848
+ return parser
849
+
850
+
851
+ def main(argv: Optional[Sequence[str]] = None) -> None:
852
+ """Entry point: `eclipse-atmosphere info FILE` describes a file without loading its cubes."""
853
+ args = build_parser().parse_args(argv)
854
+ print(args.atmosphere)
855
+ print(describe_atmosphere_file(args.atmosphere))
856
+
857
+
858
+ if __name__ == "__main__":
859
+ main(sys.argv[1:])