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
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Synthesis files: the spectra the instrument simulation observes, as HDF5.
|
|
3
|
+
|
|
4
|
+
The layout is described on the Files page of the docs. The view is
|
|
5
|
+
`integration_axis`, `"x"`, `"y"` or `"z"` seen from +x, -y or above, or
|
|
6
|
+
`"-x"`, `"+y"` or `"-z"` from the other side, where the image is mirrored
|
|
7
|
+
left to right and `x_edges` change sign. A file without it is taken as
|
|
8
|
+
seen along z.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import numbers
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterable, Mapping, Optional
|
|
18
|
+
|
|
19
|
+
import astropy.units as u
|
|
20
|
+
import h5py
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from .atmosphere import (_check_format, _open_to_read, _read_dataset, _replacing,
|
|
24
|
+
_text_attribute, _write_dataset)
|
|
25
|
+
from .utils import (_bin_edges, _unpickle, angle_to_distance, onto_wavelength_bins, require_uniform_grid,
|
|
26
|
+
view_axis_and_side, view_name)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"FORMAT_NAME",
|
|
30
|
+
"FORMAT_VERSION",
|
|
31
|
+
"RADIANCE_UNIT",
|
|
32
|
+
"SpectralLine",
|
|
33
|
+
"Synthesis",
|
|
34
|
+
"read_synthesis",
|
|
35
|
+
"read_synthesis_layout",
|
|
36
|
+
"write_synthesis",
|
|
37
|
+
"read_synthesis_products",
|
|
38
|
+
"load_synthesis",
|
|
39
|
+
"write_line_cubes",
|
|
40
|
+
"convert_synthesis_pickle",
|
|
41
|
+
"is_synthesis_file",
|
|
42
|
+
"synthesis_line_names",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
FORMAT_NAME = "eclipse-synthesis"
|
|
46
|
+
FORMAT_VERSION = 1
|
|
47
|
+
|
|
48
|
+
# Spectral radiance unit of the instrument simulation and ECLIPSE's synthesis.
|
|
49
|
+
RADIANCE_UNIT = u.erg / (u.s * u.cm**2 * u.sr * u.cm)
|
|
50
|
+
|
|
51
|
+
# A spectral radiance without the steradian: a flux density per wavelength or
|
|
52
|
+
# per frequency, in energy or in photons.
|
|
53
|
+
_SPECTRAL_FLUX_DENSITIES = (u.erg / (u.s * u.cm**2 * u.cm), u.erg / (u.s * u.cm**2 * u.Hz),
|
|
54
|
+
u.ph / (u.s * u.cm**2 * u.cm), u.ph / (u.s * u.cm**2 * u.Hz))
|
|
55
|
+
|
|
56
|
+
AXES = ("x", "y")
|
|
57
|
+
EDGES = {axis: f"{axis}_edges" for axis in AXES}
|
|
58
|
+
# Example unit for each dataset, for error messages that ask for one.
|
|
59
|
+
UNITS = {"intensity": u.erg / (u.s * u.cm**2 * u.sr * u.AA), "wavelength": u.AA,
|
|
60
|
+
"rest_wavelength": u.AA, "x_edges": u.Mm, "y_edges": u.Mm, "time": u.s}
|
|
61
|
+
# Attributes naming the ion that emits a line, as in ECLIPSE's line cubes.
|
|
62
|
+
IDENTITY = ("atom", "ion")
|
|
63
|
+
# Image axes for a view along each simulation axis, from either side, as
|
|
64
|
+
# named in ECLIPSE's line cubes.
|
|
65
|
+
_VIEW_CTYPES = {"z": ("SOLX", "SOLY"), "x": ("SOLY", "SOLZ"), "y": ("SOLX", "SOLZ")}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def to_radiance(intensity: u.Quantity, wavelength: u.Quantity) -> u.Quantity:
|
|
69
|
+
"""
|
|
70
|
+
Convert `intensity` from any unit of spectral radiance to erg / (s cm2 sr cm).
|
|
71
|
+
|
|
72
|
+
A radiance per frequency or in photons is converted at each value of
|
|
73
|
+
`wavelength`, which runs along the last axis of `intensity`.
|
|
74
|
+
"""
|
|
75
|
+
# spectral_density converts flux densities per wavelength or frequency,
|
|
76
|
+
# in energy or photons, but knows nothing of solid angle, so the
|
|
77
|
+
# steradian is removed and put back. It would also convert lambda
|
|
78
|
+
# F_lambda (the unit of an intensity summed over a line), so the flux
|
|
79
|
+
# density is first checked to be one of the four kinds.
|
|
80
|
+
flux = intensity * u.sr
|
|
81
|
+
if not any(flux.unit.is_equivalent(kind) for kind in _SPECTRAL_FLUX_DENSITIES):
|
|
82
|
+
raise u.UnitConversionError(
|
|
83
|
+
f"The intensity must be a spectral radiance, per wavelength or per "
|
|
84
|
+
f"frequency, such as erg / (s cm2 sr Angstrom) or W / (m2 sr Hz); "
|
|
85
|
+
f"got {intensity.unit}.")
|
|
86
|
+
return flux.to(RADIANCE_UNIT * u.sr, equivalencies=u.spectral_density(wavelength)) / u.sr
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _checked(value, name: str, ndim: int, kinds: Optional[tuple] = None) -> u.Quantity:
|
|
90
|
+
"""
|
|
91
|
+
Validate and return `value`.
|
|
92
|
+
|
|
93
|
+
It must be a finite Quantity with `ndim` axes and, if `kinds` is given,
|
|
94
|
+
a unit of one of those physical types.
|
|
95
|
+
"""
|
|
96
|
+
if not isinstance(value, u.Quantity):
|
|
97
|
+
raise TypeError(f"{name} must be an astropy Quantity, got "
|
|
98
|
+
f"{type(value).__name__}.")
|
|
99
|
+
if kinds is not None and value.unit.physical_type not in kinds:
|
|
100
|
+
raise u.UnitConversionError(
|
|
101
|
+
f"{name} must be in a unit of {' or '.join(kinds)}, got {value.unit}.")
|
|
102
|
+
if value.ndim != ndim:
|
|
103
|
+
raise ValueError(f"{name} must have {ndim} dimensions, got {value.ndim}.")
|
|
104
|
+
if not np.all(np.isfinite(value.value)):
|
|
105
|
+
raise ValueError(f"{name} contains NaN or infinite values.")
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _to_length(value: u.Quantity) -> u.Quantity:
|
|
110
|
+
"""Position or size on the Sun in Mm, from a length or an angle seen from 1 AU."""
|
|
111
|
+
if value.unit.physical_type == "angle":
|
|
112
|
+
return angle_to_distance(value).to(u.Mm)
|
|
113
|
+
return value.to(u.Mm)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Relative tolerance for two grids that should be the same, allowing only for
|
|
117
|
+
# rounding, e.g. from converting between Angstrom and nm or from edges
|
|
118
|
+
# computed from two WCSs.
|
|
119
|
+
_SAME_GRID = 1e-12
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _same_grid(found: u.Quantity, expected: u.Quantity) -> bool:
|
|
123
|
+
"""Whether `found` is the same grid as `expected`, up to rounding."""
|
|
124
|
+
if found.shape != expected.shape:
|
|
125
|
+
return False
|
|
126
|
+
values = found.to_value(expected.unit)
|
|
127
|
+
return bool(np.all(np.abs(values - expected.value)
|
|
128
|
+
<= _SAME_GRID * np.abs(expected.value).max()))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _check_wavelength(wavelength) -> u.Quantity:
|
|
132
|
+
"""
|
|
133
|
+
Validate and return `wavelength`.
|
|
134
|
+
|
|
135
|
+
It must be a finite, increasing 1D Quantity of lengths with at least 2
|
|
136
|
+
values.
|
|
137
|
+
"""
|
|
138
|
+
wavelength = _checked(wavelength, "wavelength", ndim=1, kinds=("length",))
|
|
139
|
+
if wavelength.size < 2:
|
|
140
|
+
raise ValueError(f"wavelength must have at least 2 values, got {wavelength.size}.")
|
|
141
|
+
if np.any(np.diff(wavelength.value) <= 0):
|
|
142
|
+
raise ValueError("wavelength must increase; a grid that decreases, as "
|
|
143
|
+
"one converted from increasing frequencies does, "
|
|
144
|
+
"needs it and the intensity reversed along it.")
|
|
145
|
+
return wavelength
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class SpectralLine:
|
|
150
|
+
"""
|
|
151
|
+
Spectra of one line, or of one spectral window, over the image.
|
|
152
|
+
|
|
153
|
+
Parameters
|
|
154
|
+
----------
|
|
155
|
+
intensity : u.Quantity
|
|
156
|
+
Spectral radiance at each pixel and wavelength,
|
|
157
|
+
`(ny, nx, n_wavelength)`, in any unit of spectral radiance.
|
|
158
|
+
wavelength : u.Quantity
|
|
159
|
+
Wavelength of each plane of `intensity`, increasing, not necessarily
|
|
160
|
+
evenly spaced.
|
|
161
|
+
rest_wavelength : u.Quantity
|
|
162
|
+
Wavelength the line's Doppler shifts are measured from, within the
|
|
163
|
+
range of `wavelength`.
|
|
164
|
+
atom, ion : int, optional
|
|
165
|
+
Atomic number and ionisation stage of the emitting ion, e.g. 26 and
|
|
166
|
+
12 for Fe XII. Carried into the results.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
intensity: u.Quantity
|
|
170
|
+
wavelength: u.Quantity
|
|
171
|
+
rest_wavelength: u.Quantity
|
|
172
|
+
atom: Optional[int] = None
|
|
173
|
+
ion: Optional[int] = None
|
|
174
|
+
|
|
175
|
+
def __post_init__(self):
|
|
176
|
+
wavelength = _check_wavelength(self.wavelength)
|
|
177
|
+
for name in IDENTITY:
|
|
178
|
+
value = getattr(self, name)
|
|
179
|
+
if value is not None and (isinstance(value, bool)
|
|
180
|
+
or not isinstance(value, numbers.Integral) or value < 1):
|
|
181
|
+
raise ValueError(f"{name} must be a whole number from 1, got {value!r}.")
|
|
182
|
+
if self.atom is not None and self.ion is not None and self.ion > self.atom + 1:
|
|
183
|
+
raise ValueError(f"ion {self.ion} is beyond the last stage of atom {self.atom}, "
|
|
184
|
+
f"{self.atom + 1}.")
|
|
185
|
+
rest = _checked(self.rest_wavelength, "rest_wavelength", ndim=0, kinds=("length",))
|
|
186
|
+
if not wavelength[0] <= rest <= wavelength[-1]:
|
|
187
|
+
raise ValueError(f"rest_wavelength {rest} is outside the wavelengths, "
|
|
188
|
+
f"which run from {wavelength[0]} to {wavelength[-1]}.")
|
|
189
|
+
intensity = _checked(self.intensity, "intensity", ndim=3)
|
|
190
|
+
# A trial conversion rejects any unit that is not a spectral radiance.
|
|
191
|
+
to_radiance(1.0 * intensity.unit, wavelength[0])
|
|
192
|
+
if intensity.shape[-1] != wavelength.size:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"intensity has {intensity.shape[-1]} wavelengths along its last "
|
|
195
|
+
f"axis but there are {wavelength.size}. The intensity is "
|
|
196
|
+
f"(y, x, wavelength): y along the slit first.")
|
|
197
|
+
if np.any(intensity.value < 0):
|
|
198
|
+
raise ValueError("intensity has negative values, which no spectral "
|
|
199
|
+
"radiance can have.")
|
|
200
|
+
|
|
201
|
+
def radiance(self) -> u.Quantity:
|
|
202
|
+
"""Intensity in erg / (s cm2 sr cm), the unit of ECLIPSE's own synthesis."""
|
|
203
|
+
return to_radiance(self.intensity, self.wavelength)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class Synthesis:
|
|
208
|
+
"""
|
|
209
|
+
Spectra over an image, line by line, for the instrument simulation.
|
|
210
|
+
|
|
211
|
+
Parameters
|
|
212
|
+
----------
|
|
213
|
+
lines : mapping of str to SpectralLine
|
|
214
|
+
Lines keyed by the names `reference_line` uses in the instrument
|
|
215
|
+
configuration. All share the image.
|
|
216
|
+
x_edges, y_edges : u.Quantity
|
|
217
|
+
Pixel boundaries, evenly spaced, as lengths on the Sun or angles seen
|
|
218
|
+
from 1 AU. x runs across the slit and y along it.
|
|
219
|
+
source : str, optional
|
|
220
|
+
Free text naming the code and the model, kept in the file.
|
|
221
|
+
time : u.Quantity, optional
|
|
222
|
+
Time of the snapshot, needed to place it in a time series of
|
|
223
|
+
synthesis files.
|
|
224
|
+
integration_axis : str, optional
|
|
225
|
+
View: the simulation axis the image was seen along, `"x"`, `"y"` or
|
|
226
|
+
`"z"`, which names the image axes of the cubes made from it. A sign,
|
|
227
|
+
such as `"-x"`, gives the side of the box it was seen from. None
|
|
228
|
+
means seen along z.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
lines: Mapping[str, SpectralLine]
|
|
232
|
+
x_edges: u.Quantity
|
|
233
|
+
y_edges: u.Quantity
|
|
234
|
+
source: str = ""
|
|
235
|
+
time: Optional[u.Quantity] = None
|
|
236
|
+
integration_axis: Optional[str] = None
|
|
237
|
+
|
|
238
|
+
def __post_init__(self):
|
|
239
|
+
if self.time is not None:
|
|
240
|
+
_checked(self.time, "time", ndim=0, kinds=("time",))
|
|
241
|
+
if self.integration_axis is not None:
|
|
242
|
+
try:
|
|
243
|
+
view = view_name(self.integration_axis)
|
|
244
|
+
except ValueError:
|
|
245
|
+
raise ValueError(f"integration_axis must be one of 'x', 'y', 'z', '+x', '-x', "
|
|
246
|
+
f"'+y', '-y', '+z', '-z' or None, got "
|
|
247
|
+
f"{self.integration_axis!r}.") from None
|
|
248
|
+
# Normalise the name, so "+x" and "x" are the same view.
|
|
249
|
+
object.__setattr__(self, "integration_axis", view)
|
|
250
|
+
for axis in AXES:
|
|
251
|
+
name = EDGES[axis]
|
|
252
|
+
edges = _checked(getattr(self, name), name, ndim=1, kinds=("length", "angle"))
|
|
253
|
+
# The instrument grid uses one pixel size per axis, so the edges
|
|
254
|
+
# must be evenly spaced.
|
|
255
|
+
require_uniform_grid(edges.value, name)
|
|
256
|
+
if not isinstance(self.lines, Mapping) or not self.lines:
|
|
257
|
+
raise ValueError("A synthesis needs at least one line, as a mapping "
|
|
258
|
+
"from its name to a SpectralLine.")
|
|
259
|
+
for name, line in self.lines.items():
|
|
260
|
+
if not isinstance(name, str) or not name or "/" in name:
|
|
261
|
+
raise ValueError(f"A line's name must be a string without '/', "
|
|
262
|
+
f"got {name!r}.")
|
|
263
|
+
if not isinstance(line, SpectralLine):
|
|
264
|
+
raise TypeError(f"Line {name!r} must be a SpectralLine, got "
|
|
265
|
+
f"{type(line).__name__}.")
|
|
266
|
+
if line.intensity.shape[:2] != self.shape:
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"Line {name!r} has an image of {line.intensity.shape[:2]} pixels "
|
|
269
|
+
f"but the edges give (ny, nx) = {self.shape}. The intensity is "
|
|
270
|
+
f"(y, x, wavelength): y along the slit first.")
|
|
271
|
+
if not isinstance(self.source, str):
|
|
272
|
+
raise TypeError(f"source must be a string, got "
|
|
273
|
+
f"{type(self.source).__name__}.")
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def shape(self) -> tuple[int, int]:
|
|
277
|
+
"""Image shape, `(ny, nx)`."""
|
|
278
|
+
return (self.y_edges.size - 1, self.x_edges.size - 1)
|
|
279
|
+
|
|
280
|
+
def pixel_size(self, axis: str) -> u.Quantity:
|
|
281
|
+
"""Pixel size along `axis` on the Sun, in Mm."""
|
|
282
|
+
edges = getattr(self, EDGES[_check_axis(axis)])
|
|
283
|
+
return _to_length(require_uniform_grid(edges.value, EDGES[axis]) * edges.unit)
|
|
284
|
+
|
|
285
|
+
def centre(self, axis: str) -> u.Quantity:
|
|
286
|
+
"""Centre of the image along `axis` on the Sun, in Mm."""
|
|
287
|
+
edges = getattr(self, EDGES[_check_axis(axis)])
|
|
288
|
+
return _to_length(0.5 * (edges[0] + edges[-1]))
|
|
289
|
+
|
|
290
|
+
def summed(self, reference: str) -> u.Quantity:
|
|
291
|
+
"""
|
|
292
|
+
Sum of all lines' radiance on the wavelengths of line `reference`.
|
|
293
|
+
|
|
294
|
+
This is what the instrument observes when it measures `reference`.
|
|
295
|
+
Each line is averaged onto the reference wavelength bins, conserving
|
|
296
|
+
flux, and is zero outside its own wavelength range. Lines reaching
|
|
297
|
+
the reference window add to it as blends; the others add nothing.
|
|
298
|
+
"""
|
|
299
|
+
if reference not in self.lines:
|
|
300
|
+
raise ValueError(f"No line is named {reference!r}; the lines are "
|
|
301
|
+
f"{list(self.lines)}.")
|
|
302
|
+
wavelength = self.lines[reference].wavelength
|
|
303
|
+
total = np.zeros(self.shape + (wavelength.size,))
|
|
304
|
+
for line in self.lines.values():
|
|
305
|
+
total += onto_wavelength_bins(line.radiance().to_value(RADIANCE_UNIT),
|
|
306
|
+
line.wavelength.to_value(wavelength.unit),
|
|
307
|
+
wavelength.value)
|
|
308
|
+
return total * RADIANCE_UNIT
|
|
309
|
+
|
|
310
|
+
def evenly_spaced(self, name: str) -> bool:
|
|
311
|
+
"""Whether line `name` has evenly spaced wavelengths, as a WCS needs."""
|
|
312
|
+
try:
|
|
313
|
+
require_uniform_grid(self.lines[name].wavelength.value, "wavelength")
|
|
314
|
+
except ValueError:
|
|
315
|
+
return False
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
def line_meta(self, name: str) -> dict:
|
|
319
|
+
"""Metadata of line `name`, as in ECLIPSE's line cubes."""
|
|
320
|
+
from .utils import VELOCITY_CONVENTION
|
|
321
|
+
|
|
322
|
+
line = self.lines[name]
|
|
323
|
+
return {"line_name": name, "rest_wav": line.rest_wavelength,
|
|
324
|
+
"integration_axis": self.integration_axis or "z", "source": self.source,
|
|
325
|
+
"velocity_convention": VELOCITY_CONVENTION,
|
|
326
|
+
**{key: getattr(line, key) for key in IDENTITY
|
|
327
|
+
if getattr(line, key) is not None}}
|
|
328
|
+
|
|
329
|
+
def summed_meta(self, reference: str) -> dict:
|
|
330
|
+
"""
|
|
331
|
+
Metadata for `summed`: that of `reference` plus the names of the summed lines.
|
|
332
|
+
"""
|
|
333
|
+
return {**self.line_meta(reference), "combined_lines": list(self.lines),
|
|
334
|
+
"n_lines": len(self.lines), "metadata_source": reference,
|
|
335
|
+
"summed_intensity": True}
|
|
336
|
+
|
|
337
|
+
def line_cube(self, name: str):
|
|
338
|
+
"""
|
|
339
|
+
Line `name` as an NDCube, indexed `[y, x, wavelength]`, in erg / (s cm2 sr cm).
|
|
340
|
+
|
|
341
|
+
The wavelengths must be evenly spaced for the cube's WCS to describe
|
|
342
|
+
them.
|
|
343
|
+
"""
|
|
344
|
+
return self._cube(self.lines[name].radiance(), name, self.line_meta(name))
|
|
345
|
+
|
|
346
|
+
def summed_cube(self, reference: str, summed: Optional[u.Quantity] = None,
|
|
347
|
+
meta: Optional[Mapping] = None):
|
|
348
|
+
"""
|
|
349
|
+
`summed` as an NDCube on the wavelengths of `reference`.
|
|
350
|
+
|
|
351
|
+
The reference wavelengths must be evenly spaced. The `summed`
|
|
352
|
+
argument, if given, is the result of `summed` already computed.
|
|
353
|
+
`meta` is added to `summed_meta`.
|
|
354
|
+
"""
|
|
355
|
+
return self._cube(self.summed(reference) if summed is None else summed, reference,
|
|
356
|
+
{**self.summed_meta(reference), **(meta or {})})
|
|
357
|
+
|
|
358
|
+
def _cube(self, data: u.Quantity, name: str, meta: dict):
|
|
359
|
+
from astropy.wcs import WCS
|
|
360
|
+
from ndcube import NDCube
|
|
361
|
+
|
|
362
|
+
line = self.lines[name]
|
|
363
|
+
step = require_uniform_grid(line.wavelength.to_value(u.cm), f"the wavelengths of {name}")
|
|
364
|
+
ny, nx = self.shape
|
|
365
|
+
n_wavelength = line.wavelength.size
|
|
366
|
+
wcs = WCS(naxis=3)
|
|
367
|
+
axis, _ = view_axis_and_side(self.integration_axis or "z")
|
|
368
|
+
wcs.wcs.ctype = ["WAVE", *_VIEW_CTYPES[axis]]
|
|
369
|
+
wcs.wcs.cunit = ["cm", "Mm", "Mm"]
|
|
370
|
+
wcs.wcs.crpix = [(n_wavelength + 1) / 2, (nx + 1) / 2, (ny + 1) / 2]
|
|
371
|
+
middle = 0.5 * (line.wavelength[0] + line.wavelength[-1])
|
|
372
|
+
wcs.wcs.crval = [middle.to_value(u.cm), self.centre("x").to_value(u.Mm),
|
|
373
|
+
self.centre("y").to_value(u.Mm)]
|
|
374
|
+
wcs.wcs.cdelt = [step, self.pixel_size("x").to_value(u.Mm),
|
|
375
|
+
self.pixel_size("y").to_value(u.Mm)]
|
|
376
|
+
return NDCube(data.value, wcs=wcs, unit=data.unit, meta=meta)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _check_axis(axis: str) -> str:
|
|
380
|
+
if axis not in AXES:
|
|
381
|
+
raise ValueError(f"axis must be one of {AXES}, got {axis!r}.")
|
|
382
|
+
return axis
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ----------------------------------------------------------------------
|
|
386
|
+
# HDF5
|
|
387
|
+
# ----------------------------------------------------------------------
|
|
388
|
+
def is_synthesis_file(path: str | Path) -> bool:
|
|
389
|
+
"""Whether `path` is HDF5, as synthesis files are, rather than an older pickle."""
|
|
390
|
+
return h5py.is_hdf5(str(path))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def synthesis_line_names(path: str | Path) -> list:
|
|
394
|
+
"""Names of the lines in a synthesis file, in file order, without reading the spectra."""
|
|
395
|
+
path = Path(path)
|
|
396
|
+
with _open_to_read(path, "synthesis") as f:
|
|
397
|
+
_check_format(f, path, kind="synthesis", format_name=FORMAT_NAME,
|
|
398
|
+
format_version=FORMAT_VERSION)
|
|
399
|
+
return list(f["lines"]) if "lines" in f else []
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def write_synthesis(synthesis: Synthesis, path: str | Path,
|
|
403
|
+
products: Optional[Mapping] = None,
|
|
404
|
+
compression: Optional[str] = None) -> Path:
|
|
405
|
+
"""
|
|
406
|
+
Write a synthesis file.
|
|
407
|
+
|
|
408
|
+
The file is written to a new file beside it and then moved into place,
|
|
409
|
+
so a run stopped part way leaves any existing file unchanged.
|
|
410
|
+
|
|
411
|
+
Parameters
|
|
412
|
+
----------
|
|
413
|
+
synthesis : Synthesis
|
|
414
|
+
Spectra to write.
|
|
415
|
+
path : str or Path
|
|
416
|
+
File to write. An existing file is replaced.
|
|
417
|
+
products : mapping, optional
|
|
418
|
+
Intermediate products of the synthesis, stored in the file's
|
|
419
|
+
`synthesis` group and read back by `read_synthesis_products`.
|
|
420
|
+
compression : str, optional
|
|
421
|
+
HDF5 compression filter for the spectra, such as `"gzip"`. Default
|
|
422
|
+
None (uncompressed), which reads fastest.
|
|
423
|
+
|
|
424
|
+
Returns
|
|
425
|
+
-------
|
|
426
|
+
Path
|
|
427
|
+
Path of the written file.
|
|
428
|
+
"""
|
|
429
|
+
path = Path(path)
|
|
430
|
+
with _replacing(path) as partial, h5py.File(partial, "w") as f:
|
|
431
|
+
f.attrs["format"] = FORMAT_NAME
|
|
432
|
+
f.attrs["version"] = FORMAT_VERSION
|
|
433
|
+
f.attrs["source"] = synthesis.source
|
|
434
|
+
if synthesis.integration_axis is not None:
|
|
435
|
+
f.attrs["integration_axis"] = synthesis.integration_axis
|
|
436
|
+
for axis in AXES:
|
|
437
|
+
_write_dataset(f, EDGES[axis], getattr(synthesis, EDGES[axis]))
|
|
438
|
+
if synthesis.time is not None:
|
|
439
|
+
_write_dataset(f, "time", synthesis.time)
|
|
440
|
+
# Keep the given line order, which HDF5 would otherwise sort by name.
|
|
441
|
+
group = f.create_group("lines", track_order=True)
|
|
442
|
+
for name, line in synthesis.lines.items():
|
|
443
|
+
entry = group.create_group(name)
|
|
444
|
+
_write_dataset(entry, "intensity", line.intensity, compression=compression)
|
|
445
|
+
_write_dataset(entry, "wavelength", line.wavelength)
|
|
446
|
+
_write_dataset(entry, "rest_wavelength", line.rest_wavelength)
|
|
447
|
+
for key in IDENTITY:
|
|
448
|
+
if getattr(line, key) is not None:
|
|
449
|
+
entry.attrs[key] = int(getattr(line, key))
|
|
450
|
+
if products:
|
|
451
|
+
_write_tree(f.create_group("synthesis", track_order=True), products)
|
|
452
|
+
return path
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def read_synthesis(path: str | Path, reference_line: Optional[str] = None,
|
|
456
|
+
columns: Optional[slice] = None) -> Synthesis:
|
|
457
|
+
"""
|
|
458
|
+
Read the spectra of a synthesis file, on their original wavelengths.
|
|
459
|
+
|
|
460
|
+
Parameters
|
|
461
|
+
----------
|
|
462
|
+
path : str or Path
|
|
463
|
+
Synthesis file.
|
|
464
|
+
reference_line : str, optional
|
|
465
|
+
Read only the lines that reach this line's spectral window. Default
|
|
466
|
+
None reads every line.
|
|
467
|
+
columns : slice, optional
|
|
468
|
+
Contiguous slice of pixels along x to read. Default None reads the
|
|
469
|
+
whole image.
|
|
470
|
+
|
|
471
|
+
Returns
|
|
472
|
+
-------
|
|
473
|
+
Synthesis
|
|
474
|
+
Spectra in the file.
|
|
475
|
+
"""
|
|
476
|
+
path = Path(path)
|
|
477
|
+
with _open_to_read(path, "synthesis") as f:
|
|
478
|
+
_check_format(f, path, kind="synthesis", format_name=FORMAT_NAME,
|
|
479
|
+
format_version=FORMAT_VERSION)
|
|
480
|
+
edges = {EDGES[axis]: _read_dataset(f, EDGES[axis], units=UNITS) for axis in AXES}
|
|
481
|
+
if columns is not None:
|
|
482
|
+
nx = edges["x_edges"].size - 1
|
|
483
|
+
first = 0 if columns.start is None else columns.start
|
|
484
|
+
last = nx if columns.stop is None else columns.stop
|
|
485
|
+
if columns.step not in (None, 1) or not 0 <= first < last <= nx:
|
|
486
|
+
raise ValueError(f"columns must be a slice of neighbouring pixels among the "
|
|
487
|
+
f"{nx} along x, got {columns}.")
|
|
488
|
+
edges["x_edges"] = edges["x_edges"][first:last + 1]
|
|
489
|
+
columns = slice(first, last)
|
|
490
|
+
group, names = _lines_reaching(f, path, reference_line)
|
|
491
|
+
lines = {}
|
|
492
|
+
for name in names:
|
|
493
|
+
entry = group[name]
|
|
494
|
+
fields = {"intensity": _read_dataset(entry, "intensity", columns, units=UNITS, axis=1),
|
|
495
|
+
"wavelength": _read_dataset(entry, "wavelength", units=UNITS),
|
|
496
|
+
"rest_wavelength": _read_dataset(entry, "rest_wavelength", units=UNITS),
|
|
497
|
+
**_identity(entry)}
|
|
498
|
+
try:
|
|
499
|
+
lines[name] = SpectralLine(**fields)
|
|
500
|
+
except (TypeError, ValueError) as error:
|
|
501
|
+
raise type(error)(f"{path}, line {name!r}: {error}") from None
|
|
502
|
+
source, integration_axis = _source(f), _integration_axis(f)
|
|
503
|
+
time = _read_dataset(f, "time", units=UNITS) if "time" in f else None
|
|
504
|
+
try:
|
|
505
|
+
return Synthesis(lines=lines, source=source, time=time,
|
|
506
|
+
integration_axis=integration_axis, **edges)
|
|
507
|
+
except (TypeError, ValueError) as error:
|
|
508
|
+
raise type(error)(f"{path}: {error}") from None
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def read_synthesis_layout(path: str | Path, reference_line: Optional[str] = None) -> dict:
|
|
512
|
+
"""
|
|
513
|
+
Contents of a synthesis file, without reading the spectra.
|
|
514
|
+
|
|
515
|
+
Returns a dict of `x_edges`, `y_edges`, `time` (None if absent),
|
|
516
|
+
`source`, `integration_axis` (None if absent) and `lines`. `lines` maps
|
|
517
|
+
each line, or each one reaching the window of `reference_line` if
|
|
518
|
+
given, to its `wavelength`, `rest_wavelength`, `identity` (its `atom`
|
|
519
|
+
and `ion`, where the file gives them) and the `shape` of its intensity.
|
|
520
|
+
A time series uses it to check its files before reading any spectra.
|
|
521
|
+
"""
|
|
522
|
+
path = Path(path)
|
|
523
|
+
with _open_to_read(path, "synthesis") as f:
|
|
524
|
+
_check_format(f, path, kind="synthesis", format_name=FORMAT_NAME,
|
|
525
|
+
format_version=FORMAT_VERSION)
|
|
526
|
+
layout = {EDGES[axis]: _read_dataset(f, EDGES[axis], units=UNITS) for axis in AXES}
|
|
527
|
+
layout["time"] = None
|
|
528
|
+
if "time" in f:
|
|
529
|
+
try:
|
|
530
|
+
layout["time"] = _checked(_read_dataset(f, "time", units=UNITS), "time",
|
|
531
|
+
ndim=0, kinds=("time",))
|
|
532
|
+
except (TypeError, ValueError) as error:
|
|
533
|
+
raise type(error)(f"{path}: {error}") from None
|
|
534
|
+
layout["source"] = _source(f)
|
|
535
|
+
layout["integration_axis"] = _integration_axis(f)
|
|
536
|
+
group, names = _lines_reaching(f, path, reference_line)
|
|
537
|
+
layout["lines"] = {}
|
|
538
|
+
for name in names:
|
|
539
|
+
entry = group[name]
|
|
540
|
+
if "intensity" not in entry:
|
|
541
|
+
raise ValueError(f"{path}, line {name!r} has no 'intensity' dataset.")
|
|
542
|
+
layout["lines"][name] = {
|
|
543
|
+
"wavelength": _read_dataset(entry, "wavelength", units=UNITS),
|
|
544
|
+
"rest_wavelength": _read_dataset(entry, "rest_wavelength", units=UNITS),
|
|
545
|
+
"identity": _identity(entry), "shape": entry["intensity"].shape}
|
|
546
|
+
return layout
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _lines_reaching(f: h5py.File, path: Path, reference_line: Optional[str]):
|
|
550
|
+
"""
|
|
551
|
+
The file's `lines` group and the names of its lines.
|
|
552
|
+
|
|
553
|
+
Only the lines reaching the window of `reference_line` are named, or all
|
|
554
|
+
of them if it is None.
|
|
555
|
+
"""
|
|
556
|
+
group = f.get("lines")
|
|
557
|
+
if group is None or len(group) == 0:
|
|
558
|
+
raise ValueError(f"{path} holds no lines.")
|
|
559
|
+
names = list(group)
|
|
560
|
+
if reference_line is not None:
|
|
561
|
+
if reference_line not in group:
|
|
562
|
+
raise ValueError(f"'reference_line' {reference_line!r} is not in {path}; "
|
|
563
|
+
f"its lines are {names}.")
|
|
564
|
+
window = _line_wavelength(group, reference_line, path)
|
|
565
|
+
names = [name for name in names
|
|
566
|
+
if _reaches(_line_wavelength(group, name, path), window)]
|
|
567
|
+
return group, names
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _line_wavelength(group: h5py.Group, name: str, path: Path) -> u.Quantity:
|
|
571
|
+
"""
|
|
572
|
+
Wavelengths of line `name`, checked as when the line is read.
|
|
573
|
+
|
|
574
|
+
They decide whether the line reaches the window, so a line with
|
|
575
|
+
unreadable wavelengths raises an error rather than being skipped.
|
|
576
|
+
"""
|
|
577
|
+
wavelength = _read_dataset(group[name], "wavelength", units=UNITS)
|
|
578
|
+
try:
|
|
579
|
+
return _check_wavelength(wavelength)
|
|
580
|
+
except (TypeError, ValueError) as error:
|
|
581
|
+
raise type(error)(f"{path}, line {name!r}: {error}") from None
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _source(f: h5py.File) -> str:
|
|
585
|
+
return str(_text_attribute(f.attrs, "source", ""))
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _identity(entry: h5py.Group) -> dict:
|
|
589
|
+
"""A line's atom and ion, where its group gives them."""
|
|
590
|
+
return {key: int(entry.attrs[key]) for key in IDENTITY if key in entry.attrs}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _integration_axis(f: h5py.File) -> Optional[str]:
|
|
594
|
+
axis = _text_attribute(f.attrs, "integration_axis")
|
|
595
|
+
return None if axis is None else str(axis)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _reaches(wavelength: u.Quantity, window: u.Quantity) -> bool:
|
|
599
|
+
"""
|
|
600
|
+
Whether a line sampled at `wavelength` has any part inside `window`.
|
|
601
|
+
|
|
602
|
+
Each wavelength covers the bin halfway to its neighbours, as in the sum
|
|
603
|
+
of the lines, so a line reaches the window if its outermost bin
|
|
604
|
+
overlaps the window's, even when all its wavelengths lie outside.
|
|
605
|
+
"""
|
|
606
|
+
own, window = wavelength.to_value(window.unit), window.value
|
|
607
|
+
if own.size < 2 or window.size < 2:
|
|
608
|
+
# Too few values to form bins; the line is rejected when it is read.
|
|
609
|
+
return own.max() >= window.min() and own.min() <= window.max()
|
|
610
|
+
own, window = _bin_edges(own), _bin_edges(window)
|
|
611
|
+
return own[-1] > window[0] and own[0] < window[-1]
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def read_synthesis_products(path: str | Path, keys: Optional[Iterable[str]] = None) -> dict:
|
|
615
|
+
"""
|
|
616
|
+
Intermediate products of ECLIPSE's synthesis.
|
|
617
|
+
|
|
618
|
+
For a file from `synthesise-spectra` these are:
|
|
619
|
+
|
|
620
|
+
- `dem_map`: DEM in each pixel, on the temperatures `logT_grid`.
|
|
621
|
+
- `em_tv`: emission measure in each pixel by temperature and velocity,
|
|
622
|
+
on the velocities `vel_grid`.
|
|
623
|
+
- `electron_density`: electron density in cm^-3 at which the
|
|
624
|
+
contribution functions were evaluated, at each temperature of each
|
|
625
|
+
pixel, same shape as `dem_map`. It is the mean over the cells,
|
|
626
|
+
weighted by their emission measure.
|
|
627
|
+
- `goft`: each line's contribution function, on the temperatures and
|
|
628
|
+
the densities `logN_grid`.
|
|
629
|
+
- `voxel_sizes`, `atmosphere`, `dynamic_mode` and `config`: the
|
|
630
|
+
atmosphere and the settings of the synthesis.
|
|
631
|
+
|
|
632
|
+
A file from another code has none of these and gives an empty dict.
|
|
633
|
+
Tuples are returned as lists, and dictionary keys as strings.
|
|
634
|
+
|
|
635
|
+
Parameters
|
|
636
|
+
----------
|
|
637
|
+
path : str or Path
|
|
638
|
+
Synthesis file.
|
|
639
|
+
keys : iterable of str, optional
|
|
640
|
+
Products to read. Leaving out `em_tv`, often the largest part of the
|
|
641
|
+
file, saves time. Default None reads everything.
|
|
642
|
+
|
|
643
|
+
Returns
|
|
644
|
+
-------
|
|
645
|
+
dict
|
|
646
|
+
Products keyed by name.
|
|
647
|
+
"""
|
|
648
|
+
path = Path(path)
|
|
649
|
+
with _open_to_read(path, "synthesis") as f:
|
|
650
|
+
_check_format(f, path, kind="synthesis", format_name=FORMAT_NAME,
|
|
651
|
+
format_version=FORMAT_VERSION)
|
|
652
|
+
if "synthesis" not in f:
|
|
653
|
+
return {}
|
|
654
|
+
if isinstance(keys, str):
|
|
655
|
+
keys = [keys]
|
|
656
|
+
return _read_tree(f["synthesis"], None if keys is None else set(keys))
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def load_synthesis(path: str | Path) -> dict:
|
|
660
|
+
"""
|
|
661
|
+
Everything in a synthesis file: line cubes and synthesis products.
|
|
662
|
+
|
|
663
|
+
The line cubes need evenly spaced wavelengths, as ECLIPSE's own synthesis
|
|
664
|
+
writes them. For a file with uneven wavelengths, use `read_synthesis`.
|
|
665
|
+
|
|
666
|
+
Parameters
|
|
667
|
+
----------
|
|
668
|
+
path : str or Path
|
|
669
|
+
Synthesis file.
|
|
670
|
+
|
|
671
|
+
Returns
|
|
672
|
+
-------
|
|
673
|
+
dict
|
|
674
|
+
`line_cubes`, an NDCube per line indexed `[y, x, wavelength]` in
|
|
675
|
+
erg / (s cm2 sr cm), plus the products from
|
|
676
|
+
`read_synthesis_products`.
|
|
677
|
+
"""
|
|
678
|
+
synthesis = read_synthesis(path)
|
|
679
|
+
uneven = [name for name in synthesis.lines if not synthesis.evenly_spaced(name)]
|
|
680
|
+
if uneven:
|
|
681
|
+
raise ValueError(
|
|
682
|
+
f"{path}: the wavelengths of {', '.join(uneven)} are not evenly spaced, which "
|
|
683
|
+
f"the WCS of an NDCube cannot describe. Read the file with read_synthesis, "
|
|
684
|
+
f"which keeps each line's wavelengths as they are.")
|
|
685
|
+
line_cubes = {name: synthesis.line_cube(name) for name in synthesis.lines}
|
|
686
|
+
return {"line_cubes": line_cubes, **read_synthesis_products(path)}
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
# The products are nested dicts of arrays and plain values. Arrays and
|
|
690
|
+
# non-scalar Quantities become datasets. Everything else becomes a JSON
|
|
691
|
+
# attribute, with Quantities stored as value and unit so they read back as
|
|
692
|
+
# Quantities.
|
|
693
|
+
def _write_tree(group: h5py.Group, tree: Mapping) -> None:
|
|
694
|
+
for key, value in tree.items():
|
|
695
|
+
key = str(key)
|
|
696
|
+
if _is_array(value):
|
|
697
|
+
data = value.value if isinstance(value, u.Quantity) else np.asarray(value)
|
|
698
|
+
dataset = group.create_dataset(key, data=data, compression="gzip",
|
|
699
|
+
compression_opts=1)
|
|
700
|
+
if isinstance(value, u.Quantity):
|
|
701
|
+
dataset.attrs["unit"] = value.unit.to_string()
|
|
702
|
+
elif isinstance(value, Mapping) and any(_holds_array(v) for v in value.values()):
|
|
703
|
+
_write_tree(group.create_group(key, track_order=True), value)
|
|
704
|
+
else:
|
|
705
|
+
group.attrs[key] = json.dumps(_jsonable(value))
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _read_tree(group: h5py.Group, keys: Optional[set]) -> dict:
|
|
709
|
+
tree = {}
|
|
710
|
+
for key, value in group.attrs.items():
|
|
711
|
+
if keys is None or key in keys:
|
|
712
|
+
tree[key] = json.loads(value, object_hook=_unjson)
|
|
713
|
+
for key, item in group.items():
|
|
714
|
+
if keys is not None and key not in keys:
|
|
715
|
+
continue
|
|
716
|
+
if isinstance(item, h5py.Group):
|
|
717
|
+
tree[key] = _read_tree(item, None)
|
|
718
|
+
else:
|
|
719
|
+
data = item[()]
|
|
720
|
+
unit = item.attrs.get("unit")
|
|
721
|
+
tree[key] = data if unit is None else u.Quantity(data, u.Unit(
|
|
722
|
+
unit.decode() if isinstance(unit, bytes) else unit))
|
|
723
|
+
return tree
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _is_array(value) -> bool:
|
|
727
|
+
return (isinstance(value, np.ndarray) and value.ndim > 0) or (
|
|
728
|
+
isinstance(value, u.Quantity) and value.ndim > 0)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _holds_array(value) -> bool:
|
|
732
|
+
if _is_array(value):
|
|
733
|
+
return True
|
|
734
|
+
return isinstance(value, Mapping) and any(_holds_array(v) for v in value.values())
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _jsonable(value):
|
|
738
|
+
if isinstance(value, u.Quantity):
|
|
739
|
+
return {"__quantity__": _jsonable(value.value), "unit": value.unit.to_string()}
|
|
740
|
+
if isinstance(value, np.ndarray):
|
|
741
|
+
return value.tolist()
|
|
742
|
+
if isinstance(value, np.generic):
|
|
743
|
+
return value.item()
|
|
744
|
+
if isinstance(value, Mapping):
|
|
745
|
+
return {str(k): _jsonable(v) for k, v in value.items()}
|
|
746
|
+
if isinstance(value, (list, tuple)):
|
|
747
|
+
return [_jsonable(v) for v in value]
|
|
748
|
+
if isinstance(value, Path):
|
|
749
|
+
return str(value)
|
|
750
|
+
return value
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _unjson(value: dict):
|
|
754
|
+
if "__quantity__" in value:
|
|
755
|
+
return u.Quantity(value["__quantity__"], u.Unit(value["unit"]))
|
|
756
|
+
return value
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
# ----------------------------------------------------------------------
|
|
760
|
+
# From ECLIPSE's line cubes
|
|
761
|
+
# ----------------------------------------------------------------------
|
|
762
|
+
def write_line_cubes(line_cubes: Mapping, path: str | Path, source: str = "",
|
|
763
|
+
products: Optional[Mapping] = None,
|
|
764
|
+
time: Optional[u.Quantity] = None) -> Path:
|
|
765
|
+
"""
|
|
766
|
+
Write ECLIPSE line cubes as a synthesis file.
|
|
767
|
+
|
|
768
|
+
Each cube keeps its values, wavelengths, rest wavelength, atom and ion.
|
|
769
|
+
The cubes must share one image, which is taken from the first cube's
|
|
770
|
+
coordinates.
|
|
771
|
+
|
|
772
|
+
Parameters
|
|
773
|
+
----------
|
|
774
|
+
line_cubes : mapping of str to NDCube
|
|
775
|
+
Cubes keyed by line name, indexed `[y, x, wavelength]`, with
|
|
776
|
+
`rest_wav` in their metadata, as made by `create_line_cube`.
|
|
777
|
+
path : str or Path
|
|
778
|
+
File to write. An existing file is replaced.
|
|
779
|
+
source : str, optional
|
|
780
|
+
Free text naming the simulation.
|
|
781
|
+
products : mapping, optional
|
|
782
|
+
Intermediate products of the synthesis; see `write_synthesis`.
|
|
783
|
+
time : u.Quantity, optional
|
|
784
|
+
Time of the snapshot.
|
|
785
|
+
|
|
786
|
+
Returns
|
|
787
|
+
-------
|
|
788
|
+
Path
|
|
789
|
+
Path of the written file.
|
|
790
|
+
"""
|
|
791
|
+
cubes = dict(line_cubes)
|
|
792
|
+
if not cubes:
|
|
793
|
+
raise ValueError("There are no line cubes to write.")
|
|
794
|
+
first_name, first = next(iter(cubes.items()))
|
|
795
|
+
edges = {"x_edges": _pixel_edges(first, 1), "y_edges": _pixel_edges(first, 2)}
|
|
796
|
+
lines = {}
|
|
797
|
+
for name, cube in cubes.items():
|
|
798
|
+
if cube.data.shape[:2] != first.data.shape[:2]:
|
|
799
|
+
raise ValueError(f"Line {name!r} has an image of {cube.data.shape[:2]} "
|
|
800
|
+
f"pixels, not {first.data.shape[:2]} as the others.")
|
|
801
|
+
# All lines share one image, so a cube of the same shape but at another
|
|
802
|
+
# position or with other pixel sizes would land on the wrong pixels.
|
|
803
|
+
for axis, wcs_axis in (("x", 1), ("y", 2)):
|
|
804
|
+
if not _same_grid(_pixel_edges(cube, wcs_axis), edges[f"{axis}_edges"]):
|
|
805
|
+
raise ValueError(f"Line {name!r} has other pixel positions along {axis} "
|
|
806
|
+
f"than {first_name!r}; the lines of a synthesis file "
|
|
807
|
+
f"share one image.")
|
|
808
|
+
meta = cube.meta or {}
|
|
809
|
+
lines[name] = SpectralLine(intensity=np.asarray(cube.data) * cube.unit,
|
|
810
|
+
wavelength=cube.axis_world_coords_values(-1)[0],
|
|
811
|
+
rest_wavelength=meta["rest_wav"],
|
|
812
|
+
**{key: int(meta[key]) for key in IDENTITY
|
|
813
|
+
if meta.get(key) is not None})
|
|
814
|
+
# Store the view, which names the image axes, so the cubes read back as
|
|
815
|
+
# written. A cube without one is taken as seen along z.
|
|
816
|
+
views = {(cube.meta or {}).get("integration_axis") or "z" for cube in cubes.values()}
|
|
817
|
+
if len(views) > 1:
|
|
818
|
+
raise ValueError(f"The line cubes were seen along different axes, {sorted(views)}; "
|
|
819
|
+
f"the lines of a synthesis file share one image.")
|
|
820
|
+
synthesis = Synthesis(lines=lines, source=source, time=time,
|
|
821
|
+
integration_axis=views.pop(), **edges)
|
|
822
|
+
return write_synthesis(synthesis, path, products=products)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _pixel_edges(cube, wcs_axis: int) -> u.Quantity:
|
|
826
|
+
"""Pixel boundaries along one image axis of `cube`, from its WCS, in Mm."""
|
|
827
|
+
wcs = getattr(cube.wcs, "low_level_wcs", cube.wcs)
|
|
828
|
+
n = cube.data.shape[::-1][wcs_axis]
|
|
829
|
+
points = np.zeros((n + 1, wcs.pixel_n_dim))
|
|
830
|
+
points[:, wcs_axis] = np.arange(n + 1) - 0.5
|
|
831
|
+
world = wcs.pixel_to_world_values(*points.T)
|
|
832
|
+
return (np.asarray(world[wcs_axis]) * u.Unit(wcs.world_axis_units[wcs_axis])).to(u.Mm)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def convert_synthesis_pickle(pickle_path: str | Path, path: str | Path) -> Path:
|
|
836
|
+
"""
|
|
837
|
+
Convert a synthesis pickle from ECLIPSE 0.11.0 or earlier to a synthesis file.
|
|
838
|
+
|
|
839
|
+
Everything in the pickle is kept: the line cubes as the spectra and the
|
|
840
|
+
rest as the synthesis products. A pickle made before the axis order or
|
|
841
|
+
the velocity convention changed is rejected, as `eclipse` rejects it.
|
|
842
|
+
Loading a pickle runs any code it contains, so only convert pickles you
|
|
843
|
+
trust.
|
|
844
|
+
|
|
845
|
+
Parameters
|
|
846
|
+
----------
|
|
847
|
+
pickle_path : str or Path
|
|
848
|
+
Pickle to convert.
|
|
849
|
+
path : str or Path
|
|
850
|
+
Synthesis file to write. Must not be the pickle itself.
|
|
851
|
+
|
|
852
|
+
Returns
|
|
853
|
+
-------
|
|
854
|
+
Path
|
|
855
|
+
Path of the written file.
|
|
856
|
+
"""
|
|
857
|
+
# Only duplicates of kept data are dropped: each line's spectra (si) and
|
|
858
|
+
# wavelengths (wl_grid) in goft, and the atmosphere metadata each line
|
|
859
|
+
# cube carried as spatial_reference.
|
|
860
|
+
from .data_processing import check_old_line_cubes
|
|
861
|
+
|
|
862
|
+
# Writing over the pickle would destroy the only copy of its synthesis.
|
|
863
|
+
if Path(path).expanduser().resolve() == Path(pickle_path).expanduser().resolve():
|
|
864
|
+
raise ValueError(f"{path} is the pickle being converted; give the synthesis file "
|
|
865
|
+
f"another name, such as {Path(pickle_path).with_suffix('.h5').name}.")
|
|
866
|
+
with open(pickle_path, "rb") as f:
|
|
867
|
+
saved = _unpickle(f)
|
|
868
|
+
if "line_cubes" not in saved or not saved["line_cubes"]:
|
|
869
|
+
raise ValueError(f"{pickle_path} holds no line cubes.")
|
|
870
|
+
check_old_line_cubes(saved["line_cubes"], pickle_path)
|
|
871
|
+
products = {key: value for key, value in saved.items() if key != "line_cubes"}
|
|
872
|
+
goft = products.get("goft")
|
|
873
|
+
if isinstance(goft, Mapping):
|
|
874
|
+
# si and wl_grid repeat the spectra and wavelengths of the line cubes.
|
|
875
|
+
products["goft"] = {name: {key: value for key, value in info.items()
|
|
876
|
+
if key not in ("si", "wl_grid")}
|
|
877
|
+
for name, info in goft.items()}
|
|
878
|
+
atmosphere = products.get("atmosphere") or {}
|
|
879
|
+
time = atmosphere.get("time")
|
|
880
|
+
return write_line_cubes(saved["line_cubes"], path, source=str(atmosphere.get("source") or ""),
|
|
881
|
+
products=products,
|
|
882
|
+
time=time if isinstance(time, u.Quantity) else None)
|