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,409 @@
1
+ """
2
+ Pinhole diffraction effects for aluminum filter modeling.
3
+
4
+ This module calculates the diffraction patterns from pinholes in the aluminum filter,
5
+ including both EUV and visible light contributions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import numpy as np
10
+ import astropy.units as u
11
+ import astropy.constants as const
12
+ from scipy.special import j1
13
+ from ndcube import NDCube
14
+ from typing import List, Tuple
15
+
16
+
17
+ def airy_disk_pattern(r: np.ndarray, wavelength: u.Quantity, pinhole_diameter: u.Quantity,
18
+ distance: u.Quantity) -> np.ndarray:
19
+ """
20
+ Airy pattern of a circular pinhole on a screen some distance behind it.
21
+
22
+ Parameters
23
+ ----------
24
+ r : np.ndarray
25
+ Distances from the pattern centre on the screen, in metres.
26
+ wavelength : u.Quantity
27
+ Wavelength of the light.
28
+ pinhole_diameter : u.Quantity
29
+ Pinhole diameter.
30
+ distance : u.Quantity
31
+ Distance from the pinhole to the screen.
32
+
33
+ Returns
34
+ -------
35
+ np.ndarray
36
+ Brightness at each distance, 1 at the centre.
37
+ """
38
+ # Calculate the exact sine of the diffraction angle
39
+ # sin(theta) = r / sqrt(r^2 + distance^2)
40
+ distance_m = distance.to(u.m).value
41
+ sin_theta = r / np.sqrt(r**2 + distance_m**2)
42
+
43
+ # Airy disk parameter
44
+ # beta = (pi * D * sin(theta)) / lambda
45
+ beta = (np.pi * pinhole_diameter.to(u.m).value * sin_theta) / wavelength.to(u.m).value
46
+
47
+ # Avoid division by zero at center
48
+ beta = np.where(beta == 0, 1e-10, beta)
49
+
50
+ # Airy disk intensity pattern: I(beta) = (2*J1(beta)/beta)^2
51
+ # where J1 is the first-order Bessel function
52
+ intensity = (2 * j1(beta) / beta) ** 2
53
+
54
+ return intensity
55
+
56
+
57
+ def airy_peak_fraction_per_pixel(
58
+ pinhole_diameter: u.Quantity,
59
+ distance: u.Quantity,
60
+ wavelength: u.Quantity,
61
+ pixel_size: u.Quantity,
62
+ ) -> float:
63
+ """
64
+ Fraction of a pinhole's transmitted photons in the brightest detector pixel.
65
+
66
+ This is the absolute normalisation of the Airy pattern, which
67
+ `calculate_pinhole_diffraction_pattern` leaves out (its pattern peaks at
68
+ 1.0). For a circular aperture of area A at distance L, the on-axis
69
+ irradiance is
70
+
71
+ E_0 = P_total * A / (lambda^2 L^2)
72
+
73
+ (integrating (2*J1(u)/u)^2 over the plane gives 4*lambda^2*L^2/(pi*D^2),
74
+ which recovers P_total), so the fraction of the transmitted power on one
75
+ pixel of area a is `A * a / (lambda L)^2`.
76
+
77
+ Normalising the pattern by its sum over the detector would force every
78
+ pinhole photon onto the detector. For a small pinhole the Airy disc is
79
+ far larger than the detector: a 1 micron hole at 250 mm has its first
80
+ minimum at 183 mm, against a detector tens of mm across, so most of the
81
+ light misses the detector and must not be redistributed onto it. Use
82
+ this function when absolute photon numbers matter, e.g. for a pinhole
83
+ budget.
84
+
85
+ Returns
86
+ -------
87
+ float
88
+ Fraction of transmitted photons in the brightest pixel, capped at
89
+ 1.0. The cap only binds for holes so large that the geometric image
90
+ is smaller than a pixel, where Fraunhofer diffraction no longer
91
+ applies.
92
+ """
93
+ area = np.pi * (pinhole_diameter.to(u.m).value / 2.0) ** 2
94
+ pix = pixel_size.to(u.m).value
95
+ lam = wavelength.to(u.m).value
96
+ dist = distance.to(u.m).value
97
+ return float(min(1.0, area * pix ** 2 / (lam * dist) ** 2))
98
+
99
+ def calculate_pinhole_diffraction_pattern(
100
+ detector_shape: Tuple[int, int],
101
+ pixel_size: u.Quantity,
102
+ pinhole_diameter: u.Quantity,
103
+ pinhole_position_slit: float,
104
+ slit_width: u.Quantity,
105
+ plate_scale: u.Quantity,
106
+ distance: u.Quantity,
107
+ wavelength: u.Quantity,
108
+ pinhole_position_spectral: float | None = None,
109
+ ) -> np.ndarray:
110
+ """
111
+ Calculate the diffraction pattern from a single pinhole on the detector.
112
+
113
+ Parameters
114
+ ----------
115
+ detector_shape : tuple of int
116
+ (n_slit, n_spectral) shape of detector
117
+ pixel_size : u.Quantity
118
+ Physical size of detector pixels
119
+ pinhole_diameter : u.Quantity
120
+ Diameter of the pinhole
121
+ pinhole_position_slit : float
122
+ Position along slit as fraction (0.0 to 1.0)
123
+ slit_width : u.Quantity
124
+ Width of the slit
125
+ plate_scale : u.Quantity
126
+ Angular plate scale (arcsec/pixel)
127
+ distance : u.Quantity
128
+ Distance from pinhole to detector
129
+ wavelength : u.Quantity
130
+ Wavelength of light
131
+ pinhole_position_spectral : float, optional
132
+ Position along the spectral axis as a fraction (0.0 to 1.0) of the
133
+ detector width. The default, None, puts every pinhole at the centre
134
+ of the spectral window. On a slit-scan spectrograph the spectral
135
+ axis is wavelength, so this sets which emission lines a pinhole
136
+ contaminates.
137
+
138
+ Returns
139
+ -------
140
+ np.ndarray
141
+ 2D diffraction pattern normalized to a peak of 1.0, with no absolute
142
+ normalisation; see `airy_peak_fraction_per_pixel` when photon
143
+ numbers matter.
144
+ """
145
+ n_slit, n_spectral = detector_shape
146
+
147
+ # Create coordinate grids for detector
148
+ slit_pixels = np.arange(n_slit)
149
+ spectral_pixels = np.arange(n_spectral)
150
+
151
+ # Convert pinhole position from slit fraction to pixel coordinate
152
+ pinhole_pixel_slit = pinhole_position_slit * (n_slit - 1)
153
+
154
+ # Pinhole position on the detector. Without a spectral position, use the
155
+ # centre of the spectral window.
156
+ if pinhole_position_spectral is None:
157
+ pinhole_pixel_spectral = n_spectral // 2
158
+ else:
159
+ if not 0.0 <= pinhole_position_spectral <= 1.0:
160
+ raise ValueError(
161
+ "pinhole_position_spectral is a fraction of the detector "
162
+ f"width and must lie in [0, 1], got "
163
+ f"{pinhole_position_spectral}. Out of range it would place "
164
+ "the pinhole off the detector, where it looks like a valid "
165
+ "pinhole whose light merely happens to be missing."
166
+ )
167
+ pinhole_pixel_spectral = pinhole_position_spectral * (n_spectral - 1)
168
+
169
+ # Create 2D coordinate arrays
170
+ slit_grid, spectral_grid = np.meshgrid(slit_pixels, spectral_pixels, indexing='ij')
171
+
172
+ # Calculate distances from pinhole center in detector plane
173
+ dy_pixels = slit_grid - pinhole_pixel_slit
174
+ dx_pixels = spectral_grid - pinhole_pixel_spectral
175
+
176
+ # Convert to physical distances
177
+ dy_physical = dy_pixels * pixel_size.to(u.m).value
178
+ dx_physical = dx_pixels * pixel_size.to(u.m).value
179
+
180
+ # Radial distance from pinhole center
181
+ r_physical = np.sqrt(dx_physical**2 + dy_physical**2)
182
+
183
+ # Calculate Airy disk pattern
184
+ pattern = airy_disk_pattern(r_physical, wavelength, pinhole_diameter, distance)
185
+
186
+ return pattern
187
+
188
+
189
+ def _pinholes_on(sim) -> bool:
190
+ """Whether `sim` has pinholes enabled and at least one given."""
191
+ return bool(sim.enable_pinholes and len(sim.pinhole_sizes) > 0)
192
+
193
+
194
+ def _filter_transmission(tel, wavelength: u.Quantity) -> np.ndarray:
195
+ """
196
+ Share of the EUV the filter passes at each `wavelength`, for the pinholes.
197
+
198
+ The pinholes work out the light the filter blocks from the light it
199
+ passes, which cannot be done where it passes none. A filter that passes
200
+ no EUV at a wavelength its tables cover is therefore refused.
201
+ """
202
+ wavelength = u.Quantity(wavelength)
203
+ passed = tel.filter.total_throughput(wavelength).to_value(u.dimensionless_unscaled)
204
+ opaque = np.isfinite(passed) & (passed <= 0)
205
+ if opaque.any():
206
+ where = np.broadcast_to(wavelength, passed.shape, subok=True)[opaque].to(u.AA)
207
+ raise ValueError(
208
+ f"The filter passes no EUV from {where.min():.3f} to {where.max():.3f}, so the "
209
+ f"light the pinholes let through there cannot be worked out from the light it "
210
+ f"passes. Model the pinholes with a filter that passes some EUV at every "
211
+ f"wavelength.")
212
+ return passed
213
+
214
+
215
+ def _with_filter_transmission(photon_counts: NDCube, tel) -> NDCube:
216
+ """
217
+ `photon_counts` with the `meta` that :func:`apply_euv_pinhole_diffraction`
218
+ needs, for photons counted at each pixel's own wavelength: the share of
219
+ their light the filter passed there, and the light it blocked, at the
220
+ same wavelength.
221
+ """
222
+ meta = dict(photon_counts.meta or {})
223
+ own = photon_counts.axis_world_coords_values(2)[0]
224
+ shape = photon_counts.data.shape
225
+ meta["photon_filter_transmission"] = np.broadcast_to(_filter_transmission(tel, own), shape)
226
+ meta["blocked_photon_wavelength"] = meta.get("photon_wavelength",
227
+ np.broadcast_to(own, shape, subok=True))
228
+ meta["blocked_photon_rms_wavelength"] = meta.get("photon_rms_wavelength",
229
+ meta["blocked_photon_wavelength"])
230
+ return NDCube(photon_counts.data, wcs=photon_counts.wcs, unit=photon_counts.unit, meta=meta)
231
+
232
+
233
+ def apply_euv_pinhole_diffraction(
234
+ photon_counts: NDCube,
235
+ det,
236
+ sim,
237
+ tel
238
+ ) -> NDCube:
239
+ """
240
+ Add the unattenuated EUV light that passes through pinholes in the filter.
241
+
242
+ At each pinhole the filtered light is replaced by the unattenuated light
243
+ through the pinhole, spread in its diffraction pattern. The filter is
244
+ behind the mirror and the grating, so this comes after the PSF. It does
245
+ nothing unless `sim.enable_pinholes` is set and there are pinholes.
246
+
247
+ The light the filter blocked is worked out at each photon's own
248
+ wavelength, from `photon_filter_transmission` in the cube's `meta`, the
249
+ share of the photons' light the filter passed, which `rebin_atmosphere`
250
+ and `rebin_spectra` set with the pinholes on. Without it, each pixel's
251
+ photons are taken to have passed the filter at the pixel's own
252
+ wavelength, as photons counted there have. The pinholes' photons have
253
+ the energies of the blocked light, and the wavelengths of the mean and
254
+ RMS photon energies in `meta` include them.
255
+
256
+ Parameters
257
+ ----------
258
+ photon_counts : NDCube
259
+ EUV photons in each pixel, after the filter and the PSF.
260
+ det : Detector_SWC
261
+ EUVST-SW detector, whose `filter_distance` sets the diffraction.
262
+ sim : Simulation
263
+ Simulation, with the pinholes.
264
+ tel : Telescope_EUVST
265
+ Telescope, whose filter transmission is taken out where the pinholes
266
+ are.
267
+
268
+ Returns
269
+ -------
270
+ NDCube
271
+ Photons in each pixel, including the pinholes' light.
272
+ """
273
+ if not (sim.enable_pinholes and len(sim.pinhole_sizes) > 0):
274
+ return photon_counts # No pinholes enabled
275
+
276
+ # Get detector and data properties
277
+ data_shape = photon_counts.data.shape # (n_slit, n_scan, n_spectral)
278
+ n_slit, n_scan, n_spectral = data_shape
279
+
280
+ # Get rest wavelength for EUV calculations
281
+ rest_wavelength = photon_counts.meta['rest_wav']
282
+
283
+ # Calculate pixel area
284
+ pixel_area = (det.pix_size*1*u.pix)**2
285
+
286
+ # Initialize additional photon contributions
287
+ additional_photons = np.zeros_like(photon_counts.data)
288
+
289
+ # Light the filter blocked from each pixel's photons, at each photon's
290
+ # own wavelength.
291
+ counts = (photon_counts if "photon_filter_transmission" in (photon_counts.meta or {})
292
+ else _with_filter_transmission(photon_counts, tel))
293
+ data = np.asarray(photon_counts.data, dtype=float)
294
+ passed = np.asarray(counts.meta["photon_filter_transmission"], dtype=float)
295
+ blocked = data * np.divide(1.0 - passed, passed, out=np.zeros(data.shape), where=passed > 0)
296
+
297
+ # Spectral positions are optional, as in the visible path: without them
298
+ # every pinhole projects to the centre of the spectral window.
299
+ spectral_positions = (list(sim.pinhole_positions_spectral)
300
+ or [None] * len(sim.pinhole_sizes))
301
+
302
+ for pinhole_diameter, pinhole_position, pinhole_spectral in zip(
303
+ sim.pinhole_sizes, sim.pinhole_positions, spectral_positions):
304
+ # Calculate pinhole area
305
+ pinhole_area = np.pi * (pinhole_diameter / 2)**2
306
+
307
+ # photon_counts already have the filter attenuation, so:
308
+ # 1. Work out the unfiltered signal.
309
+ # 2. Apply the pinhole diffraction to it.
310
+ # 3. Subtract the filtered signal already counted in the pinhole regions.
311
+
312
+ area_ratio = (pinhole_area / pixel_area).to(u.dimensionless_unscaled).value
313
+
314
+ # Calculate theoretical diffraction size for validation
315
+ # First Airy minimum: r = 1.22 * lambda * distance / diameter
316
+ theoretical_radius = (1.22 * rest_wavelength * det.filter_distance / pinhole_diameter).to(u.m)
317
+ theoretical_radius_pixels = (theoretical_radius / (det.pix_size*1*u.pix)).to(u.dimensionless_unscaled).value
318
+
319
+ # Calculate EUV diffraction pattern
320
+ euv_pattern = calculate_pinhole_diffraction_pattern(
321
+ detector_shape=(n_slit, n_spectral),
322
+ pixel_size=det.pix_size*u.pix,
323
+ pinhole_diameter=pinhole_diameter,
324
+ pinhole_position_slit=pinhole_position,
325
+ slit_width=sim.slit_width,
326
+ plate_scale=det.plate_scale_angle,
327
+ distance=det.filter_distance,
328
+ wavelength=rest_wavelength,
329
+ pinhole_position_spectral=pinhole_spectral,
330
+ )
331
+
332
+ # Scale the pattern by its absolute normalisation, as the visible path
333
+ # does. Dividing by its sum over the detector would force every photon
334
+ # through the pinhole onto the detector, whatever the geometry. The EUV
335
+ # Airy pattern is about thirty times smaller than the visible one at
336
+ # the same diameter, but a 1 micron hole at 195 Angstrom still has its
337
+ # first minimum 5.95 mm out, against a detector a few mm across, so
338
+ # most of its light misses and must be lost, not redistributed.
339
+ # euv_pattern peaks at 1.0, so multiplying by the peak per-pixel
340
+ # fraction gives the right fraction everywhere, summing to less than 1
341
+ # when light falls off the detector.
342
+ peak_fraction = airy_peak_fraction_per_pixel(
343
+ pinhole_diameter=pinhole_diameter,
344
+ distance=det.filter_distance,
345
+ wavelength=rest_wavelength,
346
+ pixel_size=det.pix_size * u.pix,
347
+ )
348
+ euv_pattern_normalized = euv_pattern * peak_fraction
349
+
350
+
351
+ # Process each scan position
352
+ for i in range(n_scan):
353
+ # Current filtered signal at this scan position
354
+ filtered_signal = photon_counts.data[:, i, :] # Shape: (n_slit, n_spectral)
355
+
356
+ # Unfiltered signal: the filtered signal plus the light the filter
357
+ # blocked from it.
358
+ unfiltered_signal = filtered_signal + blocked[:, i, :]
359
+
360
+ # Unattenuated light through the pinhole. unfiltered_signal *
361
+ # area_ratio is the light collected over the pinhole's area, and
362
+ # the scaled pattern gives the fraction of it reaching each pixel,
363
+ # which need not sum to 1.
364
+ pinhole_signal = unfiltered_signal * area_ratio * euv_pattern_normalized
365
+
366
+ # The filtered light already counted in the pinhole regions
367
+ # (filtered signal weighted by diffraction pattern and area ratio)
368
+ overcounted_filtered = filtered_signal * area_ratio * euv_pattern_normalized
369
+
370
+ # Net extra light from the pinhole: its total light less the
371
+ # filtered light already counted there, which is
372
+ # blocked * area_ratio * pattern.
373
+ correction = (pinhole_signal - overcounted_filtered)
374
+ additional_photons[:, i, :] += correction
375
+
376
+ # Create new photon counts with EUV pinhole contributions
377
+ new_data = photon_counts.data + additional_photons
378
+
379
+ # The pinholes' photons have the energies of the blocked light. The
380
+ # filter entries in meta no longer hold once they are added, so drop them.
381
+ meta = {key: value for key, value in counts.meta.items() if key not in _FILTER_KEYS}
382
+ if "photon_wavelength" in meta:
383
+ mean = u.Quantity(meta["photon_wavelength"])
384
+ unit = mean.unit
385
+ rms = u.Quantity(meta.get("photon_rms_wavelength", mean)).to_value(unit)
386
+ blocked_mean = u.Quantity(counts.meta["blocked_photon_wavelength"]).to_value(unit)
387
+ blocked_rms = u.Quantity(counts.meta["blocked_photon_rms_wavelength"]).to_value(unit)
388
+
389
+ def over(photons, wavelength):
390
+ return np.divide(photons, wavelength, out=np.zeros(data.shape), where=wavelength > 0)
391
+
392
+ energy = over(data, mean.value) + over(additional_photons, blocked_mean)
393
+ square = over(data, rms**2) + over(additional_photons, blocked_rms**2)
394
+ meta["photon_wavelength"] = np.divide(new_data, energy, out=np.array(mean.value, dtype=float),
395
+ where=energy > 0) * unit
396
+ meta["photon_rms_wavelength"] = np.sqrt(np.divide(
397
+ new_data, square, out=np.array(rms, dtype=float) ** 2, where=square > 0)) * unit
398
+
399
+ return NDCube(
400
+ data=new_data,
401
+ wcs=photon_counts.wcs.deepcopy(),
402
+ unit=photon_counts.unit,
403
+ meta=meta,
404
+ )
405
+
406
+
407
+ # Filter entries in a cube's meta, which the pinholes need.
408
+ _FILTER_KEYS = ("photon_filter_transmission", "blocked_photon_wavelength",
409
+ "blocked_photon_rms_wavelength")
eclipse/psf.py ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ Deprecated helpers for tabulated PSF files, which ECLIPSE does not use.
3
+
4
+ They warn when called and will be removed in a future release.
5
+ """
6
+ from .deprecated import _combine_psfs, _load_psf_ascii, _resample_psf # noqa: F401