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/readout.py
ADDED
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Read-out of the SW CCDs, with and without a shutter.
|
|
3
|
+
|
|
4
|
+
The SW focal plane is two Teledyne e2v CCD42-40s, 2048 x 2048 pixels of
|
|
5
|
+
13.5 micron, butted along the dispersion with a 1 mm gap. Each serial
|
|
6
|
+
register is on an outer edge, so a CCD row is one wavelength and row 0 is
|
|
7
|
+
the row next to the register.
|
|
8
|
+
|
|
9
|
+
A frame is a dump of the image area, the exposure, and a row-by-row
|
|
10
|
+
read-out, in which rows outside the read-out windows are dumped
|
|
11
|
+
(SOLC-EUVST-MSSL-ICD-0003 v3.1 {SWI-IRD}-032). Without a shutter, each
|
|
12
|
+
charge packet also collects light from every row it crosses on its way to
|
|
13
|
+
the register (:func:`smear_photons`).
|
|
14
|
+
|
|
15
|
+
Each transfer leaves a fraction 1 - CTE of a packet's charge to the packet
|
|
16
|
+
behind it, along the columns (`cte_parallel`) and along the serial register
|
|
17
|
+
(`cte_serial`); :func:`transfer_probabilities` gives where the charge ends
|
|
18
|
+
up.
|
|
19
|
+
|
|
20
|
+
Sources
|
|
21
|
+
-------
|
|
22
|
+
RSC-2022021C SOLAR-C EUVST Optical Design Summary (ver.20230909): focal plane
|
|
23
|
+
layout, wavelength coverage and dispersion.
|
|
24
|
+
SOLC-EUVST-MSSL-SP-0001 v1.4: CCD format, registers and dump drain.
|
|
25
|
+
SOLC-EUVST-MSSL-RS-0002 v2.0: windowing, the dump-and-wait sequence, and the
|
|
26
|
+
500 ns pixel period.
|
|
27
|
+
SOLC-EUVST-MSSL-ICD-0003 v3.1: the same sequence at the FEE to SEB interface.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import warnings
|
|
33
|
+
from dataclasses import dataclass, field, replace
|
|
34
|
+
from typing import List, Sequence, Tuple
|
|
35
|
+
|
|
36
|
+
import astropy.units as u
|
|
37
|
+
import numpy as np
|
|
38
|
+
from scipy.optimize import brentq
|
|
39
|
+
from scipy.signal import fftconvolve
|
|
40
|
+
from scipy.special import gammaln
|
|
41
|
+
|
|
42
|
+
# The focal plane constants are set in config and can be imported from here too.
|
|
43
|
+
from .config import (DISPERSION_COEFFICIENTS, MEASURED_SLIT_IMAGE_TILT, # noqa: F401
|
|
44
|
+
TILT_REFERENCE_ANGLE, Detector_SWC, _check_focal_plane)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class FocalPlane_SWC:
|
|
49
|
+
"""
|
|
50
|
+
Geometry of the SW focal plane, and the wavelength each row records.
|
|
51
|
+
|
|
52
|
+
Every setting defaults to that of :class:`eclipse.config.Detector_SWC`,
|
|
53
|
+
and :meth:`from_detector` takes them from a configured detector.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
n_rows, n_columns : int
|
|
58
|
+
Pixels along the dispersion and along the slit, per CCD.
|
|
59
|
+
pixel_size : u.Quantity
|
|
60
|
+
Pixel pitch.
|
|
61
|
+
gap : u.Quantity
|
|
62
|
+
Space between the two imaging areas.
|
|
63
|
+
dispersion : tuple of float
|
|
64
|
+
Coefficients of the wavelength scale, lowest order first, in Angstrom
|
|
65
|
+
for a position in mm. See
|
|
66
|
+
:data:`~eclipse.config.DISPERSION_COEFFICIENTS`.
|
|
67
|
+
wavelength_offset : u.Quantity
|
|
68
|
+
Added to every row's wavelength. The design positions are good to
|
|
69
|
+
about a row, but the camera's alignment to the beam is quoted at
|
|
70
|
+
+/-0.79 Angstrom, some 47 rows (SOLC-EUVST-MSSL-RP-0007 v1.2 p45), so
|
|
71
|
+
an as-built or in-flight wavelength calibration goes here.
|
|
72
|
+
lit_band : tuple of u.Quantity
|
|
73
|
+
Wavelengths between which light reaches the detectors; outside them a
|
|
74
|
+
baffle vignettes the beam. Rows outside this band record nothing, but
|
|
75
|
+
charge still crosses them on its way out.
|
|
76
|
+
plate_scale : u.Quantity
|
|
77
|
+
Angle a pixel covers along the slit.
|
|
78
|
+
slit_image_tilt : tuple of float
|
|
79
|
+
Drift of a line along the dispersion between the field centre and
|
|
80
|
+
140 arcsec from it, in rows: a linear and a quadratic term. The
|
|
81
|
+
default `(0.0, 0.0)` puts each line on one row along the whole slit,
|
|
82
|
+
and the methods then ignore their `column` argument. Pass
|
|
83
|
+
:data:`~eclipse.config.MEASURED_SLIT_IMAGE_TILT` to model the
|
|
84
|
+
drift in the optical design, about two rows end to end. Which end of
|
|
85
|
+
the slit it runs toward is not documented, so the sign may need
|
|
86
|
+
flipping against a calibration. `full_ccd_frame` does not yet take a
|
|
87
|
+
tilted slit image.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
n_rows: int = Detector_SWC.n_rows
|
|
91
|
+
n_columns: int = Detector_SWC.n_columns
|
|
92
|
+
pixel_size: u.Quantity = (Detector_SWC.pix_size * u.pixel).to(u.micron)
|
|
93
|
+
gap: u.Quantity = Detector_SWC.ccd_gap
|
|
94
|
+
dispersion: Tuple[float, ...] = Detector_SWC.dispersion
|
|
95
|
+
wavelength_offset: u.Quantity = Detector_SWC.wavelength_offset
|
|
96
|
+
lit_band: Tuple[u.Quantity, u.Quantity] = Detector_SWC.lit_band
|
|
97
|
+
plate_scale: u.Quantity = (Detector_SWC.plate_scale_angle * u.pixel).to(u.arcsec)
|
|
98
|
+
slit_image_tilt: Tuple[float, float] = Detector_SWC.slit_image_tilt
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_detector(cls, det: Detector_SWC, **settings) -> "FocalPlane_SWC":
|
|
102
|
+
"""
|
|
103
|
+
Focal plane of the camera `det`.
|
|
104
|
+
|
|
105
|
+
Every setting comes from `det`. `dispersion`, `wavelength_offset`,
|
|
106
|
+
`lit_band` and `slit_image_tilt` given in `settings` replace the
|
|
107
|
+
detector's. The CCD format, pixel size, gap and plate scale are
|
|
108
|
+
refused in `settings`, so they are set in one place, the detector.
|
|
109
|
+
"""
|
|
110
|
+
camera = dict(n_rows=det.n_rows, n_columns=det.n_columns,
|
|
111
|
+
pixel_size=(det.pix_size * u.pixel).to(u.micron), gap=det.ccd_gap,
|
|
112
|
+
plate_scale=(det.plate_scale_angle * u.pixel).to(u.arcsec))
|
|
113
|
+
taken = sorted(set(settings) & set(camera))
|
|
114
|
+
if taken:
|
|
115
|
+
raise ValueError(f"{', '.join(taken)} come from the detector; set them there, as "
|
|
116
|
+
f"Detector_SWC.n_rows, n_columns, pix_size, ccd_gap and "
|
|
117
|
+
f"plate_scale_angle.")
|
|
118
|
+
optics = dict(dispersion=det.dispersion, wavelength_offset=det.wavelength_offset,
|
|
119
|
+
lit_band=det.lit_band, slit_image_tilt=det.slit_image_tilt)
|
|
120
|
+
return cls(**camera, **{**optics, **settings})
|
|
121
|
+
|
|
122
|
+
def __post_init__(self):
|
|
123
|
+
if self.n_rows < 1 or self.n_columns < 1:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"A CCD needs at least one row and one column, got "
|
|
126
|
+
f"{self.n_rows} x {self.n_columns}."
|
|
127
|
+
)
|
|
128
|
+
_check_focal_plane(self, "focal_plane")
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def outer_edge(self) -> u.Quantity:
|
|
132
|
+
"""Distance from the focal plane centre to the outer edge of a CCD."""
|
|
133
|
+
return self.n_rows * self.pixel_size.to(u.mm) + self.gap.to(u.mm) / 2
|
|
134
|
+
|
|
135
|
+
def _check_ccd(self, ccd: str) -> str:
|
|
136
|
+
if ccd not in ("left", "right"):
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"ccd must be 'left' (short wavelength) or 'right' (long "
|
|
139
|
+
f"wavelength), got {ccd!r}."
|
|
140
|
+
)
|
|
141
|
+
return ccd
|
|
142
|
+
|
|
143
|
+
def position(self, row, ccd: str) -> u.Quantity:
|
|
144
|
+
"""
|
|
145
|
+
Position along the dispersion of the centre of `row`, in mm from the
|
|
146
|
+
focal plane centre. Rows count from the serial register on the outer
|
|
147
|
+
edge of each CCD, so the position moves inward as the row number
|
|
148
|
+
rises.
|
|
149
|
+
"""
|
|
150
|
+
self._check_ccd(ccd)
|
|
151
|
+
offset = (np.asarray(row, dtype=float) + 0.5) * self.pixel_size.to_value(u.mm)
|
|
152
|
+
edge = self.outer_edge.to_value(u.mm)
|
|
153
|
+
return (-edge + offset if ccd == "left" else edge - offset) * u.mm
|
|
154
|
+
|
|
155
|
+
def field_angle(self, column) -> u.Quantity:
|
|
156
|
+
"""Angle along the slit of a column, from the centre of the field."""
|
|
157
|
+
middle = (self.n_columns - 1) / 2
|
|
158
|
+
return (np.asarray(column, dtype=float) - middle) * self.plate_scale
|
|
159
|
+
|
|
160
|
+
def line_shift(self, column=None) -> u.Quantity:
|
|
161
|
+
"""
|
|
162
|
+
Shift of a line from where it lands at the field centre, in mm along
|
|
163
|
+
the dispersion, positive toward longer wavelength. Zero everywhere
|
|
164
|
+
unless :attr:`slit_image_tilt` is non-zero.
|
|
165
|
+
"""
|
|
166
|
+
linear, quadratic = self.slit_image_tilt
|
|
167
|
+
if column is None or (linear == 0.0 and quadratic == 0.0):
|
|
168
|
+
return 0.0 * u.mm
|
|
169
|
+
f = (self.field_angle(column) / TILT_REFERENCE_ANGLE).to_value(u.dimensionless_unscaled)
|
|
170
|
+
return (linear * f + quadratic * f**2) * self.pixel_size.to(u.mm)
|
|
171
|
+
|
|
172
|
+
def wavelength(self, row, ccd: str, column=None) -> u.Quantity:
|
|
173
|
+
"""
|
|
174
|
+
Wavelength recorded by the centre of `row`.
|
|
175
|
+
|
|
176
|
+
Give `column` to include the drift of a line along the slit; without
|
|
177
|
+
:attr:`slit_image_tilt` it makes no difference.
|
|
178
|
+
"""
|
|
179
|
+
s = (self.position(row, ccd) - self.line_shift(column)).to_value(u.mm)
|
|
180
|
+
lam = np.polynomial.polynomial.polyval(s, self.dispersion) * u.Angstrom
|
|
181
|
+
return lam + self.wavelength_offset
|
|
182
|
+
|
|
183
|
+
def row_edges(self, ccd: str, column=None) -> u.Quantity:
|
|
184
|
+
"""
|
|
185
|
+
Wavelengths at the `n_rows + 1` boundaries between rows.
|
|
186
|
+
|
|
187
|
+
Binning a spectrum onto the detector conserves flux when it is
|
|
188
|
+
integrated between these, rather than sampled at the row centres.
|
|
189
|
+
They decrease with row number on the right CCD, whose register is at
|
|
190
|
+
the long-wavelength end.
|
|
191
|
+
"""
|
|
192
|
+
s = (self.position(np.arange(self.n_rows + 1) - 0.5, ccd)
|
|
193
|
+
- self.line_shift(column)).to_value(u.mm)
|
|
194
|
+
lam = np.polynomial.polynomial.polyval(s, self.dispersion) * u.Angstrom
|
|
195
|
+
return lam + self.wavelength_offset
|
|
196
|
+
|
|
197
|
+
def pixel_width(self, wavelength: u.Quantity) -> u.Quantity:
|
|
198
|
+
"""
|
|
199
|
+
Width in wavelength of a row centred where `wavelength` lands, at the
|
|
200
|
+
centre of the slit, per pixel.
|
|
201
|
+
|
|
202
|
+
The dispersion is a polynomial in position, so the width changes
|
|
203
|
+
slowly along the CCD. Raises ValueError if the wavelength falls in
|
|
204
|
+
the gap or off the focal plane.
|
|
205
|
+
"""
|
|
206
|
+
ccd, row = self.row_of_wavelength(wavelength)
|
|
207
|
+
width = abs(self.wavelength(row + 0.5, ccd) - self.wavelength(row - 0.5, ccd))
|
|
208
|
+
return width / u.pixel
|
|
209
|
+
|
|
210
|
+
def row_of_wavelength(self, wavelength: u.Quantity, column=None) -> Tuple[str, float]:
|
|
211
|
+
"""
|
|
212
|
+
Which CCD records a wavelength, and at which row.
|
|
213
|
+
|
|
214
|
+
The row is a float: an integer is the centre of that row, and the
|
|
215
|
+
outer edges of the first and last rows are -0.5 and `n_rows - 0.5`.
|
|
216
|
+
Raises ValueError if the wavelength falls in the gap or off the focal
|
|
217
|
+
plane. Give `column` to include the drift of a line along the slit.
|
|
218
|
+
"""
|
|
219
|
+
target = u.Quantity(wavelength).to_value(u.Angstrom)
|
|
220
|
+
for ccd in ("left", "right"):
|
|
221
|
+
row = self._row_on(ccd, target, column)
|
|
222
|
+
if -0.5 <= row <= self.n_rows - 0.5:
|
|
223
|
+
return ccd, row
|
|
224
|
+
ends = {ccd: self.row_edges(ccd, column)[[0, -1]] for ccd in ("left", "right")}
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"{u.Quantity(wavelength)} is not on either CCD. The left CCD covers "
|
|
227
|
+
f"{ends['left'][0]:.4f} to {ends['left'][1]:.4f}, the right CCD "
|
|
228
|
+
f"{ends['right'][1]:.4f} to {ends['right'][0]:.4f}, and the gap "
|
|
229
|
+
f"between them is not recorded."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def _nearest_rows(self, wavelength: np.ndarray) -> np.ndarray:
|
|
233
|
+
"""
|
|
234
|
+
Row recording each wavelength, in Angstrom, at the centre of the
|
|
235
|
+
slit, counted as in :meth:`row_of_wavelength`. A wavelength in the
|
|
236
|
+
gap between the CCDs, or beyond their outer ends, takes the nearest
|
|
237
|
+
row of the nearer CCD.
|
|
238
|
+
"""
|
|
239
|
+
positions = np.arange(self.n_rows + 1) - 0.5
|
|
240
|
+
rows, distances = [], []
|
|
241
|
+
for ccd in ("left", "right"):
|
|
242
|
+
edges = self.row_edges(ccd).to_value(u.Angstrom)
|
|
243
|
+
order = np.argsort(edges)
|
|
244
|
+
rows.append(np.interp(wavelength, edges[order], positions[order]))
|
|
245
|
+
distances.append(np.maximum(edges.min() - wavelength, wavelength - edges.max()))
|
|
246
|
+
return np.where(distances[0] <= distances[1], rows[0], rows[1])
|
|
247
|
+
|
|
248
|
+
def _row_on(self, ccd: str, target: float, column=None) -> float:
|
|
249
|
+
"""
|
|
250
|
+
Row of `ccd` where `target`, in Angstrom, lands, counted as in
|
|
251
|
+
:meth:`row_of_wavelength`. Off the CCD it is below -0.5 or above
|
|
252
|
+
`n_rows - 0.5`, and infinite if beyond every row.
|
|
253
|
+
"""
|
|
254
|
+
ends = (-0.5, self.n_rows - 0.5)
|
|
255
|
+
|
|
256
|
+
def beyond(row):
|
|
257
|
+
return self.wavelength(row, ccd, column).to_value(u.Angstrom) - target
|
|
258
|
+
|
|
259
|
+
at_ends = [beyond(end) for end in ends]
|
|
260
|
+
if at_ends[0] == 0.0:
|
|
261
|
+
return ends[0]
|
|
262
|
+
if at_ends[1] == 0.0:
|
|
263
|
+
return ends[1]
|
|
264
|
+
if np.sign(at_ends[0]) == np.sign(at_ends[1]):
|
|
265
|
+
# Off the CCD, on the side of the end nearer to it in wavelength.
|
|
266
|
+
return -np.inf if abs(at_ends[0]) < abs(at_ends[1]) else np.inf
|
|
267
|
+
# The dispersion is monotonic across a CCD, so there is one root.
|
|
268
|
+
return float(brentq(beyond, *ends, xtol=1e-12, rtol=4 * np.finfo(float).eps))
|
|
269
|
+
|
|
270
|
+
def _extreme_columns(self) -> List[float]:
|
|
271
|
+
"""
|
|
272
|
+
Columns where a line lands furthest each way along the dispersion.
|
|
273
|
+
|
|
274
|
+
These are the two ends of the slit and, when the curvature turns the
|
|
275
|
+
drift back between them, the columns either side of the turn. With
|
|
276
|
+
no :attr:`slit_image_tilt` a line lands on the same row at all of
|
|
277
|
+
them.
|
|
278
|
+
"""
|
|
279
|
+
columns = [0.0, float(self.n_columns - 1)]
|
|
280
|
+
linear, quadratic = self.slit_image_tilt
|
|
281
|
+
if quadratic != 0.0:
|
|
282
|
+
# line_shift is linear * f + quadratic * f**2 in the field angle f
|
|
283
|
+
# in units of TILT_REFERENCE_ANGLE, which turns at -linear / 2 quadratic.
|
|
284
|
+
turn = (-linear / (2 * quadratic) * TILT_REFERENCE_ANGLE
|
|
285
|
+
/ self.plate_scale).to_value(u.dimensionless_unscaled) + (self.n_columns - 1) / 2
|
|
286
|
+
columns += [float(c) for c in (np.floor(turn), np.ceil(turn)) if 0 <= c <= self.n_columns - 1]
|
|
287
|
+
return columns
|
|
288
|
+
|
|
289
|
+
def lit_rows(self, ccd: str, column=None) -> Tuple[int, int]:
|
|
290
|
+
"""
|
|
291
|
+
First and last row of `ccd` whose centre receives light, inclusive.
|
|
292
|
+
|
|
293
|
+
The edge is taken as sharp at :attr:`lit_band`. The documents put it
|
|
294
|
+
within a few rows of there but do not dimension the baffle, so a row
|
|
295
|
+
or two either side of these values is a modelling choice, not a
|
|
296
|
+
measurement.
|
|
297
|
+
Rows outside the range still pass charge; on the right CCD some 1290
|
|
298
|
+
of them lie between the band and the register.
|
|
299
|
+
"""
|
|
300
|
+
self._check_ccd(ccd)
|
|
301
|
+
lam = self.wavelength(np.arange(self.n_rows), ccd, column).to_value(u.Angstrom)
|
|
302
|
+
low, high = sorted(u.Quantity(limit).to_value(u.Angstrom) for limit in self.lit_band)
|
|
303
|
+
lit = np.flatnonzero((lam >= low) & (lam <= high))
|
|
304
|
+
if lit.size == 0:
|
|
305
|
+
raise ValueError(
|
|
306
|
+
f"No row of the {ccd} CCD sees light: it covers "
|
|
307
|
+
f"{lam.min():.2f} to {lam.max():.2f} Angstrom and the band is "
|
|
308
|
+
f"{low:.2f} to {high:.2f}."
|
|
309
|
+
)
|
|
310
|
+
return int(lit[0]), int(lit[-1])
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass
|
|
314
|
+
class ReadoutSequence:
|
|
315
|
+
"""
|
|
316
|
+
How the FEE clocks a frame out of the SW CCDs.
|
|
317
|
+
|
|
318
|
+
Both CCDs are clocked together, and a row wanted on either is read on
|
|
319
|
+
both, so one row timeline covers the pair
|
|
320
|
+
(SOLC-EUVST-MSSL-RS-0002 v2.0 SolC-FPGA-RS-033).
|
|
321
|
+
|
|
322
|
+
The shutter, timing and register layout belong to the camera, and
|
|
323
|
+
default to those of :class:`eclipse.config.Detector_SWC`;
|
|
324
|
+
:meth:`from_detector` takes them from a configured detector. The windows
|
|
325
|
+
and the clear belong to an observation, and are given here.
|
|
326
|
+
|
|
327
|
+
Parameters
|
|
328
|
+
----------
|
|
329
|
+
shutter : bool
|
|
330
|
+
True keeps the chip dark outside the exposure, as a mechanical
|
|
331
|
+
shutter does. False leaves it illuminated while the image area is
|
|
332
|
+
cleared and read, which smears the frame.
|
|
333
|
+
row_transfer_time : u.Quantity
|
|
334
|
+
Time to move every row one step toward the register. Configurable in
|
|
335
|
+
flight; 15 us is the value in the SWC modes table and in the read-out
|
|
336
|
+
times of RP-0007 v1.2 Table 5-11.
|
|
337
|
+
pixel_period : u.Quantity
|
|
338
|
+
Time to move one pixel along the serial register and digitise it.
|
|
339
|
+
serial_prescan, serial_image_pixels, serial_overscan : int
|
|
340
|
+
Samples read per output for each row that goes through the register.
|
|
341
|
+
The register is split, so each half carries half the columns, 1024
|
|
342
|
+
image pixels, with the prescan at the outer end and the overscan at
|
|
343
|
+
the middle. Both scans are configurable in flight from 0 to 200. A
|
|
344
|
+
row takes as long as the output with more pixels, so for an odd
|
|
345
|
+
number of columns the larger half counts.
|
|
346
|
+
parallel_overscan_rows : int
|
|
347
|
+
Rows clocked and read after the last image row. Without a shutter
|
|
348
|
+
they hold pure smear, as their charge crosses the whole illuminated
|
|
349
|
+
image area on the way out.
|
|
350
|
+
dump_rows : int
|
|
351
|
+
Row transfers used to clear the image area before the exposure.
|
|
352
|
+
Default a whole CCD. A frame here always starts from an empty chip,
|
|
353
|
+
so with fewer rows the charge a partial clear leaves behind is not
|
|
354
|
+
modelled. :func:`dark_current_time` warns of this, and so does
|
|
355
|
+
:func:`smear_photons` without a shutter (with one there is no smear
|
|
356
|
+
to leave out).
|
|
357
|
+
windows : list of tuple of int
|
|
358
|
+
Inclusive row ranges that are read out. An empty list reads every
|
|
359
|
+
row, the slowest case.
|
|
360
|
+
cte_parallel, cte_serial : float
|
|
361
|
+
Charge transfer efficiency of one row transfer and of one transfer
|
|
362
|
+
along the serial register: the fraction of a packet's charge that
|
|
363
|
+
moves, the rest joining the packet behind. 1 is a perfect transfer.
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
shutter: bool = Detector_SWC.shutter
|
|
367
|
+
row_transfer_time: u.Quantity = Detector_SWC.row_transfer_time
|
|
368
|
+
pixel_period: u.Quantity = Detector_SWC.pixel_period
|
|
369
|
+
serial_prescan: int = Detector_SWC.serial_prescan
|
|
370
|
+
serial_image_pixels: int = (Detector_SWC.n_columns + 1) // 2
|
|
371
|
+
serial_overscan: int = Detector_SWC.serial_overscan
|
|
372
|
+
parallel_overscan_rows: int = Detector_SWC.parallel_overscan_rows
|
|
373
|
+
dump_rows: int = Detector_SWC.n_rows
|
|
374
|
+
windows: List[Tuple[int, int]] = field(default_factory=list)
|
|
375
|
+
cte_parallel: float = Detector_SWC.cte_parallel
|
|
376
|
+
cte_serial: float = Detector_SWC.cte_serial
|
|
377
|
+
|
|
378
|
+
@classmethod
|
|
379
|
+
def from_detector(cls, det: Detector_SWC, *, windows: Sequence[Tuple[int, int]] = (),
|
|
380
|
+
dump_rows: int | None = None, shutter: bool | None = None,
|
|
381
|
+
) -> "ReadoutSequence":
|
|
382
|
+
"""
|
|
383
|
+
Read-out of the camera `det` for one observation.
|
|
384
|
+
|
|
385
|
+
Parameters
|
|
386
|
+
----------
|
|
387
|
+
det : Detector_SWC
|
|
388
|
+
Camera, whose shutter, timing, register layout and charge
|
|
389
|
+
transfer efficiencies are used.
|
|
390
|
+
windows : sequence of tuple of int, optional
|
|
391
|
+
Row ranges read out, as for the constructor. Default every row.
|
|
392
|
+
dump_rows : int, optional
|
|
393
|
+
Row transfers of the clear. Default a whole CCD.
|
|
394
|
+
shutter : bool, optional
|
|
395
|
+
Whether the frame is taken with the shutter. Default as the
|
|
396
|
+
camera is configured.
|
|
397
|
+
"""
|
|
398
|
+
return cls(shutter=det.shutter if shutter is None else shutter,
|
|
399
|
+
row_transfer_time=det.row_transfer_time, pixel_period=det.pixel_period,
|
|
400
|
+
serial_prescan=det.serial_prescan,
|
|
401
|
+
serial_image_pixels=(det.n_columns + 1) // 2,
|
|
402
|
+
serial_overscan=det.serial_overscan,
|
|
403
|
+
parallel_overscan_rows=det.parallel_overscan_rows,
|
|
404
|
+
dump_rows=det.n_rows if dump_rows is None else dump_rows,
|
|
405
|
+
windows=list(windows), cte_parallel=det.cte_parallel,
|
|
406
|
+
cte_serial=det.cte_serial)
|
|
407
|
+
|
|
408
|
+
def __post_init__(self):
|
|
409
|
+
for first, last in self.windows:
|
|
410
|
+
if first > last:
|
|
411
|
+
raise ValueError(
|
|
412
|
+
f"A window runs from its first row to its last, got "
|
|
413
|
+
f"({first}, {last})."
|
|
414
|
+
)
|
|
415
|
+
if first < 0:
|
|
416
|
+
raise ValueError(f"Window rows start at 0, got {first}.")
|
|
417
|
+
if self.dump_rows < 0:
|
|
418
|
+
raise ValueError(f"dump_rows cannot be negative, got {self.dump_rows}.")
|
|
419
|
+
if self.parallel_overscan_rows < 0:
|
|
420
|
+
raise ValueError(
|
|
421
|
+
f"parallel_overscan_rows cannot be negative, got "
|
|
422
|
+
f"{self.parallel_overscan_rows}."
|
|
423
|
+
)
|
|
424
|
+
for name in ("cte_parallel", "cte_serial"):
|
|
425
|
+
value = getattr(self, name)
|
|
426
|
+
if not 0 < value <= 1:
|
|
427
|
+
raise ValueError(
|
|
428
|
+
f"{name} is the fraction of a packet's charge one transfer moves, above "
|
|
429
|
+
f"0 and at most 1, got {value}."
|
|
430
|
+
)
|
|
431
|
+
if self.cte_serial < 1:
|
|
432
|
+
# Each row is read from an empty serial register, so charge the
|
|
433
|
+
# last image pixel leaves behind past the end of the overscan,
|
|
434
|
+
# which would be read with the next row, is left out.
|
|
435
|
+
transfers = self.serial_prescan + self.serial_image_pixels
|
|
436
|
+
arrived = transfer_probabilities([transfers], self.cte_serial,
|
|
437
|
+
most=self.serial_overscan)[:, 0].sum()
|
|
438
|
+
if 1 - arrived > _LEFT_IN_REGISTER:
|
|
439
|
+
warnings.warn(
|
|
440
|
+
f"With cte_serial {self.cte_serial} and {self.serial_overscan} serial "
|
|
441
|
+
f"overscan samples, {1 - arrived:.1e} of the last image pixel's charge is "
|
|
442
|
+
f"still in the serial register when the next row is read. Each row is "
|
|
443
|
+
f"read from an empty register, so that charge is left out.", UserWarning,
|
|
444
|
+
stacklevel=3)
|
|
445
|
+
|
|
446
|
+
@property
|
|
447
|
+
def samples_per_row(self) -> int:
|
|
448
|
+
"""Samples each output digitises for one row that is read."""
|
|
449
|
+
return self.serial_prescan + self.serial_image_pixels + self.serial_overscan
|
|
450
|
+
|
|
451
|
+
@property
|
|
452
|
+
def line_read_time(self) -> u.Quantity:
|
|
453
|
+
"""Time to read one row through the register, both halves at once."""
|
|
454
|
+
return (self.samples_per_row * self.pixel_period).to(u.s)
|
|
455
|
+
|
|
456
|
+
def is_read(self, n_rows: int) -> np.ndarray:
|
|
457
|
+
"""
|
|
458
|
+
Which rows go through the serial register.
|
|
459
|
+
|
|
460
|
+
A boolean array over the image rows and then the parallel overscan
|
|
461
|
+
rows. The overscan is always read; with no windows, so is every
|
|
462
|
+
other row.
|
|
463
|
+
"""
|
|
464
|
+
total = n_rows + self.parallel_overscan_rows
|
|
465
|
+
if not self.windows:
|
|
466
|
+
return np.ones(total, dtype=bool)
|
|
467
|
+
read = np.zeros(total, dtype=bool)
|
|
468
|
+
for first, last in self.windows:
|
|
469
|
+
read[first:min(last, n_rows - 1) + 1] = True
|
|
470
|
+
read[n_rows:] = True
|
|
471
|
+
return read
|
|
472
|
+
|
|
473
|
+
def dwell(self, n_rows: int) -> np.ndarray:
|
|
474
|
+
"""
|
|
475
|
+
Time in seconds between successive row transfers of a read-out.
|
|
476
|
+
|
|
477
|
+
Element `k` is the interval after transfer `k + 1`, while image row
|
|
478
|
+
`k` is in the serial register being read or dumped. Every charge
|
|
479
|
+
packet in the image area is stationary for that whole interval,
|
|
480
|
+
which is why the smear depends on the window layout and not only on
|
|
481
|
+
the number of rows.
|
|
482
|
+
"""
|
|
483
|
+
read = self.is_read(n_rows)
|
|
484
|
+
return (self.row_transfer_time.to_value(u.s)
|
|
485
|
+
+ read * self.line_read_time.to_value(u.s))
|
|
486
|
+
|
|
487
|
+
def readout_duration(self, n_rows: int) -> u.Quantity:
|
|
488
|
+
"""How long one read-out takes, from the first transfer to the last."""
|
|
489
|
+
return self.dwell(n_rows).sum() * u.s
|
|
490
|
+
|
|
491
|
+
def dump_duration(self) -> u.Quantity:
|
|
492
|
+
"""How long clearing the image area takes."""
|
|
493
|
+
return (self.dump_rows * self.row_transfer_time).to(u.s)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def windows_from_wavelengths(focal_plane: FocalPlane_SWC,
|
|
497
|
+
ranges: Sequence[Tuple[u.Quantity, u.Quantity]],
|
|
498
|
+
) -> List[Tuple[int, int]]:
|
|
499
|
+
"""
|
|
500
|
+
Convert wavelength ranges to the row ranges of read-out windows.
|
|
501
|
+
|
|
502
|
+
A window holds every row that any part of the range lands on, at any
|
|
503
|
+
column. With a :attr:`~FocalPlane_SWC.slit_image_tilt` a line drifts
|
|
504
|
+
along the dispersion from one end of the slit to the other, and the
|
|
505
|
+
window follows it. A row the range only touches at its edge is left
|
|
506
|
+
out.
|
|
507
|
+
|
|
508
|
+
Both CCDs share one row timeline, so the returned ranges are row numbers
|
|
509
|
+
with no CCD attached: a window over rows 1850 to 1880 makes those rows
|
|
510
|
+
slow on both devices, though they record a different wavelength on the
|
|
511
|
+
other one. A range that crosses the gap runs from each of its ends to
|
|
512
|
+
the butted edge, so its window extends to the last row. Parts of a range
|
|
513
|
+
in the gap or beyond the ends of the focal plane are not recorded and
|
|
514
|
+
need no rows.
|
|
515
|
+
"""
|
|
516
|
+
n_rows = focal_plane.n_rows
|
|
517
|
+
windows = []
|
|
518
|
+
for low, high in ranges:
|
|
519
|
+
targets = [u.Quantity(w).to_value(u.Angstrom) for w in (low, high)]
|
|
520
|
+
first, last = np.inf, -np.inf
|
|
521
|
+
for column in focal_plane._extreme_columns():
|
|
522
|
+
for ccd in ("left", "right"):
|
|
523
|
+
rows = [focal_plane._row_on(ccd, target, column) for target in targets]
|
|
524
|
+
# Rows the range covers on this CCD, cut to its edges. A range
|
|
525
|
+
# that reaches the CCD only at its outer edge is not on it. A
|
|
526
|
+
# range of no width on a row boundary is on the row below, so
|
|
527
|
+
# at the lower edge, with no row below, it is not on the CCD.
|
|
528
|
+
lo, hi = min(rows), max(rows)
|
|
529
|
+
start, end = max(lo, -0.5), min(hi, n_rows - 0.5)
|
|
530
|
+
if start < end or (lo == hi and -0.5 < lo <= n_rows - 0.5):
|
|
531
|
+
first, last = min(first, start), max(last, end)
|
|
532
|
+
if first > last:
|
|
533
|
+
raise ValueError(
|
|
534
|
+
f"No part of {u.Quantity(low)} to {u.Quantity(high)} is on either CCD.")
|
|
535
|
+
# Rows whose span, half a row either side of the centre, overlaps the
|
|
536
|
+
# range; a range of no width is on one row.
|
|
537
|
+
top = int(np.ceil(last + 0.5)) - 1
|
|
538
|
+
windows.append((min(int(np.floor(first - 0.5)) + 1, top), top))
|
|
539
|
+
return windows
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _warn_of_a_partial_clear(sequence: ReadoutSequence, n_rows: int, what: str) -> None:
|
|
543
|
+
"""Warn that the charge left by a clear of fewer rows than the CCD has is not modelled."""
|
|
544
|
+
if sequence.dump_rows < n_rows:
|
|
545
|
+
warnings.warn(
|
|
546
|
+
f"dump_rows is {sequence.dump_rows}, fewer than the {n_rows} rows of the CCD, so "
|
|
547
|
+
f"the clear leaves charge from before it in the image area. The {what} here "
|
|
548
|
+
f"starts from an empty chip and leaves that charge out.", UserWarning, stacklevel=3)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# Largest fraction of a charge that transfer_probabilities leaves out past its
|
|
552
|
+
# last term.
|
|
553
|
+
_TAIL = 1e-15
|
|
554
|
+
|
|
555
|
+
# Largest fraction of the last image pixel's charge that may still be in the
|
|
556
|
+
# serial register after the overscan without a warning.
|
|
557
|
+
_LEFT_IN_REGISTER = 1e-6
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def transfer_probabilities(transfers, cte: float, most: int | None = None) -> np.ndarray:
|
|
561
|
+
"""
|
|
562
|
+
Fraction of a charge arriving 0, 1, 2, ... packets late after its transfers.
|
|
563
|
+
|
|
564
|
+
Each transfer moves a fraction `cte` of a packet's charge and leaves the
|
|
565
|
+
rest in the well it vacated, where the packet behind picks it up. Every
|
|
566
|
+
electron is left behind independently, so charge with `m` transfers to
|
|
567
|
+
go arrives `k` packets late with the negative binomial probability
|
|
568
|
+
`C(m - 1 + k, k) * cte**m * (1 - cte)**k`: it moves `m` times and is
|
|
569
|
+
held back `k` times, each time joining a packet with one more transfer
|
|
570
|
+
to go.
|
|
571
|
+
|
|
572
|
+
Parameters
|
|
573
|
+
----------
|
|
574
|
+
transfers : array_like of int
|
|
575
|
+
Transfers each charge has to go, 1 or more.
|
|
576
|
+
cte : float
|
|
577
|
+
Fraction of a packet's charge one transfer moves, above 0 and at
|
|
578
|
+
most 1.
|
|
579
|
+
most : int, optional
|
|
580
|
+
Latest arrival wanted; later charge would leave the frame anyway.
|
|
581
|
+
Default no limit.
|
|
582
|
+
|
|
583
|
+
Returns
|
|
584
|
+
-------
|
|
585
|
+
np.ndarray
|
|
586
|
+
Shape `(n_terms, *transfers.shape)`; element `[k, ...]` is the
|
|
587
|
+
fraction arriving `k` packets late. Terms run until less than 1e-15
|
|
588
|
+
of every charge is left over, or to `most`. A perfect transfer gives
|
|
589
|
+
one term of ones.
|
|
590
|
+
|
|
591
|
+
Raises
|
|
592
|
+
------
|
|
593
|
+
ValueError
|
|
594
|
+
If a transfer count is not finite, a charge has no transfer to go,
|
|
595
|
+
or `cte` is not above 0 and at most 1.
|
|
596
|
+
"""
|
|
597
|
+
m = np.asarray(transfers, dtype=float)
|
|
598
|
+
if not np.all(np.isfinite(m)):
|
|
599
|
+
raise ValueError("Transfers must be finite.")
|
|
600
|
+
if np.any(m < 1):
|
|
601
|
+
raise ValueError("A charge has at least one transfer to go.")
|
|
602
|
+
if not 0 < cte <= 1:
|
|
603
|
+
raise ValueError(f"cte is a fraction above 0 and at most 1, got {cte}.")
|
|
604
|
+
if cte == 1:
|
|
605
|
+
return np.ones((1,) + m.shape)
|
|
606
|
+
log_kept, log_left = np.log(cte), np.log1p(-cte)
|
|
607
|
+
terms = []
|
|
608
|
+
k = 0
|
|
609
|
+
while True:
|
|
610
|
+
terms.append(np.exp(gammaln(m + k) - gammaln(k + 1) - gammaln(m)
|
|
611
|
+
+ m * log_kept + k * log_left))
|
|
612
|
+
# Past the most likely arrival each term is at most `ratio` times the
|
|
613
|
+
# last, so the rest is bounded by a geometric series.
|
|
614
|
+
ratio = (m + k) / (k + 1) * (1.0 - cte)
|
|
615
|
+
with np.errstate(divide="ignore"):
|
|
616
|
+
left = np.where(ratio < 1, terms[-1] * ratio / (1.0 - ratio), np.inf)
|
|
617
|
+
if np.all(left < _TAIL) or (most is not None and k >= most):
|
|
618
|
+
return np.array(terms)
|
|
619
|
+
k += 1
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _check_rate(rate: np.ndarray) -> np.ndarray:
|
|
623
|
+
rate = np.asarray(rate, dtype=float)
|
|
624
|
+
if rate.ndim != 2:
|
|
625
|
+
raise ValueError(
|
|
626
|
+
f"rate is one CCD's pixels, shaped (n_rows, n_columns), got shape "
|
|
627
|
+
f"{rate.shape}."
|
|
628
|
+
)
|
|
629
|
+
return rate
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _cleared(rate: np.ndarray, sequence: ReadoutSequence, packets: np.ndarray) -> np.ndarray:
|
|
633
|
+
"""
|
|
634
|
+
Charge each of `packets` collects while the image area is cleared, in
|
|
635
|
+
units of `rate` times seconds.
|
|
636
|
+
|
|
637
|
+
The packet that ends the clear at row `r` came down from row
|
|
638
|
+
`r + dump_rows`, collecting one row transfer at each of rows `r + 1` to
|
|
639
|
+
`r + dump_rows`. Rows above the image area are empty. A packet with `r`
|
|
640
|
+
below zero went into the dump drain before the clear ended.
|
|
641
|
+
"""
|
|
642
|
+
n_rows = rate.shape[0]
|
|
643
|
+
cleared = np.zeros((packets.size, rate.shape[1]))
|
|
644
|
+
if not sequence.dump_rows:
|
|
645
|
+
return cleared
|
|
646
|
+
above = np.zeros((n_rows + 1, rate.shape[1]))
|
|
647
|
+
above[:-1] = np.cumsum(rate[::-1], axis=0)[::-1]
|
|
648
|
+
first = np.clip(packets + 1, 0, n_rows)
|
|
649
|
+
last = np.clip(packets + sequence.dump_rows + 1, 0, n_rows)
|
|
650
|
+
some = first < last
|
|
651
|
+
cleared[some] = ((above[first[some]] - above[last[some]])
|
|
652
|
+
* sequence.row_transfer_time.to_value(u.s))
|
|
653
|
+
return cleared
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _along_columns(rate: np.ndarray, exposure, sequence: ReadoutSequence,
|
|
657
|
+
power: int = 1) -> np.ndarray:
|
|
658
|
+
"""
|
|
659
|
+
Charge each packet of a frame brings to the serial register.
|
|
660
|
+
|
|
661
|
+
`rate` is any quantity each pixel's light adds per second. It is
|
|
662
|
+
collected during the exposure, unless `exposure` is None, and without a
|
|
663
|
+
shutter also while the image area is cleared and read. Without a
|
|
664
|
+
shutter, the packet read out of row `r` adds
|
|
665
|
+
`sum_k rate[r - k] * dwell[k - 1]` over `k >= 1` during the read-out,
|
|
666
|
+
and :func:`_cleared` during the clear. Each addition, made at row `x`,
|
|
667
|
+
has `x + 1` row transfers to go and is shared among the packets by
|
|
668
|
+
:func:`transfer_probabilities`, with the probabilities raised to
|
|
669
|
+
`power`. Packets dumped during the clear also leave charge behind, which
|
|
670
|
+
arrives in the first rows read.
|
|
671
|
+
"""
|
|
672
|
+
n_rows, n_columns = rate.shape
|
|
673
|
+
total_rows = n_rows + sequence.parallel_overscan_rows
|
|
674
|
+
lit = not sequence.shutter
|
|
675
|
+
# A packet dumped during the clear can start up to dump_rows rows before
|
|
676
|
+
# the first one read, so its charge can arrive that much later.
|
|
677
|
+
latest = total_rows - 1 + (sequence.dump_rows if lit else 0)
|
|
678
|
+
probability = transfer_probabilities(np.arange(1, n_rows + 1), sequence.cte_parallel,
|
|
679
|
+
most=latest) ** power
|
|
680
|
+
if lit:
|
|
681
|
+
kernel = np.concatenate([[0.0], sequence.dwell(n_rows)])
|
|
682
|
+
# The FFT leaves rounding noise, of order 1e-16 of the brightest row, in
|
|
683
|
+
# packets no light reached, which would give them a mean photon energy
|
|
684
|
+
# made of noise. Zero the packets whose path crossed no lit pixel,
|
|
685
|
+
# found by convolving a count of lit pixels, which is good to far
|
|
686
|
+
# better than a half.
|
|
687
|
+
crossed = fftconvolve((rate > 0).astype(float),
|
|
688
|
+
(kernel > 0).astype(float)[:, np.newaxis],
|
|
689
|
+
mode="full", axes=0)[:total_rows]
|
|
690
|
+
arrived = np.zeros((total_rows, n_columns))
|
|
691
|
+
for k, weight in enumerate(probability):
|
|
692
|
+
weighted = rate * weight[:, np.newaxis]
|
|
693
|
+
# Indexed by arrival, k packets after collection, so the first k rows
|
|
694
|
+
# come from packets dumped during the clear.
|
|
695
|
+
packets = np.zeros((total_rows, n_columns))
|
|
696
|
+
if lit:
|
|
697
|
+
if k < total_rows:
|
|
698
|
+
# Read-out. The packet leaving row r is at row r - j while image
|
|
699
|
+
# row j - 1 is in the register, so the smear is the rate
|
|
700
|
+
# convolved with the dwell times. The parallel overscan rows are
|
|
701
|
+
# the terms past the last image row.
|
|
702
|
+
reading = fftconvolve(weighted, kernel[:, np.newaxis], mode="full",
|
|
703
|
+
axes=0)[:total_rows]
|
|
704
|
+
reading[crossed < 0.5] = 0.0
|
|
705
|
+
packets[k:] += reading[:total_rows - k]
|
|
706
|
+
packets += _cleared(weighted, sequence, np.arange(total_rows) - k)
|
|
707
|
+
# The sum cannot be negative, but the FFT convolution leaves rounding
|
|
708
|
+
# noise of order 1e-20 where it is zero, which would reach the
|
|
709
|
+
# Poisson draw downstream as a negative mean.
|
|
710
|
+
np.maximum(packets, 0.0, out=packets)
|
|
711
|
+
if exposure is not None and k < total_rows:
|
|
712
|
+
# Exposure. Image row r's charge arrives in packet r + k, in the
|
|
713
|
+
# parallel overscan for the last rows.
|
|
714
|
+
end = min(n_rows + k, total_rows)
|
|
715
|
+
packets[k:end] += (weighted * u.Quantity(exposure).to_value(u.s))[:end - k]
|
|
716
|
+
arrived += packets
|
|
717
|
+
return arrived
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _along_register(frame: np.ndarray, sequence: ReadoutSequence, power: int = 1,
|
|
721
|
+
overscan: int = 0):
|
|
722
|
+
"""
|
|
723
|
+
Charge each pixel of a frame brings to its output along the serial
|
|
724
|
+
register, with the arrival probabilities raised to `power`.
|
|
725
|
+
|
|
726
|
+
Each half of the register is read from its outer end. A pixel `j` image
|
|
727
|
+
pixels from the outer end of its half has `serial_prescan + j + 1`
|
|
728
|
+
transfers to go. What it leaves behind arrives in the pixels read after
|
|
729
|
+
it, toward the middle of the register, and past the last image pixel
|
|
730
|
+
goes into the overscan. With `overscan` above zero, the charge reaching
|
|
731
|
+
the first `overscan` overscan samples of each half is returned too,
|
|
732
|
+
shaped `(rows, 2 * overscan)` and laid out across the CCD: the left
|
|
733
|
+
output's samples in the order read, then the right output's in reverse,
|
|
734
|
+
so that each half's first sample sits next to its image pixels.
|
|
735
|
+
"""
|
|
736
|
+
n_columns = frame.shape[1]
|
|
737
|
+
if sequence.cte_serial == 1:
|
|
738
|
+
trail = np.zeros((frame.shape[0], 2 * overscan))
|
|
739
|
+
return (frame, trail) if overscan else frame
|
|
740
|
+
half = sequence.serial_image_pixels
|
|
741
|
+
if n_columns not in (2 * half, 2 * half - 1):
|
|
742
|
+
raise ValueError(
|
|
743
|
+
f"A transfer along the serial register needs the frame's every column, "
|
|
744
|
+
f"{2 * half} for two halves of {half}, to know how far each is from its "
|
|
745
|
+
f"output; got {n_columns}."
|
|
746
|
+
)
|
|
747
|
+
arrived = np.zeros_like(frame)
|
|
748
|
+
trails = []
|
|
749
|
+
# Each half in the order its output reads it: the first from column 0, the
|
|
750
|
+
# second from the last column.
|
|
751
|
+
for columns in (np.arange(half), np.arange(n_columns - 1, half - 1, -1)):
|
|
752
|
+
charge = frame[:, columns]
|
|
753
|
+
size = columns.size
|
|
754
|
+
probability = transfer_probabilities(sequence.serial_prescan + 1 + np.arange(size),
|
|
755
|
+
sequence.cte_serial, most=size + overscan) ** power
|
|
756
|
+
# Samples in the order read: the image pixels, then the overscan.
|
|
757
|
+
reached = np.zeros((frame.shape[0], size + overscan))
|
|
758
|
+
for k, weight in enumerate(probability[:size + overscan]):
|
|
759
|
+
n = min(size, size + overscan - k)
|
|
760
|
+
reached[:, k:k + n] += (charge * weight)[:, :n]
|
|
761
|
+
arrived[:, columns] = reached[:, :size]
|
|
762
|
+
trails.append(reached[:, size:])
|
|
763
|
+
if not overscan:
|
|
764
|
+
return arrived
|
|
765
|
+
return arrived, np.concatenate([trails[0], trails[1][:, ::-1]], axis=1)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def smear_photons(rate: np.ndarray, sequence: ReadoutSequence) -> np.ndarray:
|
|
769
|
+
"""
|
|
770
|
+
Photons a shutterless frame collects outside its exposure.
|
|
771
|
+
|
|
772
|
+
Charge is clocked toward the register while the chip is still lit, so a
|
|
773
|
+
packet collects light from every row it crosses. For the packet read out
|
|
774
|
+
of row `r`, the read-out adds `sum_k rate[r - k] * dwell[k - 1]` over
|
|
775
|
+
`k >= 1`. Clearing the image area beforehand adds `row_transfer_time`
|
|
776
|
+
times the rows above it, since the packet that ends at row `r` was
|
|
777
|
+
clocked down from `r + dump_rows`.
|
|
778
|
+
|
|
779
|
+
With a transfer efficiency below 1, part of what each packet collects
|
|
780
|
+
arrives in the packets after it (:func:`transfer_probabilities`), along
|
|
781
|
+
the columns and then along the serial register.
|
|
782
|
+
|
|
783
|
+
Parameters
|
|
784
|
+
----------
|
|
785
|
+
rate : np.ndarray
|
|
786
|
+
Photons per second reaching each pixel of one CCD, shaped
|
|
787
|
+
`(n_rows, n_columns)` with row 0 at the serial register. With
|
|
788
|
+
`cte_serial` below 1, `n_columns` must be the whole CCD's, so that
|
|
789
|
+
each column's distance from its output is known.
|
|
790
|
+
sequence : ReadoutSequence
|
|
791
|
+
Clocking. With `shutter` True this returns zeros.
|
|
792
|
+
|
|
793
|
+
Returns
|
|
794
|
+
-------
|
|
795
|
+
np.ndarray
|
|
796
|
+
Photons collected outside the exposure, shaped
|
|
797
|
+
`(n_rows + parallel_overscan_rows, n_columns)`. The extra rows are
|
|
798
|
+
the parallel overscan, which sees only smear.
|
|
799
|
+
"""
|
|
800
|
+
rate = _check_rate(rate)
|
|
801
|
+
n_rows, _ = rate.shape
|
|
802
|
+
total_rows = n_rows + sequence.parallel_overscan_rows
|
|
803
|
+
if sequence.shutter:
|
|
804
|
+
return np.zeros((total_rows, rate.shape[1]))
|
|
805
|
+
_warn_of_a_partial_clear(sequence, n_rows, "smear")
|
|
806
|
+
return _along_register(_along_columns(rate, None, sequence), sequence)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def expose(rate: np.ndarray, exposure: u.Quantity, sequence: ReadoutSequence) -> np.ndarray:
|
|
810
|
+
"""
|
|
811
|
+
Photons in each pixel of a frame, exposure and smear together.
|
|
812
|
+
|
|
813
|
+
Parameters
|
|
814
|
+
----------
|
|
815
|
+
rate : np.ndarray
|
|
816
|
+
Photons per second reaching each pixel of one CCD, `(n_rows, n_columns)`.
|
|
817
|
+
exposure : u.Quantity
|
|
818
|
+
Time between clearing the image area and starting the read-out.
|
|
819
|
+
sequence : ReadoutSequence
|
|
820
|
+
Clocking.
|
|
821
|
+
|
|
822
|
+
Returns
|
|
823
|
+
-------
|
|
824
|
+
np.ndarray
|
|
825
|
+
Mean photons per pixel, shaped
|
|
826
|
+
`(n_rows + parallel_overscan_rows, n_columns)`. Without a shutter a
|
|
827
|
+
pixel holds photons from every row its charge crossed, so for a frame
|
|
828
|
+
:func:`eclipse.frame.expose_with_wavelengths` gives these with
|
|
829
|
+
the wavelengths of each pixel's mean and root mean square photon
|
|
830
|
+
energies. A Poisson draw of whole photons in each pixel goes to
|
|
831
|
+
:func:`eclipse.frame.detect` with those wavelengths and
|
|
832
|
+
:func:`dark_current_time`. For the frame's mean, pass the means
|
|
833
|
+
instead, with `noise=False`.
|
|
834
|
+
|
|
835
|
+
With a transfer efficiency below 1, these are the mean photons whose
|
|
836
|
+
charge arrives in each pixel. The transfer itself is not drawn, so a
|
|
837
|
+
frame drawn from them has the right mean, but its noise leaves out
|
|
838
|
+
the spread of the charge left behind; :func:`expose_variance`
|
|
839
|
+
includes it.
|
|
840
|
+
"""
|
|
841
|
+
rate = _check_rate(rate)
|
|
842
|
+
if not sequence.shutter:
|
|
843
|
+
_warn_of_a_partial_clear(sequence, rate.shape[0], "smear")
|
|
844
|
+
return _along_register(_along_columns(rate, exposure, sequence), sequence)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def expose_serial_overscan(rate: np.ndarray, exposure: u.Quantity,
|
|
848
|
+
sequence: ReadoutSequence) -> np.ndarray:
|
|
849
|
+
"""
|
|
850
|
+
Photons whose charge the serial register carries past the image into the overscan.
|
|
851
|
+
|
|
852
|
+
With `cte_serial` below 1, each pixel leaves charge behind at every
|
|
853
|
+
transfer along the register, and that charge arrives in the samples read
|
|
854
|
+
after it. For the last image pixels of each half these are the serial
|
|
855
|
+
overscan samples. The register's own dark current is not included, and
|
|
856
|
+
the image's dark charge is in :func:`serial_dark_shares`.
|
|
857
|
+
|
|
858
|
+
Parameters
|
|
859
|
+
----------
|
|
860
|
+
rate, exposure, sequence
|
|
861
|
+
As for :func:`expose`. `rate` must have the whole CCD's columns.
|
|
862
|
+
|
|
863
|
+
Returns
|
|
864
|
+
-------
|
|
865
|
+
np.ndarray
|
|
866
|
+
Mean photons in each serial overscan sample, shaped
|
|
867
|
+
`(n_rows + parallel_overscan_rows, 2 * serial_overscan)`: the left
|
|
868
|
+
output's samples in the order read, then the right output's in
|
|
869
|
+
reverse, as they sit across the CCD. Zeros with `cte_serial` 1.
|
|
870
|
+
"""
|
|
871
|
+
rate = _check_rate(rate)
|
|
872
|
+
n_out = (rate.shape[0] + sequence.parallel_overscan_rows, 2 * sequence.serial_overscan)
|
|
873
|
+
if sequence.cte_serial == 1 or sequence.serial_overscan == 0:
|
|
874
|
+
return np.zeros(n_out)
|
|
875
|
+
return _along_register(_along_columns(rate, exposure, sequence), sequence,
|
|
876
|
+
overscan=sequence.serial_overscan)[1]
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def serial_dark_shares(sequence: ReadoutSequence, n_columns: int) -> tuple:
|
|
880
|
+
"""
|
|
881
|
+
Share of a row's dark charge per pixel that each sample receives along the register.
|
|
882
|
+
|
|
883
|
+
Dark current is the same in every column of a row, so with `cte_serial`
|
|
884
|
+
below 1 each sample gets the row's dark charge per pixel times a share
|
|
885
|
+
that is the same for every row. Most image pixels get 1. The first
|
|
886
|
+
pixels each output reads get a little less, as their charge left behind
|
|
887
|
+
is not made up by pixels read before them, and the serial overscan
|
|
888
|
+
samples get the charge left behind by the last image pixels.
|
|
889
|
+
|
|
890
|
+
Parameters
|
|
891
|
+
----------
|
|
892
|
+
sequence : ReadoutSequence
|
|
893
|
+
Clocking.
|
|
894
|
+
n_columns : int
|
|
895
|
+
Columns of the whole CCD.
|
|
896
|
+
|
|
897
|
+
Returns
|
|
898
|
+
-------
|
|
899
|
+
image : np.ndarray
|
|
900
|
+
Share for each image column, `(n_columns,)`.
|
|
901
|
+
overscan : np.ndarray
|
|
902
|
+
Share for each serial overscan sample, `(2 * serial_overscan,)`, laid
|
|
903
|
+
out as from :func:`expose_serial_overscan`. Ones and zeros with
|
|
904
|
+
`cte_serial` 1.
|
|
905
|
+
"""
|
|
906
|
+
ones = np.ones((1, n_columns))
|
|
907
|
+
if not sequence.serial_overscan:
|
|
908
|
+
return _along_register(ones, sequence)[0], np.zeros(0)
|
|
909
|
+
image, overscan = _along_register(ones, sequence, overscan=sequence.serial_overscan)
|
|
910
|
+
return image[0], overscan[0]
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def expose_variance(variance_rate: np.ndarray, mean_rate: np.ndarray, exposure: u.Quantity,
|
|
914
|
+
sequence: ReadoutSequence) -> np.ndarray:
|
|
915
|
+
"""
|
|
916
|
+
Variance of the charge in each pixel of a frame, exposure and smear
|
|
917
|
+
together.
|
|
918
|
+
|
|
919
|
+
Every electron is left behind independently at each transfer, so a
|
|
920
|
+
pixel receiving a fraction `P` of a charge `Q` gets a variance of
|
|
921
|
+
`P**2 * var(Q) + P * (1 - P) * mean(Q)`. Summed over the frame, with `P`
|
|
922
|
+
from :func:`transfer_probabilities`, this is the transfer of
|
|
923
|
+
`variance_rate - mean_rate` weighted by `P**2` plus that of `mean_rate`
|
|
924
|
+
weighted by `P`. With a perfect transfer it equals :func:`expose` of
|
|
925
|
+
`variance_rate`.
|
|
926
|
+
|
|
927
|
+
Parameters
|
|
928
|
+
----------
|
|
929
|
+
variance_rate, mean_rate : np.ndarray
|
|
930
|
+
Variance (electrons^2) and mean (electrons) of the charge each
|
|
931
|
+
pixel's light frees per second, `(n_rows, n_columns)`. The variance
|
|
932
|
+
is at least the mean, as for charge freed by whole photons.
|
|
933
|
+
exposure, sequence
|
|
934
|
+
As for :func:`expose`.
|
|
935
|
+
|
|
936
|
+
Returns
|
|
937
|
+
-------
|
|
938
|
+
np.ndarray
|
|
939
|
+
Variance in electrons^2, shape
|
|
940
|
+
`(n_rows + parallel_overscan_rows, n_columns)`.
|
|
941
|
+
|
|
942
|
+
Raises
|
|
943
|
+
------
|
|
944
|
+
ValueError
|
|
945
|
+
If the two rates differ in shape, or the variance is below the mean
|
|
946
|
+
in any pixel.
|
|
947
|
+
"""
|
|
948
|
+
variance_rate, mean_rate = _check_rate(variance_rate), _check_rate(mean_rate)
|
|
949
|
+
if variance_rate.shape != mean_rate.shape:
|
|
950
|
+
raise ValueError(
|
|
951
|
+
f"variance_rate and mean_rate describe the same pixels, got shapes "
|
|
952
|
+
f"{variance_rate.shape} and {mean_rate.shape}."
|
|
953
|
+
)
|
|
954
|
+
excess = variance_rate - mean_rate
|
|
955
|
+
if np.any(excess < -1e-12 * np.abs(variance_rate).max()):
|
|
956
|
+
raise ValueError("variance_rate must be at least mean_rate in every pixel.")
|
|
957
|
+
if sequence.cte_parallel == 1 and sequence.cte_serial == 1:
|
|
958
|
+
return expose(variance_rate, exposure, sequence)
|
|
959
|
+
if not sequence.shutter:
|
|
960
|
+
_warn_of_a_partial_clear(sequence, variance_rate.shape[0], "smear")
|
|
961
|
+
return (_along_register(_along_columns(np.maximum(excess, 0.0), exposure, sequence, 2),
|
|
962
|
+
sequence, 2)
|
|
963
|
+
+ _along_register(_along_columns(mean_rate, exposure, sequence), sequence))
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def dark_current_time(exposure: u.Quantity, sequence: ReadoutSequence,
|
|
967
|
+
n_rows: int) -> u.Quantity:
|
|
968
|
+
"""
|
|
969
|
+
How long each packet of a frame collects dark current, for the image rows
|
|
970
|
+
and then the parallel overscan rows.
|
|
971
|
+
|
|
972
|
+
A packet collects dark current in any pixel it sits in, so this is its
|
|
973
|
+
time in the image area, the same time :func:`smear_photons` gives it
|
|
974
|
+
light for. An image row's packet is clocked into place during the clear,
|
|
975
|
+
sits through the exposure, and waits while the rows before it are read.
|
|
976
|
+
A parallel overscan packet only crosses the image area during the
|
|
977
|
+
read-out. None of this needs light, so it is the same with a shutter as
|
|
978
|
+
without one. The serial register's own dark current is not included.
|
|
979
|
+
|
|
980
|
+
With `cte_parallel` below 1, dark charge is left behind and picked up
|
|
981
|
+
like any other, so this is the time each packet's dark charge is worth,
|
|
982
|
+
weighted as :func:`expose` weights light. Along the serial register,
|
|
983
|
+
:func:`serial_dark_shares` gives the share of a row's dark charge each
|
|
984
|
+
sample receives.
|
|
985
|
+
"""
|
|
986
|
+
_warn_of_a_partial_clear(sequence, n_rows, "dark current")
|
|
987
|
+
if sequence.cte_parallel != 1:
|
|
988
|
+
unlit = replace(sequence, shutter=False)
|
|
989
|
+
return _along_columns(np.ones((n_rows, 1)), exposure, unlit)[:, 0] * u.s
|
|
990
|
+
dwell = sequence.dwell(n_rows)
|
|
991
|
+
packets = np.arange(n_rows + sequence.parallel_overscan_rows)
|
|
992
|
+
image = packets < n_rows
|
|
993
|
+
# Read-out: every dwell before the packet's own, back to the one in which
|
|
994
|
+
# it entered the top of the image area.
|
|
995
|
+
elapsed = np.concatenate([[0.0], np.cumsum(dwell)])
|
|
996
|
+
reading = elapsed[packets] - elapsed[np.maximum(packets - n_rows, 0)]
|
|
997
|
+
# Clear: one row transfer at each row above its own, as far as the clear
|
|
998
|
+
# reaches.
|
|
999
|
+
above = np.where(image, n_rows - 1 - packets, 0)
|
|
1000
|
+
clear = np.minimum(sequence.dump_rows, above) * sequence.row_transfer_time.to_value(u.s)
|
|
1001
|
+
exposed = np.where(image, u.Quantity(exposure).to_value(u.s), 0.0)
|
|
1002
|
+
return (clear + exposed + reading) * u.s
|