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.
pymhd/numdiss.py ADDED
@@ -0,0 +1,598 @@
1
+ # PyMHD: Python for Magnetohydrodynamic Turbulence.
2
+ # Copyright (c) 2026 Yuyang Hua (华宇阳)
3
+ # License: MIT
4
+
5
+ """
6
+ pymhd/numdiss.py
7
+ ----------------
8
+
9
+ Implements the framework for the analysis of numerical dissipation in (M)HD turbulence.
10
+ - NumericalDissipation class: computes and stores numerical dissipation terms
11
+ - DissipationSpectra class: computes and stores physical and numerical dissipation spectra
12
+ """
13
+
14
+ import numpy as np
15
+
16
+ from pathlib import Path
17
+ import pickle
18
+
19
+ from typing import Any, Sequence
20
+
21
+ from functools import partial
22
+
23
+ import time
24
+
25
+ from .turbulence import ScalarField, VectorField, Vector, Turbulence
26
+ from .derivatives import derivative, Algorithm
27
+ from .spectra import EnergySpectra, Spectrum
28
+
29
+ # Set flush=True to avoid buffer output
30
+ print = partial(print, flush=True)
31
+
32
+ def calculateFornbergWeights(
33
+ times: Sequence[float], t0: float, M: int
34
+ ) -> np.ndarray:
35
+ """Calculate Fornberg finite difference weights
36
+
37
+ Calculate finite difference weights on non-uniform grid with Fornberg's algorithm
38
+
39
+ References
40
+ ----------
41
+ [1] Fornberg, Bengt. Mathematics of computation 51.184 (1988): 699-706.
42
+
43
+ Parameters
44
+ ----------
45
+ times : time sequence
46
+ t0 : target time
47
+ M : maximum derivative order
48
+
49
+ Returns
50
+ -------
51
+ array of weights c[m, n, i], where:
52
+ - m: derivative order (0 to M)
53
+ - n: number of data points - 1 (0 to N)
54
+ - i: sum index (0 to n)
55
+
56
+ Usage
57
+ -----
58
+ f^(m)(t0) ≈ sum_i=0^n c[m, n, i] * f(times[i])
59
+ """
60
+ N = len(times) - 1
61
+ c = np.zeros((M + 1, N + 1, N + 1))
62
+ c[0, 0, 0] = 1.0
63
+ c1 = 1.0
64
+
65
+ for n in range(1, N + 1):
66
+ c2 = 1.0
67
+ for i in range(n):
68
+ c3 = times[n] - times[i]
69
+ c2 *= c3
70
+
71
+ if n <= M:
72
+ c[n, n - 1, i] = 0
73
+
74
+ for m in range(0, min(n, M) + 1):
75
+ c[m, n, i] = ((times[n] - t0) * c[m, n - 1, i] - m * c[m - 1, n - 1, i]) / c3
76
+
77
+ for m in range(0, min(n, M) + 1):
78
+ c[m, n, n] = c1 / c2 * (m * c[m - 1, n - 1, n - 1] - (times[n - 1] - t0) * c[m, n - 1, n - 1])
79
+
80
+ c1 = c2
81
+
82
+ return c
83
+
84
+
85
+ def computeTimeDerivative(
86
+ turbulence: Turbulence, t0: float
87
+ ) -> tuple[VectorField, VectorField | None]:
88
+ """Calculate time derivative of fields at t0
89
+
90
+ Parameters
91
+ ----------
92
+ turbulence : Turbulence object containing consecutive time steps
93
+ t0 : target time for calculating derivative
94
+
95
+ Returns
96
+ -------
97
+ Vdot : time derivative of velocity field, dV/dt
98
+ Bdot : time derivative of magnetic field, dB/dt; None for hydro
99
+ """
100
+ times = turbulence.times
101
+ N = len(times) - 1 # Order of accuracy
102
+
103
+ coeffs = calculateFornbergWeights(times, t0, 1)[1, N, :]
104
+
105
+ Vdot = sum((c * V for c, V in zip(coeffs, turbulence.Vs)), turbulence.Vs[0] * 0)
106
+ Bdot = None
107
+ if turbulence.type != 'hydro':
108
+ Bdot = sum((c * B for c, B in zip(coeffs, turbulence.Bs)), turbulence.Bs[0] * 0)
109
+
110
+ return Vdot, Bdot
111
+
112
+
113
+ class NumericalDissipation:
114
+ """Data container for numerical dissipation analysis
115
+
116
+ Computes and stores numerical dissipation terms in MHD turbulence.
117
+
118
+ Attributes
119
+ ----------
120
+ type : turbulence type ('SSD', 'Bx', 'Bz', 'MRI', or 'hydro')
121
+ nu : kinematic viscosity
122
+ eta : resistivity (magnetic diffusivity)
123
+ outputdir : output directory name
124
+
125
+ phyVisTerm : VectorField, ν∇·T, where T = ρ[∇u+(∇u)ᵀ-(2/3)(∇·u)I] is the stress tensor
126
+ divStressT : VectorField, ∇·T (divergence of stress tensor)
127
+ phyResTerm : VectorField, η∇²B
128
+ LaplacianB : VectorField, ∇²B
129
+ numVisTerm : VectorField, D^num_vis
130
+ numResTerm : VectorField, D^num_res
131
+
132
+ rho : ScalarField, density field
133
+ V : VectorField, velocity field
134
+ B : VectorField, magnetic field
135
+
136
+ numVisRate : ScalarField, numerical viscous dissipation rate, V @ numVisTerm
137
+ numResRate : ScalarField, numerical resistive dissipation rate, B @ numResTerm
138
+ VdotStress : ScalarField, V·(∇·T), V @ divStressT
139
+ BdotLaplaB : ScalarField, B·(∇²B), B @ LaplacianB
140
+ phyVisRate : ScalarField, physical viscous dissipation rate, V @ phyVisTerm
141
+ phyResRate : ScalarField, physical resistive dissipation rate, B @ phyResTerm
142
+ """
143
+ @staticmethod
144
+ def alg2dir(algorithm: Algorithm) -> str:
145
+
146
+ method = algorithm.method.upper()
147
+ stencil = algorithm.stencil
148
+ CT = algorithm.CT
149
+
150
+ return {
151
+ 'WENO' : f'numdiss(WENO{stencil})',
152
+ 'TENO' : f'numdiss(TENO{stencil}-M,CT={CT})',
153
+ 'TCS' : f'numdiss(TCS7-M,CT={CT})',
154
+ 'CENTRAL' : 'numdiss(CENTRAL)',
155
+ 'SPECTRAL': 'numdiss(SPECTRAL)'
156
+ }[method]
157
+
158
+ def load(self, path: Path) -> bool:
159
+ """Load cached result if metadata matches current request."""
160
+ if not path.is_file():
161
+ return False
162
+
163
+ print(f"Loading NumericalDissipation cache from {path} ...")
164
+
165
+ try:
166
+ with path.open('rb') as f:
167
+ obj = pickle.load(f)
168
+ except Exception as exc:
169
+ print(f"Failed to load cache: {exc}. Recomputing...\n")
170
+ return False
171
+
172
+ self.__dict__.update(obj.__dict__)
173
+ print("NumericalDissipation cache loaded.\n")
174
+ return True
175
+
176
+ def cache(self, path: Path) -> None:
177
+ """Save computed result with low-overhead pickle serialization."""
178
+ try:
179
+ path.parent.mkdir(parents=True, exist_ok=True)
180
+ with path.open('wb') as f:
181
+ pickle.dump(self, f, protocol=pickle.HIGHEST_PROTOCOL)
182
+ except Exception as exc:
183
+ print(f"Failed to save cache: {exc}\n")
184
+ return
185
+
186
+ print(f"NumericalDissipation cache saved to ./{path}\n")
187
+
188
+ def __init__(
189
+ self,
190
+ turbulence: Turbulence | None = None,
191
+ algorithm : Algorithm = Algorithm(method = 'TENO', stencil = 7, CT = 0.01)
192
+ ) -> None:
193
+ """Compute numerical dissipation
194
+
195
+ Parameters
196
+ ----------
197
+ turbulence: Turbulence or None, input Turbulence object
198
+ algorithm : high-order scheme in L^{OH}, defaults to TENO7-M
199
+ """
200
+ self.outputdir = self.alg2dir(algorithm)
201
+
202
+ # if cache exists, load it
203
+ path = Path(self.outputdir) / "cache.pkl"
204
+ if self.load(path):
205
+ return
206
+
207
+ if turbulence is None:
208
+ raise ValueError("NumericalDissipation requires turbulence when cache is not valid.")
209
+
210
+ self.type = turbulence.type
211
+ self.Nx, self.Ny, self.Nz = turbulence.Nx, turbulence.Ny, turbulence.Nz
212
+ self.Lx, self.Ly, self.Lz = turbulence.Lx, turbulence.Ly, turbulence.Lz
213
+
214
+ self.nu = turbulence.nu
215
+ self.eta = turbulence.eta
216
+
217
+ print("┌──────────────────────────────────────┐")
218
+ print("│ │")
219
+ print("│ Numerical Dissipation Analysis │")
220
+ print("│ │")
221
+ print("└──────────────────────────────────────┘")
222
+
223
+ print("")
224
+ print("═════════════ Computation ══════════════")
225
+ print("")
226
+
227
+ start_time = time.time()
228
+ results = self.compute(turbulence, algorithm)
229
+ self.phyResTerm = results["phyResTerm"]
230
+ self.LaplacianB = results["LaplacianB"]
231
+ self.phyVisTerm = results["phyVisTerm"]
232
+ self.divStressT = results["divStressT"]
233
+ self.numVisTerm = results["numVisTerm"]
234
+ self.numResTerm = results["numResTerm"]
235
+ self.rho = results["rho"]
236
+ self.V = results["V"]
237
+ self.B = results["B"]
238
+ self.numVisRate = results["numVisRate"]
239
+ self.numResRate = results["numResRate"]
240
+ self.VdotStress = results["VdotStress"]
241
+ self.BdotLaplaB = results["BdotLaplaB"]
242
+ self.phyVisRate = results["phyVisRate"]
243
+ self.phyResRate = results["phyResRate"]
244
+ self.cache(path)
245
+ end_time = time.time()
246
+
247
+ @staticmethod
248
+ def compute(
249
+ turbulence: Turbulence,
250
+ algorithm : Algorithm,
251
+ ) -> dict[str, Any]:
252
+
253
+ center = len(turbulence.times) // 2
254
+ t0 = turbulence.times[center]
255
+
256
+ rho = turbulence.rhos[center]
257
+ p = turbulence.ps[center]
258
+ V = turbulence.Vs[center]
259
+ Vdot, Bdot = computeTimeDerivative(turbulence, t0)
260
+
261
+ acc = None
262
+ if turbulence.type != 'MRI':
263
+ accs = getattr(turbulence, 'accs', None)
264
+ if accs is None or len(accs) == 0:
265
+ raise ValueError("NumericalDissipation for forced turbulence requires acceleration field.")
266
+ acc = accs[center]
267
+
268
+ nu = turbulence.nu
269
+ eta = turbulence.eta
270
+
271
+ if turbulence.solver == 'FVM':
272
+ print(f"Converting cell averages to cell centers...")
273
+ average2center = partial(derivative.average2center, algorithm=algorithm)
274
+ start_time = time.time()
275
+
276
+ rho = average2center(rho)
277
+ p = average2center(p)
278
+ V = average2center(V)
279
+ Vdot = average2center(Vdot)
280
+
281
+ end_time = time.time()
282
+ print(f"rho, p, V, Vdot converted! Time: {end_time - start_time:.2f} s\n")
283
+
284
+ print(f"Computing numerical dissipation terms...")
285
+ start_time = time.time()
286
+
287
+ Dx = partial(derivative.Dx, algorithm=algorithm)
288
+ Dy = partial(derivative.Dy, algorithm=algorithm)
289
+ Dz = partial(derivative.Dz, algorithm=algorithm)
290
+ grad = partial(derivative.grad, algorithm=algorithm)
291
+ div = partial(derivative.div, algorithm=algorithm)
292
+ curl = partial(derivative.curl, algorithm=algorithm)
293
+ laplacian = partial(derivative.laplacian, algorithm=algorithm)
294
+
295
+ Nx, Ny, Nz = rho.data.shape
296
+ box = rho.box
297
+ Lx, Ly, Lz = box
298
+ dx, dy, dz = rho.dx, rho.dy, rho.dz
299
+
300
+ xs = np.linspace(-Lx / 2, Lx / 2, Nx, endpoint=False) + dx / 2
301
+ ys = np.linspace(-Ly / 2, Ly / 2, Ny, endpoint=False) + dy / 2
302
+ zs = np.linspace(-Lz / 2, Lz / 2, Nz, endpoint=False) + dz / 2
303
+ X, Y, Z = np.meshgrid(xs, ys, zs, indexing='ij')
304
+
305
+ x = ScalarField(X, box)
306
+
307
+ # Unit vectors
308
+ Ex = Vector(1, 0, 0)
309
+ Ey = Vector(0, 1, 0)
310
+ Ez = Vector(0, 0, 1)
311
+
312
+ Vx = ScalarField(V.x, V.box)
313
+ Vy = ScalarField(V.y, V.box)
314
+ Vz = ScalarField(V.z, V.box)
315
+
316
+ B: VectorField | None = None
317
+ if turbulence.type != 'hydro':
318
+ B = turbulence.Bs[center]
319
+
320
+ if turbulence.type in ('SSD', 'Bx', 'Bz'):
321
+
322
+ assert Bdot is not None
323
+ assert B is not None
324
+ J = curl(B)
325
+
326
+ assert acc is not None # guaranteed by __init__
327
+
328
+ Faraday = curl(V ** B)
329
+ LaplacianB = laplacian(B)
330
+
331
+ convective = rho * (Vx * Dx(V) + Vy * Dy(V) + Vz * Dz(V))
332
+ pressure = -grad(p)
333
+ Lorentz = J ** B
334
+
335
+ Tx = rho * (grad(Vx) + Dx(V) - (2 / 3) * div(V) * Ex)
336
+ Ty = rho * (grad(Vy) + Dy(V) - (2 / 3) * div(V) * Ey)
337
+ Tz = rho * (grad(Vz) + Dz(V) - (2 / 3) * div(V) * Ez)
338
+
339
+ divStressT = div(Tx) * Ex + div(Ty) * Ey + div(Tz) * Ez
340
+ phyResTerm = eta * LaplacianB
341
+ phyVisTerm = nu * divStressT
342
+
343
+ # ===== Numerical dissipation =====
344
+ NavierStokesLHS = rho * Vdot + convective
345
+ NavierStokesRHS = pressure + Lorentz + phyVisTerm + rho * acc
346
+
347
+ numVisTerm = NavierStokesLHS - NavierStokesRHS
348
+ numResTerm = Bdot - Faraday - phyResTerm
349
+
350
+ elif turbulence.type == 'MRI':
351
+
352
+ assert Bdot is not None
353
+ assert B is not None
354
+ Bx = ScalarField(B.x, B.box)
355
+ J = curl(B)
356
+
357
+ Omega = turbulence.Omega
358
+ q = turbulence.q
359
+
360
+ # ===== Magnetic induction equation =====
361
+ Faraday1 = -q * Omega * Bx * Ey
362
+ Faraday2 = q * Omega * x * Dy(B)
363
+ Faraday3 = curl(V ** B)
364
+
365
+ Faraday = Faraday1 + Faraday2 + Faraday3
366
+ phyResTerm = eta * laplacian(B)
367
+ LaplacianB = laplacian(B)
368
+
369
+ # ===== Navier-Stokes equation =====
370
+ convective = rho * (Vx * Dx(V) + Vy * Dy(V) + Vz * Dz(V) - q * Omega * x * Dy(V) - q * Omega * Vx * Ey)
371
+ pressure = -grad(p)
372
+ Coriolis = -2 * rho * Omega * (Ez ** V)
373
+ Lorentz = J ** B
374
+
375
+ Tx = rho * (grad(Vx) + Dx(V) - (2 / 3) * div(V) * Ex)
376
+ Ty = rho * (grad(Vy) + Dy(V) - (2 / 3) * div(V) * Ey)
377
+ Tz = rho * (grad(Vz) + Dz(V) - (2 / 3) * div(V) * Ez)
378
+ divTx, divTy, divTz = div(Tx), div(Ty), div(Tz)
379
+ divT = divTx * Ex + divTy * Ey + divTz * Ez
380
+ divStressT = divT
381
+ phyVisTerm = nu * divT
382
+
383
+ # ===== Numerical dissipation =====
384
+ NavierStokesLHS = rho * Vdot + convective
385
+ NavierStokesRHS = pressure + Coriolis + Lorentz + phyVisTerm
386
+
387
+ numVisTerm = NavierStokesLHS - NavierStokesRHS
388
+ numResTerm = Bdot - Faraday - phyResTerm
389
+
390
+
391
+ elif turbulence.type == 'hydro':
392
+
393
+ convective = rho * (Vx * Dx(V) + Vy * Dy(V) + Vz * Dz(V))
394
+ pressure = -grad(p)
395
+
396
+ Tx = rho * (grad(Vx) + Dx(V) - (2 / 3) * div(V) * Ex)
397
+ Ty = rho * (grad(Vy) + Dy(V) - (2 / 3) * div(V) * Ey)
398
+ Tz = rho * (grad(Vz) + Dz(V) - (2 / 3) * div(V) * Ez)
399
+
400
+ divStressT = div(Tx) * Ex + div(Ty) * Ey + div(Tz) * Ez
401
+ phyVisTerm = nu * divStressT
402
+
403
+ assert acc is not None
404
+ NavierStokesLHS = rho * Vdot + convective
405
+ NavierStokesRHS = pressure + phyVisTerm + rho * acc
406
+
407
+ numVisTerm = NavierStokesLHS - NavierStokesRHS
408
+
409
+ phyResTerm = None
410
+ LaplacianB = None
411
+ numResTerm = None
412
+
413
+ else:
414
+ raise ValueError(
415
+ f"Unsupported type: {turbulence.type!r}; expected 'SSD', 'Bx', 'Bz', 'MRI', or 'hydro'."
416
+ )
417
+
418
+ end_time = time.time()
419
+ print(f"Numerical dissipation computation completed! Time: {end_time - start_time:.2f} s")
420
+
421
+ numVisRate = V @ numVisTerm
422
+ VdotStress = V @ divStressT
423
+ phyVisRate = V @ phyVisTerm
424
+
425
+ if B is not None:
426
+ assert numResTerm is not None
427
+ assert phyResTerm is not None
428
+ assert LaplacianB is not None
429
+ numResRate = B @ numResTerm
430
+ BdotLaplaB = B @ LaplacianB
431
+ phyResRate = B @ phyResTerm
432
+ else:
433
+ numResRate = None
434
+ BdotLaplaB = None
435
+ phyResRate = None
436
+
437
+ results: dict[str, Any] = {
438
+ "phyResTerm": phyResTerm,
439
+ "LaplacianB": LaplacianB,
440
+ "phyVisTerm": phyVisTerm,
441
+ "divStressT": divStressT,
442
+ "numVisTerm": numVisTerm,
443
+ "numResTerm": numResTerm,
444
+ "rho" : rho,
445
+ "V" : V,
446
+ "B" : B,
447
+ "numVisRate": numVisRate,
448
+ "numResRate": numResRate,
449
+ "VdotStress": VdotStress,
450
+ "BdotLaplaB": BdotLaplaB,
451
+ "phyVisRate": phyVisRate,
452
+ "phyResRate": phyResRate,
453
+ }
454
+ return results
455
+
456
+
457
+ def computeSpectra(
458
+ field1: VectorField | None, field2: VectorField | None
459
+ ) -> tuple[Spectrum, Spectrum, Spectrum, Spectrum]:
460
+ r"""Compute component-wise and total spectra
461
+
462
+ Parameters
463
+ ----------
464
+ field1: VectorField, the first field
465
+ field2: VectorField, the second field
466
+
467
+ Returns
468
+ -------
469
+ tuple[Spectrum, Spectrum, Spectrum, Spectrum]: the component-wise and total spectra
470
+
471
+ Raises
472
+ ------
473
+ ValueError: if field1 or field2 is None or if field1 and field2 do not match
474
+ """
475
+ if field1 is None or field2 is None:
476
+ raise ValueError("computeSpectra(): field1 and field2 must not be None.")
477
+
478
+ Nx1, Ny1, Nz1 = field1.x.shape
479
+ Nx2, Ny2, Nz2 = field2.x.shape
480
+
481
+ if (Nx1, Ny1, Nz1) != (Nx2, Ny2, Nz2):
482
+ raise ValueError(
483
+ "computeSpectra(): field1 and field2 must have the same resolution, "
484
+ f"got {(Nx1, Ny1, Nz1)} and {(Nx2, Ny2, Nz2)}."
485
+ )
486
+ if field1.box != field2.box:
487
+ raise ValueError(
488
+ "computeSpectra(): field1 and field2 must share the same box, "
489
+ f"got {field1.box} and {field2.box}."
490
+ )
491
+
492
+ dV = field1.dxdydz
493
+ box = field1.box
494
+
495
+ f1x = np.fft.fftn(field1.x) * dV
496
+ f1y = np.fft.fftn(field1.y) * dV
497
+ f1z = np.fft.fftn(field1.z) * dV
498
+
499
+ f2x = np.fft.fftn(field2.x) * dV
500
+ f2y = np.fft.fftn(field2.y) * dV
501
+ f2z = np.fft.fftn(field2.z) * dV
502
+
503
+ Sx = Spectrum(np.real(np.conjugate(f1x) * f2x), box)
504
+ Sy = Spectrum(np.real(np.conjugate(f1y) * f2y), box)
505
+ Sz = Spectrum(np.real(np.conjugate(f1z) * f2z), box)
506
+ Stot = Sx + Sy + Sz
507
+
508
+ return (Sx, Sy, Sz, Stot)
509
+
510
+
511
+ class DissipationSpectra:
512
+ """Container for dissipation spectra used by numerical dissipation plots.
513
+
514
+ Attributes
515
+ ----------
516
+ Lx, Ly, Lz: float, size of the box in x, y, z directions
517
+ nu, eta : float, kinematic viscosity and resistivity (magnetic diffusivity)
518
+
519
+ Ek: EnergySpectra, cached energy spectra (component and total E_{kin} / E_{mag}).
520
+
521
+ For nd.type == 'SSD', only the summed spectra are specified:
522
+ - eNumVis, eNumRes, ePhyVis, ePhyRes, eTotVis, eTotRes.
523
+
524
+ Otherwise (e.g. 'Bx', 'Bz', 'MRI'):
525
+ - xNumVis, yNumVis, zNumVis: component-wise numerical viscous dissipation spectra
526
+ - xNumRes, yNumRes, zNumRes: component-wise numerical resistive dissipation spectra
527
+ - xPhyVis, yPhyVis, zPhyVis: component-wise physical viscous dissipation spectra
528
+ - xPhyRes, yPhyRes, zPhyRes: component-wise physical resistive dissipation spectra
529
+
530
+ - eNumVis, eNumRes: sums of the three num components
531
+ - ePhyVis, ePhyRes: sums of the three phy components
532
+
533
+ - xTotVis, yTotVis, zTotVis: per-component viscous total (phy + num)
534
+ - xTotRes, yTotRes, zTotRes: per-component resistive total (phy + num)
535
+ - eTotVis, eTotRes: summed total dissipation
536
+
537
+ - xUdivT, yUdivT, zUdivT, eUdivT: physical viscous dissipation spectra without nu
538
+ - xBLapB, yBLapB, zBLapB, eBLapB: physical resistive dissipation spectra without eta
539
+ """
540
+ def cache(self, path: Path) -> None:
541
+
542
+ try:
543
+ path.parent.mkdir(parents=True, exist_ok=True)
544
+ with path.open("wb") as f:
545
+ pickle.dump(self, f, protocol=pickle.HIGHEST_PROTOCOL)
546
+ except Exception as exc:
547
+ print(f"Failed to save dissipation spectra cache: {exc}\n")
548
+ return
549
+
550
+ print(f"DissipationSpectra cache saved to ./{path}\n")
551
+
552
+ def __init__(
553
+ self,
554
+ nd: NumericalDissipation,
555
+ Ek: EnergySpectra,
556
+ ) -> None:
557
+
558
+ self.box = nd.V.box
559
+ self.Lx, self.Ly, self.Lz = self.box
560
+
561
+ self.nu = nd.nu
562
+ self.eta = nd.eta
563
+ self.Ek = Ek
564
+
565
+ if nd.type == "SSD":
566
+ *_, self.eNumVis = computeSpectra(nd.V, nd.numVisTerm)
567
+ *_, self.eNumRes = computeSpectra(nd.B, nd.numResTerm)
568
+ *_, self.ePhyVis = computeSpectra(nd.V, nd.phyVisTerm)
569
+ *_, self.ePhyRes = computeSpectra(nd.B, nd.phyResTerm)
570
+
571
+ self.eTotVis, self.eTotRes = self.ePhyVis + self.eNumVis, self.ePhyRes + self.eNumRes
572
+
573
+ else:
574
+ xNumVis, yNumVis, zNumVis, eNumVis = computeSpectra(nd.V, nd.numVisTerm)
575
+ xNumRes, yNumRes, zNumRes, eNumRes = computeSpectra(nd.B, nd.numResTerm)
576
+ xPhyVis, yPhyVis, zPhyVis, ePhyVis = computeSpectra(nd.V, nd.phyVisTerm)
577
+ xPhyRes, yPhyRes, zPhyRes, ePhyRes = computeSpectra(nd.B, nd.phyResTerm)
578
+
579
+ self.xNumVis, self.yNumVis, self.zNumVis = xNumVis, yNumVis, zNumVis
580
+ self.xNumRes, self.yNumRes, self.zNumRes = xNumRes, yNumRes, zNumRes
581
+ self.xPhyVis, self.yPhyVis, self.zPhyVis = xPhyVis, yPhyVis, zPhyVis
582
+ self.xPhyRes, self.yPhyRes, self.zPhyRes = xPhyRes, yPhyRes, zPhyRes
583
+
584
+ self.eNumVis, self.eNumRes = eNumVis, eNumRes
585
+ self.ePhyVis, self.ePhyRes = ePhyVis, ePhyRes
586
+
587
+ self.xTotVis, self.yTotVis, self.zTotVis = xPhyVis + xNumVis, yPhyVis + yNumVis, zPhyVis + zNumVis
588
+ self.xTotRes, self.yTotRes, self.zTotRes = xPhyRes + xNumRes, yPhyRes + yNumRes, zPhyRes + zNumRes
589
+ self.eTotVis, self.eTotRes = ePhyVis + eNumVis, ePhyRes + eNumRes
590
+
591
+ xUdivT, yUdivT, zUdivT, eUdivT = computeSpectra(nd.V, nd.divStressT)
592
+ xBLapB, yBLapB, zBLapB, eBLapB = computeSpectra(nd.B, nd.LaplacianB)
593
+
594
+ self.xUdivT, self.yUdivT, self.zUdivT = xUdivT, yUdivT, zUdivT
595
+ self.xBLapB, self.yBLapB, self.zBLapB = xBLapB, yBLapB, zBLapB
596
+ self.eUdivT, self.eBLapB = eUdivT, eBLapB
597
+
598
+ self.cache(Path(nd.outputdir) / "nd.spectra.pkl")
pymhd/plot/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ # PyMHD: Python for Magnetohydrodynamic Turbulence.
2
+ # Copyright (c) 2026 Yuyang Hua (华宇阳)
3
+ # License: MIT
4
+
5
+ """
6
+ pymhd/plot/__init__.py
7
+ ----------------------
8
+
9
+ Plotting tools for PyMHD.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from ..numdiss import NumericalDissipation
15
+ from ..spectra import EnergySpectra
16
+
17
+ from .nd import plot as plotNumericalDissipation
18
+ from .spc import plot as plotSpectra
19
+
20
+ def plot(obj: NumericalDissipation | EnergySpectra, **kwargs) -> None:
21
+ """Unified plot function for PyMHD
22
+
23
+ Route to the appropriate plot function for each object type.
24
+
25
+ Parameters
26
+ ----------
27
+ obj : NumericalDissipation | EnergySpectra, a plottable PyMHD object
28
+ **kwargs: passed to the plot function of the object (e.g. fraction for colormap scaling).
29
+
30
+ Examples
31
+ --------
32
+ >>> from pymhd import plot, NumericalDissipation, EnergySpectra
33
+ >>> spectra = EnergySpectra(...)
34
+ >>> plot(spectra)
35
+ >>> nd = NumericalDissipation(...)
36
+ >>> plot(nd)
37
+ """
38
+ if isinstance(obj, NumericalDissipation):
39
+ plotNumericalDissipation(obj, **kwargs)
40
+ return
41
+ if isinstance(obj, EnergySpectra):
42
+ plotSpectra(obj)
43
+ return
44
+
45
+ raise TypeError(
46
+ f"plot() does not support type '{type(obj).__name__}'. "
47
+ f"Supported types: NumericalDissipation, EnergySpectra"
48
+ )