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