eclipse 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. eclipse/__init__.py +95 -0
  2. eclipse/analysis.py +853 -0
  3. eclipse/atmosphere.py +859 -0
  4. eclipse/band.py +477 -0
  5. eclipse/cli.py +138 -0
  6. eclipse/config.py +1710 -0
  7. eclipse/continuum.py +413 -0
  8. eclipse/data/eis_response/EIS_EffArea_A.004 +61 -0
  9. eclipse/data/eis_response/EIS_EffArea_B.004 +60 -0
  10. eclipse/data/eis_response/EIS_EffArea_B.005 +64 -0
  11. eclipse/data/eis_response/README_dz2025 +50 -0
  12. eclipse/data/eis_response/fit_eis_ea_2023-05-04_smooth.sav +0 -0
  13. eclipse/data/eis_response/source.txt +33 -0
  14. eclipse/data/science_cases.yaml +365 -0
  15. eclipse/data/throughput/grating_reflection_efficiency.dat +25 -0
  16. eclipse/data/throughput/primary_mirror_coating_reflectance.dat +25 -0
  17. eclipse/data/throughput/source.txt +6 -0
  18. eclipse/data/throughput/throughput_aluminium_1000_angstrom.dat +503 -0
  19. eclipse/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +503 -0
  20. eclipse/data/throughput/throughput_carbon_1000_angstrom.dat +503 -0
  21. eclipse/data_processing.py +904 -0
  22. eclipse/deprecated.py +161 -0
  23. eclipse/eis_calibration.py +573 -0
  24. eclipse/extern/__init__.py +0 -0
  25. eclipse/extern/mpfit.py +2388 -0
  26. eclipse/fitting.py +1734 -0
  27. eclipse/frame.py +594 -0
  28. eclipse/full_frame.py +944 -0
  29. eclipse/main.py +1646 -0
  30. eclipse/monte_carlo.py +660 -0
  31. eclipse/pinhole_diffraction.py +409 -0
  32. eclipse/psf.py +6 -0
  33. eclipse/radiometric.py +1328 -0
  34. eclipse/raster.py +869 -0
  35. eclipse/readout.py +1002 -0
  36. eclipse/resolution_budget.py +108 -0
  37. eclipse/results_file.py +1226 -0
  38. eclipse/sampling.py +351 -0
  39. eclipse/science_cases.py +364 -0
  40. eclipse/synthesis.py +2492 -0
  41. eclipse/synthesis_cli.py +12 -0
  42. eclipse/synthesis_file.py +882 -0
  43. eclipse/utils.py +1145 -0
  44. eclipse-0.13.0.dist-info/METADATA +70 -0
  45. eclipse-0.13.0.dist-info/RECORD +50 -0
  46. eclipse-0.13.0.dist-info/WHEEL +5 -0
  47. eclipse-0.13.0.dist-info/entry_points.txt +7 -0
  48. eclipse-0.13.0.dist-info/licenses/LICENSE +3 -0
  49. eclipse-0.13.0.dist-info/top_level.txt +2 -0
  50. euvst_response/__init__.py +129 -0
eclipse/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