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,133 @@
1
+ """Reduced isothermal scrape-off-layer (SOL) flux-tube parallel transport.
2
+
3
+ An open-field-line model: on the open slab geometry
4
+ (:func:`drbx.geometry.build_open_slab_geometry`), plasma is transported along
5
+ the field to the two target plates, where a Bohm sheath drains it at the sound
6
+ speed. The model evolves the parallel density ``n`` and parallel momentum
7
+ ``m = n v`` as an isothermal Euler system in the field-parallel coordinate ``z``,
8
+
9
+ d n / dt + d (n v) / dz = S_n
10
+ d m / dt + d (n v^2 + n c_s^2) / dz = 0
11
+
12
+ with an upstream particle source ``S_n`` and Bohm sheath outflow (``|v| >= c_s``)
13
+ at ``z = 0`` and ``z = L``. Faces use a Rusanov (local Lax--Friedrichs) flux; the
14
+ whole update is pure JAX (``jit``/``grad``/``vmap`` transparent). The steady
15
+ state is the classic two-point SOL solution: the flow accelerates from a
16
+ stagnation point to the sound speed at each target, and the target density is
17
+ half the upstream density.
18
+
19
+ The transport acts along the parallel (``z``) axis of the ``(nx, ny, nz)`` field
20
+ arrays, so each ``(x, y)`` column is an independent flux tube sharing the open
21
+ target plates.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+
28
+ import jax
29
+ import jax.numpy as jnp
30
+
31
+ from ..geometry import FciGeometry3D
32
+
33
+ __all__ = [
34
+ "SolFluxTubeParameters",
35
+ "sol_flux_tube_source",
36
+ "sol_flux_tube_rhs",
37
+ "sol_flux_tube_step",
38
+ "sol_flux_tube_run",
39
+ ]
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class SolFluxTubeParameters:
44
+ """Parameters of the reduced isothermal SOL flux tube."""
45
+
46
+ sound_speed: float = 1.0 # normalized isothermal sound speed c_s
47
+ source_amplitude: float = 0.02 # peak upstream particle source
48
+ source_width: float = 4.0 # parallel width of the upstream source
49
+ density_floor: float = 1.0e-6
50
+
51
+
52
+ def sol_flux_tube_source(geometry: FciGeometry3D, params: SolFluxTubeParameters) -> jnp.ndarray:
53
+ """Upstream particle source: a Gaussian centered at the parallel midplane."""
54
+
55
+ z = jnp.asarray(geometry.grid.z.centers, dtype=jnp.float64)
56
+ midplane = 0.5 * (float(z[0]) + float(z[-1]))
57
+ profile = params.source_amplitude * jnp.exp(-((z - midplane) ** 2) / params.source_width**2)
58
+ return jnp.broadcast_to(profile[None, None, :], geometry.shape)
59
+
60
+
61
+ def _flux(density: jnp.ndarray, momentum: jnp.ndarray, c_s: float) -> tuple[jnp.ndarray, jnp.ndarray]:
62
+ velocity = momentum / density
63
+ return momentum, momentum * velocity + density * c_s**2
64
+
65
+
66
+ def _rusanov(nL, mL, nR, mR, c_s):
67
+ fL_n, fL_m = _flux(nL, mL, c_s)
68
+ fR_n, fR_m = _flux(nR, mR, c_s)
69
+ speed = jnp.maximum(jnp.abs(mL / nL), jnp.abs(mR / nR)) + c_s
70
+ flux_n = 0.5 * (fL_n + fR_n) - 0.5 * speed * (nR - nL)
71
+ flux_m = 0.5 * (fL_m + fR_m) - 0.5 * speed * (mR - mL)
72
+ return flux_n, flux_m
73
+
74
+
75
+ def sol_flux_tube_rhs(
76
+ density: jnp.ndarray,
77
+ momentum: jnp.ndarray,
78
+ geometry: FciGeometry3D,
79
+ params: SolFluxTubeParameters,
80
+ source: jnp.ndarray,
81
+ ) -> tuple[jnp.ndarray, jnp.ndarray]:
82
+ """Parallel-transport RHS ``(dn/dt, dm/dt)`` with Bohm sheath targets."""
83
+
84
+ c_s = float(params.sound_speed)
85
+ dz = jnp.asarray(geometry.spacing.dz, dtype=jnp.float64)
86
+ n = jnp.maximum(density, float(params.density_floor))
87
+ v = momentum / n
88
+
89
+ # Interior faces along z (between adjacent cells).
90
+ interior_n, interior_m = _rusanov(n[:, :, :-1], momentum[:, :, :-1], n[:, :, 1:], momentum[:, :, 1:], c_s)
91
+
92
+ # Bohm sheath outflow at the two targets: |v| >= c_s pointing into the plate.
93
+ v_left = jnp.minimum(v[:, :, 0], -c_s)
94
+ v_right = jnp.maximum(v[:, :, -1], c_s)
95
+ left_n, left_m = _flux(n[:, :, 0], n[:, :, 0] * v_left, c_s)
96
+ right_n, right_m = _flux(n[:, :, -1], n[:, :, -1] * v_right, c_s)
97
+
98
+ face_n = jnp.concatenate([left_n[:, :, None], interior_n, right_n[:, :, None]], axis=2)
99
+ face_m = jnp.concatenate([left_m[:, :, None], interior_m, right_m[:, :, None]], axis=2)
100
+
101
+ d_density = -(face_n[:, :, 1:] - face_n[:, :, :-1]) / dz + source
102
+ d_momentum = -(face_m[:, :, 1:] - face_m[:, :, :-1]) / dz
103
+ return d_density, d_momentum
104
+
105
+
106
+ def sol_flux_tube_step(density, momentum, geometry, params, source, dt):
107
+ """One RK4 step of the reduced SOL flux tube (density floored to stay positive)."""
108
+
109
+ def rhs(n, m):
110
+ return sol_flux_tube_rhs(n, m, geometry, params, source)
111
+
112
+ k1n, k1m = rhs(density, momentum)
113
+ k2n, k2m = rhs(density + 0.5 * dt * k1n, momentum + 0.5 * dt * k1m)
114
+ k3n, k3m = rhs(density + 0.5 * dt * k2n, momentum + 0.5 * dt * k2m)
115
+ k4n, k4m = rhs(density + dt * k3n, momentum + dt * k3m)
116
+ density = density + dt / 6.0 * (k1n + 2 * k2n + 2 * k3n + k4n)
117
+ momentum = momentum + dt / 6.0 * (k1m + 2 * k2m + 2 * k3m + k4m)
118
+ return jnp.maximum(density, float(params.density_floor)), momentum
119
+
120
+
121
+ def sol_flux_tube_run(density, momentum, geometry, params, source, *, dt, steps):
122
+ """Advance ``steps`` RK4 steps with a jitted ``lax.scan``; return the final state."""
123
+
124
+ @jax.jit
125
+ def _run(n0, m0):
126
+ def body(carry, _):
127
+ n, m = carry
128
+ return sol_flux_tube_step(n, m, geometry, params, source, dt), None
129
+
130
+ (n_final, m_final), _ = jax.lax.scan(body, (n0, m0), None, length=steps)
131
+ return n_final, m_final
132
+
133
+ return _run(density, momentum)
@@ -0,0 +1,343 @@
1
+ """Reusable four-field stellarator turbulence driver (closed and open field lines).
2
+
3
+ A thin, reusable harness around the validated four-field interchange RK4 step:
4
+ seed a multi-mode density perturbation on a stellarator FCI geometry (rotating
5
+ ellipse, island divertor, ...) and let the curvature drive develop
6
+ turbulence-like interchange dynamics. On an open geometry (endpoint masks set,
7
+ e.g. via ``limiter_radius`` or traced island-divertor masks) a Bohm sheath
8
+ density sink drains the open-endpoint cells each step, so the scrape-off layer
9
+ empties onto the target while the core stays closed.
10
+
11
+ Public pieces, each reusable on its own:
12
+
13
+ - :func:`build_free_decay_boundary_conditions` -- Dirichlet phi / Neumann
14
+ fields at the radial walls, bundled as :class:`FourFieldBoundaryConditions`;
15
+ - :func:`build_four_field_phi_solver` -- the GMRES perpendicular-Laplacian
16
+ inverter used by the phi inversion;
17
+ - :func:`four_field_rk4_step` -- one classic RK4 step of the four-field blob
18
+ RHS (``compute_4field_blob_rhs``), carrying the phi guess between stages;
19
+ - :func:`multi_mode_state` -- the seeded multi-mode initial state;
20
+ - :func:`run_stellarator_turbulence` -- the full loop, returning a
21
+ :class:`TurbulenceRun` with frames and particle/sheath-flux traces.
22
+
23
+ Used by ``tests/test_stellarator_turbulence.py`` and
24
+ ``tests/test_island_divertor.py`` (fast physics gates). The example scripts in
25
+ ``examples/stellarator/`` show the same anatomy written out step by step.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import NamedTuple
31
+
32
+ import jax
33
+ import jax.numpy as jnp
34
+ import numpy as np
35
+
36
+ from ..geometry import (
37
+ ConservativeStencilBuilder,
38
+ FciGeometry3D,
39
+ LocalStencilBuilder,
40
+ RegularFaceGeometry3D,
41
+ build_conservative_stencil_from_field,
42
+ build_curvature_coefficients,
43
+ build_local_stencil_from_field,
44
+ )
45
+ from .fci_4_field_rhs import (
46
+ Fci4FieldBlobParameters,
47
+ Fci4FieldState,
48
+ compute_4field_blob_rhs,
49
+ )
50
+ from .fci_boundaries import (
51
+ BC_DIRICHLET,
52
+ BC_NEUMANN,
53
+ BoundaryFaceBC3D,
54
+ CutWallBC3D,
55
+ CutWallGeometry3D,
56
+ FourFieldBoundaryConditions,
57
+ )
58
+ from .fci_operators import PerpLaplacianInverseSolver, build_perp_laplacian_face_projectors
59
+ from .fci_sheath_recycling import compute_fci_sheath_recycling
60
+
61
+ TEMPERATURE = 1.0 # normalized Te = Ti for the sheath sound speed
62
+ SHEATH_DENSITY_FLOOR = 1.0e-4 # positivity floor after the sheath sink
63
+
64
+ __all__ = [
65
+ "TEMPERATURE",
66
+ "SHEATH_DENSITY_FLOOR",
67
+ "FourFieldBoundaryConditions",
68
+ "TurbulenceRun",
69
+ "apply_sheath_sink",
70
+ "build_free_decay_boundary_conditions",
71
+ "build_four_field_phi_solver",
72
+ "four_field_rk4_step",
73
+ "multi_mode_state",
74
+ "radial_dirichlet_face_bc",
75
+ "radial_neumann_face_bc",
76
+ "run_stellarator_turbulence",
77
+ ]
78
+
79
+
80
+ def radial_dirichlet_face_bc(geometry: FciGeometry3D) -> BoundaryFaceBC3D:
81
+ """Zero-Dirichlet at the two radial (x) walls; periodic elsewhere."""
82
+
83
+ face = RegularFaceGeometry3D.unit(geometry)
84
+ return BoundaryFaceBC3D(
85
+ kind_x=jnp.zeros_like(face.x_area, dtype=jnp.int32).at[0].set(BC_DIRICHLET).at[-1].set(BC_DIRICHLET),
86
+ kind_y=jnp.zeros_like(face.y_area, dtype=jnp.int32),
87
+ kind_z=jnp.zeros_like(face.z_area, dtype=jnp.int32),
88
+ value_x=jnp.zeros_like(face.x_area, dtype=jnp.float64),
89
+ value_y=jnp.zeros_like(face.y_area, dtype=jnp.float64),
90
+ value_z=jnp.zeros_like(face.z_area, dtype=jnp.float64),
91
+ mask_x=jnp.zeros_like(face.x_open_mask, dtype=bool).at[0].set(True).at[-1].set(True),
92
+ mask_y=jnp.zeros_like(face.y_open_mask, dtype=bool),
93
+ mask_z=jnp.zeros_like(face.z_open_mask, dtype=bool),
94
+ )
95
+
96
+
97
+ def radial_neumann_face_bc(geometry: FciGeometry3D) -> BoundaryFaceBC3D:
98
+ """Zero-Neumann at the two radial (x) walls; periodic elsewhere."""
99
+
100
+ face = RegularFaceGeometry3D.unit(geometry)
101
+ return BoundaryFaceBC3D(
102
+ kind_x=jnp.zeros_like(face.x_area, dtype=jnp.int32).at[0].set(BC_NEUMANN).at[-1].set(BC_NEUMANN),
103
+ kind_y=jnp.zeros_like(face.y_area, dtype=jnp.int32),
104
+ kind_z=jnp.zeros_like(face.z_area, dtype=jnp.int32),
105
+ value_x=jnp.zeros_like(face.x_area, dtype=jnp.float64),
106
+ value_y=jnp.zeros_like(face.y_area, dtype=jnp.float64),
107
+ value_z=jnp.zeros_like(face.z_area, dtype=jnp.float64),
108
+ mask_x=jnp.zeros_like(face.x_open_mask, dtype=bool).at[0].set(True).at[-1].set(True),
109
+ mask_y=jnp.zeros_like(face.y_open_mask, dtype=bool),
110
+ mask_z=jnp.zeros_like(face.z_open_mask, dtype=bool),
111
+ )
112
+
113
+
114
+ def build_free_decay_boundary_conditions(geometry: FciGeometry3D) -> FourFieldBoundaryConditions:
115
+ """Free-decay walls: Dirichlet phi = 0, zero-Neumann for the evolved fields."""
116
+
117
+ neumann = radial_neumann_face_bc(geometry)
118
+ return FourFieldBoundaryConditions(
119
+ phi_face_bc=radial_dirichlet_face_bc(geometry),
120
+ density_face_bc=neumann,
121
+ omega_face_bc=neumann,
122
+ v_ion_parallel_face_bc=neumann,
123
+ v_electron_parallel_face_bc=neumann,
124
+ )
125
+
126
+
127
+ def build_four_field_phi_solver(
128
+ geometry: FciGeometry3D,
129
+ parameters: Fci4FieldBlobParameters,
130
+ *,
131
+ conservative_stencil_builder: ConservativeStencilBuilder,
132
+ face_projectors: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray] | None,
133
+ ) -> PerpLaplacianInverseSolver:
134
+ """GMRES inverter for the perpendicular Laplacian used by the phi inversion."""
135
+
136
+ return PerpLaplacianInverseSolver(
137
+ geometry,
138
+ conservative_stencil_builder,
139
+ tol=float(parameters.phi_inversion_tol),
140
+ maxiter=int(parameters.phi_inversion_maxiter),
141
+ restart=int(parameters.phi_inversion_restart),
142
+ face_projectors=face_projectors,
143
+ regular_face_geometry=RegularFaceGeometry3D.unit(geometry),
144
+ cut_wall_geometry=CutWallGeometry3D.empty(),
145
+ cut_wall_bc=CutWallBC3D.empty(),
146
+ periodic_axes=(False, True, True),
147
+ pin_point=None,
148
+ pin_value=0.0,
149
+ project_mean_zero=False,
150
+ target_mean_phi=None,
151
+ regularization_epsilon=0.0,
152
+ gmres_debug=False,
153
+ # Hot-loop configuration: skip the per-solve host-synced residual
154
+ # check so the phi inversion (and any step built on it) can run fully
155
+ # inside jit. The validation harnesses build their own solvers with
156
+ # check_residual=True.
157
+ check_residual=False,
158
+ )
159
+
160
+
161
+ def four_field_rk4_step(
162
+ state: Fci4FieldState,
163
+ *,
164
+ geometry: FciGeometry3D,
165
+ timestep: float,
166
+ parameters: Fci4FieldBlobParameters,
167
+ curvature_coefficients: jnp.ndarray,
168
+ stencil_builder: LocalStencilBuilder,
169
+ conservative_stencil_builder: ConservativeStencilBuilder,
170
+ boundary_conditions: FourFieldBoundaryConditions,
171
+ phi_face_projectors: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray] | None,
172
+ phi_inverse_solver: PerpLaplacianInverseSolver,
173
+ phi_guess: jnp.ndarray | None = None,
174
+ ) -> tuple[Fci4FieldState, jnp.ndarray]:
175
+ """One classic RK4 step of the four-field blob RHS.
176
+
177
+ The electrostatic potential from each stage seeds the GMRES phi inversion of
178
+ the next, so passing the returned phi back in as ``phi_guess`` keeps the
179
+ inversion warm across steps. Returns ``(next_state, phi_of_last_stage)``.
180
+ """
181
+
182
+ common_kwargs = dict(
183
+ geometry=geometry,
184
+ stencil_builder=stencil_builder,
185
+ conservative_stencil_builder=conservative_stencil_builder,
186
+ parameters=parameters,
187
+ curvature_coefficients=curvature_coefficients,
188
+ boundary_conditions=boundary_conditions,
189
+ phi_face_projectors=phi_face_projectors,
190
+ phi_inverse_solver=phi_inverse_solver,
191
+ gmres_debug=False,
192
+ return_phi=True,
193
+ )
194
+
195
+ rhs_1, _timings, phi_1 = compute_4field_blob_rhs(state, phi_guess=phi_guess, **common_kwargs)
196
+ k1 = rhs_1.rhs
197
+ stage_1 = state.axpy(k1, scale=0.5 * timestep)
198
+
199
+ rhs_2, _timings, phi_2 = compute_4field_blob_rhs(stage_1, phi_guess=phi_1, **common_kwargs)
200
+ k2 = rhs_2.rhs
201
+ stage_2 = state.axpy(k2, scale=0.5 * timestep)
202
+
203
+ rhs_3, _timings, phi_3 = compute_4field_blob_rhs(stage_2, phi_guess=phi_2, **common_kwargs)
204
+ k3 = rhs_3.rhs
205
+ stage_3 = state.axpy(k3, scale=timestep)
206
+
207
+ rhs_4, _timings, phi_4 = compute_4field_blob_rhs(stage_3, phi_guess=phi_3, **common_kwargs)
208
+ k4 = rhs_4.rhs
209
+
210
+ increment = Fci4FieldState(
211
+ density=(k1.density + 2.0 * k2.density + 2.0 * k3.density + k4.density) / 6.0,
212
+ omega=(k1.omega + 2.0 * k2.omega + 2.0 * k3.omega + k4.omega) / 6.0,
213
+ v_ion_parallel=(
214
+ k1.v_ion_parallel + 2.0 * k2.v_ion_parallel + 2.0 * k3.v_ion_parallel + k4.v_ion_parallel
215
+ )
216
+ / 6.0,
217
+ v_electron_parallel=(
218
+ k1.v_electron_parallel
219
+ + 2.0 * k2.v_electron_parallel
220
+ + 2.0 * k3.v_electron_parallel
221
+ + k4.v_electron_parallel
222
+ )
223
+ / 6.0,
224
+ )
225
+ return state.axpy(increment, scale=timestep), phi_4
226
+
227
+
228
+ def apply_sheath_sink(
229
+ state: Fci4FieldState,
230
+ geometry: FciGeometry3D,
231
+ dt: float,
232
+ *,
233
+ temperature: float = TEMPERATURE,
234
+ ) -> tuple[Fci4FieldState, float]:
235
+ """Bohm sheath density sink on the open-endpoint cells (explicit Euler).
236
+
237
+ Returns the drained state and the total ion particle loss rate of the
238
+ *pre-sink* state (the instantaneous sheath flux to the target).
239
+ """
240
+
241
+ temperature_field = jnp.full(geometry.shape, float(temperature))
242
+ sheath = compute_fci_sheath_recycling(state.density, temperature_field, temperature_field, geometry.maps)
243
+ drained = Fci4FieldState(
244
+ density=jnp.maximum(state.density - dt * sheath.ion_particle_loss, SHEATH_DENSITY_FLOOR),
245
+ omega=state.omega,
246
+ v_ion_parallel=state.v_ion_parallel,
247
+ v_electron_parallel=state.v_electron_parallel,
248
+ )
249
+ return drained, float(sheath.total_ion_particle_loss)
250
+
251
+
252
+ def multi_mode_state(
253
+ geometry: FciGeometry3D,
254
+ *,
255
+ amplitude: float = 0.08,
256
+ seed: int = 0,
257
+ modes: tuple[tuple[int, int], ...] = ((2, 1), (3, 2), (4, 1), (5, 3)),
258
+ ) -> Fci4FieldState:
259
+ """Seed a random-phase multi-mode density perturbation with a radial envelope."""
260
+
261
+ rng = np.random.default_rng(seed)
262
+ x = np.asarray(geometry.grid.x.centers)[:, None, None]
263
+ theta = np.asarray(geometry.grid.y.centers)[None, :, None]
264
+ zeta = np.asarray(geometry.grid.z.centers)[None, None, :]
265
+ x_norm = (x - x.min()) / (x.max() - x.min())
266
+ envelope = np.sin(np.pi * x_norm)
267
+ perturbation = np.zeros(geometry.shape)
268
+ for m, n in modes:
269
+ perturbation += rng.uniform(0.5, 1.0) * np.cos(m * theta + n * zeta + rng.uniform(0, 2 * np.pi))
270
+ density = 1.0 + amplitude * envelope * perturbation
271
+ zeros = jnp.zeros(geometry.shape, dtype=jnp.float64)
272
+ return Fci4FieldState(density=jnp.asarray(density), omega=zeros,
273
+ v_ion_parallel=zeros, v_electron_parallel=zeros)
274
+
275
+
276
+ class TurbulenceRun(NamedTuple):
277
+ """Density/vorticity frames plus particle-content and sheath-flux traces."""
278
+
279
+ density_frames: np.ndarray # (n_frames, nx, ny, nz), float32
280
+ omega_frames: np.ndarray
281
+ times: np.ndarray
282
+ particle_content: np.ndarray # Jacobian-weighted total density per frame
283
+ target_flux: np.ndarray # total sheath ion loss per frame (0 when closed)
284
+
285
+
286
+ def run_stellarator_turbulence(geometry: FciGeometry3D, *, steps: int, dt: float,
287
+ amplitude: float = 0.08, seed: int = 0,
288
+ sheath_sink: bool = False, frame_stride: int = 1) -> TurbulenceRun:
289
+ """Advance the seeded four-field state; optionally drain open endpoints."""
290
+
291
+ parameters = Fci4FieldBlobParameters(rho_star=1.0, phi_inversion_tol=5.0e-5,
292
+ phi_inversion_maxiter=100, phi_inversion_restart=200)
293
+ stencil_builder = LocalStencilBuilder(build_local_stencil_from_field.build_fn)
294
+ conservative_builder = ConservativeStencilBuilder(build_conservative_stencil_from_field.build_fn)
295
+ boundary_conditions = build_free_decay_boundary_conditions(geometry)
296
+ curvature = build_curvature_coefficients(geometry, periodic_axes=(False, True, True))
297
+ projectors = build_perp_laplacian_face_projectors(geometry)
298
+ phi_solver = build_four_field_phi_solver(
299
+ geometry, parameters,
300
+ conservative_stencil_builder=conservative_builder, face_projectors=projectors,
301
+ )
302
+
303
+ temperature = jnp.full(geometry.shape, TEMPERATURE)
304
+ jacobian = np.asarray(geometry.cell_metric.J)
305
+ state = multi_mode_state(geometry, amplitude=amplitude, seed=seed)
306
+ phi_guess = jnp.zeros(geometry.shape, dtype=jnp.float64)
307
+ density_frames, omega_frames, times, content, flux = [], [], [], [], []
308
+
309
+ # The whole RK4 step (four RHS evaluations, each with a GMRES phi
310
+ # inversion) is sync-free, so compile it once and run it as a single
311
+ # XLA program.
312
+ jitted_step = jax.jit(
313
+ lambda current, guess: four_field_rk4_step(
314
+ current, geometry=geometry, timestep=dt, parameters=parameters,
315
+ curvature_coefficients=curvature, stencil_builder=stencil_builder,
316
+ conservative_stencil_builder=conservative_builder,
317
+ boundary_conditions=boundary_conditions, phi_face_projectors=projectors,
318
+ phi_inverse_solver=phi_solver, phi_guess=guess,
319
+ )
320
+ )
321
+
322
+ def record(step_index, current):
323
+ density = np.asarray(current.density, dtype=np.float32)
324
+ density_frames.append(density)
325
+ omega_frames.append(np.asarray(current.omega, dtype=np.float32))
326
+ times.append(step_index * dt)
327
+ content.append(float(np.sum(np.asarray(current.density, dtype=np.float64) * jacobian)))
328
+ if sheath_sink:
329
+ sheath = compute_fci_sheath_recycling(current.density, temperature, temperature, geometry.maps)
330
+ flux.append(float(sheath.total_ion_particle_loss))
331
+ else:
332
+ flux.append(0.0)
333
+
334
+ record(0, state)
335
+ for step_index in range(1, steps + 1):
336
+ state, phi_guess = jitted_step(state, phi_guess)
337
+ if sheath_sink:
338
+ state, _loss = apply_sheath_sink(state, geometry, dt)
339
+ if step_index % frame_stride == 0 or step_index == steps:
340
+ record(step_index, state)
341
+
342
+ return TurbulenceRun(np.stack(density_frames), np.stack(omega_frames),
343
+ np.asarray(times), np.asarray(content), np.asarray(flux))
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import jax.numpy as jnp
6
+ from jax.scipy.linalg import expm
7
+
8
+ from ..runtime import runtime_jax_dtype
9
+ from .mesh import StructuredMesh, apply_field_boundaries
10
+ from .metrics import StructuredMetrics
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class OneStepDiffusionResult:
15
+ density: jnp.ndarray
16
+ pressure: jnp.ndarray
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class DiffusionHistoryResult:
21
+ density_history: jnp.ndarray
22
+ pressure_history: jnp.ndarray
23
+
24
+
25
+ def advance_anomalous_diffusion_one_step(
26
+ density: jnp.ndarray,
27
+ pressure: jnp.ndarray,
28
+ *,
29
+ mesh: StructuredMesh,
30
+ metrics: StructuredMetrics,
31
+ anomalous_D: float,
32
+ density_boundary: str,
33
+ pressure_boundary: str,
34
+ timestep: float,
35
+ ) -> OneStepDiffusionResult:
36
+ dtype = runtime_jax_dtype()
37
+ if mesh.nz != 1:
38
+ raise NotImplementedError("Native one-step anomalous diffusion currently supports nz = 1 only.")
39
+ if density_boundary.strip().lower() != "neumann" or pressure_boundary.strip().lower() != "neumann":
40
+ raise NotImplementedError("Native one-step anomalous diffusion currently supports Neumann X boundaries only.")
41
+ tolerance = 1e-6 if dtype == jnp.float32 else 1e-12
42
+ if not jnp.allclose(density, pressure, rtol=tolerance, atol=tolerance):
43
+ raise NotImplementedError(
44
+ "Native one-step anomalous diffusion currently requires identical density and pressure initial states."
45
+ )
46
+ if not jnp.allclose(metrics.g23, 0.0, rtol=tolerance, atol=tolerance):
47
+ raise NotImplementedError("Native one-step anomalous diffusion currently supports g23 = 0 structured metrics only.")
48
+
49
+ operator = _build_radial_diffusion_operator(mesh, metrics, anomalous_D)
50
+ propagator = expm(operator * timestep)
51
+ density_next = _advance_field_with_operator(density, propagator, mesh, boundary_kind=density_boundary)
52
+ pressure_next = _advance_field_with_operator(pressure, propagator, mesh, boundary_kind=pressure_boundary)
53
+ return OneStepDiffusionResult(density=density_next, pressure=pressure_next)
54
+
55
+
56
+ def advance_anomalous_diffusion_history(
57
+ density: jnp.ndarray,
58
+ pressure: jnp.ndarray,
59
+ *,
60
+ mesh: StructuredMesh,
61
+ metrics: StructuredMetrics,
62
+ anomalous_D: float,
63
+ density_boundary: str,
64
+ pressure_boundary: str,
65
+ timestep: float,
66
+ steps: int,
67
+ ) -> DiffusionHistoryResult:
68
+ if steps < 0:
69
+ raise ValueError("steps must be non-negative")
70
+ if mesh.nz != 1:
71
+ raise NotImplementedError("Native anomalous diffusion history currently supports nz = 1 only.")
72
+ if density_boundary.strip().lower() != "neumann" or pressure_boundary.strip().lower() != "neumann":
73
+ raise NotImplementedError("Native anomalous diffusion history currently supports Neumann X boundaries only.")
74
+
75
+ dtype = runtime_jax_dtype()
76
+ operator = _build_radial_diffusion_operator(mesh, metrics, anomalous_D)
77
+ propagator = expm(operator * timestep)
78
+ density_history = [jnp.asarray(density, dtype=dtype)]
79
+ pressure_history = [jnp.asarray(pressure, dtype=dtype)]
80
+ current_density = density_history[0]
81
+ current_pressure = pressure_history[0]
82
+ for _ in range(steps):
83
+ current_density = _advance_field_with_operator(current_density, propagator, mesh, boundary_kind=density_boundary)
84
+ current_pressure = _advance_field_with_operator(current_pressure, propagator, mesh, boundary_kind=pressure_boundary)
85
+ density_history.append(current_density)
86
+ pressure_history.append(current_pressure)
87
+ return DiffusionHistoryResult(
88
+ density_history=jnp.stack(density_history, axis=0),
89
+ pressure_history=jnp.stack(pressure_history, axis=0),
90
+ )
91
+
92
+
93
+ def _build_radial_diffusion_operator(
94
+ mesh: StructuredMesh,
95
+ metrics: StructuredMetrics,
96
+ anomalous_D: float,
97
+ ) -> jnp.ndarray:
98
+ y_index = mesh.ystart
99
+ z_index = 0
100
+ dx = metrics.dx[:, y_index, z_index]
101
+ J = metrics.J[:, y_index, z_index]
102
+ g11 = metrics.g11[:, y_index, z_index]
103
+ dtype = runtime_jax_dtype()
104
+
105
+ interior_nx = mesh.xend - mesh.xstart + 1
106
+ matrix = jnp.zeros((interior_nx, interior_nx), dtype=dtype)
107
+
108
+ for global_index in range(mesh.xstart, mesh.xend):
109
+ left = global_index - mesh.xstart
110
+ right = left + 1
111
+ face_coef = anomalous_D * (J[global_index] * g11[global_index] + J[global_index + 1] * g11[global_index + 1])
112
+ face_coef /= dx[global_index] + dx[global_index + 1]
113
+
114
+ matrix = matrix.at[left, left].add(-face_coef / (dx[global_index] * J[global_index]))
115
+ matrix = matrix.at[left, right].add(face_coef / (dx[global_index] * J[global_index]))
116
+ matrix = matrix.at[right, left].add(face_coef / (dx[global_index + 1] * J[global_index + 1]))
117
+ matrix = matrix.at[right, right].add(-face_coef / (dx[global_index + 1] * J[global_index + 1]))
118
+
119
+ return matrix
120
+
121
+
122
+ def _advance_field_with_operator(
123
+ field: jnp.ndarray,
124
+ propagator: jnp.ndarray,
125
+ mesh: StructuredMesh,
126
+ *,
127
+ boundary_kind: str,
128
+ ) -> jnp.ndarray:
129
+ result = jnp.asarray(field, dtype=runtime_jax_dtype())
130
+ interior = result[mesh.xstart : mesh.xend + 1, mesh.ystart : mesh.yend + 1, 0]
131
+ updated = propagator @ interior
132
+ result = result.at[mesh.xstart : mesh.xend + 1, mesh.ystart : mesh.yend + 1, 0].set(updated)
133
+ result = apply_field_boundaries(result, mesh, x_boundary=boundary_kind)
134
+ return result
drbx/native/units.py ADDED
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from ..config.normalization import ELEMENTARY_CHARGE, PROTON_MASS
4
+ from ..runtime.run_config import RunConfiguration
5
+
6
+
7
+ def resolved_dataset_scalars(run_config: RunConfiguration) -> dict[str, float]:
8
+ if run_config.normalization is not None:
9
+ normalization = run_config.normalization
10
+ return {
11
+ "Nnorm": normalization.Nnorm,
12
+ "Tnorm": normalization.Tnorm,
13
+ "Bnorm": normalization.Bnorm,
14
+ "Cs0": normalization.Cs0,
15
+ "Omega_ci": normalization.Omega_ci,
16
+ "rho_s0": normalization.rho_s0,
17
+ }
18
+
19
+ Nnorm = float(run_config.model_scalars.get("Nnorm", 1.0e19))
20
+ Tnorm = float(run_config.model_scalars.get("Tnorm", 100.0))
21
+ Bnorm = float(run_config.model_scalars.get("Bnorm", 1.0))
22
+ Cs0 = float((ELEMENTARY_CHARGE * Tnorm / PROTON_MASS) ** 0.5)
23
+ Omega_ci = float(ELEMENTARY_CHARGE * Bnorm / PROTON_MASS)
24
+ rho_s0 = float(Cs0 / Omega_ci)
25
+ return {
26
+ "Nnorm": Nnorm,
27
+ "Tnorm": Tnorm,
28
+ "Bnorm": Bnorm,
29
+ "Cs0": Cs0,
30
+ "Omega_ci": Omega_ci,
31
+ "rho_s0": rho_s0,
32
+ }