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
@@ -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)