eclipse 0.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eclipse/__init__.py +95 -0
- eclipse/analysis.py +853 -0
- eclipse/atmosphere.py +859 -0
- eclipse/band.py +477 -0
- eclipse/cli.py +138 -0
- eclipse/config.py +1710 -0
- eclipse/continuum.py +413 -0
- eclipse/data/eis_response/EIS_EffArea_A.004 +61 -0
- eclipse/data/eis_response/EIS_EffArea_B.004 +60 -0
- eclipse/data/eis_response/EIS_EffArea_B.005 +64 -0
- eclipse/data/eis_response/README_dz2025 +50 -0
- eclipse/data/eis_response/fit_eis_ea_2023-05-04_smooth.sav +0 -0
- eclipse/data/eis_response/source.txt +33 -0
- eclipse/data/science_cases.yaml +365 -0
- eclipse/data/throughput/grating_reflection_efficiency.dat +25 -0
- eclipse/data/throughput/primary_mirror_coating_reflectance.dat +25 -0
- eclipse/data/throughput/source.txt +6 -0
- eclipse/data/throughput/throughput_aluminium_1000_angstrom.dat +503 -0
- eclipse/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +503 -0
- eclipse/data/throughput/throughput_carbon_1000_angstrom.dat +503 -0
- eclipse/data_processing.py +904 -0
- eclipse/deprecated.py +161 -0
- eclipse/eis_calibration.py +573 -0
- eclipse/extern/__init__.py +0 -0
- eclipse/extern/mpfit.py +2388 -0
- eclipse/fitting.py +1734 -0
- eclipse/frame.py +594 -0
- eclipse/full_frame.py +944 -0
- eclipse/main.py +1646 -0
- eclipse/monte_carlo.py +660 -0
- eclipse/pinhole_diffraction.py +409 -0
- eclipse/psf.py +6 -0
- eclipse/radiometric.py +1328 -0
- eclipse/raster.py +869 -0
- eclipse/readout.py +1002 -0
- eclipse/resolution_budget.py +108 -0
- eclipse/results_file.py +1226 -0
- eclipse/sampling.py +351 -0
- eclipse/science_cases.py +364 -0
- eclipse/synthesis.py +2492 -0
- eclipse/synthesis_cli.py +12 -0
- eclipse/synthesis_file.py +882 -0
- eclipse/utils.py +1145 -0
- eclipse-0.13.0.dist-info/METADATA +70 -0
- eclipse-0.13.0.dist-info/RECORD +50 -0
- eclipse-0.13.0.dist-info/WHEEL +5 -0
- eclipse-0.13.0.dist-info/entry_points.txt +7 -0
- eclipse-0.13.0.dist-info/licenses/LICENSE +3 -0
- eclipse-0.13.0.dist-info/top_level.txt +2 -0
- euvst_response/__init__.py +129 -0
eclipse/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)
|