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/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:])
|