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.
- euvst_response/__init__.py +52 -0
- euvst_response/analysis.py +680 -0
- euvst_response/cli.py +113 -0
- euvst_response/config.py +396 -0
- euvst_response/data/throughput/grating_reflection_efficiency.dat +25 -0
- euvst_response/data/throughput/primary_mirror_coating_reflectance.dat +25 -0
- euvst_response/data/throughput/source.txt +3 -0
- euvst_response/data/throughput/throughput_aluminium_1000_angstrom.dat +503 -0
- euvst_response/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +503 -0
- euvst_response/data/throughput/throughput_carbon_1000_angstrom.dat +503 -0
- euvst_response/data_processing.py +269 -0
- euvst_response/fitting.py +144 -0
- euvst_response/main.py +424 -0
- euvst_response/monte_carlo.py +159 -0
- euvst_response/pinhole_diffraction.py +260 -0
- euvst_response/psf.py +46 -0
- euvst_response/radiometric.py +512 -0
- euvst_response/synthesis.py +911 -0
- euvst_response/synthesis_cli.py +12 -0
- euvst_response/utils.py +176 -0
- solarc_eclipse-0.5.0.dist-info/METADATA +354 -0
- solarc_eclipse-0.5.0.dist-info/RECORD +26 -0
- solarc_eclipse-0.5.0.dist-info/WHEEL +5 -0
- solarc_eclipse-0.5.0.dist-info/entry_points.txt +5 -0
- solarc_eclipse-0.5.0.dist-info/licenses/LICENSE +1 -0
- solarc_eclipse-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Radiometric pipeline functions for converting intensities to detector signals.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import numpy as np
|
|
7
|
+
import astropy.units as u
|
|
8
|
+
import astropy.constants as const
|
|
9
|
+
from ndcube import NDCube
|
|
10
|
+
from scipy.signal import convolve2d
|
|
11
|
+
from .utils import wl_to_vel, vel_to_wl, debug_break
|
|
12
|
+
from scipy.special import voigt_profile
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _vectorized_fano_noise(photon_counts: np.ndarray, rest_wavelength: u.Quantity, det) -> np.ndarray:
|
|
16
|
+
"""
|
|
17
|
+
Vectorized version of Fano noise calculation for improved performance.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
photon_counts : np.ndarray
|
|
22
|
+
Array of photon counts (unitless values)
|
|
23
|
+
rest_wavelength : u.Quantity
|
|
24
|
+
Rest wavelength with units
|
|
25
|
+
det : Detector_SWC or Detector_EIS
|
|
26
|
+
Detector object with fano noise parameters
|
|
27
|
+
|
|
28
|
+
Returns
|
|
29
|
+
-------
|
|
30
|
+
np.ndarray
|
|
31
|
+
Array of electron counts with Fano noise applied
|
|
32
|
+
"""
|
|
33
|
+
# Handle zero or negative photon counts
|
|
34
|
+
mask_positive = photon_counts > 0
|
|
35
|
+
electron_counts = np.zeros_like(photon_counts)
|
|
36
|
+
|
|
37
|
+
if not np.any(mask_positive):
|
|
38
|
+
return electron_counts
|
|
39
|
+
|
|
40
|
+
# Get CCD temperature - must be set via with_temperature()
|
|
41
|
+
if not hasattr(det, '_ccd_temperature'):
|
|
42
|
+
raise ValueError("CCD temperature not set. Use Detector_SWC.with_temperature() to create detector instance.")
|
|
43
|
+
|
|
44
|
+
# Convert to Kelvin for the calculation
|
|
45
|
+
temp_kelvin = det._ccd_temperature.to(u.K, equivalencies=u.temperature()).value
|
|
46
|
+
|
|
47
|
+
# Convert wavelength to photon energy: E = hc/lambda
|
|
48
|
+
photon_energy_ev = (const.h * const.c / (rest_wavelength.to(u.angstrom))).to(u.eV).value
|
|
49
|
+
|
|
50
|
+
# Calculate temperature-dependent energy per electron-hole pair
|
|
51
|
+
w_T = 3.71 - 0.0006 * (temp_kelvin - 300.0) # eV per electron-hole pair
|
|
52
|
+
|
|
53
|
+
# Mean number of electrons per photon
|
|
54
|
+
mean_electrons_per_photon = photon_energy_ev / w_T
|
|
55
|
+
|
|
56
|
+
# Fano noise variance per photon
|
|
57
|
+
sigma_fano_per_photon = np.sqrt(det.si_fano * mean_electrons_per_photon)
|
|
58
|
+
|
|
59
|
+
# Work only with positive photon counts
|
|
60
|
+
positive_photons = photon_counts[mask_positive]
|
|
61
|
+
|
|
62
|
+
# For efficiency, use a simpler approximation for most cases
|
|
63
|
+
# The exact method is: for each photon, sample from Normal(mean_e, sigma_fano)
|
|
64
|
+
# Approximation: for N photons, sample from Normal(N*mean_e, sqrt(N)*sigma_fano)
|
|
65
|
+
# This is mathematically equivalent for large N and much faster
|
|
66
|
+
|
|
67
|
+
mean_total_electrons = positive_photons * mean_electrons_per_photon
|
|
68
|
+
std_total_electrons = np.sqrt(positive_photons) * sigma_fano_per_photon
|
|
69
|
+
|
|
70
|
+
# Sample total electrons per pixel
|
|
71
|
+
total_electrons = np.random.normal(
|
|
72
|
+
loc=mean_total_electrons,
|
|
73
|
+
scale=std_total_electrons
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Ensure non-negative
|
|
77
|
+
total_electrons = np.maximum(total_electrons, 0)
|
|
78
|
+
|
|
79
|
+
# Map back to full array
|
|
80
|
+
electron_counts[mask_positive] = total_electrons
|
|
81
|
+
|
|
82
|
+
return electron_counts
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def intensity_to_photons(I: NDCube) -> NDCube:
|
|
86
|
+
"""Convert intensity to photon flux."""
|
|
87
|
+
wl_axis = I.axis_world_coords(2)[0]
|
|
88
|
+
E_ph = (const.h * const.c / wl_axis).to("erg") * (1 / u.photon)
|
|
89
|
+
|
|
90
|
+
photon_data = (I.data * I.unit / E_ph).to(u.photon / u.cm**2 / u.sr / u.cm)
|
|
91
|
+
|
|
92
|
+
return NDCube(
|
|
93
|
+
data=photon_data.value,
|
|
94
|
+
wcs=I.wcs.deepcopy(),
|
|
95
|
+
unit=photon_data.unit,
|
|
96
|
+
meta=I.meta,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def add_telescope_throughput(ph_flux: NDCube, tel) -> NDCube:
|
|
101
|
+
"""Add telescope optical throughput (collecting area x optical efficiencies) to photon flux."""
|
|
102
|
+
wl0 = ph_flux.meta['rest_wav']
|
|
103
|
+
wl_axis = ph_flux.axis_world_coords(2)[0]
|
|
104
|
+
throughput = np.array([tel.ea_and_throughput(wl).cgs.value for wl in wl_axis]) * u.cm**2
|
|
105
|
+
|
|
106
|
+
out_data = (ph_flux.data * ph_flux.unit * throughput)
|
|
107
|
+
|
|
108
|
+
return NDCube(
|
|
109
|
+
data=out_data.value,
|
|
110
|
+
wcs=ph_flux.wcs.deepcopy(),
|
|
111
|
+
unit=out_data.unit,
|
|
112
|
+
meta=ph_flux.meta,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def photons_to_pixel_counts(ph_flux: NDCube, wl_pitch: u.Quantity, plate_scale: u.Quantity, slit_width: u.Quantity) -> NDCube:
|
|
117
|
+
"""Convert photon flux to pixel counts (total over exposure)."""
|
|
118
|
+
pixel_solid_angle = ((plate_scale * u.pixel * slit_width).cgs / const.au.cgs ** 2) * u.sr
|
|
119
|
+
|
|
120
|
+
out_data = (ph_flux.data * ph_flux.unit * pixel_solid_angle * wl_pitch)
|
|
121
|
+
|
|
122
|
+
return NDCube(
|
|
123
|
+
data=out_data.value,
|
|
124
|
+
wcs=ph_flux.wcs.deepcopy(),
|
|
125
|
+
unit=out_data.unit,
|
|
126
|
+
meta=ph_flux.meta,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def apply_focusing_optics_psf(signal: NDCube, tel) -> NDCube:
|
|
131
|
+
"""
|
|
132
|
+
Convolve each detector row (first axis) of an NDCube with a parameterized PSF
|
|
133
|
+
from the focusing optics (primary mirror and diffraction grating).
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
signal : NDCube
|
|
138
|
+
Input cube with shape (n_scan, n_slit, n_lambda).
|
|
139
|
+
The first axis is stepped by the raster scan.
|
|
140
|
+
tel : Telescope_EUVST or Telescope_EIS
|
|
141
|
+
Telescope configuration containing PSF parameters.
|
|
142
|
+
For Gaussian: psf_params = [width]
|
|
143
|
+
For Voigt: psf_params = [width, gamma]
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
NDCube
|
|
148
|
+
New cube with identical WCS / unit / meta but PSF-blurred data.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
# Extract data and units
|
|
152
|
+
data_in = signal.data # ndarray view (no units)
|
|
153
|
+
unit = signal.unit
|
|
154
|
+
n_scan, n_slit, n_lambda = data_in.shape
|
|
155
|
+
|
|
156
|
+
# Get PSF parameters from telescope
|
|
157
|
+
psf_type = tel.psf_type.lower()
|
|
158
|
+
psf_params = tel.psf_params
|
|
159
|
+
|
|
160
|
+
# Extract width parameter (first parameter for both Gaussian and Voigt)
|
|
161
|
+
width_pixels = psf_params[0].to(u.pixel).value
|
|
162
|
+
|
|
163
|
+
# Create 2D PSF kernel
|
|
164
|
+
# Make kernel size based on width (use 6*width to capture most of the profile)
|
|
165
|
+
kernel_size = max(7, int(6 * width_pixels))
|
|
166
|
+
if kernel_size % 2 == 0: # Ensure odd size for symmetric kernel
|
|
167
|
+
kernel_size += 1
|
|
168
|
+
|
|
169
|
+
# Create coordinate grids centered at 0
|
|
170
|
+
center = kernel_size // 2
|
|
171
|
+
y, x = np.mgrid[:kernel_size, :kernel_size]
|
|
172
|
+
y = y - center
|
|
173
|
+
x = x - center
|
|
174
|
+
|
|
175
|
+
# Create radial distance from center
|
|
176
|
+
r = np.sqrt(x**2 + y**2)
|
|
177
|
+
|
|
178
|
+
# Create PSF based on type
|
|
179
|
+
if psf_type == "gaussian":
|
|
180
|
+
sigma = width_pixels
|
|
181
|
+
psf = np.exp(-0.5 * (r / sigma)**2)
|
|
182
|
+
|
|
183
|
+
elif psf_type == "voigt":
|
|
184
|
+
# For Voigt: need both width and gamma parameters
|
|
185
|
+
if len(psf_params) < 2:
|
|
186
|
+
raise ValueError("Voigt PSF requires two parameters: [width, gamma]")
|
|
187
|
+
|
|
188
|
+
sigma_gauss = width_pixels
|
|
189
|
+
# Get gamma parameter for Lorentzian component
|
|
190
|
+
gamma_lorentz = psf_params[1].to(u.pixel).value
|
|
191
|
+
|
|
192
|
+
# Create 2D Voigt PSF (approximate as radially symmetric)
|
|
193
|
+
psf = voigt_profile(r, sigma_gauss, gamma_lorentz)
|
|
194
|
+
|
|
195
|
+
else:
|
|
196
|
+
raise ValueError(f"Unsupported PSF type: {psf_type}. Supported types: 'gaussian', 'voigt'")
|
|
197
|
+
|
|
198
|
+
# Normalize PSF
|
|
199
|
+
psf = psf / np.sum(psf)
|
|
200
|
+
|
|
201
|
+
# Convolve each scan position
|
|
202
|
+
blurred = np.empty_like(data_in)
|
|
203
|
+
for i in range(n_scan):
|
|
204
|
+
blurred[i] = convolve2d(data_in[i], psf, mode="same")
|
|
205
|
+
|
|
206
|
+
return NDCube(
|
|
207
|
+
data=blurred,
|
|
208
|
+
wcs=signal.wcs.deepcopy(),
|
|
209
|
+
unit=unit,
|
|
210
|
+
meta=signal.meta,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def to_electrons(photon_counts: NDCube, t_exp: u.Quantity, det) -> NDCube:
|
|
215
|
+
"""
|
|
216
|
+
Convert a photon-count NDCube to an electron-count NDCube.
|
|
217
|
+
|
|
218
|
+
Parameters
|
|
219
|
+
----------
|
|
220
|
+
photon_counts : NDCube
|
|
221
|
+
Cube of total photon counts per pixel (over exposure).
|
|
222
|
+
t_exp : Quantity
|
|
223
|
+
Exposure time (used for dark current and read noise).
|
|
224
|
+
det : Detector_SWC or Detector_EIS
|
|
225
|
+
Detector description.
|
|
226
|
+
|
|
227
|
+
Returns
|
|
228
|
+
-------
|
|
229
|
+
NDCube
|
|
230
|
+
Electron counts per pixel for the given exposure.
|
|
231
|
+
"""
|
|
232
|
+
# Get rest wavelength from metadata (keep as Quantity with units)
|
|
233
|
+
rest_wavelength = photon_counts.meta['rest_wav'] # Should be a Quantity
|
|
234
|
+
|
|
235
|
+
# Apply quantum efficiency first using binomial distribution (proper physics)
|
|
236
|
+
photons_detected = np.random.binomial(
|
|
237
|
+
photon_counts.to(u.photon/u.pix).data.astype(int), # Extract unitless data
|
|
238
|
+
det.qe_euv
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Apply proper Fano noise per pixel using a vectorized approach
|
|
242
|
+
electron_counts = _vectorized_fano_noise(photons_detected.astype(float), rest_wavelength, det)
|
|
243
|
+
|
|
244
|
+
e = electron_counts * (u.electron / u.pixel)
|
|
245
|
+
|
|
246
|
+
# Add dark current and read noise (these still depend on exposure time)
|
|
247
|
+
e += det.dark_current * t_exp # dark current
|
|
248
|
+
e += np.random.normal(0, det.read_noise_rms.value,
|
|
249
|
+
photon_counts.data.shape) * (u.electron / u.pixel) # read noise
|
|
250
|
+
|
|
251
|
+
e = e.to(u.electron / u.pixel)
|
|
252
|
+
e_val = e.value
|
|
253
|
+
e_val[e_val < 0] = 0 # clip negatives
|
|
254
|
+
|
|
255
|
+
return NDCube(
|
|
256
|
+
data=e_val,
|
|
257
|
+
wcs=photon_counts.wcs.deepcopy(),
|
|
258
|
+
unit=e.unit,
|
|
259
|
+
meta=photon_counts.meta,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def to_dn(electrons: NDCube, det) -> NDCube:
|
|
264
|
+
"""
|
|
265
|
+
Convert an electron-count NDCube to DN and clip at the detector's full-well.
|
|
266
|
+
|
|
267
|
+
Parameters
|
|
268
|
+
----------
|
|
269
|
+
electrons : NDCube
|
|
270
|
+
Electron counts per pixel (u.electron / u.pixel).
|
|
271
|
+
det : Detector_SWC or Detector_EIS
|
|
272
|
+
Detector description containing the gain and max DN.
|
|
273
|
+
|
|
274
|
+
Returns
|
|
275
|
+
-------
|
|
276
|
+
NDCube
|
|
277
|
+
Same cube in DN / pixel, with values clipped to det.max_dn.
|
|
278
|
+
"""
|
|
279
|
+
dn_q = (electrons.data * electrons.unit) / det.gain_e_per_dn # Quantity
|
|
280
|
+
dn_q = dn_q.to(det.max_dn.unit)
|
|
281
|
+
|
|
282
|
+
dn_val = dn_q.value
|
|
283
|
+
dn_val[dn_val > det.max_dn.value] = det.max_dn.value # clip
|
|
284
|
+
|
|
285
|
+
return NDCube(
|
|
286
|
+
data=dn_val,
|
|
287
|
+
wcs=electrons.wcs.deepcopy(),
|
|
288
|
+
unit=dn_q.unit,
|
|
289
|
+
meta=electrons.meta,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def add_poisson(cube: NDCube) -> NDCube:
|
|
294
|
+
"""
|
|
295
|
+
Apply Poisson noise to an input NDCube and return a new NDCube
|
|
296
|
+
with the same WCS, unit, and metadata.
|
|
297
|
+
|
|
298
|
+
Parameters
|
|
299
|
+
----------
|
|
300
|
+
cube : NDCube
|
|
301
|
+
Input data cube.
|
|
302
|
+
|
|
303
|
+
Returns
|
|
304
|
+
-------
|
|
305
|
+
NDCube
|
|
306
|
+
New cube containing Poisson-noised data.
|
|
307
|
+
"""
|
|
308
|
+
noisy = np.random.poisson(cube.data) * cube.unit
|
|
309
|
+
return NDCube(
|
|
310
|
+
data=noisy.value,
|
|
311
|
+
wcs=cube.wcs.deepcopy(),
|
|
312
|
+
unit=noisy.unit,
|
|
313
|
+
meta=cube.meta,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def apply_exposure_and_poisson(I: NDCube, t_exp: u.Quantity) -> NDCube:
|
|
318
|
+
"""
|
|
319
|
+
Apply exposure time to intensity and add Poisson noise.
|
|
320
|
+
|
|
321
|
+
This converts intensity (per second) to total counts over the exposure
|
|
322
|
+
and applies appropriate Poisson noise.
|
|
323
|
+
|
|
324
|
+
Parameters
|
|
325
|
+
----------
|
|
326
|
+
I : NDCube
|
|
327
|
+
Input intensity cube (per second).
|
|
328
|
+
t_exp : u.Quantity
|
|
329
|
+
Exposure time.
|
|
330
|
+
|
|
331
|
+
Returns
|
|
332
|
+
-------
|
|
333
|
+
NDCube
|
|
334
|
+
New cube with exposure applied and Poisson noise added.
|
|
335
|
+
"""
|
|
336
|
+
# Convert intensity rate to total intensity over exposure
|
|
337
|
+
total_intensity = (I.data * I.unit * t_exp)
|
|
338
|
+
|
|
339
|
+
# Apply Poisson noise
|
|
340
|
+
noisy = np.random.poisson(total_intensity.value) * total_intensity.unit
|
|
341
|
+
|
|
342
|
+
return NDCube(
|
|
343
|
+
data=noisy.value,
|
|
344
|
+
wcs=I.wcs.deepcopy(),
|
|
345
|
+
unit=noisy.unit,
|
|
346
|
+
meta=I.meta,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def add_visible_stray_light(electrons: NDCube, t_exp: u.Quantity, det, sim, tel=None) -> NDCube:
|
|
351
|
+
"""
|
|
352
|
+
Add visible-light stray-light to a cube of electron counts.
|
|
353
|
+
|
|
354
|
+
Parameters
|
|
355
|
+
----------
|
|
356
|
+
electrons : NDCube
|
|
357
|
+
Electron counts per pixel (unit: u.electron / u.pixel).
|
|
358
|
+
t_exp : astropy.units.Quantity
|
|
359
|
+
Exposure time.
|
|
360
|
+
det : Detector_SWC or Detector_EIS
|
|
361
|
+
Detector description.
|
|
362
|
+
sim : Simulation
|
|
363
|
+
Simulation parameters (contains vis_sl - photon/s/cm2).
|
|
364
|
+
tel : Telescope_EUVST or Telescope_EIS, optional
|
|
365
|
+
Telescope configuration for filter throughput calculation.
|
|
366
|
+
|
|
367
|
+
Returns
|
|
368
|
+
-------
|
|
369
|
+
NDCube
|
|
370
|
+
New cube with stray-light signal added.
|
|
371
|
+
"""
|
|
372
|
+
# Convert vis_sl from photon/s/cm2 to photon/s/pixel using detector pixel area
|
|
373
|
+
pixel_area = ((det.pix_size*1*u.pix)**2)/u.pix # cm/pix -> cm2/pixel
|
|
374
|
+
vis_sl_per_pixel = (sim.vis_sl * pixel_area).to(u.photon / (u.s * u.pixel))
|
|
375
|
+
|
|
376
|
+
# Apply filter throughput if telescope with filter is available
|
|
377
|
+
if tel is not None and hasattr(tel, 'filter'):
|
|
378
|
+
filter_throughput = tel.filter.visible_light_throughput()
|
|
379
|
+
vis_sl_per_pixel *= filter_throughput
|
|
380
|
+
|
|
381
|
+
# Draw Poisson realisation of stray-light photons
|
|
382
|
+
n_vis_ph = np.random.poisson(
|
|
383
|
+
(vis_sl_per_pixel * t_exp).to_value(u.photon / u.pixel),
|
|
384
|
+
size=electrons.data.shape
|
|
385
|
+
) * (u.photon / u.pixel)
|
|
386
|
+
|
|
387
|
+
# Assume visible stray light is ~600nm (typical visible wavelength)
|
|
388
|
+
visible_wavelength = 600 * u.nm # Keep as Quantity with units
|
|
389
|
+
|
|
390
|
+
# Apply quantum efficiency first, then vectorized Fano noise
|
|
391
|
+
vis_photons_detected = np.random.binomial(
|
|
392
|
+
n_vis_ph.to_value(u.photon / u.pixel).astype(int),
|
|
393
|
+
det.qe_vis
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Apply vectorized Fano noise to detected visible photons
|
|
397
|
+
stray_electrons_values = _vectorized_fano_noise(vis_photons_detected.astype(float), visible_wavelength, det)
|
|
398
|
+
stray_electrons = stray_electrons_values * (u.electron / u.pixel)
|
|
399
|
+
|
|
400
|
+
# Add to original signal
|
|
401
|
+
out_q = electrons.data * electrons.unit + stray_electrons
|
|
402
|
+
out_q = out_q.to(electrons.unit)
|
|
403
|
+
|
|
404
|
+
return NDCube(
|
|
405
|
+
data=out_q.value,
|
|
406
|
+
wcs=electrons.wcs.deepcopy(),
|
|
407
|
+
unit=out_q.unit,
|
|
408
|
+
meta=electrons.meta,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def add_pinhole_visible_light(electrons: NDCube, t_exp: u.Quantity, det, sim, tel) -> NDCube:
|
|
413
|
+
"""
|
|
414
|
+
Add visible light contributions from pinholes to electron counts.
|
|
415
|
+
|
|
416
|
+
This function adds the visible light that bypasses the aluminum filter
|
|
417
|
+
through pinholes and creates diffraction patterns on the detector.
|
|
418
|
+
|
|
419
|
+
Parameters
|
|
420
|
+
----------
|
|
421
|
+
electrons : NDCube
|
|
422
|
+
Electron counts per pixel (unit: u.electron / u.pixel).
|
|
423
|
+
t_exp : u.Quantity
|
|
424
|
+
Exposure time.
|
|
425
|
+
det : Detector_SWC
|
|
426
|
+
Detector configuration (must be SWC for pinhole support).
|
|
427
|
+
sim : Simulation
|
|
428
|
+
Simulation parameters containing pinhole configuration.
|
|
429
|
+
tel : Telescope_EUVST
|
|
430
|
+
Telescope configuration with aluminum filter.
|
|
431
|
+
|
|
432
|
+
Returns
|
|
433
|
+
-------
|
|
434
|
+
NDCube
|
|
435
|
+
New cube with pinhole visible light contributions added.
|
|
436
|
+
"""
|
|
437
|
+
if not (sim.enable_pinholes and len(sim.pinhole_sizes) > 0):
|
|
438
|
+
return electrons # No pinholes enabled
|
|
439
|
+
|
|
440
|
+
# Import here to avoid circular imports
|
|
441
|
+
from .pinhole_diffraction import calculate_pinhole_diffraction_pattern
|
|
442
|
+
|
|
443
|
+
# Get detector and data properties
|
|
444
|
+
data_shape = electrons.data.shape # Should be (n_scan, n_slit, n_spectral)
|
|
445
|
+
|
|
446
|
+
# Visible light wavelength (typical)
|
|
447
|
+
visible_wavelength = 600 * u.nm
|
|
448
|
+
|
|
449
|
+
# Initialize additional electron contributions
|
|
450
|
+
additional_electrons = np.zeros_like(electrons.data)
|
|
451
|
+
|
|
452
|
+
for pinhole_diameter, pinhole_position in zip(sim.pinhole_sizes, sim.pinhole_positions):
|
|
453
|
+
# Calculate pinhole area
|
|
454
|
+
pinhole_area = np.pi * (pinhole_diameter / 2)**2
|
|
455
|
+
|
|
456
|
+
# === Visible Light Contribution Through Pinhole ===
|
|
457
|
+
# Calculate total photons incident on the pinhole area (unfiltered)
|
|
458
|
+
# sim.vis_sl is photon/s/cm^2, pinhole_area is in cm^2
|
|
459
|
+
vis_photons_per_sec_through_pinhole = sim.vis_sl * pinhole_area
|
|
460
|
+
vis_photons_total_through_pinhole = (vis_photons_per_sec_through_pinhole * t_exp).to(u.photon)
|
|
461
|
+
|
|
462
|
+
# Calculate visible diffraction pattern - this shows how the pinhole photons spread
|
|
463
|
+
n_scan, n_slit, n_spectral = data_shape
|
|
464
|
+
vis_pattern = calculate_pinhole_diffraction_pattern(
|
|
465
|
+
detector_shape=(n_slit, n_spectral),
|
|
466
|
+
pixel_size=det.pix_size*u.pix,
|
|
467
|
+
pinhole_diameter=pinhole_diameter,
|
|
468
|
+
pinhole_position_slit=pinhole_position,
|
|
469
|
+
slit_width=sim.slit_width,
|
|
470
|
+
plate_scale=det.plate_scale_angle,
|
|
471
|
+
distance=det.filter_distance,
|
|
472
|
+
wavelength=visible_wavelength
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Distribute the total pinhole photons according to diffraction pattern
|
|
476
|
+
# vis_pattern is normalized (peak = 1), so we need to ensure photon conservation
|
|
477
|
+
# Normalize the pattern so the total integrated intensity equals 1.0
|
|
478
|
+
pattern_total = np.sum(vis_pattern)
|
|
479
|
+
if pattern_total > 0:
|
|
480
|
+
vis_pattern_normalized = vis_pattern / pattern_total
|
|
481
|
+
else:
|
|
482
|
+
vis_pattern_normalized = vis_pattern
|
|
483
|
+
|
|
484
|
+
vis_photons_distributed = vis_photons_total_through_pinhole.to(u.photon).value * vis_pattern_normalized
|
|
485
|
+
|
|
486
|
+
# Sample Poisson photons for this pinhole contribution
|
|
487
|
+
vis_photons_poisson = np.random.poisson(vis_photons_distributed)
|
|
488
|
+
|
|
489
|
+
# Apply quantum efficiency
|
|
490
|
+
vis_photons_detected = np.random.binomial(
|
|
491
|
+
vis_photons_poisson.astype(int),
|
|
492
|
+
det.qe_vis
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
# Apply Fano noise to detected visible photons
|
|
496
|
+
vis_electrons_values = _vectorized_fano_noise(vis_photons_detected.astype(float), visible_wavelength, det)
|
|
497
|
+
|
|
498
|
+
# Add to all scan positions (visible light affects all equally)
|
|
499
|
+
for scan_idx in range(n_scan):
|
|
500
|
+
additional_electrons[scan_idx] += vis_electrons_values
|
|
501
|
+
|
|
502
|
+
# Add pinhole contributions to original signal
|
|
503
|
+
additional_electrons_quantity = additional_electrons * (u.electron / u.pixel)
|
|
504
|
+
out_q = electrons.data * electrons.unit + additional_electrons_quantity
|
|
505
|
+
out_q = out_q.to(electrons.unit)
|
|
506
|
+
|
|
507
|
+
return NDCube(
|
|
508
|
+
data=out_q.value,
|
|
509
|
+
wcs=electrons.wcs.deepcopy(),
|
|
510
|
+
unit=out_q.unit,
|
|
511
|
+
meta=electrons.meta,
|
|
512
|
+
)
|