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/full_frame.py
ADDED
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Full-CCD frames of the SW camera: every line in the band, at one slit position.
|
|
3
|
+
|
|
4
|
+
A frame is made from a synthesis file, which must hold each pixel's
|
|
5
|
+
emission measure by temperature and line-of-sight velocity and the electron
|
|
6
|
+
density at each temperature, and from the contribution functions of every
|
|
7
|
+
line in the band (`eclipse.band`).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import dataclasses
|
|
13
|
+
import inspect
|
|
14
|
+
import multiprocessing as mp
|
|
15
|
+
import os
|
|
16
|
+
import warnings
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Dict, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
import h5py
|
|
22
|
+
import numpy as np
|
|
23
|
+
import astropy.constants as const
|
|
24
|
+
import astropy.units as u
|
|
25
|
+
|
|
26
|
+
from .band import BandLines
|
|
27
|
+
from .config import Simulation
|
|
28
|
+
from .frame import (_REACH_IN_SIGMA, _cells, _photons_per_energy, _response, _sorted_rows,
|
|
29
|
+
detect, digitise, expose_serial_overscan_with_wavelengths,
|
|
30
|
+
expose_with_wavelengths, pixel_solid_angle)
|
|
31
|
+
from .radiometric import across_slit_fwhm, photons_per_energy, spatial_psf_fwhm
|
|
32
|
+
from .config import _FOCAL_PLANE_SETTINGS, _same_setting
|
|
33
|
+
from .data_processing import _openings_along_the_slit
|
|
34
|
+
from .readout import FocalPlane_SWC, ReadoutSequence, dark_current_time, serial_dark_shares
|
|
35
|
+
from .sampling import (light_onto_pixels, pixel_weights, pixel_weights_through,
|
|
36
|
+
points_onto_pixels)
|
|
37
|
+
from .utils import _fwhm_to_sigma, distance_to_angle
|
|
38
|
+
|
|
39
|
+
CCDS = ("left", "right")
|
|
40
|
+
C_CM_S = const.c.to_value(u.cm / u.s)
|
|
41
|
+
K_B = const.k_B.cgs.value
|
|
42
|
+
AMU_G = const.u.cgs.value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _density_shares(log_density: np.ndarray, logN_grid: np.ndarray):
|
|
46
|
+
"""
|
|
47
|
+
Indices of the grid densities either side of each density, and the share
|
|
48
|
+
of its emission measure each takes.
|
|
49
|
+
|
|
50
|
+
The shares are linear in log10 n_e, as the contribution functions are
|
|
51
|
+
interpolated. Off the grid both shares are zero, as are the contribution
|
|
52
|
+
functions.
|
|
53
|
+
"""
|
|
54
|
+
grid = np.asarray(logN_grid, dtype=float)
|
|
55
|
+
finite = np.isfinite(log_density)
|
|
56
|
+
inside = finite & (log_density >= grid[0]) & (log_density <= grid[-1])
|
|
57
|
+
position = np.interp(np.where(inside, log_density, grid[0]), grid,
|
|
58
|
+
np.arange(grid.size, dtype=float))
|
|
59
|
+
lower = np.floor(position).astype(int)
|
|
60
|
+
upper = np.minimum(lower + 1, grid.size - 1)
|
|
61
|
+
fraction = position - lower
|
|
62
|
+
return lower, upper, np.where(inside, 1.0 - fraction, 0.0), np.where(inside, fraction, 0.0)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _on_the_grid(points: np.ndarray, grid: np.ndarray) -> Optional[np.ndarray]:
|
|
66
|
+
"""
|
|
67
|
+
Index of each synthesis density on the band's grid, or None if any is not on it.
|
|
68
|
+
|
|
69
|
+
A synthesis's density grid is the part of ECLIPSE's default grid that its
|
|
70
|
+
plasma needs, so lines computed on the default grid serve any synthesis
|
|
71
|
+
whose densities it covers.
|
|
72
|
+
"""
|
|
73
|
+
grid = np.asarray(grid, dtype=float)
|
|
74
|
+
index = np.abs(np.asarray(points, dtype=float)[:, np.newaxis] - grid).argmin(axis=1)
|
|
75
|
+
return index if np.allclose(grid[index], points, rtol=0.0, atol=1e-6) else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _angle(edges: u.Quantity) -> np.ndarray:
|
|
79
|
+
"""Edges on the Sun, given as lengths or angles, in arcsec seen from 1 AU."""
|
|
80
|
+
edges = u.Quantity(edges)
|
|
81
|
+
if edges.unit.is_equivalent(u.arcsec):
|
|
82
|
+
return edges.to_value(u.arcsec)
|
|
83
|
+
return distance_to_angle(edges).to_value(u.arcsec)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _scene_weights(cell_edges: np.ndarray, pixel_edges: np.ndarray, sigma: float,
|
|
87
|
+
extend: bool = False, openings: Optional[np.ndarray] = None) -> np.ndarray:
|
|
88
|
+
"""
|
|
89
|
+
Weight of each scene cell in each pixel, `(n_pixels, n_cells)`.
|
|
90
|
+
|
|
91
|
+
With `extend` and a blur, the edge cells continue past the scene for
|
|
92
|
+
the blur to bring in, and each pixel takes light only over its part
|
|
93
|
+
inside the scene, as every pixel of a run lies inside it. With
|
|
94
|
+
`openings`, `(n, 2)` in the unit of the edges, light gets through only
|
|
95
|
+
there, after the blur.
|
|
96
|
+
"""
|
|
97
|
+
if not (extend and sigma > 0):
|
|
98
|
+
if openings is None:
|
|
99
|
+
return pixel_weights(cell_edges, pixel_edges, sigma)
|
|
100
|
+
return pixel_weights_through(cell_edges, pixel_edges, openings, sigma)
|
|
101
|
+
low, high = cell_edges[0], cell_edges[-1]
|
|
102
|
+
inside = np.clip([[low, high]] if openings is None else openings, low, high)
|
|
103
|
+
inside = inside[inside[:, 1] > inside[:, 0]]
|
|
104
|
+
return pixel_weights_through(cell_edges, pixel_edges, inside, sigma, extend=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class _Scene:
|
|
109
|
+
"""Scene under the slit, read from a synthesis file."""
|
|
110
|
+
|
|
111
|
+
logT_grid: np.ndarray
|
|
112
|
+
logN_grid: np.ndarray
|
|
113
|
+
velocity: np.ndarray # bin centres, cm/s
|
|
114
|
+
emission_measure: np.ndarray # (image rows, nT, nN, nv), under the slit
|
|
115
|
+
total_emission_measure: np.ndarray # (image rows, nT, nN), from the DEM
|
|
116
|
+
y_edges: np.ndarray # arcsec along the slit, from the image centre
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_slit_scene(path: str | Path, slit_position: u.Quantity, slit_width: u.Quantity,
|
|
120
|
+
psf_across_slit: Optional[u.Quantity] = None,
|
|
121
|
+
extend: bool = False) -> _Scene:
|
|
122
|
+
"""
|
|
123
|
+
Emission measure under the slit from a synthesis file, by temperature, density and velocity.
|
|
124
|
+
|
|
125
|
+
The emission measure by velocity (`em_tv`) holds only plasma within the
|
|
126
|
+
velocity grid. The total at each temperature, from the DEM, also holds
|
|
127
|
+
plasma moving faster, and is kept for the continuum, which is not
|
|
128
|
+
Doppler shifted.
|
|
129
|
+
|
|
130
|
+
Parameters
|
|
131
|
+
----------
|
|
132
|
+
path : str or Path
|
|
133
|
+
Synthesis file from ECLIPSE's own synthesis, with the emission
|
|
134
|
+
measure (`em_tv`), the DEM and the electron density at each
|
|
135
|
+
temperature.
|
|
136
|
+
slit_position : u.Quantity
|
|
137
|
+
Position of the slit centre across the slit, in the file's x
|
|
138
|
+
coordinates.
|
|
139
|
+
slit_width : u.Quantity
|
|
140
|
+
Slit width, as an angle.
|
|
141
|
+
psf_across_slit : u.Quantity, optional
|
|
142
|
+
FWHM of the telescope's blur across the slit, which brings in
|
|
143
|
+
emission from beside the slit. Default None, for no blur.
|
|
144
|
+
extend : bool, optional
|
|
145
|
+
Take the Sun beyond the file's x edges as their edge cells, for the
|
|
146
|
+
blur to bring in, and the slit's light only from its part inside
|
|
147
|
+
them. Default False, for dark.
|
|
148
|
+
"""
|
|
149
|
+
from .synthesis import _temperature_bins
|
|
150
|
+
|
|
151
|
+
slit_position = u.Quantity(slit_position)
|
|
152
|
+
if not slit_position.isscalar:
|
|
153
|
+
raise ValueError(f"slit_position must be one position, got {slit_position}.")
|
|
154
|
+
path = Path(path).expanduser()
|
|
155
|
+
with h5py.File(path, "r") as f:
|
|
156
|
+
x_edges = u.Quantity(f["x_edges"][()], f["x_edges"].attrs["unit"])
|
|
157
|
+
y_edges = u.Quantity(f["y_edges"][()], f["y_edges"].attrs["unit"])
|
|
158
|
+
products = f["synthesis"]
|
|
159
|
+
missing = [key for key in ("em_tv", "dem_map", "electron_density", "logT_grid",
|
|
160
|
+
"logN_grid", "vel_grid") if key not in products]
|
|
161
|
+
if missing:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
f"{path} has no {', '.join(missing)}, which a full-CCD frame needs: it was "
|
|
164
|
+
f"not made by this version of ECLIPSE's synthesis. Synthesise it again.")
|
|
165
|
+
x = _angle(x_edges)
|
|
166
|
+
centre = _angle(slit_position[np.newaxis])[0]
|
|
167
|
+
width = u.Quantity(slit_width).to_value(u.arcsec)
|
|
168
|
+
sigma = (0.0 if psf_across_slit is None
|
|
169
|
+
else _fwhm_to_sigma(u.Quantity(psf_across_slit).to_value(u.arcsec)))
|
|
170
|
+
slit = np.array([centre - width / 2, centre + width / 2])
|
|
171
|
+
across = _scene_weights(x, slit, sigma, extend)[0]
|
|
172
|
+
columns = np.flatnonzero(across > 0)
|
|
173
|
+
if columns.size == 0:
|
|
174
|
+
raise ValueError(f"The slit at {slit_position} is off the image, which runs from "
|
|
175
|
+
f"{x_edges[0]} to {x_edges[-1]}.")
|
|
176
|
+
first, last = columns[0], columns[-1] + 1
|
|
177
|
+
em = products["em_tv"][:, first:last, :, :].astype(float)
|
|
178
|
+
dem = products["dem_map"][:, first:last, :].astype(float)
|
|
179
|
+
density = products["electron_density"][:, first:last, :].astype(float)
|
|
180
|
+
logT = products["logT_grid"][()]
|
|
181
|
+
logN = products["logN_grid"][()]
|
|
182
|
+
velocity = u.Quantity(products["vel_grid"][()],
|
|
183
|
+
products["vel_grid"].attrs.get("unit", "cm / s")).to_value(u.cm / u.s)
|
|
184
|
+
weights = across[first:last]
|
|
185
|
+
lower, upper, low_share, high_share = _density_shares(
|
|
186
|
+
np.log10(np.where(density > 0, density, np.nan)), logN)
|
|
187
|
+
ny, _, n_t, n_v = em.shape
|
|
188
|
+
scene = np.zeros((ny, n_t, logN.size, n_v))
|
|
189
|
+
total = np.zeros((ny, n_t, logN.size))
|
|
190
|
+
# The DEM is per unit log T, as the synthesis's continuum takes it.
|
|
191
|
+
total_em = dem * _temperature_bins(logT)[0]
|
|
192
|
+
for k, weight in enumerate(weights):
|
|
193
|
+
for share, index in ((low_share, lower), (high_share, upper)):
|
|
194
|
+
part = weight * share[:, k, :, np.newaxis] * em[:, k, :, :]
|
|
195
|
+
rows, temperatures = np.indices(index[:, k, :].shape)
|
|
196
|
+
np.add.at(scene, (rows, temperatures, index[:, k, :]), part)
|
|
197
|
+
np.add.at(total, (rows, temperatures, index[:, k, :]),
|
|
198
|
+
weight * share[:, k, :] * total_em[:, k, :])
|
|
199
|
+
y = _angle(y_edges)
|
|
200
|
+
return _Scene(logT_grid=logT, logN_grid=logN, velocity=velocity, emission_measure=scene,
|
|
201
|
+
total_emission_measure=total, y_edges=y - 0.5 * (y[0] + y[-1]))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class _Layout:
|
|
206
|
+
"""
|
|
207
|
+
Mapping of light onto one CCD's rows, computed once for all temperatures.
|
|
208
|
+
|
|
209
|
+
A line is laid onto the rows by exact integrals of its Gaussian, with the
|
|
210
|
+
telescope throughput and photon energy at its centre. It is then
|
|
211
|
+
corrected cell by cell for how those change across the line (see
|
|
212
|
+
:func:`eclipse.frame.photons_from_lines`). The cells are fixed in
|
|
213
|
+
the plasma's rest frame and move and stretch with the Doppler shift, so
|
|
214
|
+
each velocity bin has its own mapping onto the rows.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
edges: np.ndarray # row edges, increasing, in Angstrom
|
|
218
|
+
order: np.ndarray # sorts the rows into that order
|
|
219
|
+
lit: Tuple[int, int] # first and last lit row
|
|
220
|
+
cells: np.ndarray # cell edges at rest, in Angstrom
|
|
221
|
+
onto_rows: list # each velocity bin's cells onto the rows, (rows, cells)
|
|
222
|
+
collected: np.ndarray # photons per erg at each bin's cells, (nv, cells)
|
|
223
|
+
cell_wavelength: np.ndarray # cell middles in each bin, (nv, cells)
|
|
224
|
+
continuum: Optional[tuple] = None # intervals onto the rows, photons per unit radiance
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _layout(focal_plane: FocalPlane_SWC, ccd: str, telescope, det, slit_width: u.Quantity,
|
|
228
|
+
blur: Optional[str], velocity: np.ndarray, solid_angle: float,
|
|
229
|
+
continuum_wavelength: Optional[np.ndarray]) -> _Layout:
|
|
230
|
+
"""`_Layout` of one CCD's rows, for `full_ccd_frame`."""
|
|
231
|
+
edges, order = _sorted_rows(focal_plane, ccd)
|
|
232
|
+
if blur is None:
|
|
233
|
+
reach = 0.0
|
|
234
|
+
else:
|
|
235
|
+
sigma, box = _response(focal_plane, ccd, None, telescope, det, slit_width, blur,
|
|
236
|
+
edges[[0, -1]])
|
|
237
|
+
reach = _REACH_IN_SIGMA * sigma.max() + box.max()
|
|
238
|
+
# Extend the cells at rest as far as the largest Doppler shift can bring
|
|
239
|
+
# light onto the rows from.
|
|
240
|
+
shift = (1.0 + velocity / C_CM_S)
|
|
241
|
+
reach += np.abs(edges[[0, -1]]).max() * np.abs(shift - 1.0).max()
|
|
242
|
+
cells = _cells(focal_plane, ccd, None, edges, reach)
|
|
243
|
+
onto_rows, collected, cell_wavelength = [], [], []
|
|
244
|
+
for factor in shift:
|
|
245
|
+
low, high = cells[:-1] * factor, cells[1:] * factor
|
|
246
|
+
middle = (low + high) / 2
|
|
247
|
+
if blur is None:
|
|
248
|
+
sigma = box = 0.0
|
|
249
|
+
else:
|
|
250
|
+
sigma, box = _response(focal_plane, ccd, None, telescope, det, slit_width, blur,
|
|
251
|
+
middle)
|
|
252
|
+
onto_rows.append(light_onto_pixels(low, high, edges, sigma, box))
|
|
253
|
+
# Photons per second per pixel per unit of G times emission measure:
|
|
254
|
+
# radiance G EM / 4 pi through the pixel's solid angle, in photons at
|
|
255
|
+
# the cell's own wavelength. Zero beyond the throughput tables.
|
|
256
|
+
collected.append(solid_angle / (4.0 * np.pi) * np.nan_to_num(
|
|
257
|
+
photons_per_energy(telescope, middle * u.Angstrom).to_value(u.cm**2 / u.erg)))
|
|
258
|
+
cell_wavelength.append(middle)
|
|
259
|
+
continuum = None
|
|
260
|
+
if continuum_wavelength is not None:
|
|
261
|
+
# The continuum is not Doppler shifted. It is laid like a spectrum:
|
|
262
|
+
# each interval between samples holds its trapezium-rule light, in
|
|
263
|
+
# photons at its own wavelengths.
|
|
264
|
+
low, high = continuum_wavelength[:-1], continuum_wavelength[1:]
|
|
265
|
+
if blur is None:
|
|
266
|
+
sigma = box = 0.0
|
|
267
|
+
else:
|
|
268
|
+
sigma, box = _response(focal_plane, ccd, None, telescope, det, slit_width, blur,
|
|
269
|
+
(low + high) / 2)
|
|
270
|
+
# The continuum is a radiance already, per steradian.
|
|
271
|
+
at_samples = solid_angle * _photons_per_energy(
|
|
272
|
+
telescope, continuum_wavelength * u.Angstrom).to_value(u.cm**2 / u.erg)
|
|
273
|
+
continuum = (light_onto_pixels(low, high, edges, sigma, box), at_samples)
|
|
274
|
+
return _Layout(edges=edges, order=order, lit=focal_plane.lit_rows(ccd), cells=cells,
|
|
275
|
+
onto_rows=onto_rows, collected=np.array(collected),
|
|
276
|
+
cell_wavelength=np.array(cell_wavelength), continuum=continuum)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _lines_on_rows(layout: _Layout, focal_plane: FocalPlane_SWC, ccd: str, telescope, det,
|
|
280
|
+
slit_width: u.Quantity, blur: Optional[str], wavelength: np.ndarray,
|
|
281
|
+
thermal: np.ndarray, g: np.ndarray, velocity: np.ndarray,
|
|
282
|
+
bins: np.ndarray, solid_angle: float) -> Tuple[np.ndarray, ...]:
|
|
283
|
+
"""
|
|
284
|
+
Photons per second in each row of one CCD per unit emission measure, at one temperature.
|
|
285
|
+
|
|
286
|
+
Parameters
|
|
287
|
+
----------
|
|
288
|
+
wavelength, thermal : np.ndarray
|
|
289
|
+
Rest wavelength and thermal width of each line, in Angstrom.
|
|
290
|
+
g : np.ndarray
|
|
291
|
+
Contribution function of each line at this temperature, at the
|
|
292
|
+
densities to compute, (n_lines, n_densities).
|
|
293
|
+
velocity : np.ndarray
|
|
294
|
+
Velocity bin centres, in cm/s.
|
|
295
|
+
bins : np.ndarray
|
|
296
|
+
Indices of the velocity bins to compute.
|
|
297
|
+
|
|
298
|
+
Returns
|
|
299
|
+
-------
|
|
300
|
+
photons, per_wavelength, per_square : np.ndarray
|
|
301
|
+
`(n_densities, bins.size, n_rows)`: photons per second per pixel per
|
|
302
|
+
cm^-5, and the same with each photon divided by its wavelength in
|
|
303
|
+
Angstrom and by its square. These give the mean and root mean square
|
|
304
|
+
energies of a pixel's photons.
|
|
305
|
+
"""
|
|
306
|
+
n_rows = layout.edges.size - 1
|
|
307
|
+
photons = np.zeros((g.shape[1], bins.size, n_rows))
|
|
308
|
+
per_wavelength = np.zeros_like(photons)
|
|
309
|
+
per_square = np.zeros_like(photons)
|
|
310
|
+
masses = points_onto_pixels(wavelength, layout.cells, thermal) # (cells, lines)
|
|
311
|
+
at_rest = masses @ g # (cells, densities)
|
|
312
|
+
for i, v in enumerate(bins):
|
|
313
|
+
factor = 1.0 + velocity[v] / C_CM_S
|
|
314
|
+
centre = wavelength * factor
|
|
315
|
+
at_centre = solid_angle / (4.0 * np.pi) * np.nan_to_num(
|
|
316
|
+
photons_per_energy(telescope, centre * u.Angstrom).to_value(u.cm**2 / u.erg))
|
|
317
|
+
if blur is None:
|
|
318
|
+
psf_sigma = box = np.zeros(centre.size)
|
|
319
|
+
else:
|
|
320
|
+
psf_sigma, box = _response(focal_plane, ccd, None, telescope, det, slit_width,
|
|
321
|
+
blur, centre)
|
|
322
|
+
# Lay each line, its Gaussian blurred by the response, onto the rows
|
|
323
|
+
# by exact integrals, in photons at the line centre's wavelength.
|
|
324
|
+
exact = points_onto_pixels(centre, layout.edges,
|
|
325
|
+
np.hypot(thermal * factor, psf_sigma), box)
|
|
326
|
+
weighted = g * at_centre[:, np.newaxis]
|
|
327
|
+
per_centre = weighted / centre[:, np.newaxis]
|
|
328
|
+
per_centre_square = per_centre / centre[:, np.newaxis]
|
|
329
|
+
rows = exact @ weighted
|
|
330
|
+
rows_per = exact @ per_centre
|
|
331
|
+
rows_square = exact @ per_centre_square
|
|
332
|
+
# Then correct, cell by cell across each line, for photons at the
|
|
333
|
+
# cell's own wavelength rather than the centre's.
|
|
334
|
+
collected = layout.collected[v][:, np.newaxis]
|
|
335
|
+
own = layout.cell_wavelength[v][:, np.newaxis]
|
|
336
|
+
change = collected * at_rest - masses @ weighted
|
|
337
|
+
change_per = collected / own * at_rest - masses @ per_centre
|
|
338
|
+
change_square = collected / own**2 * at_rest - masses @ per_centre_square
|
|
339
|
+
rows += layout.onto_rows[v] @ change
|
|
340
|
+
rows_per += layout.onto_rows[v] @ change_per
|
|
341
|
+
rows_square += layout.onto_rows[v] @ change_square
|
|
342
|
+
photons[:, i, layout.order] = rows.T
|
|
343
|
+
per_wavelength[:, i, layout.order] = rows_per.T
|
|
344
|
+
per_square[:, i, layout.order] = rows_square.T
|
|
345
|
+
first, last = layout.lit
|
|
346
|
+
for array in (photons, per_wavelength, per_square):
|
|
347
|
+
array[..., :first] = 0.0
|
|
348
|
+
array[..., last + 1:] = 0.0
|
|
349
|
+
return photons, per_wavelength, per_square
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# Data shared by the workers of one `full_ccd_frame` call, set in each worker
|
|
353
|
+
# as it starts.
|
|
354
|
+
_WORKER_CONTEXT: Optional[dict] = None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _set_worker_context(context: dict) -> None:
|
|
358
|
+
global _WORKER_CONTEXT
|
|
359
|
+
_WORKER_CONTEXT = context
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _rates_in_worker(t: int):
|
|
363
|
+
return _rates_at(t, _WORKER_CONTEXT)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _rates_at(t: int, context: dict):
|
|
367
|
+
"""
|
|
368
|
+
Photons per second in each pixel of both CCDs from the plasma at one
|
|
369
|
+
temperature (an index into the temperature grid).
|
|
370
|
+
|
|
371
|
+
Returns three dicts by CCD, each array (n_columns, n_rows): the photons,
|
|
372
|
+
and the same with each photon divided by its wavelength in Angstrom and
|
|
373
|
+
by its square.
|
|
374
|
+
"""
|
|
375
|
+
scene, lines, along = context["scene"], context["lines"], context["along"]
|
|
376
|
+
focal_plane, telescope, det = context["focal_plane"], context["telescope"], context["det"]
|
|
377
|
+
slit_width, blur, continuum = context["slit_width"], context["blur"], context["continuum"]
|
|
378
|
+
density_index, layouts = context["density_index"], context["layouts"]
|
|
379
|
+
solid_angle = context["solid_angle"]
|
|
380
|
+
n_n, n_v = scene.logN_grid.size, scene.velocity.size
|
|
381
|
+
shape = (focal_plane.n_columns, focal_plane.n_rows)
|
|
382
|
+
rate = {ccd: np.zeros(shape) for ccd in CCDS}
|
|
383
|
+
per_wavelength = {ccd: np.zeros(shape) for ccd in CCDS}
|
|
384
|
+
per_square = {ccd: np.zeros(shape) for ccd in CCDS}
|
|
385
|
+
em = scene.emission_measure[:, t] # (image rows, nN, nv)
|
|
386
|
+
em_columns = (along @ em.reshape(em.shape[0], -1)).reshape(-1, n_n, n_v)
|
|
387
|
+
# Only the densities and velocity bins with emission measure are computed.
|
|
388
|
+
densities = np.flatnonzero(em_columns.any(axis=(0, 2)))
|
|
389
|
+
bins = np.flatnonzero(em_columns.any(axis=(0, 1)))
|
|
390
|
+
present = [ion for ion in lines.ions if t in ion.temperatures]
|
|
391
|
+
if present and bins.size:
|
|
392
|
+
g = np.concatenate([ion.g[:, density_index[densities],
|
|
393
|
+
np.searchsorted(ion.temperatures, t)] for ion in present])
|
|
394
|
+
wavelength = np.concatenate([ion.wavelength for ion in present])
|
|
395
|
+
mass = np.concatenate([np.full(ion.wavelength.size, ion.mass) for ion in present])
|
|
396
|
+
thermal = (wavelength * np.sqrt(K_B * 10.0 ** scene.logT_grid[t] / (mass * AMU_G))
|
|
397
|
+
/ C_CM_S)
|
|
398
|
+
em_used = em_columns[:, densities][:, :, bins].reshape(shape[0], -1)
|
|
399
|
+
for ccd in CCDS:
|
|
400
|
+
layout = layouts[ccd]
|
|
401
|
+
# Lines whose light can reach the cells.
|
|
402
|
+
near = ((wavelength + _REACH_IN_SIGMA * thermal >= layout.cells[0])
|
|
403
|
+
& (wavelength - _REACH_IN_SIGMA * thermal <= layout.cells[-1]))
|
|
404
|
+
if not near.any():
|
|
405
|
+
continue
|
|
406
|
+
photons, per, square = _lines_on_rows(
|
|
407
|
+
layout, focal_plane, ccd, telescope, det, slit_width, blur,
|
|
408
|
+
wavelength[near], thermal[near], g[near], scene.velocity, bins, solid_angle)
|
|
409
|
+
rate[ccd] += em_used @ photons.reshape(em_used.shape[1], -1)
|
|
410
|
+
per_wavelength[ccd] += em_used @ per.reshape(em_used.shape[1], -1)
|
|
411
|
+
per_square[ccd] += em_used @ square.reshape(em_used.shape[1], -1)
|
|
412
|
+
if continuum is not None:
|
|
413
|
+
wavelength, free, two_photon = continuum
|
|
414
|
+
# The continuum takes the total emission measure, which also holds
|
|
415
|
+
# plasma faster than the velocity grid.
|
|
416
|
+
total = along @ scene.total_emission_measure[:, t] # (columns, nN)
|
|
417
|
+
densities = np.flatnonzero(total.any(axis=0))
|
|
418
|
+
em_density = total[:, densities]
|
|
419
|
+
for ccd in CCDS:
|
|
420
|
+
onto_rows, at_samples = layouts[ccd].continuum
|
|
421
|
+
order = layouts[ccd].order
|
|
422
|
+
# Photons per second per pixel per Angstrom at each sample, per
|
|
423
|
+
# unit emission measure at each density.
|
|
424
|
+
spectrum = (free[t][np.newaxis, :] + two_photon[density_index[densities], t]) * 1e-8
|
|
425
|
+
density = spectrum * at_samples
|
|
426
|
+
step = np.diff(wavelength)
|
|
427
|
+
light = step * (density[:, 1:] + density[:, :-1]) / 2
|
|
428
|
+
per_sample = density / wavelength
|
|
429
|
+
light_per = step * (per_sample[:, 1:] + per_sample[:, :-1]) / 2
|
|
430
|
+
square_sample = per_sample / wavelength
|
|
431
|
+
light_square = step * (square_sample[:, 1:] + square_sample[:, :-1]) / 2
|
|
432
|
+
rows = np.zeros((densities.size, focal_plane.n_rows))
|
|
433
|
+
rows_per = np.zeros_like(rows)
|
|
434
|
+
rows_square = np.zeros_like(rows)
|
|
435
|
+
rows[:, order] = (onto_rows @ light.T).T
|
|
436
|
+
rows_per[:, order] = (onto_rows @ light_per.T).T
|
|
437
|
+
rows_square[:, order] = (onto_rows @ light_square.T).T
|
|
438
|
+
first, last = layouts[ccd].lit
|
|
439
|
+
for array in (rows, rows_per, rows_square):
|
|
440
|
+
array[:, :first] = 0.0
|
|
441
|
+
array[:, last + 1:] = 0.0
|
|
442
|
+
rate[ccd] += em_density @ rows
|
|
443
|
+
per_wavelength[ccd] += em_density @ rows_per
|
|
444
|
+
per_square[ccd] += em_density @ rows_square
|
|
445
|
+
return rate, per_wavelength, per_square
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@dataclass
|
|
449
|
+
class FullFrame:
|
|
450
|
+
"""
|
|
451
|
+
Full-CCD frame of both SW CCDs, at one slit position.
|
|
452
|
+
|
|
453
|
+
The dict attributes are keyed by CCD, `"left"` and `"right"`.
|
|
454
|
+
|
|
455
|
+
Attributes
|
|
456
|
+
----------
|
|
457
|
+
rate : dict
|
|
458
|
+
Photons per second reaching each image pixel, (n_rows, n_columns).
|
|
459
|
+
photons : dict
|
|
460
|
+
Photons in each pixel, including the parallel overscan rows. The
|
|
461
|
+
mean if `noise` was off.
|
|
462
|
+
dn : dict
|
|
463
|
+
Frame as read out, in DN: the prescan, image and overscan of each
|
|
464
|
+
output, (n_rows + parallel overscan, samples per row).
|
|
465
|
+
wavelength : dict
|
|
466
|
+
Wavelength each image row records.
|
|
467
|
+
photon_wavelength : dict
|
|
468
|
+
Wavelength of the mean energy of the photons in each pixel,
|
|
469
|
+
including the parallel overscan rows. Sets the electrons they free.
|
|
470
|
+
photon_rms_wavelength : dict
|
|
471
|
+
Wavelength of the root mean square energy of the same photons. Sets
|
|
472
|
+
how far their electrons spread.
|
|
473
|
+
exposure : u.Quantity
|
|
474
|
+
slit_position, slit_width : u.Quantity
|
|
475
|
+
settings : dict
|
|
476
|
+
Options the frame was made with, and the prescan and overscan sizes
|
|
477
|
+
in `dn`.
|
|
478
|
+
"""
|
|
479
|
+
|
|
480
|
+
rate: Dict[str, np.ndarray]
|
|
481
|
+
photons: Dict[str, np.ndarray]
|
|
482
|
+
dn: Dict[str, np.ndarray]
|
|
483
|
+
wavelength: Dict[str, u.Quantity]
|
|
484
|
+
photon_wavelength: Dict[str, u.Quantity]
|
|
485
|
+
photon_rms_wavelength: Dict[str, u.Quantity]
|
|
486
|
+
exposure: u.Quantity
|
|
487
|
+
slit_position: u.Quantity
|
|
488
|
+
slit_width: u.Quantity
|
|
489
|
+
settings: dict = field(default_factory=dict)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def lay_out_register(image_dn: np.ndarray, det, noise: bool = True,
|
|
493
|
+
overscan_dn: Optional[np.ndarray] = None) -> np.ndarray:
|
|
494
|
+
"""
|
|
495
|
+
Frame samples as the two outputs read them, with prescan and overscan.
|
|
496
|
+
|
|
497
|
+
Each output reads half the columns, with the prescan at the outer end of
|
|
498
|
+
the register and the overscan at the inner end (`ReadoutSequence`). In
|
|
499
|
+
order across the CCD, a row is: left prescan, first half of the image,
|
|
500
|
+
left overscan, right overscan, second half of the image, right prescan.
|
|
501
|
+
The prescan samples hold no charge from the image area, so they read
|
|
502
|
+
only the read noise. So do the overscan samples, unless `overscan_dn`
|
|
503
|
+
gives them, `(n_rows, 2 * serial_overscan)` in that order, with the
|
|
504
|
+
charge, light and dark, the serial register carries past the image. The
|
|
505
|
+
serial register's own dark current is not modelled, here or elsewhere.
|
|
506
|
+
"""
|
|
507
|
+
n_rows, n_columns = image_dn.shape
|
|
508
|
+
half = (n_columns + 1) // 2
|
|
509
|
+
prescan, overscan = det.serial_prescan, det.serial_overscan
|
|
510
|
+
empty = np.zeros((n_rows, 2 * (prescan + overscan)))
|
|
511
|
+
blank = digitise(detect(empty, np.full(n_rows, 200.0) * u.AA, 0.0 * u.s, det,
|
|
512
|
+
noise=noise), det)
|
|
513
|
+
pieces = np.split(blank, np.cumsum([prescan, overscan, overscan]), axis=1)
|
|
514
|
+
if overscan_dn is not None:
|
|
515
|
+
if np.shape(overscan_dn) != (n_rows, 2 * overscan):
|
|
516
|
+
raise ValueError(f"overscan_dn must be shaped {(n_rows, 2 * overscan)}, got "
|
|
517
|
+
f"{np.shape(overscan_dn)}.")
|
|
518
|
+
pieces[1], pieces[2] = overscan_dn[:, :overscan], overscan_dn[:, overscan:]
|
|
519
|
+
return np.concatenate([pieces[0], image_dn[:, :half], pieces[1], pieces[2],
|
|
520
|
+
image_dn[:, half:], pieces[3]], axis=1)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# Arguments of `full_ccd_frame` that a `Simulation` sets.
|
|
524
|
+
_FROM_SIMULATION = ("slit_width", "psf", "spectral_psf", "noise", "psf_boundary",
|
|
525
|
+
"slit_mask")
|
|
526
|
+
|
|
527
|
+
# `Simulation` fields a frame does not use, as they do not change it. `expos`
|
|
528
|
+
# is replaced by the `exposure` argument, and a frame always takes its
|
|
529
|
+
# wavelengths from the focal plane.
|
|
530
|
+
_IGNORED_SIMULATION_FIELDS = ("expos", "n_iter", "ncpu", "focal_plane_dispersion")
|
|
531
|
+
|
|
532
|
+
# Fields not checked one by one. The pinhole settings are checked through
|
|
533
|
+
# `enable_pinholes`.
|
|
534
|
+
_UNCHECKED_FIELDS = frozenset(_FROM_SIMULATION + _IGNORED_SIMULATION_FIELDS
|
|
535
|
+
+ ("pinhole_sizes", "pinhole_positions",
|
|
536
|
+
"pinhole_positions_spectral", "pinhole_visible_wavelength"))
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _same(a, b) -> bool:
|
|
540
|
+
"""Whether `a` and `b` are equal and of one shape, False if they cannot be compared."""
|
|
541
|
+
try:
|
|
542
|
+
return np.shape(a) == np.shape(b) and bool(np.all(a == b))
|
|
543
|
+
except (TypeError, ValueError):
|
|
544
|
+
return False
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _field_default(f: dataclasses.Field):
|
|
548
|
+
"""Default of a dataclass field, or `dataclasses.MISSING` if it has none."""
|
|
549
|
+
if f.default is not dataclasses.MISSING:
|
|
550
|
+
return f.default
|
|
551
|
+
if f.default_factory is not dataclasses.MISSING:
|
|
552
|
+
return f.default_factory()
|
|
553
|
+
return dataclasses.MISSING
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _settings_from_simulation(sim: Simulation, given: Dict[str, object],
|
|
557
|
+
exposure: u.Quantity) -> dict:
|
|
558
|
+
"""
|
|
559
|
+
Slit, PSF and noise settings for `full_ccd_frame` from a `Simulation`.
|
|
560
|
+
|
|
561
|
+
An argument in `given` equal to its default takes the value in `sim`.
|
|
562
|
+
Raises TypeError if `sim` is not a `Simulation`. Raises one ValueError
|
|
563
|
+
listing every argument that differs from both its default and `sim`, and
|
|
564
|
+
every setting in `sim` the frame does not model, including any field a
|
|
565
|
+
subclass adds. Warns if `sim.expos` differs from both 1 s, its default,
|
|
566
|
+
and `exposure`.
|
|
567
|
+
"""
|
|
568
|
+
from .pinhole_diffraction import _pinholes_on
|
|
569
|
+
|
|
570
|
+
if not isinstance(sim, Simulation):
|
|
571
|
+
raise TypeError(f"sim must be a Simulation, got {type(sim).__name__}.")
|
|
572
|
+
arguments = inspect.signature(full_ccd_frame).parameters
|
|
573
|
+
problems = [f"{name} is {value!r} here and {getattr(sim, name)!r} in sim"
|
|
574
|
+
for name, value in given.items()
|
|
575
|
+
if not _same(value, arguments[name].default)
|
|
576
|
+
and not _same(value, getattr(sim, name))]
|
|
577
|
+
# Defaults of `Simulation` itself, so a subclass cannot hide a setting by
|
|
578
|
+
# changing its default.
|
|
579
|
+
base = {f.name: f for f in dataclasses.fields(Simulation)}
|
|
580
|
+
for f in dataclasses.fields(sim):
|
|
581
|
+
name, value = f.name, getattr(sim, f.name)
|
|
582
|
+
if name in _UNCHECKED_FIELDS:
|
|
583
|
+
continue
|
|
584
|
+
if name not in base:
|
|
585
|
+
problems.append(f"{name}: the frame does not know this setting")
|
|
586
|
+
elif name == "instrument":
|
|
587
|
+
if str(value).upper() != "SWC":
|
|
588
|
+
problems.append(f"instrument {value!r}: a frame is of SWC")
|
|
589
|
+
elif name == "enable_pinholes":
|
|
590
|
+
if _pinholes_on(sim):
|
|
591
|
+
problems.append("enable_pinholes: the frame does not model pinholes in the "
|
|
592
|
+
"filter")
|
|
593
|
+
elif not _same(value, _field_default(base[name])):
|
|
594
|
+
problems.append(f"{name} {value!r}: the frame does not model it")
|
|
595
|
+
if problems:
|
|
596
|
+
raise ValueError("full_ccd_frame cannot take these settings: " + "; ".join(problems)
|
|
597
|
+
+ ".")
|
|
598
|
+
if not _same(sim.expos, _field_default(base["expos"])) and not _same(sim.expos, exposure):
|
|
599
|
+
warnings.warn(f"The frame's exposure is {exposure}, from exposure; sim.expos, "
|
|
600
|
+
f"{sim.expos}, is not used.", UserWarning, stacklevel=3)
|
|
601
|
+
return {name: getattr(sim, name) for name in _FROM_SIMULATION}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def full_ccd_frame(synthesis_path: str | Path, lines: BandLines, slit_position: u.Quantity,
|
|
605
|
+
exposure: u.Quantity, *, slit_width: u.Quantity = 0.2 * u.arcsec,
|
|
606
|
+
det=None, telescope=None, focal_plane: Optional[FocalPlane_SWC] = None,
|
|
607
|
+
psf: bool = True, spectral_psf: str = "quadrature",
|
|
608
|
+
continuum: Optional[Tuple[u.Quantity, np.ndarray, np.ndarray]] = None,
|
|
609
|
+
noise: bool = True, n_workers: int = 0,
|
|
610
|
+
sim: Optional[Simulation] = None,
|
|
611
|
+
psf_boundary: str = "replicate", slit_mask: bool = False) -> FullFrame:
|
|
612
|
+
"""
|
|
613
|
+
Full-CCD frame of the SW camera from a synthesis and every line in the band.
|
|
614
|
+
|
|
615
|
+
Parameters
|
|
616
|
+
----------
|
|
617
|
+
synthesis_path : str or Path
|
|
618
|
+
Synthesis file from ECLIPSE's synthesis.
|
|
619
|
+
lines : BandLines
|
|
620
|
+
Every line in the band, from
|
|
621
|
+
`eclipse.band.band_contribution_functions`. Must be on the
|
|
622
|
+
synthesis's temperatures, with its abundances, and on a density grid
|
|
623
|
+
that has the synthesis's densities, such as the default one.
|
|
624
|
+
slit_position : u.Quantity
|
|
625
|
+
Position of the slit centre across the slit, in the file's x
|
|
626
|
+
coordinates. Along the slit, the image centre is at the slit centre.
|
|
627
|
+
exposure : u.Quantity
|
|
628
|
+
Exposure time.
|
|
629
|
+
slit_width : u.Quantity, optional
|
|
630
|
+
Default 0.2 arcsec.
|
|
631
|
+
det, telescope : optional
|
|
632
|
+
Default `Detector_SWC()` and `Telescope_EUVST()`.
|
|
633
|
+
focal_plane : FocalPlane_SWC, optional
|
|
634
|
+
Default `FocalPlane_SWC.from_detector(det)`. Its `dispersion`,
|
|
635
|
+
`wavelength_offset`, `lit_band` and `slit_image_tilt` must be the
|
|
636
|
+
detector's.
|
|
637
|
+
psf : bool, optional
|
|
638
|
+
Blur with the telescope's PSF along the slit and in wavelength, and
|
|
639
|
+
across the slit if the telescope gives that. Default True.
|
|
640
|
+
spectral_psf : str, optional
|
|
641
|
+
`"quadrature"` (default) or `"convolution"`, as in the configuration.
|
|
642
|
+
continuum : tuple, optional
|
|
643
|
+
Continuum per unit emission measure: wavelengths, free-free and
|
|
644
|
+
free-bound emission (nT, n_wavelength) and two-photon emission (nN,
|
|
645
|
+
nT, n_wavelength). As returned by
|
|
646
|
+
`eclipse.continuum.compute_continuum_fiasco` on the grids of
|
|
647
|
+
`lines`, within the telescope's throughput tables. Default None, for
|
|
648
|
+
no continuum.
|
|
649
|
+
noise : bool, optional
|
|
650
|
+
If False, every random draw is replaced by its mean. Default True.
|
|
651
|
+
n_workers : int, optional
|
|
652
|
+
Number of processes to compute the temperatures with. Default 0 uses
|
|
653
|
+
every CPU this process may use. Where processes cannot be forked, as
|
|
654
|
+
on Windows, one process is used.
|
|
655
|
+
sim : Simulation, optional
|
|
656
|
+
Settings to take `slit_width`, `psf`, `spectral_psf`, `noise`,
|
|
657
|
+
`psf_boundary` and `slit_mask` from. Where one of these arguments
|
|
658
|
+
equals its default, given or not, the `Simulation`'s value is used;
|
|
659
|
+
any other value must agree with it. A `Simulation` has the PSF off unless made
|
|
660
|
+
with `psf=True`. Its `expos`, `n_iter` and `ncpu` are not used, nor
|
|
661
|
+
the pinhole settings with pinholes off. Default None.
|
|
662
|
+
psf_boundary : str, optional
|
|
663
|
+
What the PSF brings in from beyond the edges of the synthesis, as
|
|
664
|
+
in a run. `"replicate"` (default) takes the Sun there to look like
|
|
665
|
+
the edge cells. Each column, and the slit across, then gets light
|
|
666
|
+
only over its part inside the scene, so a uniform scene gives every
|
|
667
|
+
column inside it the same light and the columns beyond none.
|
|
668
|
+
`"zero"` takes the Sun there as dark, so the columns at the ends of
|
|
669
|
+
the scene get up to about a third less light, which the blur
|
|
670
|
+
carries to the columns just beyond. Keyword only.
|
|
671
|
+
slit_mask : bool, optional
|
|
672
|
+
Block the light beyond the ends of the slit and behind its
|
|
673
|
+
fiducials, from `telescope.slit_openings`, as in a run. The slit's
|
|
674
|
+
centre is the middle column and the middle of the scene. The blur
|
|
675
|
+
along the slit comes before the slit, so a column partly behind a
|
|
676
|
+
fiducial gets the light falling on its open part. Default False.
|
|
677
|
+
Keyword only.
|
|
678
|
+
|
|
679
|
+
Returns
|
|
680
|
+
-------
|
|
681
|
+
FullFrame
|
|
682
|
+
|
|
683
|
+
Raises
|
|
684
|
+
------
|
|
685
|
+
TypeError
|
|
686
|
+
If `sim` is not a `Simulation`.
|
|
687
|
+
ValueError
|
|
688
|
+
Among others: if `exposure` or `slit_width` is not above zero,
|
|
689
|
+
`psf_boundary` is not `"replicate"` or `"zero"`, `slit_mask` is on
|
|
690
|
+
and the telescope does not give the slit's openings for this slit
|
|
691
|
+
width, the slit is off the synthesis, `lines` or `continuum` are
|
|
692
|
+
not on the synthesis's grids, or `focal_plane` does not match `det`
|
|
693
|
+
or has a tilted slit image. With `sim`, if an argument differs from
|
|
694
|
+
both its default and the `Simulation`, or the `Simulation` asks for
|
|
695
|
+
something the frame does not model: an instrument other than SWC,
|
|
696
|
+
pinholes, visible stray light, or a field a subclass adds. One
|
|
697
|
+
error lists every such setting.
|
|
698
|
+
|
|
699
|
+
Warns
|
|
700
|
+
-----
|
|
701
|
+
UserWarning
|
|
702
|
+
If `sim.expos` differs from both 1 s, its default, and `exposure`,
|
|
703
|
+
which is used.
|
|
704
|
+
"""
|
|
705
|
+
from . import frame as frame_module
|
|
706
|
+
from .config import Detector_SWC, Telescope_EUVST
|
|
707
|
+
|
|
708
|
+
if sim is not None:
|
|
709
|
+
given = {"slit_width": slit_width, "psf": psf, "spectral_psf": spectral_psf,
|
|
710
|
+
"noise": noise, "psf_boundary": psf_boundary, "slit_mask": slit_mask}
|
|
711
|
+
settings = _settings_from_simulation(sim, given, exposure)
|
|
712
|
+
slit_width, psf = settings["slit_width"], settings["psf"]
|
|
713
|
+
spectral_psf, noise = settings["spectral_psf"], settings["noise"]
|
|
714
|
+
psf_boundary, slit_mask = settings["psf_boundary"], settings["slit_mask"]
|
|
715
|
+
if not (isinstance(psf_boundary, str) and psf_boundary in ("replicate", "zero")):
|
|
716
|
+
raise ValueError(f"psf_boundary must be 'replicate' or 'zero', got {psf_boundary!r}.")
|
|
717
|
+
extend = psf_boundary == "replicate"
|
|
718
|
+
if slit_mask not in (True, False):
|
|
719
|
+
raise ValueError(f"slit_mask must be True or False, got {slit_mask!r}.")
|
|
720
|
+
det = Detector_SWC() if det is None else det
|
|
721
|
+
telescope = Telescope_EUVST() if telescope is None else telescope
|
|
722
|
+
focal_plane = FocalPlane_SWC.from_detector(det) if focal_plane is None else focal_plane
|
|
723
|
+
for name, value, unit in (("exposure", exposure, u.s), ("slit_width", slit_width, u.arcsec)):
|
|
724
|
+
value = u.Quantity(value)
|
|
725
|
+
if not (value.isscalar and value.unit.is_equivalent(unit) and np.isfinite(value.value)
|
|
726
|
+
and value.value > 0):
|
|
727
|
+
raise ValueError(f"{name} must be a finite {unit.physical_type} above zero, got "
|
|
728
|
+
f"{value}.")
|
|
729
|
+
openings = _openings_along_the_slit(telescope, slit_width) if slit_mask else None
|
|
730
|
+
if (focal_plane.n_rows, focal_plane.n_columns) != (det.n_rows, det.n_columns):
|
|
731
|
+
raise ValueError(
|
|
732
|
+
f"The focal plane has {focal_plane.n_rows} rows and {focal_plane.n_columns} "
|
|
733
|
+
f"columns and the detector {det.n_rows} and {det.n_columns}; they must match.")
|
|
734
|
+
if any(value != 0.0 for value in focal_plane.slit_image_tilt):
|
|
735
|
+
raise ValueError("The slit's image is tilted, so each column's rows have their own "
|
|
736
|
+
"wavelengths, which this frame does not yet work out.")
|
|
737
|
+
differ = [name for name in _FOCAL_PLANE_SETTINGS
|
|
738
|
+
if not _same_setting(getattr(det, name), getattr(focal_plane, name))]
|
|
739
|
+
if differ:
|
|
740
|
+
raise ValueError(
|
|
741
|
+
f"The detector and the focal plane give different {', '.join(differ)}. Set them on "
|
|
742
|
+
f"the detector, Detector_SWC, and leave out focal_plane, or make it with "
|
|
743
|
+
f"FocalPlane_SWC.from_detector(det).")
|
|
744
|
+
across = across_slit_fwhm(telescope) if psf else None
|
|
745
|
+
scene = read_slit_scene(synthesis_path, slit_position, slit_width, across, extend)
|
|
746
|
+
if not np.array_equal(scene.logT_grid, lines.logT_grid):
|
|
747
|
+
raise ValueError("The band's lines are on another grid of temperature than the "
|
|
748
|
+
"synthesis; work them out on the synthesis's own.")
|
|
749
|
+
density_index = _on_the_grid(scene.logN_grid, lines.logN_grid)
|
|
750
|
+
if density_index is None:
|
|
751
|
+
raise ValueError(
|
|
752
|
+
f"The synthesis takes the contribution functions at densities, log10 n_e "
|
|
753
|
+
f"{scene.logN_grid[0]:.2f} to {scene.logN_grid[-1]:.2f}, that are not all on the "
|
|
754
|
+
f"band's grid, {lines.logN_grid[0]:.2f} to {lines.logN_grid[-1]:.2f}; work the "
|
|
755
|
+
f"lines out on a grid that has them.")
|
|
756
|
+
from .synthesis_file import read_synthesis_products
|
|
757
|
+
|
|
758
|
+
abundance = (read_synthesis_products(synthesis_path, keys=["config"]).get("config")
|
|
759
|
+
or {}).get("abundance")
|
|
760
|
+
if abundance is not None and abundance != lines.abundance:
|
|
761
|
+
raise ValueError(f"The synthesis used the abundances {abundance} and the band's lines "
|
|
762
|
+
f"{lines.abundance}; work the lines out with the synthesis's.")
|
|
763
|
+
if continuum is not None:
|
|
764
|
+
wavelength, free, two_photon = continuum
|
|
765
|
+
expected = (lines.logN_grid.size, lines.logT_grid.size, u.Quantity(wavelength).size)
|
|
766
|
+
if np.shape(two_photon) != expected or np.shape(free) != expected[1:]:
|
|
767
|
+
raise ValueError(
|
|
768
|
+
f"The continuum is shaped {np.shape(free)} and {np.shape(two_photon)}, not "
|
|
769
|
+
f"{expected[1:]} and {expected}: work it out on the band's grids.")
|
|
770
|
+
grid = u.Quantity(wavelength).to_value(u.Angstrom)
|
|
771
|
+
if grid.ndim != 1 or not np.all(np.isfinite(grid)) or np.any(np.diff(grid) <= 0):
|
|
772
|
+
raise ValueError("The continuum's wavelengths must be finite and increase.")
|
|
773
|
+
|
|
774
|
+
# Along the slit: weight of each image row in each detector column. The
|
|
775
|
+
# middle column, the middle of the scene and the slit's centre are at 0.
|
|
776
|
+
columns = np.arange(focal_plane.n_columns + 1) - 0.5
|
|
777
|
+
column_edges = focal_plane.field_angle(columns).to_value(u.arcsec)
|
|
778
|
+
sigma = (_fwhm_to_sigma(spatial_psf_fwhm(telescope, det))
|
|
779
|
+
* focal_plane.plate_scale.to_value(u.arcsec) if psf else 0.0)
|
|
780
|
+
along = _scene_weights(scene.y_edges, column_edges, sigma, extend,
|
|
781
|
+
openings) # (columns, image rows)
|
|
782
|
+
|
|
783
|
+
blur = spectral_psf if psf else None
|
|
784
|
+
temperatures = [t for t in range(scene.logT_grid.size)
|
|
785
|
+
if np.any(scene.emission_measure[:, t])
|
|
786
|
+
or (continuum is not None and np.any(scene.total_emission_measure[:, t]))]
|
|
787
|
+
solid_angle = pixel_solid_angle(focal_plane, slit_width).to_value(u.sr)
|
|
788
|
+
if continuum is not None:
|
|
789
|
+
wavelength, free, two_photon = continuum
|
|
790
|
+
continuum = (u.Quantity(wavelength).to_value(u.Angstrom), free, two_photon)
|
|
791
|
+
# The row layouts are the same at every temperature, so they are computed
|
|
792
|
+
# once, before the workers share them.
|
|
793
|
+
layouts = {ccd: _layout(focal_plane, ccd, telescope, det, u.Quantity(slit_width), blur,
|
|
794
|
+
scene.velocity, solid_angle,
|
|
795
|
+
None if continuum is None else continuum[0])
|
|
796
|
+
for ccd in CCDS}
|
|
797
|
+
context = {"scene": scene, "lines": lines, "along": along, "focal_plane": focal_plane,
|
|
798
|
+
"telescope": telescope, "det": det, "slit_width": u.Quantity(slit_width),
|
|
799
|
+
"blur": blur, "continuum": continuum, "density_index": density_index,
|
|
800
|
+
"layouts": layouts, "solid_angle": solid_angle}
|
|
801
|
+
if n_workers <= 0:
|
|
802
|
+
n_workers = (len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity")
|
|
803
|
+
else os.cpu_count() or 1)
|
|
804
|
+
n_workers = max(1, min(n_workers, len(temperatures)))
|
|
805
|
+
if n_workers > 1 and "fork" not in mp.get_all_start_methods():
|
|
806
|
+
warnings.warn("Processes cannot be forked here, so the temperatures are computed in "
|
|
807
|
+
"one process.", stacklevel=2)
|
|
808
|
+
n_workers = 1
|
|
809
|
+
shape = (focal_plane.n_columns, focal_plane.n_rows)
|
|
810
|
+
totals = tuple({ccd: np.zeros(shape) for ccd in CCDS} for _ in range(3))
|
|
811
|
+
|
|
812
|
+
def add(part):
|
|
813
|
+
for total, rates in zip(totals, part):
|
|
814
|
+
for ccd in CCDS:
|
|
815
|
+
total[ccd] += rates[ccd]
|
|
816
|
+
|
|
817
|
+
if n_workers > 1:
|
|
818
|
+
# Fork, so the workers share the scene and lines rather than being
|
|
819
|
+
# sent them. Each worker takes the next temperature when it is free,
|
|
820
|
+
# and the temperatures are added in order, so the frame does not
|
|
821
|
+
# depend on the number of workers.
|
|
822
|
+
with mp.get_context("fork").Pool(n_workers, initializer=_set_worker_context,
|
|
823
|
+
initargs=(context,)) as pool:
|
|
824
|
+
for part in pool.imap(_rates_in_worker, temperatures):
|
|
825
|
+
add(part)
|
|
826
|
+
else:
|
|
827
|
+
for t in temperatures:
|
|
828
|
+
add(_rates_at(t, context))
|
|
829
|
+
rate, per_wavelength, per_square = totals
|
|
830
|
+
|
|
831
|
+
sequence = ReadoutSequence.from_detector(det)
|
|
832
|
+
dark = dark_current_time(exposure, sequence, focal_plane.n_rows)
|
|
833
|
+
# Each sample's share of its row's dark charge along the serial register.
|
|
834
|
+
dark_image, dark_overscan = serial_dark_shares(sequence, focal_plane.n_columns)
|
|
835
|
+
dark_time = dark[:, np.newaxis] * dark_image
|
|
836
|
+
rows = np.arange(focal_plane.n_rows)
|
|
837
|
+
frame = {"rate": {}, "photons": {}, "dn": {}, "wavelength": {}, "photon_wavelength": {},
|
|
838
|
+
"photon_rms_wavelength": {}}
|
|
839
|
+
for ccd in CCDS:
|
|
840
|
+
# Far from a line, the exact part and the correction nearly cancel and
|
|
841
|
+
# can leave a rate slightly below zero. Clip at zero, as a run does
|
|
842
|
+
# before its Poisson draw.
|
|
843
|
+
image_rate = np.maximum(rate[ccd], 0.0, out=rate[ccd]).T # (rows, columns)
|
|
844
|
+
wavelength = focal_plane.wavelength(rows, ccd)
|
|
845
|
+
# Wavelengths of the mean and root mean square energies of the
|
|
846
|
+
# photons reaching each pixel. A pixel no photons reach keeps its
|
|
847
|
+
# row's wavelength.
|
|
848
|
+
own = np.broadcast_to(wavelength.to_value(u.Angstrom)[:, np.newaxis], image_rate.shape)
|
|
849
|
+
lit = image_rate > 0
|
|
850
|
+
reaching = np.divide(image_rate, per_wavelength[ccd].T, out=np.array(own, dtype=float),
|
|
851
|
+
where=lit & (per_wavelength[ccd].T > 0)) * u.Angstrom
|
|
852
|
+
reaching_rms = np.sqrt(np.divide(image_rate, per_square[ccd].T,
|
|
853
|
+
out=np.array(own, dtype=float) ** 2,
|
|
854
|
+
where=lit & (per_square[ccd].T > 0))) * u.Angstrom
|
|
855
|
+
photons, mean_wavelength, rms_wavelength = expose_with_wavelengths(
|
|
856
|
+
image_rate, reaching, exposure, sequence, rms_wavelength=reaching_rms)
|
|
857
|
+
drawn = np.random.poisson(photons).astype(float) if noise else photons
|
|
858
|
+
electrons = detect(drawn, mean_wavelength, dark_time, det, noise=noise,
|
|
859
|
+
rms_wavelength=rms_wavelength)
|
|
860
|
+
overscan_dn = None
|
|
861
|
+
if sequence.cte_serial != 1:
|
|
862
|
+
# Charge the serial register leaves behind reaches the overscan.
|
|
863
|
+
trail, trail_wavelength, trail_rms = expose_serial_overscan_with_wavelengths(
|
|
864
|
+
image_rate, reaching, exposure, sequence, rms_wavelength=reaching_rms)
|
|
865
|
+
trail = np.random.poisson(trail).astype(float) if noise else trail
|
|
866
|
+
overscan_dn = digitise(detect(trail, trail_wavelength,
|
|
867
|
+
dark[:, np.newaxis] * dark_overscan, det,
|
|
868
|
+
noise=noise, rms_wavelength=trail_rms), det)
|
|
869
|
+
frame["rate"][ccd] = image_rate
|
|
870
|
+
frame["photons"][ccd] = drawn
|
|
871
|
+
frame["dn"][ccd] = lay_out_register(digitise(electrons, det), det, noise=noise,
|
|
872
|
+
overscan_dn=overscan_dn)
|
|
873
|
+
frame["wavelength"][ccd] = wavelength
|
|
874
|
+
frame["photon_wavelength"][ccd] = mean_wavelength
|
|
875
|
+
frame["photon_rms_wavelength"][ccd] = rms_wavelength
|
|
876
|
+
return FullFrame(**frame, exposure=u.Quantity(exposure),
|
|
877
|
+
slit_position=u.Quantity(slit_position), slit_width=u.Quantity(slit_width),
|
|
878
|
+
settings={"psf": psf, "spectral_psf": spectral_psf, "noise": noise,
|
|
879
|
+
"psf_boundary": psf_boundary, "slit_mask": bool(slit_mask),
|
|
880
|
+
"continuum": continuum is not None,
|
|
881
|
+
"cells_per_row": frame_module.CELLS_PER_ROW,
|
|
882
|
+
"serial_prescan": det.serial_prescan,
|
|
883
|
+
"serial_overscan": det.serial_overscan,
|
|
884
|
+
"parallel_overscan_rows": det.parallel_overscan_rows})
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
FRAME_FORMAT = "eclipse-full-ccd-frame"
|
|
888
|
+
FRAME_VERSION = 1
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def write_full_frame(frame: FullFrame, path: str | Path) -> Path:
|
|
892
|
+
"""
|
|
893
|
+
Write a full-CCD frame to an HDF5 file.
|
|
894
|
+
|
|
895
|
+
The root holds the settings as attributes, including the prescan and
|
|
896
|
+
overscan sizes in `dn` (`serial_prescan`, `serial_overscan` and
|
|
897
|
+
`parallel_overscan_rows`). A group for each CCD, `left` and `right`,
|
|
898
|
+
holds these datasets, each with its unit:
|
|
899
|
+
|
|
900
|
+
- `dn`: frame as read out, with its prescan and overscan.
|
|
901
|
+
- `photons` and `rate`.
|
|
902
|
+
- `wavelength`: wavelength each image row records.
|
|
903
|
+
- `photon_wavelength` and `photon_rms_wavelength`: wavelengths of the
|
|
904
|
+
mean and root mean square energies of each pixel's photons.
|
|
905
|
+
|
|
906
|
+
Parameters
|
|
907
|
+
----------
|
|
908
|
+
frame : FullFrame
|
|
909
|
+
Frame to write.
|
|
910
|
+
path : str or Path
|
|
911
|
+
File to write. An existing file is replaced.
|
|
912
|
+
|
|
913
|
+
Returns
|
|
914
|
+
-------
|
|
915
|
+
Path
|
|
916
|
+
File written.
|
|
917
|
+
"""
|
|
918
|
+
from .atmosphere import _replacing
|
|
919
|
+
|
|
920
|
+
path = Path(path).expanduser()
|
|
921
|
+
with _replacing(path) as partial, h5py.File(partial, "w") as f:
|
|
922
|
+
f.attrs["format"] = FRAME_FORMAT
|
|
923
|
+
f.attrs["version"] = FRAME_VERSION
|
|
924
|
+
for name in ("exposure", "slit_position", "slit_width"):
|
|
925
|
+
value = getattr(frame, name)
|
|
926
|
+
f.attrs[name] = value.value
|
|
927
|
+
f.attrs[f"{name}_unit"] = str(value.unit)
|
|
928
|
+
for key, value in frame.settings.items():
|
|
929
|
+
f.attrs[key] = value
|
|
930
|
+
for ccd in CCDS:
|
|
931
|
+
group = f.create_group(ccd)
|
|
932
|
+
for name, data, unit in (("dn", frame.dn[ccd], "DN"),
|
|
933
|
+
("photons", frame.photons[ccd], "photon"),
|
|
934
|
+
("rate", frame.rate[ccd], "photon / s"),
|
|
935
|
+
("wavelength", frame.wavelength[ccd].to_value(u.AA),
|
|
936
|
+
"Angstrom"),
|
|
937
|
+
("photon_wavelength",
|
|
938
|
+
frame.photon_wavelength[ccd].to_value(u.AA), "Angstrom"),
|
|
939
|
+
("photon_rms_wavelength",
|
|
940
|
+
frame.photon_rms_wavelength[ccd].to_value(u.AA),
|
|
941
|
+
"Angstrom")):
|
|
942
|
+
group.create_dataset(name, data=data, compression="gzip")
|
|
943
|
+
group[name].attrs["unit"] = unit
|
|
944
|
+
return path
|