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/frame.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Laying a spectrum onto the SW detectors.
|
|
3
|
+
|
|
4
|
+
The light each CCD row receives from a solar spectrum, by the radiometric
|
|
5
|
+
equation::
|
|
6
|
+
|
|
7
|
+
photons per second per pixel = I * Omega_pix * A_eff / E_photon
|
|
8
|
+
|
|
9
|
+
with the spectral radiance `I` integrated over each row's wavelengths, as
|
|
10
|
+
the rows are not evenly spaced and the effective area changes across the
|
|
11
|
+
band. Lines are integrated between the row boundaries, so their flux is
|
|
12
|
+
kept for any sampling, and the spectral response, if given, is applied as
|
|
13
|
+
they are laid on the rows. :func:`detect` and :func:`digitise` take a
|
|
14
|
+
frame's photons to electrons and DN.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
import astropy.constants as const
|
|
22
|
+
import astropy.units as u
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from .radiometric import (
|
|
26
|
+
_dark_electrons,
|
|
27
|
+
_detected_photons,
|
|
28
|
+
_digitise,
|
|
29
|
+
_read_noise,
|
|
30
|
+
_spectral_response,
|
|
31
|
+
_vectorized_fano_noise,
|
|
32
|
+
photons_per_energy,
|
|
33
|
+
slit_image_width, # noqa: F401
|
|
34
|
+
spectral_blur_fwhm, # noqa: F401
|
|
35
|
+
spectral_line_spread, # noqa: F401
|
|
36
|
+
spectral_psf_fwhm, # noqa: F401
|
|
37
|
+
spectral_psf_reach, # noqa: F401
|
|
38
|
+
)
|
|
39
|
+
from .deprecated import apply_spectral_psf # noqa: F401 (deprecated)
|
|
40
|
+
from .readout import FocalPlane_SWC, expose, expose_serial_overscan
|
|
41
|
+
from .sampling import (_REACH_IN_SIGMA, _carry_photon_wavelengths, light_onto_pixels,
|
|
42
|
+
points_onto_pixels)
|
|
43
|
+
from .utils import angle_to_distance
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pixel_solid_angle(focal_plane: FocalPlane_SWC, slit_width: u.Quantity) -> u.Quantity:
|
|
47
|
+
"""
|
|
48
|
+
Solid angle of the Sun seen by one pixel, in steradian.
|
|
49
|
+
|
|
50
|
+
A pixel covers the plate scale along the slit and the slit width, which
|
|
51
|
+
is the raster step, across it.
|
|
52
|
+
"""
|
|
53
|
+
along_slit = angle_to_distance(focal_plane.plate_scale)
|
|
54
|
+
across = angle_to_distance(u.Quantity(slit_width))
|
|
55
|
+
return ((along_slit * across).cgs / const.au.cgs ** 2).value * u.sr
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _row_bounds(focal_plane: FocalPlane_SWC, ccd: str, column=None, margin: int = 0) -> np.ndarray:
|
|
59
|
+
"""
|
|
60
|
+
Row boundaries in Angstrom, shorter wavelength first, for the CCD's rows
|
|
61
|
+
and `margin` more past each end.
|
|
62
|
+
"""
|
|
63
|
+
if margin:
|
|
64
|
+
rows = np.arange(-margin, focal_plane.n_rows + margin + 1) - 0.5
|
|
65
|
+
edges = focal_plane.wavelength(rows, ccd, column).to_value(u.Angstrom)
|
|
66
|
+
else:
|
|
67
|
+
edges = focal_plane.row_edges(ccd, column).to_value(u.Angstrom)
|
|
68
|
+
return np.column_stack([np.minimum(edges[:-1], edges[1:]),
|
|
69
|
+
np.maximum(edges[:-1], edges[1:])])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Number of cells per row a line is laid onto, by exact integrals of its
|
|
73
|
+
# Gaussian, so the telescope throughput and photon energy are taken at each
|
|
74
|
+
# cell's wavelength rather than once for the whole line.
|
|
75
|
+
CELLS_PER_ROW = 16
|
|
76
|
+
|
|
77
|
+
# Lines are laid in blocks of this many, to bound the memory a whole band
|
|
78
|
+
# takes. The result does not depend on it.
|
|
79
|
+
_LINES_AT_ONCE = 10000
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _sorted_rows(focal_plane: FocalPlane_SWC, ccd: str, column=None, margin: int = 0):
|
|
83
|
+
"""
|
|
84
|
+
Row edges in increasing wavelength, in Angstrom, and the order that
|
|
85
|
+
sorts the rows that way.
|
|
86
|
+
"""
|
|
87
|
+
bounds = _row_bounds(focal_plane, ccd, column, margin)
|
|
88
|
+
order = np.argsort(bounds[:, 0])
|
|
89
|
+
low, high = bounds[order, 0], bounds[order, 1]
|
|
90
|
+
if not np.allclose(high[:-1], low[1:], rtol=1e-12, atol=0.0):
|
|
91
|
+
raise ValueError("The rows must meet, each starting where the one before ends.")
|
|
92
|
+
return np.append(low, high[-1]), order
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _cells(focal_plane: FocalPlane_SWC, ccd: str, column, row_edges: np.ndarray,
|
|
96
|
+
reach: float) -> np.ndarray:
|
|
97
|
+
"""
|
|
98
|
+
Cell edges, `CELLS_PER_ROW` cells to the CCD's narrowest row, covering
|
|
99
|
+
the rows and `reach` Angstrom past each end. The edges are whole steps
|
|
100
|
+
from 0 Angstrom, so extending the range does not move them.
|
|
101
|
+
"""
|
|
102
|
+
step = np.abs(np.diff(focal_plane.row_edges(ccd, column).to_value(u.Angstrom))).min()
|
|
103
|
+
step /= CELLS_PER_ROW
|
|
104
|
+
first = np.floor((row_edges[0] - reach) / step)
|
|
105
|
+
last = np.ceil((row_edges[-1] + reach) / step)
|
|
106
|
+
return step * np.arange(first, last + 1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _response(focal_plane: FocalPlane_SWC, ccd: str, column, telescope, det,
|
|
110
|
+
slit_width: u.Quantity, spectral_psf: str, wavelength: np.ndarray):
|
|
111
|
+
"""
|
|
112
|
+
Spectral response of the instrument at each `wavelength` (Angstrom).
|
|
113
|
+
|
|
114
|
+
Returns the standard deviation of the Gaussian and the width of the
|
|
115
|
+
slit's image it is convolved with, both in Angstrom. With `spectral_psf`
|
|
116
|
+
`"quadrature"` the slit is included in the Gaussian and the width is 0.
|
|
117
|
+
|
|
118
|
+
The response is set in rows, as :func:`~eclipse.radiometric._spectral_response`
|
|
119
|
+
gives it, and converted at the width of the row each wavelength falls
|
|
120
|
+
in. The row width changes by less than a part in 1e4 across the
|
|
121
|
+
response.
|
|
122
|
+
"""
|
|
123
|
+
sigma_rows, box_rows = _spectral_response(telescope, det, slit_width, spectral_psf)
|
|
124
|
+
bounds = _row_bounds(focal_plane, ccd, column)
|
|
125
|
+
centres = bounds.mean(axis=1)
|
|
126
|
+
order = np.argsort(centres)
|
|
127
|
+
row_width = np.interp(wavelength, centres[order], (bounds[:, 1] - bounds[:, 0])[order])
|
|
128
|
+
return sigma_rows * row_width, box_rows * row_width
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _intervals_onto_rows(bounds: np.ndarray, low: np.ndarray, high: np.ndarray,
|
|
132
|
+
light: np.ndarray, sigma: np.ndarray, box: np.ndarray) -> np.ndarray:
|
|
133
|
+
"""
|
|
134
|
+
Light in each row of `bounds` from intervals `low` to `high`.
|
|
135
|
+
|
|
136
|
+
Each interval holds `light` spread evenly over it, blurred by a Gaussian
|
|
137
|
+
of standard deviation `sigma` convolved with a rectangle `box` wide, and
|
|
138
|
+
integrated exactly over the rows. Only the rows the blur reaches from an
|
|
139
|
+
interval are computed.
|
|
140
|
+
"""
|
|
141
|
+
order = np.argsort(bounds[:, 0])
|
|
142
|
+
row_low, row_high = bounds[order, 0], bounds[order, 1]
|
|
143
|
+
if not np.allclose(row_high[:-1], row_low[1:], rtol=1e-12, atol=0.0):
|
|
144
|
+
raise ValueError("The rows must meet, each starting where the one before ends.")
|
|
145
|
+
share = light_onto_pixels(low, high, np.append(row_low, row_high[-1]), sigma, box)
|
|
146
|
+
rows = np.zeros(len(bounds))
|
|
147
|
+
rows[order] = share @ np.asarray(light, dtype=float)
|
|
148
|
+
return rows
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _check_margin(margin: int, lit_only: bool) -> None:
|
|
152
|
+
if margin < 0:
|
|
153
|
+
raise ValueError(f"margin is a number of rows, and cannot be negative, got {margin}.")
|
|
154
|
+
if margin and lit_only:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
"A margin is for the spectral blur, and the baffle comes after it: "
|
|
157
|
+
"lay the spectrum with lit_only=False, blur it, and empty the dark "
|
|
158
|
+
"rows then."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _photons_per_energy(telescope, wavelength: u.Quantity) -> u.Quantity:
|
|
163
|
+
"""
|
|
164
|
+
:func:`~eclipse.radiometric.photons_per_energy` at each wavelength.
|
|
165
|
+
|
|
166
|
+
Raises ValueError for a wavelength outside the telescope's throughput
|
|
167
|
+
tables, where it has no effective area: a single one would turn every
|
|
168
|
+
row of the frame to NaN through the spectral blur.
|
|
169
|
+
"""
|
|
170
|
+
wavelength = np.atleast_1d(u.Quantity(wavelength))
|
|
171
|
+
collected = photons_per_energy(telescope, wavelength)
|
|
172
|
+
if not np.all(np.isfinite(collected)):
|
|
173
|
+
outside = wavelength[~np.isfinite(collected)].to_value(u.Angstrom)
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"The telescope has no effective area at {outside.min():.4f} to "
|
|
176
|
+
f"{outside.max():.4f} Angstrom ({outside.size} wavelengths); the "
|
|
177
|
+
f"spectrum must stay within its throughput tables."
|
|
178
|
+
)
|
|
179
|
+
return collected
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def photons_from_lines(focal_plane: FocalPlane_SWC, ccd: str, telescope,
|
|
183
|
+
slit_width: u.Quantity, wavelengths: u.Quantity,
|
|
184
|
+
intensities: u.Quantity, widths: u.Quantity,
|
|
185
|
+
column=None, lit_only: bool = True, margin: int = 0, *,
|
|
186
|
+
det=None, spectral_psf: Optional[str] = None) -> u.Quantity:
|
|
187
|
+
"""
|
|
188
|
+
Photons per second in each row of one CCD from a list of emission lines.
|
|
189
|
+
|
|
190
|
+
Each line is split between cells, `CELLS_PER_ROW` to a row, by exact
|
|
191
|
+
integrals of its Gaussian. Each cell's light is converted to photons at
|
|
192
|
+
the cell's wavelength, with the telescope's effective area there. With
|
|
193
|
+
`spectral_psf`, the light is blurred by the instrument's spectral
|
|
194
|
+
response as it is laid onto the rows, so a line narrower than a row
|
|
195
|
+
stays where it is. Blurring the rows afterwards with
|
|
196
|
+
:func:`apply_spectral_psf` moves it toward the middle of its row.
|
|
197
|
+
|
|
198
|
+
Parameters
|
|
199
|
+
----------
|
|
200
|
+
focal_plane : FocalPlane_SWC
|
|
201
|
+
Wavelength each row records.
|
|
202
|
+
ccd : str
|
|
203
|
+
`'left'` or `'right'`.
|
|
204
|
+
telescope : Telescope_EUVST
|
|
205
|
+
Gives the effective area, for an array of wavelengths at once.
|
|
206
|
+
slit_width : u.Quantity
|
|
207
|
+
Width of the slit the light came through, as an angle.
|
|
208
|
+
wavelengths : u.Quantity
|
|
209
|
+
Rest wavelengths of the lines.
|
|
210
|
+
intensities : u.Quantity
|
|
211
|
+
Spectrally integrated radiance of each line, in erg / (s cm2 sr).
|
|
212
|
+
widths : u.Quantity
|
|
213
|
+
Gaussian 1-sigma width of each line as emitted, as a wavelength:
|
|
214
|
+
thermal plus any non-thermal broadening, without the instrument's
|
|
215
|
+
spectral response.
|
|
216
|
+
column : int, optional
|
|
217
|
+
Column along the slit, for a focal plane whose lines drift along it.
|
|
218
|
+
Ignored otherwise.
|
|
219
|
+
lit_only : bool
|
|
220
|
+
Zero the rows the baffle keeps dark. They still pass charge, so the
|
|
221
|
+
smear model needs them present but empty. Only for the deprecated
|
|
222
|
+
:func:`apply_spectral_psf`, leave this off, blur, and empty the dark
|
|
223
|
+
rows afterwards.
|
|
224
|
+
margin : int
|
|
225
|
+
Rows to add past each end of the CCD, only for the deprecated
|
|
226
|
+
:func:`apply_spectral_psf`. Needs `lit_only=False`. Not needed with
|
|
227
|
+
`spectral_psf`, which blurs the light of a line off the chip onto
|
|
228
|
+
the edge rows as it is laid.
|
|
229
|
+
det : Detector_SWC, optional
|
|
230
|
+
Detector, for the spectral response in rows. Needed with
|
|
231
|
+
`spectral_psf`.
|
|
232
|
+
spectral_psf : str, optional
|
|
233
|
+
`"quadrature"` or `"convolution"`, as in the configuration: blur the
|
|
234
|
+
lines with the spectral response of
|
|
235
|
+
:func:`~eclipse.radiometric.apply_focusing_optics_psf` for a
|
|
236
|
+
synthesis through the same slit. None, the default, lays the lines
|
|
237
|
+
as emitted.
|
|
238
|
+
|
|
239
|
+
Returns
|
|
240
|
+
-------
|
|
241
|
+
u.Quantity
|
|
242
|
+
Photons per second per pixel, one per row, plus `margin` more past
|
|
243
|
+
each end.
|
|
244
|
+
"""
|
|
245
|
+
_check_margin(margin, lit_only)
|
|
246
|
+
if spectral_psf is not None and det is None:
|
|
247
|
+
raise ValueError("The spectral response is in detector rows: give det with "
|
|
248
|
+
"spectral_psf.")
|
|
249
|
+
wavelengths = np.atleast_1d(u.Quantity(wavelengths).to(u.Angstrom))
|
|
250
|
+
intensities = np.atleast_1d(u.Quantity(intensities).to(u.erg / (u.s * u.cm**2 * u.sr)))
|
|
251
|
+
widths = np.atleast_1d(u.Quantity(widths).to(u.Angstrom))
|
|
252
|
+
if not (len(wavelengths) == len(intensities) == len(widths)):
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"Each line needs a wavelength, an intensity and a width, got "
|
|
255
|
+
f"{len(wavelengths)}, {len(intensities)} and {len(widths)}."
|
|
256
|
+
)
|
|
257
|
+
if np.any(widths.value <= 0):
|
|
258
|
+
raise ValueError("Line widths must be positive.")
|
|
259
|
+
|
|
260
|
+
# A line outside the telescope's throughput tables is an error, as is a
|
|
261
|
+
# spectrum reaching beyond them.
|
|
262
|
+
at_centre = _photons_per_energy(telescope, wavelengths).to_value(u.cm**2 / u.erg)
|
|
263
|
+
edges, order = _sorted_rows(focal_plane, ccd, column, margin)
|
|
264
|
+
centre, width = wavelengths.to_value(u.Angstrom), widths.to_value(u.Angstrom)
|
|
265
|
+
light = (intensities * pixel_solid_angle(focal_plane, slit_width)).to_value(
|
|
266
|
+
u.erg / (u.s * u.cm**2))
|
|
267
|
+
if spectral_psf is None:
|
|
268
|
+
psf_sigma = box = np.zeros(centre.size)
|
|
269
|
+
else:
|
|
270
|
+
psf_sigma, box = _response(focal_plane, ccd, column, telescope, det, slit_width,
|
|
271
|
+
spectral_psf, centre)
|
|
272
|
+
reach = (_REACH_IN_SIGMA * psf_sigma.max() + box.max()
|
|
273
|
+
if spectral_psf is not None and centre.size else 0.0)
|
|
274
|
+
cells = _cells(focal_plane, ccd, column, edges, reach)
|
|
275
|
+
sorted_rows = np.zeros(edges.size - 1)
|
|
276
|
+
in_cells, at_centres = np.zeros(cells.size - 1), np.zeros(cells.size - 1)
|
|
277
|
+
for start in range(0, centre.size, _LINES_AT_ONCE):
|
|
278
|
+
block = slice(start, start + _LINES_AT_ONCE)
|
|
279
|
+
collected = light[block] * at_centre[block]
|
|
280
|
+
# Each line blurred by the response is a Gaussian of the two widths
|
|
281
|
+
# in quadrature, convolved with the slit's image for "convolution".
|
|
282
|
+
# Share it between the rows by exact integrals, in photons at the
|
|
283
|
+
# line centre's wavelength.
|
|
284
|
+
sorted_rows += points_onto_pixels(
|
|
285
|
+
centre[block], edges, np.hypot(width[block], psf_sigma[block]), box[block]
|
|
286
|
+
) @ collected
|
|
287
|
+
masses = points_onto_pixels(centre[block], cells, width[block])
|
|
288
|
+
in_cells += masses @ light[block]
|
|
289
|
+
at_centres += masses @ collected
|
|
290
|
+
# Then correct, cell by cell across each line, for the throughput and
|
|
291
|
+
# photon energy at each cell's wavelength rather than the line centre's,
|
|
292
|
+
# with the same blur as the line.
|
|
293
|
+
lit = np.flatnonzero(in_cells > 0)
|
|
294
|
+
low, high = cells[lit], cells[lit + 1]
|
|
295
|
+
middle = (low + high) / 2
|
|
296
|
+
own = np.nan_to_num(photons_per_energy(telescope, middle * u.Angstrom).to_value(
|
|
297
|
+
u.cm**2 / u.erg))
|
|
298
|
+
change = in_cells[lit] * own - at_centres[lit]
|
|
299
|
+
if spectral_psf is None:
|
|
300
|
+
sigma = cell_box = 0.0
|
|
301
|
+
else:
|
|
302
|
+
sigma, cell_box = _response(focal_plane, ccd, column, telescope, det, slit_width,
|
|
303
|
+
spectral_psf, middle)
|
|
304
|
+
sorted_rows += light_onto_pixels(low, high, edges, sigma, cell_box) @ change
|
|
305
|
+
# Far from a line, the exact part and the correction nearly cancel and
|
|
306
|
+
# can leave a row slightly below zero, which no light can be.
|
|
307
|
+
np.maximum(sorted_rows, 0.0, out=sorted_rows)
|
|
308
|
+
rows = np.zeros(edges.size - 1)
|
|
309
|
+
rows[order] = sorted_rows
|
|
310
|
+
|
|
311
|
+
if lit_only:
|
|
312
|
+
first, last = focal_plane.lit_rows(ccd, column)
|
|
313
|
+
rows[:first] = 0.0
|
|
314
|
+
rows[last + 1:] = 0.0
|
|
315
|
+
return rows / u.s
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def photons_from_spectrum(focal_plane: FocalPlane_SWC, ccd: str, telescope,
|
|
319
|
+
slit_width: u.Quantity, wavelength: u.Quantity,
|
|
320
|
+
radiance: u.Quantity, column=None,
|
|
321
|
+
lit_only: bool = True, margin: int = 0, *,
|
|
322
|
+
det=None, spectral_psf: Optional[str] = None) -> u.Quantity:
|
|
323
|
+
"""
|
|
324
|
+
Photons per second in each row of one CCD from a continuous spectrum.
|
|
325
|
+
|
|
326
|
+
Use this for a continuum, or anything already sampled on a wavelength
|
|
327
|
+
grid. The grid must be finer than a row, since the radiance is
|
|
328
|
+
integrated between the row boundaries by the trapezium rule on it: each
|
|
329
|
+
grid interval holds its trapezium-rule light, spread evenly over it.
|
|
330
|
+
With `spectral_psf` that light is blurred by the instrument's spectral
|
|
331
|
+
response as it is laid onto the rows, as in :func:`photons_from_lines`.
|
|
332
|
+
|
|
333
|
+
Parameters
|
|
334
|
+
----------
|
|
335
|
+
wavelength : u.Quantity
|
|
336
|
+
Grid the spectrum is sampled on, increasing.
|
|
337
|
+
radiance : u.Quantity
|
|
338
|
+
Spectral radiance per unit wavelength, in erg / (s cm2 sr Angstrom).
|
|
339
|
+
|
|
340
|
+
Other parameters as for :func:`photons_from_lines`.
|
|
341
|
+
|
|
342
|
+
Returns
|
|
343
|
+
-------
|
|
344
|
+
u.Quantity
|
|
345
|
+
Photons per second per pixel, one per row, plus `margin` more past
|
|
346
|
+
each end.
|
|
347
|
+
"""
|
|
348
|
+
_check_margin(margin, lit_only)
|
|
349
|
+
if spectral_psf is not None and det is None:
|
|
350
|
+
raise ValueError("The spectral response is in detector rows: give det with "
|
|
351
|
+
"spectral_psf.")
|
|
352
|
+
wavelength = u.Quantity(wavelength).to(u.Angstrom)
|
|
353
|
+
radiance = u.Quantity(radiance).to(u.erg / (u.s * u.cm**2 * u.sr * u.Angstrom))
|
|
354
|
+
if wavelength.size != radiance.size:
|
|
355
|
+
raise ValueError(
|
|
356
|
+
f"The spectrum needs one radiance per wavelength, got "
|
|
357
|
+
f"{radiance.size} for {wavelength.size}."
|
|
358
|
+
)
|
|
359
|
+
if np.any(np.diff(wavelength.value) <= 0):
|
|
360
|
+
raise ValueError("The wavelength grid must increase.")
|
|
361
|
+
|
|
362
|
+
solid_angle = pixel_solid_angle(focal_plane, slit_width)
|
|
363
|
+
# Photons per second per pixel per Angstrom, on the input grid.
|
|
364
|
+
density = (radiance * solid_angle * _photons_per_energy(telescope, wavelength)).to_value(
|
|
365
|
+
1 / (u.s * u.Angstrom))
|
|
366
|
+
|
|
367
|
+
grid = wavelength.to_value(u.Angstrom)
|
|
368
|
+
bounds = _row_bounds(focal_plane, ccd, column, margin)
|
|
369
|
+
low, high = grid[:-1], grid[1:]
|
|
370
|
+
if spectral_psf is None:
|
|
371
|
+
sigma = box = np.zeros(low.size)
|
|
372
|
+
else:
|
|
373
|
+
sigma, box = _response(focal_plane, ccd, column, telescope, det, slit_width,
|
|
374
|
+
spectral_psf, (low + high) / 2)
|
|
375
|
+
rows = _intervals_onto_rows(bounds, low, high, np.diff(grid) * (density[1:] + density[:-1]) / 2,
|
|
376
|
+
sigma, box)
|
|
377
|
+
|
|
378
|
+
if lit_only:
|
|
379
|
+
first, last = focal_plane.lit_rows(ccd, column)
|
|
380
|
+
rows[:first] = 0.0
|
|
381
|
+
rows[last + 1:] = 0.0
|
|
382
|
+
return rows / u.s
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def thermal_width(wavelength: u.Quantity, temperature: u.Quantity,
|
|
386
|
+
atomic_weight: u.Quantity,
|
|
387
|
+
non_thermal: Optional[u.Quantity] = None) -> u.Quantity:
|
|
388
|
+
"""
|
|
389
|
+
Gaussian 1-sigma width of a line, as a wavelength.
|
|
390
|
+
|
|
391
|
+
Thermal motion at `temperature` for an ion of `atomic_weight`, with the
|
|
392
|
+
optional non-thermal speed `non_thermal` added in quadrature.
|
|
393
|
+
"""
|
|
394
|
+
speed = np.sqrt(const.k_B * u.Quantity(temperature) / u.Quantity(atomic_weight).to(u.g))
|
|
395
|
+
if non_thermal is not None:
|
|
396
|
+
speed = np.sqrt(speed**2 + u.Quantity(non_thermal).to(u.cm / u.s)**2)
|
|
397
|
+
return (u.Quantity(wavelength) * speed / const.c).to(u.Angstrom)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def expose_with_wavelength(rate: np.ndarray, wavelength: u.Quantity, exposure: u.Quantity,
|
|
401
|
+
sequence) -> tuple:
|
|
402
|
+
"""
|
|
403
|
+
Photons in each pixel of a frame and the wavelength of their mean energy.
|
|
404
|
+
|
|
405
|
+
The photons are as from :func:`~eclipse.readout.expose`. This is
|
|
406
|
+
:func:`expose_with_wavelengths` without the spread of photon energies,
|
|
407
|
+
which :func:`detect` also takes.
|
|
408
|
+
|
|
409
|
+
Parameters
|
|
410
|
+
----------
|
|
411
|
+
rate : np.ndarray
|
|
412
|
+
Photons per second reaching each pixel of one CCD, `(n_rows, n_columns)`.
|
|
413
|
+
wavelength : u.Quantity
|
|
414
|
+
Wavelength of the mean energy of the photons reaching each row (one
|
|
415
|
+
per row) or each pixel (same shape as `rate`).
|
|
416
|
+
exposure, sequence
|
|
417
|
+
As for `expose`.
|
|
418
|
+
|
|
419
|
+
Returns
|
|
420
|
+
-------
|
|
421
|
+
photons : np.ndarray
|
|
422
|
+
Photons per pixel, `(n_rows + parallel_overscan_rows, n_columns)`.
|
|
423
|
+
wavelength : u.Quantity
|
|
424
|
+
Wavelength of their mean energy, same shape.
|
|
425
|
+
"""
|
|
426
|
+
photons, mean, _ = expose_with_wavelengths(rate, wavelength, exposure, sequence)
|
|
427
|
+
return photons, mean
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def expose_with_wavelengths(rate: np.ndarray, wavelength: u.Quantity, exposure: u.Quantity,
|
|
431
|
+
sequence, rms_wavelength: Optional[u.Quantity] = None) -> tuple:
|
|
432
|
+
"""
|
|
433
|
+
Photons in each pixel of a frame and the wavelengths of their mean and
|
|
434
|
+
root mean square energies.
|
|
435
|
+
|
|
436
|
+
The photons are as from :func:`~eclipse.readout.expose`. Without
|
|
437
|
+
a shutter a pixel holds photons from every row its charge crossed.
|
|
438
|
+
:func:`detect` needs their mean energy to convert them to electrons, and
|
|
439
|
+
their root mean square energy for the spread in electrons from photons
|
|
440
|
+
of different energies. `expose` is linear in the rate, so exposing the
|
|
441
|
+
rate weighted by the energy, and by its square, gives each pixel's total
|
|
442
|
+
energy and squared energy; dividing by the photons gives the means. A
|
|
443
|
+
pixel with no photons keeps its given wavelength, and a parallel
|
|
444
|
+
overscan pixel with none takes the last image row's.
|
|
445
|
+
|
|
446
|
+
Parameters
|
|
447
|
+
----------
|
|
448
|
+
rate : np.ndarray
|
|
449
|
+
Photons per second reaching each pixel of one CCD, `(n_rows, n_columns)`.
|
|
450
|
+
wavelength : u.Quantity
|
|
451
|
+
Wavelength of the mean energy of the photons reaching each row (one
|
|
452
|
+
per row) or each pixel (same shape as `rate`).
|
|
453
|
+
exposure, sequence
|
|
454
|
+
As for `expose`.
|
|
455
|
+
rms_wavelength : u.Quantity, optional
|
|
456
|
+
Wavelength of the root mean square energy of the photons reaching
|
|
457
|
+
each row or pixel, shaped as `wavelength`. Default `wavelength`, for
|
|
458
|
+
photons of one energy.
|
|
459
|
+
|
|
460
|
+
Returns
|
|
461
|
+
-------
|
|
462
|
+
photons : np.ndarray
|
|
463
|
+
Photons per pixel, `(n_rows + parallel_overscan_rows, n_columns)`.
|
|
464
|
+
wavelength, rms_wavelength : u.Quantity
|
|
465
|
+
Wavelengths of their mean and root mean square energies, same shape.
|
|
466
|
+
"""
|
|
467
|
+
rate = np.asarray(rate, dtype=float)
|
|
468
|
+
if rate.ndim != 2:
|
|
469
|
+
raise ValueError(f"A frame has two axes, rows and columns, not {rate.ndim}.")
|
|
470
|
+
own = _per_pixel(wavelength, rate.shape)
|
|
471
|
+
rms = own if rms_wavelength is None else _per_pixel(rms_wavelength, rate.shape)
|
|
472
|
+
n_more = sequence.parallel_overscan_rows
|
|
473
|
+
fallback = np.concatenate([own, np.repeat(own[-1:], n_more, axis=0)])
|
|
474
|
+
photons, mean, rms = _carry_photon_wavelengths(
|
|
475
|
+
lambda values: expose(values, exposure, sequence), rate, own, rms, fallback)
|
|
476
|
+
return photons, mean * u.Angstrom, rms * u.Angstrom
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def expose_serial_overscan_with_wavelengths(rate: np.ndarray, wavelength: u.Quantity,
|
|
480
|
+
exposure: u.Quantity, sequence,
|
|
481
|
+
rms_wavelength: Optional[u.Quantity] = None) -> tuple:
|
|
482
|
+
"""
|
|
483
|
+
Photons the serial register carries into the overscan, with the
|
|
484
|
+
wavelengths of their mean and root mean square energies.
|
|
485
|
+
|
|
486
|
+
As :func:`expose_with_wavelengths`, for the samples of
|
|
487
|
+
:func:`~eclipse.readout.expose_serial_overscan`. A sample no
|
|
488
|
+
photons reach takes the wavelength of the image pixel next to it.
|
|
489
|
+
|
|
490
|
+
Returns
|
|
491
|
+
-------
|
|
492
|
+
photons : np.ndarray
|
|
493
|
+
Photons per sample, `(n_rows + parallel_overscan_rows, 2 * serial_overscan)`.
|
|
494
|
+
wavelength, rms_wavelength : u.Quantity
|
|
495
|
+
Wavelengths of their mean and root mean square energies, same shape.
|
|
496
|
+
"""
|
|
497
|
+
rate = np.asarray(rate, dtype=float)
|
|
498
|
+
if rate.ndim != 2:
|
|
499
|
+
raise ValueError(f"A frame has two axes, rows and columns, not {rate.ndim}.")
|
|
500
|
+
own = _per_pixel(wavelength, rate.shape)
|
|
501
|
+
rms = own if rms_wavelength is None else _per_pixel(rms_wavelength, rate.shape)
|
|
502
|
+
n_more, overscan = sequence.parallel_overscan_rows, sequence.serial_overscan
|
|
503
|
+
# The image columns next to each half's overscan. A CCD one column wide
|
|
504
|
+
# has only the first half, whose column stands in for both.
|
|
505
|
+
half = (rate.shape[1] + 1) // 2
|
|
506
|
+
edges = np.concatenate([own, np.repeat(own[-1:], n_more, axis=0)])[
|
|
507
|
+
:, [half - 1, min(half, rate.shape[1] - 1)]]
|
|
508
|
+
fallback = np.repeat(edges, overscan, axis=1)
|
|
509
|
+
photons, mean, rms = _carry_photon_wavelengths(
|
|
510
|
+
lambda values: expose_serial_overscan(values, exposure, sequence), rate, own, rms,
|
|
511
|
+
fallback)
|
|
512
|
+
return photons, mean * u.Angstrom, rms * u.Angstrom
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _per_pixel(wavelength: u.Quantity, shape: tuple) -> np.ndarray:
|
|
516
|
+
"""Wavelengths per row or per pixel, broadcast to a frame of `shape`, in Angstrom."""
|
|
517
|
+
values = np.asarray(u.Quantity(wavelength).to_value(u.Angstrom), dtype=float)
|
|
518
|
+
if values.shape not in ((shape[0],), shape):
|
|
519
|
+
raise ValueError(
|
|
520
|
+
f"One wavelength per row or per pixel: got {values.shape} for a frame of "
|
|
521
|
+
f"{shape}."
|
|
522
|
+
)
|
|
523
|
+
return np.broadcast_to(values[:, np.newaxis] if values.ndim == 1 else values, shape)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def detect(photons: np.ndarray, wavelength: u.Quantity, dark_time: u.Quantity, det,
|
|
527
|
+
*, noise: bool = True, rms_wavelength: Optional[u.Quantity] = None) -> np.ndarray:
|
|
528
|
+
"""
|
|
529
|
+
Electrons per pixel from the photons a frame recorded.
|
|
530
|
+
|
|
531
|
+
The stages are those of :func:`eclipse.radiometric.to_electrons`:
|
|
532
|
+
quantum efficiency, electrons per photon with their Fano spread, dark
|
|
533
|
+
current and read noise. A frame spans the band, so each row has its own
|
|
534
|
+
photon energy. Without a shutter each row waits a different time to be
|
|
535
|
+
read out, so each row has its own dark current.
|
|
536
|
+
|
|
537
|
+
Parameters
|
|
538
|
+
----------
|
|
539
|
+
photons : np.ndarray
|
|
540
|
+
Photons recorded per pixel, shape (rows, columns). With `noise` on,
|
|
541
|
+
these must be whole numbers, as from a Poisson draw.
|
|
542
|
+
wavelength : u.Quantity
|
|
543
|
+
Wavelength of the photons, one per row or one per pixel (same shape
|
|
544
|
+
as `photons`). Without a shutter a pixel holds photons from every
|
|
545
|
+
row its charge crossed; per pixel, give the wavelength of their mean
|
|
546
|
+
energy, as from :func:`expose_with_wavelengths`.
|
|
547
|
+
dark_time : u.Quantity
|
|
548
|
+
Time each row collects dark current, one per row or one for all, as
|
|
549
|
+
from :func:`eclipse.readout.dark_current_time`, or one per
|
|
550
|
+
pixel, same shape as `photons`.
|
|
551
|
+
det : Detector_SWC
|
|
552
|
+
Detector.
|
|
553
|
+
noise : bool
|
|
554
|
+
If False, every random draw is replaced by its mean.
|
|
555
|
+
rms_wavelength : u.Quantity, optional
|
|
556
|
+
Wavelength of the root mean square energy of the photons in each row
|
|
557
|
+
or pixel, shaped as `wavelength`, as from
|
|
558
|
+
:func:`expose_with_wavelengths`. Photons of different energies free
|
|
559
|
+
different numbers of electrons, which widens the spread of a pixel's
|
|
560
|
+
electrons. Default `wavelength`, for photons of one energy.
|
|
561
|
+
|
|
562
|
+
Returns
|
|
563
|
+
-------
|
|
564
|
+
np.ndarray
|
|
565
|
+
Electrons per pixel, same shape as `photons`.
|
|
566
|
+
"""
|
|
567
|
+
photons = np.asarray(photons, dtype=float)
|
|
568
|
+
if photons.ndim != 2:
|
|
569
|
+
raise ValueError(f"A frame has two axes, rows and columns, not {photons.ndim}.")
|
|
570
|
+
wavelength = _per_pixel(wavelength, photons.shape)
|
|
571
|
+
rms = wavelength if rms_wavelength is None else _per_pixel(rms_wavelength, photons.shape)
|
|
572
|
+
if noise and not np.all(np.mod(photons, 1) == 0):
|
|
573
|
+
raise ValueError("With noise on the photons must be whole numbers, as from a Poisson draw.")
|
|
574
|
+
detected = _detected_photons(photons, det, noise=noise)
|
|
575
|
+
electrons = _vectorized_fano_noise(detected, wavelength * u.Angstrom, det, noise=noise,
|
|
576
|
+
rms_wavelength=rms * u.Angstrom)
|
|
577
|
+
|
|
578
|
+
dark = (det.dark_current * u.Quantity(dark_time)).to_value(u.electron / u.pixel)
|
|
579
|
+
if np.ndim(dark) == 1:
|
|
580
|
+
dark = np.reshape(dark, (-1, 1))
|
|
581
|
+
electrons = electrons + _dark_electrons(dark, photons.shape, noise=noise)
|
|
582
|
+
if noise:
|
|
583
|
+
electrons = electrons + _read_noise(photons.shape, det)
|
|
584
|
+
# Not clipped at zero, as a CCD reads out above a bias level; see
|
|
585
|
+
# radiometric.to_electrons.
|
|
586
|
+
return electrons
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def digitise(electrons: np.ndarray, det) -> np.ndarray:
|
|
590
|
+
"""
|
|
591
|
+
DN per pixel: electrons divided by the gain, rounded and clipped at the
|
|
592
|
+
detector's maximum, as in :func:`eclipse.radiometric.to_dn`.
|
|
593
|
+
"""
|
|
594
|
+
return _digitise(electrons, det)
|