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/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]