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/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