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,847 @@
1
+ # PyMHD: Python for Magnetohydrodynamic Turbulence.
2
+ # Copyright (c) 2026 Yuyang Hua (华宇阳)
3
+ # License: MIT
4
+
5
+ """
6
+ pymhd/preprocess/Athena.py
7
+ --------------------------
8
+
9
+ Extract data from Athena++/K/PK output files and build Turbulence object
10
+ - Supports MRI-driven and forced turbulence simulations
11
+ - Supports Athena++ (.athdf), AthenaK (.bin), and AthenaPK (.phdf) output files
12
+ - Supports isothermal and adiabatic EoS
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib.util
18
+
19
+ import time as timer
20
+
21
+ from pathlib import Path, PurePath
22
+ from typing import Any, Sequence
23
+
24
+ from .. import ScalarField, VectorField, Turbulence
25
+
26
+ import yt
27
+ yt.set_log_level('ERROR')
28
+
29
+ # Set print function to flush=True
30
+ from functools import partial
31
+ print = partial(print, flush=True)
32
+
33
+ # ==================================================================================================
34
+ # Input file parsing
35
+ # athinput.* for Athena++, *.athinput for AthenaK, *.in for AthenaPK
36
+ # ==================================================================================================
37
+
38
+ def resolveinputfile(inputfile: str | Path | None) -> Path:
39
+ """Resolve athinput file path from explicit path or glob pattern.
40
+
41
+ Parameters
42
+ ----------
43
+ inputfile : str, Path, or None
44
+ Input file path or glob pattern, e.g. 'athinput.hgb'.
45
+ If None, automatically detect default Athena(++/K/PK) input files.
46
+
47
+ Returns
48
+ -------
49
+ Path : resolved input file path
50
+
51
+ Raises
52
+ ------
53
+ FileNotFoundError : if no valid input file is found
54
+ """
55
+ inputs = [
56
+ "athinput.hgb",
57
+ "athinput.turb",
58
+ "turb.athinput",
59
+ "mri3d_unstratified.athinput",
60
+ "turbulence.in",
61
+ ]
62
+
63
+ if inputfile is None:
64
+ matched = [path for name in inputs for path in sorted(Path.cwd().glob(name))]
65
+ if len(matched) != 1:
66
+ raise FileNotFoundError(
67
+ "Cannot uniquely determine input file from known candidates: "
68
+ f"{inputs}. Found {len(matched)} match(es)."
69
+ )
70
+ candidates = matched
71
+ else:
72
+ pattern = str(inputfile)
73
+ if any(c in pattern for c in '*?['):
74
+ if pattern.startswith('./'):
75
+ pattern = pattern[2:]
76
+ candidates = sorted(Path.cwd().glob(pattern))
77
+ else:
78
+ candidates = [Path(inputfile)]
79
+
80
+ if not candidates:
81
+ raise FileNotFoundError(f"Cannot find input file matching: {inputfile}")
82
+
83
+ return candidates[0]
84
+
85
+ def parameter(
86
+ inputfile: Path,
87
+ blockname: str,
88
+ params : Sequence[str],
89
+ defaults : Sequence[Any] | None = None
90
+ ) -> list[Any]:
91
+ """Extract parameter(s) from a certain block in an athinput file
92
+
93
+ Parameters
94
+ ----------
95
+ inputfile : Path, path to the input parameter file
96
+ blockname : str, block name, e.g., "<orbital_advection>"
97
+ params : Sequence[str], parameter names, e.g., ["Omega0", "qshear"]
98
+ defaults : Sequence[Any] | None, default values for params not found
99
+
100
+ Returns
101
+ -------
102
+ values : list[Any], parameter values in the same order as params
103
+
104
+ Raises
105
+ ------
106
+ ValueError : if parameter not found and no default provided
107
+ FileNotFoundError : if 'inputfile' does not exist
108
+ """
109
+ if not params:
110
+ raise ValueError("At least one parameter should be specified")
111
+
112
+ if defaults is not None and len(defaults) != len(params):
113
+ raise ValueError("Number of defaults should match number of params")
114
+
115
+ if not inputfile.exists():
116
+ raise FileNotFoundError(f"Input file not found: {inputfile}")
117
+
118
+ lines = inputfile.read_text().splitlines()
119
+
120
+ # Initialize results dict with defaults
121
+ results: dict[str, Any] = {}
122
+ if defaults is not None:
123
+ results = {p: d for p, d in zip(params, defaults)}
124
+
125
+ # Find the blockname section and extract parameters
126
+ inblock = False
127
+ for line in lines:
128
+ # Check if entering target block
129
+ if line.strip() == blockname:
130
+ inblock = True
131
+ continue
132
+ # Check if leaving current block (entering another block)
133
+ if inblock and line.strip().startswith('<') and line.strip().endswith('>'):
134
+ break
135
+ # Look for parameters in current block
136
+ if inblock and '=' in line:
137
+ lhs, rhs = line.split('=', maxsplit=1)
138
+ key = lhs.strip()
139
+ if key in params:
140
+ value = rhs.split('#', maxsplit=1)[0].strip()
141
+ try:
142
+ results[key] = float(value)
143
+ except ValueError:
144
+ results[key] = value
145
+
146
+ # Check for missing parameters
147
+ for p in params:
148
+ if p not in results:
149
+ raise ValueError(f"Parameter '{p}' not found in {blockname} block of {inputfile}")
150
+
151
+ return [results[p] for p in params]
152
+
153
+
154
+ def hasblock(inputfile: Path, blockname: str) -> bool:
155
+ """Check whether an athinput file contains a given block."""
156
+ if not inputfile.exists():
157
+ raise FileNotFoundError(f"Input file not found: {inputfile}")
158
+
159
+ lines = inputfile.read_text().splitlines()
160
+ return any(line.strip() == blockname for line in lines)
161
+
162
+
163
+ def AthenaPPinput(inputfile: str | Path) -> dict[str, Any]:
164
+ """Extract parameters from Athena++ input file (athinput.*)
165
+
166
+ Currently supports:
167
+ 1. Athena++ shearing box simulation: athinput.hgb (type = "MRI")
168
+ 2. Athena++ hydrodynamic turbulence simulation: athinput.turb (type = "hydro")
169
+
170
+ Parameters
171
+ ----------
172
+ inputfile : str or Path, path to athinput.*
173
+
174
+ Returns
175
+ -------
176
+ params : dict, simulation parameters
177
+ """
178
+ inputfile = Path(inputfile)
179
+
180
+ x1min, x1max, x2min, x2max, x3min, x3max = parameter(
181
+ inputfile, "<mesh>", ["x1min", "x1max", "x2min", "x2max", "x3min", "x3max"]
182
+ )
183
+ box = (x1max - x1min, x2max - x2min, x3max - x3min)
184
+ Nx, Ny, Nz = parameter(inputfile, "<mesh>", ["nx1", "nx2", "nx3"])
185
+ Omega, q = parameter(inputfile, "<orbital_advection>", ["Omega0", "qshear"], [0.0, 0.0])
186
+ Cs, = parameter(inputfile, "<hydro>", ["iso_sound_speed"], [0.0])
187
+ gamma, = parameter(inputfile, "<hydro>", ["gamma"], [0.0])
188
+ nu, eta = parameter(inputfile, "<problem>", ["nu_iso", "eta_ohm"], [0.0, 0.0])
189
+
190
+ if Cs != 0.0 and gamma != 0.0:
191
+ raise ValueError(
192
+ f"Ambiguous EoS in {inputfile}: "
193
+ "both 'iso_sound_speed' and 'gamma' are defined in <hydro> block."
194
+ )
195
+ if Cs == 0.0 and gamma == 0.0:
196
+ raise ValueError(
197
+ f"Cannot determine EoS in {inputfile}: "
198
+ "neither 'iso_sound_speed' nor 'gamma' is defined in <hydro> block."
199
+ )
200
+
201
+ EoS = {
202
+ True : "isothermal",
203
+ False: "adiabatic",
204
+ }[Cs != 0.0]
205
+
206
+ params = {
207
+ "box" : box,
208
+ "Nx" : int(Nx),
209
+ "Ny" : int(Ny),
210
+ "Nz" : int(Nz),
211
+ "Omega" : Omega,
212
+ "q" : q,
213
+ "Cs" : Cs,
214
+ "gamma" : gamma,
215
+ "nu" : nu,
216
+ "eta" : eta,
217
+ "type" : "MRI" if (Omega != 0 and q != 0) else "hydro",
218
+ "solver": "FVM",
219
+ "EoS" : EoS,
220
+ }
221
+
222
+ print(f"Code : Athena++")
223
+ print(f"Turbulence type : {params['type']}")
224
+ print(f"Grid resolution : {Nx} * {Ny} * {Nz}")
225
+ print(f"Box dimensions : {box[0]} * {box[1]} * {box[2]}")
226
+ print(f"Viscosity : nu = {nu}")
227
+ print(f"Resistivity : eta = {eta}")
228
+ if EoS == "isothermal":
229
+ print(f"Sound speed : Cs = {Cs}")
230
+ else:
231
+ print(f"Adiabatic index : gamma = {gamma}")
232
+
233
+ if Omega != 0 or q != 0:
234
+ print(f"Angular velocity: Omega = {Omega}")
235
+ print(f"Shearing rate : q = {q}")
236
+
237
+ return params
238
+
239
+
240
+ def AthenaKinput(inputfile: str | Path) -> dict[str, Any]:
241
+ """Extract parameters from AthenaK input file (*.athinput).
242
+
243
+ Currently supports:
244
+ 1. AthenaK shearing box simulation: mri3d_unstratified.athinput (type = "MRI")
245
+ 2. AthenaK MHD turbulence simulation: turb.athinput (type = "SSD" or "Bz")
246
+ 3. AthenaK hydrodynamic turbulence simulation: turb.athinput (type = "hydro")
247
+
248
+ Parameters
249
+ ----------
250
+ inputfile : str or Path, path to *.athinput
251
+
252
+ Returns
253
+ -------
254
+ params : dict, simulation parameters
255
+ """
256
+ inputfile = Path(inputfile)
257
+
258
+ x1min, x1max, x2min, x2max, x3min, x3max = parameter(
259
+ inputfile, "<mesh>", ["x1min", "x1max", "x2min", "x2max", "x3min", "x3max"]
260
+ )
261
+ Nx, Ny, Nz = parameter(inputfile, "<mesh>", ["nx1", "nx2", "nx3"])
262
+ Omega, q = parameter(inputfile, "<shearing_box>", ["omega0", "qshear"], [0.0, 0.0])
263
+ ifield, = parameter(inputfile, "<problem>", ["ifield"], [1])
264
+
265
+ hydro = hasblock(inputfile, "<hydro>")
266
+ mhd = hasblock(inputfile, "<mhd>")
267
+ if hydro == mhd:
268
+ raise ValueError(
269
+ f"Conflicting AthenaK blocks in {inputfile}: "
270
+ "exactly one of <hydro> or <mhd> must be present."
271
+ )
272
+
273
+ block = "<hydro>" if hydro else "<mhd>"
274
+
275
+ Cs, = parameter(inputfile, block, ["iso_sound_speed"], [0.0])
276
+ gamma, = parameter(inputfile, block, ["gamma"], [0.0])
277
+ nu, eta = parameter(inputfile, block, ["viscosity", "ohmic_resistivity"], [0.0, 0.0])
278
+
279
+ eos, = parameter(inputfile, block, ["eos"], ["isothermal"])
280
+ eos = str(eos).lower()
281
+
282
+ if eos == "isothermal":
283
+ EoS = "isothermal"
284
+ if Cs == 0.0:
285
+ raise ValueError(
286
+ f"Invalid isothermal EoS in {inputfile}: "
287
+ f"A non-zero 'iso_sound_speed' must be provided in {block} block."
288
+ )
289
+ elif eos in ["ideal", "adiabatic"]:
290
+ EoS = "adiabatic"
291
+ if gamma == 0.0:
292
+ raise ValueError(
293
+ f"Invalid adiabatic EoS in {inputfile}: "
294
+ f"A non-zero 'gamma' must be provided in {block} block."
295
+ )
296
+ else:
297
+ raise ValueError(
298
+ f"Unsupported AthenaK EoS '{eos}' in {inputfile}. "
299
+ "Available options: isothermal, ideal."
300
+ )
301
+
302
+ if Omega != 0 and q != 0:
303
+ type = "MRI"
304
+ elif hydro:
305
+ type = "hydro"
306
+ elif mhd:
307
+ type = {1: "SSD", 2: "Bz"}[int(ifield)]
308
+ else:
309
+ raise ValueError(f"Unsupported AthenaK turbulence type in {inputfile}. ")
310
+
311
+ params = {
312
+ "box" : (x1max - x1min, x2max - x2min, x3max - x3min),
313
+ "Nx" : int(Nx),
314
+ "Ny" : int(Ny),
315
+ "Nz" : int(Nz),
316
+ "Omega" : Omega,
317
+ "q" : q,
318
+ "Cs" : Cs,
319
+ "gamma" : gamma,
320
+ "nu" : nu,
321
+ "eta" : eta,
322
+ "type" : type,
323
+ "solver": "FVM",
324
+ "EoS" : EoS,
325
+ }
326
+
327
+ box = params["box"]
328
+ print(f"Code : AthenaK")
329
+ print(f"Turbulence type : {params['type']}")
330
+ print(f"Grid resolution : {params['Nx']} * {params['Ny']} * {params['Nz']}")
331
+ print(f"Box dimensions : {box[0]} * {box[1]} * {box[2]}")
332
+ print(f"Viscosity : nu = {params['nu']}")
333
+ print(f"Resistivity : eta = {params['eta']}")
334
+
335
+ if EoS == "isothermal":
336
+ print(f"Sound speed : Cs = {params['Cs']}")
337
+ else:
338
+ print(f"Adiabatic index : gamma = {params['gamma']}")
339
+
340
+ if params["Omega"] != 0 or params["q"] != 0:
341
+ print(f"Angular velocity: Omega = {params['Omega']}")
342
+ print(f"Shearing rate : q = {params['q']}")
343
+
344
+ return params
345
+
346
+
347
+ def AthenaPKinput(inputfile: str | Path) -> dict[str, Any]:
348
+ """Extract parameters from an AthenaPK input file (e.g. turbulence.in).
349
+
350
+ Currently supports:
351
+ 1. AthenaPK hydrodynamic turbulence simulation: turbulence.in (type = "hydro")
352
+ 2. AthenaPK MHD turbulence simulation: turb.in (type = "SSD" or "Bx")
353
+
354
+ Parameters
355
+ ----------
356
+ inputfile : str or Path, path to *.in
357
+
358
+ Returns
359
+ -------
360
+ params : dict, simulation parameters
361
+
362
+ Raises
363
+ ------
364
+ ValueError
365
+ """
366
+ inputfile = Path(inputfile)
367
+
368
+ x1min, x1max, x2min, x2max, x3min, x3max = parameter(
369
+ inputfile,
370
+ "<parthenon/mesh>",
371
+ ["x1min", "x1max", "x2min", "x2max", "x3min", "x3max"],
372
+ )
373
+ Nx, Ny, Nz = parameter(inputfile, "<parthenon/mesh>", ["nx1", "nx2", "nx3"])
374
+ fluid, gamma = parameter(inputfile, "<hydro>", ["fluid", "gamma"], ['glmmhd', 0.0])
375
+ b_config, = parameter(inputfile, "<problem/turbulence>", ["b_config"], [1])
376
+
377
+ if hasblock(inputfile, "<diffusion>"):
378
+ nu, eta = parameter(
379
+ inputfile,
380
+ "<diffusion>",
381
+ ["mom_diff_coeff_code", "ohm_diff_coeff_code"],
382
+ [0.0, 0.0],
383
+ )
384
+ else:
385
+ nu, eta = 0.0, 0.0
386
+
387
+ type = {
388
+ "euler" : "hydro",
389
+ "glmmhd": {0: "Bx", 1: "SSD", 2: "SSD"}[int(b_config)],
390
+ }[fluid]
391
+
392
+ if gamma == 0.0:
393
+ raise ValueError(f"Missing 'gamma' in {inputfile}: adiabatic EoS requires a non-zero 'gamma'.")
394
+
395
+ params = {
396
+ "box" : (x1max - x1min, x2max - x2min, x3max - x3min),
397
+ "Nx" : int(Nx),
398
+ "Ny" : int(Ny),
399
+ "Nz" : int(Nz),
400
+ "Omega" : 0.0,
401
+ "q" : 0.0,
402
+ "Cs" : 0.0,
403
+ "gamma" : gamma,
404
+ "nu" : nu,
405
+ "eta" : eta,
406
+ "type" : type,
407
+ "solver": "FVM",
408
+ "EoS" : "adiabatic",
409
+ }
410
+
411
+ box = params["box"]
412
+ print(f"Code : AthenaPK")
413
+ print(f"Turbulence type : {params['type']}")
414
+ print(f"Grid resolution : {params['Nx']} * {params['Ny']} * {params['Nz']}")
415
+ print(f"Box dimensions : {box[0]} * {box[1]} * {box[2]}")
416
+ print(f"Viscosity : nu = {params['nu']}")
417
+ print(f"Resistivity : eta = {params['eta']}")
418
+ print(f"Adiabatic index : gamma = {params['gamma']}")
419
+
420
+ return params
421
+
422
+
423
+ def input2parameters(inputfile: str | Path) -> dict[str, Any]:
424
+ """Extract simulation parameters from an input file
425
+
426
+ Dispatches to the appropriate parser based on the input file name.
427
+
428
+ Parameters
429
+ ----------
430
+ inputfile : str or Path, path to the input parameter file
431
+
432
+ Returns
433
+ -------
434
+ params : dict, extracted parameters with standardized keys:
435
+ - box : tuple[float, float, float], box dimensions (Lx, Ly, Lz)
436
+ - Nx, Ny, Nz : int, grid resolution
437
+ - Omega : float, angular velocity
438
+ - q : float, shear parameter
439
+ - Cs : float, isothermal sound speed (for isothermal EoS)
440
+ - gamma : float, adiabatic index (for adiabatic EoS)
441
+ - nu : float, viscosity
442
+ - eta : float, resistivity
443
+ - type : str, turbulence type ("SSD", "Bx", "Bz", "MRI", or "hydro")
444
+ - solver : str, numerical solver ("FVM", "FDM", "SPECTRAL")
445
+ - EoS : str, equation of state ("isothermal", "adiabatic", "incompressible")
446
+
447
+ Raises
448
+ ------
449
+ ValueError : if input file name is not recognized
450
+ """
451
+ inputfile = Path(inputfile)
452
+ filename = inputfile.name
453
+
454
+ if PurePath(filename).match("athinput.*"):
455
+ return AthenaPPinput(inputfile)
456
+
457
+ if PurePath(filename).match("*.athinput"):
458
+ return AthenaKinput(inputfile)
459
+
460
+ if PurePath(filename).match("*.in") and hasblock(inputfile, "<parthenon/mesh>"):
461
+ return AthenaPKinput(inputfile)
462
+
463
+ raise ValueError(
464
+ f"Unrecognized input file: '{filename}'. "
465
+ "Supported: athinput.* (Athena++), *.athinput (AthenaK), or *.in (AthenaPK)."
466
+ )
467
+
468
+
469
+ # ==================================================================================================
470
+ # Output data extraction
471
+ # .athdf for Athena++, .bin for AthenaK, .phdf for AthenaPK
472
+ # For AthenaK, .bin files need to be converted to .athdf files first using
473
+ # helper/bin_convert.py, make_athdf.py
474
+ # ==================================================================================================
475
+
476
+ def resolveoutputs(outputs: str | Path | Sequence[str | Path]) -> list[Path]:
477
+ """Resolve output file paths from a list or glob pattern
478
+
479
+ Parameters
480
+ ----------
481
+ outputs : str, Path, or list of str/Path
482
+ If str containing glob characters (*, ?, [), treated as a glob pattern.
483
+ Otherwise treated as explicit file path(s).
484
+
485
+ Returns
486
+ -------
487
+ list[Path] : sorted list of resolved output file paths
488
+
489
+ Raises
490
+ ------
491
+ FileNotFoundError : if no output files match the pattern
492
+ """
493
+ if isinstance(outputs, (str, Path)):
494
+ pattern = str(outputs)
495
+ if any(c in pattern for c in '*?['):
496
+ # Glob pattern: strip leading ./ if present
497
+ if pattern.startswith('./'):
498
+ pattern = pattern[2:]
499
+ resolved = sorted(Path.cwd().glob(pattern))
500
+ else:
501
+ filepath = Path(pattern)
502
+ if not filepath.exists():
503
+ raise FileNotFoundError(f"Output file not found: {filepath}")
504
+ resolved = [filepath]
505
+ else:
506
+ # List of file paths
507
+ resolved = sorted(Path(output) for output in outputs)
508
+
509
+ if not resolved:
510
+ raise FileNotFoundError(f"No output files found matching: {outputs}")
511
+
512
+ return resolved
513
+
514
+
515
+ def bin2athdf(binfiles: Sequence[Path]) -> list[Path]:
516
+ """Convert AthenaK .bin files to Athena++ .athdf files.
517
+
518
+ Uses the bin_convert.py module from AthenaK.
519
+
520
+ Parameters
521
+ ----------
522
+ binfiles : Sequence[Path], paths to AthenaK .bin files
523
+
524
+ Returns
525
+ -------
526
+ list[Path] : converted Athena++ .athdf paths
527
+ """
528
+ if not binfiles:
529
+ raise FileNotFoundError("No .bin files provided for conversion.")
530
+
531
+ path = Path(__file__).resolve().parent / "helper" / "bin_convert.py"
532
+ spec = importlib.util.spec_from_file_location("athenak_bin_convert", path)
533
+ if spec is None or spec.loader is None:
534
+ raise ImportError(f"Cannot load bin_convert module: {path}")
535
+
536
+ bin_convert = importlib.util.module_from_spec(spec)
537
+ spec.loader.exec_module(bin_convert)
538
+
539
+ converted: list[Path] = []
540
+ for binfile in sorted(binfiles):
541
+ if binfile.suffix != ".bin":
542
+ raise ValueError(f"Unsupported file suffix for conversion: {binfile}")
543
+
544
+ anchor = None
545
+ for parent in binfile.parents:
546
+ if parent.name == "bin":
547
+ anchor = parent.parent
548
+ break
549
+ if anchor is None:
550
+ raise ValueError(f"Cannot locate '<root>/bin' anchor for file: {binfile}")
551
+
552
+ athdf = anchor / "athdf"
553
+ athdf.mkdir(parents=True, exist_ok=True)
554
+ file = athdf / f"{binfile.stem}.athdf"
555
+
556
+ # AthenaK parallel outputs are usually split into rank_* directories.
557
+ # Read from rank_00000000 and gather all ranks in one .athdf output.
558
+ if binfile.parent.name.startswith("rank_"):
559
+ if binfile.parent.name != "rank_00000000":
560
+ continue
561
+ filedata = bin_convert.read_all_ranks_binary(str(binfile))
562
+ else:
563
+ filedata = bin_convert.read_binary(str(binfile))
564
+
565
+ bin_convert.write_athdf(str(file), filedata)
566
+ converted.append(file)
567
+
568
+ if not converted:
569
+ raise FileNotFoundError(
570
+ "No convertible .bin files found. "
571
+ "For rank-split outputs, make sure rank_00000000 files are included."
572
+ )
573
+
574
+ return converted
575
+
576
+
577
+ def output2turbulence(
578
+ outputs : str | Path | Sequence[str | Path] | None = None,
579
+ outn : str | None = None,
580
+ inputfile: str | Path | None = None,
581
+ t1 : float | None = None,
582
+ t2 : float | None = None,
583
+ casename : str | None = None,
584
+ ) -> Turbulence:
585
+ """Extract data from Athena output files and build a Turbulence object
586
+
587
+ Parameters
588
+ ----------
589
+ outputs : str, Path, Sequence[str | Path], or None; output file paths or glob pattern string
590
+ e.g. './outputs/*.prim.*.athdf' (Athena++) or './outputs/*.prim.*.phdf' (AthenaPK)
591
+ If None, a default pattern inferred from input filename is adopted.
592
+ outn : str | None, output number, defaults to None
593
+ inputfile : str or Path; path or glob pattern of input parameter file, e.g. 'athinput.hgb'
594
+ If None, automatically detect default Athena(++/K/PK) input files.
595
+ t1 : float | None, start time for filtering, defaults to None (no lower limit)
596
+ t2 : float | None, end time for filtering, defaults to None (no upper limit)
597
+ casename : str | None, case name, defaults to None (use parent directory name)
598
+
599
+ Returns
600
+ -------
601
+ Turbulence: Turbulence object containing extracted field data
602
+
603
+ Raises
604
+ ------
605
+ FileNotFoundError: if no matching files found
606
+ ValueError : if no valid data found in the specified time range
607
+ """
608
+ start_time = timer.time()
609
+ inputfile = resolveinputfile(inputfile)
610
+
611
+ print("")
612
+ print("┌──────────────────────────────────────┐")
613
+ print("│ │")
614
+ print("│ PyMHD: Data Preprocessing │")
615
+ print("│ │")
616
+ print("└──────────────────────────────────────┘")
617
+
618
+ print("\n═════════ Parameter Extraction ═════════\n")
619
+
620
+ params = input2parameters(inputfile)
621
+ box = params["box"]
622
+
623
+ print("\n════════════ Data Extraction ═══════════\n")
624
+
625
+ if outputs is not None and outn is not None:
626
+ raise ValueError("'outputs' and 'outn' cannot be specified at the same time.")
627
+
628
+ defaultoutputs = {
629
+ "athinput.hgb" : "./*/*.prim.*.athdf",
630
+ "athinput.turb" : "./*/*.prim.*.athdf",
631
+ "turb.athinput" : "./*/bin/*.prim.*.bin",
632
+ "mri3d_unstratified.athinput" : "./*/bin/*.prim.*.bin",
633
+ "turbulence.in" : "./*/parthenon.prim.*.phdf",
634
+ }
635
+
636
+ outn2outputs = {
637
+ "athinput.hgb" : f"./*/*.{outn}.*.athdf",
638
+ "athinput.turb" : f"./*/*.{outn}.*.athdf",
639
+ "turb.athinput" : f"./*/bin/*.{outn}.*.bin",
640
+ "mri3d_unstratified.athinput" : f"./*/bin/*.{outn}.*.bin",
641
+ "turbulence.in" : f"./*/parthenon.{outn}.*.phdf",
642
+ }
643
+
644
+ if outputs is None:
645
+ if inputfile.name not in outn2outputs:
646
+ raise ValueError(f"Cannot infer outputs for input file: {inputfile.name}")
647
+
648
+ if outn is None:
649
+ outputs = defaultoutputs[inputfile.name]
650
+ else:
651
+ outputs = outn2outputs[inputfile.name]
652
+
653
+ outputfiles = resolveoutputs(outputs)
654
+
655
+ # Validate suffixes and convert all .bin files to .athdf when present.
656
+ allowed = {".bin", ".athdf", ".phdf"}
657
+ suffixes = {path.suffix.lower() for path in outputfiles}
658
+ unsupported = sorted(suffixes - allowed)
659
+ if unsupported:
660
+ raise ValueError(f"Unsupported file(s): {', '.join(unsupported)}. Supported: .athdf, .bin, .phdf")
661
+
662
+ binfiles = [path for path in outputfiles if path.suffix.lower() == ".bin"]
663
+ athdffiles = [path for path in outputfiles if path.suffix.lower() == ".athdf"]
664
+ phdffiles = [path for path in outputfiles if path.suffix.lower() == ".phdf"]
665
+
666
+ if binfiles:
667
+ print("AthenaK .bin file(s) detected, converting to .athdf under outputs/athdf/ ...")
668
+ converted = bin2athdf(binfiles)
669
+ outputfiles = sorted({*athdffiles, *phdffiles, *converted})
670
+ else:
671
+ outputfiles = sorted({*athdffiles, *phdffiles})
672
+
673
+ # Extract field data from .athdf files using yt
674
+ rhos : list[ScalarField] = []
675
+ ps : list[ScalarField | None] = []
676
+ Vs : list[VectorField] = []
677
+ Bs : list[VectorField | None] = []
678
+ accs : list[VectorField | None] = []
679
+ times: list[float] = []
680
+
681
+ def getFields(cg, ds, names: Sequence[str], strict: bool = False):
682
+ """Read the first existing Athena(++/K/PK) field from candidate names
683
+
684
+ yt field types: 'athena_pp' (Athena++/K), 'parthenon' (AthenaPK).
685
+ When strict is False, return None if no candidate exists.
686
+ """
687
+ for name in names:
688
+ for ftype in ("athena_pp", "parthenon"):
689
+ key = (ftype, name)
690
+ if key in ds.field_list:
691
+ return cg[key].value
692
+ if strict:
693
+ raise KeyError(f"Cannot find 'athena_pp' or 'parthenon' field in candidates: {names}")
694
+
695
+ return None
696
+
697
+ # Step 1: collect all files and times in [t1, t2]
698
+ filetime: list[tuple[Path, float]] = []
699
+ for file in outputfiles:
700
+ try:
701
+ ds = yt.load(file)
702
+ time = float(ds.current_time)
703
+ if (t1 is None or t1 <= time) and (t2 is None or time <= t2):
704
+ filetime.append((file, time))
705
+ except Exception as e:
706
+ print(f"Warning: Error reading file {file}: {e}")
707
+ continue
708
+
709
+ if not filetime:
710
+ T1 = str(t1) if t1 is not None else '0'
711
+ T2 = str(t2) if t2 is not None else '∞'
712
+ raise ValueError(f"No valid data found in time range [{T1}, {T2}]")
713
+
714
+ # De-duplicate and sort times, where set ({... }) is the set of times
715
+ times = sorted(set({time for _, time in filetime}))
716
+
717
+ # Build a map: time -> all files at this time (preserve original file order)
718
+ time2files: dict[float, list[Path]] = {time: [] for time in times}
719
+ for file, time in filetime:
720
+ time2files[time].append(file)
721
+
722
+ # Step 2: for each unique time, try extracting all target fields from all files at this time
723
+ for time in times:
724
+ rho: ScalarField | None = None
725
+ p : ScalarField | None = None
726
+ V : VectorField | None = None
727
+ B : VectorField | None = None
728
+ acc: VectorField | None = None
729
+ requireB = params["type"] != "hydro"
730
+
731
+ for file in time2files[time]:
732
+ try:
733
+ ds = yt.load(file)
734
+ cg = ds.covering_grid(
735
+ level=0,
736
+ left_edge=ds.domain_left_edge,
737
+ dims=ds.domain_dimensions
738
+ )
739
+
740
+ if rho is None or V is None:
741
+ rhodata = getFields(cg, ds, ["rho", "dens", "prim_density"])
742
+ Vx = getFields(cg, ds, ["vel1", "velx", "prim_velocity_1"])
743
+ Vy = getFields(cg, ds, ["vel2", "vely", "prim_velocity_2"])
744
+ Vz = getFields(cg, ds, ["vel3", "velz", "prim_velocity_3"])
745
+
746
+ if all(field is not None for field in (rhodata, Vx, Vy, Vz)):
747
+ assert rhodata is not None
748
+ assert Vx is not None and Vy is not None and Vz is not None
749
+ rho = ScalarField(rhodata, box)
750
+ V = VectorField(Vx, Vy, Vz, box)
751
+
752
+ if requireB and B is None:
753
+ Bx = getFields(cg, ds, ["Bcc1", "bcc1", "prim_magnetic_field_1"])
754
+ By = getFields(cg, ds, ["Bcc2", "bcc2", "prim_magnetic_field_2"])
755
+ Bz = getFields(cg, ds, ["Bcc3", "bcc3", "prim_magnetic_field_3"])
756
+ if all(field is not None for field in (Bx, By, Bz)):
757
+ assert Bx is not None and By is not None and Bz is not None
758
+ B = VectorField(Bx, By, Bz, box)
759
+
760
+ if params["EoS"] == "adiabatic" and p is None:
761
+ pdata = getFields(cg, ds, ["press", "p", "prim_pressure"])
762
+ if pdata is not None:
763
+ p = ScalarField(pdata, box)
764
+ else:
765
+ # AthenaK (non-GR) stores internal energy density "eint" for "mhd_w_bcc" outputs
766
+ # instead of pressure; convert via P = (gamma-1) * eint
767
+ eintdata = getFields(cg, ds, ["eint"])
768
+ if eintdata is not None:
769
+ assert params["gamma"] is not None
770
+ p = ScalarField(eintdata * (params["gamma"] - 1.0), box)
771
+
772
+ if params["type"] != "MRI" and acc is None:
773
+ accx = getFields(cg, ds, ["force1", "acc1", "accx", "acc_0"])
774
+ accy = getFields(cg, ds, ["force2", "acc2", "accy", "acc_1"])
775
+ accz = getFields(cg, ds, ["force3", "acc3", "accz", "acc_2"])
776
+ if all(field is not None for field in (accx, accy, accz)):
777
+ assert accx is not None and accy is not None and accz is not None
778
+ acc = VectorField(accx, accy, accz, box)
779
+
780
+ # Check if all required fields are present
781
+ if (
782
+ rho is not None and V is not None and (not requireB or B is not None)
783
+ and (params["EoS"] != "adiabatic" or p is not None)
784
+ ):
785
+ break
786
+
787
+ except Exception as e:
788
+ print(f"Error reading file {file}: {e}")
789
+ continue
790
+
791
+ # Required fields at each time: rho/V, B only for MHD, p for adiabatic EoS
792
+ if rho is None or V is None or (requireB and B is None):
793
+ raise ValueError(
794
+ f"Missing required fields (rho, V, or B) at time={time}. "
795
+ f"Files at this time: {[str(path) for path in time2files[time]]}"
796
+ )
797
+ if params["EoS"] == "adiabatic" and p is None:
798
+ raise ValueError(
799
+ f"Missing required pressure field at time={time} for adiabatic EoS. "
800
+ f"Files at this time: {[str(path) for path in time2files[time]]}"
801
+ )
802
+
803
+ rhos.append(rho)
804
+ Vs.append(V)
805
+ Bs.append(B)
806
+ accs.append(acc)
807
+
808
+ if params["EoS"] == "adiabatic":
809
+ ps.append(p)
810
+
811
+ if not times:
812
+ T1 = str(t1) if t1 is not None else '0'
813
+ T2 = str(t2) if t2 is not None else '∞'
814
+ raise ValueError(f"No valid data found in time range [{T1}, {T2}]")
815
+
816
+ print(
817
+ f"{len(filetime)} .athdf/.phdf file(s) found in range, "
818
+ f"{len(times)} unique snapshot(s), time range: [{min(times)}, {max(times)}]\n"
819
+ )
820
+
821
+ # Default case name: the parent directory of the input file
822
+ if casename is None:
823
+ casename = inputfile.resolve().parent.name
824
+
825
+ turbulence = Turbulence(
826
+ case = casename,
827
+ type = params["type"],
828
+ solver = params["solver"],
829
+ rhos = rhos,
830
+ ps = ps if params["EoS"] == "adiabatic" else [],
831
+ Vs = Vs,
832
+ Bs = Bs,
833
+ accs = accs,
834
+ times = times,
835
+ Omega = params["Omega"],
836
+ q = params["q"],
837
+ EoS = params["EoS"],
838
+ Cs = params["Cs"] if params["EoS"] == "isothermal" else None,
839
+ gamma = params["gamma"] if params["EoS"] == "adiabatic" else None,
840
+ nu = params["nu"],
841
+ eta = params["eta"]
842
+ )
843
+
844
+ end_time = timer.time()
845
+ print(f"Turbulence object constructed! Total time: {end_time - start_time:.2f} s\n")
846
+
847
+ return turbulence