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,911 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import argparse
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict, Tuple, List, Optional
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.io import readsav
|
|
7
|
+
from scipy.interpolate import RegularGridInterpolator
|
|
8
|
+
import astropy.units as u
|
|
9
|
+
import astropy.constants as const
|
|
10
|
+
from tqdm import tqdm
|
|
11
|
+
import psutil
|
|
12
|
+
import dask.array as da
|
|
13
|
+
from dask.diagnostics import ProgressBar
|
|
14
|
+
from mendeleev import element
|
|
15
|
+
import dill
|
|
16
|
+
from ndcube import NDCube
|
|
17
|
+
from astropy.wcs import WCS
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
import shutil
|
|
20
|
+
|
|
21
|
+
##############################################################################
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# I/O helpers
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
##############################################################################
|
|
26
|
+
|
|
27
|
+
def velocity_centers_to_edges(vel_grid: np.ndarray) -> np.ndarray:
|
|
28
|
+
"""
|
|
29
|
+
Convert velocity grid centers to bin edges.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
vel_grid : np.ndarray
|
|
34
|
+
1D array of velocity centers.
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
np.ndarray
|
|
39
|
+
1D array of velocity bin edges (length = len(vel_grid) + 1).
|
|
40
|
+
"""
|
|
41
|
+
if len(vel_grid) < 2:
|
|
42
|
+
raise ValueError("vel_grid must have at least 2 elements")
|
|
43
|
+
|
|
44
|
+
dv = vel_grid[1] - vel_grid[0]
|
|
45
|
+
return np.concatenate([
|
|
46
|
+
[vel_grid[0] - 0.5 * dv],
|
|
47
|
+
vel_grid[:-1] + 0.5 * dv,
|
|
48
|
+
[vel_grid[-1] + 0.5 * dv]
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
def load_cube(
|
|
52
|
+
file_path: str | Path,
|
|
53
|
+
shape: Tuple[int, int, int] = (512, 768, 256),
|
|
54
|
+
unit: Optional[u.Unit] = None,
|
|
55
|
+
downsample: int | bool = False,
|
|
56
|
+
precision: type = np.float32,
|
|
57
|
+
voxel_dx: Optional[u.Quantity] = None,
|
|
58
|
+
voxel_dy: Optional[u.Quantity] = None,
|
|
59
|
+
voxel_dz: Optional[u.Quantity] = None,
|
|
60
|
+
create_ndcube: bool = False,
|
|
61
|
+
) -> np.ndarray | u.Quantity | NDCube:
|
|
62
|
+
"""
|
|
63
|
+
Read a Fortran-ordered binary cube (single precision) and optionally return as NDCube.
|
|
64
|
+
|
|
65
|
+
The cube is stored (x, z, y) in the file and transposed to (x, y, z)
|
|
66
|
+
upon loading.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
file_path : str | Path
|
|
71
|
+
Path to the binary file.
|
|
72
|
+
shape : Tuple[int, int, int]
|
|
73
|
+
Tuple (nx, ny, nz) describing the *full* cube dimensions.
|
|
74
|
+
unit : astropy.units.Unit, optional
|
|
75
|
+
Astropy unit to attach (e.g. u.K or u.g/u.cm**3). If None, returns
|
|
76
|
+
a plain ndarray.
|
|
77
|
+
downsample : int | bool
|
|
78
|
+
Integer factor; if non-False, keep every *downsample*-th cell along
|
|
79
|
+
each axis (simple stride).
|
|
80
|
+
precision : type
|
|
81
|
+
np.float32 or np.float64 for returned dtype.
|
|
82
|
+
voxel_dx, voxel_dy, voxel_dz : u.Quantity, optional
|
|
83
|
+
Voxel sizes for creating proper WCS coordinates. Required if create_ndcube=True.
|
|
84
|
+
create_ndcube : bool, optional
|
|
85
|
+
If True, return an NDCube with proper WCS coordinates.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
ndarray, Quantity, or NDCube
|
|
90
|
+
Array with shape (nx', ny', nz') or NDCube with proper coordinates.
|
|
91
|
+
"""
|
|
92
|
+
data = np.fromfile(file_path, dtype=np.float32).reshape(shape, order="F")
|
|
93
|
+
data = data.transpose(0, 2, 1) # (x,y,z)
|
|
94
|
+
|
|
95
|
+
if downsample:
|
|
96
|
+
data = data[::downsample, ::downsample, ::downsample]
|
|
97
|
+
voxel_dx *= downsample
|
|
98
|
+
voxel_dy *= downsample
|
|
99
|
+
voxel_dz *= downsample
|
|
100
|
+
|
|
101
|
+
data = data.astype(precision, copy=False)
|
|
102
|
+
|
|
103
|
+
if unit is not None:
|
|
104
|
+
data = data * unit
|
|
105
|
+
|
|
106
|
+
if create_ndcube:
|
|
107
|
+
return create_atmosphere_ndcube(data, voxel_dx, voxel_dy, voxel_dz)
|
|
108
|
+
else:
|
|
109
|
+
return data
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def create_atmosphere_ndcube(
|
|
113
|
+
data: np.ndarray | u.Quantity,
|
|
114
|
+
voxel_dx: u.Quantity,
|
|
115
|
+
voxel_dy: u.Quantity,
|
|
116
|
+
voxel_dz: u.Quantity,
|
|
117
|
+
) -> NDCube:
|
|
118
|
+
"""
|
|
119
|
+
Create an NDCube for atmospheric data with proper heliocentric coordinates.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
data : np.ndarray or u.Quantity
|
|
124
|
+
3D data array with shape (nx, ny, nz).
|
|
125
|
+
voxel_dx, voxel_dy, voxel_dz : u.Quantity
|
|
126
|
+
Voxel sizes in Mm.
|
|
127
|
+
|
|
128
|
+
Returns
|
|
129
|
+
-------
|
|
130
|
+
NDCube
|
|
131
|
+
Cube with proper WCS coordinates.
|
|
132
|
+
X,Y centered at origin, Z starting at 0.
|
|
133
|
+
"""
|
|
134
|
+
nx, ny, nz = data.shape
|
|
135
|
+
|
|
136
|
+
# Create WCS for heliocentric coordinates
|
|
137
|
+
wcs = WCS(naxis=3)
|
|
138
|
+
wcs.wcs.ctype = ['SOLZ', 'SOLY', 'SOLX']
|
|
139
|
+
wcs.wcs.cunit = ['Mm', 'Mm', 'Mm']
|
|
140
|
+
|
|
141
|
+
# Reference pixels (1-indexed for WCS)
|
|
142
|
+
wcs.wcs.crpix = [1, (ny + 1) / 2, (nx + 1) / 2] # Z starts at first pixel
|
|
143
|
+
|
|
144
|
+
# Reference values
|
|
145
|
+
wcs.wcs.crval = [0, 0, 0] # X,Y centered at origin, Z starts at 0
|
|
146
|
+
|
|
147
|
+
# Pixel scales
|
|
148
|
+
wcs.wcs.cdelt = [
|
|
149
|
+
voxel_dz.to(u.Mm).value,
|
|
150
|
+
voxel_dy.to(u.Mm).value,
|
|
151
|
+
voxel_dx.to(u.Mm).value
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
return NDCube(data.data,
|
|
155
|
+
wcs=wcs,
|
|
156
|
+
unit=data.unit)
|
|
157
|
+
|
|
158
|
+
def read_goft(
|
|
159
|
+
sav_file: str | Path,
|
|
160
|
+
limit_lines: Optional[List[str]] = None,
|
|
161
|
+
precision: type = np.float64,
|
|
162
|
+
) -> Tuple[Dict[str, dict], np.ndarray, np.ndarray]:
|
|
163
|
+
"""
|
|
164
|
+
Read a CHIANTI G(T,N) .sav file produced by IDL.
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
sav_file : str | Path
|
|
169
|
+
Path to the IDL save file containing GOFT data.
|
|
170
|
+
limit_lines : List[str], optional
|
|
171
|
+
If provided, only load these specific lines.
|
|
172
|
+
precision : type
|
|
173
|
+
Precision for arrays (np.float32 or np.float64).
|
|
174
|
+
|
|
175
|
+
Returns
|
|
176
|
+
-------
|
|
177
|
+
goft_dict : Dict[str, dict]
|
|
178
|
+
Dictionary keyed by line name, each entry holding:
|
|
179
|
+
'wl0' - rest wavelength (Quantity, cm)
|
|
180
|
+
'g_tn' - 2-D array G(logT, logN) [erg cm^3 s^-1]
|
|
181
|
+
'atom' - atomic number
|
|
182
|
+
'ion' - ionization stage
|
|
183
|
+
logT_grid : np.ndarray
|
|
184
|
+
1-D array of log10(T/K) values.
|
|
185
|
+
logN_grid : np.ndarray
|
|
186
|
+
1-D array of log10(N_e/cm^3) values.
|
|
187
|
+
"""
|
|
188
|
+
raw = readsav(sav_file)
|
|
189
|
+
goft_dict: Dict[str, dict] = {}
|
|
190
|
+
|
|
191
|
+
logT_grid = raw["logTarr"].astype(precision)
|
|
192
|
+
logN_grid = raw["logNarr"].astype(precision)
|
|
193
|
+
|
|
194
|
+
for entry in raw["goftarr"]:
|
|
195
|
+
# Handle both string and bytes for line names (different IDL save versions)
|
|
196
|
+
line_name = entry[0] # This is the 'name' field from the IDL structure
|
|
197
|
+
if hasattr(line_name, 'decode'):
|
|
198
|
+
line_name = line_name.decode() # bytes -> string
|
|
199
|
+
# line_name is now a string, e.g. "Fe12_195.1190"
|
|
200
|
+
|
|
201
|
+
if limit_lines and line_name not in limit_lines:
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
rest_wl = float(line_name.split("_")[1]) * u.AA # A -> Quantity
|
|
205
|
+
goft_dict[line_name] = {
|
|
206
|
+
"wl0": rest_wl.to(u.cm),
|
|
207
|
+
"g_tn": entry[4].astype(precision), # This is the 'goft' field [nT, nN]
|
|
208
|
+
"atom": entry[1], # This is the 'atom' field
|
|
209
|
+
"ion": entry[2], # This is the 'ion' field
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return goft_dict, logT_grid, logN_grid
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
##############################################################################
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# DEM and G(T) helpers
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
##############################################################################
|
|
220
|
+
|
|
221
|
+
def compute_dem(
|
|
222
|
+
logT_cube: np.ndarray,
|
|
223
|
+
logN_cube: np.ndarray,
|
|
224
|
+
voxel_dh_cm: float,
|
|
225
|
+
logT_grid: np.ndarray,
|
|
226
|
+
integration_axis: str = "z",
|
|
227
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
228
|
+
"""
|
|
229
|
+
Build the differential emission measure DEM(T) and the emission-measure
|
|
230
|
+
weighted mean electron density <n_e>(T).
|
|
231
|
+
|
|
232
|
+
Parameters
|
|
233
|
+
----------
|
|
234
|
+
logT_cube : np.ndarray
|
|
235
|
+
3D array of log10(T/K) values.
|
|
236
|
+
logN_cube : np.ndarray
|
|
237
|
+
3D array of log10(n_e/cm^3) values.
|
|
238
|
+
voxel_dh_cm : float
|
|
239
|
+
Voxel depth in cm along integration axis.
|
|
240
|
+
logT_grid : np.ndarray
|
|
241
|
+
1D array of temperature bin centers for DEM calculation.
|
|
242
|
+
integration_axis : str
|
|
243
|
+
Axis along which to integrate ("x", "y", or "z").
|
|
244
|
+
|
|
245
|
+
Returns
|
|
246
|
+
-------
|
|
247
|
+
dem_map : np.ndarray
|
|
248
|
+
DEM array [cm^-5 per dex]. Shape depends on integration_axis:
|
|
249
|
+
- "x": (ny, nz, nT)
|
|
250
|
+
- "y": (nx, nz, nT)
|
|
251
|
+
- "z": (nx, ny, nT)
|
|
252
|
+
avg_ne : np.ndarray
|
|
253
|
+
Mean electron density per T-bin [cm^-3]. Same shape as dem_map.
|
|
254
|
+
"""
|
|
255
|
+
nT = len(logT_grid)
|
|
256
|
+
|
|
257
|
+
# Determine integration axis and output shape
|
|
258
|
+
axis_map = {"x": 0, "y": 1, "z": 2}
|
|
259
|
+
if integration_axis not in axis_map:
|
|
260
|
+
raise ValueError(f"integration_axis must be 'x', 'y', or 'z', got {integration_axis}")
|
|
261
|
+
|
|
262
|
+
integration_axis_idx = axis_map[integration_axis]
|
|
263
|
+
|
|
264
|
+
# Output shape depends on which axis we integrate over
|
|
265
|
+
if integration_axis == "x":
|
|
266
|
+
output_shape = (logT_cube.shape[1], logT_cube.shape[2], nT) # (ny, nz, nT)
|
|
267
|
+
elif integration_axis == "y":
|
|
268
|
+
output_shape = (logT_cube.shape[0], logT_cube.shape[2], nT) # (nx, nz, nT)
|
|
269
|
+
else: # "z"
|
|
270
|
+
output_shape = (logT_cube.shape[0], logT_cube.shape[1], nT) # (nx, ny, nT)
|
|
271
|
+
|
|
272
|
+
# Create temperature bin edges from centers
|
|
273
|
+
dlogT = logT_grid[1] - logT_grid[0] if len(logT_grid) > 1 else 0.1
|
|
274
|
+
logT_edges = np.concatenate([
|
|
275
|
+
[logT_grid[0] - dlogT/2],
|
|
276
|
+
logT_grid[:-1] + dlogT/2,
|
|
277
|
+
[logT_grid[-1] + dlogT/2]
|
|
278
|
+
])
|
|
279
|
+
|
|
280
|
+
ne = 10.0 ** logN_cube.astype(np.float64)
|
|
281
|
+
w2 = ne**2 # weights for EM
|
|
282
|
+
w3 = ne**3 # weights for EM*n_e
|
|
283
|
+
|
|
284
|
+
dem = np.zeros(output_shape)
|
|
285
|
+
avg_ne = np.zeros_like(dem)
|
|
286
|
+
|
|
287
|
+
for idx in tqdm(range(nT), desc="DEM bins", unit="bin", leave=False):
|
|
288
|
+
lo, hi = logT_edges[idx], logT_edges[idx + 1]
|
|
289
|
+
mask = (logT_cube >= lo) & (logT_cube < hi) # (nx,ny,nz)
|
|
290
|
+
|
|
291
|
+
# Integrate along the specified axis
|
|
292
|
+
em = np.sum(w2 * mask, axis=integration_axis_idx) * voxel_dh_cm # cm^-5
|
|
293
|
+
em_n = np.sum(w3 * mask, axis=integration_axis_idx) * voxel_dh_cm # cm^-5 * n_e
|
|
294
|
+
|
|
295
|
+
dem[..., idx] = em / dlogT
|
|
296
|
+
avg_ne[..., idx] = np.divide(em_n, em, where=em > 0.0)
|
|
297
|
+
|
|
298
|
+
return dem, avg_ne
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def interpolate_g_on_dem(
|
|
302
|
+
goft: Dict[str, dict],
|
|
303
|
+
avg_ne: np.ndarray,
|
|
304
|
+
logT_grid: np.ndarray,
|
|
305
|
+
logN_grid: np.ndarray,
|
|
306
|
+
logT_goft: np.ndarray,
|
|
307
|
+
precision: type = np.float32,
|
|
308
|
+
) -> None:
|
|
309
|
+
"""
|
|
310
|
+
For every spectral line, interpolate G(T,N) onto the DEM grid.
|
|
311
|
+
|
|
312
|
+
Parameters
|
|
313
|
+
----------
|
|
314
|
+
goft : Dict[str, dict]
|
|
315
|
+
Dictionary of line data, modified in place.
|
|
316
|
+
avg_ne : np.ndarray
|
|
317
|
+
Emission-measure weighted electron density (nx, ny, nT).
|
|
318
|
+
logT_grid : np.ndarray
|
|
319
|
+
Temperature grid for DEM (nT,).
|
|
320
|
+
logN_grid : np.ndarray
|
|
321
|
+
Density grid for GOFT interpolation.
|
|
322
|
+
logT_goft : np.ndarray
|
|
323
|
+
Temperature grid for GOFT interpolation.
|
|
324
|
+
precision : type
|
|
325
|
+
Output precision for interpolated G values.
|
|
326
|
+
"""
|
|
327
|
+
nT, nx, ny = len(logT_grid), *avg_ne.shape[:2]
|
|
328
|
+
|
|
329
|
+
# Build query points for interpolation
|
|
330
|
+
logNe_flat = np.log10(avg_ne, where=avg_ne > 0.0,
|
|
331
|
+
out=np.zeros_like(avg_ne)).transpose(2, 0, 1).ravel()
|
|
332
|
+
logT_flat = np.broadcast_to(logT_grid[:, None, None],
|
|
333
|
+
(nT, nx, ny)).ravel()
|
|
334
|
+
query_pts = np.column_stack((logNe_flat, logT_flat))
|
|
335
|
+
|
|
336
|
+
for name, info in tqdm(goft.items(), desc="interpolating G", unit="line", leave=False):
|
|
337
|
+
rgi = RegularGridInterpolator(
|
|
338
|
+
(logN_grid, logT_goft), info["g_tn"],
|
|
339
|
+
method="linear", bounds_error=False, fill_value=0.0
|
|
340
|
+
)
|
|
341
|
+
g_flat = rgi(query_pts)
|
|
342
|
+
info["g"] = g_flat.reshape(nT, nx, ny).transpose(1, 2, 0).astype(precision)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
##############################################################################
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
# Build EM(T,v) and synthesise spectra
|
|
348
|
+
# ---------------------------------------------------------------------------
|
|
349
|
+
##############################################################################
|
|
350
|
+
|
|
351
|
+
def build_em_tv(
|
|
352
|
+
logT_cube: np.ndarray,
|
|
353
|
+
vel_cube: np.ndarray,
|
|
354
|
+
logT_grid: np.ndarray,
|
|
355
|
+
vel_grid: np.ndarray,
|
|
356
|
+
ne_sq_dh: np.ndarray,
|
|
357
|
+
integration_axis: str = "z",
|
|
358
|
+
) -> np.ndarray:
|
|
359
|
+
"""
|
|
360
|
+
Construct 4-D emission-measure cube EM(x,y,T,v) [cm^-5].
|
|
361
|
+
|
|
362
|
+
Parameters
|
|
363
|
+
----------
|
|
364
|
+
logT_cube : np.ndarray
|
|
365
|
+
3D temperature cube.
|
|
366
|
+
vel_cube : np.ndarray
|
|
367
|
+
3D velocity cube along the integration axis.
|
|
368
|
+
logT_grid : np.ndarray
|
|
369
|
+
Temperature bin centers.
|
|
370
|
+
vel_grid : np.ndarray
|
|
371
|
+
Velocity bin centers.
|
|
372
|
+
ne_sq_dh : np.ndarray
|
|
373
|
+
n_e^2 * dh for each voxel.
|
|
374
|
+
integration_axis : str
|
|
375
|
+
Axis along which to integrate ("x", "y", or "z").
|
|
376
|
+
|
|
377
|
+
Returns
|
|
378
|
+
-------
|
|
379
|
+
em_tv : np.ndarray
|
|
380
|
+
4D emission measure cube. Shape depends on integration_axis:
|
|
381
|
+
- "x": (ny, nz, nT, nv)
|
|
382
|
+
- "y": (nx, nz, nT, nv)
|
|
383
|
+
- "z": (nx, ny, nT, nv)
|
|
384
|
+
"""
|
|
385
|
+
print(f" Building 4-D emission-measure cube along {integration_axis}-axis...")
|
|
386
|
+
|
|
387
|
+
# Determine integration axis and output shape
|
|
388
|
+
axis_map = {"x": 0, "y": 1, "z": 2}
|
|
389
|
+
if integration_axis not in axis_map:
|
|
390
|
+
raise ValueError(f"integration_axis must be 'x', 'y', or 'z', got {integration_axis}")
|
|
391
|
+
|
|
392
|
+
integration_axis_idx = axis_map[integration_axis]
|
|
393
|
+
|
|
394
|
+
# Create temperature bin edges from centers
|
|
395
|
+
dlogT = logT_grid[1] - logT_grid[0] if len(logT_grid) > 1 else 0.1
|
|
396
|
+
logT_edges = np.concatenate([
|
|
397
|
+
[logT_grid[0] - dlogT/2],
|
|
398
|
+
logT_grid[:-1] + dlogT/2,
|
|
399
|
+
[logT_grid[-1] + dlogT/2]
|
|
400
|
+
])
|
|
401
|
+
|
|
402
|
+
# Compute velocity bin edges from centers
|
|
403
|
+
v_edges = velocity_centers_to_edges(vel_grid.value)
|
|
404
|
+
|
|
405
|
+
mask_T = (logT_cube[..., None] >= logT_edges[:-1]) & \
|
|
406
|
+
(logT_cube[..., None] < logT_edges[1:])
|
|
407
|
+
mask_V = (vel_cube[..., None] >= v_edges[:-1]) & \
|
|
408
|
+
(vel_cube[..., None] < v_edges[1:])
|
|
409
|
+
|
|
410
|
+
# Build the 4-D emission-measure cube EM(spatial,T,v) by summing over the integration axis
|
|
411
|
+
ne_sq_dh_d = da.from_array(ne_sq_dh, chunks='auto')
|
|
412
|
+
mask_T_d = da.from_array(mask_T, chunks='auto')
|
|
413
|
+
mask_V_d = da.from_array(mask_V, chunks='auto')
|
|
414
|
+
|
|
415
|
+
# Sum along the specified integration axis
|
|
416
|
+
if integration_axis == "x":
|
|
417
|
+
em_tv_d = da.einsum("ijk,ijkl,ijkm->jklm", ne_sq_dh_d, mask_T_d, mask_V_d, optimize=True)
|
|
418
|
+
elif integration_axis == "y":
|
|
419
|
+
em_tv_d = da.einsum("ijk,ijkl,ijkm->iklm", ne_sq_dh_d, mask_T_d, mask_V_d, optimize=True)
|
|
420
|
+
else: # "z"
|
|
421
|
+
em_tv_d = da.einsum("ijk,ijkl,ijkm->ijlm", ne_sq_dh_d, mask_T_d, mask_V_d, optimize=True)
|
|
422
|
+
|
|
423
|
+
with ProgressBar():
|
|
424
|
+
em_tv = em_tv_d.compute()
|
|
425
|
+
|
|
426
|
+
return em_tv
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def synthesise_spectra(
|
|
430
|
+
goft: Dict[str, dict],
|
|
431
|
+
em_tv: np.ndarray,
|
|
432
|
+
vel_grid: np.ndarray,
|
|
433
|
+
logT_grid: np.ndarray,
|
|
434
|
+
) -> None:
|
|
435
|
+
"""
|
|
436
|
+
Convolve EM(T,v) with thermal Gaussians plus Doppler shift to obtain the
|
|
437
|
+
specific intensity cube I(x,y,lambda) for every line.
|
|
438
|
+
|
|
439
|
+
Parameters
|
|
440
|
+
----------
|
|
441
|
+
goft : Dict[str, dict]
|
|
442
|
+
Dictionary of line data, modified in place with 'si' and 'wl_grid'.
|
|
443
|
+
em_tv : np.ndarray
|
|
444
|
+
4D emission measure cube (nx, ny, nT, nv).
|
|
445
|
+
vel_grid : np.ndarray
|
|
446
|
+
Velocity grid centers for wavelength calculation.
|
|
447
|
+
logT_grid : np.ndarray
|
|
448
|
+
Temperature bin centers.
|
|
449
|
+
"""
|
|
450
|
+
kb = const.k_B.cgs.value
|
|
451
|
+
c_cm_s = const.c.cgs.value
|
|
452
|
+
|
|
453
|
+
for line, data in tqdm(goft.items(), desc="spectra", unit="line", leave=False):
|
|
454
|
+
wl0 = data["wl0"].cgs.value # cm
|
|
455
|
+
|
|
456
|
+
# Create wavelength grid for this line
|
|
457
|
+
data["wl_grid"] = (vel_grid * data["wl0"] / const.c + data["wl0"]).cgs
|
|
458
|
+
wl_grid = data["wl_grid"].cgs.value # (n_lambda,)
|
|
459
|
+
|
|
460
|
+
atom = element(int(data["atom"]))
|
|
461
|
+
atom_weight_g = (atom.atomic_weight * u.u).cgs.value
|
|
462
|
+
|
|
463
|
+
# Thermal width per T-bin: sigma_T (nT,)
|
|
464
|
+
sigma_T = wl0 * np.sqrt(2 * kb * (10 ** logT_grid) / atom_weight_g) / c_cm_s
|
|
465
|
+
|
|
466
|
+
# Doppler-shifted center for each v-bin: (nv,)
|
|
467
|
+
lam_cent = wl0 * (1 + vel_grid.value / c_cm_s)
|
|
468
|
+
|
|
469
|
+
# Build phi(T,v,lambda) as (nT,nv,n_lambda)
|
|
470
|
+
delta = wl_grid[None, None, :] - lam_cent[None, :, None]
|
|
471
|
+
phi = np.exp(-0.5 * (delta / sigma_T[:, None, None]) ** 2)
|
|
472
|
+
phi /= sigma_T[:, None, None] * np.sqrt(2 * np.pi)
|
|
473
|
+
|
|
474
|
+
# EM(x,y,T,v) * G(T) -> (nx,ny,nT,nv)
|
|
475
|
+
weighted = em_tv * data["g"][..., None]
|
|
476
|
+
|
|
477
|
+
# Collapse T and v: dot ((nT,nv) , (nT,nv)) -> (nx,ny,n_lambda)
|
|
478
|
+
spec_map = np.tensordot(weighted, phi, axes=([2, 3], [0, 1]))
|
|
479
|
+
|
|
480
|
+
data["si"] = spec_map / (4 * np.pi)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def create_line_cube(
|
|
484
|
+
line_name: str,
|
|
485
|
+
line_data: dict,
|
|
486
|
+
spatial_cube: NDCube,
|
|
487
|
+
intensity_unit: u.Unit,
|
|
488
|
+
integration_axis: str = "z",
|
|
489
|
+
) -> NDCube:
|
|
490
|
+
"""
|
|
491
|
+
Create an NDCube for a single spectral line using spatial coordinates from existing cube.
|
|
492
|
+
|
|
493
|
+
Parameters
|
|
494
|
+
----------
|
|
495
|
+
line_name : str
|
|
496
|
+
Name of the spectral line.
|
|
497
|
+
line_data : dict
|
|
498
|
+
Dictionary containing line data with 'si', 'wl_grid', 'wl0'.
|
|
499
|
+
spatial_cube : NDCube
|
|
500
|
+
Reference cube for spatial coordinates.
|
|
501
|
+
intensity_unit : u.Unit
|
|
502
|
+
Unit for the intensity data.
|
|
503
|
+
integration_axis : str
|
|
504
|
+
Axis along which integration was performed ("x", "y", or "z").
|
|
505
|
+
|
|
506
|
+
Returns
|
|
507
|
+
-------
|
|
508
|
+
NDCube
|
|
509
|
+
Cube with proper WCS and metadata.
|
|
510
|
+
"""
|
|
511
|
+
cube_data = line_data["si"] # Shape depends on integration_axis
|
|
512
|
+
|
|
513
|
+
# Get spatial coordinate information from the reference cube
|
|
514
|
+
if integration_axis == "x":
|
|
515
|
+
# Integration along X -> data shape (ny, nz, n_lambda), spatial axes: Y, Z
|
|
516
|
+
ny, nz, nl = cube_data.shape
|
|
517
|
+
y_coords = spatial_cube.axis_world_coords(1)[0] # Y coordinates
|
|
518
|
+
z_coords = spatial_cube.axis_world_coords(2)[0] # Z coordinates
|
|
519
|
+
|
|
520
|
+
spatial_axes = ['WAVE', 'SOLZ', 'SOLY'] # Wavelength, Z, Y
|
|
521
|
+
spatial_units = ['cm', 'Mm', 'Mm']
|
|
522
|
+
spatial_cdelt = [
|
|
523
|
+
np.diff(line_data["wl_grid"].to(u.cm).value)[0],
|
|
524
|
+
z_coords[1].to(u.Mm).value - z_coords[0].to(u.Mm).value,
|
|
525
|
+
y_coords[1].to(u.Mm).value - y_coords[0].to(u.Mm).value
|
|
526
|
+
]
|
|
527
|
+
spatial_crpix = [(nl + 1) / 2, 1, (ny + 1) / 2] # Wavelength centered, Z at first pixel, Y centered
|
|
528
|
+
spatial_crval = [
|
|
529
|
+
line_data["wl0"].to(u.cm).value,
|
|
530
|
+
z_coords[0].to(u.Mm).value, # Z starts where original cube starts
|
|
531
|
+
y_coords[ny//2].to(u.Mm).value # Y centered
|
|
532
|
+
]
|
|
533
|
+
|
|
534
|
+
elif integration_axis == "y":
|
|
535
|
+
# Integration along Y -> data shape (nx, nz, n_lambda), spatial axes: X, Z
|
|
536
|
+
nx, nz, nl = cube_data.shape
|
|
537
|
+
x_coords = spatial_cube.axis_world_coords(0)[0] # X coordinates
|
|
538
|
+
z_coords = spatial_cube.axis_world_coords(2)[0] # Z coordinates
|
|
539
|
+
|
|
540
|
+
spatial_axes = ['WAVE', 'SOLZ', 'SOLX'] # Wavelength, Z, X
|
|
541
|
+
spatial_units = ['cm', 'Mm', 'Mm']
|
|
542
|
+
spatial_cdelt = [
|
|
543
|
+
np.diff(line_data["wl_grid"].to(u.cm).value)[0],
|
|
544
|
+
z_coords[1].to(u.Mm).value - z_coords[0].to(u.Mm).value,
|
|
545
|
+
x_coords[1].to(u.Mm).value - x_coords[0].to(u.Mm).value
|
|
546
|
+
]
|
|
547
|
+
spatial_crpix = [(nl + 1) / 2, 1, (nx + 1) / 2] # Wavelength centered, Z at first pixel, X centered
|
|
548
|
+
spatial_crval = [
|
|
549
|
+
line_data["wl0"].to(u.cm).value,
|
|
550
|
+
z_coords[0].to(u.Mm).value, # Z starts where original cube starts
|
|
551
|
+
x_coords[nx//2].to(u.Mm).value # X centered
|
|
552
|
+
]
|
|
553
|
+
|
|
554
|
+
else: # integration_axis == "z"
|
|
555
|
+
# Integration along Z -> data shape (nx, ny, n_lambda), spatial axes: X, Y
|
|
556
|
+
nx, ny, nl = cube_data.shape
|
|
557
|
+
x_coords = spatial_cube.axis_world_coords(0)[0] # X coordinates
|
|
558
|
+
y_coords = spatial_cube.axis_world_coords(1)[0] # Y coordinates
|
|
559
|
+
|
|
560
|
+
spatial_axes = ['WAVE', 'SOLY', 'SOLX'] # Wavelength, Y, X
|
|
561
|
+
spatial_units = ['cm', 'Mm', 'Mm']
|
|
562
|
+
spatial_cdelt = [
|
|
563
|
+
np.diff(line_data["wl_grid"].to(u.cm).value)[0],
|
|
564
|
+
y_coords[1].to(u.Mm).value - y_coords[0].to(u.Mm).value,
|
|
565
|
+
x_coords[1].to(u.Mm).value - x_coords[0].to(u.Mm).value
|
|
566
|
+
]
|
|
567
|
+
spatial_crpix = [(nl + 1) / 2, (ny + 1) / 2, (nx + 1) / 2] # All centered
|
|
568
|
+
spatial_crval = [
|
|
569
|
+
line_data["wl0"].to(u.cm).value,
|
|
570
|
+
y_coords[ny//2].to(u.Mm).value, # Y centered
|
|
571
|
+
x_coords[nx//2].to(u.Mm).value # X centered
|
|
572
|
+
]
|
|
573
|
+
|
|
574
|
+
wcs = WCS(naxis=3)
|
|
575
|
+
wcs.wcs.ctype = spatial_axes
|
|
576
|
+
wcs.wcs.cunit = spatial_units
|
|
577
|
+
wcs.wcs.crpix = spatial_crpix
|
|
578
|
+
wcs.wcs.crval = spatial_crval
|
|
579
|
+
wcs.wcs.cdelt = spatial_cdelt
|
|
580
|
+
|
|
581
|
+
return NDCube(
|
|
582
|
+
cube_data,
|
|
583
|
+
wcs=wcs,
|
|
584
|
+
unit=intensity_unit,
|
|
585
|
+
meta={
|
|
586
|
+
"line_name": line_name,
|
|
587
|
+
"rest_wav": line_data["wl0"],
|
|
588
|
+
"atom": line_data["atom"],
|
|
589
|
+
"ion": line_data["ion"],
|
|
590
|
+
"integration_axis": integration_axis,
|
|
591
|
+
"spatial_reference": spatial_cube.meta if hasattr(spatial_cube, 'meta') else None
|
|
592
|
+
}
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
##############################################################################
|
|
598
|
+
# ---------------------------------------------------------------------------
|
|
599
|
+
# M A I N W O R K F L O W
|
|
600
|
+
# ---------------------------------------------------------------------------
|
|
601
|
+
##############################################################################
|
|
602
|
+
|
|
603
|
+
def parse_arguments():
|
|
604
|
+
"""Parse command line arguments for spectrum synthesis."""
|
|
605
|
+
parser = argparse.ArgumentParser(
|
|
606
|
+
description="Synthesize solar spectra from 3D MHD simulation data",
|
|
607
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# Input/Output paths
|
|
611
|
+
parser.add_argument("--data-dir", type=str, default="data/atmosphere",
|
|
612
|
+
help="Directory containing simulation data")
|
|
613
|
+
parser.add_argument("--goft-file", type=str, default="./data/gofnt.sav",
|
|
614
|
+
help="Path to CHIANTI G(T,N) save file")
|
|
615
|
+
parser.add_argument("--output-dir", type=str, default="./run/input",
|
|
616
|
+
help="Output directory for results")
|
|
617
|
+
parser.add_argument("--output-name", type=str, default="synthesised_spectra.pkl",
|
|
618
|
+
help="Output filename")
|
|
619
|
+
|
|
620
|
+
# Simulation files
|
|
621
|
+
parser.add_argument("--temp-file", type=str, default="temp/eosT.0270000",
|
|
622
|
+
help="Temperature file relative to data-dir")
|
|
623
|
+
parser.add_argument("--rho-file", type=str, default="rho/result_prim_0.0270000",
|
|
624
|
+
help="Density file relative to data-dir")
|
|
625
|
+
parser.add_argument("--vx-file", type=str, default="vx/result_prim_1.0270000",
|
|
626
|
+
help="Velocity x file relative to data-dir")
|
|
627
|
+
parser.add_argument("--vy-file", type=str, default="vy/result_prim_3.0270000",
|
|
628
|
+
help="Velocity y file relative to data-dir")
|
|
629
|
+
parser.add_argument("--vz-file", type=str, default="vz/result_prim_2.0270000",
|
|
630
|
+
help="Velocity z file relative to data-dir")
|
|
631
|
+
|
|
632
|
+
# Grid parameters
|
|
633
|
+
parser.add_argument("--cube-shape", nargs=3, type=int, default=[512, 768, 256],
|
|
634
|
+
help="Cube dimensions (nx ny nz)")
|
|
635
|
+
parser.add_argument("--voxel-dx", type=float, default=0.192,
|
|
636
|
+
help="Voxel size in x (Mm)")
|
|
637
|
+
parser.add_argument("--voxel-dy", type=float, default=0.192,
|
|
638
|
+
help="Voxel size in y (Mm)")
|
|
639
|
+
parser.add_argument("--voxel-dz", type=float, default=0.064,
|
|
640
|
+
help="Voxel size in z (Mm)")
|
|
641
|
+
|
|
642
|
+
# Integration direction
|
|
643
|
+
parser.add_argument("--integration-axis", choices=["x", "y", "z"], default="z",
|
|
644
|
+
help="Axis along which to integrate (x, y, or z)")
|
|
645
|
+
|
|
646
|
+
# Cropping parameters (in Heliocentric coordinates, Mm)
|
|
647
|
+
parser.add_argument("--crop-x", nargs=2, type=float, default=None,
|
|
648
|
+
help="Crop in x direction: x_min x_max (Mm, None for no cropping)")
|
|
649
|
+
parser.add_argument("--crop-y", nargs=2, type=float, default=None,
|
|
650
|
+
help="Crop in y direction: y_min y_max (Mm, None for no cropping)")
|
|
651
|
+
parser.add_argument("--crop-z", nargs=2, type=float, default=None,
|
|
652
|
+
help="Crop in z direction: z_min z_max (Mm, None for no cropping)")
|
|
653
|
+
|
|
654
|
+
# Velocity grid
|
|
655
|
+
parser.add_argument("--vel-res", type=float, default=5.0,
|
|
656
|
+
help="Velocity resolution (km/s)")
|
|
657
|
+
parser.add_argument("--vel-lim", type=float, default=300.0,
|
|
658
|
+
help="Velocity limit +/- (km/s)")
|
|
659
|
+
|
|
660
|
+
# Processing options
|
|
661
|
+
parser.add_argument("--downsample", type=int, default=1,
|
|
662
|
+
help="Downsampling factor (1 = no downsampling)")
|
|
663
|
+
parser.add_argument("--precision", choices=["float32", "float64"], default="float64",
|
|
664
|
+
help="Numerical precision")
|
|
665
|
+
parser.add_argument("--mean-mol-wt", type=float, default=1.29,
|
|
666
|
+
help="Mean molecular weight")
|
|
667
|
+
|
|
668
|
+
# Line selection
|
|
669
|
+
parser.add_argument("--limit-lines", nargs="*", default=None,
|
|
670
|
+
help="Limit to specific lines (e.g. Fe12_195.1190)")
|
|
671
|
+
|
|
672
|
+
return parser.parse_args()
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def main(args=None) -> None:
|
|
676
|
+
"""
|
|
677
|
+
Main workflow for synthesizing solar spectra from 3D MHD simulations.
|
|
678
|
+
|
|
679
|
+
Parameters
|
|
680
|
+
----------
|
|
681
|
+
args : argparse.Namespace, optional
|
|
682
|
+
Command line arguments. If None, will parse from sys.argv.
|
|
683
|
+
"""
|
|
684
|
+
if args is None:
|
|
685
|
+
args = parse_arguments()
|
|
686
|
+
|
|
687
|
+
# ---------------- Configuration from arguments -----------------
|
|
688
|
+
precision = np.float32 if args.precision == "float32" else np.float64
|
|
689
|
+
downsample = args.downsample if args.downsample > 1 else False
|
|
690
|
+
limit_lines = args.limit_lines
|
|
691
|
+
vel_res = args.vel_res * u.km / u.s
|
|
692
|
+
vel_lim = args.vel_lim * u.km / u.s
|
|
693
|
+
voxel_dz = args.voxel_dz * u.Mm
|
|
694
|
+
voxel_dx = args.voxel_dx * u.Mm
|
|
695
|
+
voxel_dy = args.voxel_dy * u.Mm
|
|
696
|
+
|
|
697
|
+
if downsample:
|
|
698
|
+
voxel_dz *= downsample
|
|
699
|
+
voxel_dx *= downsample
|
|
700
|
+
voxel_dy *= downsample
|
|
701
|
+
|
|
702
|
+
mean_mol_wt = args.mean_mol_wt
|
|
703
|
+
intensity_unit = u.erg/u.s/u.cm**2/u.sr/u.cm
|
|
704
|
+
|
|
705
|
+
print_mem = lambda: f"{psutil.virtual_memory().used/1e9:.2f}/" \
|
|
706
|
+
f"{psutil.virtual_memory().total/1e9:.2f} GB"
|
|
707
|
+
|
|
708
|
+
# File paths from arguments
|
|
709
|
+
base_dir = Path(args.data_dir)
|
|
710
|
+
files = {
|
|
711
|
+
"T": args.temp_file,
|
|
712
|
+
"rho": args.rho_file,
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
# Determine velocity file based on integration axis
|
|
716
|
+
integration_axis = args.integration_axis.lower()
|
|
717
|
+
if integration_axis == "x":
|
|
718
|
+
files["vel"] = args.vx_file
|
|
719
|
+
voxel_dh = voxel_dx
|
|
720
|
+
elif integration_axis == "y":
|
|
721
|
+
files["vel"] = args.vy_file
|
|
722
|
+
voxel_dh = voxel_dy
|
|
723
|
+
else: # "z"
|
|
724
|
+
files["vel"] = args.vz_file
|
|
725
|
+
voxel_dh = voxel_dz
|
|
726
|
+
|
|
727
|
+
paths = {k: base_dir / fname for k, fname in files.items()}
|
|
728
|
+
|
|
729
|
+
# Validate input files exist
|
|
730
|
+
for name, path in paths.items():
|
|
731
|
+
if not path.exists():
|
|
732
|
+
raise FileNotFoundError(f"{name} file not found: {path}")
|
|
733
|
+
|
|
734
|
+
goft_path = Path(args.goft_file)
|
|
735
|
+
if not goft_path.exists():
|
|
736
|
+
raise FileNotFoundError(f"GOFT file not found: {goft_path}")
|
|
737
|
+
|
|
738
|
+
print(f"Synthesis configuration:")
|
|
739
|
+
print(f" Data directory: {base_dir}")
|
|
740
|
+
print(f" GOFT file: {goft_path}")
|
|
741
|
+
print(f" Cube shape: {args.cube_shape}")
|
|
742
|
+
print(f" Voxel sizes: {voxel_dx:.3f} x {voxel_dy:.3f} x {voxel_dz:.3f}")
|
|
743
|
+
print(f" Integration axis: {integration_axis}")
|
|
744
|
+
print(f" Velocity grid: ±{vel_lim:.1f} at {vel_res:.1f} resolution")
|
|
745
|
+
print(f" Precision: {precision}")
|
|
746
|
+
if downsample:
|
|
747
|
+
print(f" Downsampling: {downsample}x")
|
|
748
|
+
if limit_lines:
|
|
749
|
+
print(f" Limited to lines: {limit_lines}")
|
|
750
|
+
if args.crop_x or args.crop_y or args.crop_z:
|
|
751
|
+
print(f" Cropping: X={args.crop_x}, Y={args.crop_y}, Z={args.crop_z}")
|
|
752
|
+
print()
|
|
753
|
+
|
|
754
|
+
# ---------------- Build grids -----------------
|
|
755
|
+
# Velocity grid (symmetric about zero, inclusive)
|
|
756
|
+
vel_grid = np.arange(
|
|
757
|
+
-vel_lim.to(u.cm / u.s).value,
|
|
758
|
+
vel_lim.to(u.cm / u.s).value + vel_res.to(u.cm / u.s).value,
|
|
759
|
+
vel_res.to(u.cm / u.s).value
|
|
760
|
+
) * (u.cm / u.s)
|
|
761
|
+
|
|
762
|
+
# ---------------- Load simulation data as NDCubes -----------------
|
|
763
|
+
print(f"Loading cubes ({print_mem()})")
|
|
764
|
+
temp_cube = load_cube(
|
|
765
|
+
paths["T"], shape=tuple(args.cube_shape), unit=u.K,
|
|
766
|
+
downsample=downsample, precision=precision,
|
|
767
|
+
voxel_dx=voxel_dx, voxel_dy=voxel_dy, voxel_dz=voxel_dz,
|
|
768
|
+
create_ndcube=True
|
|
769
|
+
)
|
|
770
|
+
rho_cube = load_cube(
|
|
771
|
+
paths["rho"], shape=tuple(args.cube_shape), unit=u.g/u.cm**3,
|
|
772
|
+
downsample=downsample, precision=precision,
|
|
773
|
+
voxel_dx=voxel_dx, voxel_dy=voxel_dy, voxel_dz=voxel_dz,
|
|
774
|
+
create_ndcube=True
|
|
775
|
+
)
|
|
776
|
+
vel_cube = load_cube(
|
|
777
|
+
paths["vel"], shape=tuple(args.cube_shape), unit=u.cm/u.s,
|
|
778
|
+
downsample=downsample, precision=precision,
|
|
779
|
+
voxel_dx=voxel_dx, voxel_dy=voxel_dy, voxel_dz=voxel_dz,
|
|
780
|
+
create_ndcube=True
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
# Apply cropping if requested
|
|
784
|
+
if args.crop_x or args.crop_y or args.crop_z:
|
|
785
|
+
print(f"Applying cropping ({print_mem()})")
|
|
786
|
+
|
|
787
|
+
# Create coordinate points for cropping
|
|
788
|
+
# NDCube expects coordinates as [point1, point2] where each point is [coord1, coord2, coord3]
|
|
789
|
+
point1 = []
|
|
790
|
+
point2 = []
|
|
791
|
+
|
|
792
|
+
# Z coordinate (first axis)
|
|
793
|
+
if args.crop_z:
|
|
794
|
+
point1.append(args.crop_z[0] * u.Mm)
|
|
795
|
+
point2.append(args.crop_z[1] * u.Mm)
|
|
796
|
+
else:
|
|
797
|
+
point1.append(None)
|
|
798
|
+
point2.append(None)
|
|
799
|
+
|
|
800
|
+
# Y coordinate (second axis)
|
|
801
|
+
if args.crop_y:
|
|
802
|
+
point1.append(args.crop_y[0] * u.Mm)
|
|
803
|
+
point2.append(args.crop_y[1] * u.Mm)
|
|
804
|
+
else:
|
|
805
|
+
point1.append(None)
|
|
806
|
+
point2.append(None)
|
|
807
|
+
|
|
808
|
+
# X coordinate (third axis)
|
|
809
|
+
if args.crop_x:
|
|
810
|
+
point1.append(args.crop_x[0] * u.Mm)
|
|
811
|
+
point2.append(args.crop_x[1] * u.Mm)
|
|
812
|
+
else:
|
|
813
|
+
point1.append(None)
|
|
814
|
+
point2.append(None)
|
|
815
|
+
|
|
816
|
+
# Crop all cubes
|
|
817
|
+
temp_cube = temp_cube.crop(point1, point2)
|
|
818
|
+
rho_cube = rho_cube.crop(point1, point2)
|
|
819
|
+
vel_cube = vel_cube.crop(point1, point2)
|
|
820
|
+
|
|
821
|
+
print(f"Cropped cubes to shape: {temp_cube.data.shape}")
|
|
822
|
+
|
|
823
|
+
# Convert to log10 temperature and density
|
|
824
|
+
ne_arr = (rho_cube / (mean_mol_wt * const.u.cgs.to(u.g))).to(1/u.cm**3)
|
|
825
|
+
logN_cube = np.log10(ne_arr.data, where=ne_arr.data > 0.0,
|
|
826
|
+
out=np.zeros_like(ne_arr.data)).astype(precision)
|
|
827
|
+
logT_cube = np.log10(temp_cube.data, where=temp_cube.data > 0.0,
|
|
828
|
+
out=np.zeros_like(temp_cube.data)).astype(precision)
|
|
829
|
+
|
|
830
|
+
# Extract data arrays for calculations but keep reference cube for coordinates
|
|
831
|
+
temp_data = temp_cube.data
|
|
832
|
+
rho_data = rho_cube.data
|
|
833
|
+
vel_data = vel_cube.data
|
|
834
|
+
reference_cube = temp_cube # Keep this for coordinate reference
|
|
835
|
+
|
|
836
|
+
# ---------------- Load contribution functions -----------------
|
|
837
|
+
print(f"Loading contribution functions ({print_mem()})")
|
|
838
|
+
goft, logT_goft, logN_grid = read_goft(goft_path, limit_lines, precision)
|
|
839
|
+
|
|
840
|
+
# Use the GOFT temperature grid as our DEM temperature grid
|
|
841
|
+
logT_grid = logT_goft
|
|
842
|
+
dh_cm = voxel_dh.to(u.cm).value
|
|
843
|
+
|
|
844
|
+
# ---------------- Calculate DEM -----------------
|
|
845
|
+
print(f"Calculating DEM and average density per bin ({print_mem()})")
|
|
846
|
+
dem_map, avg_ne_map = compute_dem(logT_cube, logN_cube, dh_cm, logT_grid, integration_axis)
|
|
847
|
+
|
|
848
|
+
print(f"Interpolating contribution function on the DEM ({print_mem()})")
|
|
849
|
+
interpolate_g_on_dem(goft, avg_ne_map, logT_grid, logN_grid, logT_goft, precision)
|
|
850
|
+
|
|
851
|
+
# ---------------- Build EM(T,v) cube -----------------
|
|
852
|
+
ne_sq_dh = (10.0 ** logN_cube.astype(np.float64)) ** 2 * dh_cm
|
|
853
|
+
print(f"Calculating emission measure cube in (T,v) space ({print_mem()})")
|
|
854
|
+
em_tv = build_em_tv(logT_cube, vel_data, logT_grid, vel_grid, ne_sq_dh, integration_axis)
|
|
855
|
+
|
|
856
|
+
# ---------------- Synthesize spectra -----------------
|
|
857
|
+
print(f"Synthesising spectra ({print_mem()})")
|
|
858
|
+
synthesise_spectra(goft, em_tv, vel_grid, logT_grid)
|
|
859
|
+
|
|
860
|
+
# ---------------- Create output cubes -----------------
|
|
861
|
+
print(f"Creating output cubes ({print_mem()})")
|
|
862
|
+
line_cubes = {}
|
|
863
|
+
for name, info in goft.items():
|
|
864
|
+
line_cubes[name] = create_line_cube(
|
|
865
|
+
name, info, reference_cube, intensity_unit, integration_axis
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
print(f"Built {len(line_cubes)} line cubes")
|
|
869
|
+
|
|
870
|
+
# ---------------- Save results -----------------
|
|
871
|
+
output_dir = Path(args.output_dir)
|
|
872
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
873
|
+
output_file = output_dir / args.output_name
|
|
874
|
+
|
|
875
|
+
# Save main results
|
|
876
|
+
results_data = {
|
|
877
|
+
"line_cubes": line_cubes,
|
|
878
|
+
"dem_map": dem_map,
|
|
879
|
+
"em_tv": em_tv,
|
|
880
|
+
"logT_grid": logT_grid,
|
|
881
|
+
"vel_grid": vel_grid,
|
|
882
|
+
"logN_grid": logN_grid,
|
|
883
|
+
"goft": goft,
|
|
884
|
+
"voxel_sizes": {"dx": voxel_dx, "dy": voxel_dy, "dz": voxel_dz},
|
|
885
|
+
"config": {
|
|
886
|
+
"precision": precision.__name__,
|
|
887
|
+
"downsample": downsample,
|
|
888
|
+
"vel_res": vel_res,
|
|
889
|
+
"vel_lim": vel_lim,
|
|
890
|
+
"mean_mol_wt": mean_mol_wt,
|
|
891
|
+
"intensity_unit": str(intensity_unit),
|
|
892
|
+
"cube_shape": args.cube_shape,
|
|
893
|
+
"data_dir": str(base_dir),
|
|
894
|
+
"goft_file": str(goft_path),
|
|
895
|
+
"integration_axis": integration_axis,
|
|
896
|
+
"crop_params": {
|
|
897
|
+
"crop_x": args.crop_x,
|
|
898
|
+
"crop_y": args.crop_y,
|
|
899
|
+
"crop_z": args.crop_z
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
with open(output_file, "wb") as f:
|
|
905
|
+
dill.dump(results_data, f)
|
|
906
|
+
|
|
907
|
+
print(f"Saved results to {output_file} ({os.path.getsize(output_file) / 1e6:.2f} MB)")
|
|
908
|
+
print("Synthesis complete!")
|
|
909
|
+
|
|
910
|
+
if __name__ == "__main__":
|
|
911
|
+
main()
|