drbx 2.0.0.dev0__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.
Files changed (106) hide show
  1. drbx/__init__.py +20 -0
  2. drbx/__main__.py +5 -0
  3. drbx/cli.py +586 -0
  4. drbx/config/__init__.py +20 -0
  5. drbx/config/boutinp.py +556 -0
  6. drbx/config/model.py +34 -0
  7. drbx/config/normalization.py +66 -0
  8. drbx/data/atomic_rates/__init__.py +1 -0
  9. drbx/data/atomic_rates/iz_AMJUEL_H.x_2.1.5.json +213 -0
  10. drbx/data/atomic_rates/iz_AMJUEL_H.x_2.3.9a.json +213 -0
  11. drbx/data/atomic_rates/rec_AMJUEL_H.x_2.1.8.json +213 -0
  12. drbx/data/atomic_rates/rec_AMJUEL_H.x_2.3.13a.json +213 -0
  13. drbx/geometry/__init__.py +207 -0
  14. drbx/geometry/embedding.py +56 -0
  15. drbx/geometry/essos_import.py +1385 -0
  16. drbx/geometry/fci_geometry.py +4622 -0
  17. drbx/geometry/fci_maps.py +85 -0
  18. drbx/geometry/island_divertor.py +291 -0
  19. drbx/geometry/metric_tensor.py +99 -0
  20. drbx/geometry/open_slab.py +150 -0
  21. drbx/geometry/rotating_ellipse.py +253 -0
  22. drbx/geometry/shifted_torus.py +225 -0
  23. drbx/geometry/stellarator.py +287 -0
  24. drbx/geometry/vmec_extender_import.py +499 -0
  25. drbx/geometry/vmec_jax_import.py +306 -0
  26. drbx/linear/__init__.py +37 -0
  27. drbx/linear/dispersion.py +138 -0
  28. drbx/linear/eigen.py +91 -0
  29. drbx/native/__init__.py +224 -0
  30. drbx/native/array_backend.py +64 -0
  31. drbx/native/deck_runner.py +779 -0
  32. drbx/native/electromagnetic.py +250 -0
  33. drbx/native/expression.py +173 -0
  34. drbx/native/fci.py +295 -0
  35. drbx/native/fci_2_field_rhs.py +182 -0
  36. drbx/native/fci_4_field_rhs.py +1267 -0
  37. drbx/native/fci_boundaries.py +2494 -0
  38. drbx/native/fci_differentiable_case.py +304 -0
  39. drbx/native/fci_drb_EB_rhs.py +1243 -0
  40. drbx/native/fci_drb_rhs.py +190 -0
  41. drbx/native/fci_halo.py +1575 -0
  42. drbx/native/fci_helpers.py +350 -0
  43. drbx/native/fci_model.py +294 -0
  44. drbx/native/fci_neutral.py +139 -0
  45. drbx/native/fci_operators.py +4081 -0
  46. drbx/native/fci_sharding.py +597 -0
  47. drbx/native/fci_sheath_recycling.py +206 -0
  48. drbx/native/fci_time_integrator.py +96 -0
  49. drbx/native/fci_vorticity.py +198 -0
  50. drbx/native/fluid_1d.py +330 -0
  51. drbx/native/hasegawa_wakatani.py +196 -0
  52. drbx/native/limiters.py +57 -0
  53. drbx/native/mesh.py +238 -0
  54. drbx/native/metrics.py +234 -0
  55. drbx/native/neutrals/__init__.py +58 -0
  56. drbx/native/neutrals/atomic_rates.py +134 -0
  57. drbx/native/neutrals/detachment_sol_model.py +221 -0
  58. drbx/native/neutrals/reactions.py +164 -0
  59. drbx/native/neutrals/recycling_sol_model.py +197 -0
  60. drbx/native/sol_flux_tube.py +133 -0
  61. drbx/native/stellarator_turbulence.py +343 -0
  62. drbx/native/transport.py +134 -0
  63. drbx/native/units.py +32 -0
  64. drbx/native/vorticity.py +252 -0
  65. drbx/runtime/__init__.py +53 -0
  66. drbx/runtime/artifacts.py +161 -0
  67. drbx/runtime/memory.py +144 -0
  68. drbx/runtime/output.py +374 -0
  69. drbx/runtime/paths.py +9 -0
  70. drbx/runtime/performance.py +161 -0
  71. drbx/runtime/run_config.py +184 -0
  72. drbx/runtime/scheduler.py +99 -0
  73. drbx/runtime/state.py +40 -0
  74. drbx/validation/__init__.py +424 -0
  75. drbx/validation/autodiff_diffusion.py +329 -0
  76. drbx/validation/autodiff_diffusion_uncertainty.py +235 -0
  77. drbx/validation/diverted_tokamak_movie.py +558 -0
  78. drbx/validation/essos_fieldline_import_campaign.py +181 -0
  79. drbx/validation/essos_imported_artifact_audit.py +535 -0
  80. drbx/validation/essos_imported_drb_movie_campaign.py +2826 -0
  81. drbx/validation/essos_imported_fci_campaign.py +5241 -0
  82. drbx/validation/essos_imported_pytree_campaign.py +406 -0
  83. drbx/validation/essos_vmec_closed_field_campaign.py +314 -0
  84. drbx/validation/essos_vmec_closed_field_transient_campaign.py +629 -0
  85. drbx/validation/essos_vmec_fieldline_surface_campaign.py +620 -0
  86. drbx/validation/fluid_1d_mms_convergence.py +250 -0
  87. drbx/validation/geometry_lineouts.py +136 -0
  88. drbx/validation/geometry_slices.py +178 -0
  89. drbx/validation/publication_plotting.py +91 -0
  90. drbx/validation/stellarator_drb_pytree_campaign.py +621 -0
  91. drbx/validation/stellarator_fci_geometry_campaign.py +200 -0
  92. drbx/validation/stellarator_fci_operator_campaign.py +304 -0
  93. drbx/validation/stellarator_fci_suite_campaign.py +264 -0
  94. drbx/validation/stellarator_metric_mms_campaign.py +289 -0
  95. drbx/validation/stellarator_neutral_physics_campaign.py +255 -0
  96. drbx/validation/stellarator_sheath_recycling_campaign.py +331 -0
  97. drbx/validation/stellarator_sol_showcase.py +628 -0
  98. drbx/validation/stellarator_vorticity_campaign.py +304 -0
  99. drbx/validation/vmec_extender_edge_field_campaign.py +260 -0
  100. drbx/validation/vmec_extender_sol_smoke_campaign.py +365 -0
  101. drbx-2.0.0.dev0.dist-info/METADATA +380 -0
  102. drbx-2.0.0.dev0.dist-info/RECORD +106 -0
  103. drbx-2.0.0.dev0.dist-info/WHEEL +5 -0
  104. drbx-2.0.0.dev0.dist-info/entry_points.txt +2 -0
  105. drbx-2.0.0.dev0.dist-info/licenses/LICENSE +21 -0
  106. drbx-2.0.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,306 @@
