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/config.py ADDED
@@ -0,0 +1,1710 @@
1
+ """
2
+ Configuration classes for instruments, detectors, and simulation parameters.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ import dataclasses
7
+ import functools
8
+ import os
9
+ import sys
10
+ import warnings
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import List
14
+ import numpy as np
15
+ import astropy.units as u
16
+ import scipy.interpolate
17
+ from . import eis_calibration
18
+ from .utils import _warn_outside, angle_to_distance
19
+ from importlib.resources import files
20
+
21
+
22
+ # ------------------------------------------------------------------
23
+ # Detector materials and shared properties
24
+ # ------------------------------------------------------------------
25
+
26
+ # Properties of each detector material. A detector's `fano_factor`,
27
+ # `pair_energy_300k` and `pair_energy_slope` default to its material's.
28
+ # The energy to free one electron-hole pair in silicon is 3.71 eV at 300 K
29
+ # and rises by 0.0006 eV per kelvin of cooling.
30
+ DETECTOR_MATERIALS = {
31
+ "silicon": {
32
+ "fano_factor": 0.115,
33
+ "pair_energy_300k": 3.71 * u.eV,
34
+ "pair_energy_slope": -0.0006 * u.eV / u.K,
35
+ }
36
+ }
37
+
38
+ # Wavelength in Angstrom against position along the dispersion, in mm from the
39
+ # SW focal plane centre (the middle of the gap), positive toward longer
40
+ # wavelength. Fitted to the RSC-2022021C positions of the two detector edges,
41
+ # the focal plane centre, the chief ray and the dispersion there, and to the
42
+ # ray-traced line positions on its SW footprint figure. A linear scale is not
43
+ # good enough: the design runs from 17.0 mA per row at the short-wavelength
44
+ # end to 16.8 at the long end, so the nominal 16.9 misplaces a line by up to
45
+ # about seven rows.
46
+ DISPERSION_COEFFICIENTS = (198.885944, 1.2516577, -1.361114e-4)
47
+
48
+ # A spectral line is an image of the slit, and the optics do not put it on
49
+ # exactly one row: it drifts along the dispersion from one end of the slit to
50
+ # the other. These are the row offsets at 140 arcsec from the field centre,
51
+ # linear term then quadratic, measured from the ray-traced line positions on
52
+ # the SW footprint figure of RSC-2022021C. All eleven lines drawn there agree
53
+ # on them to a few tenths of a row. Set them as `Detector_SWC.slit_image_tilt`
54
+ # to model the drift; by default a line lands on one row along the whole slit.
55
+ MEASURED_SLIT_IMAGE_TILT = (0.991, 0.605)
56
+
57
+ # The field angle those offsets are quoted at.
58
+ TILT_REFERENCE_ANGLE = 140.0 * u.arcsec
59
+
60
+
61
+ def _material_value(det, name: str):
62
+ """Detector setting `name`, or its material's when the setting is None or missing."""
63
+ value = getattr(det, name, None)
64
+ material = getattr(det, "material", "silicon")
65
+ return DETECTOR_MATERIALS[material][name] if value is None else value
66
+
67
+
68
+ def _pair_energy(det) -> u.Quantity:
69
+ """Energy to free one electron-hole pair at the detector's CCD temperature."""
70
+ temp_kelvin = det.ccd_temperature.to_value(u.K, equivalencies=u.temperature())
71
+ at_300k = _material_value(det, "pair_energy_300k").to_value(u.eV)
72
+ slope = _material_value(det, "pair_energy_slope").to_value(u.eV / u.K)
73
+ return (at_300k + slope * (temp_kelvin - 300.0)) * u.eV
74
+
75
+
76
+ class _RenamedSetting:
77
+ """Old name of a setting, which reads and sets the new one with a FutureWarning."""
78
+
79
+ def __init__(self, old: str, new: str):
80
+ self.old, self.new = old, new
81
+
82
+ def warn(self, section: str = "") -> None:
83
+ """Warn that the old name is used; `section`, such as "detector.", prefixes it."""
84
+ _warn_outside(f"{section}{self.old} is renamed {self.new}; the old name will be "
85
+ f"removed in a future release.")
86
+
87
+ def __get__(self, obj, owner=None):
88
+ self.warn()
89
+ return getattr(owner if obj is None else obj, self.new)
90
+
91
+ def __set__(self, obj, value):
92
+ self.warn()
93
+ setattr(obj, self.new, value)
94
+
95
+
96
+ def _old_names_used(cls, given, section: str = "", replacing: bool = False,
97
+ error=TypeError) -> dict:
98
+ """
99
+ Old names of `cls`'s settings among the keys of `given`, mapped to their new names.
100
+
101
+ Each warns with a FutureWarning. Both names of one setting are refused
102
+ with `error`, unless `replacing`, when the old name's value is meant.
103
+ """
104
+ used = {}
105
+ for old, new in cls._OLD_NAMES.items():
106
+ if old in given:
107
+ if new in given and not replacing:
108
+ raise error(f"Give {section}{new} only, not {section}{old} as well.")
109
+ _RenamedSetting(old, new).warn(section)
110
+ used[old] = new
111
+ return used
112
+
113
+
114
+ def _keep_old_names(cls):
115
+ """
116
+ Accept the names in `cls._OLD_NAMES` for their new ones, with a FutureWarning.
117
+
118
+ They work as constructor arguments and attributes, and an object
119
+ pickled with an old name reads back with the new one.
120
+ `dataclasses.replace` with an old name passes both names, the new one
121
+ with the object's current value, so there the old name's value is used.
122
+ """
123
+ init = cls.__init__
124
+
125
+ @functools.wraps(init)
126
+ def __init__(self, *args, **kwargs):
127
+ replacing = sys._getframe(1).f_globals.get("__name__") == "dataclasses"
128
+ for old, new in _old_names_used(cls, kwargs, replacing=replacing).items():
129
+ kwargs.pop(new, None)
130
+ kwargs[new] = kwargs.pop(old)
131
+ init(self, *args, **kwargs)
132
+
133
+ def __setstate__(self, state):
134
+ state = dict(state)
135
+ for old, new in cls._OLD_NAMES.items():
136
+ if old in state:
137
+ state.setdefault(new, state.pop(old))
138
+ self.__dict__.update(state)
139
+
140
+ cls.__init__ = __init__
141
+ cls.__setstate__ = __setstate__
142
+ for old, new in cls._OLD_NAMES.items():
143
+ setattr(cls, old, _RenamedSetting(old, new))
144
+ return cls
145
+
146
+
147
+ def _check_detector_material(det, section: str) -> None:
148
+ """Check the settings that default to the material's, and fill those left as None."""
149
+ fano = det.fano_factor
150
+ if fano is not None and (isinstance(fano, bool)
151
+ or not isinstance(fano, (int, float, np.integer, np.floating))
152
+ or not np.isfinite(fano) or fano < 0):
153
+ raise ValueError(f"{section}.fano_factor must be a number, zero or more, or left out "
154
+ f"for the material's; got {fano!r}.")
155
+ for name, unit, example in (("pair_energy_300k", u.eV, "3.71 eV"),
156
+ ("pair_energy_slope", u.eV / u.K, "-0.0006 eV / K")):
157
+ value = getattr(det, name)
158
+ if value is not None and not (isinstance(value, u.Quantity) and value.isscalar
159
+ and value.unit.is_equivalent(unit)
160
+ and np.isfinite(value.value)):
161
+ raise ValueError(f"{section}.{name} must be in a unit of {unit.physical_type}, such "
162
+ f"as {example}, or left out for the material's; got {value!r}.")
163
+ energy = _pair_energy(det)
164
+ if not energy.value > 0:
165
+ raise ValueError(f"{section}.pair_energy_300k and pair_energy_slope give {energy} per "
166
+ f"electron-hole pair at {det.ccd_temperature}; it must be above zero.")
167
+ # Filled in, so a run records the values it used.
168
+ for name in ("fano_factor", "pair_energy_300k", "pair_energy_slope"):
169
+ setattr(det, name, _material_value(det, name))
170
+
171
+
172
+ def _numbers(value, count, section: str, name: str, example: str) -> tuple:
173
+ """
174
+ `value` as a tuple of finite floats.
175
+
176
+ Refused unless `count`, a test of how many there are, is true of them.
177
+ """
178
+ def number(item):
179
+ if isinstance(item, u.Quantity) and item.unit == u.dimensionless_unscaled:
180
+ item = item.value
181
+ if isinstance(item, bool) or not isinstance(item, (int, float, np.integer, np.floating)):
182
+ raise TypeError
183
+ return float(item)
184
+
185
+ try:
186
+ numbers = tuple(number(item) for item in value)
187
+ except TypeError:
188
+ numbers = None
189
+ if numbers is None or not count(len(numbers)) or not np.all(np.isfinite(numbers)):
190
+ raise ValueError(f"{section}.{name} must be {example}; got {value!r}.")
191
+ return numbers
192
+
193
+
194
+ # The SW focal plane settings, which a detector and a focal plane both hold.
195
+ _FOCAL_PLANE_SETTINGS = ("dispersion", "wavelength_offset", "lit_band", "slit_image_tilt")
196
+
197
+
198
+ def _same_setting(first, second) -> bool:
199
+ """Whether two values of a focal plane setting are the same, to a part in 1e12."""
200
+ first, second = u.Quantity(first), u.Quantity(second)
201
+ if first.shape != second.shape or not second.unit.is_equivalent(first.unit):
202
+ return False
203
+ return bool(np.allclose(first.value, second.to_value(first.unit), rtol=1e-12, atol=0.0))
204
+
205
+
206
+ def _one_length(value, zero: bool = False):
207
+ """
208
+ `value` as one length, read with `u.Quantity`, or None if it is not one.
209
+
210
+ With `zero`, a plain zero is taken as 0 Angstrom, as it adds to a wavelength.
211
+ """
212
+ if isinstance(value, str) and not value.strip():
213
+ return None
214
+ try:
215
+ items = ([] if isinstance(value, (str, u.Quantity))
216
+ else np.ravel(np.asarray(value, dtype=object)))
217
+ if any(isinstance(item, (bool, np.bool_)) for item in items):
218
+ return None
219
+ value = u.Quantity(value)
220
+ except (TypeError, ValueError):
221
+ return None
222
+ if value.size != 1:
223
+ return None
224
+ value = value.reshape(())
225
+ if zero and value.unit == u.dimensionless_unscaled and value.value == 0:
226
+ value = value.value * u.Angstrom
227
+ if not (value.unit.is_equivalent(u.AA) and np.isfinite(value.value)):
228
+ return None
229
+ return value
230
+
231
+
232
+ def _check_focal_plane(det, section: str = "detector") -> None:
233
+ """Check the SW focal plane settings of a detector or focal plane; store the lists as tuples."""
234
+ offset = _one_length(det.wavelength_offset, zero=True)
235
+ if offset is None:
236
+ raise ValueError(f"{section}.wavelength_offset must be one wavelength, such as "
237
+ f"0.1 Angstrom; got {det.wavelength_offset!r}.")
238
+ det.wavelength_offset = offset
239
+ det.dispersion = _numbers(det.dispersion, lambda n: n >= 2, section, "dispersion",
240
+ "two or more coefficients, lowest order first, such as "
241
+ "[198.9, 1.25]")
242
+ det.slit_image_tilt = _numbers(det.slit_image_tilt, lambda n: n == 2, section,
243
+ "slit_image_tilt", "two numbers of rows, such as [0.991, 0.605]")
244
+ band = det.lit_band
245
+ try:
246
+ limits = None if isinstance(band, str) else tuple(_one_length(item) for item in band)
247
+ except TypeError:
248
+ limits = None
249
+ if limits is None or len(limits) != 2 or any(limit is None for limit in limits):
250
+ raise ValueError(f"{section}.lit_band must be two wavelengths, such as [170.0 Angstrom, "
251
+ f"212.3 Angstrom]; got {band!r}.")
252
+ det.lit_band = tuple(sorted(limits, key=lambda limit: limit.to_value(u.AA)))
253
+
254
+
255
+ def _check_settings(obj, section: str, positive: tuple = (), non_negative: tuple = (),
256
+ fractions: tuple = (), optional: tuple = ()) -> None:
257
+ """
258
+ Refuse any setting of `obj` not of its default's kind, or out of range.
259
+
260
+ A quantity needs a unit of the same kind as its default. A number with
261
+ no unit, or one of another kind, would be read in whatever unit the code
262
+ works in: a `D_ap` of 0.28, meant in metres, would give 1e4 times too
263
+ few photons. A switch must be true or false, and a number a number.
264
+ `positive`, `non_negative` and `fractions` name the fields that must be
265
+ above zero, at or above zero, or from 0 to 1; a value outside would run
266
+ to completion and save meaningless results. `optional` names those that
267
+ may also be None.
268
+ """
269
+ for f in dataclasses.fields(obj):
270
+ if not f.init:
271
+ continue
272
+ name, value = f"{section}.{f.name.lstrip('_')}", getattr(obj, f.name)
273
+ if value is None and f.name in optional:
274
+ continue
275
+ default = None if f.default is dataclasses.MISSING else f.default
276
+ if isinstance(default, u.Quantity):
277
+ if not isinstance(value, u.Quantity):
278
+ raise ValueError(f"{name} needs a unit, of {default.unit.physical_type}, such as "
279
+ f"{default}; got {value!r}.")
280
+ if not value.unit.is_equivalent(default.unit, equivalencies=u.temperature()):
281
+ raise ValueError(f"{name} must be in a unit of {default.unit.physical_type}, "
282
+ f"such as {default.unit}; got {value.unit}.")
283
+ if not np.all(np.isfinite(value.value)):
284
+ raise ValueError(f"{name} must be finite, got {value}.")
285
+ elif isinstance(default, bool):
286
+ if not isinstance(value, bool):
287
+ raise ValueError(f"{name} must be true or false, got {value!r}.")
288
+ elif isinstance(default, float):
289
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
290
+ raise ValueError(f"{name} must be a number, got {value!r}.")
291
+ magnitude = getattr(value, "value", value)
292
+ if f.name in positive and not np.all(magnitude > 0):
293
+ raise ValueError(f"{name} must be more than zero, got {value}.")
294
+ if f.name in non_negative and not np.all(magnitude >= 0):
295
+ raise ValueError(f"{name} cannot be negative, got {value}.")
296
+ if f.name in fractions and not (0 <= magnitude <= 1):
297
+ raise ValueError(f"{name} is a fraction, from 0 to 1, got {value}.")
298
+
299
+
300
+ def _check_psf_params(tel, section: str = "telescope") -> None:
301
+ """
302
+ Check the PSF settings.
303
+
304
+ The PSF must be a Gaussian, `psf_params` two widths above zero in
305
+ pixels, along the slit and along the dispersion, and `psf_across_slit`
306
+ None or an angle above zero. Otherwise they would only be read part way
307
+ through a run, when the scene is laid onto the detector.
308
+ """
309
+ if not isinstance(tel.psf_type, str) or tel.psf_type.lower() != "gaussian":
310
+ raise ValueError(f"{section}.psf_type must be 'gaussian', the only PSF there is; got "
311
+ f"{tel.psf_type!r}.")
312
+ value = tel.psf_params
313
+ if (not isinstance(value, (list, tuple)) or len(value) != 2
314
+ or not all(isinstance(q, u.Quantity) and q.unit.is_equivalent(u.pix)
315
+ and np.isfinite(q.value) and q.value > 0 for q in value)):
316
+ raise ValueError(f"{section}.psf_params must be two FWHMs in pixels, along the slit "
317
+ f"and along the dispersion, such as [2.66 pix, 2.54 pix]; got {value!r}.")
318
+ across = tel.psf_across_slit
319
+ if across is not None and not (isinstance(across, u.Quantity) and across.isscalar
320
+ and across.unit.is_equivalent(u.arcsec)
321
+ and np.isfinite(across.value) and across.value > 0):
322
+ raise ValueError(f"{section}.psf_across_slit must be a FWHM in an angle above zero, "
323
+ f"such as 1 arcsec, or left out for none; got {across!r}.")
324
+
325
+
326
+ def _check_fiducials(tel, section: str = "telescope") -> None:
327
+ """
328
+ Check the fiducials are angles along the slit, with one length for each
329
+ of the four slits, and each inside the slit and clear of the others at
330
+ the longest of those lengths.
331
+ """
332
+ def angles(value):
333
+ return (isinstance(value, (list, tuple))
334
+ and all(isinstance(q, u.Quantity) and q.isscalar and q.unit.is_equivalent(u.arcsec)
335
+ and np.isfinite(q.value) for q in value))
336
+
337
+ if not angles(tel.fiducial_centres):
338
+ raise ValueError(f"{section}.fiducial_centres must be a list of angles along the slit "
339
+ f"from its centre, such as [-25 arcsec, 70 arcsec]; got "
340
+ f"{tel.fiducial_centres!r}.")
341
+ lengths = tel.fiducial_lengths
342
+ if not angles(lengths) or len(lengths) != 4 or not all(q.value > 0 for q in lengths):
343
+ raise ValueError(f"{section}.fiducial_lengths must be four lengths above zero, one for "
344
+ f"each of the 0.2, 0.4, 0.8 and 1.6 arcsec slits, such as [0.8 arcsec, "
345
+ f"0.8 arcsec, 0.8 arcsec, 1.6 arcsec]; got {lengths!r}.")
346
+ longest = max(q.to_value(u.arcsec) for q in lengths)
347
+ centres = np.sort([q.to_value(u.arcsec) for q in tel.fiducial_centres])
348
+ half = tel.slit_length.to_value(u.arcsec) / 2
349
+ if centres.size and (centres[0] - longest / 2 <= -half or centres[-1] + longest / 2 >= half):
350
+ raise ValueError(f"{section}.fiducial_centres must keep every fiducial inside the "
351
+ f"{tel.slit_length} slit; got {tel.fiducial_centres!r}.")
352
+ if np.any(np.diff(centres) <= longest):
353
+ raise ValueError(f"{section}.fiducial_centres must keep the fiducials apart; got "
354
+ f"{tel.fiducial_centres!r} for fiducials up to {longest} arcsec long.")
355
+
356
+
357
+ def _same_widths(given, widths) -> bool:
358
+ try:
359
+ return len(given) == len(widths) and all(
360
+ u.Quantity(a).unit.is_equivalent(u.pix) and u.Quantity(a).to_value(u.pix) == b.value
361
+ for a, b in zip(given, widths))
362
+ except (TypeError, ValueError):
363
+ return False
364
+
365
+
366
+ def _check_resolution_budget(tel, section: str = "telescope") -> None:
367
+ """Check `resolution_budget` is one the budget has, and `pointing_jitter` None or an angle."""
368
+ from .resolution_budget import BUDGETS
369
+
370
+ if not isinstance(tel.resolution_budget, str) or tel.resolution_budget not in BUDGETS:
371
+ raise ValueError(f"{section}.resolution_budget must be one of {', '.join(BUDGETS)}, "
372
+ f"got {tel.resolution_budget!r}.")
373
+ jitter = tel.pointing_jitter
374
+ if jitter is not None and not (isinstance(jitter, u.Quantity) and jitter.isscalar
375
+ and jitter.unit.is_equivalent(u.arcsec)
376
+ and np.isfinite(jitter.value) and jitter.value >= 0):
377
+ raise ValueError(f"{section}.pointing_jitter must be a FWHM in an angle, zero or more, "
378
+ f"such as 0.2 arcsec, or left out for the budget's; got {jitter!r}.")
379
+
380
+
381
+ def _check_tables(obj, names: tuple, section: str) -> None:
382
+ """
383
+ Check each of `names` is a table path; text, as from a configuration, becomes a Path.
384
+
385
+ Whether the table exists is only checked when it is read, since a
386
+ results file made on another machine rebuilds these with that machine's
387
+ paths.
388
+ """
389
+ for name in names:
390
+ value = getattr(obj, name)
391
+ if isinstance(value, (str, os.PathLike)):
392
+ value = Path(value).expanduser()
393
+ setattr(obj, name, value)
394
+ if not hasattr(value, "is_file"):
395
+ raise ValueError(f"{section}.{name} must be the path of a table, got {value!r}.")
396
+
397
+
398
+ def _check_detector(det, section: str = "detector") -> None:
399
+ """Checks both detectors share: kinds, ranges, temperature above 0 K and a known material."""
400
+ _check_settings(det, section,
401
+ positive=("gain_e_per_dn", "max_dn", "full_well", "pix_size", "wvl_res",
402
+ "plate_scale_angle", "filter_distance", "ccd_gap",
403
+ "row_transfer_time", "pixel_period"),
404
+ non_negative=("read_noise_rms", "dark_current_293k"),
405
+ fractions=("qe_euv", "qe_vis"))
406
+ if det.ccd_temperature.to_value(u.K, equivalencies=u.temperature()) <= 0:
407
+ raise ValueError(f"{section}.ccd_temperature must be above absolute zero, got "
408
+ f"{det.ccd_temperature}; -60 K was perhaps meant as -60 C.")
409
+ if det.material not in DETECTOR_MATERIALS:
410
+ raise ValueError(f"{section}.material must be one of {sorted(DETECTOR_MATERIALS)}, got "
411
+ f"{det.material!r}.")
412
+ _check_detector_material(det, section)
413
+
414
+
415
+ def calculate_dark_current(temp: u.Quantity, q_d0_293k: u.Quantity, ccd_type: str = "NIMO") -> u.Quantity:
416
+ """
417
+ Calculate dark current based on CCD temperature and type.
418
+
419
+ Parameters
420
+ ----------
421
+ temp : u.Quantity
422
+ CCD temperature with units (e.g., -60 * u.deg_C)
423
+ q_d0_293k : u.Quantity
424
+ Dark current at 293K in electrons per pixel per second
425
+ ccd_type : str
426
+ "NIMO" (non-inverted mode), "AIMO" (advanced inverted mode)
427
+
428
+ Returns
429
+ -------
430
+ dark_current : u.Quantity
431
+ Dark current in electrons per pixel per second
432
+
433
+ Raises
434
+ ------
435
+ ValueError
436
+ If temperature is above 300K (27 deg C) or unknown CCD type
437
+ """
438
+ temp_kelvin = temp.to(u.Kelvin, equivalencies=u.temperature())
439
+ max_temp = 300 * u.K
440
+ min_temp = 198 * u.K
441
+
442
+ # Check temperature limits
443
+ if temp_kelvin > max_temp:
444
+ raise ValueError(f"Cannot calculate dark current at {temp_kelvin}. "
445
+ f"Maximum temperature is {max_temp}")
446
+
447
+ # Apply minimum temperature limit (clamp to 198K; based on MSSL test results)
448
+ if temp_kelvin < min_temp:
449
+ temp_kelvin = min_temp
450
+
451
+ Q_d0 = q_d0_293k.to_value(u.electron / (u.pixel * u.s))
452
+ T = temp_kelvin.value
453
+
454
+ if ccd_type.upper() == "NIMO":
455
+ # Q_d = Q_d0 * 122 * T^3 * exp(-6400/T)
456
+ dark_current = Q_d0 * 122 * T**3 * np.exp(-6400/T)
457
+ elif ccd_type.upper() == "AIMO":
458
+ # Q_d = Qd0 * 1.14e6 * T^3 * exp(-9080/T)
459
+ dark_current = Q_d0 * 1.14e6 * T**3 * np.exp(-9080/T)
460
+ else:
461
+ raise ValueError(f"Unknown CCD type: {ccd_type}. Must be 'NIMO' or 'AIMO'.")
462
+
463
+ return dark_current * u.electron / (u.pixel * u.s)
464
+
465
+
466
+ # ------------------------------------------------------------------
467
+ # Throughput helpers & AluminiumFilter
468
+ # ------------------------------------------------------------------
469
+ # Throughput tables already read, by path.
470
+ _THROUGHPUT_TABLES: dict = {}
471
+
472
+
473
+ def _starts_with_a_number(text: str) -> bool:
474
+ try:
475
+ float(text.split()[0])
476
+ except ValueError:
477
+ return False
478
+ return True
479
+
480
+
481
+ def _load_throughput_table(path) -> tuple[u.Quantity, np.ndarray]:
482
+ """
483
+ Wavelength (nm) and throughput arrays from a two-column text table.
484
+
485
+ Comment lines are skipped. Each table is read once and cached: the
486
+ effective area is evaluated at every wavelength of a spectrum, and
487
+ rereading five tables each time would make a spectrum take minutes. The
488
+ arrays are shared, so they are read-only.
489
+ """
490
+ key = str(path)
491
+ if key not in _THROUGHPUT_TABLES:
492
+ # Text becomes a Path; a packaged table may be a resource inside an
493
+ # archive, which can read itself but is not a path.
494
+ content = (path if hasattr(path, "read_text") else Path(path)).read_text()
495
+ # The header is every line before the data that is not two numbers,
496
+ # however many there are, rather than a fixed count, which would drop
497
+ # data rows from a table with a shorter header. A line that is not
498
+ # two numbers once the data has begun is an error in the table, which
499
+ # would otherwise change the curve.
500
+ data = []
501
+ for number, line in enumerate(content.splitlines(), start=1):
502
+ text = line.strip()
503
+ if not text or text.startswith('#'):
504
+ continue
505
+ # A data line is all numbers, the first two being wavelength and
506
+ # throughput.
507
+ try:
508
+ row = [float(x) for x in text.split()]
509
+ except ValueError:
510
+ row = []
511
+ if len(row) >= 2:
512
+ # A throughput in per cent, or a NaN, would make every value
513
+ # of the effective area meaningless.
514
+ wavelength, throughput = row[:2]
515
+ if not (np.isfinite(wavelength) and wavelength > 0
516
+ and np.isfinite(throughput) and 0 <= throughput <= 1):
517
+ raise ValueError(f"{path}, line {number}: {text!r} needs a wavelength "
518
+ f"above zero, in nm, and a throughput from 0 to 1.")
519
+ data.append([wavelength, throughput])
520
+ elif data:
521
+ raise ValueError(f"{path}, line {number}: {text!r} is not a wavelength and a "
522
+ f"throughput, and the table's data had begun.")
523
+ elif _starts_with_a_number(text):
524
+ # A line before the data is a header, as in the packaged
525
+ # tables, but one starting with a number may be a first data
526
+ # line with an error in it, which would change the curve's end.
527
+ warnings.warn(f"{path}, line {number}: {text!r} is taken as a header, but it "
528
+ f"begins with a number. If it is a line of data, it has a slip "
529
+ f"in it; if it is a header, a # in front of it says so.",
530
+ stacklevel=2)
531
+ if not data:
532
+ raise ValueError(f"{path} has no lines of a wavelength and a throughput.")
533
+ if len(data) < 2:
534
+ raise ValueError(f"{path} has one line of a wavelength and a throughput, and "
535
+ f"needs two or more to interpolate between.")
536
+ arr = np.array(data)
537
+ wl = arr[:, 0] * u.nm
538
+ tr = arr[:, 1]
539
+ wl.flags.writeable = False
540
+ tr.flags.writeable = False
541
+ _THROUGHPUT_TABLES[key] = (wl, tr)
542
+ return _THROUGHPUT_TABLES[key]
543
+
544
+
545
+ def _interp_tr(wavelength_nm, wl_tab: np.ndarray, tr_tab: np.ndarray) -> float | np.ndarray:
546
+ """Linear interpolation: a float for one wavelength, an array for several."""
547
+ f = scipy.interpolate.interp1d(wl_tab, tr_tab, bounds_error=False, fill_value=np.nan)
548
+ # Snap to the table's ends: a change of unit can put an end wavelength a
549
+ # part in 1e16 beyond it, as 214 Angstrom becomes 21.400000000000002 nm.
550
+ wavelength_nm = np.asarray(wavelength_nm, dtype=float)
551
+ ends = np.asarray(wl_tab.value if hasattr(wl_tab, "value") else wl_tab, dtype=float)
552
+ for end in (ends[0], ends[-1]):
553
+ wavelength_nm = np.where(np.isclose(wavelength_nm, end, rtol=1e-12, atol=0.0),
554
+ end, wavelength_nm)
555
+ out = f(wavelength_nm)
556
+ return float(out) if np.ndim(out) == 0 else out
557
+
558
+
559
+ def check_pinhole_lists(sizes: list, positions: list, spectral: list) -> tuple[list, list]:
560
+ """
561
+ Validate the paired pinhole lists and return the positions as floats.
562
+
563
+ Used for both configuration files and a directly built `Simulation`, so
564
+ they agree on what a valid pinhole is.
565
+
566
+ Parameters
567
+ ----------
568
+ sizes, positions : list
569
+ Pinhole diameters and positions along the slit, one entry per pinhole.
570
+ spectral : list
571
+ Positions along the spectral axis, one per pinhole, or empty to put
572
+ every pinhole at the centre of the spectral window.
573
+
574
+ Returns
575
+ -------
576
+ tuple of list
577
+ `(positions, spectral)`, each entry converted to float.
578
+ """
579
+ # Always compared: guarding this on the sizes would let positions without
580
+ # sizes through, and the run would silently produce no pinholes.
581
+ if len(sizes) != len(positions):
582
+ raise ValueError(
583
+ f"pinhole_sizes and pinhole_positions are a paired list, one entry "
584
+ f"per pinhole, so they must have the same length. Got "
585
+ f"{len(sizes)} size(s) and {len(positions)} position(s)."
586
+ )
587
+ if spectral and len(spectral) != len(sizes):
588
+ raise ValueError(
589
+ f"pinhole_positions_spectral, when given, must have the same "
590
+ f"length as pinhole_sizes. Got {len(spectral)} spectral "
591
+ f"position(s) and {len(sizes)} size(s)."
592
+ )
593
+ return (_fractions(positions, "pinhole_positions"),
594
+ _fractions(spectral, "pinhole_positions_spectral"))
595
+
596
+
597
+ def _fractions(values: list, name: str) -> list:
598
+ """Return `values` as floats, rejecting any outside [0, 1].
599
+
600
+ Both position lists are fractions of the way across the detector. A
601
+ value out of range puts the pinhole off the detector, where it looks like
602
+ a working pinhole whose light is missing. Converting also stops a quoted
603
+ YAML value such as '0.3' reaching the diffraction code as a string.
604
+ """
605
+ out = []
606
+ for idx, value in enumerate(values):
607
+ try:
608
+ as_float = float(value)
609
+ except (TypeError, ValueError):
610
+ raise ValueError(
611
+ f"{name}[{idx}] is {value!r}. Positions are a plain fraction "
612
+ f"of the detector, so they carry no units."
613
+ ) from None
614
+ if not 0.0 <= as_float <= 1.0:
615
+ raise ValueError(
616
+ f"{name}[{idx}] is {as_float}. Positions are a fraction of the "
617
+ f"way across the detector and must lie in [0, 1]."
618
+ )
619
+ out.append(as_float)
620
+ return out
621
+
622
+
623
+ # Each instrument setting gives where its default comes from, as `source` in
624
+ # its field's metadata, and a quantity also the `unit` to show it in.
625
+ # tools/update_instrument_sources.py writes them to docs/instrument-sources.md.
626
+ # These sources are shared by several settings.
627
+ _FILTER_THICKNESS_SOURCE = ("Not recorded. SOLC-EUVST-MSSL-RP-0007 v1.2, Table 6-2, gives a "
628
+ "1500 Angstrom aluminium foil")
629
+ _HENKE = "Henke filter transmission calculator (henke.lbl.gov)"
630
+ _SENSITIVITY_BUDGET = "RSC-2022035A, Sensitivity Budget (13 Dec 2024)"
631
+ _SLIT_REQUIREMENTS = "TASI-SCE-SSS-0001 issue 1, R-SA-SYS-REQ-1160 and 1170"
632
+ _SW_CCD_SPEC = "SOLC-EUVST-MSSL-SP-0001 v1.4"
633
+ _SW_QE_SOURCE = ("Not recorded. SOLC-EUVST-MSSL-RP-0007 v1.2, Table 6-1, expects an EUV QE of "
634
+ "0.75; SOLC-EUVST-MSSL-SP-0001 v1.4 CCD-4.5.3 requires more than 0.71")
635
+ _SCAN_SOURCE = ("SOLC-EUVST-MSSL-RS-0002 v2.0 for the range. The read-out times of "
636
+ "SOLC-EUVST-MSSL-RP-0007 v1.2, Table 5-11, follow from 50 prescan and 16 "
637
+ "overscan")
638
+ _CTE_SOURCE = ("Not recorded. SOLC-EUVST-MSSL-SP-0001 v1.4 CCD-4.5.1 requires more than "
639
+ "0.999993; SOLC-EUVST-MSSL-RP-0007 v1.2, Table 6-1, gives 0.99999 as typical")
640
+ _NOT_RECORDED = "Not recorded"
641
+
642
+
643
+ @dataclass
644
+ class AluminiumFilter:
645
+ """
646
+ EUVST-SW filter: aluminium, aluminium oxide and carbon layers on a mesh.
647
+
648
+ Each layer's transmission is the tabulated one for a thickness of
649
+ `table_thickness`, raised to the power of the layer's thickness over
650
+ `table_thickness`.
651
+
652
+ Parameters
653
+ ----------
654
+ al_thickness, oxide_thickness, c_thickness : u.Quantity
655
+ Thickness of each layer. Default 1485, 95 and 0 angstrom.
656
+ mesh_throughput : float
657
+ Fraction of light the supporting mesh lets through. Default 0.8.
658
+ al_table, oxide_table, c_table : path
659
+ Transmission table for each layer, two columns: wavelength in nm and
660
+ transmission. Default the packaged tables.
661
+ table_thickness : u.Quantity
662
+ Thickness the tables are for. Default 1000 angstrom.
663
+ visible_decade_thickness : u.Quantity
664
+ Thickness of aluminium that cuts visible light by a factor of 10.
665
+ Default 170 angstrom. Keyword only.
666
+ """
667
+ al_thickness: u.Quantity = field(
668
+ default=1485 * u.angstrom,
669
+ metadata={"source": _FILTER_THICKNESS_SOURCE, "unit": "Angstrom"})
670
+ oxide_thickness: u.Quantity = field(
671
+ default=95 * u.angstrom,
672
+ metadata={"source": _FILTER_THICKNESS_SOURCE, "unit": "Angstrom"})
673
+ c_thickness: u.Quantity = field(
674
+ default=0 * u.angstrom,
675
+ metadata={"source": _FILTER_THICKNESS_SOURCE, "unit": "Angstrom"})
676
+ mesh_throughput: float = field(
677
+ default=0.8,
678
+ metadata={"source": "SOLC-EUVST-MSSL-RP-0007 v1.2, Table 6-2: 80% transmission, to be "
679
+ "confirmed. Its effective area in Sec. 7.1.3 uses 0.81"})
680
+ al_table: Path = field(default_factory=lambda: files('eclipse') / 'data' / 'throughput' / 'throughput_aluminium_1000_angstrom.dat',
681
+ metadata={"source": _HENKE})
682
+ oxide_table: Path = field(default_factory=lambda: files('eclipse') / 'data' / 'throughput' / 'throughput_aluminium_oxide_1000_angstrom.dat',
683
+ metadata={"source": _HENKE})
684
+ c_table: Path = field(default_factory=lambda: files('eclipse') / 'data' / 'throughput' / 'throughput_carbon_1000_angstrom.dat',
685
+ metadata={"source": _HENKE})
686
+ table_thickness: u.Quantity = field(default=1000 * u.angstrom,
687
+ metadata={"source": _HENKE, "unit": "Angstrom"})
688
+ visible_decade_thickness: u.Quantity = field(
689
+ default=170.0 * u.angstrom, kw_only=True,
690
+ metadata={"source": _NOT_RECORDED, "unit": "Angstrom"})
691
+
692
+ def __post_init__(self):
693
+ _check_settings(self, "filter", positive=("table_thickness", "visible_decade_thickness"),
694
+ non_negative=("al_thickness", "oxide_thickness", "c_thickness"),
695
+ fractions=("mesh_throughput",))
696
+ _check_tables(self, ("al_table", "oxide_table", "c_table"), "filter")
697
+
698
+ def total_throughput(self, wl0: u.Quantity) -> u.Quantity:
699
+ """
700
+ EUV transmission of the filter, including the mesh.
701
+
702
+ Parameters
703
+ ----------
704
+ wl0 : u.Quantity
705
+ Wavelength, or an array of them.
706
+
707
+ Returns
708
+ -------
709
+ u.Quantity
710
+ Dimensionless transmission, one value or one per wavelength. NaN
711
+ outside the tables' wavelengths.
712
+ """
713
+ wl_nm = wl0.to_value(u.nm)
714
+ wl_al, tr_al = _load_throughput_table(self.al_table)
715
+ wl_ox, tr_ox = _load_throughput_table(self.oxide_table)
716
+ wl_c, tr_c = _load_throughput_table(self.c_table)
717
+ t_al = _interp_tr(wl_nm, wl_al, tr_al) ** (self.al_thickness.cgs / self.table_thickness.cgs)
718
+ t_ox = _interp_tr(wl_nm, wl_ox, tr_ox) ** (self.oxide_thickness.cgs / self.table_thickness.cgs)
719
+ t_c = _interp_tr(wl_nm, wl_c, tr_c) ** (self.c_thickness.cgs / self.table_thickness.cgs)
720
+ return t_al * t_ox * t_c * self.mesh_throughput
721
+
722
+ def visible_light_throughput(self) -> float:
723
+ """
724
+ Visible-light transmission of the filter, including the mesh.
725
+
726
+ It falls by a factor of 10 per `visible_decade_thickness` of
727
+ aluminium.
728
+ """
729
+ thickness_aa = self.al_thickness.to(u.angstrom).value
730
+ layers = thickness_aa / self.visible_decade_thickness.to_value(u.angstrom)
731
+ return 10.0 ** (-layers) * self.mesh_throughput
732
+
733
+
734
+ # -----------------------------------------------------------------------------
735
+ # Configuration objects
736
+ # -----------------------------------------------------------------------------
737
+ @_keep_old_names
738
+ @dataclass
739
+ class Detector_SWC:
740
+ """
741
+ EUVST-SW detector.
742
+
743
+ The dark current is computed from the CCD temperature when the detector
744
+ is made, and stored as `dark_current`.
745
+
746
+ Parameters
747
+ ----------
748
+ ccd_temperature : u.Quantity
749
+ CCD temperature. Default -60 Celsius.
750
+ qe_vis, qe_euv : float
751
+ Quantum efficiency for visible stray light and for EUV. Default 1.0
752
+ and 0.76.
753
+ read_noise_rms : u.Quantity
754
+ Read noise. Default 10 electron per pixel.
755
+ dark_current_293k : u.Quantity
756
+ Dark current at 293 K, scaled to `ccd_temperature` by the
757
+ non-inverted mode law of :func:`calculate_dark_current`. Default
758
+ 20000 electron per pixel per second. `_dark_current_293k` is an old
759
+ name for it, which still works with a FutureWarning.
760
+ gain_e_per_dn : u.Quantity
761
+ Electrons per DN. Default 2.78.
762
+ max_dn : u.Quantity
763
+ Digitiser maximum, where DN are clipped. Default 65535 DN per pixel.
764
+ full_well : u.Quantity
765
+ CCD full well, for comparison only: nothing is clipped at it.
766
+ Default 150000 electron per pixel. Keyword only.
767
+ pix_size : u.Quantity
768
+ Pixel size. Default 13.5 um.
769
+ wvl_res : u.Quantity
770
+ Wavelength step from one pixel to the next. Default 16.9 mA.
771
+ plate_scale_angle : u.Quantity
772
+ Angle on the sky of one pixel along the slit. Default 0.159 arcsec.
773
+ material : str
774
+ Detector material. Only `"silicon"` is modelled.
775
+ filter_distance : u.Quantity
776
+ Distance from the filter to the detector, for pinhole diffraction.
777
+ Default 250 mm.
778
+ shutter : bool
779
+ Whether frames are taken with the mechanical shutter, which keeps the
780
+ CCDs dark while a frame is cleared and read. Default True. An
781
+ instrument run models frames taken with it and refuses False; the
782
+ read-out model, :mod:`eclipse.readout`, takes either. Keyword
783
+ only, as are the settings below, which describe the SW camera's CCDs
784
+ and their read-out.
785
+ n_rows, n_columns : int
786
+ Pixels of each CCD along the dispersion and along the slit. Default
787
+ 2048 by 2048 (SOLC-EUVST-MSSL-SP-0001 v1.4).
788
+ ccd_gap : u.Quantity
789
+ Gap between the two CCDs' imaging areas, along the dispersion.
790
+ Default 1 mm.
791
+ row_transfer_time : u.Quantity
792
+ Time to move every row one step towards the serial register.
793
+ Default 15 us, as in the SWC modes table and the read-out times of
794
+ SOLC-EUVST-MSSL-RP-0007 v1.2 Table 5-11. It can be changed in
795
+ flight.
796
+ pixel_period : u.Quantity
797
+ Time to move one pixel along the serial register and digitise it.
798
+ Default 500 ns (SOLC-EUVST-MSSL-RS-0002 v2.0).
799
+ serial_prescan, serial_overscan : int
800
+ Samples each output digitises before and after the image pixels of
801
+ each row it reads. Default 50 and 20. Each can be set in flight,
802
+ from 0 to 200.
803
+ parallel_overscan_rows : int
804
+ Rows clocked and read after the last image row. Default 20.
805
+ cte_parallel, cte_serial : float
806
+ Charge transfer efficiency along the columns and along the serial
807
+ register: the fraction of a packet's charge one transfer moves, the
808
+ rest being left to the next packet. Default 1, a perfect transfer.
809
+ SOLC-EUVST-MSSL-SP-0001 v1.4 CCD-4.5.1 requires more than 0.999993
810
+ at -60 Celsius at the start of the mission, and
811
+ SOLC-EUVST-MSSL-RP-0007 v1.2 Table 6.1 gives 0.99999 as typical for
812
+ both.
813
+ fano_factor : float or None
814
+ Fano factor, which sets the spread in electrons per photon. Default
815
+ None, for the material's in :data:`DETECTOR_MATERIALS` (0.115 for
816
+ silicon).
817
+ pair_energy_300k, pair_energy_slope : u.Quantity or None
818
+ Energy to free one electron-hole pair at 300 K, and its change per
819
+ kelvin; it sets the electrons each photon frees. Default None, for
820
+ the material's in :data:`DETECTOR_MATERIALS` (3.71 eV and -0.0006 eV
821
+ per kelvin for silicon).
822
+ dispersion : tuple of float
823
+ Wavelength in Angstrom against position along the dispersion in mm
824
+ from the focal plane centre: polynomial coefficients, lowest order
825
+ first. Default :data:`DISPERSION_COEFFICIENTS`.
826
+ wavelength_offset : u.Quantity
827
+ Added to every row's wavelength, for a wavelength calibration of the
828
+ camera as built or in flight. Default 0 Angstrom.
829
+ lit_band : tuple of u.Quantity
830
+ Wavelengths between which light reaches the CCDs; outside them a
831
+ baffle blocks the beam. Default 170.0 and 212.3 Angstrom.
832
+ slit_image_tilt : tuple of float
833
+ Drift of a line along the dispersion, in rows, between the field
834
+ centre and 140 arcsec from it: a linear and a quadratic term.
835
+ Default (0, 0), a line on one row along the whole slit.
836
+ :data:`MEASURED_SLIT_IMAGE_TILT` is measured from the ray-traced
837
+ spots in the SW footprint figure of RSC-2022021C.
838
+
839
+ Notes
840
+ -----
841
+ `dispersion`, `wavelength_offset`, `lit_band` and `slit_image_tilt`
842
+ describe the SW focal plane, which
843
+ :class:`eclipse.readout.FocalPlane_SWC` models. An instrument run
844
+ takes its wavelengths from `wvl_res`, not from the focal plane. It uses
845
+ the focal plane only to find the row recording each wavelength, for the
846
+ dark current, and, with `Simulation.focal_plane_dispersion`, the width
847
+ of its pixels. It refuses values other than the defaults, which would
848
+ make these disagree with its wavelengths.
849
+ """
850
+ _OLD_NAMES = {"_dark_current_293k": "dark_current_293k"}
851
+
852
+ ccd_temperature: u.Quantity = field(
853
+ default=-60 * u.deg_C,
854
+ metadata={"source": "Not recorded. SOLC-EUVST-MSSL-RP-0007 v1.2, Sec. 5.5.2.1, gives a "
855
+ "goal of -50 to -60 Celsius", "unit": "Celsius"})
856
+ qe_vis: float = field(default=1.0, metadata={"source": _SW_QE_SOURCE})
857
+ qe_euv: float = field(default=0.76, metadata={"source": _SW_QE_SOURCE})
858
+ read_noise_rms: u.Quantity = field(
859
+ default=10.0 * u.electron / u.pixel,
860
+ metadata={"source": "Not recorded. SOLC-EUVST-MSSL-SP-0001 v1.4 CCD-4.7.1 requires 6 "
861
+ "electrons rms; SOLC-EUVST-MSSL-RP-0007 v1.2, Table 6-1, predicts "
862
+ "11.0 electrons at most", "unit": "electrons"})
863
+ dark_current: u.Quantity = field(init=False)
864
+ dark_current_293k: u.Quantity = field(
865
+ default=20000.0 * u.electron / (u.pixel * u.s),
866
+ metadata={"source": "SOLC-EUVST-MSSL-SP-0001 v1.4 CCD-4.7.3 (requirement)",
867
+ "unit": "electrons per pixel per second"})
868
+ gain_e_per_dn: u.Quantity = field(
869
+ default=2.78 * u.electron / u.DN,
870
+ metadata={"source": "MSSL engineering model test results; SOLC-EUVST-MSSL-RP-0012 v2.0, "
871
+ "Table 5-5, gives the same design value",
872
+ "unit": "electrons per DN"})
873
+ max_dn: u.Quantity = field(
874
+ default=65535 * u.DN / u.pixel,
875
+ metadata={"source": "SOLC-EUVST-MSSL-RP-0012 v2.0, Table 5-5: a 16-bit converter",
876
+ "unit": "DN"})
877
+ # Peak charge storage of the CCD42-40 in non-inverted mode, where
878
+ # resolution begins to degrade: 150 ke- typical, 80 ke- minimum (Teledyne
879
+ # e2v CCD42-40 BSI datasheet, 1B300000-A1A version 1, January 2024). It is
880
+ # below the 182 ke- the FEE accepts, so a pixel fills before the digitiser
881
+ # does. Nothing is clipped or spilled at it; it only shows which pixels a
882
+ # frame would saturate. Keyword-only, so the fields after it keep their
883
+ # places in the constructor.
884
+ full_well: u.Quantity = field(
885
+ default=150000 * u.electron / u.pixel, kw_only=True,
886
+ metadata={"source": "Teledyne e2v CCD42-40 BSI datasheet, 1B300000-A1A version 1 "
887
+ "(January 2024); SOLC-EUVST-MSSL-SP-0001 v1.4 CCD-4.6.1 requires "
888
+ "more than 100000, typically 150000. SOLC-EUVST-MSSL-RP-0007 v1.2, "
889
+ "Table 6-1, gives 75000 typical for the image area",
890
+ "unit": "electrons"})
891
+ pix_size: u.Quantity = field(default=(13.5 * u.um).cgs / u.pixel,
892
+ metadata={"source": _SW_CCD_SPEC, "unit": "micron"})
893
+ wvl_res: u.Quantity = field(
894
+ default=(16.9 * u.mAA).cgs / u.pixel,
895
+ metadata={"source": "RSC-2022021C: 1.253 mA per micron at the chief ray",
896
+ "unit": "mA per pixel"})
897
+ plate_scale_angle: u.Quantity = field(
898
+ default=0.159 * u.arcsec / u.pixel,
899
+ metadata={"source": "RSC-2022021C: 0.0118 arcsec per micron", "unit": "arcsec per pixel"})
900
+ material: str = "silicon"
901
+ filter_distance: u.Quantity = field(default=250 * u.mm,
902
+ metadata={"source": _NOT_RECORDED, "unit": "mm"})
903
+ # Settings of the camera's CCDs and their read-out, which
904
+ # eclipse.readout models. An instrument run uses only their timing, for
905
+ # the dark current collected during the clear and the read-out.
906
+ shutter: bool = field(
907
+ default=True, kw_only=True,
908
+ metadata={"source": "SOLC-EUVST-MSSL-RP-0007 v1.2, Sec. 6.1.2: the SW CCD uses a "
909
+ "mechanical shutter"})
910
+ n_rows: int = field(default=2048, kw_only=True, metadata={"source": _SW_CCD_SPEC})
911
+ n_columns: int = field(default=2048, kw_only=True, metadata={"source": _SW_CCD_SPEC})
912
+ ccd_gap: u.Quantity = field(
913
+ default=1.0 * u.mm, kw_only=True,
914
+ metadata={"source": "SOLC-EUVST-MSSL-RP-0007 v1.2, Sec. 7.1; RSC-2022021C", "unit": "mm"})
915
+ row_transfer_time: u.Quantity = field(
916
+ default=15.0 * u.us, kw_only=True,
917
+ metadata={"source": "SWC modes table; it gives the read-out times of "
918
+ "SOLC-EUVST-MSSL-RP-0007 v1.2, Table 5-11", "unit": "us"})
919
+ pixel_period: u.Quantity = field(
920
+ default=500.0 * u.ns, kw_only=True,
921
+ metadata={"source": "SOLC-EUVST-MSSL-RS-0002 v2.0", "unit": "ns"})
922
+ serial_prescan: int = field(
923
+ default=50, kw_only=True,
924
+ metadata={"source": _SCAN_SOURCE})
925
+ serial_overscan: int = field(
926
+ default=20, kw_only=True,
927
+ metadata={"source": _SCAN_SOURCE})
928
+ parallel_overscan_rows: int = field(default=20, kw_only=True,
929
+ metadata={"source": _NOT_RECORDED})
930
+ cte_parallel: float = field(default=1.0, kw_only=True,
931
+ metadata={"source": _CTE_SOURCE})
932
+ cte_serial: float = field(default=1.0, kw_only=True,
933
+ metadata={"source": _CTE_SOURCE})
934
+ # Properties of the material, None for those in DETECTOR_MATERIALS.
935
+ fano_factor: float | None = field(
936
+ default=None, kw_only=True,
937
+ metadata={"source": _NOT_RECORDED})
938
+ pair_energy_300k: u.Quantity | None = field(
939
+ default=None, kw_only=True,
940
+ metadata={"source": _NOT_RECORDED, "unit": "eV"})
941
+ pair_energy_slope: u.Quantity | None = field(
942
+ default=None, kw_only=True,
943
+ metadata={"source": _NOT_RECORDED, "unit": "eV per K"})
944
+ # The SW focal plane, which eclipse.readout models.
945
+ dispersion: tuple = field(
946
+ default=DISPERSION_COEFFICIENTS, kw_only=True,
947
+ metadata={"source": "Fitted to RSC-2022021C: the detector edges, the focal plane "
948
+ "centre, the chief ray and the ray-traced line positions"})
949
+ wavelength_offset: u.Quantity = field(
950
+ default=0.0 * u.Angstrom, kw_only=True,
951
+ metadata={"source": "SOLC-EUVST-MSSL-RP-0007 v1.2, page 45", "unit": "Angstrom"})
952
+ lit_band: tuple = field(
953
+ default=(170.0 * u.Angstrom, 212.3 * u.Angstrom), kw_only=True,
954
+ metadata={"source": "The required range, RSC-2022021C REQ-103 and "
955
+ "SOLC-EUVST-MSSL-RP-0007 v1.2, Table 7-2. No document gives the "
956
+ "baffle's edges, so where the band stops to within a row or two is "
957
+ "a modelling choice", "unit": "Angstrom"})
958
+ slit_image_tilt: tuple = field(
959
+ default=(0.0, 0.0), kw_only=True,
960
+ metadata={"source": "No document gives it. Measured from the ray-traced spots in the SW "
961
+ "footprint figure of RSC-2022021C, where a row is about a quarter "
962
+ "of a pixel of the figure"})
963
+
964
+ def __post_init__(self):
965
+ _check_detector(self)
966
+ _check_focal_plane(self)
967
+ for name in ("cte_parallel", "cte_serial"):
968
+ value = getattr(self, name)
969
+ if (isinstance(value, bool) or not isinstance(value, (int, float, np.floating))
970
+ or not 0 < value <= 1):
971
+ raise ValueError(f"detector.{name} is the fraction of a packet's charge one "
972
+ f"transfer moves, above 0 and at most 1, got {value!r}.")
973
+ # Scan limits are the FEE's settable range (SOLC-EUVST-MSSL-RS-0002 v2.0).
974
+ for name, least, most in (("n_rows", 1, None), ("n_columns", 1, None),
975
+ ("serial_prescan", 0, 200), ("serial_overscan", 0, 200),
976
+ ("parallel_overscan_rows", 0, None)):
977
+ value = getattr(self, name)
978
+ if isinstance(value, bool) or not isinstance(value, (int, np.integer)) or value < least:
979
+ raise ValueError(f"detector.{name} must be a whole number, {least} or more, "
980
+ f"got {value!r}.")
981
+ if most is not None and value > most:
982
+ raise ValueError(f"detector.{name} can be set between {least} and {most}, "
983
+ f"got {value}.")
984
+ self.dark_current = self.calculate_dark_current(self.ccd_temperature,
985
+ self.dark_current_293k)
986
+
987
+ @property
988
+ def si_fano(self) -> float:
989
+ """Fano factor: `fano_factor`, or the material's."""
990
+ return _material_value(self, "fano_factor")
991
+
992
+ @property
993
+ def pair_energy(self) -> u.Quantity:
994
+ """Energy to free one electron-hole pair at `ccd_temperature`."""
995
+ return _pair_energy(self)
996
+
997
+ @staticmethod
998
+ def calculate_dark_current(temp: u.Quantity,
999
+ dark_current_293k: u.Quantity | None = None) -> u.Quantity:
1000
+ """
1001
+ Dark current of the EUVST-SW CCD at a given temperature.
1002
+
1003
+ Parameters
1004
+ ----------
1005
+ temp : u.Quantity
1006
+ CCD temperature. Below 198 K the dark current at 198 K is used;
1007
+ above 300 K the temperature is refused.
1008
+ dark_current_293k : u.Quantity, optional
1009
+ Dark current at 293 K. Default 20000 electron per pixel per
1010
+ second.
1011
+
1012
+ Returns
1013
+ -------
1014
+ u.Quantity
1015
+ Dark current in electron per pixel per second.
1016
+ """
1017
+ rate = Detector_SWC.dark_current_293k if dark_current_293k is None else dark_current_293k
1018
+ return calculate_dark_current(temp, rate, ccd_type="NIMO")
1019
+
1020
+ @property
1021
+ def plate_scale_length(self) -> u.Quantity:
1022
+ """Length on the Sun, seen from 1 AU, of one pixel along the slit."""
1023
+ return angle_to_distance(self.plate_scale_angle * 1*u.pix) / u.pixel
1024
+
1025
+
1026
+ @_keep_old_names
1027
+ @dataclass
1028
+ class Detector_EIS:
1029
+ """
1030
+ Hinode/EIS detector.
1031
+
1032
+ The pixel, noise and gain settings of `Detector_SWC`, with EIS values:
1033
+ quantum efficiency 0.64 for EUV and 0.65 for visible light, read noise 5
1034
+ electron per pixel, 6.3 electron per DN, 22.3 mA per pixel and 1 arcsec
1035
+ per pixel along the slit. Its `dark_current_293k` is 250 electron per
1036
+ pixel per second, scaled by the advanced inverted mode law of
1037
+ :func:`calculate_dark_current`. `fano_factor`, `pair_energy_300k` and
1038
+ `pair_energy_slope` are as for `Detector_SWC`. It has none of SWC's
1039
+ full-well, filter, shutter, focal plane or CCD read-out settings.
1040
+ """
1041
+ _OLD_NAMES = {"_dark_current_293k": "dark_current_293k"}
1042
+
1043
+ ccd_temperature: u.Quantity = field(default=-60 * u.deg_C,
1044
+ metadata={"source": _NOT_RECORDED, "unit": "Celsius"})
1045
+ qe_euv: float = field(default=0.64, metadata={"source": "EIS Software Note 2"})
1046
+ qe_vis: float = field(default=0.65, metadata={"source": "MSSL engineering test report"})
1047
+ read_noise_rms: u.Quantity = field(default=5.0 * u.electron / u.pixel,
1048
+ metadata={"source": _NOT_RECORDED, "unit": "electrons"})
1049
+ dark_current: u.Quantity = field(init=False)
1050
+ dark_current_293k: u.Quantity = field(
1051
+ default=250.0 * u.electron / (u.pixel * u.s),
1052
+ metadata={"source": _NOT_RECORDED, "unit": "electrons per pixel per second"})
1053
+ gain_e_per_dn: u.Quantity = field(
1054
+ default=6.3 * u.electron / u.DN,
1055
+ metadata={"source": _NOT_RECORDED, "unit": "electrons per DN"})
1056
+ max_dn: u.Quantity = field(default=65535 * u.DN / u.pixel,
1057
+ metadata={"source": _NOT_RECORDED, "unit": "DN"})
1058
+ pix_size: u.Quantity = field(default=(13.5 * u.um).cgs / u.pixel,
1059
+ metadata={"source": _NOT_RECORDED, "unit": "micron"})
1060
+ wvl_res: u.Quantity = field(default=(22.3 * u.mAA).cgs / u.pixel,
1061
+ metadata={"source": _NOT_RECORDED, "unit": "mA per pixel"})
1062
+ plate_scale_angle: u.Quantity = field(
1063
+ default=1 * u.arcsec / u.pixel,
1064
+ metadata={"source": _NOT_RECORDED, "unit": "arcsec per pixel"})
1065
+ material: str = "silicon"
1066
+ # Properties of the material, None for those in DETECTOR_MATERIALS.
1067
+ fano_factor: float | None = field(default=None, kw_only=True)
1068
+ pair_energy_300k: u.Quantity | None = field(default=None, kw_only=True)
1069
+ pair_energy_slope: u.Quantity | None = field(default=None, kw_only=True)
1070
+
1071
+ @property
1072
+ def si_fano(self) -> float:
1073
+ """Fano factor: `fano_factor`, or the material's."""
1074
+ return _material_value(self, "fano_factor")
1075
+
1076
+ @property
1077
+ def pair_energy(self) -> u.Quantity:
1078
+ """Energy to free one electron-hole pair at `ccd_temperature`."""
1079
+ return _pair_energy(self)
1080
+
1081
+ def __post_init__(self):
1082
+ _check_detector(self)
1083
+ self.dark_current = self.calculate_dark_current(self.ccd_temperature,
1084
+ self.dark_current_293k)
1085
+
1086
+ @property
1087
+ def plate_scale_length(self) -> u.Quantity:
1088
+ """Length on the Sun, seen from 1 AU, of one pixel along the slit."""
1089
+ return angle_to_distance(self.plate_scale_angle * 1*u.pix) / u.pixel
1090
+
1091
+ @staticmethod
1092
+ def calculate_dark_current(temp: u.Quantity,
1093
+ dark_current_293k: u.Quantity | None = None) -> u.Quantity:
1094
+ """
1095
+ Dark current of the EIS CCD at a given temperature.
1096
+
1097
+ Parameters
1098
+ ----------
1099
+ temp : u.Quantity
1100
+ CCD temperature. Below 198 K the dark current at 198 K is used;
1101
+ above 300 K the temperature is refused.
1102
+ dark_current_293k : u.Quantity, optional
1103
+ Dark current at 293 K. Default 250 electron per pixel per second.
1104
+
1105
+ Returns
1106
+ -------
1107
+ u.Quantity
1108
+ Dark current in electron per pixel per second.
1109
+ """
1110
+ rate = Detector_EIS.dark_current_293k if dark_current_293k is None else dark_current_293k
1111
+ return calculate_dark_current(temp, rate, ccd_type="AIMO")
1112
+
1113
+
1114
+ @dataclass
1115
+ class Telescope_EUVST:
1116
+ """
1117
+ EUVST telescope, with the EUVST-SW grating and filter.
1118
+
1119
+ Parameters
1120
+ ----------
1121
+ D_ap : u.Quantity
1122
+ Aperture diameter. Half of the aperture area feeds the SW channel.
1123
+ Default 0.28 m.
1124
+ microroughness_sigma : u.Quantity
1125
+ RMS roughness of the primary mirror. Default 0.3 nm.
1126
+ filter : AluminiumFilter
1127
+ Filter. Default `AluminiumFilter()`.
1128
+ psf_type : str
1129
+ PSF shape. Only `"gaussian"` is modelled.
1130
+ psf_params : list of u.Quantity or None
1131
+ FWHMs in pixels of the optical design's PSF, along the slit and in
1132
+ wavelength. Default None, which takes them from `field_of_view`.
1133
+ psf_slit_width : u.Quantity or None
1134
+ Slit the FWHM in wavelength was measured with. The FWHM for other
1135
+ slits is derived from it. Default 0.2 arcsec.
1136
+ field_of_view : str or None
1137
+ Field the optical design's PSF is the worst case over, in arcsec:
1138
+ `"280x280"`, `"100x100"` or `"70x70"`. It sets `psf_params` when
1139
+ that is not given. Default None, which is `"280x280"`, the full
1140
+ scan. Keyword only.
1141
+ resolution_budget : str
1142
+ Blur added in quadrature to the optical design's PSF, from the
1143
+ resolution budget RSC-2022033B: `"current_estimate"`, `"allocation"`
1144
+ or `"none"`. Default `"current_estimate"`. Keyword only.
1145
+ pointing_jitter : u.Quantity or None
1146
+ FWHM of the spacecraft's pointing jitter, in place of the budget's
1147
+ 0.110 arcsec. It blurs along and across the slit, and the grating's
1148
+ micro-vibration is taken as 2/3 of it, as in the budget. It is added
1149
+ even with `resolution_budget="none"`. Default None, the budget's
1150
+ own. Keyword only.
1151
+ psf_across_slit : u.Quantity or None
1152
+ FWHM of the telescope optics' blur across the slit, which brings in
1153
+ light from either side of it. The budget's blur before the slit is
1154
+ added to it in quadrature. Default None, for none. Keyword only.
1155
+ slit_length : u.Quantity
1156
+ Length of the slits. Default 300 arcsec. Keyword only.
1157
+ fiducial_centres : list of u.Quantity
1158
+ Middle of each fiducial along the slit, from the slit's centre. A
1159
+ fiducial is a short gap in the slit, used to co-align observations
1160
+ made with different slits. Default -25 and 70 arcsec. Keyword only.
1161
+ fiducial_lengths : list of u.Quantity
1162
+ Fiducial length for the 0.2, 0.4, 0.8 and 1.6 arcsec slits, in that
1163
+ order. Default 0.8, 0.8, 0.8 and 1.6 arcsec. Keyword only.
1164
+ pm_table, grating_table : path
1165
+ Tables of primary mirror reflectance and grating efficiency, two
1166
+ columns: wavelength in nm and value. Default the packaged tables.
1167
+ """
1168
+ D_ap: u.Quantity = field(
1169
+ default=0.28 * u.m,
1170
+ metadata={"source": "RSC-2022021C, Optical Design Summary: entrance pupil 280 mm",
1171
+ "unit": "m"})
1172
+ microroughness_sigma: u.Quantity = field(default=0.3 * u.nm,
1173
+ metadata={"source": _NOT_RECORDED, "unit": "nm"})
1174
+ filter: AluminiumFilter = field(default_factory=AluminiumFilter)
1175
+ psf_type: str = "gaussian"
1176
+ # [spatial_fwhm, spectral_fwhm] in pixels, or None to take them from
1177
+ # field_of_view (FIELDS_OF_VIEW).
1178
+ psf_params: list | None = field(default=None,
1179
+ metadata={"source": "RSC-2022021C"})
1180
+ # Slit width the spectral FWHM in psf_params is for. RSC-2022021C quotes
1181
+ # the spectral resolution for the 0.2 arcsec slit as the optics FWHM after
1182
+ # the slit (0.352 arcsec at 212.3 A) added in quadrature to the slit width
1183
+ # (giving 0.405 arcsec, 43.00 mA). The spectral PSF for any other slit is
1184
+ # derived from it; see radiometric.spectral_psf_fwhm.
1185
+ psf_slit_width: u.Quantity = field(
1186
+ default=0.2 * u.arcsec,
1187
+ metadata={"source": "RSC-2022021C, Appendix A.2", "unit": "arcsec"})
1188
+ # Telescope optics' blur across the slit: the FWHM of their image at the
1189
+ # slit, which sets how much light from beside the slit falls into it. None
1190
+ # leaves it out; the resolution budget's blur before the slit still
1191
+ # applies (radiometric.across_slit_fwhm). This is not psf_params[0], the
1192
+ # blur along the slit measured at the detector, which includes the
1193
+ # spectrograph after the slit.
1194
+ psf_across_slit: u.Quantity | None = field(
1195
+ default=None, kw_only=True,
1196
+ metadata={"source": _NOT_RECORDED, "unit": "arcsec"})
1197
+ field_of_view: str | None = field(default=None, kw_only=True,
1198
+ metadata={"source": "RSC-2022021C"})
1199
+ resolution_budget: str = field(
1200
+ default="current_estimate", kw_only=True,
1201
+ metadata={"source": "RSC-2022033B rev. B (28 Oct 2025), Tables 3-1 and 4-1"})
1202
+ pointing_jitter: u.Quantity | None = field(
1203
+ default=None, kw_only=True,
1204
+ metadata={"source": "RSC-2022033B rev. B, Table 3-1 row 20", "unit": "arcsec"})
1205
+ # The slits are 4.07 mm long, 300 arcsec at the slit's 13.57 um per arcsec,
1206
+ # and each has two fiducials, gaps 10.9 um (0.8 arcsec) long in the three
1207
+ # narrow slits and 21.8 um (1.6 arcsec) in the widest, centred -340 um
1208
+ # (-25 arcsec) and +950 um (+70 arcsec) from its centre along the slit
1209
+ # (TASI-SCE-SSS-0001 issue 1, R-SA-SYS-REQ-1160 and 1170). The documents
1210
+ # do not say which end of the slit is positive on the detector.
1211
+ slit_length: u.Quantity = field(default=300.0 * u.arcsec, kw_only=True,
1212
+ metadata={"source": _SLIT_REQUIREMENTS, "unit": "arcsec"})
1213
+ fiducial_centres: list = field(
1214
+ default_factory=lambda: [-25.0 * u.arcsec, 70.0 * u.arcsec], kw_only=True,
1215
+ metadata={"source": _SLIT_REQUIREMENTS, "unit": "arcsec"})
1216
+ fiducial_lengths: list = field(
1217
+ default_factory=lambda: [0.8 * u.arcsec, 0.8 * u.arcsec, 0.8 * u.arcsec, 1.6 * u.arcsec],
1218
+ kw_only=True,
1219
+ metadata={"source": _SLIT_REQUIREMENTS, "unit": "arcsec"})
1220
+
1221
+ # Wavelength-dependent efficiency tables
1222
+ pm_table: Path = field(default_factory=lambda: files('eclipse') / 'data' / 'throughput' / 'primary_mirror_coating_reflectance.dat',
1223
+ metadata={"source": _SENSITIVITY_BUDGET})
1224
+ grating_table: Path = field(default_factory=lambda: files('eclipse') / 'data' / 'throughput' / 'grating_reflection_efficiency.dat',
1225
+ metadata={"source": _SENSITIVITY_BUDGET})
1226
+
1227
+ # Worst-case FWHMs of the optical design's PSF in each field, in pixels
1228
+ # at 0.159 arcsec per pixel, from RSC-2022021C (Mar 2025). Along the slit,
1229
+ # 0.423, 0.195 and 0.161 arcsec, with the slit scanned across the field
1230
+ # (FOV2, FOV1 and FOV0). In wavelength, 43.00 mA with the 0.2 arcsec slit,
1231
+ # given for the 70 x 70 arcsec field only.
1232
+ FIELDS_OF_VIEW = {"280x280": [2.66 * u.pixel, 2.54 * u.pixel],
1233
+ "100x100": [1.23 * u.pixel, 2.54 * u.pixel],
1234
+ "70x70": [1.01 * u.pixel, 2.54 * u.pixel]}
1235
+
1236
+ def __post_init__(self):
1237
+ _check_settings(self, "telescope", positive=("D_ap", "psf_slit_width", "slit_length"),
1238
+ non_negative=("microroughness_sigma",), optional=("psf_slit_width",))
1239
+ if self.field_of_view is not None and (not isinstance(self.field_of_view, str)
1240
+ or self.field_of_view not in self.FIELDS_OF_VIEW):
1241
+ raise ValueError(f"telescope.field_of_view must be one of "
1242
+ f"{', '.join(self.FIELDS_OF_VIEW)}, got {self.field_of_view!r}.")
1243
+ if self.psf_params is None:
1244
+ self.field_of_view = self.field_of_view or "280x280"
1245
+ self.psf_params = [q.copy() for q in self.FIELDS_OF_VIEW[self.field_of_view]]
1246
+ elif (self.field_of_view is not None
1247
+ and not _same_widths(self.psf_params, self.FIELDS_OF_VIEW[self.field_of_view])):
1248
+ raise ValueError(f"telescope.field_of_view sets psf_params, so give one or the "
1249
+ f"other; got {self.field_of_view!r} and {self.psf_params!r}.")
1250
+ _check_psf_params(self)
1251
+ _check_resolution_budget(self)
1252
+ _check_fiducials(self)
1253
+ _check_tables(self, ("pm_table", "grating_table"), "telescope")
1254
+
1255
+ def slit_openings(self, slit_width: u.Quantity) -> u.Quantity:
1256
+ """
1257
+ Stretches of the slit that let light through: its length less its fiducials.
1258
+
1259
+ Parameters
1260
+ ----------
1261
+ slit_width : u.Quantity
1262
+ Slit, 0.2, 0.4, 0.8 or 1.6 arcsec, which sets the fiducial length.
1263
+
1264
+ Returns
1265
+ -------
1266
+ u.Quantity
1267
+ `(n, 2)` start and end of each open stretch, along the slit from
1268
+ its centre, in increasing order.
1269
+ """
1270
+ widths = [0.2, 0.4, 0.8, 1.6]
1271
+ width = u.Quantity(slit_width).to_value(u.arcsec)
1272
+ if width not in widths:
1273
+ raise ValueError(f"The fiducials are known for the 0.2, 0.4, 0.8 and 1.6 arcsec "
1274
+ f"slits, not {slit_width}.")
1275
+ length = self.fiducial_lengths[widths.index(width)].to_value(u.arcsec)
1276
+ centres = np.sort([c.to_value(u.arcsec) for c in self.fiducial_centres])
1277
+ half = self.slit_length.to_value(u.arcsec) / 2
1278
+ edges = np.concatenate([[-half], np.ravel(np.column_stack(
1279
+ [centres - length / 2, centres + length / 2])), [half]])
1280
+ return edges.reshape(-1, 2) * u.arcsec
1281
+
1282
+ @property
1283
+ def collecting_area(self) -> u.Quantity:
1284
+ """Aperture area feeding the SW channel: half of the total."""
1285
+ return 0.5 * np.pi * (self.D_ap / 2) ** 2 # Accounting for 50% loss due to beam division between SW and LW channels.
1286
+
1287
+ def primary_mirror_efficiency(self, wl0: u.Quantity) -> float | np.ndarray:
1288
+ """
1289
+ Primary mirror reflectance from its table, without roughness.
1290
+
1291
+ Parameters
1292
+ ----------
1293
+ wl0 : u.Quantity
1294
+ Wavelength, or an array of them.
1295
+
1296
+ Returns
1297
+ -------
1298
+ float or np.ndarray
1299
+ Reflectance, one value or one per wavelength. NaN outside the
1300
+ table's wavelengths.
1301
+ """
1302
+ wl_nm = wl0.to_value(u.nm)
1303
+ wl_pm, eff_pm = _load_throughput_table(self.pm_table)
1304
+ return _interp_tr(wl_nm, wl_pm, eff_pm)
1305
+
1306
+ def grating_efficiency(self, wl0: u.Quantity) -> float | np.ndarray:
1307
+ """
1308
+ Grating efficiency from its table.
1309
+
1310
+ Parameters
1311
+ ----------
1312
+ wl0 : u.Quantity
1313
+ Wavelength, or an array of them.
1314
+
1315
+ Returns
1316
+ -------
1317
+ float or np.ndarray
1318
+ Efficiency, one value or one per wavelength. NaN outside the
1319
+ table's wavelengths.
1320
+ """
1321
+ wl_nm = wl0.to_value(u.nm)
1322
+ wl_grat, eff_grat = _load_throughput_table(self.grating_table)
1323
+ return _interp_tr(wl_nm, wl_grat, eff_grat)
1324
+
1325
+ def microroughness_efficiency(self, wl0: u.Quantity) -> float | np.ndarray:
1326
+ """
1327
+ Fraction of light the primary mirror's roughness leaves in the image.
1328
+
1329
+ This is `exp(-(4 pi sigma / lambda)**2)` for RMS roughness sigma
1330
+ (`microroughness_sigma`) at wavelength lambda.
1331
+
1332
+ Parameters
1333
+ ----------
1334
+ wl0 : u.Quantity
1335
+ Wavelength, or an array of them.
1336
+
1337
+ Returns
1338
+ -------
1339
+ float or np.ndarray
1340
+ Fraction, one value or one per wavelength.
1341
+ """
1342
+ # Convert both wavelength and sigma to the same units (nm for convenience)
1343
+ wl_nm = wl0.to(u.nm)
1344
+ sigma_nm = self.microroughness_sigma.to(u.nm)
1345
+
1346
+ # Calculate (4*pi*sigma/lambda)^2
1347
+ roughness_term = (4 * np.pi * sigma_nm / wl_nm) ** 2
1348
+
1349
+ # Return exp(-(4*pi*sigma/lambda)^2) [Debye-Waller specular efficiency]
1350
+ return np.exp(-roughness_term.value)
1351
+
1352
+ def throughput(self, wl0: u.Quantity) -> u.Quantity:
1353
+ """
1354
+ Throughput of the primary mirror (with roughness), grating and filter.
1355
+
1356
+ Parameters
1357
+ ----------
1358
+ wl0 : u.Quantity
1359
+ Wavelength, or an array of them.
1360
+
1361
+ Returns
1362
+ -------
1363
+ u.Quantity
1364
+ Dimensionless throughput, one value or one per wavelength.
1365
+ """
1366
+ # Get wavelength-dependent efficiencies
1367
+ pm_eff_wl = self.primary_mirror_efficiency(wl0)
1368
+ grat_eff_wl = self.grating_efficiency(wl0)
1369
+
1370
+ # Apply microroughness efficiency to primary mirror efficiency
1371
+ pm_eff_with_roughness = pm_eff_wl * self.microroughness_efficiency(wl0)
1372
+
1373
+ # Calculate total throughput
1374
+ return pm_eff_with_roughness * grat_eff_wl * self.filter.total_throughput(wl0)
1375
+
1376
+ def ea_and_throughput(self, wl0: u.Quantity) -> u.Quantity:
1377
+ """
1378
+ Collecting area times throughput: the effective area before the detector.
1379
+
1380
+ Multiplied by the detector's quantum efficiency, `qe_euv`, this gives
1381
+ the instrument's effective area.
1382
+
1383
+ Parameters
1384
+ ----------
1385
+ wl0 : u.Quantity
1386
+ Wavelength, or an array of them.
1387
+
1388
+ Returns
1389
+ -------
1390
+ u.Quantity
1391
+ Area, one value or one per wavelength.
1392
+ """
1393
+ return self.collecting_area * self.throughput(wl0)
1394
+
1395
+
1396
+ @dataclass
1397
+ class Telescope_EIS:
1398
+ """
1399
+ Hinode/EIS telescope.
1400
+
1401
+ The effective area comes from the EIS calibration tables, so it varies
1402
+ with wavelength and, for the in-flight calibrations, with the date of the
1403
+ observation.
1404
+
1405
+ Parameters
1406
+ ----------
1407
+ psf_type : str
1408
+ PSF shape. Only `"gaussian"` is modelled.
1409
+ psf_params : list of u.Quantity
1410
+ PSF FWHMs in pixels, along the slit and in wavelength. Default 3 and 3
1411
+ pixels.
1412
+ psf_slit_width : u.Quantity or None
1413
+ Slit the FWHM in wavelength was measured with. Default None, which
1414
+ gives every slit the same FWHM.
1415
+ psf_across_slit : u.Quantity or None
1416
+ FWHM of the telescope's blur across the slit. Default None, for no
1417
+ blur. Keyword only.
1418
+ calibration : str
1419
+ `"ground"` (default), `"dz2013"`, `"warren2014"` or `"dz2025"`.
1420
+ `"ground"` is the pre-flight calibration, which needs no date.
1421
+ `"dz2025"` is the one to use for a real observation.
1422
+ date : str, optional
1423
+ Observation date, such as `"2012-06-03"`, needed by every calibration
1424
+ except `"ground"`. A `datetime` or an `astropy.time.Time` also works.
1425
+
1426
+ Examples
1427
+ --------
1428
+ >>> tel = Telescope_EIS() # pre-flight
1429
+ >>> tel = Telescope_EIS(calibration="dz2025", date="2012-06-03")
1430
+ """
1431
+ psf_type: str = "gaussian"
1432
+ psf_params: list = field(default_factory=lambda: [3.0 * u.pixel, 3.0 * u.pixel], # [spatial_fwhm, spectral_fwhm] in pixels
1433
+ metadata={"source": "Ugarte-Urra (2016), EIS Software Note 2",
1434
+ "unit": "pixels"})
1435
+ # The EIS PSF is not tied to a slit width, so its spectral FWHM is the
1436
+ # same for every slit. Setting this gives the slit psf_params was measured
1437
+ # with, and the spectral PSF then follows the slit as for SWC.
1438
+ psf_slit_width: u.Quantity | None = None
1439
+ # Telescope blur across the slit, the FWHM of its image at the slit, as
1440
+ # for Telescope_EUVST. None leaves it out.
1441
+ psf_across_slit: u.Quantity | None = field(default=None, kw_only=True)
1442
+ calibration: str = field(
1443
+ default="ground",
1444
+ metadata={"source": "Pre-flight MSSL calibration, April 2007 (Lang et al. 2006, Applied "
1445
+ "Optics, 45, 8689); `dz2013`, `warren2014` and `dz2025` from Del "
1446
+ "Zanna (2013), Warren, Ugarte-Urra & Landi (2014) and Del Zanna et "
1447
+ "al. (2025)"})
1448
+ date: str | None = None
1449
+
1450
+ def __post_init__(self):
1451
+ _check_psf_params(self)
1452
+ if self.calibration not in eis_calibration.CALIBRATIONS:
1453
+ raise ValueError(
1454
+ f"Unknown EIS calibration {self.calibration!r}. Choose from: "
1455
+ f"{', '.join(eis_calibration.CALIBRATIONS)}."
1456
+ )
1457
+ if self.psf_slit_width is not None and not (
1458
+ isinstance(self.psf_slit_width, u.Quantity) and self.psf_slit_width.isscalar
1459
+ and self.psf_slit_width.unit.is_equivalent(u.arcsec)
1460
+ and np.isfinite(self.psf_slit_width.value) and self.psf_slit_width.value > 0):
1461
+ raise ValueError(f"telescope.psf_slit_width must be a finite angle above zero, got "
1462
+ f"{self.psf_slit_width!r}.")
1463
+ if self.date is not None:
1464
+ self.date = eis_calibration.normalise_date(self.date)
1465
+ # Parse the date now, rather than part-way through a run when the
1466
+ # effective area is first needed.
1467
+ try:
1468
+ eis_calibration._parse_date(self.date)
1469
+ except ValueError:
1470
+ raise ValueError(f"telescope.date {self.date!r} is not a date ECLIPSE can "
1471
+ f"read; give it in ISO form, such as 2012-06-03.") from None
1472
+ elif self.calibration in eis_calibration.TIME_DEPENDENT_CALIBRATIONS:
1473
+ raise ValueError(
1474
+ f"The {self.calibration!r} EIS calibration is time-dependent, "
1475
+ f"so Telescope_EIS needs a date, for example "
1476
+ f"Telescope_EIS(calibration='{self.calibration}', "
1477
+ f"date='2012-06-03'). Use calibration='ground' for the "
1478
+ f"epoch-independent pre-flight calibration."
1479
+ )
1480
+
1481
+ def effective_area(self, wl0: u.Quantity) -> u.Quantity:
1482
+ """
1483
+ EIS effective area, including the detector quantum efficiency.
1484
+
1485
+ This is the effective area as published by the EIS calibrations.
1486
+ Wavelengths outside the two EIS bands are refused.
1487
+
1488
+ Parameters
1489
+ ----------
1490
+ wl0 : u.Quantity
1491
+ Wavelength, or an array of them.
1492
+
1493
+ Returns
1494
+ -------
1495
+ u.Quantity
1496
+ Area, one value or one per wavelength.
1497
+ """
1498
+ wl_aa = u.Quantity(wl0).to_value(u.AA)
1499
+ area = eis_calibration.effective_area(
1500
+ wl_aa, date=self.date, method=self.calibration
1501
+ )
1502
+
1503
+ if np.any(~np.isfinite(area)):
1504
+ bad = np.atleast_1d(wl_aa)[~np.isfinite(area)]
1505
+ raise ValueError(
1506
+ f"EIS has no effective area at {bad[0]:.3f} Angstrom "
1507
+ f"({bad.size} wavelength(s) affected). Its bands are "
1508
+ f"{eis_calibration.SW_BAND} and {eis_calibration.LW_BAND} "
1509
+ f"Angstrom."
1510
+ )
1511
+
1512
+ area = area * u.cm**2
1513
+ return area[0] if np.ndim(wl_aa) == 0 else area
1514
+
1515
+ def ea_and_throughput(self, wl0: u.Quantity) -> u.Quantity:
1516
+ """
1517
+ EIS effective area without the detector quantum efficiency.
1518
+
1519
+ ECLIPSE applies the quantum efficiency, `Detector_EIS.qe_euv`, in the
1520
+ detector, so this is the area the rest of the simulation uses.
1521
+
1522
+ Parameters
1523
+ ----------
1524
+ wl0 : u.Quantity
1525
+ Wavelength, or an array of them.
1526
+
1527
+ Returns
1528
+ -------
1529
+ u.Quantity
1530
+ Area, one value or one per wavelength.
1531
+ """
1532
+ # The tabulated areas include the QE the EIS calibration is quoted
1533
+ # against. ECLIPSE applies the QE separately, so it is divided out
1534
+ # here. This is the table constant on purpose, not a setting: dividing
1535
+ # by a settable value while to_electrons applies Detector_EIS.qe_euv
1536
+ # would silently scale the whole response.
1537
+ # https://hinode.nao.ac.jp/en/for-researchers/instruments/eis/fact-sheet/
1538
+ # https://solarb.mssl.ucl.ac.uk/SolarB/eis_docs/eis_notes/02_RADIOMETRIC_CALIBRATION/eis_swnote_02.pdf
1539
+ return self.effective_area(wl0) / eis_calibration.QE_IN_TABLES
1540
+
1541
+
1542
+ @dataclass
1543
+ class Simulation:
1544
+ """
1545
+ Settings of one simulation: exposure, slit, PSF and noise.
1546
+
1547
+ Unlike in the configuration file, each setting takes one value here.
1548
+
1549
+ Parameters
1550
+ ----------
1551
+ expos : u.Quantity
1552
+ Exposure time. Default 1 s.
1553
+ n_iter : int
1554
+ Number of Monte Carlo iterations. Default 10.
1555
+ slit_width : u.Quantity
1556
+ Slit width: 0.2, 0.4, 0.8 or 1.6 arcsec for SWC, 1 or 2 arcsec for
1557
+ EIS. Default 0.2 arcsec.
1558
+ ncpu : int
1559
+ Number of CPU cores to use; -1 for all of them. Default -1.
1560
+ instrument : str
1561
+ `"SWC"` (default) or `"EIS"`.
1562
+ vis_sl : u.Quantity
1563
+ Visible stray light, in photons per second per cm2: before the filter
1564
+ for SWC, and at the CCD for EIS. Default 0.
1565
+ psf : bool
1566
+ Whether to blur the spectra with the PSF. Default False.
1567
+ psf_boundary : str
1568
+ What the PSF brings in from beyond the edges of the atmosphere:
1569
+ `"replicate"` (default) or `"zero"`.
1570
+ spectral_psf : str
1571
+ How the slit is combined with the optics blur: `"quadrature"`
1572
+ (default) or `"convolution"`.
1573
+ slit_mask : bool
1574
+ Whether the slit's ends and fiducials block the light (SWC only).
1575
+ Default False. Keyword only.
1576
+ focal_plane_dispersion : bool
1577
+ Whether the width in wavelength of the pixels is that of the SW
1578
+ focal plane where the observed line lands, from
1579
+ :meth:`eclipse.readout.FocalPlane_SWC.pixel_width`, rather than the
1580
+ detector's `wvl_res` (SWC only). An instrument run sets the
1581
+ detector's `wvl_res` to it; a `Simulation` given to the functions
1582
+ directly leaves `wvl_res` as it is. Default False. Keyword only.
1583
+ noise : bool
1584
+ If False, every random draw in the detector is replaced by its mean.
1585
+ Default True.
1586
+ readout_dark_current : bool
1587
+ Whether each pixel also collects dark current while the CCD is
1588
+ cleared before the exposure and while it waits to be read out
1589
+ afterwards, as in a full-CCD frame (SWC only). Without it, dark
1590
+ current is collected over the exposure only. Default True. Keyword
1591
+ only.
1592
+ enable_pinholes : bool
1593
+ Whether to model pinholes in the filter (SWC only). Default False.
1594
+ pinhole_sizes : list of u.Quantity
1595
+ Pinhole diameters.
1596
+ pinhole_positions : list of float
1597
+ Position of each pinhole along the slit, as a fraction from 0 to 1.
1598
+ pinhole_positions_spectral : list of float
1599
+ Position of each pinhole along the spectral axis, as a fraction from
1600
+ 0 to 1. Default empty, for the middle.
1601
+ pinhole_visible_wavelength : u.Quantity
1602
+ Wavelength of the visible light through the pinholes, which sets its
1603
+ diffraction. Default 600 nm. Keyword only.
1604
+ """
1605
+ expos: u.Quantity = 1.0 * u.s # Single exposure time
1606
+ n_iter: int = 10
1607
+ slit_width: u.Quantity = field(
1608
+ default=0.2 * u.arcsec,
1609
+ metadata={"source": "RSC-2022021C", "unit": "arcsec"})
1610
+ ncpu: int = -1
1611
+ instrument: str = "SWC"
1612
+ vis_sl: u.Quantity = 0 * u.photon / (u.s * u.cm**2) # Visible stray light flux before SWC's filter; at EIS's CCD
1613
+ psf: bool = False
1614
+ # What the spatial PSF convolution assumes beyond the ends of the slit.
1615
+ # "replicate" extends the edge rows outward, assuming the Sun looks much
1616
+ # as it does at the edge of the field. "zero" treats everything outside
1617
+ # as dark, which removes real signal from the outermost rows. The
1618
+ # spectral direction is zero-filled either way: the wavelength grid runs
1619
+ # several sigma past the line, so nothing is lost at its ends.
1620
+ psf_boundary: str = "replicate"
1621
+ # How the slit enters the spectral PSF. "quadrature" keeps the PSF a
1622
+ # Gaussian and adds the slit width to the optics FWHM in quadrature, as
1623
+ # RSC-2022021C quotes the spectral resolution. "convolution" convolves
1624
+ # the optics Gaussian with the slit's rectangular image, as the same
1625
+ # document defines the line profile; it gives the flat-topped profile of
1626
+ # a wide slit, and a narrower profile than quadrature for the 0.2 arcsec
1627
+ # slit. See radiometric.spectral_line_spread.
1628
+ spectral_psf: str = "quadrature"
1629
+ # With slit_mask True, no light gets through beyond the slit's ends or in
1630
+ # its fiducials, as set in the telescope. The slit's centre is the middle
1631
+ # of the scene along the slit. The blur along the slit comes before the
1632
+ # slit, so a fiducial's edges are as sharp as the pixels sampling them.
1633
+ slit_mask: bool = field(default=False, kw_only=True)
1634
+ # The focal plane's width is used for every pixel of the window, though
1635
+ # it changes by about 2 parts in 10,000 across 60 pixels.
1636
+ focal_plane_dispersion: bool = field(default=False, kw_only=True)
1637
+ # With noise False, every random draw in the detector chain is replaced by
1638
+ # its mean, so the run gives the signal the instrument would measure on
1639
+ # average. Quantisation stays: DN are still rounded and still clipped at
1640
+ # the digitiser maximum, max_dn.
1641
+ noise: bool = True
1642
+ # A run takes only the rows of its own wavelengths to be read. The clear
1643
+ # and the read-out then add 0.03 to 0.05 s for 32 rows with the default
1644
+ # timing.
1645
+ readout_dark_current: bool = field(default=True, kw_only=True)
1646
+ enable_pinholes: bool = False
1647
+ pinhole_sizes: List[u.Quantity] = field(default_factory=list)
1648
+ pinhole_positions: List[float] = field(default_factory=list)
1649
+ # Position along the spectral axis, as a fraction (0.0 to 1.0) of the
1650
+ # detector width, one per pinhole. Empty (the default) puts every pinhole
1651
+ # at the centre of the spectral window. On a slit-scan spectrograph this
1652
+ # fraction decides which emission lines a pinhole contaminates.
1653
+ pinhole_positions_spectral: List[float] = field(default_factory=list)
1654
+ pinhole_visible_wavelength: u.Quantity = field(
1655
+ default=600.0 * u.nm, kw_only=True, metadata={"source": _NOT_RECORDED, "unit": "nm"})
1656
+
1657
+ @property
1658
+ def slit_scan_step(self) -> u.Quantity:
1659
+ return self.slit_width
1660
+
1661
+ def __post_init__(self):
1662
+ _check_settings(self, "simulation",
1663
+ positive=("expos", "slit_width", "pinhole_visible_wavelength"),
1664
+ non_negative=("vis_sl",))
1665
+ if isinstance(self.n_iter, bool) or not isinstance(self.n_iter, int) or self.n_iter < 1:
1666
+ raise ValueError(f"n_iter must be a whole number of iterations, 1 or more, got "
1667
+ f"{self.n_iter!r}.")
1668
+ for index, size in enumerate(self.pinhole_sizes):
1669
+ if not (isinstance(size, u.Quantity) and size.unit.is_equivalent(u.um) and size > 0):
1670
+ raise ValueError(f"pinhole_sizes[{index}] must be a diameter, a positive "
1671
+ f"length, such as 5 um; got {size!r}.")
1672
+ # EIS has 1 and 2 arcsec slits, and 40 and 266 arcsec slots, which
1673
+ # ECLIPSE does not model.
1674
+ allowed_slits = {
1675
+ "EIS": [1, 2],
1676
+ "SWC": [0.2, 0.4, 0.8, 1.6],
1677
+ }
1678
+ inst = self.instrument.upper()
1679
+ slit_val = self.slit_width.to_value(u.arcsec)
1680
+ if inst == "EIS":
1681
+ if slit_val not in allowed_slits["EIS"]:
1682
+ raise ValueError("For EIS, slit_width must be 1 or 2 arcsec, its two slits.")
1683
+ elif inst in ("SWC"):
1684
+ if slit_val not in allowed_slits["SWC"]:
1685
+ raise ValueError("For SWC, slit_width must be 0.2, 0.4, 0.8, or 1.6 arcsec.")
1686
+
1687
+ if self.psf_boundary not in ("replicate", "zero"):
1688
+ raise ValueError(
1689
+ f"psf_boundary must be 'replicate' or 'zero', got "
1690
+ f"{self.psf_boundary!r}."
1691
+ )
1692
+ if self.spectral_psf not in ("quadrature", "convolution"):
1693
+ raise ValueError(
1694
+ f"spectral_psf must be 'quadrature' or 'convolution', got "
1695
+ f"{self.spectral_psf!r}."
1696
+ )
1697
+ if self.slit_mask and inst == "EIS":
1698
+ raise ValueError("slit_mask is for SWC, whose slits' fiducials ECLIPSE knows; "
1699
+ "EIS's slits are taken to be open along their length.")
1700
+ if self.focal_plane_dispersion and inst == "EIS":
1701
+ raise ValueError("focal_plane_dispersion describes the SW focal plane, so it is "
1702
+ "for SWC only.")
1703
+
1704
+ # Both pipelines zip the paired pinhole lists, and zip stops at the
1705
+ # shortest, so a mismatch would silently drop pinholes. main() runs
1706
+ # the same check on configuration files, but Simulation is also
1707
+ # constructed directly.
1708
+ self.pinhole_positions, self.pinhole_positions_spectral = check_pinhole_lists(
1709
+ self.pinhole_sizes, self.pinhole_positions,
1710
+ self.pinhole_positions_spectral)