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
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hinode/EIS effective area as a function of wavelength and date.
|
|
3
|
+
|
|
4
|
+
Four calibrations, as the `calib=` keywords of `eis_get_fitdata.pro`:
|
|
5
|
+
|
|
6
|
+
`ground`
|
|
7
|
+
Pre-flight MSSL calibration, April 2007, with no time dependence.
|
|
8
|
+
Port of `eis_ea.pro`.
|
|
9
|
+
`dz2013`
|
|
10
|
+
Del Zanna (2013). Port of `eis_ltds.pro`. The long-wavelength channel
|
|
11
|
+
degrades as a polynomial, held constant after 2012-09-14 where the fit
|
|
12
|
+
ends; the short-wavelength channel does not change.
|
|
13
|
+
`warren2014`
|
|
14
|
+
Warren, Ugarte-Urra & Landi (2014). Exponential decay at each spline
|
|
15
|
+
knot, from `nrl_ea_coeff_v1.3.genx`.
|
|
16
|
+
`dz2025`
|
|
17
|
+
Del Zanna et al. (2025), the current recommendation, fitted to EIS
|
|
18
|
+
spectra from 2007 to 2022. Port of `interpol_eis_ea.pro`.
|
|
19
|
+
|
|
20
|
+
All four include the CCD quantum efficiency of 0.64, as the tables do.
|
|
21
|
+
`config.Telescope_EIS` divides it out, as ECLIPSE applies the quantum
|
|
22
|
+
efficiency in the detector.
|
|
23
|
+
|
|
24
|
+
References
|
|
25
|
+
----------
|
|
26
|
+
- Lang, J. et al. 2006, Applied Optics, 45, 8689
|
|
27
|
+
- Del Zanna, G. 2013, A&A, 555, A47
|
|
28
|
+
- Warren, H. P., Ugarte-Urra, I. & Landi, E. 2014, ApJS, 213, 11
|
|
29
|
+
- Del Zanna, G. et al. 2025, doi:10.48550/arXiv.2308.06609
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from datetime import date as _date, datetime as _datetime, timezone as _tz
|
|
35
|
+
import warnings
|
|
36
|
+
from functools import lru_cache
|
|
37
|
+
from importlib.resources import as_file, files
|
|
38
|
+
|
|
39
|
+
import numpy as np
|
|
40
|
+
from astropy.time import Time
|
|
41
|
+
from scipy.interpolate import CubicSpline
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"CALIBRATIONS",
|
|
45
|
+
"TIME_DEPENDENT_CALIBRATIONS",
|
|
46
|
+
"SW_BAND",
|
|
47
|
+
"LW_BAND",
|
|
48
|
+
"band_of",
|
|
49
|
+
"effective_area",
|
|
50
|
+
"normalise_date",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
# EIS observes two separate bands, with no effective area between them.
|
|
54
|
+
# Edges as in eis_get_band (eis_ea_nrl.pro). The latest ground tables, read
|
|
55
|
+
# by eis_ea.pro, are sampled over 165-213 and 245-292 Angstrom.
|
|
56
|
+
SW_BAND = (165.0, 213.0)
|
|
57
|
+
LW_BAND = (245.0, 292.0)
|
|
58
|
+
|
|
59
|
+
CALIBRATIONS = ("ground", "dz2013", "warren2014", "dz2025")
|
|
60
|
+
|
|
61
|
+
# 'ground' is the pre-flight calibration and has no epoch; the others do.
|
|
62
|
+
TIME_DEPENDENT_CALIBRATIONS = ("dz2013", "warren2014", "dz2025")
|
|
63
|
+
|
|
64
|
+
_DATA_DIR = files("eclipse") / "data" / "eis_response"
|
|
65
|
+
_DZ2025_SAV = _DATA_DIR / "fit_eis_ea_2023-05-04_smooth.sav"
|
|
66
|
+
|
|
67
|
+
# CCD quantum efficiency included in the tabulated areas (EIS SW Note 2).
|
|
68
|
+
# It is fixed by how the tables are quoted, not a free parameter. The
|
|
69
|
+
# detector QE that ECLIPSE applies is a separate setting on Detector_EIS.
|
|
70
|
+
QE_IN_TABLES = 0.64
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Dates
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def normalise_date(date) -> str:
|
|
78
|
+
"""Coerce a date to an ISO string.
|
|
79
|
+
|
|
80
|
+
Accepts a string, a `datetime.date`, a `datetime.datetime` or anything
|
|
81
|
+
with an `isot` attribute (`astropy.time.Time`). Note that YAML reads an
|
|
82
|
+
unquoted `2012-06-03` as a `datetime.date`.
|
|
83
|
+
"""
|
|
84
|
+
if isinstance(date, str):
|
|
85
|
+
return date
|
|
86
|
+
if hasattr(date, "isot"): # astropy.time.Time
|
|
87
|
+
return str(date.isot)
|
|
88
|
+
if isinstance(date, (_datetime, _date)):
|
|
89
|
+
return date.isoformat()
|
|
90
|
+
raise TypeError(
|
|
91
|
+
f"Cannot interpret {date!r} as an observation date. Give an ISO "
|
|
92
|
+
f"string such as '2012-06-03', a datetime, or an astropy Time."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_date(date_str: str) -> _datetime:
|
|
97
|
+
"""Parse an ISO date string to a UTC datetime.
|
|
98
|
+
|
|
99
|
+
A string with an offset is converted to UTC, not relabelled.
|
|
100
|
+
`replace(tzinfo=utc)` would keep the wall clock and move the instant, so
|
|
101
|
+
`2012-06-03T00:00:00-05:00` would enter the degradation calculation five
|
|
102
|
+
hours early.
|
|
103
|
+
"""
|
|
104
|
+
text = normalise_date(date_str).replace("Z", "+00:00")
|
|
105
|
+
# From Python 3.11 this reads any ISO form, offsets included, and refuses
|
|
106
|
+
# anything else rather than dropping what follows a +.
|
|
107
|
+
parsed = _datetime.fromisoformat(text)
|
|
108
|
+
if parsed.tzinfo is None:
|
|
109
|
+
# EIS dates are UTC, so naive input is taken as UTC.
|
|
110
|
+
return parsed.replace(tzinfo=_tz.utc)
|
|
111
|
+
return parsed.astimezone(_tz.utc)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _date_to_year_fraction(date_str: str) -> float:
|
|
115
|
+
"""Convert an ISO date string to a decimal year."""
|
|
116
|
+
dt = _parse_date(date_str)
|
|
117
|
+
start = _datetime(dt.year, 1, 1, tzinfo=_tz.utc)
|
|
118
|
+
end = _datetime(dt.year + 1, 1, 1, tzinfo=_tz.utc)
|
|
119
|
+
return dt.year + (dt - start).total_seconds() / (end - start).total_seconds()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Pre-flight ground calibration
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
@lru_cache(maxsize=None)
|
|
127
|
+
def _ground_table(band: str, latest: bool = False) -> tuple[np.ndarray, np.ndarray]:
|
|
128
|
+
"""
|
|
129
|
+
Wavelength (Angstrom) and effective area (cm^2) from the SSW tables.
|
|
130
|
+
|
|
131
|
+
eis_ltds.pro uses the .004 tables. eis_ea.pro reads the latest, which for
|
|
132
|
+
the short band is B.005: B.004 with a point added at 213 Angstrom. Set
|
|
133
|
+
`latest` to read that one.
|
|
134
|
+
"""
|
|
135
|
+
fname = ("EIS_EffArea_B.005" if latest else "EIS_EffArea_B.004") if band == "SW" \
|
|
136
|
+
else "EIS_EffArea_A.004"
|
|
137
|
+
wave, area = [], []
|
|
138
|
+
for line in (_DATA_DIR / fname).read_text().splitlines():
|
|
139
|
+
line = line.strip()
|
|
140
|
+
if not line or line.startswith("#"):
|
|
141
|
+
continue
|
|
142
|
+
parts = line.split()
|
|
143
|
+
wave.append(float(parts[0]))
|
|
144
|
+
area.append(float(parts[1]))
|
|
145
|
+
return np.array(wave), np.array(area)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# IDL interpolants, as called by the SolarSoft routines
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
class _IdlInterpolSpline:
|
|
153
|
+
"""
|
|
154
|
+
IDL's `INTERPOL(y, x, xout, /SPLINE)` through `x`, `y`, called with `xout`.
|
|
155
|
+
|
|
156
|
+
This is not one spline through all the points. At each output point it
|
|
157
|
+
is a natural cubic spline through four data points: the two ends of the
|
|
158
|
+
interval holding the point and one either side. Beyond the data it
|
|
159
|
+
extrapolates from the end four. As interpol.pro in IDL 8.8. The splines
|
|
160
|
+
are built once, so a call only evaluates them.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(self, x, y):
|
|
164
|
+
self.x = np.asarray(x, dtype=float)
|
|
165
|
+
y = np.asarray(y, dtype=float)
|
|
166
|
+
# One spline per interval with a point either side, by its first point.
|
|
167
|
+
self.splines = [CubicSpline(self.x[first - 1:first + 3], y[first - 1:first + 3],
|
|
168
|
+
bc_type="natural")
|
|
169
|
+
for first in range(1, self.x.size - 2)]
|
|
170
|
+
|
|
171
|
+
def __call__(self, xout) -> np.ndarray:
|
|
172
|
+
xout = np.atleast_1d(np.asarray(xout, dtype=float))
|
|
173
|
+
# VALUE_LOCATE, limited to intervals with a point either side.
|
|
174
|
+
start = np.clip(np.searchsorted(self.x, xout, side="right") - 1, 1, self.x.size - 3)
|
|
175
|
+
out = np.empty(xout.shape)
|
|
176
|
+
for first in np.unique(start):
|
|
177
|
+
here = start == first
|
|
178
|
+
out[here] = self.splines[first - 1](xout[here])
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _IdlTensionSpline:
|
|
183
|
+
"""
|
|
184
|
+
IDL's `SPLINE(x, y, t)` through `x`, `y`, called with `t`.
|
|
185
|
+
|
|
186
|
+
A spline under tension `sigma`, as spline.pro in IDL 8.8. The end slopes
|
|
187
|
+
are those of the quadratics through the first and last three points.
|
|
188
|
+
Ported step by step in double precision. The solve is done once, so a
|
|
189
|
+
call only evaluates the spline.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(self, x, y, sigma: float = 1.0):
|
|
193
|
+
xx = np.asarray(x, dtype=float)
|
|
194
|
+
yy = np.asarray(y, dtype=float)
|
|
195
|
+
self.xx, self.yy = xx, yy
|
|
196
|
+
self.yp, self.sigmap = self._solve(xx, yy, sigma)
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _solve(xx, yy, sigma):
|
|
200
|
+
n = xx.size
|
|
201
|
+
nm1 = n - 1
|
|
202
|
+
yp = np.zeros(2 * n)
|
|
203
|
+
|
|
204
|
+
delx1 = xx[1] - xx[0]
|
|
205
|
+
dx1 = (yy[1] - yy[0]) / delx1
|
|
206
|
+
delx2 = xx[2] - xx[1]
|
|
207
|
+
delx12 = xx[2] - xx[0]
|
|
208
|
+
slpp1 = (-(delx12 + delx1) / delx12 / delx1 * yy[0] + delx12 / delx1 / delx2 * yy[1]
|
|
209
|
+
- delx1 / delx12 / delx2 * yy[2])
|
|
210
|
+
deln = xx[nm1] - xx[nm1 - 1]
|
|
211
|
+
delnm1 = xx[nm1 - 1] - xx[nm1 - 2]
|
|
212
|
+
delnn = xx[nm1] - xx[nm1 - 2]
|
|
213
|
+
slppn = (deln / delnn / delnm1 * yy[nm1 - 2] - delnn / deln / delnm1 * yy[nm1 - 1]
|
|
214
|
+
+ (delnn + deln) / delnn / deln * yy[nm1])
|
|
215
|
+
|
|
216
|
+
sigmap = sigma * nm1 / (xx[nm1] - xx[0])
|
|
217
|
+
dels = sigmap * delx1
|
|
218
|
+
exps = np.exp(dels)
|
|
219
|
+
sinhs = 0.5 * (exps - 1.0 / exps)
|
|
220
|
+
sinhin = 1.0 / (delx1 * sinhs)
|
|
221
|
+
diag1 = sinhin * (dels * 0.5 * (exps + 1.0 / exps) - sinhs)
|
|
222
|
+
yp[0] = (dx1 - slpp1) / diag1
|
|
223
|
+
yp[n] = sinhin * (sinhs - dels) / diag1
|
|
224
|
+
|
|
225
|
+
delx2 = np.diff(xx)
|
|
226
|
+
dx2 = np.diff(yy) / delx2
|
|
227
|
+
dels = sigmap * delx2
|
|
228
|
+
exps = np.exp(dels)
|
|
229
|
+
sinhs = 0.5 * (exps - 1.0 / exps)
|
|
230
|
+
sinhin = 1.0 / (delx2 * sinhs)
|
|
231
|
+
diag2 = sinhin * (dels * 0.5 * (exps + 1.0 / exps) - sinhs)
|
|
232
|
+
diag2 = np.concatenate([[0.0], diag2[:-1] + diag2[1:]])
|
|
233
|
+
dx2nm1 = dx2[nm1 - 1]
|
|
234
|
+
dx2 = np.concatenate([[0.0], np.diff(dx2)])
|
|
235
|
+
spdiag = sinhin * (sinhs - dels)
|
|
236
|
+
for i in range(1, nm1):
|
|
237
|
+
diagin = 1.0 / (diag2[i] - spdiag[i - 1] * yp[i + n - 1])
|
|
238
|
+
yp[i] = diagin * (dx2[i] - spdiag[i - 1] * yp[i - 1])
|
|
239
|
+
yp[i + n] = diagin * spdiag[i]
|
|
240
|
+
diagin = 1.0 / (diag1 - spdiag[nm1 - 1] * yp[n + nm1 - 1])
|
|
241
|
+
yp[nm1] = diagin * (slppn - dx2nm1 - spdiag[nm1 - 1] * yp[nm1 - 1])
|
|
242
|
+
for i in range(n - 2, -1, -1):
|
|
243
|
+
yp[i] = yp[i] - yp[i + n] * yp[i + 1]
|
|
244
|
+
return yp, sigmap
|
|
245
|
+
|
|
246
|
+
def __call__(self, t) -> np.ndarray:
|
|
247
|
+
xx, yy, yp, sigmap = self.xx, self.yy, self.yp, self.sigmap
|
|
248
|
+
tt = np.atleast_1d(np.asarray(t, dtype=float))
|
|
249
|
+
# Knot at or beyond each point, between the second knot and the last.
|
|
250
|
+
subs = np.clip(np.searchsorted(xx, tt, side="right"), 1, xx.size - 1)
|
|
251
|
+
subs1 = subs - 1
|
|
252
|
+
del1 = tt - xx[subs1]
|
|
253
|
+
del2 = xx[subs] - tt
|
|
254
|
+
dels = xx[subs] - xx[subs1]
|
|
255
|
+
exps1 = np.exp(sigmap * del1)
|
|
256
|
+
sinhd1 = 0.5 * (exps1 - 1.0 / exps1)
|
|
257
|
+
exps = np.exp(sigmap * del2)
|
|
258
|
+
sinhd2 = 0.5 * (exps - 1.0 / exps)
|
|
259
|
+
exps = exps1 * exps
|
|
260
|
+
sinhs = 0.5 * (exps - 1.0 / exps)
|
|
261
|
+
return ((yp[subs] * sinhd1 + yp[subs1] * sinhd2) / sinhs
|
|
262
|
+
+ ((yy[subs] - yp[subs]) * del1 + (yy[subs1] - yp[subs1]) * del2) / dels)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# Del Zanna (2013) - eis_ltds.pro
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
# Spline control points. The divisors are part of the published correction,
|
|
270
|
+
# not a normalisation added here: eis_ltds.pro uses them to scale the
|
|
271
|
+
# pre-flight curve onto the 2013 calibration, point by point.
|
|
272
|
+
_DZ2013_SW_WAVE = np.array([
|
|
273
|
+
165, 171, 174.5, 177.2, 178.1, 180.4, 182.2,
|
|
274
|
+
184.5, 185.2, 186.9, 188.3, 190,
|
|
275
|
+
192.4, 192.8, 193.5, 194.7, 195.1,
|
|
276
|
+
196.6, 197.4, 200, 201.1, 202.,
|
|
277
|
+
202.7, 204.9, 208., 209.9, 211.3,
|
|
278
|
+
])
|
|
279
|
+
_DZ2013_SW_EA = np.array([
|
|
280
|
+
0.000174973/1.5, 0.000255772/1.5, 0.00158207/1.5, 0.00476608/1.55,
|
|
281
|
+
0.00705735/1.5, 0.0168637/1.45, 0.0316499/1.4,
|
|
282
|
+
0.0647319/1.35, 0.0779082/1.35, 0.115240/1.4, 0.150199/1.45, 0.194897/1.25,
|
|
283
|
+
0.255993/1.13, 0.264945/1.1, 0.279607/1.05, 0.298884/1.02, 0.302737*1.,
|
|
284
|
+
0.301859/1.05, 0.287675/1.15, 0.174608*1.05, 0.119586/1.0, 0.0838537/1.,
|
|
285
|
+
0.0635698/1., 0.0332376/1.0, 0.0189209/1., 0.0133581/1., 0.0105513/1.,
|
|
286
|
+
])
|
|
287
|
+
_DZ2013_LW_WAVE = np.array([
|
|
288
|
+
245., 252., 255, 257., 259,
|
|
289
|
+
263., 265., 268., 270.,
|
|
290
|
+
272., 274., 277., 281., 286., 292,
|
|
291
|
+
])
|
|
292
|
+
_DZ2013_LW_EA = np.array([
|
|
293
|
+
0.022673*0.8, 0.03908*0.75, 0.05065*0.78, 0.0588*0.8, 0.06738*0.85,
|
|
294
|
+
0.0861*0.9, 0.09551*0.95, 0.106984*1.0, 0.110764*1.02,
|
|
295
|
+
0.10944*1.03, 0.1026*1.03, 0.084775*0.9, 0.05718*0.87, 0.0333*0.85,
|
|
296
|
+
0.01679*0.85,
|
|
297
|
+
]) / 1.1
|
|
298
|
+
|
|
299
|
+
# Long-wavelength degradation polynomial, in seconds since the reference time.
|
|
300
|
+
_DZ2013_LW_COEFF = np.array([1.0326230, -5.2495791e-09, 1.2055185e-17])
|
|
301
|
+
|
|
302
|
+
# Reference time (22-Sept-2006 21:36 UTC) and end of the fitted range
|
|
303
|
+
# (14-Sept-2012), as datetimes rather than IDL anytim2tai, for portability.
|
|
304
|
+
_DZ2013_REF_DT = _datetime(2006, 9, 22, 21, 36, 0, tzinfo=_tz.utc)
|
|
305
|
+
_DZ2013_LAST_DT = _datetime(2012, 9, 14, 0, 0, 0, tzinfo=_tz.utc)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _ground_curve(date_str: str, band: str):
|
|
309
|
+
"""eis_ea.pro: a four-point spline through the log of the latest ground table."""
|
|
310
|
+
wave, area = _ground_table(band, latest=True)
|
|
311
|
+
spline = _IdlInterpolSpline(wave, np.log(area))
|
|
312
|
+
return lambda wl: np.exp(spline(wl))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# Open interval of each band that eis_ltds.pro corrects; elsewhere the
|
|
316
|
+
# correction is 1.
|
|
317
|
+
_DZ2013_CORRECTED = {"SW": (165.0, 212.0), "LW": (245.0, 292.0)}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _dz2013_curve(date_str: str, band: str):
|
|
321
|
+
"""
|
|
322
|
+
eis_ea_gdz.pro: ground area from eis_ea.pro divided by the eis_ltds.pro correction.
|
|
323
|
+
|
|
324
|
+
The correction is the ground table divided by the Del Zanna curve, both
|
|
325
|
+
interpolated to the wavelength with INTERPOL /SPLINE. The curve is first
|
|
326
|
+
interpolated onto the table's grid the same way. The correction is 1
|
|
327
|
+
before launch. After 14 September 2012 the long-wavelength decay is held
|
|
328
|
+
fixed, as in eis_ltds.pro, and :func:`effective_area` warns of this as
|
|
329
|
+
eis_ltds.pro prints it.
|
|
330
|
+
"""
|
|
331
|
+
ground = _ground_curve(date_str, band)
|
|
332
|
+
when = _parse_date(date_str)
|
|
333
|
+
if when < _DZ2013_REF_DT:
|
|
334
|
+
return ground
|
|
335
|
+
grid, table = _ground_table(band)
|
|
336
|
+
if band == "SW":
|
|
337
|
+
curve = _IdlInterpolSpline(_DZ2013_SW_WAVE, _DZ2013_SW_EA)(grid)
|
|
338
|
+
else:
|
|
339
|
+
curve = _IdlInterpolSpline(_DZ2013_LW_WAVE, _DZ2013_LW_EA)(grid)
|
|
340
|
+
when = min(when, _DZ2013_LAST_DT)
|
|
341
|
+
curve = np.polyval(_DZ2013_LW_COEFF[::-1], (when - _DZ2013_REF_DT).total_seconds()) * curve
|
|
342
|
+
low, high = _DZ2013_CORRECTED[band]
|
|
343
|
+
table_spline, curve_spline = _IdlInterpolSpline(grid, table), _IdlInterpolSpline(grid, curve)
|
|
344
|
+
|
|
345
|
+
def area(wl):
|
|
346
|
+
wl = np.atleast_1d(np.asarray(wl, dtype=float))
|
|
347
|
+
correction = np.ones(wl.shape)
|
|
348
|
+
inside = (wl > low) & (wl < high)
|
|
349
|
+
correction[inside] = table_spline(wl[inside]) / curve_spline(wl[inside])
|
|
350
|
+
return ground(wl) / correction
|
|
351
|
+
|
|
352
|
+
return area
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
# Warren, Ugarte-Urra & Landi (2014) - nrl_ea_coeff_v1.3.genx
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
_W14_T0 = _datetime(2006, 9, 22, 0, 0, 0, tzinfo=_tz.utc) # Hinode launch
|
|
360
|
+
|
|
361
|
+
_W14_KNOTS = {
|
|
362
|
+
"SW": np.array([165., 173., 181., 189., 197., 205., 213.]),
|
|
363
|
+
"LW": np.array([245., 252.833, 260.667, 268.500, 276.333, 284.167, 292.]),
|
|
364
|
+
}
|
|
365
|
+
# Effective area at t0 (cm^2), fitted.
|
|
366
|
+
_W14_A0 = {
|
|
367
|
+
"SW": np.array([0.000142335, 0.000894009, 0.0194364, 0.207179,
|
|
368
|
+
0.384759, 0.0502141, 0.0130786]),
|
|
369
|
+
"LW": np.array([0.0253011, 0.0504537, 0.124469, 0.194087,
|
|
370
|
+
0.147056, 0.0674649, 0.0175320]),
|
|
371
|
+
}
|
|
372
|
+
# Decay time constants (years). The two negative short-wavelength values are
|
|
373
|
+
# in the published coefficient set: those knots brighten rather than decay.
|
|
374
|
+
_W14_TAU = {
|
|
375
|
+
"SW": np.array([96.6543, 96.6547, 41.4198, 78.6531, 53.0495,
|
|
376
|
+
-39.2594, -15.6689]),
|
|
377
|
+
"LW": np.array([11.2182, 13.9551, 10.4183, 7.54961, 8.58022,
|
|
378
|
+
8.01669, 14.8060]),
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _warren2014_reference(date_str: str, band: str) -> tuple[np.ndarray, np.ndarray]:
|
|
383
|
+
"""Warren (2014) areas at the seven spline knots for this band."""
|
|
384
|
+
years = (_parse_date(date_str) - _W14_T0).total_seconds() / (86400.0 * 365.25)
|
|
385
|
+
return _W14_KNOTS[band], _W14_A0[band] * np.exp(-years / _W14_TAU[band])
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
# Del Zanna et al. (2025) - interpol_eis_ea.pro
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
@lru_cache(maxsize=None)
|
|
393
|
+
def _dz2025_table() -> dict:
|
|
394
|
+
"""Load the fitted effective-area save file."""
|
|
395
|
+
import scipy.io
|
|
396
|
+
|
|
397
|
+
# readsav needs a filesystem path. For a normal install as_file returns
|
|
398
|
+
# the path unchanged.
|
|
399
|
+
with as_file(_DZ2025_SAV) as path:
|
|
400
|
+
fit_ea = scipy.io.readsav(str(path))["fit_ea"]
|
|
401
|
+
dates = [date.decode() for date in fit_ea["DATE_OBS"][0]]
|
|
402
|
+
return {
|
|
403
|
+
"year_fr": fit_ea["YEAR_FR"][0].astype(np.float64),
|
|
404
|
+
# Fitted dates as TAI days, which interpol_eis_ea.pro interpolates in;
|
|
405
|
+
# decimal years are not linear in time across the end of a year.
|
|
406
|
+
"dates": dates,
|
|
407
|
+
"tai": Time(dates, scale="utc").tai.mjd,
|
|
408
|
+
"SW_ea": fit_ea["SW_EA"][0].astype(np.float64),
|
|
409
|
+
"SW_wave": fit_ea["SW_WAVE"][0].astype(np.float64),
|
|
410
|
+
"LW_ea": fit_ea["LW_EA"][0].astype(np.float64),
|
|
411
|
+
"LW_wave": fit_ea["LW_WAVE"][0].astype(np.float64),
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _dz2025_reference(date_str: str, band: str) -> tuple[np.ndarray, np.ndarray]:
|
|
416
|
+
"""DZ2025 areas at the fitted reference wavelengths, for this date."""
|
|
417
|
+
table = _dz2025_table()
|
|
418
|
+
ref_wave = table[f"{band}_wave"]
|
|
419
|
+
ref_area = table[f"{band}_ea"]
|
|
420
|
+
year_fr = table["year_fr"]
|
|
421
|
+
|
|
422
|
+
# The fit covers 2007-2022. Outside it the end value is held, as in
|
|
423
|
+
# interpol_eis_ea.pro, and effective_area warns as that routine does.
|
|
424
|
+
# This is safer than extrapolating a degradation curve.
|
|
425
|
+
tai = table["tai"]
|
|
426
|
+
when = np.clip(Time(_parse_date(date_str)).tai.mjd, tai[0], tai[-1])
|
|
427
|
+
|
|
428
|
+
area = np.array([np.interp(when, tai, ref_area[i, :])
|
|
429
|
+
for i in range(len(ref_wave))])
|
|
430
|
+
return ref_wave, area
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _warren2014_curve(date_str: str, band: str):
|
|
434
|
+
"""eis_ea_nrl.pro: IDL's tension spline through the log of the areas at the knots."""
|
|
435
|
+
knots, area = _warren2014_reference(date_str, band)
|
|
436
|
+
spline = _IdlTensionSpline(knots, np.log(area))
|
|
437
|
+
return lambda wl: np.exp(spline(wl))
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _dz2025_curve(date_str: str, band: str):
|
|
441
|
+
"""interpol_eis_ea.pro: a four-point spline through the areas at the fitted wavelengths."""
|
|
442
|
+
return _IdlInterpolSpline(*_dz2025_reference(date_str, band))
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# ---------------------------------------------------------------------------
|
|
446
|
+
# Dispatch
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
# Each calibration's area against wavelength, for a date and a band, built as
|
|
450
|
+
# in the SolarSoft routine it ports. The interpolation matters: linear
|
|
451
|
+
# interpolation instead of eis_ltds.pro's splines puts Fe XIII 203.83 12 per
|
|
452
|
+
# cent too bright in dz2013, and the other routines' local splines differ
|
|
453
|
+
# from one spline through every point by up to a few per cent.
|
|
454
|
+
_METHODS = {
|
|
455
|
+
"ground": _ground_curve,
|
|
456
|
+
"dz2013": _dz2013_curve,
|
|
457
|
+
"warren2014": _warren2014_curve,
|
|
458
|
+
"dz2025": _dz2025_curve,
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _check_method(method: str) -> str:
|
|
463
|
+
if method not in _METHODS:
|
|
464
|
+
raise ValueError(
|
|
465
|
+
f"Unknown EIS calibration {method!r}. Choose from: "
|
|
466
|
+
f"{', '.join(CALIBRATIONS)}."
|
|
467
|
+
)
|
|
468
|
+
return method
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@lru_cache(maxsize=256)
|
|
472
|
+
def _interpolator(method: str, date_str: str, band: str):
|
|
473
|
+
"""Callable EA(wavelength_aa), built once per calibration and epoch.
|
|
474
|
+
|
|
475
|
+
Cached because `radiometric.add_telescope_throughput` evaluates the
|
|
476
|
+
telescope response one wavelength sample at a time, in a Python loop, on
|
|
477
|
+
every Monte Carlo iteration. Rebuilding a 49-point spline per sample would
|
|
478
|
+
dominate the run time. The cache holds built interpolators, not results,
|
|
479
|
+
so it is exact. Warnings are left to :func:`effective_area`, since a
|
|
480
|
+
cached call would not repeat them.
|
|
481
|
+
"""
|
|
482
|
+
return _METHODS[_check_method(method)](date_str, band)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@lru_cache(maxsize=256)
|
|
486
|
+
def _beyond_the_fit(method: str, date_str: str) -> tuple:
|
|
487
|
+
"""
|
|
488
|
+
Messages the SolarSoft routine prints for a date outside the fit of `method`.
|
|
489
|
+
|
|
490
|
+
`(bands, message)` pairs, where `bands` are those the message concerns.
|
|
491
|
+
Empty for a date within the fit.
|
|
492
|
+
"""
|
|
493
|
+
if method == "dz2013":
|
|
494
|
+
when = _parse_date(date_str)
|
|
495
|
+
if when < _DZ2013_REF_DT:
|
|
496
|
+
return ((("SW", "LW"), f"{date_str} is before 22 September 2006, where the dz2013 "
|
|
497
|
+
f"EIS calibration begins, so the ground calibration is used, as "
|
|
498
|
+
f"eis_ltds.pro does."),)
|
|
499
|
+
if when > _DZ2013_LAST_DT:
|
|
500
|
+
return ((("LW",), f"{date_str} is after 14 September 2012, the last date the dz2013 "
|
|
501
|
+
f"long-wavelength decay is fitted to, so the decay is held there, as "
|
|
502
|
+
f"eis_ltds.pro does."),)
|
|
503
|
+
if method == "dz2025":
|
|
504
|
+
table = _dz2025_table()
|
|
505
|
+
when = Time(_parse_date(date_str)).tai.mjd
|
|
506
|
+
if not table["tai"][0] <= when <= table["tai"][-1]:
|
|
507
|
+
return ((("SW", "LW"), f"{date_str} is outside {table['dates'][0]} to "
|
|
508
|
+
f"{table['dates'][-1]}, the dates the dz2025 EIS calibration is fitted to, "
|
|
509
|
+
f"so the nearer end of the fit is used, as interpol_eis_ea.pro does."),)
|
|
510
|
+
return ()
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def band_of(wavelength_aa: float) -> str | None:
|
|
514
|
+
"""EIS band of a wavelength: `'SW'`, `'LW'`, or `None` if EIS does not observe it."""
|
|
515
|
+
if SW_BAND[0] <= wavelength_aa <= SW_BAND[1]:
|
|
516
|
+
return "SW"
|
|
517
|
+
if LW_BAND[0] <= wavelength_aa <= LW_BAND[1]:
|
|
518
|
+
return "LW"
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def effective_area(wavelengths_aa, date=None, method="ground") -> np.ndarray:
|
|
523
|
+
"""
|
|
524
|
+
EIS effective area, including the detector quantum efficiency.
|
|
525
|
+
|
|
526
|
+
Parameters
|
|
527
|
+
----------
|
|
528
|
+
wavelengths_aa : array_like
|
|
529
|
+
Wavelengths in Angstrom, in either or both EIS bands.
|
|
530
|
+
date : str or datetime or astropy.time.Time, optional
|
|
531
|
+
Observation date, needed by every calibration but `"ground"`.
|
|
532
|
+
method : str, optional
|
|
533
|
+
Calibration: `"ground"` (default), `"dz2013"`, `"warren2014"` or
|
|
534
|
+
`"dz2025"`.
|
|
535
|
+
|
|
536
|
+
Returns
|
|
537
|
+
-------
|
|
538
|
+
np.ndarray
|
|
539
|
+
Effective area in cm2, NaN outside the two EIS bands.
|
|
540
|
+
"""
|
|
541
|
+
# The bands are interpolated separately: EIS has two detectors with
|
|
542
|
+
# independent calibrations, and one curve through both would put a
|
|
543
|
+
# meaningless area in the 213-245 Angstrom gap and distort the band edges.
|
|
544
|
+
_check_method(method)
|
|
545
|
+
if date is None:
|
|
546
|
+
if method in TIME_DEPENDENT_CALIBRATIONS:
|
|
547
|
+
raise ValueError(
|
|
548
|
+
f"The {method!r} EIS calibration is time-dependent, so it "
|
|
549
|
+
f"needs an observation date, for example "
|
|
550
|
+
f"date='2012-06-03'. Use method='ground' for the "
|
|
551
|
+
f"epoch-independent pre-flight calibration."
|
|
552
|
+
)
|
|
553
|
+
date_str = "" # unused, but keeps the cache key hashable
|
|
554
|
+
else:
|
|
555
|
+
date_str = normalise_date(date)
|
|
556
|
+
|
|
557
|
+
wavelengths = np.atleast_1d(np.asarray(wavelengths_aa, dtype=np.float64))
|
|
558
|
+
out = np.full(wavelengths.shape, np.nan, dtype=np.float64)
|
|
559
|
+
|
|
560
|
+
bands = []
|
|
561
|
+
for band, (low, high) in (("SW", SW_BAND), ("LW", LW_BAND)):
|
|
562
|
+
mask = (wavelengths >= low) & (wavelengths <= high)
|
|
563
|
+
if np.any(mask):
|
|
564
|
+
out[mask] = _interpolator(method, date_str, band)(wavelengths[mask])
|
|
565
|
+
bands.append(band)
|
|
566
|
+
|
|
567
|
+
# Warn on every call, once per message however many bands it spans.
|
|
568
|
+
if date_str:
|
|
569
|
+
for concerned, message in _beyond_the_fit(method, date_str):
|
|
570
|
+
if set(concerned) & set(bands):
|
|
571
|
+
warnings.warn(message, UserWarning, stacklevel=2)
|
|
572
|
+
|
|
573
|
+
return out
|
|
File without changes
|