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