PyMHD 0.1.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,45 @@
1
+ # Adapted from AthenaK (https://github.com/IAS-Astrophysics/athenak)
2
+ # Copyright (c) 2020, Institute for Advanced Study / High-Performance Computing / jmstone / Athena-Parthenon
3
+ # Licensed under BSD-3-Clause License
4
+
5
+ # A simple script for converting a collection of .bin files to .athdf/.xdmf files using
6
+ # bin_convert
7
+
8
+ # Python modules
9
+ import os
10
+ import argparse
11
+ import glob
12
+
13
+ # AthenaK modules
14
+ import bin_convert
15
+
16
+
17
+ # Main function
18
+ def main(**kwargs):
19
+ # Get the root name for the file.
20
+ files = glob.glob(kwargs['file_stem'] + '*.bin')
21
+ if len(files) < 1:
22
+ print(f"No files found with stem {kwargs['file_stem']}")
23
+ quit()
24
+
25
+ total = len(files)
26
+ count = 1
27
+
28
+ for fname in files:
29
+ athdf_name = fname.replace(".bin", ".athdf")
30
+ xdmf_name = athdf_name + ".xdmf"
31
+ filedata = bin_convert.read_binary(fname)
32
+ bin_convert.write_athdf(athdf_name, filedata)
33
+ bin_convert.write_xdmf_for(xdmf_name, os.path.basename(athdf_name), filedata)
34
+ if kwargs['verbose']:
35
+ print(f'Converting {count}/{total}: {fname}')
36
+ count = count+1
37
+
38
+
39
+ if __name__ == '__main__':
40
+ parser = argparse.ArgumentParser()
41
+ parser.add_argument('file_stem', help='path to files, excluding .#.bin')
42
+ parser.add_argument('-v', '--verbose', action='store_true',
43
+ help='print file conversion progress')
44
+ args = parser.parse_args()
45
+ main(**vars(args))
pymhd/spectra.py ADDED
@@ -0,0 +1,376 @@
1
+ # PyMHD: Python for Magnetohydrodynamic Turbulence.
2
+ # Copyright (c) 2026 Yuyang Hua (华宇阳)
3
+ # License: MIT
4
+
5
+ """
6
+ pymhd/spectra.py
7
+ ----------------
8
+
9
+ Implements the tools for turbulent energy spectra calculations:
10
+ - Spectrum class: data container for a single 3D spectrum.
11
+ - Spectrum1D class: data container for a 1D spectrum.
12
+ - EnergySpectra class: data container for magnetic and kinetic energy spectra.
13
+ - get1D() function: project a 3D spectrum to a 1D spectrum.
14
+ """
15
+
16
+ from __future__ import annotations
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+
21
+ import pickle
22
+
23
+ from collections import UserList
24
+ from typing import Literal
25
+
26
+ from .turbulence import Turbulence, VectorField
27
+
28
+ class Spectrum:
29
+ """Container for a single 3D spectrum.
30
+
31
+ Parameters
32
+ ----------
33
+ EK : np.ndarray with shape (Nx, Ny, Nz), the 3D spectrum data.
34
+ box : tuple[float, float, float], the box size in x, y, z directions (Lx, Ly, Lz).
35
+
36
+ Attributes
37
+ ----------
38
+ EK : np.ndarray
39
+ box : tuple[float, float, float]
40
+ Nx, Ny, Nz : int, the number of grid points in x, y, z directions.
41
+ dx, dy, dz : float, the grid spacings in x, y, z directions.
42
+ dkx, dky, dkz: float, the grid spacings in Fourier space.
43
+ dVk : float, the volume of a grid cell in Fourier space.
44
+ kx, ky, kz : np.ndarray, the wavenumbers.
45
+ """
46
+ def __init__(self, EK: np.ndarray, box: tuple[float, float, float]):
47
+ if EK.ndim != 3:
48
+ raise ValueError("Spectrum.EK must be a 3D array")
49
+
50
+ self.EK = EK
51
+ self.box = box
52
+ self.Lx, self.Ly, self.Lz = box
53
+
54
+ self.Nx, self.Ny, self.Nz = EK.shape
55
+ self.dx = self.Lx / self.Nx
56
+ self.dy = self.Ly / self.Ny
57
+ self.dz = self.Lz / self.Nz
58
+
59
+ self.dkx = 1.0 / self.Lx
60
+ self.dky = 1.0 / self.Ly
61
+ self.dkz = 1.0 / self.Lz
62
+ self.dVk = self.dkx * self.dky * self.dkz
63
+
64
+ self.kx = np.fft.fftfreq(self.Nx, d=self.dx)
65
+ self.ky = np.fft.fftfreq(self.Ny, d=self.dy)
66
+ self.kz = np.fft.fftfreq(self.Nz, d=self.dz)
67
+
68
+ def __add__(self, other) -> Spectrum:
69
+ """Addition
70
+
71
+ Supported operations
72
+ --------------------
73
+ Spectrum + Spectrum -> Spectrum
74
+ Spectrum + None -> Spectrum
75
+ """
76
+ if other is None:
77
+ return self
78
+ if isinstance(other, Spectrum) and assertMatchSpectra(self, other):
79
+ return Spectrum(self.EK + other.EK, self.box)
80
+
81
+ return NotImplemented
82
+
83
+ def __radd__(self, other) -> Spectrum:
84
+ """Right addition
85
+
86
+ Supported operations
87
+ --------------------
88
+ None + Spectrum -> Spectrum
89
+ """
90
+ if other is None:
91
+ return self
92
+
93
+ return NotImplemented
94
+
95
+ def __sub__(self, other) -> Spectrum:
96
+ """Subtraction
97
+
98
+ Supported operations
99
+ --------------------
100
+ Spectrum - Spectrum -> Spectrum
101
+ """
102
+ if isinstance(other, Spectrum) and assertMatchSpectra(self, other):
103
+ return Spectrum(self.EK - other.EK, self.box)
104
+
105
+ return NotImplemented
106
+
107
+
108
+ def assertMatchSpectra(spectrum1: Spectrum, spectrum2: Spectrum) -> bool:
109
+ """Assert that two spectra have matching box and data shape.
110
+
111
+ Returns True by default; raises ValueError on mismatch.
112
+
113
+ Raises
114
+ ------
115
+ ValueError : if box or array shapes do not match.
116
+ """
117
+ if spectrum1.box != spectrum2.box:
118
+ raise ValueError("Box must match")
119
+ if spectrum1.EK.shape != spectrum2.EK.shape:
120
+ raise ValueError("Spectrum data shapes must match")
121
+
122
+ return True
123
+
124
+
125
+ class Spectrum1D:
126
+ """Container for a 1D spectrum.
127
+
128
+ Parameters
129
+ ----------
130
+ k : np.ndarray, shape (N,), the 1D wavenumbers.
131
+ Ek : np.ndarray, shape (N,), the 1D spectrum data.
132
+
133
+ Attributes
134
+ ----------
135
+ k : np.ndarray
136
+ Ek : np.ndarray
137
+ dk : float, the grid spacing in wavenumber space.
138
+ kmax : float, the maximum wavenumber.
139
+ """
140
+ def __init__(self, k: np.ndarray, Ek: np.ndarray):
141
+
142
+ if k.ndim != 1 or Ek.ndim != 1:
143
+ raise ValueError("Spectrum1D must contain 1D arrays")
144
+
145
+ self.k = k
146
+ self.Ek = Ek
147
+ self.dk = k[1] - k[0]
148
+ self.kmax = np.max(k)
149
+
150
+
151
+ class SpectraList(UserList):
152
+ """List of Spectrum objects.
153
+
154
+ Attributes
155
+ ----------
156
+ data : list[Spectrum]
157
+ avg : Spectrum, the arithmetic mean of the spectra.
158
+ """
159
+ @property
160
+ def avg(self) -> Spectrum:
161
+ if len(self.data) == 0:
162
+ raise ValueError("Cannot compute avg on an empty SpectraList")
163
+
164
+ box = self.data[0].box
165
+ stack = np.stack([spc.EK for spc in self.data], axis=0)
166
+
167
+ return Spectrum(np.mean(stack, axis=0), box)
168
+
169
+
170
+ class EnergySpectra:
171
+ """Magnetic and kinetic energy spectra.
172
+
173
+ Parameters
174
+ ----------
175
+ turbulence: Turbulence, the Turbulence object.
176
+
177
+ Attributes
178
+ ----------
179
+ type : Literal['SSD', 'Bx', 'Bz', 'MRI', 'hydro'], inherited from Turbulence.
180
+ nu : float, kinematic viscosity, inherited from Turbulence.
181
+ eta : float | None, magnetic diffusivity, inherited from Turbulence.
182
+
183
+ totEkin : Spectrum, total kinetic energy spectrum.
184
+ totEmag : Spectrum | None, total magnetic energy spectrum.
185
+
186
+ xEkin : Spectrum, x-component kinetic energy spectrum.
187
+ yEkin : Spectrum, y-component kinetic energy spectrum.
188
+ zEkin : Spectrum, z-component kinetic energy spectrum.
189
+
190
+ xEmag : Spectrum | None, x-component magnetic energy spectrum.
191
+ yEmag : Spectrum | None, y-component magnetic energy spectrum.
192
+ zEmag : Spectrum | None, z-component magnetic energy spectrum.
193
+ """
194
+ def cache(self, path: Path) -> None:
195
+ """Cache computed result with pickle serialization."""
196
+ try:
197
+ path.parent.mkdir(parents=True, exist_ok=True)
198
+ with path.open("wb") as f:
199
+ pickle.dump(self, f, protocol=pickle.HIGHEST_PROTOCOL)
200
+ except Exception as exc:
201
+ print(f"Failed to save cache: {exc}\n")
202
+ return
203
+
204
+ print(f"EnergySpectra cache saved to ./{path}\n")
205
+
206
+ def __init__(self, turbulence: Turbulence) -> None:
207
+
208
+ self.type = turbulence.type
209
+ self.nu = turbulence.nu
210
+ self.eta = turbulence.eta
211
+
212
+ def computeEnergySpectra(
213
+ fields: list[VectorField],
214
+ ) -> tuple[Spectrum, Spectrum, Spectrum, Spectrum]:
215
+
216
+ xlist, ylist, zlist, totlist = [], [], [], []
217
+
218
+ for field in fields:
219
+ dV = field.dxdydz
220
+ fx = np.fft.fftn(field.x) * dV
221
+ fy = np.fft.fftn(field.y) * dV
222
+ fz = np.fft.fftn(field.z) * dV
223
+ Ex = Spectrum(0.5 * np.abs(fx) ** 2, field.box)
224
+ Ey = Spectrum(0.5 * np.abs(fy) ** 2, field.box)
225
+ Ez = Spectrum(0.5 * np.abs(fz) ** 2, field.box)
226
+ xlist.append(Ex)
227
+ ylist.append(Ey)
228
+ zlist.append(Ez)
229
+ totlist.append(Ex + Ey + Ez)
230
+
231
+ return (
232
+ SpectraList(xlist).avg,
233
+ SpectraList(ylist).avg,
234
+ SpectraList(zlist).avg,
235
+ SpectraList(totlist).avg,
236
+ )
237
+
238
+ self.xEkin, self.yEkin, self.zEkin, self.totEkin = computeEnergySpectra(turbulence.wVs)
239
+
240
+ if turbulence.type != "hydro":
241
+ self.xEmag, self.yEmag, self.zEmag, self.totEmag = computeEnergySpectra(turbulence.Bs)
242
+ else:
243
+ self.xEmag, self.yEmag, self.zEmag, self.totEmag = None, None, None, None
244
+
245
+ self.cache(Path("spectra") / "spectra.pkl")
246
+
247
+ def get1D(
248
+ spc : Spectrum,
249
+ mode : Literal["shell", "perp", "para", "avg"],
250
+ axis : Literal["x", "y", "z"] | None = None,
251
+ negative: bool = False,
252
+ ) -> Spectrum1D:
253
+ r"""Project a 3D Spectrum to a Spectrum1D.
254
+
255
+ Parameters
256
+ ----------
257
+ spc : Spectrum, the 3D spectrum object.
258
+ mode: the mode of the 3D to 1D projection.
259
+ - "shell": shell-integrated spectrum E(k), where k = |\bm{k}|;
260
+ - "perp" : E(k_\perp), axisymmetric around the background field;
261
+ - "para" : E(k_\parallel), where k_\parallel is parallel to the background field;
262
+ - "avg" : integrate out the two axes perpendicular to `axis`, giving E(k_{axis}).
263
+ axis: the axis of the 3D to 1D projection for certain modes:
264
+ - "shell": should be None;
265
+ - "perp" : specifies the background field direction;
266
+ - "para" : specifies the background field direction;
267
+ - "avg" : specifies the retained axis.
268
+ negative: bool
269
+ - False: return the absolute value of the spectrum (default);
270
+ - True : return the negative of the spectrum (mainly for dissipation spectra).
271
+
272
+ Returns
273
+ -------
274
+ Spectrum1D, the 1D spectrum object.
275
+ """
276
+ if mode == "shell":
277
+
278
+ kx3, ky3, kz3 = np.meshgrid(spc.kx, spc.ky, spc.kz, indexing="ij")
279
+ K = np.sqrt(kx3**2 + ky3**2 + kz3**2)
280
+
281
+ # Integrate up to the maximum complete spherical shell fully in the Fourier space.
282
+ kxmax, kymax, kzmax = 1.0 / (2.0 * spc.dx), 1.0 / (2.0 * spc.dy), 1.0 / (2.0 * spc.dz)
283
+ Kmax = min(kxmax, kymax, kzmax)
284
+ dK = max(spc.dkx, spc.dky, spc.dkz)
285
+
286
+ bins = np.arange(0.0, Kmax + dK, dK)
287
+ ctrs = bins[:-1] + 0.5 * dK
288
+ Ek = np.zeros_like(ctrs)
289
+
290
+ for i, (kl, kr) in enumerate(zip(bins[:-1], bins[1:])):
291
+ mask = (K >= kl) & (K < kr)
292
+ Ek[i] = np.sum(spc.EK[mask]) * spc.dVk / dK
293
+
294
+ valid = np.isfinite(Ek) & (ctrs >= dK)
295
+ k = ctrs[valid]
296
+ Ek = Ek[valid]
297
+
298
+ if Ek.size == 0:
299
+ raise ValueError("No valid shell-integrated spectrum values found.")
300
+
301
+ Ek = -Ek if negative else np.abs(Ek)
302
+ return Spectrum1D(k, Ek)
303
+
304
+ # Map the parallel axis to (axis index, dk, k array),
305
+ # and the two perpendicular axes to their indices and dk values.
306
+ if axis == "x":
307
+ para , Kpara , dKpara = 0, spc.kx, spc.dkx
308
+ perp1, Kperp1, dKperp1 = 1, spc.ky, spc.dky
309
+ perp2, Kperp2, dKperp2 = 2, spc.kz, spc.dkz
310
+ elif axis == "y":
311
+ para , Kpara , dKpara = 1, spc.ky, spc.dky
312
+ perp1, Kperp1, dKperp1 = 0, spc.kx, spc.dkx
313
+ perp2, Kperp2, dKperp2 = 2, spc.kz, spc.dkz
314
+ elif axis == "z":
315
+ para , Kpara , dKpara = 2, spc.kz, spc.dkz
316
+ perp1, Kperp1, dKperp1 = 0, spc.kx, spc.dkx
317
+ perp2, Kperp2, dKperp2 = 1, spc.ky, spc.dky
318
+ else:
319
+ raise ValueError(f"axis must be 'x', 'y', or 'z' for mode='{mode}'")
320
+
321
+ if mode == "avg":
322
+
323
+ Ek = np.sum(spc.EK, axis=(perp1, perp2)) * dKperp1 * dKperp2
324
+ k = np.fft.fftshift(Kpara)
325
+ Ek = np.fft.fftshift(Ek)
326
+
327
+ mask = k > 0.0
328
+ k, Ek = k[mask], 2.0 * Ek[mask]
329
+ Ek = -Ek if negative else np.abs(Ek)
330
+ return Spectrum1D(k, Ek)
331
+
332
+ if mode in ("perp", "para"):
333
+
334
+ mesh = np.meshgrid(Kperp1, Kperp2, indexing="ij")
335
+ Kperp2D = np.sqrt(mesh[0]**2 + mesh[1]**2)
336
+
337
+ # Shell binning for the perpendicular direction
338
+ Kperpmax = min(max(Kperp1), max(Kperp2))
339
+ dKperp = max(dKperp1, dKperp2)
340
+
341
+ bins = np.arange(0.0, Kperpmax + dKperp, dKperp)
342
+ ctrs = bins[:-1] + 0.5 * dKperp
343
+
344
+ # E2D[i_perp, i_para]: shell-integrated 2D spectrum E(k_\perp, k_\parallel).
345
+ # For each perpendicular ring and each parallel index, sum E * dKperp1 * dKperp2,
346
+ # then divide by dKperp to get a density in k_perp.
347
+ Npara = spc.EK.shape[para]
348
+ Ek2D = np.zeros((ctrs.size, Npara))
349
+ data = np.moveaxis(spc.EK, para, -1) # shape: (perp1, perp2, para)
350
+ for i, (kl, kr) in enumerate(zip(bins[:-1], bins[1:])):
351
+ ring = (Kperp2D >= kl) & (Kperp2D < kr)
352
+ if not np.any(ring):
353
+ continue
354
+ Ek2D[i, :] = data[ring, :].sum(axis=0) * dKperp1 * dKperp2 / dKperp
355
+
356
+ if mode == "perp":
357
+ # E(k_\perp) = int E(k_\perp, k_\parallel) dKparallel
358
+ k = ctrs
359
+ Ek = np.sum(Ek2D, axis=1) * dKpara
360
+ valid = np.isfinite(Ek) & (k >= dKperp)
361
+ k, Ek = k[valid], Ek[valid]
362
+ else:
363
+ # E(k_\parallel) = int E(k_\perp, k_\parallel) dKperp
364
+ Ek = np.sum(Ek2D, axis=0) * dKperp
365
+ k = np.fft.fftshift(Kpara)
366
+ Ek = np.fft.fftshift(Ek)
367
+ mask = k > 0.0
368
+ k, Ek = k[mask], 2.0 * Ek[mask]
369
+
370
+ if Ek.size == 0:
371
+ raise ValueError(f"No valid {mode}-projected spectrum values found.")
372
+
373
+ Ek = -Ek if negative else np.abs(Ek)
374
+ return Spectrum1D(k, Ek)
375
+
376
+ raise ValueError("mode must be 'shell', 'perp', 'para', or 'avg'")