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