solarc-eclipse 0.5.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.
@@ -0,0 +1,260 @@
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
+ Calculate the Airy disk diffraction pattern for a circular pinhole.
21
+
22
+ Parameters
23
+ ----------
24
+ r : np.ndarray
25
+ Radial distances from optical axis (in detector plane) in meters
26
+ wavelength : u.Quantity
27
+ Wavelength of light
28
+ pinhole_diameter : u.Quantity
29
+ Diameter of the pinhole
30
+ distance : u.Quantity
31
+ Distance from pinhole to detector
32
+
33
+ Returns
34
+ -------
35
+ np.ndarray
36
+ Normalized intensity pattern (peak = 1.0)
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
+ def calculate_pinhole_diffraction_pattern(
57
+ detector_shape: Tuple[int, int],
58
+ pixel_size: u.Quantity,
59
+ pinhole_diameter: u.Quantity,
60
+ pinhole_position_slit: float,
61
+ slit_width: u.Quantity,
62
+ plate_scale: u.Quantity,
63
+ distance: u.Quantity,
64
+ wavelength: u.Quantity
65
+ ) -> np.ndarray:
66
+ """
67
+ Calculate the diffraction pattern from a single pinhole on the detector.
68
+
69
+ Parameters
70
+ ----------
71
+ detector_shape : tuple of int
72
+ (n_slit, n_spectral) shape of detector
73
+ pixel_size : u.Quantity
74
+ Physical size of detector pixels
75
+ pinhole_diameter : u.Quantity
76
+ Diameter of the pinhole
77
+ pinhole_position_slit : float
78
+ Position along slit as fraction (0.0 to 1.0)
79
+ slit_width : u.Quantity
80
+ Width of the slit
81
+ plate_scale : u.Quantity
82
+ Angular plate scale (arcsec/pixel)
83
+ distance : u.Quantity
84
+ Distance from pinhole to detector
85
+ wavelength : u.Quantity
86
+ Wavelength of light
87
+
88
+ Returns
89
+ -------
90
+ np.ndarray
91
+ 2D diffraction pattern normalized to peak intensity of 1.0
92
+ """
93
+ n_slit, n_spectral = detector_shape
94
+
95
+ # Create coordinate grids for detector
96
+ slit_pixels = np.arange(n_slit)
97
+ spectral_pixels = np.arange(n_spectral)
98
+
99
+ # Convert pinhole position from slit fraction to pixel coordinate
100
+ pinhole_pixel_slit = pinhole_position_slit * (n_slit - 1)
101
+
102
+ # Calculate distances from pinhole position on detector
103
+ # Assuming pinhole projects to center of spectral direction
104
+ pinhole_pixel_spectral = n_spectral // 2
105
+
106
+ # Create 2D coordinate arrays
107
+ slit_grid, spectral_grid = np.meshgrid(slit_pixels, spectral_pixels, indexing='ij')
108
+
109
+ # Calculate distances from pinhole center in detector plane
110
+ dy_pixels = slit_grid - pinhole_pixel_slit
111
+ dx_pixels = spectral_grid - pinhole_pixel_spectral
112
+
113
+ # Convert to physical distances
114
+ dy_physical = dy_pixels * pixel_size.to(u.m).value
115
+ dx_physical = dx_pixels * pixel_size.to(u.m).value
116
+
117
+ # Radial distance from pinhole center
118
+ r_physical = np.sqrt(dx_physical**2 + dy_physical**2)
119
+
120
+ # Calculate Airy disk pattern
121
+ pattern = airy_disk_pattern(r_physical, wavelength, pinhole_diameter, distance)
122
+
123
+ return pattern
124
+
125
+
126
+ def apply_euv_pinhole_diffraction(
127
+ photon_counts: NDCube,
128
+ det,
129
+ sim,
130
+ tel
131
+ ) -> NDCube:
132
+ """
133
+ Apply EUV pinhole diffraction effects to photon counts.
134
+
135
+ This adds EUV light that bypasses the aluminum filter through pinholes
136
+ and creates diffraction patterns. This should be applied after the
137
+ focusing optics PSF (primary mirror + grating) since the filter is
138
+ positioned after these optical elements.
139
+
140
+ This function correctly handles the physics by:
141
+ 1. Subtracting the filtered EUV signal in pinhole regions
142
+ 2. Adding the unattenuated EUV signal through pinholes
143
+
144
+ Parameters
145
+ ----------
146
+ photon_counts : NDCube
147
+ EUV photon counts per pixel (shape: n_scan, n_slit, n_spectral)
148
+ These should already have filter throughput applied.
149
+ det : Detector_SWC
150
+ Detector configuration
151
+ sim : Simulation
152
+ Simulation configuration containing pinhole parameters
153
+ tel : Telescope_EUVST
154
+ Telescope configuration (needed to calculate filter throughput)
155
+
156
+ Returns
157
+ -------
158
+ NDCube
159
+ Modified photon counts with EUV pinhole contributions added
160
+ """
161
+ if not (sim.enable_pinholes and len(sim.pinhole_sizes) > 0):
162
+ return photon_counts # No pinholes enabled
163
+
164
+ # Get detector and data properties
165
+ data_shape = photon_counts.data.shape # (n_scan, n_slit, n_spectral)
166
+ n_scan, n_slit, n_spectral = data_shape
167
+
168
+ # Get rest wavelength for EUV calculations
169
+ rest_wavelength = photon_counts.meta['rest_wav']
170
+
171
+ # Calculate pixel area
172
+ pixel_area = (det.pix_size*1*u.pix)**2
173
+
174
+ # Initialize additional photon contributions
175
+ additional_photons = np.zeros_like(photon_counts.data)
176
+
177
+ # Get the wavelength axis and calculate filter throughput for EUV
178
+ wl_axis = photon_counts.axis_world_coords(2)[0]
179
+
180
+ # Calculate filter throughput at each wavelength
181
+ filter_throughput_spectrum = np.array([tel.filter.total_throughput(wl) for wl in wl_axis])
182
+
183
+ for pinhole_diameter, pinhole_position in zip(sim.pinhole_sizes, sim.pinhole_positions):
184
+ # Calculate pinhole area
185
+ pinhole_area = np.pi * (pinhole_diameter / 2)**2
186
+
187
+ # === Physics Correction for EUV ===
188
+ # Current photon_counts already have filter attenuation applied
189
+ # We need to:
190
+ # 1. Back-calculate what the unfiltered signal would be
191
+ # 2. Apply pinhole diffraction to that unfiltered signal
192
+ # 3. Subtract the over-counted filtered signal in pinhole regions
193
+
194
+ area_ratio = (pinhole_area / pixel_area).to(u.dimensionless_unscaled).value
195
+
196
+ # Calculate theoretical diffraction size for validation
197
+ # First Airy minimum: r = 1.22 * lambda * distance / diameter
198
+ theoretical_radius = (1.22 * rest_wavelength * det.filter_distance / pinhole_diameter).to(u.m)
199
+ theoretical_radius_pixels = (theoretical_radius / (det.pix_size*1*u.pix)).to(u.dimensionless_unscaled).value
200
+
201
+ # Calculate EUV diffraction pattern
202
+ euv_pattern = calculate_pinhole_diffraction_pattern(
203
+ detector_shape=(n_slit, n_spectral),
204
+ pixel_size=det.pix_size*u.pix,
205
+ pinhole_diameter=pinhole_diameter,
206
+ pinhole_position_slit=pinhole_position,
207
+ slit_width=sim.slit_width,
208
+ plate_scale=det.plate_scale_angle,
209
+ distance=det.filter_distance,
210
+ wavelength=rest_wavelength
211
+ )
212
+
213
+ # For EUV, the diffraction pattern is much smaller than visible light
214
+ # Normalize the pattern to ensure total integrated intensity equals 1.0
215
+ # This is crucial for proper photon conservation
216
+ pattern_total = np.sum(euv_pattern)
217
+ if pattern_total > 0:
218
+ euv_pattern_normalized = euv_pattern / pattern_total
219
+ else:
220
+ euv_pattern_normalized = euv_pattern
221
+
222
+ # Process each scan position
223
+ for i in range(n_scan):
224
+ # Current filtered signal at this scan position
225
+ filtered_signal = photon_counts.data[i, :, :] # Shape: (n_slit, n_spectral)
226
+
227
+ # Back-calculate unfiltered signal (before filter attenuation)
228
+ # filtered_signal = unfiltered_signal * filter_throughput
229
+ # So: unfiltered_signal = filtered_signal / filter_throughput
230
+ unfiltered_signal = filtered_signal / filter_throughput_spectrum[np.newaxis, :]
231
+
232
+ # Calculate what would come through pinhole (unattenuated)
233
+ # Use normalized pattern to ensure proper photon conservation
234
+ pinhole_signal = unfiltered_signal * area_ratio * euv_pattern_normalized
235
+
236
+ # Calculate what we incorrectly have from filter in pinhole regions
237
+ # (filtered signal weighted by diffraction pattern and area ratio)
238
+ overcounted_filtered = filtered_signal * area_ratio * euv_pattern_normalized
239
+
240
+ # Net correction: add unfiltered pinhole signal, subtract overcounted filtered signal
241
+ # This simplifies to: filtered_signal * area_ratio * pattern * (1/filter_throughput - 1)
242
+ # Physical meaning:
243
+ # - unfiltered * area_ratio * pattern = total light through pinhole
244
+ # - filtered * area_ratio * pattern = incorrectly counted filtered light
245
+ # - difference = net additional light from pinhole
246
+ correction = (pinhole_signal - overcounted_filtered)
247
+
248
+ # Equivalent simplified form (more efficient):
249
+ # correction = filtered_signal * area_ratio * euv_pattern * (1/filter_throughput_spectrum[np.newaxis, :] - 1)
250
+ additional_photons[i, :, :] += correction
251
+
252
+ # Create new photon counts with EUV pinhole contributions
253
+ new_data = photon_counts.data + additional_photons
254
+
255
+ return NDCube(
256
+ data=new_data,
257
+ wcs=photon_counts.wcs.deepcopy(),
258
+ unit=photon_counts.unit,
259
+ meta=photon_counts.meta,
260
+ )
euvst_response/psf.py ADDED
@@ -0,0 +1,46 @@
1
+ """
2
+ PSF handling functions for telescope and detector point spread functions.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ from pathlib import Path
7
+ import numpy as np
8
+ import astropy.units as u
9
+ from scipy.ndimage import zoom
10
+ from scipy.signal import convolve2d
11
+
12
+
13
+ def _load_psf_ascii(fname: Path, skip: int) -> np.ndarray:
14
+ """Load PSF data from ASCII file."""
15
+ return np.loadtxt(fname, skiprows=skip, encoding="utf-16 LE")
16
+
17
+
18
+ def _resample_psf(psf: np.ndarray, res_in: u.Quantity, res_out: u.Quantity) -> np.ndarray:
19
+ """Resample PSF to different pixel scale."""
20
+ factor = (res_in / res_out).decompose().value
21
+ return zoom(psf, factor, order=1)
22
+
23
+
24
+ def _combine_psfs(psf_focus: np.ndarray, psf_mesh: np.ndarray, crop: float = 0.99, size: int | None = None) -> np.ndarray:
25
+ """Convolve focus and mesh PSF and crop to given energy or size."""
26
+ psf = convolve2d(psf_focus, psf_mesh, mode="same")
27
+ if size is not None:
28
+ if size % 2 == 0:
29
+ size += 1
30
+ r0, c0 = np.array(psf.shape) // 2
31
+ half = size // 2
32
+ psf = psf[r0 - half : r0 + half + 1, c0 - half : c0 + half + 1]
33
+ else:
34
+ flat = psf.ravel()
35
+ idx = flat.argsort()[::-1]
36
+ csum = flat[idx].cumsum()
37
+ thr = flat[idx[np.searchsorted(csum, flat.sum() * crop)]]
38
+ flat[flat < thr] = 0
39
+ rows, cols = np.where(flat.reshape(psf.shape))
40
+ r0, r1 = rows.min(), rows.max()
41
+ c0, c1 = cols.min(), cols.max()
42
+ side = max(r1 - r0, c1 - c0) + 1
43
+ r0 = (r0 + r1) // 2 - side // 2
44
+ c0 = (c0 + c1) // 2 - side // 2
45
+ psf = psf[r0 : r0 + side, c0 : c0 + side]
46
+ return psf / psf.sum()