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/raster.py
ADDED
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Slit observations of a time series of atmospheres or synthesis files.
|
|
3
|
+
|
|
4
|
+
For each exposure of a :class:`RasterPlan`, only the columns under the slit
|
|
5
|
+
are synthesised, or read from synthesis files, from the snapshots the
|
|
6
|
+
exposure overlaps, weighted by the share of the exposure each covers. A
|
|
7
|
+
snapshot stands for the atmosphere until the next one's time, and the last
|
|
8
|
+
for as long again as the gap before it; an exposure outside that range is
|
|
9
|
+
an error. Each raster is one cube, and a sit-and-stare is a raster of one
|
|
10
|
+
slit position.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
18
|
+
|
|
19
|
+
import astropy.constants as const
|
|
20
|
+
import astropy.units as u
|
|
21
|
+
import numpy as np
|
|
22
|
+
from astropy.wcs import WCS
|
|
23
|
+
from ndcube import NDCube
|
|
24
|
+
|
|
25
|
+
from .atmosphere import (Atmosphere, read_atmosphere, read_edges, read_time,
|
|
26
|
+
require_mass_per_electron)
|
|
27
|
+
from .data_processing import sum_line_cubes
|
|
28
|
+
from .synthesis import (
|
|
29
|
+
_LOGT_MAX,
|
|
30
|
+
_LOGT_MIN,
|
|
31
|
+
_N_N,
|
|
32
|
+
_N_T,
|
|
33
|
+
_density_point,
|
|
34
|
+
_density_points,
|
|
35
|
+
compute_goft_fiasco,
|
|
36
|
+
line_of_sight_velocity,
|
|
37
|
+
synthesise_cubes,
|
|
38
|
+
)
|
|
39
|
+
from .synthesis_file import (RADIANCE_UNIT, SpectralLine, Synthesis, _same_grid, _to_length,
|
|
40
|
+
read_synthesis, read_synthesis_layout, read_synthesis_products)
|
|
41
|
+
from .sampling import pixel_weights
|
|
42
|
+
from .utils import (VELOCITY_CONVENTION, _fwhm_to_sigma, angle_to_distance, require_uniform_grid,
|
|
43
|
+
velocity_grid)
|
|
44
|
+
|
|
45
|
+
__all__ = ["SynthesisSettings", "RasterPlan", "Exposure", "AtmosphereSeries",
|
|
46
|
+
"RasterSynthesiser", "SynthesisSeries", "SynthesisRaster"]
|
|
47
|
+
|
|
48
|
+
INTENSITY_UNIT = u.erg / u.s / u.cm**2 / u.sr / u.cm
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class SynthesisSettings:
|
|
53
|
+
"""
|
|
54
|
+
Synthesis settings, as given to `synthesise-spectra` on the command line.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
lines : sequence of str
|
|
59
|
+
Line names such as `"Fe12_195.1190"`.
|
|
60
|
+
abundance : str
|
|
61
|
+
CHIANTI abundance set.
|
|
62
|
+
vel_res, vel_lim : u.Quantity
|
|
63
|
+
Spacing and half range of the velocity grid.
|
|
64
|
+
crop_y, crop_z : tuple of two quantities, optional
|
|
65
|
+
Ranges to keep along y and z, in the atmosphere's own coordinates.
|
|
66
|
+
precision : type
|
|
67
|
+
np.float32 or np.float64.
|
|
68
|
+
mass_per_electron : float, optional
|
|
69
|
+
Atomic mass units per free electron, for atmospheres that give only
|
|
70
|
+
a mass density. None derives it from the abundance set.
|
|
71
|
+
hdf5_dbase_root : str, optional
|
|
72
|
+
CHIANTI database for fiasco. None uses fiasco's default.
|
|
73
|
+
n_workers : int
|
|
74
|
+
Workers for computing the contribution functions; 0 uses all CPUs.
|
|
75
|
+
goft_temperature_chunk : int, optional
|
|
76
|
+
Number of temperatures passed to fiasco at a time when computing the
|
|
77
|
+
contribution functions, to reduce memory use. None passes the whole
|
|
78
|
+
grid at once.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
lines: Tuple[str, ...]
|
|
82
|
+
abundance: str = "sun_coronal_2021_chianti"
|
|
83
|
+
vel_res: u.Quantity = 5.0 * u.km / u.s
|
|
84
|
+
vel_lim: u.Quantity = 300.0 * u.km / u.s
|
|
85
|
+
crop_y: Optional[Tuple[u.Quantity, u.Quantity]] = None
|
|
86
|
+
crop_z: Optional[Tuple[u.Quantity, u.Quantity]] = None
|
|
87
|
+
precision: type = np.float64
|
|
88
|
+
mass_per_electron: Optional[float] = None
|
|
89
|
+
hdf5_dbase_root: Optional[str] = None
|
|
90
|
+
n_workers: int = 0
|
|
91
|
+
goft_temperature_chunk: Optional[int] = None
|
|
92
|
+
|
|
93
|
+
def __post_init__(self):
|
|
94
|
+
if not self.lines:
|
|
95
|
+
raise ValueError("At least one line is needed.")
|
|
96
|
+
for name in ("vel_res", "vel_lim"):
|
|
97
|
+
value = getattr(self, name)
|
|
98
|
+
if not isinstance(value, u.Quantity) or not value.unit.is_equivalent(u.km / u.s):
|
|
99
|
+
raise ValueError(f"{name} must be a velocity, got {value!r}.")
|
|
100
|
+
if value <= 0:
|
|
101
|
+
raise ValueError(f"{name} must be positive, got {value}.")
|
|
102
|
+
if self.precision not in (np.float32, np.float64):
|
|
103
|
+
raise ValueError(f"precision must be np.float32 or np.float64, got {self.precision!r}.")
|
|
104
|
+
if self.mass_per_electron is not None:
|
|
105
|
+
require_mass_per_electron(self.mass_per_electron)
|
|
106
|
+
|
|
107
|
+
def velocity_grid(self) -> u.Quantity:
|
|
108
|
+
"""Velocity bin centres, as built by `synthesise-spectra`."""
|
|
109
|
+
return velocity_grid(self.vel_res, self.vel_lim)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class Exposure:
|
|
114
|
+
"""One exposure of a plan: where the slit is and when it is open."""
|
|
115
|
+
|
|
116
|
+
index: int
|
|
117
|
+
position: u.Quantity # heliocentric x of the slit's centre
|
|
118
|
+
start: u.Quantity # simulation time
|
|
119
|
+
end: u.Quantity
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class RasterPlan:
|
|
124
|
+
"""
|
|
125
|
+
Observing plan: rasters of slit positions, one exposure after another.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
start : u.Quantity
|
|
130
|
+
Simulation time at which the first exposure starts.
|
|
131
|
+
steps : int
|
|
132
|
+
Slit positions per raster; 1 is a sit-and-stare.
|
|
133
|
+
step : u.Quantity, optional
|
|
134
|
+
Angle between neighbouring slit positions. None uses the slit width,
|
|
135
|
+
so the positions abut.
|
|
136
|
+
repeats : int
|
|
137
|
+
Number of rasters, one after another.
|
|
138
|
+
cadence : u.Quantity, optional
|
|
139
|
+
Time between the starts of consecutive exposures. None uses the
|
|
140
|
+
exposure time, so each exposure starts as the previous one ends.
|
|
141
|
+
centre : u.Quantity, optional
|
|
142
|
+
Heliocentric x of the raster centre, as a length. None centres it on
|
|
143
|
+
the atmosphere.
|
|
144
|
+
direction : str
|
|
145
|
+
Direction the slit steps in x: `"increasing"` (default) or
|
|
146
|
+
`"decreasing"`, as the deprecated dynamic mode scanned. Either way
|
|
147
|
+
the image columns run along increasing x, each observed at its own
|
|
148
|
+
exposure's time.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
start: u.Quantity
|
|
152
|
+
steps: int = 1
|
|
153
|
+
step: Optional[u.Quantity] = None
|
|
154
|
+
repeats: int = 1
|
|
155
|
+
cadence: Optional[u.Quantity] = None
|
|
156
|
+
centre: Optional[u.Quantity] = None
|
|
157
|
+
direction: str = "increasing"
|
|
158
|
+
|
|
159
|
+
def __post_init__(self):
|
|
160
|
+
if not isinstance(self.start, u.Quantity) or not self.start.unit.is_equivalent(u.s):
|
|
161
|
+
raise ValueError(f"start must be a time, got {self.start!r}.")
|
|
162
|
+
for name in ("steps", "repeats"):
|
|
163
|
+
value = getattr(self, name)
|
|
164
|
+
if isinstance(value, bool) or not isinstance(value, (int, np.integer)) or value < 1:
|
|
165
|
+
raise ValueError(f"{name} must be a whole number of 1 or more, got {value!r}.")
|
|
166
|
+
if self.step is not None:
|
|
167
|
+
if not isinstance(self.step, u.Quantity) or not self.step.unit.is_equivalent(u.arcsec):
|
|
168
|
+
raise ValueError(f"step must be an angle, got {self.step!r}.")
|
|
169
|
+
if self.step <= 0:
|
|
170
|
+
raise ValueError(f"step must be positive, got {self.step}.")
|
|
171
|
+
if self.cadence is not None:
|
|
172
|
+
if not isinstance(self.cadence, u.Quantity) or not self.cadence.unit.is_equivalent(u.s):
|
|
173
|
+
raise ValueError(f"cadence must be a time, got {self.cadence!r}.")
|
|
174
|
+
if self.cadence <= 0:
|
|
175
|
+
raise ValueError(f"cadence must be positive, got {self.cadence}.")
|
|
176
|
+
if self.centre is not None and (not isinstance(self.centre, u.Quantity)
|
|
177
|
+
or not self.centre.unit.is_equivalent(u.Mm)):
|
|
178
|
+
raise ValueError(f"centre must be a length, got {self.centre!r}.")
|
|
179
|
+
if self.direction not in ("increasing", "decreasing"):
|
|
180
|
+
raise ValueError(f"direction must be 'increasing' or 'decreasing' x, got "
|
|
181
|
+
f"{self.direction!r}.")
|
|
182
|
+
|
|
183
|
+
def exposures(self, slit_width: u.Quantity, expos: u.Quantity,
|
|
184
|
+
atmosphere_centre: u.Quantity,
|
|
185
|
+
repeat: Optional[int] = None) -> List[Exposure]:
|
|
186
|
+
"""
|
|
187
|
+
Exposures of one raster of the plan, for one slit width and exposure time.
|
|
188
|
+
|
|
189
|
+
Parameters
|
|
190
|
+
----------
|
|
191
|
+
slit_width : u.Quantity
|
|
192
|
+
Slit width as an angle. Also the step if none was given.
|
|
193
|
+
expos : u.Quantity
|
|
194
|
+
Exposure time. Also the cadence if none was given.
|
|
195
|
+
atmosphere_centre : u.Quantity
|
|
196
|
+
Heliocentric x of the centre of the atmosphere, or of the image
|
|
197
|
+
of a series of synthesis files. Also the raster centre if none
|
|
198
|
+
was given.
|
|
199
|
+
repeat : int, optional
|
|
200
|
+
Raster index, from 0. Not needed for a plan of one raster, but
|
|
201
|
+
required for several, since each raster is observed separately.
|
|
202
|
+
"""
|
|
203
|
+
if expos <= 0:
|
|
204
|
+
raise ValueError(f"The exposure time must be positive, got {expos}.")
|
|
205
|
+
if repeat is None:
|
|
206
|
+
if self.repeats > 1:
|
|
207
|
+
raise ValueError(f"The plan has {self.repeats} rasters; say which one "
|
|
208
|
+
f"with repeat=0 to {self.repeats - 1}.")
|
|
209
|
+
repeat = 0
|
|
210
|
+
if isinstance(repeat, bool) or not isinstance(repeat, (int, np.integer)):
|
|
211
|
+
raise ValueError(f"repeat must be a whole number, got {repeat!r}.")
|
|
212
|
+
if not 0 <= repeat < self.repeats:
|
|
213
|
+
raise ValueError(f"repeat must be between 0 and {self.repeats - 1}, got {repeat}.")
|
|
214
|
+
cadence = expos if self.cadence is None else self.cadence
|
|
215
|
+
if cadence < expos:
|
|
216
|
+
raise ValueError(f"The cadence, {cadence}, is shorter than the exposure, "
|
|
217
|
+
f"{expos}, so exposures would overlap.")
|
|
218
|
+
step = angle_to_distance(slit_width if self.step is None else self.step).to(u.Mm)
|
|
219
|
+
if self.direction == "decreasing":
|
|
220
|
+
step = -step
|
|
221
|
+
centre = (atmosphere_centre if self.centre is None else self.centre).to(u.Mm)
|
|
222
|
+
first = centre - (self.steps - 1) / 2 * step
|
|
223
|
+
exposures = []
|
|
224
|
+
for position in range(self.steps):
|
|
225
|
+
index = repeat * self.steps + position
|
|
226
|
+
start = self.start + index * cadence
|
|
227
|
+
exposures.append(Exposure(index, first + position * step, start, start + expos))
|
|
228
|
+
return exposures
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class _Series:
|
|
232
|
+
"""
|
|
233
|
+
Snapshot files sorted by their recorded times.
|
|
234
|
+
|
|
235
|
+
Each snapshot represents the Sun from its time until the next one's,
|
|
236
|
+
and the last for as long again as the gap before it.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
# Suggestion for a single snapshot, used in the error for a series of one.
|
|
240
|
+
_alone = "observe a single snapshot as a static atmosphere"
|
|
241
|
+
|
|
242
|
+
def __init__(self, timed: Sequence[Tuple[float, Path]]):
|
|
243
|
+
timed = sorted(timed, key=lambda item: item[0])
|
|
244
|
+
times = np.array([t for t, _ in timed])
|
|
245
|
+
if np.any(np.diff(times) <= 0):
|
|
246
|
+
same = [str(p) for (t, p), (t2, _) in zip(timed[:-1], timed[1:]) if t == t2]
|
|
247
|
+
raise ValueError(f"Two files of the series record the same time: {same[0]}")
|
|
248
|
+
self.paths: List[Path] = [p for _, p in timed]
|
|
249
|
+
self.times: u.Quantity = times * u.s
|
|
250
|
+
|
|
251
|
+
def __len__(self) -> int:
|
|
252
|
+
return len(self.paths)
|
|
253
|
+
|
|
254
|
+
def valid_until(self) -> u.Quantity:
|
|
255
|
+
"""
|
|
256
|
+
Time at which each snapshot stops representing the atmosphere.
|
|
257
|
+
|
|
258
|
+
This is the next snapshot's time, and for the last snapshot its own
|
|
259
|
+
time plus the gap before it.
|
|
260
|
+
"""
|
|
261
|
+
times = self.times.to_value(u.s)
|
|
262
|
+
if times.size == 1:
|
|
263
|
+
raise ValueError(f"A series of one snapshot cannot say how long it stands for; "
|
|
264
|
+
f"give at least two, or {self._alone}.")
|
|
265
|
+
ends = np.append(times[1:], times[-1] + (times[-1] - times[-2]))
|
|
266
|
+
return ends * u.s
|
|
267
|
+
|
|
268
|
+
def coverage(self, start: u.Quantity, end: u.Quantity) -> List[Tuple[int, float]]:
|
|
269
|
+
"""
|
|
270
|
+
Snapshots an exposure from `start` to `end` falls in, with the fraction each covers.
|
|
271
|
+
|
|
272
|
+
The fractions sum to one. An exposure reaching before the first
|
|
273
|
+
snapshot or beyond the last one's span raises a ValueError.
|
|
274
|
+
"""
|
|
275
|
+
t0, t1 = start.to_value(u.s), end.to_value(u.s)
|
|
276
|
+
if t1 <= t0:
|
|
277
|
+
raise ValueError(f"An exposure must end after it starts, got {start} to {end}.")
|
|
278
|
+
begins = self.times.to_value(u.s)
|
|
279
|
+
ends = self.valid_until().to_value(u.s)
|
|
280
|
+
# Allow for rounding, so a plan that exactly fills the series is not
|
|
281
|
+
# rejected. Exposure times are a start plus whole cadences plus an
|
|
282
|
+
# exposure, and the series end is the last time plus the last gap.
|
|
283
|
+
# Each of these few steps, and each unit change, rounds by at most
|
|
284
|
+
# half a unit in the last place of the largest time; 16 units bound
|
|
285
|
+
# them all.
|
|
286
|
+
rounding = 16 * np.spacing(max(abs(begins[0]), abs(ends[-1]), abs(t0), abs(t1)))
|
|
287
|
+
early, late = begins[0] - t0, t1 - ends[-1]
|
|
288
|
+
if early > rounding or late > rounding:
|
|
289
|
+
where = (f"starting {early:.3g} s before it" if early > rounding
|
|
290
|
+
else f"ending {late:.3g} s after it")
|
|
291
|
+
raise ValueError(
|
|
292
|
+
f"An exposure from {t0:.10g} to {t1:.10g} s lies outside the series, {where}; "
|
|
293
|
+
f"the series runs from {begins[0]:.10g} to {ends[-1]:.10g} s (the last "
|
|
294
|
+
f"snapshot, at {begins[-1]:.10g} s, stands for as long as the gap before it).")
|
|
295
|
+
inside = max(t0, begins[0]), min(t1, ends[-1])
|
|
296
|
+
if inside[1] <= inside[0]:
|
|
297
|
+
raise ValueError(
|
|
298
|
+
f"An exposure from {t0:.10g} to {t1:.10g} s lies outside the series, which "
|
|
299
|
+
f"runs from {begins[0]:.10g} to {ends[-1]:.10g} s.")
|
|
300
|
+
t0, t1 = inside
|
|
301
|
+
overlap = np.clip(np.minimum(ends, t1) - np.maximum(begins, t0), 0.0, None)
|
|
302
|
+
fractions = overlap / (t1 - t0)
|
|
303
|
+
return [(int(k), float(f)) for k, f in enumerate(fractions) if f > 0.0]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class AtmosphereSeries(_Series):
|
|
307
|
+
"""
|
|
308
|
+
Atmosphere files sorted by their recorded times.
|
|
309
|
+
|
|
310
|
+
Every file must record a time, and all must share one grid, since an
|
|
311
|
+
exposure combines columns from different snapshots.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __init__(self, paths: Sequence[str | Path]):
|
|
315
|
+
if not paths:
|
|
316
|
+
raise ValueError("An atmosphere series needs at least one file.")
|
|
317
|
+
timed = []
|
|
318
|
+
for path in paths:
|
|
319
|
+
time = read_time(path)
|
|
320
|
+
if time is None:
|
|
321
|
+
raise ValueError(f"{path} records no time, which a series needs to place "
|
|
322
|
+
f"its snapshots; write one into the file.")
|
|
323
|
+
timed.append((time.to_value(u.s), Path(path)))
|
|
324
|
+
super().__init__(timed)
|
|
325
|
+
# The grid comes from the first file; the others are checked as they
|
|
326
|
+
# are read.
|
|
327
|
+
edges = read_edges(self.paths[0])
|
|
328
|
+
self.x_edges = edges["x"]
|
|
329
|
+
self.y_edges = edges["y"]
|
|
330
|
+
self.z_edges = edges["z"]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class SynthesisSeries(_Series):
|
|
334
|
+
"""
|
|
335
|
+
Synthesis files sorted by their recorded times, for one line's window.
|
|
336
|
+
|
|
337
|
+
Every file must record a time, and all must share one image, seen along
|
|
338
|
+
one axis, and the same lines on the same wavelengths, since an exposure
|
|
339
|
+
combines columns from different snapshots and adds up their spectra.
|
|
340
|
+
Only the lines reaching the window of `reference_line` are read, as for
|
|
341
|
+
a single synthesis file.
|
|
342
|
+
|
|
343
|
+
Parameters
|
|
344
|
+
----------
|
|
345
|
+
paths : sequence of str or Path
|
|
346
|
+
reference_line : str
|
|
347
|
+
Line the instrument measures.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
_alone = "observe the one file as a single snapshot, with 'synthesis_file'"
|
|
351
|
+
|
|
352
|
+
def __init__(self, paths: Sequence[str | Path], reference_line: str):
|
|
353
|
+
if not paths:
|
|
354
|
+
raise ValueError("A synthesis series needs at least one file.")
|
|
355
|
+
layouts = {}
|
|
356
|
+
timed = []
|
|
357
|
+
for path in paths:
|
|
358
|
+
path = Path(path)
|
|
359
|
+
layout = read_synthesis_layout(path, reference_line)
|
|
360
|
+
if layout["time"] is None:
|
|
361
|
+
raise ValueError(f"{path} records no time, which a series needs to place "
|
|
362
|
+
f"its snapshots; write one into the file.")
|
|
363
|
+
dynamic = read_synthesis_products(path, keys=("dynamic_mode",)).get("dynamic_mode")
|
|
364
|
+
if (dynamic or {}).get("enabled"):
|
|
365
|
+
raise ValueError(f"{path} was synthesised in dynamic mode, which already "
|
|
366
|
+
f"scanned a slit over time; a series takes one snapshot "
|
|
367
|
+
f"per file.")
|
|
368
|
+
layouts[path] = layout
|
|
369
|
+
timed.append((layout["time"].to_value(u.s), path))
|
|
370
|
+
super().__init__(timed)
|
|
371
|
+
self.reference_line = reference_line
|
|
372
|
+
first = self.paths[0]
|
|
373
|
+
layout = layouts[first]
|
|
374
|
+
for axis in ("x", "y"):
|
|
375
|
+
try:
|
|
376
|
+
require_uniform_grid(layout[f"{axis}_edges"].value, f"{axis}_edges")
|
|
377
|
+
except ValueError as error:
|
|
378
|
+
raise ValueError(f"{first}: {error}") from None
|
|
379
|
+
# x in Mm, like the slit positions; y in the files' own unit.
|
|
380
|
+
self.x_edges: u.Quantity = _to_length(layout["x_edges"])
|
|
381
|
+
self.y_edges: u.Quantity = layout["y_edges"]
|
|
382
|
+
self.integration_axis: Optional[str] = layout["integration_axis"]
|
|
383
|
+
self.wavelengths: Dict[str, u.Quantity] = {
|
|
384
|
+
name: info["wavelength"] for name, info in layout["lines"].items()}
|
|
385
|
+
self.rest_wavelengths: Dict[str, u.Quantity] = {
|
|
386
|
+
name: info["rest_wavelength"] for name, info in layout["lines"].items()}
|
|
387
|
+
self.identities: Dict[str, dict] = {
|
|
388
|
+
name: info["identity"] for name, info in layout["lines"].items()}
|
|
389
|
+
shape = (self.y_edges.size - 1, self.x_edges.size - 1)
|
|
390
|
+
for name, info in layout["lines"].items():
|
|
391
|
+
if tuple(info["shape"]) != shape + (info["wavelength"].size,):
|
|
392
|
+
raise ValueError(
|
|
393
|
+
f"{first}, line {name!r}: the intensity is {tuple(info['shape'])}, but "
|
|
394
|
+
f"the edges and wavelengths give (ny, nx, n_wavelength) = "
|
|
395
|
+
f"{shape + (info['wavelength'].size,)}.")
|
|
396
|
+
for path in self.paths[1:]:
|
|
397
|
+
other = layouts[path]
|
|
398
|
+
for axis, edges in (("x", self.x_edges), ("y", self.y_edges)):
|
|
399
|
+
found = other[f"{axis}_edges"]
|
|
400
|
+
if not _same_grid(_to_length(found), _to_length(edges)):
|
|
401
|
+
raise ValueError(f"{path} has a different {axis} grid from {first}; a "
|
|
402
|
+
f"series must share one image.")
|
|
403
|
+
if (other["integration_axis"] or "z") != (self.integration_axis or "z"):
|
|
404
|
+
raise ValueError(f"{path} was seen along {other['integration_axis'] or 'z'}, "
|
|
405
|
+
f"and {first} along {self.integration_axis or 'z'}; a series "
|
|
406
|
+
f"must share one view.")
|
|
407
|
+
if set(other["lines"]) != set(layout["lines"]):
|
|
408
|
+
raise ValueError(f"{path} has the lines {list(other['lines'])} in the window "
|
|
409
|
+
f"of {reference_line}, and {first} has "
|
|
410
|
+
f"{list(layout['lines'])}; a series must have the same.")
|
|
411
|
+
for name, info in other["lines"].items():
|
|
412
|
+
if not _same_grid(info["wavelength"], self.wavelengths[name]):
|
|
413
|
+
raise ValueError(f"{path} has different wavelengths for {name} from "
|
|
414
|
+
f"{first}; a series must keep each line on one grid.")
|
|
415
|
+
if not _same_grid(info["rest_wavelength"], self.rest_wavelengths[name]):
|
|
416
|
+
raise ValueError(f"{path} gives {name} a different rest wavelength from "
|
|
417
|
+
f"{first}.")
|
|
418
|
+
if info["identity"] != self.identities[name]:
|
|
419
|
+
raise ValueError(f"{path} gives {name} the atom and ion "
|
|
420
|
+
f"{info['identity']}, and {first} "
|
|
421
|
+
f"{self.identities[name]}.")
|
|
422
|
+
if tuple(info["shape"]) != tuple(layout["lines"][name]["shape"]):
|
|
423
|
+
raise ValueError(f"{path}, line {name!r}: the intensity is "
|
|
424
|
+
f"{tuple(info['shape'])}, not "
|
|
425
|
+
f"{tuple(layout['lines'][name]['shape'])} as in {first}.")
|
|
426
|
+
|
|
427
|
+
@property
|
|
428
|
+
def lines(self) -> List[str]:
|
|
429
|
+
"""Lines read: those reaching the window of the reference line."""
|
|
430
|
+
return list(self.wavelengths)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _runs(indices: Sequence[int]) -> List[Tuple[int, int]]:
|
|
434
|
+
"""Runs of consecutive values in ascending `indices`, as (first, last + 1) pairs."""
|
|
435
|
+
runs: List[Tuple[int, int]] = []
|
|
436
|
+
for index in indices:
|
|
437
|
+
if runs and index == runs[-1][1]:
|
|
438
|
+
runs[-1] = (runs[-1][0], index + 1)
|
|
439
|
+
else:
|
|
440
|
+
runs.append((index, index + 1))
|
|
441
|
+
return runs
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class _SlitRaster:
|
|
445
|
+
"""
|
|
446
|
+
Spectra a slit sees over a plan, from the columns of a snapshot series.
|
|
447
|
+
|
|
448
|
+
Each column of each snapshot is fetched when an exposure first needs it
|
|
449
|
+
and then cached, so a sweep over exposure times or slit widths reuses
|
|
450
|
+
most of the work. Subclasses define how a strip of columns is fetched.
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
# What the x grid spans, named in the error when a slit reaches beyond it.
|
|
454
|
+
_extent = "atmosphere"
|
|
455
|
+
|
|
456
|
+
def __init__(self, series: _Series):
|
|
457
|
+
self.series = series
|
|
458
|
+
self._columns: Dict[Tuple[int, int], Dict[str, np.ndarray]] = {}
|
|
459
|
+
|
|
460
|
+
def _load_strip(self, snapshot: int, first: int, last: int) -> None:
|
|
461
|
+
"""Put columns `first` to `last` - 1 of one snapshot into the cache."""
|
|
462
|
+
raise NotImplementedError
|
|
463
|
+
|
|
464
|
+
# ------------------------------------------------------------------
|
|
465
|
+
# Geometry
|
|
466
|
+
# ------------------------------------------------------------------
|
|
467
|
+
def atmosphere_centre(self) -> u.Quantity:
|
|
468
|
+
"""Heliocentric x of the centre of the series' x grid."""
|
|
469
|
+
edges = self.series.x_edges
|
|
470
|
+
return 0.5 * (edges[0] + edges[-1])
|
|
471
|
+
|
|
472
|
+
@classmethod
|
|
473
|
+
def check_plan(cls, series: _Series, plan: RasterPlan, slit_widths: Sequence[u.Quantity],
|
|
474
|
+
exposure_times: Sequence[u.Quantity]) -> None:
|
|
475
|
+
"""
|
|
476
|
+
Check that `plan` can observe `series` with every slit width and exposure time.
|
|
477
|
+
|
|
478
|
+
Every exposure of every raster must lie within the series' times and
|
|
479
|
+
put the slit within its x grid, or a ValueError is raised. Only the
|
|
480
|
+
series' times and edges are used, so the check runs before anything
|
|
481
|
+
is synthesised or read and a sweep does not fail part-way through.
|
|
482
|
+
"""
|
|
483
|
+
geometry = _SlitRaster(series)
|
|
484
|
+
geometry._extent = cls._extent
|
|
485
|
+
repeats = range(plan.repeats) if plan.repeats > 1 else [None]
|
|
486
|
+
for slit_width in slit_widths:
|
|
487
|
+
for expos in exposure_times:
|
|
488
|
+
for repeat in repeats:
|
|
489
|
+
for exposure in plan.exposures(slit_width, expos,
|
|
490
|
+
geometry.atmosphere_centre(), repeat):
|
|
491
|
+
series.coverage(exposure.start, exposure.end)
|
|
492
|
+
geometry.columns_under(exposure.position, slit_width)
|
|
493
|
+
|
|
494
|
+
def columns_under(self, position: u.Quantity, slit_width: u.Quantity) -> Tuple[int, int, np.ndarray]:
|
|
495
|
+
"""
|
|
496
|
+
Columns covered by a slit at `position`.
|
|
497
|
+
|
|
498
|
+
Returns the first index, the last index plus one, and the fraction of
|
|
499
|
+
the slit each column covers, summing to one.
|
|
500
|
+
"""
|
|
501
|
+
width = angle_to_distance(slit_width).to_value(u.Mm)
|
|
502
|
+
edges = self.series.x_edges.to_value(u.Mm)
|
|
503
|
+
low = position.to_value(u.Mm) - width / 2
|
|
504
|
+
high = position.to_value(u.Mm) + width / 2
|
|
505
|
+
# A slit edge on a cell boundary (as when the slit is a whole number
|
|
506
|
+
# of cells wide) must not, through rounding, pull in the next cell or
|
|
507
|
+
# reach past the edge of the box. A column counts only when more than
|
|
508
|
+
# a millionth of a cell of it is under the slit.
|
|
509
|
+
tolerance = 1e-6 * np.diff(edges).min()
|
|
510
|
+
if low < edges[0] - tolerance or high > edges[-1] + tolerance:
|
|
511
|
+
where = (f"{edges[0] - low:.3g} Mm below it" if low < edges[0] - tolerance
|
|
512
|
+
else f"{high - edges[-1]:.3g} Mm beyond it")
|
|
513
|
+
raise ValueError(
|
|
514
|
+
f"A slit {width:.4g} Mm wide at x = {position.to_value(u.Mm):.4g} Mm reaches "
|
|
515
|
+
f"outside the {self._extent}, {where}; the {self._extent} spans x = "
|
|
516
|
+
f"{edges[0]:.4g} to {edges[-1]:.4g} Mm.")
|
|
517
|
+
overlap = np.clip(np.minimum(edges[1:], high) - np.maximum(edges[:-1], low), 0.0, None)
|
|
518
|
+
inside = np.flatnonzero(overlap > tolerance)
|
|
519
|
+
if inside.size == 0:
|
|
520
|
+
raise ValueError(
|
|
521
|
+
f"A slit {width:.4g} Mm wide at x = {position.to_value(u.Mm):.4g} Mm covers no "
|
|
522
|
+
f"cell of the {self._extent}, which spans x = {edges[0]:.4g} to "
|
|
523
|
+
f"{edges[-1]:.4g} Mm.")
|
|
524
|
+
first, last = int(inside[0]), int(inside[-1]) + 1
|
|
525
|
+
return first, last, overlap[first:last] / overlap[first:last].sum()
|
|
526
|
+
|
|
527
|
+
# ------------------------------------------------------------------
|
|
528
|
+
# Exposures
|
|
529
|
+
# ------------------------------------------------------------------
|
|
530
|
+
def column_spectra(self, snapshot: int, column: int) -> Dict[str, np.ndarray]:
|
|
531
|
+
"""Spectra of one column of one snapshot, `(rows, wavelength)` per line."""
|
|
532
|
+
if (snapshot, column) not in self._columns:
|
|
533
|
+
self._load_strip(snapshot, column, column + 1)
|
|
534
|
+
return self._columns[(snapshot, column)]
|
|
535
|
+
|
|
536
|
+
def columns_seen(self, position: u.Quantity, slit_width: u.Quantity,
|
|
537
|
+
across_slit: Optional[u.Quantity] = None,
|
|
538
|
+
extend: bool = True) -> Tuple[int, int, np.ndarray]:
|
|
539
|
+
"""
|
|
540
|
+
Columns whose light enters a slit at `position`.
|
|
541
|
+
|
|
542
|
+
Returned in the same form as :meth:`columns_under`. With
|
|
543
|
+
`across_slit`, the telescope blurs the image across the slit by a
|
|
544
|
+
Gaussian of that FWHM, so light from columns beside the slit enters
|
|
545
|
+
it too. With `extend` the edge columns stand in for the Sun beyond
|
|
546
|
+
them; without it, the Sun beyond is dark. Without `across_slit`,
|
|
547
|
+
these are the columns under the slit.
|
|
548
|
+
"""
|
|
549
|
+
first, last, fractions = self.columns_under(position, slit_width)
|
|
550
|
+
if across_slit is None:
|
|
551
|
+
return first, last, fractions
|
|
552
|
+
width = angle_to_distance(slit_width).to_value(u.Mm)
|
|
553
|
+
sigma = angle_to_distance(_fwhm_to_sigma(across_slit)).to_value(u.Mm)
|
|
554
|
+
centre = position.to_value(u.Mm)
|
|
555
|
+
weights = pixel_weights(self.series.x_edges.to_value(u.Mm),
|
|
556
|
+
[centre - width / 2, centre + width / 2], sigma, extend=extend)[0]
|
|
557
|
+
seen = np.flatnonzero(weights > 0)
|
|
558
|
+
first, last = int(seen[0]), int(seen[-1]) + 1
|
|
559
|
+
return first, last, weights[first:last]
|
|
560
|
+
|
|
561
|
+
def exposure_spectra(self, exposure: Exposure, slit_width: u.Quantity,
|
|
562
|
+
across_slit: Optional[u.Quantity] = None,
|
|
563
|
+
extend: bool = True) -> Dict[str, np.ndarray]:
|
|
564
|
+
"""
|
|
565
|
+
Spectra collected by the slit in one exposure.
|
|
566
|
+
|
|
567
|
+
The columns under the slit are averaged over the slit, and the
|
|
568
|
+
snapshots the exposure spans are weighted by the time each covers.
|
|
569
|
+
With `across_slit`, light blurred into the slit from beside it is
|
|
570
|
+
included too (:meth:`columns_seen`).
|
|
571
|
+
"""
|
|
572
|
+
first, last, fractions = self.columns_seen(exposure.position, slit_width,
|
|
573
|
+
across_slit, extend)
|
|
574
|
+
spectra: Dict[str, np.ndarray] = {}
|
|
575
|
+
for snapshot, weight in self.series.coverage(exposure.start, exposure.end):
|
|
576
|
+
missing = [c for c in range(first, last) if (snapshot, c) not in self._columns]
|
|
577
|
+
# Fetch each run of missing neighbouring columns as one strip, so
|
|
578
|
+
# a wider slit only fetches the columns beside those a narrower
|
|
579
|
+
# slit already fetched.
|
|
580
|
+
for run_first, run_last in _runs(missing):
|
|
581
|
+
self._load_strip(snapshot, run_first, run_last)
|
|
582
|
+
for column, fraction in zip(range(first, last), fractions):
|
|
583
|
+
for name, si in self._columns[(snapshot, column)].items():
|
|
584
|
+
contribution = weight * fraction * si
|
|
585
|
+
spectra[name] = contribution if name not in spectra else spectra[name] + contribution
|
|
586
|
+
return spectra
|
|
587
|
+
|
|
588
|
+
def _observe(self, plan: RasterPlan, slit_width: u.Quantity, expos: u.Quantity,
|
|
589
|
+
repeat: Optional[int], across_slit: Optional[u.Quantity] = None,
|
|
590
|
+
extend: bool = True) -> Tuple[List[Dict[str, np.ndarray]], u.Quantity, u.Quantity, dict]:
|
|
591
|
+
"""
|
|
592
|
+
Observe one raster of the plan.
|
|
593
|
+
|
|
594
|
+
Returns each exposure's spectra, the slit positions, the spacing
|
|
595
|
+
between them, and the raster metadata for the cube.
|
|
596
|
+
|
|
597
|
+
The columns are the raster's exposures in order, at slit positions
|
|
598
|
+
advancing by the step. A sit-and-stare is a raster of one exposure,
|
|
599
|
+
so its cube has one column, as wide as the slit. `across_slit` and
|
|
600
|
+
`extend` are as for :meth:`columns_seen`.
|
|
601
|
+
"""
|
|
602
|
+
exposures = plan.exposures(slit_width, expos, self.atmosphere_centre(), repeat)
|
|
603
|
+
# Order the columns along increasing x whichever way the slit stepped,
|
|
604
|
+
# so the image has the same orientation; each keeps its own times.
|
|
605
|
+
if plan.direction == "decreasing":
|
|
606
|
+
exposures = exposures[::-1]
|
|
607
|
+
collected = [self.exposure_spectra(exposure, slit_width, across_slit, extend)
|
|
608
|
+
for exposure in exposures]
|
|
609
|
+
positions = u.Quantity([e.position for e in exposures]).to(u.Mm)
|
|
610
|
+
pitch = (positions[1] - positions[0] if plan.steps > 1
|
|
611
|
+
else angle_to_distance(slit_width).to(u.Mm))
|
|
612
|
+
meta_raster = {
|
|
613
|
+
"raster": True,
|
|
614
|
+
"repeat": 0 if repeat is None else repeat,
|
|
615
|
+
"positions": positions,
|
|
616
|
+
"starts": u.Quantity([e.start for e in exposures]).to(u.s),
|
|
617
|
+
"ends": u.Quantity([e.end for e in exposures]).to(u.s),
|
|
618
|
+
"steps": plan.steps,
|
|
619
|
+
"repeats": plan.repeats,
|
|
620
|
+
"slit_width": slit_width,
|
|
621
|
+
"expos": expos,
|
|
622
|
+
}
|
|
623
|
+
return collected, positions, pitch, meta_raster
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
class RasterSynthesiser(_SlitRaster):
|
|
627
|
+
"""
|
|
628
|
+
Synthesises the spectra a slit sees over a plan, one exposure at a time.
|
|
629
|
+
|
|
630
|
+
The contribution functions are computed for the densities of the first
|
|
631
|
+
strip synthesised. Later strips add only the densities they need beyond
|
|
632
|
+
those, which can take as long as the first if the plasma reaches far
|
|
633
|
+
beyond them. Each column of each snapshot is synthesised when an
|
|
634
|
+
exposure first needs it and then cached, so a sweep over exposure times
|
|
635
|
+
or slit widths reuses most of the work.
|
|
636
|
+
|
|
637
|
+
Parameters
|
|
638
|
+
----------
|
|
639
|
+
series : AtmosphereSeries
|
|
640
|
+
settings : SynthesisSettings
|
|
641
|
+
"""
|
|
642
|
+
|
|
643
|
+
def __init__(self, series: AtmosphereSeries, settings: SynthesisSettings):
|
|
644
|
+
super().__init__(series)
|
|
645
|
+
self.settings = settings
|
|
646
|
+
self.vel_grid = settings.velocity_grid()
|
|
647
|
+
# The contribution functions are computed for the first strip's
|
|
648
|
+
# densities, and extended when a later strip needs others.
|
|
649
|
+
self.goft: Optional[Dict[str, dict]] = None
|
|
650
|
+
self.logT_grid = np.linspace(_LOGT_MIN, _LOGT_MAX, _N_T).astype(settings.precision)
|
|
651
|
+
self.logN_grid: Optional[np.ndarray] = None
|
|
652
|
+
self.goft_dbase_root: Optional[str] = None
|
|
653
|
+
self._density_points: Optional[Tuple[int, int]] = None
|
|
654
|
+
self._mass_per_electron: Optional[Tuple[float, str]] = None
|
|
655
|
+
self._wl_grids: Dict[str, u.Quantity] = {}
|
|
656
|
+
self._rows: Optional[u.Quantity] = None
|
|
657
|
+
self._row_pitch: Optional[u.Quantity] = None
|
|
658
|
+
self.strips_synthesised = 0
|
|
659
|
+
|
|
660
|
+
# ------------------------------------------------------------------
|
|
661
|
+
# Synthesis
|
|
662
|
+
# ------------------------------------------------------------------
|
|
663
|
+
def _mass_per_electron_amu(self) -> Tuple[float, str]:
|
|
664
|
+
from .atmosphere import mass_per_electron
|
|
665
|
+
if self._mass_per_electron is None:
|
|
666
|
+
if self.settings.mass_per_electron is not None:
|
|
667
|
+
self._mass_per_electron = (self.settings.mass_per_electron,
|
|
668
|
+
"given in the configuration")
|
|
669
|
+
else:
|
|
670
|
+
self._mass_per_electron = (
|
|
671
|
+
mass_per_electron(self.settings.abundance, self.settings.hdf5_dbase_root),
|
|
672
|
+
f"fully ionised plasma with {self.settings.abundance} abundances")
|
|
673
|
+
return self._mass_per_electron
|
|
674
|
+
|
|
675
|
+
def _load_strip(self, snapshot: int, first: int, last: int) -> None:
|
|
676
|
+
self._synthesise_strip(snapshot, first, last)
|
|
677
|
+
|
|
678
|
+
def _cover_densities(self, temperature: np.ndarray, electron_density: np.ndarray) -> None:
|
|
679
|
+
"""Compute the contribution functions at the missing densities these cells need."""
|
|
680
|
+
settings = self.settings
|
|
681
|
+
needed = _density_points(temperature, electron_density, self.logT_grid,
|
|
682
|
+
settings.precision)
|
|
683
|
+
if needed is None:
|
|
684
|
+
if self.goft is not None:
|
|
685
|
+
return
|
|
686
|
+
needed = (0, _N_N - 1)
|
|
687
|
+
have = self._density_points
|
|
688
|
+
if have is None:
|
|
689
|
+
missing = [needed]
|
|
690
|
+
else:
|
|
691
|
+
missing = ([(needed[0], have[0] - 1)] if needed[0] < have[0] else []) + (
|
|
692
|
+
[(have[1] + 1, needed[1])] if needed[1] > have[1] else [])
|
|
693
|
+
for first, last in missing:
|
|
694
|
+
print(f"Computing contribution functions via fiasco for {len(settings.lines)} lines "
|
|
695
|
+
f"at 10^{_density_point(first):.1f} to 10^{_density_point(last):.1f} cm^-3")
|
|
696
|
+
goft, self.logT_grid, logN_grid = compute_goft_fiasco(
|
|
697
|
+
list(settings.lines), abundance=settings.abundance,
|
|
698
|
+
logN_min=_density_point(first), logN_max=_density_point(last),
|
|
699
|
+
nN=last - first + 1, precision=settings.precision,
|
|
700
|
+
n_workers=settings.n_workers, hdf5_dbase_root=settings.hdf5_dbase_root,
|
|
701
|
+
temperature_chunk=settings.goft_temperature_chunk)
|
|
702
|
+
if self.goft is None:
|
|
703
|
+
self.goft, self.logN_grid = goft, logN_grid
|
|
704
|
+
self.goft_dbase_root = next(iter(goft.values()))["hdf5_dbase_root"]
|
|
705
|
+
print(f" CHIANTI database: {self.goft_dbase_root}")
|
|
706
|
+
self._density_points = (first, last)
|
|
707
|
+
continue
|
|
708
|
+
# Each density is computed independently, so the new ones are
|
|
709
|
+
# simply joined onto either end of the table.
|
|
710
|
+
below = first < self._density_points[0]
|
|
711
|
+
for name, info in goft.items():
|
|
712
|
+
parts = [info["g_tn"], self.goft[name]["g_tn"]]
|
|
713
|
+
self.goft[name]["g_tn"] = np.concatenate(parts if below else parts[::-1])
|
|
714
|
+
parts = [logN_grid, self.logN_grid]
|
|
715
|
+
self.logN_grid = np.concatenate(parts if below else parts[::-1])
|
|
716
|
+
self._density_points = (min(first, self._density_points[0]),
|
|
717
|
+
max(last, self._density_points[1]))
|
|
718
|
+
|
|
719
|
+
def _synthesise_strip(self, snapshot: int, first: int, last: int) -> None:
|
|
720
|
+
"""Synthesise columns `first` to `last` - 1 of one snapshot into the cache."""
|
|
721
|
+
settings = self.settings
|
|
722
|
+
strip = read_atmosphere(self.series.paths[snapshot], velocities=("z",),
|
|
723
|
+
columns=slice(first, last))
|
|
724
|
+
expected = {"x": self.series.x_edges[first:last + 1], "y": self.series.y_edges,
|
|
725
|
+
"z": self.series.z_edges}
|
|
726
|
+
for axis, edges in expected.items():
|
|
727
|
+
found = strip.edges(axis)
|
|
728
|
+
if found.size != edges.size or not np.allclose(found.to_value(u.Mm),
|
|
729
|
+
edges.to_value(u.Mm)):
|
|
730
|
+
raise ValueError(f"{self.series.paths[snapshot]} has a different {axis} grid "
|
|
731
|
+
f"from {self.series.paths[0]}; a series must share one grid.")
|
|
732
|
+
if settings.crop_y or settings.crop_z:
|
|
733
|
+
strip = strip.cropped(y=settings.crop_y, z=settings.crop_z)
|
|
734
|
+
precision = settings.precision
|
|
735
|
+
temperature = strip.temperature.astype(precision).to_value(u.K)
|
|
736
|
+
if strip.electron_density is not None:
|
|
737
|
+
electron_density = strip.electron_density.astype(precision).to_value(u.cm**-3)
|
|
738
|
+
else:
|
|
739
|
+
amu, _ = self._mass_per_electron_amu()
|
|
740
|
+
electron_density = (strip.mass_density.astype(precision)
|
|
741
|
+
/ (amu * const.u)).to_value(u.cm**-3)
|
|
742
|
+
velocity = line_of_sight_velocity(
|
|
743
|
+
strip.velocity("z").astype(precision).to_value(u.cm / u.s), "z")
|
|
744
|
+
dh_cm = strip.cell_thickness("z").to_value(u.cm)
|
|
745
|
+
|
|
746
|
+
self._cover_densities(temperature, electron_density)
|
|
747
|
+
lines, _, _ = synthesise_cubes(
|
|
748
|
+
temperature, electron_density, velocity, dh_cm, self.goft,
|
|
749
|
+
self.logT_grid, self.logN_grid, self.vel_grid, "z", precision)
|
|
750
|
+
self.strips_synthesised += 1
|
|
751
|
+
|
|
752
|
+
if self._rows is None:
|
|
753
|
+
self._rows = strip.coordinate("y")
|
|
754
|
+
self._row_pitch = strip.spacing("y")
|
|
755
|
+
self._wl_grids = {name: info["wl_grid"] for name, info in lines.items()}
|
|
756
|
+
for offset in range(last - first):
|
|
757
|
+
self._columns[(snapshot, first + offset)] = {
|
|
758
|
+
name: info["si"][:, offset, :] for name, info in lines.items()}
|
|
759
|
+
|
|
760
|
+
def line_cubes(self, plan: RasterPlan, slit_width: u.Quantity,
|
|
761
|
+
expos: u.Quantity, repeat: Optional[int] = None, *,
|
|
762
|
+
across_slit: Optional[u.Quantity] = None,
|
|
763
|
+
extend: bool = True) -> Dict[str, NDCube]:
|
|
764
|
+
"""
|
|
765
|
+
One cube per line for one raster of the plan, `(rows, exposures, wavelength)`.
|
|
766
|
+
|
|
767
|
+
The columns are the raster's exposures in order, at slit positions
|
|
768
|
+
advancing by the step. A sit-and-stare is a raster of one exposure,
|
|
769
|
+
so its cube has one column, as wide as the slit, and its repeats are
|
|
770
|
+
separate cubes, one per exposure. `across_slit` and `extend` are as
|
|
771
|
+
for :meth:`columns_seen`.
|
|
772
|
+
"""
|
|
773
|
+
collected, positions, pitch, meta_raster = self._observe(plan, slit_width, expos, repeat,
|
|
774
|
+
across_slit, extend)
|
|
775
|
+
rows = self._rows.to(u.Mm)
|
|
776
|
+
row_pitch = self._row_pitch.to(u.Mm)
|
|
777
|
+
n_columns, n_rows = positions.size, rows.size
|
|
778
|
+
cubes = {}
|
|
779
|
+
for name in self.settings.lines:
|
|
780
|
+
data = np.stack([c[name] for c in collected], axis=1)
|
|
781
|
+
wl_grid = self._wl_grids[name].to(u.cm)
|
|
782
|
+
# Reference pixel at the middle of each spatial axis, with the grid
|
|
783
|
+
# value there, as in the line cubes of a static synthesis.
|
|
784
|
+
wcs = WCS(naxis=3)
|
|
785
|
+
wcs.wcs.ctype = ["WAVE", "SOLX", "SOLY"]
|
|
786
|
+
wcs.wcs.cunit = ["cm", "Mm", "Mm"]
|
|
787
|
+
wcs.wcs.crpix = [1, (n_columns + 1) / 2, (n_rows + 1) / 2]
|
|
788
|
+
wcs.wcs.crval = [wl_grid[0].value,
|
|
789
|
+
positions[0].value + (n_columns - 1) / 2 * pitch.value,
|
|
790
|
+
rows[0].value + (n_rows - 1) / 2 * row_pitch.value]
|
|
791
|
+
wcs.wcs.cdelt = [(wl_grid[1] - wl_grid[0]).value, pitch.value, row_pitch.value]
|
|
792
|
+
info = self.goft[name]
|
|
793
|
+
cubes[name] = NDCube(data, wcs=wcs, unit=INTENSITY_UNIT, meta={
|
|
794
|
+
"line_name": name,
|
|
795
|
+
"rest_wav": info["wl0"],
|
|
796
|
+
"atom": info["atom"],
|
|
797
|
+
"ion": info["ion"],
|
|
798
|
+
"integration_axis": "z",
|
|
799
|
+
"velocity_convention": VELOCITY_CONVENTION,
|
|
800
|
+
**meta_raster,
|
|
801
|
+
})
|
|
802
|
+
return cubes
|
|
803
|
+
|
|
804
|
+
def summed_cube(self, plan: RasterPlan, slit_width: u.Quantity, expos: u.Quantity,
|
|
805
|
+
reference_line: str, repeat: Optional[int] = None, *,
|
|
806
|
+
across_slit: Optional[u.Quantity] = None, extend: bool = True) -> NDCube:
|
|
807
|
+
"""Lines summed onto the reference line's wavelength grid, for the instrument run."""
|
|
808
|
+
if reference_line not in self.settings.lines:
|
|
809
|
+
raise ValueError(f"The reference line {reference_line!r} is not among the "
|
|
810
|
+
f"synthesised lines {list(self.settings.lines)}.")
|
|
811
|
+
return sum_line_cubes(self.line_cubes(plan, slit_width, expos, repeat,
|
|
812
|
+
across_slit=across_slit, extend=extend),
|
|
813
|
+
reference_line)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
class SynthesisRaster(_SlitRaster):
|
|
817
|
+
"""
|
|
818
|
+
Reads the spectra a slit sees over a plan from a series of synthesis files.
|
|
819
|
+
|
|
820
|
+
Each column of each snapshot is read when an exposure first needs it and
|
|
821
|
+
cached in ECLIPSE's radiance unit, so a sweep over exposure times or
|
|
822
|
+
slit widths reads each column once.
|
|
823
|
+
|
|
824
|
+
Parameters
|
|
825
|
+
----------
|
|
826
|
+
series : SynthesisSeries
|
|
827
|
+
"""
|
|
828
|
+
|
|
829
|
+
_extent = "image"
|
|
830
|
+
|
|
831
|
+
def __init__(self, series: SynthesisSeries):
|
|
832
|
+
super().__init__(series)
|
|
833
|
+
self.strips_read = 0
|
|
834
|
+
|
|
835
|
+
def _load_strip(self, snapshot: int, first: int, last: int) -> None:
|
|
836
|
+
"""Read columns `first` to `last` - 1 of one snapshot into the cache."""
|
|
837
|
+
strip = read_synthesis(self.series.paths[snapshot], self.series.reference_line,
|
|
838
|
+
columns=slice(first, last))
|
|
839
|
+
radiance = {name: strip.lines[name].radiance().to_value(RADIANCE_UNIT)
|
|
840
|
+
for name in self.series.lines}
|
|
841
|
+
self.strips_read += 1
|
|
842
|
+
for offset in range(last - first):
|
|
843
|
+
self._columns[(snapshot, first + offset)] = {
|
|
844
|
+
name: values[:, offset, :] for name, values in radiance.items()}
|
|
845
|
+
|
|
846
|
+
def synthesis(self, plan: RasterPlan, slit_width: u.Quantity, expos: u.Quantity,
|
|
847
|
+
repeat: Optional[int] = None, *, across_slit: Optional[u.Quantity] = None,
|
|
848
|
+
extend: bool = True) -> Tuple[Synthesis, dict]:
|
|
849
|
+
"""
|
|
850
|
+
One raster of the plan as a synthesis, and the raster metadata for its cube.
|
|
851
|
+
|
|
852
|
+
The synthesis columns are the raster's exposures, at slit positions
|
|
853
|
+
advancing by the step. A sit-and-stare is a raster of one exposure,
|
|
854
|
+
so its image has one column, as wide as the slit. The synthesis is
|
|
855
|
+
put on the detector like a single snapshot, with the raster entries
|
|
856
|
+
in the cube's metadata to keep one column per exposure. `across_slit`
|
|
857
|
+
and `extend` are as for :meth:`columns_seen`.
|
|
858
|
+
"""
|
|
859
|
+
collected, positions, pitch, meta_raster = self._observe(plan, slit_width, expos, repeat,
|
|
860
|
+
across_slit, extend)
|
|
861
|
+
x_edges = positions[0] - pitch / 2 + np.arange(positions.size + 1) * pitch
|
|
862
|
+
lines = {name: SpectralLine(
|
|
863
|
+
intensity=np.stack([c[name] for c in collected], axis=1) * RADIANCE_UNIT,
|
|
864
|
+
wavelength=self.series.wavelengths[name],
|
|
865
|
+
rest_wavelength=self.series.rest_wavelengths[name],
|
|
866
|
+
**self.series.identities[name])
|
|
867
|
+
for name in self.series.lines}
|
|
868
|
+
return Synthesis(lines=lines, x_edges=x_edges, y_edges=self.series.y_edges,
|
|
869
|
+
integration_axis=self.series.integration_axis), meta_raster
|