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