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/plot/nd.py ADDED
@@ -0,0 +1,1519 @@
1
+ # PyMHD: Python for Magnetohydrodynamic Turbulence.
2
+ # Copyright (c) 2026 Yuyang Hua (华宇阳)
3
+ # License: MIT
4
+
5
+ """
6
+ pymhd/plot/nd.py
7
+ ----------------
8
+
9
+ Plotting tools for numerical dissipation analysis in (M)HD turbulence, including:
10
+ - 2D slices of numerical and physical dissipation terms
11
+ - Numerical and physical dissipation spectra
12
+
13
+ For 2D slices, similar to pymhd/plot/slc.py, currently:
14
+ - Shearing-box simulations: the box ratio is hard coded to be Lx : Ly : Lz = 2 : 4 : 1
15
+ - Forced turbulence: the box ratio is hard coded to be Lx : Ly : Lz = 1 : 1 : 1
16
+ """
17
+
18
+ import numpy as np
19
+
20
+ import matplotlib.pyplot as plt
21
+ from matplotlib.axes import Axes
22
+ from matplotlib.colors import LogNorm
23
+ from matplotlib.layout_engine import ConstrainedLayoutEngine
24
+
25
+ from pathlib import Path
26
+ import pickle
27
+
28
+ from typing import Callable, Literal
29
+
30
+ from functools import partial
31
+
32
+ from KDEpy import FFTKDE
33
+
34
+ from scipy.ndimage import gaussian_filter1d as Gaussian
35
+
36
+ from ..turbulence import ScalarField, VectorField
37
+ from ..spectra import Spectrum, Spectrum1D, EnergySpectra, get1D
38
+ from ..numdiss import NumericalDissipation, DissipationSpectra
39
+
40
+ # Set flush=True to avoid buffer output
41
+ print = partial(print, flush=True)
42
+
43
+ # Font settings
44
+ plt.rcParams['font.family'] = 'serif'
45
+ plt.rcParams['font.serif' ] = ['cmr10']
46
+
47
+ def float2LaTeX(value: float, ndigits: int = 2) -> str:
48
+ r"""Format a float as LaTeX scientific notation (no outer $), e.g. 3.1\times 10^{-4}."""
49
+ if not np.isfinite(value):
50
+ return str(value)
51
+ if value == 0.0:
52
+ return "0"
53
+ mantissa, exp = f"{value:.{ndigits}e}".split("e")
54
+ exp = int(exp)
55
+ return rf"{mantissa}\times 10^{{{exp}}}"
56
+
57
+ class Curve:
58
+ """1D spectrum curve for dissipation spectra plots.
59
+
60
+ Attributes
61
+ ----------
62
+ spc1d : Spectrum1D, the 1D spectrum object.
63
+ color : str, the color of the curve.
64
+ label : str, the label of the curve.
65
+ peak : float | None, the peak wavenumber of the curve, or None if not plotted.
66
+ lw : float, line width for plotDissipation (defaults to 2.0).
67
+ dashed: bool, dashed linestyle for plotDissipation (default False).
68
+
69
+ Methods
70
+ -------
71
+ getPeak : peak wavenumber from smoothed |E(k)|.
72
+ plotDissipation : Plot dissipation spectrum.
73
+ plotEnergy : Plot energy spectrum.
74
+ """
75
+ def __init__(
76
+ self,
77
+ spc1d : Spectrum1D,
78
+ color : str,
79
+ label : str,
80
+ peak : bool = False,
81
+ mask : bool = False,
82
+ lw : float = 2.0,
83
+ dashed: bool = False,
84
+ ) -> None:
85
+ self.spc1d = spc1d
86
+ self.color = color
87
+ self.label = label
88
+ self.lw = lw
89
+ self.dashed = dashed
90
+
91
+ if peak:
92
+ self.peak = Curve.getPeak(spc1d, mask=mask)
93
+ else:
94
+ self.peak = None
95
+
96
+ @staticmethod
97
+ def getPeak(spc1d: Spectrum1D, mask: bool = False) -> float | None:
98
+ """Peak wavenumber of smoothed |E(k)|, or None if unavailable."""
99
+
100
+ k, Ek = spc1d.k, np.abs(spc1d.Ek)
101
+ sigma = 2.0 # kernel scale: radius 2*dk in units of dk-spaced samples
102
+ smoothEk = Gaussian(Ek, sigma=sigma, mode="nearest")
103
+ if mask:
104
+ valid = (k > 5.0) & np.isfinite(Ek)
105
+ else:
106
+ valid = np.isfinite(Ek)
107
+ if not np.any(valid):
108
+ return None
109
+ k, smoothEk = k[valid], smoothEk[valid]
110
+ idx = int(np.argmax(smoothEk))
111
+ kpeak = float(k[idx])
112
+ kmin = float(np.min(k))
113
+ if np.isclose(kpeak, kmin, rtol=1e-12, atol=1e-12 * max(1.0, abs(kmin))):
114
+ return None
115
+ return kpeak
116
+
117
+ def plotDissipation(self, ax: Axes) -> None:
118
+ if self.peak is not None:
119
+ ax.axvline(self.peak, color=self.color, ls="--", lw=1.8, zorder=1)
120
+ k, Ek = self.spc1d.k, self.spc1d.Ek
121
+ ls = "--" if self.dashed else "-"
122
+ ax.semilogx(k, Ek, color=self.color, lw=self.lw, ls=ls, label=self.label, zorder=3)
123
+
124
+ def plotEnergy(self, ax: Axes, slope: float = 5.0 / 3.0) -> None:
125
+ k = self.spc1d.k
126
+ y = self.spc1d.Ek * k**slope
127
+ ax.loglog(k, y, color=self.color, ls="--", lw=1.8, alpha=0.8, label=self.label)
128
+
129
+
130
+ def plotSlices(nd: NumericalDissipation, fraction: float = 1.0) -> None:
131
+ """Plot 2D slices of dissipation terms
132
+
133
+ Outputs to nd.outputdir/slices/:
134
+ - For type in ('SSD', 'Bx', 'Bz'):
135
+ slice.phy.res.pdf, slice.phy.vis.pdf,
136
+ slice.num.res.pdf, slice.num.vis.pdf
137
+ all.slice.pdf, all.slice.pdf (for 'Bx' and 'Bz')
138
+ - For type 'hydro':
139
+ slice.phy.vis.pdf, slice.num.vis.pdf,
140
+ slice.rate.phy.vis.pdf, slice.rate.num.vis.pdf
141
+ - For type 'MRI':
142
+ 'term.{num,phy}.{res,vis}.{x,y,z}.pdf' (12 files)
143
+
144
+ Parameters
145
+ ----------
146
+ nd : NumericalDissipation object
147
+ fraction : float in (0, 1], colormap parameter
148
+ """
149
+ if not (0 < fraction <= 1.0):
150
+ raise ValueError("fraction must be in (0, 1]")
151
+
152
+ path = Path(nd.outputdir) / "slices"
153
+ path.mkdir(parents=True, exist_ok=True)
154
+
155
+ if nd.type == 'hydro':
156
+ terms = [
157
+ ('term.phy.vis', nd.phyVisTerm if nd.nu != 0 else nd.divStressT),
158
+ ('term.num.vis', nd.numVisTerm),
159
+ ('rate.phy.vis', nd.phyVisRate if nd.nu != 0 else nd.VdotStress), # ScalarField
160
+ ('rate.num.vis', nd.numVisRate), # ScalarField
161
+ ]
162
+ else:
163
+ terms = [
164
+ ('term.phy.res', nd.phyResTerm if nd.eta != 0 else nd.LaplacianB),
165
+ ('term.phy.vis', nd.phyVisTerm if nd.nu != 0 else nd.divStressT),
166
+ ('term.num.res', nd.numResTerm),
167
+ ('term.num.vis', nd.numVisTerm),
168
+ ('rate.phy.res', nd.phyResRate if nd.eta != 0 else nd.BdotLaplaB), # ScalarField
169
+ ('rate.num.res', nd.numResRate), # ScalarField
170
+ ('rate.phy.vis', nd.phyVisRate if nd.nu != 0 else nd.VdotStress), # ScalarField
171
+ ('rate.num.vis', nd.numVisRate), # ScalarField
172
+ ]
173
+
174
+ Nx, Ny, Nz = nd.Nx, nd.Ny, nd.Nz
175
+ Lx, Ly, Lz = nd.Lx, nd.Ly, nd.Lz
176
+
177
+ cmap = plt.colormaps['RdBu']
178
+
179
+ def getRange(data: np.ndarray, frac: float | None = None) -> tuple[float, float]:
180
+
181
+ f = fraction if frac is None else frac
182
+ valid = data[~np.isnan(data)]
183
+ if len(valid) == 0:
184
+ return -1.0, 1.0
185
+ r = float(np.percentile(np.abs(valid), f * 100))
186
+ return -r, r
187
+
188
+ for name, term in terms:
189
+ if term is None:
190
+ continue
191
+
192
+ if isinstance(term, VectorField):
193
+ slices = [
194
+ term.x[Nx // 2, :, :],
195
+ term.y[:, Ny // 2, :],
196
+ term.z[:, :, Nz // 2],
197
+ ]
198
+ elif isinstance(term, ScalarField):
199
+ slices = [
200
+ term.data[Nx // 2, :, :],
201
+ term.data[:, Ny // 2, :],
202
+ term.data[:, :, Nz // 2],
203
+ ]
204
+ else:
205
+ continue
206
+
207
+ if nd.type == 'MRI':
208
+ if not isinstance(term, VectorField) or not name.startswith('term.'):
209
+ continue
210
+
211
+ nu = r'\nu ' if nd.nu != 0 else ''
212
+ eta = r'\eta ' if nd.eta != 0 else ''
213
+
214
+ linewidth = 1.5
215
+ pad = 7
216
+
217
+ for comp, vardata in (
218
+ ('x', term.x),
219
+ ('y', term.y),
220
+ ('z', term.z),
221
+ ):
222
+ merged = np.concatenate(
223
+ [
224
+ vardata[:, :, Nz // 2].ravel(),
225
+ vardata[:, Ny // 2, :].ravel(),
226
+ vardata[Nx // 2, :, :].ravel(),
227
+ ]
228
+ )
229
+ vmin, vmax = getRange(merged)
230
+
231
+ fig = plt.figure(figsize=(14, 8), constrained_layout=False)
232
+ gs = plt.GridSpec(
233
+ 2, 3,
234
+ figure=fig,
235
+ width_ratios=[4, 1, 0.2],
236
+ height_ratios=[2, 1],
237
+ left=0.1,
238
+ right=0.9,
239
+ top=0.9,
240
+ bottom=0.1,
241
+ wspace=0.08,
242
+ hspace=0.08,
243
+ )
244
+
245
+ ax1 = fig.add_subplot(gs[0, 0])
246
+ ax2 = fig.add_subplot(gs[0, 1])
247
+ ax3 = fig.add_subplot(gs[1, 0])
248
+ cax = fig.add_subplot(gs[:, 2])
249
+
250
+ im1 = ax1.imshow(
251
+ vardata[:, :, Nz // 2],
252
+ origin='upper',
253
+ cmap=cmap, vmin=vmin, vmax=vmax,
254
+ extent=(-Ly / 2, Ly / 2, Lx / 2, -Lx / 2),
255
+ )
256
+ im2 = ax2.imshow(
257
+ vardata[:, Ny // 2, :],
258
+ origin='upper',
259
+ cmap=cmap, vmin=vmin, vmax=vmax,
260
+ extent=(-Lz / 2, Lz / 2, Lx / 2, -Lx / 2),
261
+ )
262
+ im3 = ax3.imshow(
263
+ vardata[Nx // 2, :, :].T,
264
+ origin='lower',
265
+ cmap=cmap, vmin=vmin, vmax=vmax,
266
+ extent=(-Ly / 2, Ly / 2, -Lz / 2, Lz / 2),
267
+ )
268
+
269
+ ax1.set_aspect('equal')
270
+ ax2.set_aspect('equal')
271
+ ax3.set_aspect('equal')
272
+
273
+ ax1.xaxis.set_ticklabels([])
274
+
275
+ ax2.yaxis.set_ticklabels([])
276
+ ax2.set_ylabel('')
277
+
278
+ ax1.tick_params(direction='in')
279
+ ax2.tick_params(direction='in')
280
+ ax3.tick_params(direction='in')
281
+
282
+ ax1.set_ylabel(r'$x/H$', labelpad=0)
283
+ ax2.set_xlabel(r'$z/H$', labelpad=10)
284
+ ax3.set_xlabel(r'$y/H$', labelpad=10)
285
+ ax3.set_ylabel(r'$z/H$', labelpad=0)
286
+
287
+ ax1.tick_params(width=linewidth, pad=pad)
288
+ ax2.tick_params(width=linewidth, pad=pad)
289
+ ax3.tick_params(width=linewidth, pad=pad)
290
+
291
+ for ax in (ax1, ax2, ax3):
292
+ for spine in ax.spines.values():
293
+ spine.set_linewidth(linewidth)
294
+
295
+ cbar = fig.colorbar(im1, cax=cax, shrink=0.9)
296
+
297
+ # Physical terms: same notation as vislabel / reslabel (Bx/Bz composite).
298
+ # Numerical terms: D^{\mathrm{num}}_{\mathrm{vis/res}, comp}.
299
+ if name == 'term.phy.res':
300
+ cbarlabel = r'$' + eta + r'\nabla^2 B_' + comp + r'$'
301
+ elif name == 'term.num.res':
302
+ cbarlabel = (r'$D^{\mathrm{num}}_{\mathrm{res}, ' + comp + r'}$')
303
+ elif name == 'term.phy.vis':
304
+ cbarlabel = r'$' + nu + r'(\nabla \cdot \mathbb{T})_' + comp + r'$'
305
+ elif name == 'term.num.vis':
306
+ cbarlabel = r'$D^{\mathrm{num}}_{\mathrm{vis}, ' + comp + r'}$'
307
+ else:
308
+ raise ValueError(f'Unexpected MRI term name: {name!r}')
309
+ cbar.set_label(cbarlabel, labelpad=10)
310
+
311
+ outline_spine = cbar.ax.spines.get('outline')
312
+ if outline_spine is not None:
313
+ outline_spine.set_linewidth(linewidth)
314
+
315
+ plt.savefig(path / f'{name}.{comp}.pdf', bbox_inches='tight')
316
+ plt.close()
317
+
318
+ continue
319
+
320
+ elif nd.type in ('SSD', 'Bx', 'Bz', 'hydro'):
321
+ merged = np.concatenate([arr.flatten() for arr in slices])
322
+ vmin, vmax = getRange(merged)
323
+
324
+ fig, axes = plt.subplots(1, 3, figsize=(16, 6), constrained_layout=True)
325
+ ax1, ax2, ax3 = axes
326
+
327
+ im1 = ax1.imshow(
328
+ slices[0].T, origin='lower',
329
+ cmap=cmap, vmin=vmin, vmax=vmax,
330
+ extent=(-Ly / 2, Ly / 2, -Lz / 2, Lz / 2), aspect='auto'
331
+ )
332
+ im2 = ax2.imshow(
333
+ slices[1].T, origin='lower',
334
+ cmap=cmap, vmin=vmin, vmax=vmax,
335
+ extent=(-Lz / 2, Lz / 2, -Lx / 2, Lx / 2), aspect='auto'
336
+ )
337
+ im3 = ax3.imshow(
338
+ slices[2].T, origin='lower',
339
+ cmap=cmap, vmin=vmin, vmax=vmax,
340
+ extent=(-Lx / 2, Lx / 2, -Ly / 2, Ly / 2), aspect='auto'
341
+ )
342
+
343
+ ax1.set_xlabel(r'$y/H$')
344
+ ax1.set_ylabel(r'$z/H$')
345
+ ax2.set_xlabel(r'$z/H$')
346
+ ax2.set_ylabel(r'$x/H$')
347
+ ax3.set_xlabel(r'$x/H$')
348
+ ax3.set_ylabel(r'$y/H$')
349
+
350
+ for ax in [ax1, ax2, ax3]:
351
+ ax.tick_params(direction='in', width=1.5, pad=7)
352
+ ax.set_box_aspect(1)
353
+ for spine in ax.spines.values():
354
+ spine.set_linewidth(1.5)
355
+
356
+ cbar_bottom = 1.035
357
+ cbar_height = 0.06
358
+ for ax, im in [(ax1, im1), (ax2, im2), (ax3, im3)]:
359
+ cax = ax.inset_axes((0, cbar_bottom, 1, cbar_height))
360
+ cbar = fig.colorbar(im, cax=cax, orientation='horizontal')
361
+ cbar.ax.xaxis.set_ticks_position('top')
362
+ cbar.ax.xaxis.set_label_position('top')
363
+ cbar.ax.tick_params(labelsize=12, pad=2)
364
+ outline_spine = cbar.ax.spines.get("outline")
365
+ if outline_spine is not None:
366
+ outline_spine.set_linewidth(1.5)
367
+
368
+ filename = f'{name}.pdf'
369
+ plt.savefig(path / filename, bbox_inches='tight')
370
+ plt.close()
371
+
372
+ else:
373
+ raise ValueError(
374
+ f"Unsupported turbulence {nd.type!r}; expected 'SSD', 'Bx', 'Bz', 'MRI', or 'hydro'."
375
+ )
376
+
377
+ # ===== Composite slice plots: all.vis.pdf and all.res.pdf =====
378
+ # Each figure is a 2x3 grid. Rows are (plane, component) pairs.
379
+ # all.vis.pdf columns: velocity, physical viscosity, numerical viscosity
380
+ # all.res.pdf columns: magnetic field, physical resistivity, numerical resistivity
381
+ # For type = 'Bz', row 1 = z-plane & z-component; row 2 = x-plane & x-component.
382
+ # For type = 'Bx', row 1 = x-plane & x-component; row 2 = z-plane & z-component.
383
+ if nd.type in ('Bx', 'Bz'):
384
+
385
+ phyVisField = nd.phyVisTerm if nd.nu != 0 else nd.divStressT
386
+ phyResField = nd.phyResTerm if nd.eta != 0 else nd.LaplacianB
387
+ assert phyVisField is not None and phyResField is not None
388
+ assert nd.numVisTerm is not None and nd.numResTerm is not None
389
+ assert nd.V is not None and nd.B is not None
390
+
391
+ def getslice(arr: np.ndarray, plane: str) -> np.ndarray:
392
+ if plane == 'x':
393
+ return arr[Nx // 2, :, :]
394
+ elif plane == 'z':
395
+ return arr[:, :, Nz // 2]
396
+ else:
397
+ raise ValueError(f"Unsupported plane: {plane!r}; expected 'x' or 'z'.")
398
+
399
+ def getextent(plane: str) -> tuple[float, float, float, float]:
400
+ if plane == 'x':
401
+ return (-Ly / 2, Ly / 2, -Lz / 2, Lz / 2)
402
+ elif plane == 'z':
403
+ return (-Lx / 2, Lx / 2, -Ly / 2, Ly / 2)
404
+ else:
405
+ raise ValueError(f"Unsupported plane: {plane!r}; expected 'x' or 'z'.")
406
+
407
+ def getlabels(plane: str) -> tuple[str, str]:
408
+ if plane == 'x':
409
+ return r'$y$', r'$z$'
410
+ elif plane == 'z':
411
+ return r'$x$', r'$y$'
412
+ else:
413
+ raise ValueError(f"Unsupported plane: {plane!r}; expected 'x' or 'z'.")
414
+
415
+ planes = {
416
+ 'Bz': [('z', 'z'), ('x', 'x')],
417
+ 'Bx': [('x', 'x'), ('z', 'z')],
418
+ }[nd.type]
419
+
420
+ nu = r'\nu ' if nd.nu != 0 else ''
421
+ eta = r'\eta ' if nd.eta != 0 else ''
422
+
423
+ def vislabel(kind: str, comp: str) -> str:
424
+ return {
425
+ 'V' : r'$u_' + comp + r'$',
426
+ 'phyVis': r'$' + nu + r'(\nabla \cdot \mathbb{T})_' + comp + r'$',
427
+ 'numVis': r'$D^{\mathrm{num}}_{\mathrm{vis}, ' + comp + r'}$',
428
+ }[kind]
429
+
430
+ def reslabel(kind: str, comp: str) -> str:
431
+ return {
432
+ 'B' : r'$B_' + comp + r'$',
433
+ 'phyRes': r'$' + eta + r'\nabla^2 B_' + comp + r'$',
434
+ 'numRes': r'$D^{\mathrm{num}}_{\mathrm{res}, ' + comp + r'}$',
435
+ }[kind]
436
+
437
+ visCols = [('V', nd.V), ('phyVis', phyVisField), ('numVis', nd.numVisTerm)]
438
+ resCols = [('B', nd.B), ('phyRes', phyResField), ('numRes', nd.numResTerm)]
439
+
440
+ def plot(
441
+ filename: str,
442
+ columns : list[tuple[str, VectorField]],
443
+ labelFn : Callable[[str, str], str],
444
+ ) -> None:
445
+ nrows, ncols = 2, 3
446
+ fig = plt.figure(figsize=(15.0, 11))
447
+ gs = fig.add_gridspec(
448
+ nrows, ncols,
449
+ wspace=0.24, hspace=0.04,
450
+ left=0.0, right=1.0, top=0.94, bottom=0.06,
451
+ )
452
+ axes = np.empty((nrows, ncols), dtype=object)
453
+ for r in range(nrows):
454
+ for c in range(ncols):
455
+ axes[r, c] = fig.add_subplot(gs[r, c])
456
+
457
+ for r, (plane, comp) in enumerate(planes):
458
+ extent = getextent(plane)
459
+ xlabel, ylabel = getlabels(plane)
460
+ for c, (kind, field) in enumerate(columns):
461
+ ax = axes[r, c]
462
+ data = getslice(getattr(field, comp), plane)
463
+ vmin, vmax = getRange(data, frac=1.0 if c == 0 else None)
464
+
465
+ im = ax.imshow(
466
+ data.T, origin='lower',
467
+ cmap=cmap, vmin=vmin, vmax=vmax,
468
+ extent=extent, aspect='auto'
469
+ )
470
+
471
+ isLeft = (c == 0)
472
+ ax.set_xlabel(xlabel)
473
+ if isLeft:
474
+ ax.set_ylabel(ylabel, labelpad=0)
475
+ ax.tick_params(direction='in', width=1.5, pad=5, labelleft=isLeft, labelsize=14)
476
+ ax.set_box_aspect(1)
477
+ for spine in ax.spines.values():
478
+ spine.set_linewidth(1.5)
479
+
480
+ ax.text(
481
+ 0.06, 0.94, labelFn(kind, comp),
482
+ transform=ax.transAxes,
483
+ ha='left', va='top',
484
+ fontsize=18,
485
+ bbox=dict(
486
+ boxstyle='round,pad=0.5',
487
+ facecolor='white',
488
+ edgecolor='black',
489
+ linewidth=1.2,
490
+ ),
491
+ zorder=10,
492
+ )
493
+
494
+ cax = ax.inset_axes((1.04, 0.0, 0.06, 1.0))
495
+ cbar = fig.colorbar(im, cax=cax, orientation='vertical')
496
+ cbar.ax.yaxis.set_ticks_position('right')
497
+ cbar.ax.yaxis.set_label_position('right')
498
+ cbar.ax.tick_params(labelsize=14, pad=4)
499
+ outline_spine = cbar.ax.spines.get("outline")
500
+ if outline_spine is not None:
501
+ outline_spine.set_linewidth(1.5)
502
+
503
+ plt.savefig(path / filename, bbox_inches='tight')
504
+ plt.close()
505
+
506
+ plot('all.vis.pdf', visCols, vislabel)
507
+ plot('all.res.pdf', resCols, reslabel)
508
+
509
+
510
+ def plotShellSpectra(ds: DissipationSpectra, spc: EnergySpectra, outdir: Path) -> None:
511
+ """Plot shell-integrated dissipation and energy spectra
512
+
513
+ Parameters
514
+ ----------
515
+ ds : DissipationSpectra
516
+ spc : EnergySpectra
517
+ outdir: Path, the output directory.
518
+ """
519
+ totEkin = spc.totEkin
520
+ totEmag = spc.totEmag
521
+
522
+ Ekin1D = get1D(totEkin, mode="shell")
523
+ Emag1D = get1D(totEmag, mode="shell") if totEmag is not None else None
524
+
525
+ def plotCurves(
526
+ filename: str,
527
+ curves1 : list[Curve],
528
+ curves2 : list[Curve],
529
+ ylabel : str,
530
+ ) -> None:
531
+
532
+ fig, ax1 = plt.subplots(figsize=(9.0, 6.0))
533
+ ax2 = ax1.twinx()
534
+
535
+ # Draw left-axis lines above right-axis lines
536
+ ax2.set_zorder(0)
537
+ ax1.set_zorder(1)
538
+ ax1.patch.set_visible(False)
539
+
540
+ for curve in curves1:
541
+ curve.plotDissipation(ax1)
542
+
543
+ for curve in curves2:
544
+ curve.plotEnergy(ax2)
545
+
546
+ # Keep low-k forcing from dominating y-max by using only k > 10 for the upper bound.
547
+ all = np.concatenate([curve.spc1d.Ek for curve in curves1])
548
+ high = np.concatenate([curve.spc1d.Ek[curve.spc1d.k > 10.0] for curve in curves1])
549
+
550
+ ymin = float(np.min(all))
551
+ ymax = float(np.max(high))
552
+
553
+ # Auto margin following the matplotlib default.
554
+ ymargin = float(plt.rcParams.get("axes.ymargin", 0.05))
555
+ pad = ymargin * (ymax - ymin)
556
+ ax1.set_ylim(ymin - pad, ymax + pad)
557
+ ax1.axhline(0.0, color="k", ls="-", lw=1.0, zorder=-10)
558
+
559
+ labelsize = 16
560
+ ticksize = 12
561
+
562
+ ax1.set_xlabel(r"$k$", fontsize=labelsize)
563
+ ax1.set_ylabel(ylabel, fontsize=labelsize)
564
+ ax2.set_ylabel(r"$k^{5/3}E(k)$", fontsize=labelsize)
565
+
566
+ ax1.tick_params(axis="both", direction="in", which="both", labelsize=ticksize, pad=5)
567
+ ax2.tick_params(axis="y", direction="in", which="both", labelsize=ticksize, pad=5)
568
+
569
+ ax1.grid(True, which="both", ls="--", alpha=0.3)
570
+ handles1, labels1 = ax1.get_legend_handles_labels()
571
+ handles2, labels2 = ax2.get_legend_handles_labels()
572
+ handles, labels = handles1 + handles2, labels1 + labels2
573
+ ax1.legend(handles, labels, loc="lower left", fontsize=14, framealpha=1.0)
574
+
575
+ fig.tight_layout()
576
+ fig.savefig(outdir / filename, bbox_inches="tight")
577
+ plt.close(fig)
578
+
579
+ # ===== shell.num.pdf =====
580
+ # numerical viscous + numerical resistive dissipation spectra
581
+ num1: list[Curve] = []
582
+ spectrum1d = get1D(ds.eNumVis, mode="shell", negative=True)
583
+ num1.append(
584
+ Curve(spectrum1d, "k", r"$\varepsilon_{\mathrm{vis}}^{\mathrm{num}}$", peak=True, mask=True)
585
+ )
586
+ if spc.totEmag is not None:
587
+ spectrum1d = get1D(ds.eNumRes, mode="shell", negative=True)
588
+ num1.append(
589
+ Curve(spectrum1d, "r", r"$\varepsilon_{\mathrm{res}}^{\mathrm{num}}$", peak=True, mask=True)
590
+ )
591
+ if num1:
592
+ num2: list[Curve] = []
593
+ curve = Curve(Ekin1D, "k", r"$E_{\mathrm{kin}}$")
594
+ num2.append(curve)
595
+ if Emag1D is not None:
596
+ curve = Curve(Emag1D, "r", r"$E_{\mathrm{mag}}$")
597
+ num2.append(curve)
598
+ ylabel = r"$-\varepsilon_{\mathrm{diss}}^{\mathrm{num}}(k)$"
599
+ plotCurves("shell.num.pdf", curves1 = num1, curves2 = num2, ylabel = ylabel)
600
+
601
+ # ===== shell.all.pdf =====
602
+ if Emag1D is not None and spc.totEmag is not None:
603
+
604
+ # vis: numerical/physical/total viscous dissipation spectrum
605
+ vis: list[Curve] = []
606
+
607
+ # numerical viscous dissipation spectrum
608
+ spectrum1d = get1D(ds.eNumVis, mode="shell", negative=True)
609
+ label = r"$\varepsilon_{\mathrm{vis}}^{\mathrm{num}}$"
610
+ vis.append(Curve(spectrum1d, "b", label, peak=True, mask=True, lw=2.5, dashed=True))
611
+
612
+ # physical viscous dissipation spectrum
613
+ if ds.nu != 0.0:
614
+ spectrum1d = get1D(ds.ePhyVis, mode="shell", negative=True)
615
+ label = r"$\varepsilon_{\mathrm{vis}}^{\mathrm{phy}}$"
616
+ vis.append(Curve(spectrum1d, "r", label, peak=True, mask=True, lw=2.5, dashed=True))
617
+
618
+ # total viscous dissipation spectrum
619
+ spectrum1d = get1D(ds.eTotVis, mode="shell", negative=True)
620
+ label = r"$\varepsilon_{\mathrm{vis}}^{\mathrm{tot}}$"
621
+ vis.append(Curve(spectrum1d, "k", label, peak=True, mask=True, lw=2.5, dashed=False))
622
+
623
+ # kin: kinetic energy spectrum
624
+ kin: list[Curve] = [Curve(Ekin1D, "k", r"$E_{\mathrm{kin}}$")]
625
+
626
+ # res: numerical/physical/total resistive dissipation spectrum
627
+ res: list[Curve] = []
628
+
629
+ # numerical resistive dissipation spectrum
630
+ spectrum1d = get1D(ds.eNumRes, mode="shell", negative=True)
631
+ label = r"$\varepsilon_{\mathrm{res}}^{\mathrm{num}}$"
632
+ res.append(Curve(spectrum1d, "b", label, peak=True, mask=True, lw=2.5, dashed=True))
633
+
634
+ # physical resistive dissipation spectrum
635
+ if ds.eta != 0.0:
636
+ spectrum1d = get1D(ds.ePhyRes, mode="shell", negative=True)
637
+ label = r"$\varepsilon_{\mathrm{res}}^{\mathrm{phy}}$"
638
+ res.append(Curve(spectrum1d, "r", label, peak=True, mask=True, lw=2.5, dashed=True))
639
+
640
+ # total resistive dissipation spectrum
641
+ spectrum1d = get1D(ds.eTotRes, mode="shell", negative=True)
642
+ label = r"$\varepsilon_{\mathrm{res}}^{\mathrm{tot}}$"
643
+ res.append(Curve(spectrum1d, "k", label, peak=True, mask=True, lw=2.5, dashed=False))
644
+
645
+ # mag: magnetic energy spectrum
646
+ mag: list[Curve] = [Curve(Emag1D, "k", r"$E_{\mathrm{mag}}$")]
647
+
648
+ yleftlabel = r"$-\varepsilon(k)$"
649
+ yrightlabel = r"$k^{5/3}E(k)$"
650
+
651
+ xlabelsize = 16
652
+ ylabelsize = 14
653
+ ticksize = 12
654
+ pad = 5.5
655
+
656
+ fig, (ax1vis, ax1res) = plt.subplots(1, 2, figsize=(12.0, 5.5), sharey=True)
657
+ ax2vis = ax1vis.twinx()
658
+ ax2res = ax1res.twinx()
659
+ ax2res.sharey(ax2vis)
660
+
661
+ for ax1, ax2 in ((ax1vis, ax2vis), (ax1res, ax2res)):
662
+ ax2.set_zorder(0)
663
+ ax1.set_zorder(1)
664
+ ax1.patch.set_visible(False)
665
+
666
+ for curve in vis:
667
+ curve.plotDissipation(ax1vis)
668
+ for curve in kin:
669
+ curve.plotEnergy(ax2vis)
670
+ for curve in res:
671
+ curve.plotDissipation(ax1res)
672
+ for curve in mag:
673
+ curve.plotEnergy(ax2res)
674
+
675
+ totvis1d = get1D(ds.eTotVis, mode="shell", negative=True)
676
+ totres1d = get1D(ds.eTotRes, mode="shell", negative=True)
677
+ diss_Ek = np.concatenate([totvis1d.Ek, totres1d.Ek])
678
+ diss_Ek_highk = np.concatenate(
679
+ [
680
+ totvis1d.Ek[totvis1d.k > 10.0],
681
+ totres1d.Ek[totres1d.k > 10.0],
682
+ ]
683
+ )
684
+ ymin = float(np.min(diss_Ek))
685
+ ymax = float(np.max(diss_Ek_highk))
686
+ ymargin = float(plt.rcParams.get("axes.ymargin", 0.05))
687
+ ypad = ymargin * (ymax - ymin)
688
+ ylow, yhigh = ymin - ypad, ymax + ypad
689
+ ax1vis.set_ylim(ylow, yhigh)
690
+ ax1vis.axhline(0.0, color="k", ls="-", lw=1.0, zorder=-10)
691
+ ax1res.axhline(0.0, color="k", ls="-", lw=1.0, zorder=-10)
692
+
693
+ ax2vis.relim()
694
+ ax2res.relim()
695
+ ax2vis.autoscale_view()
696
+
697
+ ax1vis.set_xlabel(r"$k$", fontsize=xlabelsize)
698
+ ax1res.set_xlabel(r"$k$", fontsize=xlabelsize)
699
+ ax1vis.set_ylabel(yleftlabel, fontsize=ylabelsize)
700
+ ax2res.set_ylabel(yrightlabel, fontsize=ylabelsize)
701
+
702
+ ax1vis.tick_params(axis="both", direction="in", which="both", labelsize=ticksize, pad=pad)
703
+ ax1res.tick_params(axis="x" , direction="in", which="both", labelsize=ticksize, pad=pad)
704
+ ax1res.tick_params(axis="y" , direction="in", which="both", labelsize=ticksize, pad=pad, labelleft=False)
705
+ ax2vis.tick_params(axis="y" , direction="in", which="both", labelsize=ticksize, pad=pad, labelright=False)
706
+ ax2res.tick_params(axis="y" , direction="in", which="both", labelsize=ticksize, pad=pad)
707
+
708
+ ax1vis.grid(True, which="both", ls="--", alpha=0.3)
709
+ ax1res.grid(True, which="both", ls="--", alpha=0.3)
710
+
711
+ handles1, labels1 = ax1vis.get_legend_handles_labels()
712
+ handles2, labels2 = ax2vis.get_legend_handles_labels()
713
+ frame = ax1vis.legend(
714
+ handles1 + handles2, labels1 + labels2, loc="lower left", bbox_to_anchor=(0.007, 0.007), fontsize=14,
715
+ framealpha=1.0, fancybox=True, facecolor="white", edgecolor="k",
716
+ ).get_frame()
717
+ frame.set_edgecolor("k")
718
+ frame.set_linewidth(0.9)
719
+ frame.set_boxstyle("round", pad=0.15, rounding_size=0.7)
720
+
721
+ handles1, labels1 = ax1res.get_legend_handles_labels()
722
+ handles2, labels2 = ax2res.get_legend_handles_labels()
723
+ frame = ax1res.legend(
724
+ handles1 + handles2, labels1 + labels2, loc="lower left", bbox_to_anchor=(0.007, 0.007), fontsize=14,
725
+ framealpha=1.0, fancybox=True, facecolor="white", edgecolor="k",
726
+ ).get_frame()
727
+ frame.set_edgecolor("k")
728
+ frame.set_linewidth(0.9)
729
+ frame.set_boxstyle("round", pad=0.15, rounding_size=0.7)
730
+
731
+ bbox = dict(
732
+ boxstyle="round,pad=0.45",
733
+ facecolor="white",
734
+ edgecolor="1.0",
735
+ linewidth=0.8,
736
+ alpha=0.0,
737
+ )
738
+
739
+ nu = float2LaTeX(ds.nu, ndigits=1)
740
+ eta = float2LaTeX(ds.eta, ndigits=1)
741
+ ax1vis.text(
742
+ 0.98, 0.97, rf"$\nu = {nu}$", transform=ax1vis.transAxes,
743
+ ha="right", va="top", fontsize=14, bbox=bbox,
744
+ )
745
+ ax1res.text(
746
+ 0.98, 0.97, rf"$\eta = {eta}$", transform=ax1res.transAxes,
747
+ ha="right", va="top", fontsize=14, bbox=bbox,
748
+ )
749
+
750
+ fig.tight_layout()
751
+ fig.savefig(outdir / "shell.all.pdf", bbox_inches="tight")
752
+ plt.close(fig)
753
+
754
+
755
+ def plotAxisymmetricSpectra(ds: DissipationSpectra, spc: EnergySpectra, outdir: Path) -> None:
756
+ """Plot axisymmetric dissipation and energy spectra for Bx/Bz turbulence.
757
+
758
+ Parameters
759
+ ----------
760
+ ds : DissipationSpectra
761
+ spc : EnergySpectra
762
+ outdir: Path, the output directory.
763
+ """
764
+ if spc.type == "Bx":
765
+ axis = "x"
766
+ elif spc.type == "Bz":
767
+ axis = "z"
768
+ else:
769
+ raise ValueError(f"plotAxisymmetricSpectra requires type 'Bx' or 'Bz', got '{spc.type}'")
770
+
771
+ if spc.totEmag is None:
772
+ raise ValueError("Magnetic energy spectrum cache is required for axisymmetric spectrum plotting.")
773
+
774
+ totEkin = ds.Ek.totEkin
775
+ totEmag = ds.Ek.totEmag
776
+ assert totEmag is not None
777
+ perpEkin1D = get1D(totEkin, mode="perp", axis=axis)
778
+ paraEkin1D = get1D(totEkin, mode="para", axis=axis)
779
+ perpEmag1D = get1D(totEmag, mode="perp", axis=axis)
780
+ paraEmag1D = get1D(totEmag, mode="para", axis=axis)
781
+
782
+ def getDissipationCurves(
783
+ phy : Spectrum,
784
+ num : Spectrum,
785
+ tot : Spectrum,
786
+ mode : Literal["perp", "para"],
787
+ diss : Literal["vis", "res"],
788
+ ) -> list[Curve]:
789
+
790
+ # Order: numerical, physical, total (legend: num on top). Num = blue dashed, phy = red dashed.
791
+ return [
792
+ Curve(
793
+ get1D(num, mode=mode, axis=axis, negative=True),
794
+ "b", rf"$\varepsilon_{{\mathrm{{{diss}}}}}^{{\mathrm{{num}}}}$", lw=2.5, dashed=True,
795
+ ),
796
+ Curve(
797
+ get1D(phy, mode=mode, axis=axis, negative=True),
798
+ "r", rf"$\varepsilon_{{\mathrm{{{diss}}}}}^{{\mathrm{{phy}}}}$", lw=2.5, dashed=True,
799
+ ),
800
+ Curve(
801
+ get1D(tot, mode=mode, axis=axis, negative=True),
802
+ "k", rf"$\varepsilon_{{\mathrm{{{diss}}}}}^{{\mathrm{{tot}}}}$", lw=2.5, dashed=False,
803
+ ),
804
+ ]
805
+
806
+ def getShellDissipationCurves(
807
+ phy : Spectrum,
808
+ num : Spectrum,
809
+ tot : Spectrum,
810
+ diss : Literal["vis", "res"],
811
+ direction: Literal["perp", "para"],
812
+ ) -> list[Curve]:
813
+
814
+ directionLabel = r"\perp" if direction == "perp" else r"\parallel"
815
+ curves: list[Curve] = []
816
+ # Plot / legend order: num, phy, tot. Num = blue dashed, phy = red dashed.
817
+ for spectrum, color, label, dashed, lw, use_peak in (
818
+ (num, "b", "num", True , 2.5, True ),
819
+ (phy, "r", "phy", True , 2.5, True ),
820
+ (tot, "k", "tot", False, 2.5, False),
821
+ ):
822
+ spectrum1D = get1D(spectrum, mode="shell", negative=True)
823
+ curveLabel = rf"$\mathscr{{D}}_{{\mathrm{{{diss}}},{directionLabel}}}^{{\mathrm{{{label}}}}}$"
824
+ curves.append(
825
+ Curve(
826
+ spectrum1D, color, curveLabel,
827
+ lw=lw, dashed=dashed, peak=use_peak, mask=use_peak,
828
+ ),
829
+ )
830
+ return curves
831
+
832
+ def plotCurves(
833
+ filename: str,
834
+ panels : list[tuple[list[Curve], list[Curve], float, str, str, str]],
835
+ xlim : tuple[float, float] | None = None,
836
+ ) -> None:
837
+
838
+ fig, axs = plt.subplots(2, 2, figsize=(12.0, 9.5), sharey="row")
839
+ ax2s = [[axs[i, j].twinx() for j in range(2)] for i in range(2)]
840
+
841
+ ax2s[0][1].sharey(ax2s[0][0])
842
+ ax2s[1][1].sharey(ax2s[1][0])
843
+
844
+ labelsize = 14
845
+ ticksize = 12
846
+ pad = 5.5
847
+
848
+ for i, (curves1, curves2, slope, xlabel, ylabelLeft, ylabelRight) in enumerate(panels):
849
+ row, col = divmod(i, 2)
850
+ ax1 = axs[row, col]
851
+ ax2 = ax2s[row][col]
852
+
853
+ ax2.set_zorder(0)
854
+ ax1.set_zorder(1)
855
+ ax1.patch.set_visible(False)
856
+
857
+ for curve in curves1:
858
+ curve.plotDissipation(ax1)
859
+ for curve in curves2:
860
+ curve.plotEnergy(ax2, slope=slope)
861
+
862
+ ax1.axhline(0.0, color="k", ls="-", lw=1.0, zorder=-10)
863
+ # ax1.grid(True, which="both", ls="--", alpha=0.3)
864
+
865
+ # Omit bottom-row x labels on the first row when x matches the panel below (same column).
866
+ samexlabel = panels[col][3] == panels[2 + col][3]
867
+ showbottom = (row == 1) or not samexlabel
868
+ if showbottom:
869
+ ax1.set_xlabel(xlabel, fontsize=labelsize)
870
+
871
+ if col == 0:
872
+ ax1.set_ylabel(ylabelLeft, fontsize=labelsize)
873
+ else:
874
+ ax1.tick_params(axis="y", labelleft=False)
875
+ ax2.set_ylabel(ylabelRight, fontsize=labelsize)
876
+
877
+ ax1.tick_params(
878
+ axis="both", direction="in", which="both",
879
+ labelsize=ticksize, pad=pad, labelbottom=showbottom,
880
+ )
881
+ ax2.tick_params(axis="y" , direction="in", which="both", labelsize=ticksize, pad=pad)
882
+
883
+ if col == 0:
884
+ ax2.tick_params(axis="y", labelright=False)
885
+
886
+ handles1, labels1 = ax1.get_legend_handles_labels()
887
+ handles2, labels2 = ax2.get_legend_handles_labels()
888
+ frame = ax1.legend(
889
+ handles1 + handles2, labels1 + labels2,
890
+ loc="lower left", bbox_to_anchor=(0.007, 0.007), fontsize=12,
891
+ framealpha=1.0, fancybox=True, facecolor="white", edgecolor="k",
892
+ ).get_frame()
893
+ frame.set_edgecolor("k")
894
+ frame.set_linewidth(0.9)
895
+ frame.set_boxstyle("round", pad=0.15, rounding_size=0.5)
896
+
897
+ if xlim is not None:
898
+ k0, k1 = xlim
899
+ ym = float(plt.rcParams.get("axes.ymargin", 0.05))
900
+ for row in (0, 1):
901
+ parts: list[np.ndarray] = []
902
+ for j in (0, 1):
903
+ for c in panels[2 * row + j][0]:
904
+ k, ek = c.spc1d.k, c.spc1d.Ek
905
+ m = (k >= k0) & (k <= k1) & np.isfinite(ek)
906
+ if np.any(m):
907
+ parts.append(ek[m])
908
+ if parts:
909
+ y = np.concatenate(parts)
910
+ lo, hi = float(np.min(y)), float(np.max(y))
911
+ p = ym * ((hi - lo) if hi > lo else max(abs(hi), abs(lo), 1.0))
912
+ axs[row, 0].set_ylim(lo - p, hi + p)
913
+ for ax1 in axs.flat:
914
+ ax1.set_xlim(xlim)
915
+
916
+ # Semi-transparent band for energy injection scales in [k_min, k_inj]. Draw on twin ax2 only
917
+ # (lower axes zorder) so axvspan stays below energy curves and ax1 dissipation draws on top.
918
+ injection_k_hi = 6.5
919
+ span_kw = dict(facecolor="0.5", alpha=0.24, zorder=-15, linewidth=0)
920
+ for row in range(2):
921
+ for col in range(2):
922
+ ax2 = ax2s[row][col]
923
+ k_lo, k_hi_axis = ax2.get_xlim()
924
+ k_inj_right = min(injection_k_hi, k_hi_axis)
925
+ if k_inj_right > k_lo:
926
+ ax2.axvspan(k_lo, k_inj_right, **span_kw)
927
+
928
+ fig.tight_layout()
929
+ fig.subplots_adjust(hspace=0.07, wspace=0.06)
930
+ fig.savefig(outdir / filename, bbox_inches="tight")
931
+ plt.close(fig)
932
+
933
+ # ===== axisymmetric.pdf =====
934
+ # Dissipation spectra: \varepsilon_{vis,res}(k_{\perp,\parallel})
935
+ # Energy spectra: E_{kin,mag}(k_{\perp,\parallel})
936
+ perpVis: list[Curve] = getDissipationCurves(
937
+ ds.ePhyVis, ds.eNumVis, ds.eTotVis, mode="perp", diss="vis",
938
+ )
939
+ perpRes: list[Curve] = getDissipationCurves(
940
+ ds.ePhyRes, ds.eNumRes, ds.eTotRes, mode="perp", diss="res",
941
+ )
942
+ paraVis: list[Curve] = getDissipationCurves(
943
+ ds.ePhyVis, ds.eNumVis, ds.eTotVis, mode="para", diss="vis",
944
+ )
945
+ paraRes: list[Curve] = getDissipationCurves(
946
+ ds.ePhyRes, ds.eNumRes, ds.eTotRes, mode="para", diss="res",
947
+ )
948
+
949
+ perpKin: list[Curve] = [Curve(perpEkin1D, "0.5", r"$E_{\mathrm{kin}}$")]
950
+ perpMag: list[Curve] = [Curve(perpEmag1D, "0.5", r"$E_{\mathrm{mag}}$")]
951
+ paraKin: list[Curve] = [Curve(paraEkin1D, "0.5", r"$E_{\mathrm{kin}}$")]
952
+ paraMag: list[Curve] = [Curve(paraEmag1D, "0.5", r"$E_{\mathrm{mag}}$")]
953
+
954
+ panels = [
955
+ # top-left panel: \varepsilon_{vis}(k_{\perp})
956
+ (
957
+ perpVis, perpKin, 5.0 / 3.0,
958
+ r"$k_{\perp}$", r"$-\varepsilon(k_{\perp})$", r"$k_{\perp}^{5/3}E(k_{\perp})$",
959
+ ),
960
+ # top-right panel: \varepsilon_{res}(k_{\perp})
961
+ (
962
+ perpRes, perpMag, 5.0 / 3.0,
963
+ r"$k_{\perp}$", r"$-\varepsilon(k_{\perp})$", r"$k_{\perp}^{5/3}E(k_{\perp})$",
964
+ ),
965
+ # bottom-left panel: \varepsilon_{vis}(k_{\parallel})
966
+ (
967
+ paraVis, paraKin, 2.0,
968
+ r"$k_{\parallel}$", r"$-\varepsilon(k_{\parallel})$", r"$k_{\parallel}^{2}E(k_{\parallel})$",
969
+ ),
970
+ # bottom-right panel: \varepsilon_{res}(k_{\parallel})
971
+ (
972
+ paraRes, paraMag, 2.0,
973
+ r"$k_{\parallel}$", r"$-\varepsilon(k_{\parallel})$", r"$k_{\parallel}^{2}E(k_{\parallel})$",
974
+ ),
975
+ ]
976
+ plotCurves("axisymmetric.pdf", panels)
977
+
978
+ # ===== components.pdf =====
979
+ # Shell-integrated component-wise dissipation spectra: \mathscr{D}_{vis,res}(k)
980
+ # Shell-integrated component-wise energy spectra: E_{kin,mag}(k)
981
+ Ek = ds.Ek
982
+
983
+ if axis == "z":
984
+ perpEkin, paraEkin = Ek.xEkin + Ek.yEkin, Ek.zEkin
985
+ if Ek.xEmag is None or Ek.yEmag is None or Ek.zEmag is None:
986
+ raise ValueError("Per-component magnetic energy spectra are required for components.pdf.")
987
+ perpEmag, paraEmag = Ek.xEmag + Ek.yEmag, Ek.zEmag
988
+
989
+ numPerpVis, numParaVis = ds.xNumVis + ds.yNumVis, ds.zNumVis
990
+ numPerpRes, numParaRes = ds.xNumRes + ds.yNumRes, ds.zNumRes
991
+ phyPerpVis, phyParaVis = ds.xPhyVis + ds.yPhyVis, ds.zPhyVis
992
+ phyPerpRes, phyParaRes = ds.xPhyRes + ds.yPhyRes, ds.zPhyRes
993
+ else:
994
+ perpEkin, paraEkin = Ek.yEkin + Ek.zEkin, Ek.xEkin
995
+ if Ek.xEmag is None or Ek.yEmag is None or Ek.zEmag is None:
996
+ raise ValueError("Per-component magnetic energy spectra are required for components.pdf.")
997
+ perpEmag, paraEmag = Ek.yEmag + Ek.zEmag, Ek.xEmag
998
+
999
+ numPerpVis, numParaVis = ds.yNumVis + ds.zNumVis, ds.xNumVis
1000
+ numPerpRes, numParaRes = ds.yNumRes + ds.zNumRes, ds.xNumRes
1001
+ phyPerpVis, phyParaVis = ds.yPhyVis + ds.zPhyVis, ds.xPhyVis
1002
+ phyPerpRes, phyParaRes = ds.yPhyRes + ds.zPhyRes, ds.xPhyRes
1003
+
1004
+ totPerpVis = phyPerpVis + numPerpVis
1005
+ totParaVis = phyParaVis + numParaVis
1006
+ totPerpRes = phyPerpRes + numPerpRes
1007
+ totParaRes = phyParaRes + numParaRes
1008
+
1009
+ perpVis = getShellDissipationCurves(phyPerpVis, numPerpVis, totPerpVis, diss="vis", direction="perp")
1010
+ perpRes = getShellDissipationCurves(phyPerpRes, numPerpRes, totPerpRes, diss="res", direction="perp")
1011
+ paraVis = getShellDissipationCurves(phyParaVis, numParaVis, totParaVis, diss="vis", direction="para")
1012
+ paraRes = getShellDissipationCurves(phyParaRes, numParaRes, totParaRes, diss="res", direction="para")
1013
+
1014
+ perpKin = [Curve(get1D(perpEkin, mode="shell"), "0.5", r"$E_{\mathrm{kin},\perp}$")]
1015
+ perpMag = [Curve(get1D(perpEmag, mode="shell"), "0.5", r"$E_{\mathrm{mag},\perp}$")]
1016
+ paraKin = [Curve(get1D(paraEkin, mode="shell"), "0.5", r"$E_{\mathrm{kin},\parallel}$")]
1017
+ paraMag = [Curve(get1D(paraEmag, mode="shell"), "0.5", r"$E_{\mathrm{mag},\parallel}$")]
1018
+
1019
+ panels = [
1020
+ (
1021
+ perpVis, perpKin, 5.0 / 3.0,
1022
+ r"$k$", r"$-\mathscr{D}_{\perp}(k)$", r"$k^{5/3}E_{\perp}(k)$",
1023
+ ),
1024
+ (
1025
+ perpRes, perpMag, 5.0 / 3.0,
1026
+ r"$k$", r"$-\mathscr{D}_{\perp}(k)$", r"$k^{5/3}E_{\perp}(k)$",
1027
+ ),
1028
+ (
1029
+ paraVis, paraKin, 5.0 / 3.0,
1030
+ r"$k$", r"$-\mathscr{D}_{\parallel}(k)$", r"$k^{5/3}E_{\parallel}(k)$",
1031
+ ),
1032
+ (
1033
+ paraRes, paraMag, 5.0 / 3.0,
1034
+ r"$k$", r"$-\mathscr{D}_{\parallel}(k)$", r"$k^{5/3}E_{\parallel}(k)$",
1035
+ ),
1036
+ ]
1037
+ kmax = float(np.max(perpKin[0].spc1d.k))
1038
+ plotCurves("components.pdf", panels, xlim=(2.0, kmax))
1039
+
1040
+
1041
+ def plotAnisotropicSpectra(ds: DissipationSpectra, spc: EnergySpectra, outdir: Path) -> None:
1042
+ """Plot anisotropic dissipation and energy spectra for x/y/z components.
1043
+
1044
+ Outputs components.pdf to nd.outputdir.
1045
+
1046
+ Three panels (left to right: x, y, z component), shared y-axis.
1047
+ Left axis : physical, numerical, and total resistive dissipation spectra.
1048
+ Right axis: k^{3/2}-compensated component-wise kinetic and magnetic energy spectra.
1049
+
1050
+ Parameters
1051
+ ----------
1052
+ ds : DissipationSpectra, the precomputed dissipation spectra.
1053
+ spc : EnergySpectra, the cached energy spectra.
1054
+ outdir: Path, the output directory.
1055
+ """
1056
+ Ek = ds.Ek
1057
+ if Ek.xEmag is None or Ek.yEmag is None or Ek.zEmag is None:
1058
+ raise ValueError("Per-component magnetic energy spectra are required for components.pdf.")
1059
+
1060
+ directions = ("x", "y", "z")
1061
+
1062
+ # Shell-integrated component-wise resistive dissipation spectra
1063
+ phyRes = {
1064
+ "x": get1D(ds.xPhyRes, mode="shell", negative=True),
1065
+ "y": get1D(ds.yPhyRes, mode="shell", negative=True),
1066
+ "z": get1D(ds.zPhyRes, mode="shell", negative=True),
1067
+ }
1068
+ numRes = {
1069
+ "x": get1D(ds.xNumRes, mode="shell", negative=True),
1070
+ "y": get1D(ds.yNumRes, mode="shell", negative=True),
1071
+ "z": get1D(ds.zNumRes, mode="shell", negative=True),
1072
+ }
1073
+ totRes = {
1074
+ "x": get1D(ds.xTotRes, mode="shell", negative=True),
1075
+ "y": get1D(ds.yTotRes, mode="shell", negative=True),
1076
+ "z": get1D(ds.zTotRes, mode="shell", negative=True),
1077
+ }
1078
+
1079
+ # Shell-integrated component-wise energy spectra
1080
+ ekin = {
1081
+ "x": get1D(Ek.xEkin, mode="shell"),
1082
+ "y": get1D(Ek.yEkin, mode="shell"),
1083
+ "z": get1D(Ek.zEkin, mode="shell"),
1084
+ }
1085
+ emag = {
1086
+ "x": get1D(Ek.xEmag, mode="shell"),
1087
+ "y": get1D(Ek.yEmag, mode="shell"),
1088
+ "z": get1D(Ek.zEmag, mode="shell"),
1089
+ }
1090
+
1091
+ # ===== components.pdf =====
1092
+ fig, ax1s = plt.subplots(1, 3, figsize=(14, 5), sharey=True)
1093
+ ax2s: list[Axes] = []
1094
+ for ax1 in ax1s:
1095
+ ax2 = ax1.twinx()
1096
+ ax2.set_zorder(0)
1097
+ ax1.set_zorder(1)
1098
+ ax1.patch.set_visible(False)
1099
+ ax2s.append(ax2)
1100
+ ax2s[1].sharey(ax2s[0])
1101
+ ax2s[2].sharey(ax2s[0])
1102
+
1103
+ for i, direction in enumerate(directions):
1104
+ ax1 = ax1s[i]
1105
+ ax2 = ax2s[i]
1106
+
1107
+ # Left axis: numerical / physical / total (legend: num on top). Num = blue dashed, phy = red dashed.
1108
+ ax1.semilogx(
1109
+ numRes[direction].k, numRes[direction].Ek,
1110
+ color="b", ls="--", lw=2.5,
1111
+ label=r"${\mathscr{D}}^{\mathrm{num}}_{\mathrm{res},%s}$" % direction,
1112
+ )
1113
+ ax1.semilogx(
1114
+ phyRes[direction].k, phyRes[direction].Ek,
1115
+ color="r", ls="--", lw=2.5,
1116
+ label=r"${\mathscr{D}}^{\mathrm{phy}}_{\mathrm{res},%s}$" % direction,
1117
+ )
1118
+ ax1.semilogx(
1119
+ totRes[direction].k, totRes[direction].Ek,
1120
+ color="k", ls="-", lw=2.5,
1121
+ label=r"${\mathscr{D}}^{\mathrm{tot}}_{\mathrm{res},%s}$" % direction,
1122
+ )
1123
+
1124
+ # Right axis: component kinetic and magnetic energy spectra
1125
+ k = ekin[direction].k
1126
+ slope = 3.0 / 2.0
1127
+ ax2.loglog(
1128
+ k, ekin[direction].Ek * k**slope,
1129
+ color="k", ls="--", lw=1.5, alpha=0.8,
1130
+ label=r"$E_{\mathrm{kin},%s}$" % direction,
1131
+ )
1132
+ ax2.loglog(
1133
+ k, emag[direction].Ek * k**slope,
1134
+ color="r", ls="--", lw=1.5, alpha=0.8,
1135
+ label=r"$E_{\mathrm{mag},%s}$" % direction,
1136
+ )
1137
+
1138
+ ks = (
1139
+ phyRes[direction].k,
1140
+ numRes[direction].k,
1141
+ totRes[direction].k,
1142
+ ekin[direction].k,
1143
+ emag[direction].k,
1144
+ )
1145
+ kmin = float(min(float(np.min(ka)) for ka in ks))
1146
+ kmax = float(max(float(np.max(ka)) for ka in ks))
1147
+ ax1.set_xlim(kmin, kmax)
1148
+
1149
+ ax1.set_xlabel(r"$k$", fontsize=14)
1150
+ ax1.tick_params(axis="x", direction="in", which="both", labelsize=12, pad=5)
1151
+ ax1.tick_params(
1152
+ axis="y", direction="in", which="both", labelsize=12, pad=5,
1153
+ labelleft=(i == 0),
1154
+ )
1155
+ ax2.tick_params(
1156
+ axis="y", direction="in", which="both", labelsize=12, pad=5,
1157
+ labelright=(i == 2),
1158
+ )
1159
+
1160
+ # ax1.grid(True, which="both", ls="--", alpha=0.3)
1161
+ h1, l1 = ax1.get_legend_handles_labels()
1162
+ h2, l2 = ax2.get_legend_handles_labels()
1163
+ frame = ax1.legend(
1164
+ h1 + h2, l1 + l2,
1165
+ loc="lower left", bbox_to_anchor=(0.007, 0.007), fontsize=12,
1166
+ framealpha=1.0, fancybox=True, facecolor="white", edgecolor="k",
1167
+ ).get_frame()
1168
+ frame.set_edgecolor("k")
1169
+ frame.set_linewidth(0.9)
1170
+ frame.set_boxstyle("round", pad=0.15, rounding_size=0.5)
1171
+
1172
+ _, ytop = ax1s[0].get_ylim()
1173
+ ax1s[0].set_ylim(0.0, ytop)
1174
+
1175
+ ax1s[0].set_ylabel(r"$-{\mathscr{D}}_{\mathrm{res}}(k)$", fontsize=16)
1176
+ ax2s[2].set_ylabel(r"$k^{3/2}E_i(k)$", fontsize=14)
1177
+
1178
+ fig.tight_layout()
1179
+ fig.subplots_adjust(wspace=0.07)
1180
+ fig.savefig(outdir / "components.pdf", bbox_inches="tight")
1181
+ plt.close(fig)
1182
+
1183
+
1184
+ def plotSpectra(nd: NumericalDissipation, spc: EnergySpectra) -> None:
1185
+ """Plot dissipation spectra.
1186
+
1187
+ Extracts the energy spectra and computes the dissipation spectra only once,
1188
+ then shares them across all dissipation spectrum plotting functions.
1189
+
1190
+ Parameters
1191
+ ----------
1192
+ nd : NumericalDissipation, the numerical dissipation object.
1193
+ spc: EnergySpectra, the cached energy spectra.
1194
+ """
1195
+ ds = DissipationSpectra(nd, spc)
1196
+ outdir = Path(nd.outputdir)
1197
+ outdir.mkdir(parents=True, exist_ok=True)
1198
+
1199
+ plotShellSpectra(ds, spc, outdir)
1200
+
1201
+ if nd.type in ("Bx", "Bz"):
1202
+ plotAxisymmetricSpectra(ds, spc, outdir)
1203
+
1204
+ if nd.type == "MRI":
1205
+ plotAnisotropicSpectra(ds, spc, outdir)
1206
+
1207
+
1208
+ def plotHistogram(
1209
+ nd : NumericalDissipation,
1210
+ xcoverage : float = 0.95,
1211
+ ycoverage : float = 0.99,
1212
+ resolution: int = 400
1213
+ ) -> None:
1214
+ """Plot 2D histograms of dissipation terms (combined xyz only)
1215
+
1216
+ Outputs to nd.outputdir/histograms/:
1217
+ hist.num.res.pdf, hist.num.vis.pdf
1218
+
1219
+ Two rows:
1220
+ - first row = 2D JPDF from KDE;
1221
+ - second row = conditional mean and 68% interval per x bin
1222
+
1223
+ Parameters
1224
+ ----------
1225
+ nd : NumericalDissipation object
1226
+ xcoverage : fraction of data enclosed by symmetric range for x data (lapl)
1227
+ ycoverage : fraction of data enclosed by symmetric range for y data (diss)
1228
+ resolution: histogram resolution (grid points per dimension for JPDF)
1229
+ """
1230
+ outputdir = Path(nd.outputdir)
1231
+ outputdir.mkdir(parents=True, exist_ok=True)
1232
+ path = outputdir / "histograms"
1233
+ path.mkdir(parents=True, exist_ok=True)
1234
+
1235
+ ticksize = 14
1236
+ plt.rcParams['xtick.labelsize'] = ticksize
1237
+ plt.rcParams['ytick.labelsize'] = ticksize
1238
+ xlabelpad, ylabelpad = 5, 2
1239
+ xlabelsize = 22
1240
+ ylabelsize = 24
1241
+ cbaroutlinewidth = 1.5
1242
+
1243
+ def xlabel(comp: str, term: str) -> str:
1244
+ if term == 'vis':
1245
+ return r'$u_' + comp + r'(\nabla \cdot \mathrm{\mathbb{T}})_' + comp + r'$'
1246
+ if term == 'res':
1247
+ return r'$B_' + comp + r'\nabla^2 B_' + comp + r'$'
1248
+ raise ValueError(f"Unsupported term {term!r}; expected 'vis' or 'res'.")
1249
+
1250
+ def ylabel(comp: str, term: str, mode: str | None) -> str:
1251
+ sub = r'_{\mathrm{' + term + r'}, ' + comp + r'}'
1252
+ if mode is not None:
1253
+ return r'${\mathscr{D}}^{\mathrm{' + mode + r'}}' + sub + r'$'
1254
+ return r'${\mathscr{D}}' + sub + r'$'
1255
+
1256
+ def flatten(diss: np.ndarray, lapl: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
1257
+ diss = diss.flatten()
1258
+ lapl = lapl.flatten()
1259
+ valid = np.isfinite(diss) & np.isfinite(lapl)
1260
+ return diss[valid], lapl[valid]
1261
+
1262
+ def KDE(lapl: np.ndarray, diss: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
1263
+ """Estimate JPDF using 2D KDE with KDEpy.FFTKDE
1264
+
1265
+ KDEpy docs: https://kdepy.readthedocs.io/en/latest/index.html
1266
+
1267
+ Returns hist on (resolution, resolution) grid, with xgrid, ygrid from KDEpy.
1268
+ grid: (resolution^2, 2) mesh points; points: density at each grid point.
1269
+ """
1270
+ if len(lapl) == 0 or len(diss) == 0:
1271
+ raise ValueError("No valid finite data for KDE after flatten.")
1272
+
1273
+ xrange = float(np.percentile(np.abs(lapl), xcoverage * 100))
1274
+ yrange = float(np.percentile(np.abs(diss), ycoverage * 100))
1275
+ mask = (np.abs(lapl) <= xrange) & (np.abs(diss) <= yrange)
1276
+ lapl = lapl[mask]
1277
+ diss = diss[mask]
1278
+
1279
+ if len(lapl) == 0:
1280
+ raise ValueError("No data within coverage range for KDE.")
1281
+
1282
+ # KDEpy 2D: data shape (obs, dims), grid_points tuple = (n_x, n_y) per dimension
1283
+ # Normalize each dimension by its own sigma so a scalar bw applies uniformly.
1284
+ # Reference:
1285
+ # Scott, D.W. (1992) Multivariate Density Estimation. Theory, Practice and Visualization.
1286
+ # Silverman, Bernard W. Density estimation for statistics and data analysis. Routledge, 2018.
1287
+ def Silverman(data: np.ndarray) -> float:
1288
+ sigma = float(np.std(data, ddof=1))
1289
+ IQR = float(np.percentile(data, 75) - np.percentile(data, 25))
1290
+ N = len(data)
1291
+ bw = min(sigma, IQR/1.34) * N ** (-1.0 / 5)
1292
+ return float(bw)
1293
+
1294
+ bwx = Silverman(lapl)
1295
+ bwy = Silverman(diss)
1296
+ data = np.column_stack([lapl / bwx, diss / bwy])
1297
+ kde = FFTKDE(kernel='gaussian', bw=1).fit(data)
1298
+
1299
+ # Use an explicit grid so the plotting domain matches coverage range.
1300
+ # FFTKDE requires all data points to lie strictly inside the grid;
1301
+ # expand endpoints by a tiny epsilon to satisfy this when points land on boundary.
1302
+ epsilon = 1e-6
1303
+ xgrid = np.linspace(-xrange - epsilon, xrange + epsilon, resolution)
1304
+ ygrid = np.linspace(-yrange - epsilon, yrange + epsilon, resolution)
1305
+ # Custom FFTKDE grids must be sorted in cartesian-product order.
1306
+ grid = np.column_stack([
1307
+ np.repeat(xgrid / bwx, resolution),
1308
+ np.tile(ygrid / bwy, resolution),
1309
+ ])
1310
+ points = kde.evaluate(grid)
1311
+
1312
+ hist = np.asarray(points).reshape(resolution, resolution).T / (bwx * bwy)
1313
+
1314
+ return hist, xgrid, ygrid
1315
+
1316
+ cmap = plt.get_cmap('inferno')
1317
+
1318
+ if nd.type == 'hydro':
1319
+ configs = [
1320
+ ('vis', 'num', nd.V, nd.numVisTerm, nd.divStressT, nd.nu),
1321
+ ]
1322
+ else:
1323
+ configs = [
1324
+ ('res', 'num', nd.B, nd.numResTerm, nd.LaplacianB, nd.eta),
1325
+ ('vis', 'num', nd.V, nd.numVisTerm, nd.divStressT, nd.nu),
1326
+ ]
1327
+
1328
+ for term, mode, field, dissterm, laplacian, coeff in configs:
1329
+
1330
+ if dissterm is None or laplacian is None:
1331
+ continue
1332
+
1333
+ components = [
1334
+ ('x', field.x * dissterm.x, field.x * laplacian.x),
1335
+ ('y', field.y * dissterm.y, field.y * laplacian.y),
1336
+ ('z', field.z * dissterm.z, field.z * laplacian.z)
1337
+ ]
1338
+
1339
+ fig, axes = plt.subplots(2, 3, figsize=(18, 11.75), constrained_layout=True)
1340
+ layout_engine = fig.get_layout_engine()
1341
+ assert isinstance(layout_engine, ConstrainedLayoutEngine)
1342
+ layout_engine.set(wspace=0.04)
1343
+
1344
+ # First pass: compute KDE and conditional stats for all three components
1345
+ jpdflist: list[tuple[np.ndarray, np.ndarray, np.ndarray]] = []
1346
+ meanlist: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = []
1347
+
1348
+ for comp, diss, lapl in components:
1349
+ diss, lapl = flatten(diss, lapl)
1350
+ hist, xgrid, ygrid = KDE(lapl, diss)
1351
+
1352
+ totals = hist.sum(axis=0)
1353
+ totals = np.where(totals > 0, totals, 1.0)
1354
+ conditional = hist / totals
1355
+
1356
+ cdf = np.cumsum(conditional, axis=0)
1357
+ cdf_last = cdf[-1, :]
1358
+ cdf_last = np.where(cdf_last > 0, cdf_last, 1.0)
1359
+ cdf = cdf / cdf_last
1360
+
1361
+ ymean = np.sum(conditional * ygrid[:, None], axis=0)
1362
+
1363
+ lower = np.zeros(cdf.shape[1])
1364
+ upper = np.zeros(cdf.shape[1])
1365
+ for i in range(cdf.shape[1]):
1366
+ lower[i] = np.interp(0.1585, cdf[:, i], ygrid)
1367
+ upper[i] = np.interp(0.8415, cdf[:, i], ygrid)
1368
+
1369
+ valid = (totals > 0) & np.isfinite(ymean) & (upper > lower)
1370
+ x = xgrid[valid]
1371
+ ymean = ymean[valid]
1372
+ lower = lower[valid]
1373
+ upper = upper[valid]
1374
+
1375
+ jpdflist.append((hist, xgrid, ygrid))
1376
+ meanlist.append((x, ymean, lower, upper))
1377
+
1378
+ # Compute unified y-axis range for second row
1379
+ xranges = [float(xgrid[-1]) for (_, xgrid, _) in jpdflist]
1380
+ ymeanranges = [
1381
+ float(np.max(np.abs(ymean))) if len(ymean) > 0 else 0.0
1382
+ for (_, ymean, _, _) in meanlist
1383
+ ]
1384
+ kranges = [
1385
+ ymeanranges[i] / xranges[i] if xranges[i] > 0 else 0.0
1386
+ for i in range(3)
1387
+ ]
1388
+ kmax = float(np.max(kranges))
1389
+ ymaxs = [xranges[i] * kmax * 1.2 for i in range(3)]
1390
+
1391
+ # Second pass: plot
1392
+ for idx, (comp, _, _) in enumerate(components):
1393
+ ax0 = axes[0, idx]
1394
+ ax1 = axes[1, idx]
1395
+
1396
+ hist, xgrid, ygrid = jpdflist[idx]
1397
+ x, ymean, lower, upper = meanlist[idx]
1398
+ ymax = ymaxs[idx]
1399
+
1400
+ extent = (float(xgrid[0]), float(xgrid[-1]), float(ygrid[0]), float(ygrid[-1]))
1401
+
1402
+ # ===== First row: JPDF =====
1403
+ visible = hist[(ygrid >= -ymax) & (ygrid <= ymax), :]
1404
+ positive = visible[visible > 0]
1405
+ if positive.size == 0:
1406
+ raise ValueError(
1407
+ f'JPDF color scale: no positive values in y-range '
1408
+ f'(term={term!r}, mode={mode!r}, comp={comp!r}, ymax={ymax})'
1409
+ )
1410
+ vmin = float(np.percentile(positive, 5))
1411
+ vmax = float(np.max(positive))
1412
+ if not (
1413
+ np.isfinite(vmin)
1414
+ and np.isfinite(vmax)
1415
+ and vmin > 0
1416
+ and vmax > 0
1417
+ and vmax > vmin
1418
+ ):
1419
+ raise ValueError(
1420
+ f'LogNorm needs finite vmin, vmax with 0 < vmin < vmax; '
1421
+ f'got vmin={vmin}, vmax={vmax} (term={term!r}, mode={mode!r}, comp={comp!r})'
1422
+ )
1423
+ norm = LogNorm(vmin=vmin, vmax=vmax)
1424
+
1425
+ im = ax0.imshow(hist, origin='lower', extent=extent, cmap=cmap, aspect='auto', norm=norm)
1426
+ ax0.set_xlim(xgrid[0], xgrid[-1])
1427
+ ax0.set_ylim(-ymax, ymax)
1428
+
1429
+ ax0.set_xlabel(xlabel(comp, term), labelpad=xlabelpad, fontsize=xlabelsize)
1430
+ ax0.set_ylabel(ylabel(comp, term, mode), labelpad=ylabelpad, fontsize=ylabelsize)
1431
+ ax0.tick_params(direction='in', width=1.5, pad=7, labelsize=ticksize)
1432
+ for spine in ax0.spines.values():
1433
+ spine.set_linewidth(1.5)
1434
+ ax0.set_box_aspect(1)
1435
+
1436
+ cax = ax0.inset_axes((0, 1.035, 1, 0.06))
1437
+ cbar = fig.colorbar(im, cax=cax, orientation='horizontal')
1438
+ cbar.ax.xaxis.set_ticks_position('top')
1439
+ cbar.ax.xaxis.set_label_position('top')
1440
+ cbar.ax.tick_params(labelsize=12)
1441
+ cbar.outline.set_linewidth(cbaroutlinewidth) # type: ignore[union-attr]
1442
+
1443
+ # ===== Second row: conditional mean + 68% interval =====
1444
+ if len(x) > 0:
1445
+ ax1.scatter(
1446
+ x, ymean, marker='o', facecolors='k', edgecolors='k', s=24, alpha=1.0, zorder=2,
1447
+ label=r'mean ' + ylabel(comp, term, mode)
1448
+ )
1449
+ ax1.fill_between(
1450
+ x, lower, upper, alpha=0.2, color='gray', zorder=1,
1451
+ label=r'$68.3\%$ interval'
1452
+ )
1453
+ ax1.plot(x, lower, color='gray', linewidth=1, linestyle='--', zorder=1)
1454
+ ax1.plot(x, upper, color='gray', linewidth=1, linestyle='--', zorder=1)
1455
+
1456
+ if coeff != 0.0:
1457
+
1458
+ coefficient = {
1459
+ 'vis': r'\nu',
1460
+ 'res': r'\eta',
1461
+ }[term]
1462
+
1463
+ ax1.plot(
1464
+ x, coeff * x, color='r', linestyle='--', linewidth=3, zorder=3,
1465
+ label=rf'${coefficient} = {float2LaTeX(coeff)}$',
1466
+ )
1467
+
1468
+ frame = ax1.legend(
1469
+ loc='lower right', fontsize=16, bbox_to_anchor=(0.995, 0.005),
1470
+ framealpha=1.0, fancybox=True, facecolor="white", edgecolor="k",
1471
+ ).get_frame()
1472
+ frame.set_edgecolor("k")
1473
+ frame.set_linewidth(0.8)
1474
+ frame.set_boxstyle("round", pad=0.15, rounding_size=0.4)
1475
+
1476
+ ax1.set_xlim(xgrid[0], xgrid[-1])
1477
+ ax1.set_ylim(-ymax, ymax)
1478
+ ax1.set_xlabel(xlabel(comp, term), labelpad=xlabelpad, fontsize=xlabelsize)
1479
+ ax1.set_ylabel(ylabel(comp, term, None), labelpad=ylabelpad, fontsize=ylabelsize)
1480
+ ax1.tick_params(direction='in', width=1.5, pad=7, labelsize=ticksize)
1481
+ for spine in ax1.spines.values():
1482
+ spine.set_linewidth(1.5)
1483
+ ax1.set_box_aspect(1)
1484
+
1485
+ plt.savefig(path / f'hist.{mode}.{term}.pdf', bbox_inches='tight')
1486
+ plt.close()
1487
+
1488
+
1489
+ def plot(
1490
+ nd : NumericalDissipation,
1491
+ fraction : float = 1.0,
1492
+ ) -> None:
1493
+ """Plot numerical dissipation slices and histograms
1494
+
1495
+ Parameters
1496
+ ----------
1497
+ nd : NumericalDissipation object
1498
+ xcoverage : fraction of data enclosed by symmetric range for x (lapl)
1499
+ ycoverage : fraction of data enclosed by symmetric range for y (diss)
1500
+ fraction : float in (0, 1], passed to slice colormap scaling (see plotSlices).
1501
+ """
1502
+ print("═════════ Result Visualization ═════════\n")
1503
+ print(f"Plotting dissipation term slices ...")
1504
+ plotSlices(nd, fraction=fraction)
1505
+
1506
+ print(f"Plotting histograms of numerical dissipation ...")
1507
+ plotHistogram(nd)
1508
+
1509
+ print("Plotting dissipation spectra ...")
1510
+ path = Path("spectra") / "spectra.pkl"
1511
+ if not path.is_file():
1512
+ raise FileNotFoundError(f"Energy spectra cache not found: {path}")
1513
+
1514
+ with path.open("rb") as f:
1515
+ spc = pickle.load(f)
1516
+
1517
+ plotSpectra(nd, spc)
1518
+
1519
+ print(f"All plots completed! Numerical dissipation analysis done.")