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,269 @@
1
+ """
2
+ Data processing functions for atmosphere cubes and spectral resampling.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ from pathlib import Path
7
+ import numpy as np
8
+ import astropy.units as u
9
+ import dill
10
+ from ndcube import NDCube
11
+ from astropy.wcs import WCS
12
+ from specutils import Spectrum
13
+ from specutils.manipulation import FluxConservingResampler
14
+ from joblib import Parallel, delayed
15
+ from tqdm import tqdm
16
+ from .utils import tqdm_joblib, distance_to_angle
17
+
18
+
19
+ def load_atmosphere(pkl_file: str, metadata_line: str = None) -> NDCube:
20
+ """
21
+ Load synthetic atmosphere cube from pickle file.
22
+
23
+ Creates a summed cube from all line cubes in the synthesis results.
24
+ All line cubes are interpolated onto the wavelength grid of the metadata_line
25
+ before summing to handle different wavelength grids for different lines.
26
+
27
+ Parameters
28
+ ----------
29
+ pkl_file : str
30
+ Path to the synthesized spectra pickle file.
31
+ metadata_line : str, optional
32
+ Name of the line to use for metadata and wavelength grid reference.
33
+ If None, uses the first line.
34
+
35
+ Returns
36
+ -------
37
+ NDCube
38
+ Summed cube of all line intensities with proper WCS and metadata.
39
+ Uses the wavelength grid from the metadata_line.
40
+ """
41
+ with open(pkl_file, "rb") as f:
42
+ tmp = dill.load(f)
43
+
44
+ # Handle new synthesis format
45
+ if "line_cubes" not in tmp:
46
+ raise ValueError("File does not contain synthesis results with line_cubes")
47
+
48
+ line_cubes = tmp["line_cubes"]
49
+ if not line_cubes:
50
+ raise ValueError("No line cubes found in synthesis results")
51
+
52
+ # Get the line names
53
+ line_names = list(line_cubes.keys())
54
+
55
+ # Choose metadata source line
56
+ if metadata_line is None:
57
+ metadata_line = line_names[0]
58
+ elif metadata_line not in line_names:
59
+ raise ValueError(f"Metadata line '{metadata_line}' not found. Available lines: {line_names}")
60
+
61
+ # Use the metadata line's wavelength grid as the reference
62
+ ref_cube = line_cubes[metadata_line]
63
+ ref_wavelengths = ref_cube.axis_world_coords(-1)[0]
64
+
65
+ # Get spatial dimensions from reference cube
66
+ ny, nx, nw = ref_cube.data.shape
67
+
68
+ # Initialize summed data with the reference wavelength grid
69
+ summed_data = np.zeros((ny, nx, nw))
70
+
71
+ for line_name, cube in tqdm(line_cubes.items(), desc="Summing line cubes", unit="line", leave=False):
72
+ # Get wavelength grid for this cube
73
+ cube_wavelengths = cube.axis_world_coords(-1)[0]
74
+
75
+ # Check spatial dimensions match
76
+ ny_cube, nx_cube, _ = cube.data.shape
77
+ if ny_cube != ny or nx_cube != nx:
78
+ raise ValueError(f"Spatial dimensions mismatch for {line_name}: expected ({ny}, {nx}), got ({ny_cube}, {nx_cube})")
79
+
80
+ # Vectorized interpolation for the entire cube
81
+ # Reshape data to (n_pixels, n_wavelengths) for batch interpolation
82
+ cube_data_reshaped = cube.data.reshape(-1, len(cube_wavelengths))
83
+
84
+ # Batch interpolation using numpy.interp
85
+ interpolated = np.array([
86
+ np.interp(ref_wavelengths.value, cube_wavelengths.value, spectrum, left=0.0, right=0.0)
87
+ for spectrum in cube_data_reshaped
88
+ ])
89
+
90
+ # Add to summed data
91
+ summed_data += interpolated.reshape(ny, nx, len(ref_wavelengths))
92
+
93
+ # Create new metadata combining info from all lines
94
+ combined_meta = ref_cube.meta.copy()
95
+ combined_meta.update({
96
+ "combined_lines": line_names,
97
+ "n_lines": len(line_names),
98
+ "metadata_source": metadata_line,
99
+ "summed_intensity": True
100
+ })
101
+
102
+ # Create the summed cube using the reference cube's WCS
103
+ summed_cube = NDCube(
104
+ summed_data,
105
+ wcs=ref_cube.wcs,
106
+ unit=ref_cube.unit,
107
+ meta=combined_meta
108
+ )
109
+
110
+ return summed_cube
111
+
112
+
113
+ def resample_ndcube_spectral_axis(ndcube, spectral_axis, output_resolution, ncpu=-1):
114
+ """
115
+ Resample the spectral axis of an NDCube using FluxConservingResampler.
116
+
117
+ Parameters
118
+ ----------
119
+ ndcube : NDCube
120
+ The input NDCube.
121
+ spectral_axis : int
122
+ The index of the spectral axis (e.g., 0, 1, or 2).
123
+ output_resolution : astropy.units.Quantity
124
+ The desired output spectral resolution (e.g., 0.01 * u.nm).
125
+ ncpu : int, optional
126
+ Number of CPU cores to use for parallel processing. Default is -1 (use all cores).
127
+
128
+ Returns
129
+ -------
130
+ NDCube
131
+ A new NDCube with the spectral axis resampled.
132
+ """
133
+ # Get the world coordinates of the spectral axis
134
+ spectral_world = ndcube.axis_world_coords(spectral_axis)[0]
135
+ spectral_world = spectral_world.to(output_resolution.unit)
136
+
137
+ # Define new spectral grid
138
+ new_spec_grid = np.arange(
139
+ spectral_world.min().value,
140
+ spectral_world.max().value + output_resolution.value,
141
+ output_resolution.value
142
+ ) * output_resolution.unit
143
+
144
+ n_spec = len(new_spec_grid)
145
+
146
+ # Move spectral axis to last for easier iteration
147
+ data = np.moveaxis(ndcube.data, spectral_axis, -1)
148
+ shape = data.shape
149
+ flat_data = data.reshape(-1, shape[-1])
150
+
151
+ resampler = FluxConservingResampler(extrapolation_treatment="zero_fill")
152
+ resampled = np.zeros((flat_data.shape[0], n_spec))
153
+
154
+ def _resample_pixel(i):
155
+ spec = Spectrum(flux=flat_data[i] * ndcube.unit, spectral_axis=spectral_world)
156
+ res = resampler(spec, new_spec_grid)
157
+ return res.flux.value
158
+
159
+ with tqdm_joblib(tqdm(total=flat_data.shape[0], desc="Resampling spectral axis", unit="pixel", leave=False)):
160
+ results = Parallel(n_jobs=ncpu)(
161
+ delayed(_resample_pixel)(i) for i in range(flat_data.shape[0])
162
+ )
163
+ resampled = np.vstack(results)
164
+
165
+ # Reshape back to original spatial shape, but with new spectral length
166
+ new_shape = list(shape[:-1]) + [n_spec]
167
+ resampled = resampled.reshape(new_shape)
168
+
169
+ # Move spectral axis back to original position
170
+ resampled = np.moveaxis(resampled, -1, spectral_axis)
171
+
172
+ # Update WCS for new spectral axis
173
+ new_wcs = ndcube.wcs.deepcopy()
174
+
175
+ wcs_axis = new_wcs.wcs.naxis - 1 - spectral_axis # Reverse axis order for WCS
176
+ center_pixel = (n_spec + 1) / 2 # 1-based index (FITS convention)
177
+ new_wcs.wcs.crpix[wcs_axis] = center_pixel
178
+ new_wcs.wcs.crval[wcs_axis] = new_spec_grid[int(center_pixel - 1)].to_value(new_wcs.wcs.cunit[wcs_axis])
179
+ new_wcs.wcs.cdelt[wcs_axis] = (new_spec_grid[1] - new_spec_grid[0]).to_value(new_wcs.wcs.cunit[wcs_axis])
180
+
181
+ return NDCube(resampled, wcs=new_wcs, unit=ndcube.unit, meta=ndcube.meta)
182
+
183
+
184
+ def reproject_ndcube_heliocentric_to_helioprojective(new_cube_spec, sim, det):
185
+ """ Reproject an NDCube from heliocentric to helioprojective coordinates. """
186
+
187
+ nx, ny, _ = new_cube_spec.shape
188
+ wcs_hc = new_cube_spec.wcs
189
+
190
+ dx = wcs_hc.wcs.cdelt[2] * wcs_hc.wcs.cunit[2]
191
+ dy = wcs_hc.wcs.cdelt[1] * wcs_hc.wcs.cunit[1]
192
+ x_angle = distance_to_angle(dx)
193
+ y_angle = distance_to_angle(dy)
194
+
195
+ wcs_hp = WCS(naxis=3)
196
+ wcs_hp.wcs.ctype = [wcs_hc.wcs.ctype[0], 'HPLT-TAN', 'HPLN-TAN']
197
+ wcs_hp.wcs.cunit = [wcs_hc.wcs.cunit[0], 'arcsec', 'arcsec']
198
+ wcs_hp.wcs.crpix = [wcs_hc.wcs.crpix[0],
199
+ (ny + 1) / 2,
200
+ (nx + 1) / 2]
201
+ wcs_hp.wcs.crval = [wcs_hc.wcs.crval[0], 0, 0]
202
+ wcs_hp.wcs.cdelt = [wcs_hc.wcs.cdelt[0], y_angle.to_value(u.arcsec), x_angle.to_value(u.arcsec)]
203
+ new_cube_spec_hp = NDCube(new_cube_spec.data, wcs=wcs_hp, unit=new_cube_spec.unit, meta=new_cube_spec.meta)
204
+
205
+ nx_in, ny_in, nl_in = new_cube_spec_hp.shape
206
+ fov_x = nx_in * x_angle
207
+ fov_y = ny_in * y_angle
208
+ pitch_x = sim.slit_width
209
+ pitch_y = det.plate_scale_angle
210
+ nx_out = int(np.floor((fov_x / pitch_x).decompose().value))
211
+ ny_out = int(np.floor((fov_y / pitch_y).decompose().value))
212
+ shape_out = [nx_out, ny_out, nl_in]
213
+
214
+ crpix_spec = (shape_out[2] + 1) / 2
215
+ crpix_y = (shape_out[1] + 1) / 2
216
+ crpix_x = (shape_out[0] + 1) / 2
217
+
218
+ wcs_tgt = WCS(naxis=3)
219
+ wcs_tgt.wcs.ctype = [wcs_hc.wcs.ctype[0], 'HPLT-TAN', 'HPLN-TAN']
220
+ wcs_tgt.wcs.cunit = [wcs_hc.wcs.cunit[0], 'arcsec', 'arcsec']
221
+ wcs_tgt.wcs.crpix = [crpix_spec, crpix_y, crpix_x]
222
+ wcs_tgt.wcs.crval = [wcs_hc.wcs.crval[0], 0, 0]
223
+ wcs_tgt.wcs.cdelt = [wcs_hc.wcs.cdelt[0],
224
+ (det.plate_scale_angle * u.pix).to_value(u.arcsec),
225
+ (sim.slit_width).to_value(u.arcsec)]
226
+
227
+ new_cube_spec_hp_spat = new_cube_spec_hp.reproject_to(
228
+ wcs_tgt,
229
+ shape_out=shape_out,
230
+ algorithm='interpolation',
231
+ parallel=True,
232
+ order='bilinear',
233
+ ) * new_cube_spec_hp.unit
234
+
235
+ return new_cube_spec_hp_spat
236
+
237
+
238
+ def rebin_atmosphere(cube_sim, det, sim, use_dask=False):
239
+ """
240
+ Rebin synthetic atmosphere cube to instrument resolution and spatial sampling.
241
+
242
+ Parameters
243
+ ----------
244
+ cube_sim : NDCube
245
+ Input synthetic atmosphere cube
246
+ det : Detector_SWC or Detector_EIS
247
+ Detector configuration
248
+ sim : Simulation
249
+ Simulation configuration
250
+ use_dask : bool, optional
251
+ Whether to use Dask for automatic parallelization (default: True)
252
+
253
+ Returns
254
+ -------
255
+ NDCube
256
+ Rebinned cube at instrument resolution
257
+ """
258
+ print(" Spectral rebinning to instrument resolution (nx,ny,*nl*)...")
259
+
260
+ cube_spec = resample_ndcube_spectral_axis(cube_sim, spectral_axis=2, output_resolution=det.wvl_res*u.pix, ncpu=sim.ncpu)
261
+
262
+ print(" Spatially rebinning to plate scale (nx,*ny*,nl) and slit width (*nx*,ny,nl)...")
263
+ cube_det = reproject_ndcube_heliocentric_to_helioprojective(
264
+ cube_spec,
265
+ sim,
266
+ det
267
+ )
268
+
269
+ return cube_det
@@ -0,0 +1,144 @@
1
+ """
2
+ Spectral fitting functions for Gaussian line profile analysis.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ import warnings
7
+ import numpy as np
8
+ import astropy.units as u
9
+ import astropy.constants as const
10
+ from ndcube import NDCube
11
+ from scipy.optimize import curve_fit, OptimizeWarning
12
+ from joblib import Parallel, delayed
13
+ from tqdm import tqdm
14
+ from .utils import gaussian, tqdm_joblib
15
+
16
+
17
+ def _guess_params(wv: np.ndarray, prof: np.ndarray) -> list:
18
+ """Guess initial parameters for Gaussian fit."""
19
+ back = prof.min()
20
+ prof_c = prof - back
21
+ prof_c[prof_c < 0] = 0
22
+ peak = prof_c.max()
23
+ centre = wv[np.nanargmax(prof_c)]
24
+ if peak == 0:
25
+ sigma = (wv.max() - wv.min()) / 10
26
+ else:
27
+ # Simple FWHM estimate
28
+ half_max = 0.5 * peak
29
+ indices = np.where(prof_c >= half_max)[0]
30
+ if len(indices) > 1:
31
+ fwhm = wv[indices[-1]] - wv[indices[0]]
32
+ sigma = fwhm / (2 * np.sqrt(2 * np.log(2)))
33
+ else:
34
+ sigma = (wv.max() - wv.min()) / 10
35
+ return [peak, centre, sigma, back]
36
+
37
+
38
+ def _fit_one(wv: np.ndarray, prof: np.ndarray) -> np.ndarray:
39
+ """Fit single spectrum with Gaussian."""
40
+ p0 = _guess_params(wv, prof)
41
+ with warnings.catch_warnings():
42
+ warnings.simplefilter("ignore", OptimizeWarning)
43
+ try:
44
+ popt, _ = curve_fit(gaussian, wv, prof, p0=p0)
45
+ return popt
46
+ except:
47
+ return np.array(p0)
48
+
49
+
50
+ def fit_cube_gauss(signal_cube: NDCube, n_jobs: int = -1) -> tuple[np.ndarray, list[u.Unit]]:
51
+ """
52
+ Fit a Gaussian to every (slit x wavelength) spectrum.
53
+
54
+ Returns a tuple of (data_array, units_list) where:
55
+ - data_array: shape (n_scan, n_slit, 4) with the parameters
56
+ [peak, centre, sigma, background] for each spatial pixel (values only)
57
+ - units_list: list of 4 astropy Unit objects corresponding to the parameters
58
+ """
59
+
60
+ n_scan, n_slit, _ = signal_cube.shape
61
+ wv = signal_cube.axis_world_coords(2)[0].cgs # wavelength axis (Quantity)
62
+
63
+ def _fit_block(spec_block: np.ndarray) -> np.ndarray:
64
+ results = np.empty((spec_block.shape[0], 4))
65
+ for i in range(spec_block.shape[0]):
66
+ results[i] = _fit_one(wv.value, spec_block[i])
67
+ return results
68
+
69
+ with tqdm_joblib(tqdm(total=n_scan, desc="Fit chunks", leave=False)):
70
+ results = Parallel(n_jobs=n_jobs)(
71
+ delayed(_fit_block)(signal_cube.data[i]) for i in range(n_scan)
72
+ )
73
+
74
+ # Stack to (n_scan, n_slit, 4) - values only
75
+ data_array = np.stack(results, axis=0)
76
+
77
+ # Units for [peak, centre, sigma, background]
78
+ units_list = [signal_cube.unit, wv.unit, wv.unit, signal_cube.unit]
79
+
80
+ return data_array, units_list
81
+
82
+
83
+ def velocity_from_fit(fit_arr: u.Quantity | np.ndarray, wl0: u.Quantity, n_jobs: int = -1) -> u.Quantity:
84
+ """
85
+ Convert fitted line centres to LOS velocity.
86
+ Works with either a Quantity array or an object-dtype array whose
87
+ elements are Quantities. Uses joblib.Parallel for speed.
88
+ """
89
+ centres_raw = fit_arr[..., 1] # (n_scan, n_slit)
90
+ # Ensure we have a pure Quantity array
91
+ if isinstance(centres_raw, u.Quantity):
92
+ centres = centres_raw.to(wl0.unit)
93
+ else: # object array of Quantity scalars
94
+ get_val = np.vectorize(lambda q: q.to_value(wl0.unit))
95
+ centres = u.Quantity(get_val(centres_raw), wl0.unit)
96
+
97
+ n_scan = centres.shape[0]
98
+
99
+ def _one_row(i):
100
+ return ((centres[i] - wl0) / wl0 * const.c).to(u.cm / u.s).value
101
+
102
+ with tqdm_joblib(tqdm(total=n_scan, desc="Velocity calc", leave=False)):
103
+ v_val = np.array(
104
+ Parallel(n_jobs=n_jobs)(
105
+ delayed(_one_row)(i) for i in range(n_scan)
106
+ )
107
+ )
108
+
109
+ v = v_val * (u.cm / u.s)
110
+ return v
111
+
112
+
113
+ def width_from_fit(fit_arr: u.Quantity | np.ndarray, n_jobs: int = -1) -> u.Quantity:
114
+ """
115
+ Extract fitted line widths (sigma) from fit results.
116
+ """
117
+ widths_raw = fit_arr[..., 2] # (n_scan, n_slit) - sigma is 3rd parameter
118
+ # Ensure we have a pure Quantity array
119
+ if isinstance(widths_raw, u.Quantity):
120
+ widths = widths_raw
121
+ else: # object array of Quantity scalars
122
+ get_val = np.vectorize(lambda q: q.value)
123
+ get_unit = widths_raw.flat[0].unit # Get unit from first element
124
+ widths = u.Quantity(get_val(widths_raw), get_unit)
125
+
126
+ return widths
127
+
128
+
129
+ def analyse(fits_all: u.Quantity | np.ndarray, v_true: u.Quantity, wl0: u.Quantity) -> dict:
130
+ """
131
+ Monte-Carlo velocity statistics given pre-computed ground truth.
132
+ """
133
+ v_all = velocity_from_fit(fits_all, wl0)
134
+ w_all = width_from_fit(fits_all)
135
+ return {
136
+ "v_mean": v_all.mean(axis=0),
137
+ "v_std": v_all.std(axis=0),
138
+ "v_err": v_true - v_all.mean(axis=0),
139
+ "v_samples": v_all,
140
+ "v_true": v_true,
141
+ "w_mean": w_all.mean(axis=0),
142
+ "w_std": w_all.std(axis=0),
143
+ "w_samples": w_all,
144
+ }