1
+ """Optional ``vmec_jax`` adapter: wout equilibria, surface fields, field lines.
2
+
3
+ `vmec_jax <https://github.com/rogeriojorge/vmec_jax>`_ owns the VMEC wout
4
+ schema (:func:`vmec_jax.core.wout.read_wout`) and the Fourier-synthesis
5
+ conventions of VMEC2000. This adapter imports it from an external checkout
6
+ (``DRBX_VMEC_JAX_ROOT``, defaulting to ``~/local/vmec_jax``) the same way
7
+ :mod:`drbx.geometry.essos_import` imports ESSOS, and adds the small pieces
8
+ `drbx` examples need on top of a loaded wout:
9
+
10
+ - equilibrium summaries (``nfp``, aspect ratio, iota profile, ``B0``);
11
+ - contravariant magnetic-field synthesis ``B^theta``/``B^phi`` and ``|B|`` on
12
+ half-mesh flux surfaces from the Nyquist coefficient tables;
13
+ - a JAX RK4 field-line tracer in VMEC ``(s, theta, phi)`` coordinates. A
14
+ VMEC equilibrium has ``B^s = 0`` by construction, so a field line stays on
15
+ its flux surface and obeys ``d theta / d phi = B^theta / B^phi``; the
16
+ average slope over many toroidal transits is the rotational transform;
17
+ - cylindrical ``(R, Z)`` mapping of traced lines and of the LCFS boundary
18
+ through the ``rmnc``/``zmns`` (and asymmetric partner) tables.
19
+
20
+ The adapter keeps `drbx` importable without vmec_jax: everything raises
21
+ ``ImportError``/``FileNotFoundError`` lazily and
22
+ :func:`vmec_jax_runtime_available` reports availability without raising.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import importlib
28
+ import os
29
+ import sys
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ import numpy as np
34
+ import jax
35
+ import jax.numpy as jnp
36
+
37
+ _PRIVATE_DEFAULT_VMEC_JAX_ROOT = Path.home() / "local" / "vmec_jax"
38
+
39
+
40
+ def _resolve_vmec_jax_root(vmec_jax_root: str | Path | None = None) -> Path:
41
+ if vmec_jax_root is not None:
42
+ return Path(vmec_jax_root).expanduser()
43
+ return Path(os.environ.get("DRBX_VMEC_JAX_ROOT", _PRIVATE_DEFAULT_VMEC_JAX_ROOT)).expanduser()
44
+
45
+
46
+ def _import_vmec_jax_modules(*, vmec_jax_root: str | Path | None = None) -> dict[str, Any]:
47
+ jax.config.update("jax_enable_x64", True)
48
+ root = _resolve_vmec_jax_root(vmec_jax_root)
49
+ if root.exists():
50
+ root_text = str(root)
51
+ if root_text not in sys.path:
52
+ sys.path.insert(0, root_text)
53
+ wout_module = importlib.import_module("vmec_jax.core.wout")
54
+ plotting_module = importlib.import_module("vmec_jax.core.plotting")
55
+ return {
56
+ "read_wout": wout_module.read_wout,
57
+ "surface_rz": plotting_module.surface_rz,
58
+ "surface_modB": plotting_module.surface_modB,
59
+ "axis_rz": plotting_module.axis_rz,
60
+ }
61
+
62
+
63
+ def vmec_jax_runtime_available(*, vmec_jax_root: str | Path | None = None) -> bool:
64
+ """Return whether vmec_jax can be imported by the optional adapter."""
65
+
66
+ try:
67
+ _import_vmec_jax_modules(vmec_jax_root=vmec_jax_root)
68
+ except (ImportError, ModuleNotFoundError, AttributeError):
69
+ return False
70
+ return True
71
+
72
+
73
+ def load_vmec_jax_wout(path: str | Path, *, vmec_jax_root: str | Path | None = None) -> Any:
74
+ """Read a VMEC ``wout_*.nc`` file with vmec_jax's :func:`read_wout`.
75
+
76
+ Returns the :class:`vmec_jax.core.wout.WoutData` dataclass, in file
77
+ conventions (no unit conversions).
78
+ """
79
+
80
+ resolved = Path(path).expanduser()
81
+ if not resolved.exists():
82
+ raise FileNotFoundError(
83
+ f"VMEC wout file not found: {resolved}. The pedagogical examples point "
84
+ "at an external checkout (ESSOS_test or vmec_jax examples/data); adjust "
85
+ "the WOUT_PATH parameter to a wout NetCDF file that exists locally."
86
+ )
87
+ modules = _import_vmec_jax_modules(vmec_jax_root=vmec_jax_root)
88
+ return modules["read_wout"](resolved)
89
+
90
+
91
+ def vmec_jax_wout_summary(wout: Any) -> dict[str, Any]:
92
+ """Compact scalar summary of a loaded wout equilibrium."""
93
+
94
+ iotaf = np.asarray(wout.iotaf, dtype=np.float64)
95
+ return {
96
+ "nfp": int(wout.nfp),
97
+ "ns": int(wout.ns),
98
+ "mnmax": int(wout.mnmax),
99
+ "lasym": bool(wout.lasym),
100
+ "aspect": float(wout.aspect),
101
+ "major_radius": float(wout.Rmajor_p),
102
+ "minor_radius": float(wout.Aminor_p),
103
+ "b0": float(wout.b0),
104
+ "volavgB": float(wout.volavgB),
105
+ "iota_axis": float(iotaf[0]),
106
+ "iota_edge": float(iotaf[-1]),
107
+ "iota_min": float(np.min(iotaf)),
108
+ "iota_max": float(np.max(iotaf)),
109
+ }
110
+
111
+
112
+ def vmec_jax_half_mesh_s(wout: Any) -> np.ndarray:
113
+ """Normalized toroidal flux ``s`` of the half-mesh rows ``1..ns-1``.
114
+
115
+ Row ``j`` of the half-mesh tables (``bsupumnc`` et al.) lives at
116
+ ``s = (j - 1/2) / (ns - 1)``; row 0 is unused padding in the wout schema.
117
+ """
118
+
119
+ ns = int(wout.ns)
120
+ return (np.arange(1, ns, dtype=np.float64) - 0.5) / float(ns - 1)
121
+
122
+
123
+ def _half_mesh_nyquist_pair(wout: Any, cos_name: str, sin_name: str, s_index: int) -> tuple[np.ndarray, np.ndarray]:
124
+ ns = int(wout.ns)
125
+ if not 1 <= int(s_index) <= ns - 1:
126
+ raise ValueError(f"half-mesh s_index must be in 1..{ns - 1}, got {s_index}")
127
+ cos_table = np.asarray(getattr(wout, cos_name), dtype=np.float64)[int(s_index)]
128
+ sin_table = getattr(wout, sin_name, None)
129
+ if bool(wout.lasym) and sin_table is not None:
130
+ sin_coeff = np.asarray(sin_table, dtype=np.float64)[int(s_index)]
131
+ else:
132
+ sin_coeff = np.zeros_like(cos_table)
133
+ return cos_table, sin_coeff
134
+
135
+
136
+ def _synthesize(cos_coeff: np.ndarray, sin_coeff: np.ndarray, xm: np.ndarray, xn: np.ndarray,
137
+ theta: np.ndarray, phi: np.ndarray) -> np.ndarray:
138
+ """``sum_mn [c cos(m theta - n phi) + s sin(...)]`` broadcast over theta/phi.
139
+
140
+ ``theta`` and ``phi`` may have any common broadcast shape; ``xn`` is the
141
+ file-convention toroidal mode table (``nfp`` factor included).
142
+ """
143
+
144
+ theta_b, phi_b = np.broadcast_arrays(np.asarray(theta, dtype=np.float64), np.asarray(phi, dtype=np.float64))
145
+ angle = (
146
+ np.asarray(xm, dtype=np.float64) * theta_b[..., None]
147
+ - np.asarray(xn, dtype=np.float64) * phi_b[..., None]
148
+ )
149
+ return np.sum(cos_coeff * np.cos(angle) + sin_coeff * np.sin(angle), axis=-1)
150
+
151
+
152
+ def evaluate_vmec_jax_surface_field(
153
+ wout: Any,
154
+ *,
155
+ s_index: int,
156
+ theta: np.ndarray,
157
+ phi: np.ndarray,
158
+ ) -> dict[str, np.ndarray | float]:
159
+ """Contravariant field components and ``|B|`` on one half-mesh surface.
160
+
161
+ ``theta``/``phi`` broadcast to any common shape. Returns ``b_sup_theta``
162
+ (``B^theta``), ``b_sup_phi`` (``B^phi``), ``mod_b`` and the surface's
163
+ normalized flux ``s``. The field-line pitch on the surface is
164
+ ``d theta / d phi = B^theta / B^phi``.
165
+ """
166
+
167
+ xm_nyq = np.asarray(wout.xm_nyq, dtype=np.float64)
168
+ xn_nyq = np.asarray(wout.xn_nyq, dtype=np.float64)
169
+ bsupu_c, bsupu_s = _half_mesh_nyquist_pair(wout, "bsupumnc", "bsupumns", s_index)
170
+ bsupv_c, bsupv_s = _half_mesh_nyquist_pair(wout, "bsupvmnc", "bsupvmns", s_index)
171
+ bmod_c, bmod_s = _half_mesh_nyquist_pair(wout, "bmnc", "bmns", s_index)
172
+ return {
173
+ "s": float(vmec_jax_half_mesh_s(wout)[int(s_index) - 1]),
174
+ "b_sup_theta": _synthesize(bsupu_c, bsupu_s, xm_nyq, xn_nyq, theta, phi),
175
+ "b_sup_phi": _synthesize(bsupv_c, bsupv_s, xm_nyq, xn_nyq, theta, phi),
176
+ "mod_b": _synthesize(bmod_c, bmod_s, xm_nyq, xn_nyq, theta, phi),
177
+ }
178
+
179
+
180
+ def trace_vmec_jax_field_lines(
181
+ wout: Any,
182
+ *,
183
+ s_index: int,
184
+ theta0: np.ndarray,
185
+ n_transits: int = 40,
186
+ steps_per_transit: int = 128,
187
+ ) -> tuple[np.ndarray, np.ndarray]:
188
+ """Trace field lines on one half-mesh flux surface with a JAX RK4 in phi.
189
+
190
+ Integrates ``d theta / d phi = B^theta(s, theta, phi) / B^phi(s, theta, phi)``
191
+ from the Nyquist coefficient tables of half-mesh row ``s_index``, starting
192
+ at poloidal angles ``theta0`` (any number of lines, vectorized) and
193
+ advancing ``n_transits`` toroidal transits with ``steps_per_transit``
194
+ fixed RK4 steps each.
195
+
196
+ Returns ``(phi_nodes, theta_lines)`` with shapes ``(n_steps + 1,)`` and
197
+ ``(n_lines, n_steps + 1)``; ``theta_lines`` is unwrapped (not reduced
198
+ modulo ``2 pi``), so its secular slope in phi is the rotational transform.
199
+ """
200
+
201
+ xm_nyq = jnp.asarray(np.asarray(wout.xm_nyq, dtype=np.float64))
202
+ xn_nyq = jnp.asarray(np.asarray(wout.xn_nyq, dtype=np.float64))
203
+ bsupu_c, bsupu_s = _half_mesh_nyquist_pair(wout, "bsupumnc", "bsupumns", s_index)
204
+ bsupv_c, bsupv_s = _half_mesh_nyquist_pair(wout, "bsupvmnc", "bsupvmns", s_index)
205
+ bsupu_c = jnp.asarray(bsupu_c)
206
+ bsupu_s = jnp.asarray(bsupu_s)
207
+ bsupv_c = jnp.asarray(bsupv_c)
208
+ bsupv_s = jnp.asarray(bsupv_s)
209
+
210
+ n_steps = int(n_transits) * int(steps_per_transit)
211
+ h = 2.0 * jnp.pi / float(steps_per_transit)
212
+
213
+ def pitch(theta_value: jax.Array, phi_value: jax.Array) -> jax.Array:
214
+ angle = xm_nyq[None, :] * theta_value[:, None] - xn_nyq[None, :] * phi_value
215
+ cos_a = jnp.cos(angle)
216
+ sin_a = jnp.sin(angle)
217
+ b_sup_theta = cos_a @ bsupu_c + sin_a @ bsupu_s
218
+ b_sup_phi = cos_a @ bsupv_c + sin_a @ bsupv_s
219
+ safe = jnp.where(jnp.abs(b_sup_phi) > 1.0e-30, b_sup_phi, 1.0e-30)
220
+ return b_sup_theta / safe
221
+
222
+ def step(theta_value: jax.Array, step_index: jax.Array) -> tuple[jax.Array, jax.Array]:
223
+ phi_value = h * step_index
224
+ k1 = pitch(theta_value, phi_value)
225
+ k2 = pitch(theta_value + 0.5 * h * k1, phi_value + 0.5 * h)
226
+ k3 = pitch(theta_value + 0.5 * h * k2, phi_value + 0.5 * h)
227
+ k4 = pitch(theta_value + h * k3, phi_value + h)
228
+ next_theta = theta_value + (h / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
229
+ return next_theta, next_theta
230
+
231
+ @jax.jit
232
+ def integrate(theta_start: jax.Array) -> jax.Array:
233
+ _, history = jax.lax.scan(step, theta_start, jnp.arange(n_steps, dtype=jnp.float64))
234
+ return jnp.concatenate([theta_start[None, :], history], axis=0).T
235
+
236
+ theta_start = jnp.atleast_1d(jnp.asarray(theta0, dtype=jnp.float64))
237
+ theta_lines = np.asarray(jax.block_until_ready(integrate(theta_start)), dtype=np.float64)
238
+ phi_nodes = np.asarray(float(h)) * np.arange(n_steps + 1, dtype=np.float64)
239
+ return phi_nodes, theta_lines
240
+
241
+
242
+ def traced_rotational_transform(phi_nodes: np.ndarray, theta_lines: np.ndarray) -> np.ndarray:
243
+ """Least-squares slope ``d theta / d phi`` per traced line (= iota).
244
+
245
+ VMEC's poloidal angle is not a straight-field-line angle, so the pitch
246
+ oscillates within a transit; the secular slope over many transits
247
+ converges to the rotational transform of the surface.
248
+ """
249
+
250
+ phi = np.asarray(phi_nodes, dtype=np.float64)
251
+ theta = np.atleast_2d(np.asarray(theta_lines, dtype=np.float64))
252
+ phi_centered = phi - phi.mean()
253
+ denominator = float(np.sum(phi_centered * phi_centered))
254
+ return np.sum(phi_centered[None, :] * (theta - theta.mean(axis=1, keepdims=True)), axis=1) / denominator
255
+
256
+
257
+ def _full_mesh_coeffs_at_s(wout: Any, cos_name: str, sin_name: str, s: float) -> tuple[np.ndarray, np.ndarray]:
258
+ cos_table = np.asarray(getattr(wout, cos_name), dtype=np.float64)
259
+ ns = cos_table.shape[0]
260
+ s_full = np.linspace(0.0, 1.0, ns)
261
+ s_clipped = float(np.clip(s, 0.0, 1.0))
262
+ cos_coeff = np.array([np.interp(s_clipped, s_full, cos_table[:, k]) for k in range(cos_table.shape[1])])
263
+ sin_table = getattr(wout, sin_name, None)
264
+ if bool(wout.lasym) and sin_table is not None:
265
+ sin_table = np.asarray(sin_table, dtype=np.float64)
266
+ sin_coeff = np.array([np.interp(s_clipped, s_full, sin_table[:, k]) for k in range(sin_table.shape[1])])
267
+ else:
268
+ sin_coeff = np.zeros_like(cos_coeff)
269
+ return cos_coeff, sin_coeff
270
+
271
+
272
+ def vmec_jax_surface_rz(
273
+ wout: Any,
274
+ *,
275
+ s: float,
276
+ theta: np.ndarray,
277
+ phi: np.ndarray,
278
+ ) -> tuple[np.ndarray, np.ndarray]:
279
+ """Cylindrical ``(R, Z)`` on the flux surface ``s`` at angles ``theta``/``phi``.
280
+
281
+ The full-mesh ``rmnc``/``zmns`` (plus asymmetric partner) tables are
282
+ linearly interpolated in ``s`` mode-by-mode, then synthesized at the
283
+ requested angles (any common broadcast shape). Use this to map traced
284
+ ``(theta, phi)`` field-line points, e.g. Poincare crossings at ``phi = 0``,
285
+ into the lab frame.
286
+ """
287
+
288
+ xm = np.asarray(wout.xm, dtype=np.float64)
289
+ xn = np.asarray(wout.xn, dtype=np.float64)
290
+ rmnc, rmns = _full_mesh_coeffs_at_s(wout, "rmnc", "rmns", s)
291
+ zmns, zmnc = _full_mesh_coeffs_at_s(wout, "zmns", "zmnc", s)
292
+ major_radius = _synthesize(rmnc, rmns, xm, xn, theta, phi)
293
+ vertical = _synthesize(zmnc, zmns, xm, xn, theta, phi)
294
+ return major_radius, vertical
295
+
296
+
297
+ def vmec_jax_boundary_rz(
298
+ wout: Any,
299
+ *,
300
+ phi: float = 0.0,
301
+ n_theta: int = 256,
302
+ ) -> tuple[np.ndarray, np.ndarray]:
303
+ """Closed ``(R, Z)`` curve of the LCFS (outermost surface) at fixed ``phi``."""
304
+
305
+ theta = np.linspace(0.0, 2.0 * np.pi, int(n_theta) + 1)
306
+ return vmec_jax_surface_rz(wout, s=1.0, theta=theta, phi=np.full_like(theta, float(phi)))
@@ -0,0 +1,37 @@
1
+ """Linear stability and dispersion analysis for the DRB models.
2
+
3
+ This subpackage linearizes a reduced drift-reduced Braginskii model about an
4
+ equilibrium and returns the growth rates and frequencies of its eigenmodes. It
5
+ is both a user-facing tool ("the linear solver of the DRB equations") and the
6
+ engine behind the drift-wave and shear-Alfven dispersion benchmarks (B2, B3).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .dispersion import (
12
+ drift_wave_adiabatic_frequency,
13
+ interchange_growth_rate,
14
+ interchange_operator,
15
+ resistive_drift_wave_operator,
16
+ shear_alfven_frequency,
17
+ shear_alfven_operator,
18
+ )
19
+ from .eigen import (
20
+ LinearModes,
21
+ dominant_mode,
22
+ eigenmodes,
23
+ jacobian_operator,
24
+ )
25
+
26
+ __all__ = [
27
+ "LinearModes",
28
+ "jacobian_operator",
29
+ "eigenmodes",
30
+ "dominant_mode",
31
+ "shear_alfven_operator",
32
+ "shear_alfven_frequency",
33
+ "resistive_drift_wave_operator",
34
+ "drift_wave_adiabatic_frequency",
35
+ "interchange_operator",
36
+ "interchange_growth_rate",
37
+ ]
@@ -0,0 +1,138 @@
1
+ """Linear dispersion operators for two reduced edge-plasma models.
2
+
3
+ Each function assembles the linear operator ``A`` of a single Fourier mode
4
+ (``delta ~ exp(i k.x)``) directly from the model equations, so that the
5
+ eigenfrequencies returned by :func:`drbx.linear.eigenmodes` reproduce the
6
+ analytic dispersion relation without that relation being wired in by hand.
7
+
8
+ Shear-Alfven wave (electromagnetic, electron inertia), reduced two-field model
9
+ in ``(phi, psi)`` (potential, parallel flux):
10
+
11
+ d/dt phi = i * v_A^2 * k_par * psi
12
+ d/dt psi = i * k_par * phi / (1 + k_perp^2 * d_e^2)
13
+
14
+ whose eigenvalues are ``+/- i * omega`` with
15
+ ``omega = k_par * v_A / sqrt(1 + k_perp^2 d_e^2)`` (Stegmeir et al.,
16
+ Phys. Plasmas 26, 052517 (2019)).
17
+
18
+ Resistive drift wave, Hasegawa-Wakatani two-field model in ``(phi, n)``
19
+ (potential, density), Fourier-reduced with ``kperp2 = k_perp^2``:
20
+
21
+ d/dt phi = (-alpha/kperp2) phi + (alpha/kperp2) n
22
+ d/dt n = (alpha - i kappa k_y) phi - alpha n
23
+
24
+ with adiabaticity ``alpha`` and density-gradient drive ``kappa``. In the
25
+ adiabatic limit ``alpha -> inf`` the drift mode frequency tends to
26
+ ``omega_star = kappa k_y / (1 + kperp2)``; at finite ``alpha`` the resistive
27
+ coupling makes it grow (Dudson et al., Comput. Phys. Commun. 180, 1467 (2009);
28
+ Hasegawa & Wakatani, Phys. Rev. Lett. 50, 682 (1983)).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import jax.numpy as jnp
34
+
35
+ __all__ = [
36
+ "shear_alfven_operator",
37
+ "shear_alfven_frequency",
38
+ "resistive_drift_wave_operator",
39
+ "drift_wave_adiabatic_frequency",
40
+ "interchange_operator",
41
+ "interchange_growth_rate",
42
+ ]
43
+
44
+
45
+ def shear_alfven_operator(k_par, k_perp, alfven_speed, electron_skin_depth):
46
+ """Two-field ``(phi, psi)`` shear-Alfven operator (see module docstring)."""
47
+
48
+ k_par = jnp.asarray(k_par, dtype=jnp.float64)
49
+ k_perp = jnp.asarray(k_perp, dtype=jnp.float64)
50
+ v_a = jnp.asarray(alfven_speed, dtype=jnp.float64)
51
+ d_e = jnp.asarray(electron_skin_depth, dtype=jnp.float64)
52
+ inertia = 1.0 + (k_perp * d_e) ** 2
53
+ return jnp.array(
54
+ [
55
+ [0.0, 1j * v_a**2 * k_par],
56
+ [1j * k_par / inertia, 0.0],
57
+ ],
58
+ dtype=jnp.complex128,
59
+ )
60
+
61
+
62
+ def shear_alfven_frequency(k_par, k_perp, alfven_speed, electron_skin_depth):
63
+ """Analytic shear-Alfven angular frequency ``omega(k)`` with electron inertia."""
64
+
65
+ k_par = jnp.asarray(k_par, dtype=jnp.float64)
66
+ k_perp = jnp.asarray(k_perp, dtype=jnp.float64)
67
+ v_a = jnp.asarray(alfven_speed, dtype=jnp.float64)
68
+ d_e = jnp.asarray(electron_skin_depth, dtype=jnp.float64)
69
+ return k_par * v_a / jnp.sqrt(1.0 + (k_perp * d_e) ** 2)
70
+
71
+
72
+ def resistive_drift_wave_operator(k_y, kperp2, adiabaticity, gradient):
73
+ """Two-field ``(phi, n)`` Hasegawa-Wakatani operator (see module docstring)."""
74
+
75
+ k_y = jnp.asarray(k_y, dtype=jnp.float64)
76
+ kperp2 = jnp.asarray(kperp2, dtype=jnp.float64)
77
+ alpha = jnp.asarray(adiabaticity, dtype=jnp.float64)
78
+ kappa = jnp.asarray(gradient, dtype=jnp.float64)
79
+ return jnp.array(
80
+ [
81
+ [-alpha / kperp2, alpha / kperp2],
82
+ [alpha - 1j * kappa * k_y, -alpha],
83
+ ],
84
+ dtype=jnp.complex128,
85
+ )
86
+
87
+
88
+ def drift_wave_adiabatic_frequency(k_y, kperp2, gradient):
89
+ """Adiabatic-limit drift-wave frequency ``omega_star = kappa k_y/(1+kperp2)``."""
90
+
91
+ k_y = jnp.asarray(k_y, dtype=jnp.float64)
92
+ kperp2 = jnp.asarray(kperp2, dtype=jnp.float64)
93
+ kappa = jnp.asarray(gradient, dtype=jnp.float64)
94
+ return kappa * k_y / (1.0 + kperp2)
95
+
96
+
97
+ def interchange_operator(k_y, kperp2, gravity, gradient):
98
+ """Curvature-driven interchange (Rayleigh-Taylor) operator in ``(phi, n)``.
99
+
100
+ The ideal two-field interchange model in the drift plane,
101
+
102
+ d/dt lap(phi) = -g d/dy n, d/dt n = -kappa d/dy phi,
103
+
104
+ reduces for a single Fourier mode to
105
+ ``A = [[0, i g k_y / kperp2], [-i kappa k_y, 0]]``, whose eigenvalues are
106
+ ``lambda = +/- sqrt(g kappa) |k_y| / sqrt(kperp2)``. With bad curvature
107
+ (``g kappa > 0``) one eigenvalue is real and positive -- the interchange
108
+ instability; with good curvature it is a stable oscillation. ``g`` is the
109
+ effective gravity (curvature/grad-B drive) and ``kappa`` the background
110
+ density gradient. This is the linear physics behind SOL blob propagation.
111
+ """
112
+
113
+ k_y = jnp.asarray(k_y, dtype=jnp.float64)
114
+ kperp2 = jnp.asarray(kperp2, dtype=jnp.float64)
115
+ g = jnp.asarray(gravity, dtype=jnp.float64)
116
+ kappa = jnp.asarray(gradient, dtype=jnp.float64)
117
+ return jnp.array(
118
+ [
119
+ [0.0, 1j * g * k_y / kperp2],
120
+ [-1j * kappa * k_y, 0.0],
121
+ ],
122
+ dtype=jnp.complex128,
123
+ )
124
+
125
+
126
+ def interchange_growth_rate(k_y, kperp2, gravity, gradient):
127
+ """Analytic interchange growth rate ``sqrt(g kappa) |k_y| / sqrt(kperp2)``.
128
+
129
+ Real and positive for bad curvature (``g kappa > 0``); returns 0 for good
130
+ curvature, where the mode is a stable oscillation instead.
131
+ """
132
+
133
+ k_y = jnp.asarray(k_y, dtype=jnp.float64)
134
+ kperp2 = jnp.asarray(kperp2, dtype=jnp.float64)
135
+ g = jnp.asarray(gravity, dtype=jnp.float64)
136
+ kappa = jnp.asarray(gradient, dtype=jnp.float64)
137
+ drive = g * kappa
138
+ return jnp.where(drive > 0, jnp.sqrt(jnp.abs(drive)) * jnp.abs(k_y) / jnp.sqrt(kperp2), 0.0)
drbx/linear/eigen.py ADDED
@@ -0,0 +1,91 @@
1
+ """Linear stability analysis for drift-reduced Braginskii models.
2
+
3
+ A model is linearized about an equilibrium state by taking the Jacobian of its
4
+ right-hand side, ``A = d(rhs)/d(state)``. Writing a perturbation as
5
+ ``delta ~ exp(lambda * t)``, the eigenvalues ``lambda = gamma + i * Omega`` of
6
+ ``A`` give the growth rate ``gamma = Re(lambda)`` and the oscillation frequency
7
+ ``Omega = Im(lambda)`` of each linear eigenmode.
8
+
9
+ Two entry points are provided:
10
+
11
+ - :func:`jacobian_operator` builds the dense Jacobian of an arbitrary
12
+ JAX-differentiable right-hand side about an equilibrium (via ``jax.jacfwd``),
13
+ suitable for small grids or single-Fourier-mode reductions.
14
+ - :func:`eigenmodes` diagonalizes a linear operator and returns the growth
15
+ rates, frequencies, and eigenvectors sorted by decreasing growth rate.
16
+
17
+ Both are pure JAX and therefore ``jit``/``grad``-transformable.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+
24
+ import jax
25
+ import jax.numpy as jnp
26
+
27
+ __all__ = [
28
+ "LinearModes",
29
+ "jacobian_operator",
30
+ "eigenmodes",
31
+ "dominant_mode",
32
+ ]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class LinearModes:
37
+ """Eigenmodes of a linear operator, sorted by decreasing growth rate.
38
+
39
+ ``growth_rates`` and ``frequencies`` are the real and imaginary parts of the
40
+ eigenvalues ``lambda`` (``delta ~ exp(lambda t)``); ``eigenvalues`` and
41
+ ``eigenvectors`` are the raw complex spectrum.
42
+ """
43
+
44
+ growth_rates: jnp.ndarray
45
+ frequencies: jnp.ndarray
46
+ eigenvalues: jnp.ndarray
47
+ eigenvectors: jnp.ndarray
48
+
49
+ @property
50
+ def dominant_growth_rate(self) -> jnp.ndarray:
51
+ return self.growth_rates[0]
52
+
53
+ @property
54
+ def dominant_frequency(self) -> jnp.ndarray:
55
+ return self.frequencies[0]
56
+
57
+
58
+ def jacobian_operator(rhs, equilibrium):
59
+ """Dense Jacobian ``A = d(rhs)/d(state)`` of a JAX rhs about ``equilibrium``.
60
+
61
+ ``rhs`` maps a state vector (1-D array) to its time derivative. The returned
62
+ matrix has shape ``(n, n)`` for a length-``n`` state. For a genuinely linear
63
+ ``rhs`` this recovers the operator exactly; for a nonlinear one it is the
64
+ tangent operator at the equilibrium.
65
+ """
66
+
67
+ equilibrium = jnp.asarray(equilibrium)
68
+ return jax.jacfwd(rhs)(equilibrium)
69
+
70
+
71
+ def eigenmodes(operator) -> LinearModes:
72
+ """Diagonalize ``operator`` and return modes sorted by decreasing growth."""
73
+
74
+ operator = jnp.asarray(operator)
75
+ eigenvalues, eigenvectors = jnp.linalg.eig(operator)
76
+ order = jnp.argsort(-eigenvalues.real)
77
+ eigenvalues = eigenvalues[order]
78
+ eigenvectors = eigenvectors[:, order]
79
+ return LinearModes(
80
+ growth_rates=eigenvalues.real,
81
+ frequencies=eigenvalues.imag,
82
+ eigenvalues=eigenvalues,
83
+ eigenvectors=eigenvectors,
84
+ )
85
+
86
+
87
+ def dominant_mode(operator) -> tuple[jnp.ndarray, jnp.ndarray]:
88
+ """Return ``(growth_rate, frequency)`` of the fastest-growing eigenmode."""
89
+
90
+ modes = eigenmodes(operator)
91
+ return modes.dominant_growth_rate, modes.dominant_frequency