eclipse 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. eclipse/__init__.py +95 -0
  2. eclipse/analysis.py +853 -0
  3. eclipse/atmosphere.py +859 -0
  4. eclipse/band.py +477 -0
  5. eclipse/cli.py +138 -0
  6. eclipse/config.py +1710 -0
  7. eclipse/continuum.py +413 -0
  8. eclipse/data/eis_response/EIS_EffArea_A.004 +61 -0
  9. eclipse/data/eis_response/EIS_EffArea_B.004 +60 -0
  10. eclipse/data/eis_response/EIS_EffArea_B.005 +64 -0
  11. eclipse/data/eis_response/README_dz2025 +50 -0
  12. eclipse/data/eis_response/fit_eis_ea_2023-05-04_smooth.sav +0 -0
  13. eclipse/data/eis_response/source.txt +33 -0
  14. eclipse/data/science_cases.yaml +365 -0
  15. eclipse/data/throughput/grating_reflection_efficiency.dat +25 -0
  16. eclipse/data/throughput/primary_mirror_coating_reflectance.dat +25 -0
  17. eclipse/data/throughput/source.txt +6 -0
  18. eclipse/data/throughput/throughput_aluminium_1000_angstrom.dat +503 -0
  19. eclipse/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +503 -0
  20. eclipse/data/throughput/throughput_carbon_1000_angstrom.dat +503 -0
  21. eclipse/data_processing.py +904 -0
  22. eclipse/deprecated.py +161 -0
  23. eclipse/eis_calibration.py +573 -0
  24. eclipse/extern/__init__.py +0 -0
  25. eclipse/extern/mpfit.py +2388 -0
  26. eclipse/fitting.py +1734 -0
  27. eclipse/frame.py +594 -0
  28. eclipse/full_frame.py +944 -0
  29. eclipse/main.py +1646 -0
  30. eclipse/monte_carlo.py +660 -0
  31. eclipse/pinhole_diffraction.py +409 -0
  32. eclipse/psf.py +6 -0
  33. eclipse/radiometric.py +1328 -0
  34. eclipse/raster.py +869 -0
  35. eclipse/readout.py +1002 -0
  36. eclipse/resolution_budget.py +108 -0
  37. eclipse/results_file.py +1226 -0
  38. eclipse/sampling.py +351 -0
  39. eclipse/science_cases.py +364 -0
  40. eclipse/synthesis.py +2492 -0
  41. eclipse/synthesis_cli.py +12 -0
  42. eclipse/synthesis_file.py +882 -0
  43. eclipse/utils.py +1145 -0
  44. eclipse-0.13.0.dist-info/METADATA +70 -0
  45. eclipse-0.13.0.dist-info/RECORD +50 -0
  46. eclipse-0.13.0.dist-info/WHEEL +5 -0
  47. eclipse-0.13.0.dist-info/entry_points.txt +7 -0
  48. eclipse-0.13.0.dist-info/licenses/LICENSE +3 -0
  49. eclipse-0.13.0.dist-info/top_level.txt +2 -0
  50. euvst_response/__init__.py +129 -0
@@ -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