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
pymhd/turbulence.py
ADDED
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
# PyMHD: Python for Magnetohydrodynamic Turbulence.
|
|
2
|
+
# Copyright (c) 2026 Yuyang Hua (华宇阳)
|
|
3
|
+
# License: MIT
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
pymhd/turbulence.py
|
|
7
|
+
-------------------
|
|
8
|
+
|
|
9
|
+
Implements the basic data containers for PyMHD:
|
|
10
|
+
- ScalarField class: data container for scalar fields, e.g. density, pressure, etc.
|
|
11
|
+
- VectorField class: data container for vector fields, e.g. velocity, magnetic field, etc.
|
|
12
|
+
- Turbulence class: data container for turbulence data from (M)HD simulations.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from typing import Any, Sequence, Literal, TypeGuard, TypeAlias, overload
|
|
20
|
+
|
|
21
|
+
# Simple implementation of Scalar and Vector classes
|
|
22
|
+
Scalar: TypeAlias = float | int
|
|
23
|
+
|
|
24
|
+
class Vector:
|
|
25
|
+
"""Vector class
|
|
26
|
+
|
|
27
|
+
Data container for vectors, e.g. unit vector e_z = Vector(0, 0, 1).
|
|
28
|
+
|
|
29
|
+
Attributes
|
|
30
|
+
----------
|
|
31
|
+
x, y, z (float) : x, y, z components
|
|
32
|
+
"""
|
|
33
|
+
def __init__(self, x: float, y: float, z: float):
|
|
34
|
+
self.x = x
|
|
35
|
+
self.y = y
|
|
36
|
+
self.z = z
|
|
37
|
+
|
|
38
|
+
def __mul__(self, other) -> VectorField:
|
|
39
|
+
"""Multiplication
|
|
40
|
+
|
|
41
|
+
Supported operations
|
|
42
|
+
--------------------
|
|
43
|
+
Vector * ScalarField -> VectorField
|
|
44
|
+
"""
|
|
45
|
+
if isinstance(other, ScalarField):
|
|
46
|
+
return VectorField(
|
|
47
|
+
self.x * other.data,
|
|
48
|
+
self.y * other.data,
|
|
49
|
+
self.z * other.data,
|
|
50
|
+
other.box
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return NotImplemented
|
|
54
|
+
|
|
55
|
+
def __rmul__(self, other) -> VectorField:
|
|
56
|
+
"""Right multiplication
|
|
57
|
+
|
|
58
|
+
Supported operations
|
|
59
|
+
--------------------
|
|
60
|
+
ScalarField * Vector -> VectorField
|
|
61
|
+
"""
|
|
62
|
+
if isinstance(other, ScalarField):
|
|
63
|
+
return VectorField(
|
|
64
|
+
other.data * self.x,
|
|
65
|
+
other.data * self.y,
|
|
66
|
+
other.data * self.z,
|
|
67
|
+
other.box
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return NotImplemented
|
|
71
|
+
|
|
72
|
+
# ========== ScalarField ==========
|
|
73
|
+
|
|
74
|
+
class ScalarField:
|
|
75
|
+
"""ScalarField class
|
|
76
|
+
|
|
77
|
+
Data container for scalar fields, e.g. density, pressure, etc.
|
|
78
|
+
|
|
79
|
+
Attributes
|
|
80
|
+
----------
|
|
81
|
+
data (np.ndarray) : scalar field data
|
|
82
|
+
box (tuple) : box sizes
|
|
83
|
+
Lx, Ly, Lz (float) : box size in x, y, z directions
|
|
84
|
+
Nx, Ny, Nz (int) : resolutions in x, y, z directions
|
|
85
|
+
dx, dy, dz (float) : grid size in x, y, z directions
|
|
86
|
+
dxdydz (float) : volume of a grid cell
|
|
87
|
+
|
|
88
|
+
Operations
|
|
89
|
+
----------
|
|
90
|
+
1. NumPy functions : e.g. np.sqrt(ScalarField) -> ScalarField
|
|
91
|
+
2. Plus / Minus : ScalarField + ScalarField -> ScalarField
|
|
92
|
+
3. Multiplication : ScalarField * ScalarField -> ScalarField
|
|
93
|
+
ScalarField * Scalar -> ScalarField
|
|
94
|
+
Scalar * ScalarField -> ScalarField
|
|
95
|
+
4. Division : ScalarField / ScalarField -> ScalarField
|
|
96
|
+
ScalarField / Scalar -> ScalarField
|
|
97
|
+
Scalar / ScalarField -> ScalarField
|
|
98
|
+
5. Power : ScalarField ** Scalar -> ScalarField
|
|
99
|
+
"""
|
|
100
|
+
def __init__(self, data: np.ndarray, box: tuple[float, float, float]):
|
|
101
|
+
self.data = data
|
|
102
|
+
self.box = box
|
|
103
|
+
self.Lx, self.Ly, self.Lz = box
|
|
104
|
+
|
|
105
|
+
self.Nx, self.Ny, self.Nz = data.shape
|
|
106
|
+
self.dx, self.dy, self.dz = self.Lx/self.Nx, self.Ly/self.Ny, self.Lz/self.Nz
|
|
107
|
+
self.dxdydz = self.dx * self.dy * self.dz
|
|
108
|
+
|
|
109
|
+
def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
|
|
110
|
+
"""Support NumPy functions, e.g. np.sqrt"""
|
|
111
|
+
processed_inputs = []
|
|
112
|
+
for input in inputs:
|
|
113
|
+
if isinstance(input, ScalarField):
|
|
114
|
+
processed_inputs.append(input.data)
|
|
115
|
+
else:
|
|
116
|
+
processed_inputs.append(input)
|
|
117
|
+
|
|
118
|
+
results = getattr(ufunc, method)(*processed_inputs, **kwargs)
|
|
119
|
+
|
|
120
|
+
if isinstance(results, tuple):
|
|
121
|
+
return tuple(
|
|
122
|
+
ScalarField(result, self.box)
|
|
123
|
+
if isinstance(result, np.ndarray) else result
|
|
124
|
+
for result in results
|
|
125
|
+
)
|
|
126
|
+
elif isinstance(results, np.ndarray):
|
|
127
|
+
return ScalarField(results, self.box)
|
|
128
|
+
else:
|
|
129
|
+
return results
|
|
130
|
+
|
|
131
|
+
def __add__(self, other) -> ScalarField:
|
|
132
|
+
"""Addition
|
|
133
|
+
|
|
134
|
+
Supported operations
|
|
135
|
+
--------------------
|
|
136
|
+
ScalarField + ScalarField -> ScalarField
|
|
137
|
+
"""
|
|
138
|
+
if isinstance(other, ScalarField) and assertMatchFields(self, other):
|
|
139
|
+
return ScalarField(self.data + other.data, self.box)
|
|
140
|
+
|
|
141
|
+
return NotImplemented
|
|
142
|
+
|
|
143
|
+
def __neg__(self) -> ScalarField:
|
|
144
|
+
"""Negation
|
|
145
|
+
|
|
146
|
+
Supported operations
|
|
147
|
+
--------------------
|
|
148
|
+
-ScalarField -> ScalarField
|
|
149
|
+
"""
|
|
150
|
+
return ScalarField(-self.data, self.box)
|
|
151
|
+
|
|
152
|
+
def __sub__(self, other) -> ScalarField:
|
|
153
|
+
"""Subtraction
|
|
154
|
+
|
|
155
|
+
Supported operations
|
|
156
|
+
--------------------
|
|
157
|
+
ScalarField - ScalarField -> ScalarField
|
|
158
|
+
"""
|
|
159
|
+
if isinstance(other, ScalarField) and assertMatchFields(self, other):
|
|
160
|
+
return ScalarField(self.data - other.data, self.box)
|
|
161
|
+
|
|
162
|
+
return NotImplemented
|
|
163
|
+
|
|
164
|
+
def __mul__(self, other: Scalar | ScalarField) -> ScalarField:
|
|
165
|
+
"""Left multiplication
|
|
166
|
+
|
|
167
|
+
Supported operations
|
|
168
|
+
--------------------
|
|
169
|
+
ScalarField * Scalar -> ScalarField
|
|
170
|
+
ScalarField * ScalarField -> ScalarField
|
|
171
|
+
"""
|
|
172
|
+
if isinstance(other, Scalar):
|
|
173
|
+
return ScalarField(self.data * other, self.box)
|
|
174
|
+
|
|
175
|
+
if isinstance(other, ScalarField) and assertMatchFields(self, other):
|
|
176
|
+
return ScalarField(self.data * other.data, self.box)
|
|
177
|
+
|
|
178
|
+
# Let VectorField handle ScalarField * VectorField
|
|
179
|
+
return NotImplemented
|
|
180
|
+
|
|
181
|
+
def __rmul__(self, other: Scalar) -> ScalarField:
|
|
182
|
+
"""Right multiplication
|
|
183
|
+
|
|
184
|
+
Supported operations
|
|
185
|
+
--------------------
|
|
186
|
+
Scalar * ScalarField -> ScalarField
|
|
187
|
+
"""
|
|
188
|
+
if isinstance(other, Scalar):
|
|
189
|
+
return ScalarField(other * self.data, self.box)
|
|
190
|
+
|
|
191
|
+
# Let VectorField handle VectorField * ScalarField
|
|
192
|
+
return NotImplemented
|
|
193
|
+
|
|
194
|
+
def __truediv__(self, other: Scalar | ScalarField) -> ScalarField:
|
|
195
|
+
"""Left division
|
|
196
|
+
|
|
197
|
+
Supported operations
|
|
198
|
+
--------------------
|
|
199
|
+
1. ScalarField / Scalar -> ScalarField
|
|
200
|
+
2. ScalarField / ScalarField -> ScalarField
|
|
201
|
+
"""
|
|
202
|
+
if isinstance(other, Scalar):
|
|
203
|
+
return ScalarField(self.data / other, self.box)
|
|
204
|
+
|
|
205
|
+
if isinstance(other, ScalarField) and assertMatchFields(self, other):
|
|
206
|
+
return ScalarField(self.data / other.data, self.box)
|
|
207
|
+
|
|
208
|
+
return NotImplemented
|
|
209
|
+
|
|
210
|
+
def __rtruediv__(self, other: Scalar) -> ScalarField:
|
|
211
|
+
"""Right division
|
|
212
|
+
|
|
213
|
+
Supported operations
|
|
214
|
+
--------------------
|
|
215
|
+
Scalar / ScalarField -> ScalarField
|
|
216
|
+
"""
|
|
217
|
+
if isinstance(other, Scalar):
|
|
218
|
+
return ScalarField(other / self.data, self.box)
|
|
219
|
+
|
|
220
|
+
# Let VectorField handle VectorField / ScalarField
|
|
221
|
+
return NotImplemented
|
|
222
|
+
|
|
223
|
+
def __pow__(self, other: Scalar) -> ScalarField:
|
|
224
|
+
"""Power
|
|
225
|
+
|
|
226
|
+
Supported operations
|
|
227
|
+
--------------------
|
|
228
|
+
ScalarField ** Scalar -> ScalarField
|
|
229
|
+
"""
|
|
230
|
+
if isinstance(other, Scalar):
|
|
231
|
+
return ScalarField(self.data ** other, self.box)
|
|
232
|
+
|
|
233
|
+
return NotImplemented
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def mean(self) -> float:
|
|
237
|
+
"""Volume average of the scalar field."""
|
|
238
|
+
return float(np.mean(self.data))
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def std(self) -> float:
|
|
242
|
+
"""Volume standard deviation of the scalar field."""
|
|
243
|
+
return float(np.std(self.data))
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def total(self) -> float:
|
|
247
|
+
"""Volume integral of the scalar field."""
|
|
248
|
+
return float(np.sum(self.data) * self.dxdydz)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ========== VectorField ==========
|
|
252
|
+
|
|
253
|
+
class VectorField:
|
|
254
|
+
"""VectorField class
|
|
255
|
+
|
|
256
|
+
Data container for vector fields, e.g. velocity, magnetic field, etc.
|
|
257
|
+
|
|
258
|
+
Attributes
|
|
259
|
+
----------
|
|
260
|
+
x, y, z (np.ndarray) : x, y, z components
|
|
261
|
+
box (tuple) : box sizes
|
|
262
|
+
Lx, Ly, Lz (float) : box size in x, y, z directions
|
|
263
|
+
Nx, Ny, Nz (int) : resolutions in x, y, z directions
|
|
264
|
+
dx, dy, dz (float) : grid size in x, y, z directions
|
|
265
|
+
dxdydz (float) : volume of a grid cell
|
|
266
|
+
norm (ScalarField) : magnitude of the vector field
|
|
267
|
+
|
|
268
|
+
Operations
|
|
269
|
+
----------
|
|
270
|
+
1. Plus / Minus : VectorField + VectorField -> VectorField,
|
|
271
|
+
VectorField - VectorField -> VectorField;
|
|
272
|
+
2. Multiplication : VectorField * ScalarField -> VectorField,
|
|
273
|
+
ScalarField * VectorField -> VectorField,
|
|
274
|
+
Scalar * VectorField -> VectorField,
|
|
275
|
+
VectorField * Scalar -> VectorField;
|
|
276
|
+
3. Dot Product : VectorField @ VectorField -> ScalarField;
|
|
277
|
+
4. Cross Product : VectorField ** VectorField -> VectorField;
|
|
278
|
+
5. Division : VectorField / ScalarField -> VectorField,
|
|
279
|
+
VectorField / Scalar -> VectorField.
|
|
280
|
+
"""
|
|
281
|
+
def __init__(
|
|
282
|
+
self,
|
|
283
|
+
Vx : np.ndarray,
|
|
284
|
+
Vy : np.ndarray,
|
|
285
|
+
Vz : np.ndarray,
|
|
286
|
+
box: tuple[float, float, float]
|
|
287
|
+
):
|
|
288
|
+
|
|
289
|
+
if not Vx.shape == Vy.shape == Vz.shape:
|
|
290
|
+
raise ValueError("x, y, z components must have the same shape")
|
|
291
|
+
|
|
292
|
+
self.x = Vx
|
|
293
|
+
self.y = Vy
|
|
294
|
+
self.z = Vz
|
|
295
|
+
self.box = box
|
|
296
|
+
self.Lx, self.Ly, self.Lz = box
|
|
297
|
+
|
|
298
|
+
self.Nx, self.Ny, self.Nz = Vx.shape
|
|
299
|
+
self.dx, self.dy, self.dz = self.Lx/self.Nx, self.Ly/self.Ny, self.Lz/self.Nz
|
|
300
|
+
self.dxdydz = self.dx * self.dy * self.dz
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def norm(self) -> ScalarField:
|
|
304
|
+
return ScalarField(np.sqrt(self.x**2 + self.y**2 + self.z**2), self.box)
|
|
305
|
+
|
|
306
|
+
def __add__(self, other: VectorField) -> VectorField:
|
|
307
|
+
"""Addition
|
|
308
|
+
|
|
309
|
+
Supported operations
|
|
310
|
+
--------------------
|
|
311
|
+
VectorField + VectorField -> VectorField
|
|
312
|
+
"""
|
|
313
|
+
if isinstance(other, VectorField) and assertMatchFields(self, other):
|
|
314
|
+
return VectorField(self.x + other.x, self.y + other.y, self.z + other.z, self.box)
|
|
315
|
+
|
|
316
|
+
return NotImplemented
|
|
317
|
+
|
|
318
|
+
def __neg__(self) -> VectorField:
|
|
319
|
+
"""Negation
|
|
320
|
+
|
|
321
|
+
Supported operations
|
|
322
|
+
--------------------
|
|
323
|
+
-VectorField -> VectorField
|
|
324
|
+
"""
|
|
325
|
+
return VectorField(-self.x, -self.y, -self.z, self.box)
|
|
326
|
+
|
|
327
|
+
def __sub__(self, other: VectorField) -> VectorField:
|
|
328
|
+
"""Subtraction
|
|
329
|
+
|
|
330
|
+
Supported operations
|
|
331
|
+
--------------------
|
|
332
|
+
VectorField - VectorField -> VectorField
|
|
333
|
+
"""
|
|
334
|
+
if isinstance(other, VectorField) and assertMatchFields(self, other):
|
|
335
|
+
return VectorField(self.x - other.x, self.y - other.y, self.z - other.z, self.box)
|
|
336
|
+
|
|
337
|
+
return NotImplemented
|
|
338
|
+
|
|
339
|
+
def __mul__(self, other: Scalar | ScalarField) -> VectorField:
|
|
340
|
+
"""Left multiplication
|
|
341
|
+
|
|
342
|
+
Supported operations
|
|
343
|
+
--------------------
|
|
344
|
+
1. VectorField * ScalarField -> VectorField
|
|
345
|
+
2. VectorField * Scalar -> VectorField
|
|
346
|
+
"""
|
|
347
|
+
if isinstance(other, ScalarField) and assertMatchFields(self, other):
|
|
348
|
+
# Scalar product for ScalarField
|
|
349
|
+
return VectorField(
|
|
350
|
+
self.x * other.data,
|
|
351
|
+
self.y * other.data,
|
|
352
|
+
self.z * other.data,
|
|
353
|
+
self.box
|
|
354
|
+
)
|
|
355
|
+
if isinstance(other, Scalar):
|
|
356
|
+
# Scalar product for scalar
|
|
357
|
+
return VectorField(
|
|
358
|
+
self.x * other,
|
|
359
|
+
self.y * other,
|
|
360
|
+
self.z * other,
|
|
361
|
+
self.box
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
return NotImplemented
|
|
365
|
+
|
|
366
|
+
def __rmul__(self, other: Scalar | ScalarField) -> VectorField:
|
|
367
|
+
"""Right multiplication
|
|
368
|
+
|
|
369
|
+
Supported operations
|
|
370
|
+
--------------------
|
|
371
|
+
1. ScalarField * VectorField -> VectorField
|
|
372
|
+
2. Scalar * VectorField -> VectorField
|
|
373
|
+
"""
|
|
374
|
+
if isinstance(other, ScalarField) and assertMatchFields(self, other):
|
|
375
|
+
# Scalar product for ScalarField
|
|
376
|
+
return VectorField(
|
|
377
|
+
other.data * self.x,
|
|
378
|
+
other.data * self.y,
|
|
379
|
+
other.data * self.z,
|
|
380
|
+
self.box
|
|
381
|
+
)
|
|
382
|
+
if isinstance(other, Scalar):
|
|
383
|
+
# Scalar product for scalar
|
|
384
|
+
return VectorField(
|
|
385
|
+
other * self.x,
|
|
386
|
+
other * self.y,
|
|
387
|
+
other * self.z,
|
|
388
|
+
self.box
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
return NotImplemented
|
|
392
|
+
|
|
393
|
+
def __matmul__(self, other: Vector | VectorField) -> ScalarField:
|
|
394
|
+
"""Dot product
|
|
395
|
+
|
|
396
|
+
Supported operations
|
|
397
|
+
--------------------
|
|
398
|
+
1. VectorField @ VectorField -> ScalarField
|
|
399
|
+
2. VectorField @ Vector -> ScalarField
|
|
400
|
+
"""
|
|
401
|
+
if isinstance(other, VectorField) and assertMatchFields(self, other):
|
|
402
|
+
return ScalarField(
|
|
403
|
+
self.x * other.x + self.y * other.y + self.z * other.z,
|
|
404
|
+
self.box
|
|
405
|
+
)
|
|
406
|
+
if isinstance(other, Vector):
|
|
407
|
+
return ScalarField(
|
|
408
|
+
self.x * other.x + self.y * other.y + self.z * other.z,
|
|
409
|
+
self.box
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
return NotImplemented
|
|
413
|
+
|
|
414
|
+
def __rmatmul__(self, other: Vector) -> ScalarField:
|
|
415
|
+
"""Right dot product
|
|
416
|
+
|
|
417
|
+
Supported operations
|
|
418
|
+
--------------------
|
|
419
|
+
1. Vector @ VectorField -> ScalarField
|
|
420
|
+
"""
|
|
421
|
+
if isinstance(other, Vector):
|
|
422
|
+
return ScalarField(
|
|
423
|
+
other.x * self.x + other.y * self.y + other.z * self.z,
|
|
424
|
+
self.box
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
return NotImplemented
|
|
428
|
+
|
|
429
|
+
def __truediv__(self, other: Scalar | ScalarField) -> VectorField:
|
|
430
|
+
"""Left division
|
|
431
|
+
|
|
432
|
+
Supported operations
|
|
433
|
+
--------------------
|
|
434
|
+
1. VectorField / ScalarField -> VectorField
|
|
435
|
+
2. VectorField / Scalar -> VectorField
|
|
436
|
+
"""
|
|
437
|
+
if isinstance(other, ScalarField) and assertMatchFields(self, other):
|
|
438
|
+
return VectorField(
|
|
439
|
+
self.x / other.data,
|
|
440
|
+
self.y / other.data,
|
|
441
|
+
self.z / other.data,
|
|
442
|
+
self.box
|
|
443
|
+
)
|
|
444
|
+
if isinstance(other, Scalar):
|
|
445
|
+
return VectorField(
|
|
446
|
+
self.x / other,
|
|
447
|
+
self.y / other,
|
|
448
|
+
self.z / other,
|
|
449
|
+
self.box
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
return NotImplemented
|
|
453
|
+
|
|
454
|
+
def __pow__(self, other: Vector | VectorField) -> VectorField:
|
|
455
|
+
"""Cross product
|
|
456
|
+
|
|
457
|
+
Supported operations
|
|
458
|
+
--------------------
|
|
459
|
+
1. VectorField ** VectorField -> VectorField
|
|
460
|
+
2. VectorField ** Vector -> VectorField
|
|
461
|
+
"""
|
|
462
|
+
if isinstance(other, VectorField) and assertMatchFields(self, other):
|
|
463
|
+
return VectorField(
|
|
464
|
+
self.y * other.z - self.z * other.y,
|
|
465
|
+
self.z * other.x - self.x * other.z,
|
|
466
|
+
self.x * other.y - self.y * other.x,
|
|
467
|
+
self.box
|
|
468
|
+
)
|
|
469
|
+
elif isinstance(other, Vector):
|
|
470
|
+
return VectorField(
|
|
471
|
+
self.y * other.z - self.z * other.y,
|
|
472
|
+
self.z * other.x - self.x * other.z,
|
|
473
|
+
self.x * other.y - self.y * other.x,
|
|
474
|
+
self.box
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
return NotImplemented
|
|
478
|
+
|
|
479
|
+
def __rpow__(self, other: Vector) -> VectorField:
|
|
480
|
+
"""Right cross product
|
|
481
|
+
|
|
482
|
+
Supported operations
|
|
483
|
+
--------------------
|
|
484
|
+
Vector ** VectorField -> VectorField
|
|
485
|
+
"""
|
|
486
|
+
if isinstance(other, Vector):
|
|
487
|
+
return VectorField(
|
|
488
|
+
other.y * self.z - other.z * self.y,
|
|
489
|
+
other.z * self.x - other.x * self.z,
|
|
490
|
+
other.x * self.y - other.y * self.x,
|
|
491
|
+
self.box
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
return NotImplemented
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def assertMatchFields(
|
|
498
|
+
field1: ScalarField | VectorField,
|
|
499
|
+
field2: ScalarField | VectorField
|
|
500
|
+
) -> bool:
|
|
501
|
+
"""Assert that two fields have matching box dimensions and array shapes.
|
|
502
|
+
|
|
503
|
+
Always returns True on success; raises ValueError on mismatch.
|
|
504
|
+
Designed for use in operator guards, e.g.:
|
|
505
|
+
```python
|
|
506
|
+
if isinstance(other, ScalarField) and assertMatchFields(self, other):
|
|
507
|
+
...
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Parameters
|
|
511
|
+
----------
|
|
512
|
+
field1 : ScalarField | VectorField
|
|
513
|
+
field2 : ScalarField | VectorField
|
|
514
|
+
|
|
515
|
+
Returns
|
|
516
|
+
-------
|
|
517
|
+
bool : always True if the assertion passes
|
|
518
|
+
|
|
519
|
+
Raises
|
|
520
|
+
------
|
|
521
|
+
ValueError : if box dimensions or array shapes do not match
|
|
522
|
+
"""
|
|
523
|
+
if field1.box != field2.box:
|
|
524
|
+
raise ValueError("Box must match")
|
|
525
|
+
|
|
526
|
+
matched = False
|
|
527
|
+
|
|
528
|
+
if isinstance(field1, ScalarField) and isinstance(field2, ScalarField):
|
|
529
|
+
matched = field1.data.shape == field2.data.shape
|
|
530
|
+
|
|
531
|
+
elif isinstance(field1, VectorField) and isinstance(field2, VectorField):
|
|
532
|
+
matched = (
|
|
533
|
+
field1.x.shape == field2.x.shape and
|
|
534
|
+
field1.y.shape == field2.y.shape and
|
|
535
|
+
field1.z.shape == field2.z.shape
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
elif isinstance(field1, ScalarField) and isinstance(field2, VectorField):
|
|
539
|
+
matched = field1.data.shape == field2.x.shape == field2.y.shape == field2.z.shape
|
|
540
|
+
|
|
541
|
+
elif isinstance(field1, VectorField) and isinstance(field2, ScalarField):
|
|
542
|
+
matched = (
|
|
543
|
+
field1.x.shape == field2.data.shape and
|
|
544
|
+
field1.y.shape == field2.data.shape and
|
|
545
|
+
field1.z.shape == field2.data.shape
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
if not matched:
|
|
549
|
+
raise ValueError("Array shapes must match")
|
|
550
|
+
|
|
551
|
+
return True
|
|
552
|
+
|
|
553
|
+
def isMatchScalarFieldList(
|
|
554
|
+
fields: Sequence[ScalarField | VectorField]
|
|
555
|
+
) -> TypeGuard[Sequence[ScalarField]]:
|
|
556
|
+
"""Check if all elements are ScalarField. Raise TypeError for mixed types."""
|
|
557
|
+
if not fields:
|
|
558
|
+
return False
|
|
559
|
+
count = sum(isinstance(f, ScalarField) for f in fields)
|
|
560
|
+
if 0 < count < len(fields):
|
|
561
|
+
raise TypeError(
|
|
562
|
+
f"Mixed field types: {count} ScalarField(s) and "
|
|
563
|
+
f"{len(fields) - count} VectorField(s). All elements must be the same type."
|
|
564
|
+
)
|
|
565
|
+
if count > 0:
|
|
566
|
+
for field in fields[1:]:
|
|
567
|
+
assertMatchFields(fields[0], field)
|
|
568
|
+
return count > 0
|
|
569
|
+
|
|
570
|
+
def isMatchVectorFieldList(
|
|
571
|
+
fields: Sequence[ScalarField | VectorField]
|
|
572
|
+
) -> TypeGuard[Sequence[VectorField]]:
|
|
573
|
+
"""Check if all elements are VectorField. Raise TypeError for mixed types."""
|
|
574
|
+
if not fields:
|
|
575
|
+
return False
|
|
576
|
+
count = sum(isinstance(f, VectorField) for f in fields)
|
|
577
|
+
if 0 < count < len(fields):
|
|
578
|
+
raise TypeError(
|
|
579
|
+
f"Mixed field types: {count} VectorField(s) and "
|
|
580
|
+
f"{len(fields) - count} ScalarField(s). All elements must be the same type."
|
|
581
|
+
)
|
|
582
|
+
if count > 0:
|
|
583
|
+
for field in fields[1:]:
|
|
584
|
+
assertMatchFields(fields[0], field)
|
|
585
|
+
return count > 0
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def sqrt(field: ScalarField) -> ScalarField:
|
|
589
|
+
"""Square root of a ScalarField
|
|
590
|
+
|
|
591
|
+
Parameters
|
|
592
|
+
----------
|
|
593
|
+
field: ScalarField
|
|
594
|
+
|
|
595
|
+
Returns
|
|
596
|
+
-------
|
|
597
|
+
ScalarField, square root of the input ScalarField
|
|
598
|
+
"""
|
|
599
|
+
if not isinstance(field, ScalarField):
|
|
600
|
+
raise TypeError("sqrt() only supports ScalarField")
|
|
601
|
+
|
|
602
|
+
return ScalarField(np.sqrt(field.data), field.box)
|
|
603
|
+
|
|
604
|
+
@overload
|
|
605
|
+
def avg(fields: Sequence[ScalarField]) -> ScalarField: ...
|
|
606
|
+
|
|
607
|
+
@overload
|
|
608
|
+
def avg(fields: Sequence[VectorField]) -> VectorField: ...
|
|
609
|
+
|
|
610
|
+
def avg(fields: Sequence[ScalarField | VectorField]) -> ScalarField | VectorField:
|
|
611
|
+
"""Average of fields
|
|
612
|
+
|
|
613
|
+
Parameters
|
|
614
|
+
----------
|
|
615
|
+
fields: Sequence[ScalarField | VectorField]
|
|
616
|
+
|
|
617
|
+
Returns
|
|
618
|
+
-------
|
|
619
|
+
ScalarField | VectorField: Average of the input fields
|
|
620
|
+
"""
|
|
621
|
+
if not fields:
|
|
622
|
+
raise ValueError("Field list cannot be empty")
|
|
623
|
+
|
|
624
|
+
box = fields[0].box
|
|
625
|
+
|
|
626
|
+
if isMatchScalarFieldList(fields):
|
|
627
|
+
stacked = np.stack([field.data for field in fields])
|
|
628
|
+
avgdata = np.mean(stacked, axis=0)
|
|
629
|
+
return ScalarField(avgdata, box)
|
|
630
|
+
|
|
631
|
+
if isMatchVectorFieldList(fields):
|
|
632
|
+
xs = np.stack([field.x for field in fields])
|
|
633
|
+
ys = np.stack([field.y for field in fields])
|
|
634
|
+
zs = np.stack([field.z for field in fields])
|
|
635
|
+
|
|
636
|
+
avgx = np.mean(xs, axis=0)
|
|
637
|
+
avgy = np.mean(ys, axis=0)
|
|
638
|
+
avgz = np.mean(zs, axis=0)
|
|
639
|
+
|
|
640
|
+
return VectorField(avgx, avgy, avgz, box)
|
|
641
|
+
|
|
642
|
+
raise TypeError("Unsupported type, must be ScalarField or VectorField")
|
|
643
|
+
|
|
644
|
+
@overload
|
|
645
|
+
def std(fields: Sequence[ScalarField]) -> ScalarField: ...
|
|
646
|
+
|
|
647
|
+
@overload
|
|
648
|
+
def std(fields: Sequence[VectorField]) -> VectorField: ...
|
|
649
|
+
|
|
650
|
+
def std(fields: Sequence[ScalarField | VectorField]) -> ScalarField | VectorField:
|
|
651
|
+
"""Standard deviation of fields
|
|
652
|
+
|
|
653
|
+
Parameters
|
|
654
|
+
----------
|
|
655
|
+
fields: Sequence[ScalarField | VectorField]
|
|
656
|
+
|
|
657
|
+
Returns
|
|
658
|
+
-------
|
|
659
|
+
ScalarField | VectorField: Standard deviation of the input fields
|
|
660
|
+
"""
|
|
661
|
+
if not fields:
|
|
662
|
+
raise ValueError("Field list cannot be empty")
|
|
663
|
+
|
|
664
|
+
box = fields[0].box
|
|
665
|
+
|
|
666
|
+
if isMatchScalarFieldList(fields):
|
|
667
|
+
stacked = np.stack([field.data for field in fields])
|
|
668
|
+
stddata = np.std(stacked, axis=0)
|
|
669
|
+
return ScalarField(stddata, box)
|
|
670
|
+
|
|
671
|
+
if isMatchVectorFieldList(fields):
|
|
672
|
+
xs = np.stack([field.x for field in fields])
|
|
673
|
+
ys = np.stack([field.y for field in fields])
|
|
674
|
+
zs = np.stack([field.z for field in fields])
|
|
675
|
+
|
|
676
|
+
stdx = np.std(xs, axis=0)
|
|
677
|
+
stdy = np.std(ys, axis=0)
|
|
678
|
+
stdz = np.std(zs, axis=0)
|
|
679
|
+
|
|
680
|
+
return VectorField(stdx, stdy, stdz, box)
|
|
681
|
+
|
|
682
|
+
raise TypeError("Unsupported type, must be ScalarField or VectorField")
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
# ========== Turbulence ==========
|
|
686
|
+
|
|
687
|
+
class Turbulence:
|
|
688
|
+
"""(M)HD Turbulence
|
|
689
|
+
|
|
690
|
+
Currently supported types of turbulence:
|
|
691
|
+
- SSD : Small-scale dynamo, forced MHD turbulence with zero-net-flux B field
|
|
692
|
+
- Bx : Forced MHD turbulence with a background Bx field.
|
|
693
|
+
- Bz : Forced MHD turbulence with a background Bz field.
|
|
694
|
+
- MRI : MRI-driven turbulence from shearing box simulations.
|
|
695
|
+
- hydro: Forced hydrodynamic turbulence.
|
|
696
|
+
|
|
697
|
+
Currently supported equation of state (EoS):
|
|
698
|
+
- isothermal EoS with sound speed Cs
|
|
699
|
+
- adiabatic EoS with adiabatic index gamma
|
|
700
|
+
|
|
701
|
+
Attributes
|
|
702
|
+
----------
|
|
703
|
+
case (str) : case name
|
|
704
|
+
type (str) : turbulence type, options: 'SSD', 'Bx', 'Bz', 'MRI', 'hydro'
|
|
705
|
+
solver(str) : solver scheme, options: 'FVM', 'FDM', 'SPECTRAL'
|
|
706
|
+
|
|
707
|
+
EoS (str) : equation of state, options: 'isothermal', 'adiabatic', 'incompressible'
|
|
708
|
+
Cs (float | None) : isothermal sound speed (for isothermal EoS)
|
|
709
|
+
gamma (float | None) : adiabatic index (for adiabatic EoS)
|
|
710
|
+
|
|
711
|
+
times (list[float]) : time list
|
|
712
|
+
rhos (list[ScalarField]) : list of density fields
|
|
713
|
+
ps (list[ScalarField]) : list of pressure fields
|
|
714
|
+
Vs (list[VectorField]) : list of velocity fields
|
|
715
|
+
Bs (list[VectorField]) : list of magnetic fields
|
|
716
|
+
accs (list[VectorField]) : list of acceleration fields (driving forces)
|
|
717
|
+
|
|
718
|
+
nu (float) : kinematic viscosity
|
|
719
|
+
eta (float) : magnetic diffusivity (resistivity)
|
|
720
|
+
Pm (float) : magnetic Prandtl number, Pm = nu / eta
|
|
721
|
+
|
|
722
|
+
Omega (float) : angular velocity [rad/s]
|
|
723
|
+
q (float) : shear parameter
|
|
724
|
+
|
|
725
|
+
Derived quantities
|
|
726
|
+
------------------
|
|
727
|
+
Nx, Ny, Nz (int) : grid resolution
|
|
728
|
+
Lx, Ly, Lz (float) : box size
|
|
729
|
+
wVs (list[VectorField]) : density-weighted velocity fields
|
|
730
|
+
avgBs (list[Vector]) : mean magnetic fields
|
|
731
|
+
KEs (list[float]) : total kinetic energies
|
|
732
|
+
MEs (list[float]) : total magnetic energies
|
|
733
|
+
drhos (list[float]) : relative density fluctuations
|
|
734
|
+
"""
|
|
735
|
+
type : Literal['SSD', 'Bx', 'Bz', 'MRI', 'hydro']
|
|
736
|
+
solver: Literal["FVM", "FDM", "SPECTRAL"]
|
|
737
|
+
EoS : Literal["isothermal", "adiabatic", "incompressible"]
|
|
738
|
+
|
|
739
|
+
def __init__(
|
|
740
|
+
self,
|
|
741
|
+
case : str,
|
|
742
|
+
type : Literal['SSD', 'Bx', 'Bz', 'MRI', 'hydro'],
|
|
743
|
+
solver: Literal["FVM", "FDM", "SPECTRAL"],
|
|
744
|
+
EoS : Literal["isothermal", "adiabatic", "incompressible"],
|
|
745
|
+
Cs : float | None,
|
|
746
|
+
gamma : float | None,
|
|
747
|
+
rhos : Sequence[ScalarField | None],
|
|
748
|
+
ps : Sequence[ScalarField | None],
|
|
749
|
+
Vs : Sequence[VectorField],
|
|
750
|
+
Bs : Sequence[VectorField | None],
|
|
751
|
+
accs : Sequence[VectorField | None],
|
|
752
|
+
times : Sequence[float],
|
|
753
|
+
nu : float = 0,
|
|
754
|
+
eta : float = 0,
|
|
755
|
+
Omega : float = 0,
|
|
756
|
+
q : float = 0,
|
|
757
|
+
):
|
|
758
|
+
self.case = case
|
|
759
|
+
self.type = type
|
|
760
|
+
self.solver = solver
|
|
761
|
+
self.Vs = list(Vs)
|
|
762
|
+
self.times = list(times)
|
|
763
|
+
self.Omega = Omega
|
|
764
|
+
self.q = q
|
|
765
|
+
self.EoS = EoS
|
|
766
|
+
|
|
767
|
+
if type not in ['SSD', 'Bx', 'Bz', 'MRI', 'hydro']:
|
|
768
|
+
raise ValueError("Unsupported type, available options: SSD, Bx, Bz, MRI, hydro")
|
|
769
|
+
|
|
770
|
+
if solver not in ['FVM', 'FDM', 'SPECTRAL']:
|
|
771
|
+
raise ValueError("Unsupported scheme, available options: FVM, FDM, SPECTRAL")
|
|
772
|
+
|
|
773
|
+
snapshots = len(self.times)
|
|
774
|
+
if snapshots == 0:
|
|
775
|
+
raise ValueError("No data found.")
|
|
776
|
+
|
|
777
|
+
def assertMatchListLength(name: str, seq: Sequence[Any]):
|
|
778
|
+
if len(seq) != snapshots:
|
|
779
|
+
raise ValueError(f"Length mismatch: {snapshots} snapshots, {len(seq)} {name}.")
|
|
780
|
+
|
|
781
|
+
assertMatchListLength("Vs", self.Vs)
|
|
782
|
+
|
|
783
|
+
# Assert that all Fields have matching box and shape.
|
|
784
|
+
V0 = self.Vs[0]
|
|
785
|
+
for V in self.Vs[1:]:
|
|
786
|
+
assertMatchFields(V0, V)
|
|
787
|
+
|
|
788
|
+
if type != 'hydro':
|
|
789
|
+
assertMatchListLength("Bs", Bs)
|
|
790
|
+
for V, B in zip(self.Vs, Bs):
|
|
791
|
+
if B is not None:
|
|
792
|
+
assertMatchFields(V, B)
|
|
793
|
+
|
|
794
|
+
if EoS in ['isothermal', 'adiabatic']:
|
|
795
|
+
assertMatchListLength("rhos", rhos)
|
|
796
|
+
for V, rho in zip(self.Vs, rhos):
|
|
797
|
+
if rho is not None:
|
|
798
|
+
assertMatchFields(V, rho)
|
|
799
|
+
|
|
800
|
+
if EoS == 'adiabatic':
|
|
801
|
+
assertMatchListLength("ps", ps)
|
|
802
|
+
for V, p in zip(self.Vs, ps):
|
|
803
|
+
if p is not None:
|
|
804
|
+
assertMatchFields(V, p)
|
|
805
|
+
|
|
806
|
+
if type != 'MRI':
|
|
807
|
+
if len(accs) not in [0, snapshots]:
|
|
808
|
+
raise ValueError(f"Length mismatch: expected 0 or {snapshots} accs, got {len(accs)}.")
|
|
809
|
+
for V, acc in zip(self.Vs, accs):
|
|
810
|
+
if acc is not None:
|
|
811
|
+
assertMatchFields(V, acc)
|
|
812
|
+
|
|
813
|
+
if EoS == 'isothermal':
|
|
814
|
+
if Cs is None:
|
|
815
|
+
raise ValueError("Isothermal EoS requires Cs value.")
|
|
816
|
+
if any(rho is None for rho in rhos):
|
|
817
|
+
raise ValueError("Isothermal EoS requires all rho snapshots to be provided.")
|
|
818
|
+
|
|
819
|
+
self.rhos = [rho for rho in rhos if rho is not None]
|
|
820
|
+
self.Cs = Cs
|
|
821
|
+
self.ps = [self.Cs**2 * rho for rho in self.rhos]
|
|
822
|
+
|
|
823
|
+
elif EoS == 'adiabatic':
|
|
824
|
+
if gamma is None:
|
|
825
|
+
raise ValueError("Adiabatic EoS requires gamma value.")
|
|
826
|
+
if any(rho is None for rho in rhos) or any(p is None for p in ps):
|
|
827
|
+
raise ValueError("Adiabatic EoS requires all rho and p snapshots to be provided.")
|
|
828
|
+
|
|
829
|
+
self.rhos = [rho for rho in rhos if rho is not None]
|
|
830
|
+
self.ps = [p for p in ps if p is not None]
|
|
831
|
+
self.gamma = gamma
|
|
832
|
+
|
|
833
|
+
elif EoS == 'incompressible':
|
|
834
|
+
# TODO: add support for incompressible EoS
|
|
835
|
+
raise NotImplementedError("Incompressible EoS is not supported yet.")
|
|
836
|
+
|
|
837
|
+
else:
|
|
838
|
+
raise ValueError("Unsupported EoS, available options: isothermal, adiabatic, incompressible")
|
|
839
|
+
|
|
840
|
+
if type != 'hydro':
|
|
841
|
+
if any(B is None for B in Bs):
|
|
842
|
+
raise ValueError("MHD turbulence requires all B snapshots to be provided.")
|
|
843
|
+
self.Bs = [B for B in Bs if B is not None]
|
|
844
|
+
|
|
845
|
+
if type != 'MRI':
|
|
846
|
+
self.accs = [acc for acc in accs if acc is not None]
|
|
847
|
+
|
|
848
|
+
self.nu = nu
|
|
849
|
+
self.eta = eta
|
|
850
|
+
self.Pm = nu / eta if eta != 0 else np.inf
|
|
851
|
+
if nu == 0 and eta == 0:
|
|
852
|
+
self.Pm = np.nan
|
|
853
|
+
|
|
854
|
+
self.Nx, self.Ny, self.Nz = V0.x.shape
|
|
855
|
+
self.Lx, self.Ly, self.Lz = V0.box
|
|
856
|
+
|
|
857
|
+
def __getattr__(self, name: str):
|
|
858
|
+
"""Provide informative errors for EoS/type-specific attributes."""
|
|
859
|
+
if name == 'Cs' and self.EoS != 'isothermal':
|
|
860
|
+
raise AttributeError("'Cs' is only available for isothermal EoS.")
|
|
861
|
+
if name == 'gamma' and self.EoS != 'adiabatic':
|
|
862
|
+
raise AttributeError("'gamma' is only available for adiabatic EoS.")
|
|
863
|
+
if name == 'Bs' and self.type == 'hydro':
|
|
864
|
+
raise AttributeError("'Bs' is not available for hydrodynamic turbulence.")
|
|
865
|
+
if name == 'accs' and self.type == 'MRI':
|
|
866
|
+
raise AttributeError("'accs' is not available for MRI turbulence.")
|
|
867
|
+
|
|
868
|
+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
|
869
|
+
|
|
870
|
+
@property
|
|
871
|
+
def Js(self) -> list[VectorField]:
|
|
872
|
+
"""Current density fields: J = ∇ × B."""
|
|
873
|
+
if self.type == 'hydro':
|
|
874
|
+
raise AttributeError("'Js' is not available for hydrodynamic turbulence.")
|
|
875
|
+
|
|
876
|
+
from .derivatives.derivative import Algorithm, curl
|
|
877
|
+
|
|
878
|
+
if self.solver == 'SPECTRAL':
|
|
879
|
+
algorithm = Algorithm('SPECTRAL')
|
|
880
|
+
else:
|
|
881
|
+
algorithm = Algorithm(method='TENO', stencil=7, CT=0.01)
|
|
882
|
+
|
|
883
|
+
return [curl(B, algorithm) for B in self.Bs]
|
|
884
|
+
|
|
885
|
+
@property
|
|
886
|
+
def wVs(self) -> list[VectorField]:
|
|
887
|
+
"""Density-weighted velocity fields: sqrt(rho) * V."""
|
|
888
|
+
if self.EoS == 'incompressible':
|
|
889
|
+
return self.Vs
|
|
890
|
+
|
|
891
|
+
return [sqrt(rho) * V for rho, V in zip(self.rhos, self.Vs)]
|
|
892
|
+
|
|
893
|
+
@property
|
|
894
|
+
def avgBs(self) -> list[Vector]:
|
|
895
|
+
"""Mean magnetic field at each time."""
|
|
896
|
+
if self.type == 'hydro':
|
|
897
|
+
raise AttributeError("'avgBs' is not available for hydrodynamic turbulence.")
|
|
898
|
+
return [Vector(float(np.mean(B.x)), float(np.mean(B.y)), float(np.mean(B.z))) for B in self.Bs]
|
|
899
|
+
|
|
900
|
+
@property
|
|
901
|
+
def KEs(self) -> list[float]:
|
|
902
|
+
"""Total kinetic energy at each time."""
|
|
903
|
+
return [ 0.5 * (wV.norm**2).total for wV in self.wVs ]
|
|
904
|
+
|
|
905
|
+
@property
|
|
906
|
+
def MEs(self) -> list[float]:
|
|
907
|
+
"""Total magnetic energy at each time."""
|
|
908
|
+
if self.type == 'hydro':
|
|
909
|
+
raise AttributeError("'MEs' is not available for hydrodynamic turbulence.")
|
|
910
|
+
return [ 0.5 * (B.norm**2).total for B in self.Bs ]
|
|
911
|
+
|
|
912
|
+
@property
|
|
913
|
+
def drhos(self) -> list[float]:
|
|
914
|
+
"""Relative density fluctuation δρ / <ρ> at each time."""
|
|
915
|
+
if self.EoS == 'incompressible':
|
|
916
|
+
raise AttributeError("'drhos' is not available for incompressible EoS.")
|
|
917
|
+
return [rho.std / rho.mean for rho in self.rhos]
|