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/__init__.py +31 -0
- pymhd/derivatives/TENO.py +278 -0
- pymhd/derivatives/WENO.py +323 -0
- pymhd/derivatives/__init__.py +24 -0
- pymhd/derivatives/compact.py +365 -0
- pymhd/derivatives/derivative.py +926 -0
- pymhd/numdiss.py +598 -0
- pymhd/plot/__init__.py +48 -0
- pymhd/plot/nd.py +1519 -0
- pymhd/plot/slc.py +648 -0
- pymhd/plot/spc.py +249 -0
- pymhd/preprocess/Athena.py +847 -0
- pymhd/preprocess/__init__.py +69 -0
- pymhd/preprocess/helper/NOTICE +42 -0
- pymhd/preprocess/helper/bin_convert.py +2000 -0
- pymhd/preprocess/helper/make_athdf.py +45 -0
- pymhd/spectra.py +376 -0
- pymhd/turbulence.py +917 -0
- pymhd-0.1.0.dist-info/METADATA +100 -0
- pymhd-0.1.0.dist-info/RECORD +22 -0
- pymhd-0.1.0.dist-info/WHEEL +4 -0
- pymhd-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|