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,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
+ )