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.
- drbx/__init__.py +20 -0
- drbx/__main__.py +5 -0
- drbx/cli.py +586 -0
- drbx/config/__init__.py +20 -0
- drbx/config/boutinp.py +556 -0
- drbx/config/model.py +34 -0
- drbx/config/normalization.py +66 -0
- drbx/data/atomic_rates/__init__.py +1 -0
- drbx/data/atomic_rates/iz_AMJUEL_H.x_2.1.5.json +213 -0
- drbx/data/atomic_rates/iz_AMJUEL_H.x_2.3.9a.json +213 -0
- drbx/data/atomic_rates/rec_AMJUEL_H.x_2.1.8.json +213 -0
- drbx/data/atomic_rates/rec_AMJUEL_H.x_2.3.13a.json +213 -0
- drbx/geometry/__init__.py +207 -0
- drbx/geometry/embedding.py +56 -0
- drbx/geometry/essos_import.py +1385 -0
- drbx/geometry/fci_geometry.py +4622 -0
- drbx/geometry/fci_maps.py +85 -0
- drbx/geometry/island_divertor.py +291 -0
- drbx/geometry/metric_tensor.py +99 -0
- drbx/geometry/open_slab.py +150 -0
- drbx/geometry/rotating_ellipse.py +253 -0
- drbx/geometry/shifted_torus.py +225 -0
- drbx/geometry/stellarator.py +287 -0
- drbx/geometry/vmec_extender_import.py +499 -0
- drbx/geometry/vmec_jax_import.py +306 -0
- drbx/linear/__init__.py +37 -0
- drbx/linear/dispersion.py +138 -0
- drbx/linear/eigen.py +91 -0
- drbx/native/__init__.py +224 -0
- drbx/native/array_backend.py +64 -0
- drbx/native/deck_runner.py +779 -0
- drbx/native/electromagnetic.py +250 -0
- drbx/native/expression.py +173 -0
- drbx/native/fci.py +295 -0
- drbx/native/fci_2_field_rhs.py +182 -0
- drbx/native/fci_4_field_rhs.py +1267 -0
- drbx/native/fci_boundaries.py +2494 -0
- drbx/native/fci_differentiable_case.py +304 -0
- drbx/native/fci_drb_EB_rhs.py +1243 -0
- drbx/native/fci_drb_rhs.py +190 -0
- drbx/native/fci_halo.py +1575 -0
- drbx/native/fci_helpers.py +350 -0
- drbx/native/fci_model.py +294 -0
- drbx/native/fci_neutral.py +139 -0
- drbx/native/fci_operators.py +4081 -0
- drbx/native/fci_sharding.py +597 -0
- drbx/native/fci_sheath_recycling.py +206 -0
- drbx/native/fci_time_integrator.py +96 -0
- drbx/native/fci_vorticity.py +198 -0
- drbx/native/fluid_1d.py +330 -0
- drbx/native/hasegawa_wakatani.py +196 -0
- drbx/native/limiters.py +57 -0
- drbx/native/mesh.py +238 -0
- drbx/native/metrics.py +234 -0
- drbx/native/neutrals/__init__.py +58 -0
- drbx/native/neutrals/atomic_rates.py +134 -0
- drbx/native/neutrals/detachment_sol_model.py +221 -0
- drbx/native/neutrals/reactions.py +164 -0
- drbx/native/neutrals/recycling_sol_model.py +197 -0
- drbx/native/sol_flux_tube.py +133 -0
- drbx/native/stellarator_turbulence.py +343 -0
- drbx/native/transport.py +134 -0
- drbx/native/units.py +32 -0
- drbx/native/vorticity.py +252 -0
- drbx/runtime/__init__.py +53 -0
- drbx/runtime/artifacts.py +161 -0
- drbx/runtime/memory.py +144 -0
- drbx/runtime/output.py +374 -0
- drbx/runtime/paths.py +9 -0
- drbx/runtime/performance.py +161 -0
- drbx/runtime/run_config.py +184 -0
- drbx/runtime/scheduler.py +99 -0
- drbx/runtime/state.py +40 -0
- drbx/validation/__init__.py +424 -0
- drbx/validation/autodiff_diffusion.py +329 -0
- drbx/validation/autodiff_diffusion_uncertainty.py +235 -0
- drbx/validation/diverted_tokamak_movie.py +558 -0
- drbx/validation/essos_fieldline_import_campaign.py +181 -0
- drbx/validation/essos_imported_artifact_audit.py +535 -0
- drbx/validation/essos_imported_drb_movie_campaign.py +2826 -0
- drbx/validation/essos_imported_fci_campaign.py +5241 -0
- drbx/validation/essos_imported_pytree_campaign.py +406 -0
- drbx/validation/essos_vmec_closed_field_campaign.py +314 -0
- drbx/validation/essos_vmec_closed_field_transient_campaign.py +629 -0
- drbx/validation/essos_vmec_fieldline_surface_campaign.py +620 -0
- drbx/validation/fluid_1d_mms_convergence.py +250 -0
- drbx/validation/geometry_lineouts.py +136 -0
- drbx/validation/geometry_slices.py +178 -0
- drbx/validation/publication_plotting.py +91 -0
- drbx/validation/stellarator_drb_pytree_campaign.py +621 -0
- drbx/validation/stellarator_fci_geometry_campaign.py +200 -0
- drbx/validation/stellarator_fci_operator_campaign.py +304 -0
- drbx/validation/stellarator_fci_suite_campaign.py +264 -0
- drbx/validation/stellarator_metric_mms_campaign.py +289 -0
- drbx/validation/stellarator_neutral_physics_campaign.py +255 -0
- drbx/validation/stellarator_sheath_recycling_campaign.py +331 -0
- drbx/validation/stellarator_sol_showcase.py +628 -0
- drbx/validation/stellarator_vorticity_campaign.py +304 -0
- drbx/validation/vmec_extender_edge_field_campaign.py +260 -0
- drbx/validation/vmec_extender_sol_smoke_campaign.py +365 -0
- drbx-2.0.0.dev0.dist-info/METADATA +380 -0
- drbx-2.0.0.dev0.dist-info/RECORD +106 -0
- drbx-2.0.0.dev0.dist-info/WHEEL +5 -0
- drbx-2.0.0.dev0.dist-info/entry_points.txt +2 -0
- drbx-2.0.0.dev0.dist-info/licenses/LICENSE +21 -0
- 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)))
|
drbx/linear/__init__.py
ADDED
|
@@ -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
|