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
drbx/native/vorticity.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import jax.numpy as jnp
|
|
6
|
+
import numpy as np
|
|
7
|
+
from jax.experimental.ode import odeint
|
|
8
|
+
|
|
9
|
+
from solvax import FourierHelmholtzOperator, build_fourier_helmholtz_operator, solve_fourier_helmholtz
|
|
10
|
+
from .limiters import monotonic_centered_edges_jax as _monotonic_centered_edges
|
|
11
|
+
from .mesh import StructuredMesh, apply_zero_dirichlet_x_guards, communicate_y_guards
|
|
12
|
+
from .metrics import StructuredMetrics
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
VorticityOperator = FourierHelmholtzOperator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class VorticityRhsResult:
|
|
20
|
+
vorticity: jnp.ndarray
|
|
21
|
+
potential: jnp.ndarray
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class VorticityHistoryResult:
|
|
26
|
+
vorticity_history: jnp.ndarray
|
|
27
|
+
potential_history: jnp.ndarray
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_vorticity_operator(
|
|
31
|
+
*,
|
|
32
|
+
mesh: StructuredMesh,
|
|
33
|
+
metrics: StructuredMetrics,
|
|
34
|
+
average_atomic_mass: float,
|
|
35
|
+
) -> VorticityOperator:
|
|
36
|
+
_require_supported_vorticity_mesh(mesh, metrics=metrics)
|
|
37
|
+
|
|
38
|
+
x_slice = slice(mesh.xstart, mesh.xend + 1)
|
|
39
|
+
y_index = mesh.ystart
|
|
40
|
+
|
|
41
|
+
dx = jnp.asarray(metrics.dx[x_slice, y_index, 0], dtype=jnp.float64)
|
|
42
|
+
dz = jnp.asarray(metrics.dz[x_slice, y_index, 0], dtype=jnp.float64)
|
|
43
|
+
g11 = jnp.asarray(metrics.g11[x_slice, y_index, 0], dtype=jnp.float64)
|
|
44
|
+
g33 = jnp.asarray(metrics.g33[x_slice, y_index, 0], dtype=jnp.float64)
|
|
45
|
+
rhs_scale = jnp.asarray(metrics.Bxy[x_slice, y_index, 0], dtype=jnp.float64)
|
|
46
|
+
rhs_scale = (rhs_scale * rhs_scale) / jnp.asarray(average_atomic_mass, dtype=jnp.float64)
|
|
47
|
+
|
|
48
|
+
return VorticityOperator(
|
|
49
|
+
**build_fourier_helmholtz_operator(
|
|
50
|
+
dx=dx,
|
|
51
|
+
dz=dz,
|
|
52
|
+
g11=g11,
|
|
53
|
+
g33=g33,
|
|
54
|
+
rhs_scale=rhs_scale,
|
|
55
|
+
nz=mesh.nz,
|
|
56
|
+
).__dict__,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def apply_vorticity_boundaries(field: jnp.ndarray, mesh: StructuredMesh) -> jnp.ndarray:
|
|
61
|
+
result = apply_zero_dirichlet_x_guards(field, mesh)
|
|
62
|
+
if mesh.myg > 0:
|
|
63
|
+
result = communicate_y_guards(result, mesh)
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def solve_potential(
|
|
68
|
+
vorticity: jnp.ndarray,
|
|
69
|
+
*,
|
|
70
|
+
mesh: StructuredMesh,
|
|
71
|
+
operator: VorticityOperator,
|
|
72
|
+
) -> jnp.ndarray:
|
|
73
|
+
guarded = apply_vorticity_boundaries(vorticity, mesh)
|
|
74
|
+
y_index = mesh.ystart
|
|
75
|
+
interior = jnp.asarray(guarded[mesh.xstart : mesh.xend + 1, y_index, :], dtype=jnp.float64)
|
|
76
|
+
interior_phi = solve_fourier_helmholtz(interior, operator=operator)
|
|
77
|
+
|
|
78
|
+
potential = jnp.zeros_like(guarded, dtype=jnp.float64)
|
|
79
|
+
potential = potential.at[mesh.xstart : mesh.xend + 1, y_index, :].set(interior_phi)
|
|
80
|
+
potential = _apply_potential_boundaries(potential, mesh)
|
|
81
|
+
if mesh.myg > 0:
|
|
82
|
+
potential = communicate_y_guards(potential, mesh)
|
|
83
|
+
return potential
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def compute_vorticity_rhs(
|
|
87
|
+
vorticity: jnp.ndarray,
|
|
88
|
+
*,
|
|
89
|
+
mesh: StructuredMesh,
|
|
90
|
+
metrics: StructuredMetrics,
|
|
91
|
+
operator: VorticityOperator,
|
|
92
|
+
) -> VorticityRhsResult:
|
|
93
|
+
guarded = apply_vorticity_boundaries(vorticity, mesh)
|
|
94
|
+
potential = solve_potential(guarded, mesh=mesh, operator=operator)
|
|
95
|
+
result = jnp.zeros_like(guarded, dtype=jnp.float64)
|
|
96
|
+
|
|
97
|
+
xs, xe = mesh.xstart, mesh.xend
|
|
98
|
+
interior = slice(xs, xe + 1)
|
|
99
|
+
|
|
100
|
+
def roll_km(array: jnp.ndarray) -> jnp.ndarray:
|
|
101
|
+
return jnp.roll(array, 1, axis=-1)
|
|
102
|
+
|
|
103
|
+
def roll_kp(array: jnp.ndarray) -> jnp.ndarray:
|
|
104
|
+
return jnp.roll(array, -1, axis=-1)
|
|
105
|
+
|
|
106
|
+
def shift_from_left(array: jnp.ndarray) -> jnp.ndarray:
|
|
107
|
+
return jnp.concatenate([jnp.zeros_like(array[:1]), array[:-1]], axis=0)
|
|
108
|
+
|
|
109
|
+
def shift_from_right(array: jnp.ndarray) -> jnp.ndarray:
|
|
110
|
+
return jnp.concatenate([array[1:], jnp.zeros_like(array[:1])], axis=0)
|
|
111
|
+
|
|
112
|
+
for j in range(mesh.ystart, mesh.yend + 1):
|
|
113
|
+
phi = potential[:, j, :]
|
|
114
|
+
phi_c = phi[interior]
|
|
115
|
+
phi_m = phi[xs - 1 : xe]
|
|
116
|
+
phi_p = phi[xs + 1 : xe + 2]
|
|
117
|
+
|
|
118
|
+
# Corner-averaged potential at the four cell corners (i∓1/2, k∓1/2).
|
|
119
|
+
fmm = 0.25 * (phi_c + phi_m + roll_km(phi_c) + roll_km(phi_m))
|
|
120
|
+
fmp = 0.25 * (phi_c + roll_kp(phi_c) + phi_m + roll_kp(phi_m))
|
|
121
|
+
fpp = 0.25 * (phi_c + roll_kp(phi_c) + phi_p + roll_kp(phi_p))
|
|
122
|
+
fpm = 0.25 * (phi_c + phi_p + roll_km(phi_c) + roll_km(phi_p))
|
|
123
|
+
|
|
124
|
+
dx_c = jnp.asarray(metrics.dx[interior, j, 0], dtype=jnp.float64)[:, None]
|
|
125
|
+
dz_c = jnp.asarray(metrics.dz[interior, j, 0], dtype=jnp.float64)[:, None]
|
|
126
|
+
J_c = jnp.asarray(metrics.J[interior, j, 0], dtype=jnp.float64)[:, None]
|
|
127
|
+
J_m = jnp.asarray(metrics.J[xs - 1 : xe, j, 0], dtype=jnp.float64)[:, None]
|
|
128
|
+
J_p = jnp.asarray(metrics.J[xs + 1 : xe + 2, j, 0], dtype=jnp.float64)[:, None]
|
|
129
|
+
|
|
130
|
+
v_up = J_c * (fmp - fpp) / dx_c
|
|
131
|
+
v_down = J_c * (fmm - fpm) / dx_c
|
|
132
|
+
v_right = (0.5 * (J_c + J_p)) * (fpp - fpm) / dz_c
|
|
133
|
+
v_left = (0.5 * (J_c + J_m)) * (fmp - fmm) / dz_c
|
|
134
|
+
|
|
135
|
+
w_c = guarded[interior, j, :]
|
|
136
|
+
x_left_face, x_right_face = _mc_cell_edges(w_c, guarded[xs - 1 : xe, j, :], guarded[xs + 1 : xe + 2, j, :])
|
|
137
|
+
z_left_face, z_right_face = _mc_cell_edges(w_c, roll_km(w_c), roll_kp(w_c))
|
|
138
|
+
|
|
139
|
+
# Upwinded face fluxes; x-fluxes vanish at the outer faces (bndry_flux = false).
|
|
140
|
+
flux_right = jnp.where(v_right > 0.0, v_right * x_right_face, 0.0).at[-1].set(0.0)
|
|
141
|
+
flux_left = jnp.where(v_left < 0.0, v_left * x_left_face, 0.0).at[0].set(0.0)
|
|
142
|
+
flux_up = jnp.where(v_up > 0.0, v_up * z_right_face / (J_c * dz_c), 0.0)
|
|
143
|
+
flux_down = jnp.where(v_down < 0.0, v_down * z_left_face / (J_c * dz_c), 0.0)
|
|
144
|
+
|
|
145
|
+
# Flux-conservative scatter pairs written as differences of shifted arrays.
|
|
146
|
+
dxJ = dx_c * J_c
|
|
147
|
+
total = (
|
|
148
|
+
-shift_from_left(flux_right) / dxJ
|
|
149
|
+
- roll_km(flux_up)
|
|
150
|
+
+ flux_right / dxJ
|
|
151
|
+
- flux_left / dxJ
|
|
152
|
+
+ flux_up
|
|
153
|
+
- flux_down
|
|
154
|
+
+ roll_kp(flux_down)
|
|
155
|
+
+ shift_from_right(flux_left) / dxJ
|
|
156
|
+
)
|
|
157
|
+
result = result.at[interior, j, :].set(total)
|
|
158
|
+
|
|
159
|
+
return VorticityRhsResult(
|
|
160
|
+
vorticity=-result,
|
|
161
|
+
potential=potential,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def advance_vorticity_history(
|
|
166
|
+
initial_vorticity: jnp.ndarray,
|
|
167
|
+
*,
|
|
168
|
+
mesh: StructuredMesh,
|
|
169
|
+
metrics: StructuredMetrics,
|
|
170
|
+
operator: VorticityOperator,
|
|
171
|
+
timestep: float,
|
|
172
|
+
steps: int,
|
|
173
|
+
start_time: float = 0.0,
|
|
174
|
+
rtol: float = 1e-6,
|
|
175
|
+
atol: float = 1e-8,
|
|
176
|
+
mxstep: int = 20000,
|
|
177
|
+
) -> VorticityHistoryResult:
|
|
178
|
+
if steps < 0:
|
|
179
|
+
raise ValueError("steps must be non-negative")
|
|
180
|
+
|
|
181
|
+
y0 = jnp.ravel(jnp.asarray(initial_vorticity[mesh.xstart : mesh.xend + 1, mesh.ystart, :], dtype=jnp.float64))
|
|
182
|
+
times = jnp.asarray([start_time + timestep * index for index in range(steps + 1)], dtype=jnp.float64)
|
|
183
|
+
|
|
184
|
+
def rhs_flat(interior_state: jnp.ndarray, time: jnp.ndarray) -> jnp.ndarray:
|
|
185
|
+
del time
|
|
186
|
+
full_state = _assemble_state(interior_state, mesh)
|
|
187
|
+
rhs = compute_vorticity_rhs(full_state, mesh=mesh, metrics=metrics, operator=operator).vorticity
|
|
188
|
+
return jnp.ravel(rhs[mesh.xstart : mesh.xend + 1, mesh.ystart, :])
|
|
189
|
+
|
|
190
|
+
interior_history = odeint(rhs_flat, y0, times, rtol=rtol, atol=atol, mxstep=mxstep)
|
|
191
|
+
|
|
192
|
+
vorticity_history = []
|
|
193
|
+
potential_history = []
|
|
194
|
+
for index, interior_state in enumerate(interior_history):
|
|
195
|
+
full_state = _assemble_state(interior_state, mesh)
|
|
196
|
+
vorticity_history.append(full_state)
|
|
197
|
+
if index == 0:
|
|
198
|
+
potential_history.append(jnp.zeros_like(full_state, dtype=jnp.float64))
|
|
199
|
+
else:
|
|
200
|
+
potential_history.append(solve_potential(full_state, mesh=mesh, operator=operator))
|
|
201
|
+
|
|
202
|
+
return VorticityHistoryResult(
|
|
203
|
+
vorticity_history=jnp.stack(vorticity_history, axis=0),
|
|
204
|
+
potential_history=jnp.stack(potential_history, axis=0),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _mc_cell_edges(center: jnp.ndarray, minus: jnp.ndarray, plus: jnp.ndarray) -> tuple[jnp.ndarray, jnp.ndarray]:
|
|
209
|
+
return _monotonic_centered_edges(center, minus, plus)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _apply_potential_boundaries(field: jnp.ndarray, mesh: StructuredMesh) -> jnp.ndarray:
|
|
213
|
+
if mesh.mxg != 2:
|
|
214
|
+
raise NotImplementedError("Native electrostatic potential boundaries currently require MXG = 2.")
|
|
215
|
+
|
|
216
|
+
result = jnp.asarray(field, dtype=jnp.float64)
|
|
217
|
+
rows = slice(mesh.ystart, mesh.yend + 1)
|
|
218
|
+
result = result.at[mesh.xstart - 1, rows, :].set(-result[mesh.xstart, rows, :])
|
|
219
|
+
result = result.at[mesh.xend + 1, rows, :].set(-result[mesh.xend, rows, :])
|
|
220
|
+
result = result.at[mesh.xstart - 2, rows, :].set(result[mesh.xstart - 1, rows, :])
|
|
221
|
+
result = result.at[mesh.xend + 2, rows, :].set(result[mesh.xend + 1, rows, :])
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _assemble_state(interior_state: jnp.ndarray, mesh: StructuredMesh) -> jnp.ndarray:
|
|
226
|
+
interior = jnp.reshape(interior_state, (mesh.xend - mesh.xstart + 1, mesh.nz))
|
|
227
|
+
full = jnp.zeros((mesh.nx, mesh.local_ny, mesh.nz), dtype=jnp.float64)
|
|
228
|
+
full = full.at[mesh.xstart : mesh.xend + 1, mesh.ystart, :].set(interior)
|
|
229
|
+
return apply_vorticity_boundaries(full, mesh)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _require_supported_vorticity_mesh(mesh: StructuredMesh, *, metrics: StructuredMetrics) -> None:
|
|
233
|
+
if mesh.mxg != 2:
|
|
234
|
+
raise NotImplementedError("Native electrostatic vorticity currently requires MXG = 2.")
|
|
235
|
+
if mesh.ny != 1 or mesh.myg != 0:
|
|
236
|
+
raise NotImplementedError("Native electrostatic vorticity currently requires ny = 1 with MYG = 0.")
|
|
237
|
+
if not np.allclose(np.asarray(metrics.g23), 0.0, rtol=1e-12, atol=1e-12):
|
|
238
|
+
raise NotImplementedError("Native electrostatic vorticity currently requires g23 = 0.")
|
|
239
|
+
|
|
240
|
+
x_slice = slice(mesh.xstart, mesh.xend + 1)
|
|
241
|
+
y_index = mesh.ystart
|
|
242
|
+
for name, array in {
|
|
243
|
+
"dx": metrics.dx[x_slice, y_index, 0],
|
|
244
|
+
"dz": metrics.dz[x_slice, y_index, 0],
|
|
245
|
+
"J": metrics.J[x_slice, y_index, 0],
|
|
246
|
+
"g11": metrics.g11[x_slice, y_index, 0],
|
|
247
|
+
"g33": metrics.g33[x_slice, y_index, 0],
|
|
248
|
+
"Bxy": metrics.Bxy[x_slice, y_index, 0],
|
|
249
|
+
}.items():
|
|
250
|
+
values = np.asarray(array, dtype=np.float64)
|
|
251
|
+
if not np.allclose(values, values[:1], rtol=1e-12, atol=1e-12):
|
|
252
|
+
raise NotImplementedError(f"Native electrostatic vorticity currently requires uniform {name}.")
|
drbx/runtime/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from .artifacts import (
|
|
2
|
+
ARTIFACT_BASE_URL,
|
|
3
|
+
ARTIFACT_RELEASE_TAG,
|
|
4
|
+
DOCS_MEDIA_ASSET,
|
|
5
|
+
ensure_docs_media,
|
|
6
|
+
)
|
|
7
|
+
from .performance import (
|
|
8
|
+
configure_jax_runtime,
|
|
9
|
+
resolve_host_device_count,
|
|
10
|
+
resolve_runtime_precision,
|
|
11
|
+
runtime_jax_dtype,
|
|
12
|
+
runtime_numpy_dtype,
|
|
13
|
+
runtime_parallel_summary,
|
|
14
|
+
)
|
|
15
|
+
from .memory import PeakRssMeasurement, bytes_to_mebibytes, measure_peak_rss, process_tree_rss_bytes
|
|
16
|
+
from .output import RestartBundle, build_run_log_payload, format_run_log_text, load_restart_bundle, print_run_log, write_restart_bundle, write_run_log_payload
|
|
17
|
+
from .scheduler import ComponentRequest, Scheduler, SupportsSchedulerHooks, expand_component_requests
|
|
18
|
+
from .run_config import MeshScalarConfig, ParallelTransformConfig, RunConfiguration, SolverConfig, TimeConfig
|
|
19
|
+
from .state import SimulationState
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ComponentRequest",
|
|
23
|
+
"ARTIFACT_BASE_URL",
|
|
24
|
+
"ARTIFACT_RELEASE_TAG",
|
|
25
|
+
"RestartBundle",
|
|
26
|
+
"build_run_log_payload",
|
|
27
|
+
"configure_jax_runtime",
|
|
28
|
+
"DOCS_MEDIA_ASSET",
|
|
29
|
+
"format_run_log_text",
|
|
30
|
+
"load_restart_bundle",
|
|
31
|
+
"MeshScalarConfig",
|
|
32
|
+
"ParallelTransformConfig",
|
|
33
|
+
"PeakRssMeasurement",
|
|
34
|
+
"bytes_to_mebibytes",
|
|
35
|
+
"ensure_docs_media",
|
|
36
|
+
"print_run_log",
|
|
37
|
+
"process_tree_rss_bytes",
|
|
38
|
+
"resolve_host_device_count",
|
|
39
|
+
"resolve_runtime_precision",
|
|
40
|
+
"RunConfiguration",
|
|
41
|
+
"measure_peak_rss",
|
|
42
|
+
"runtime_jax_dtype",
|
|
43
|
+
"runtime_numpy_dtype",
|
|
44
|
+
"runtime_parallel_summary",
|
|
45
|
+
"Scheduler",
|
|
46
|
+
"SimulationState",
|
|
47
|
+
"SolverConfig",
|
|
48
|
+
"SupportsSchedulerHooks",
|
|
49
|
+
"TimeConfig",
|
|
50
|
+
"expand_component_requests",
|
|
51
|
+
"write_restart_bundle",
|
|
52
|
+
"write_run_log_payload",
|
|
53
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
import urllib.request
|
|
9
|
+
import zipfile
|
|
10
|
+
|
|
11
|
+
from .paths import repo_root
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
ARTIFACT_RELEASE_TAG = "validation-artifacts-2026-04-28"
|
|
15
|
+
ARTIFACT_BASE_URL = f"https://github.com/uwplasma/drbx/releases/download/{ARTIFACT_RELEASE_TAG}"
|
|
16
|
+
DOCS_MEDIA_ASSET = "drbx_docs_media.zip"
|
|
17
|
+
|
|
18
|
+
DOCS_MEDIA_SENTINELS = (
|
|
19
|
+
Path(
|
|
20
|
+
"docs/data/diverted_tokamak_turbulence_artifacts/movies/"
|
|
21
|
+
"diverted_tokamak_turbulence.gif"
|
|
22
|
+
),
|
|
23
|
+
Path(
|
|
24
|
+
"docs/data/tokamak_tcv_x21_toroidal_movie_artifacts/movies/"
|
|
25
|
+
"tokamak_tcv_x21_toroidal.gif"
|
|
26
|
+
),
|
|
27
|
+
Path(
|
|
28
|
+
"docs/data/stellarator_fci_validation_artifacts/showcase/movies/"
|
|
29
|
+
"stellarator_sol_showcase.gif"
|
|
30
|
+
),
|
|
31
|
+
Path(
|
|
32
|
+
"docs/data/essos_imported_drb_movie_stationarity_jacobi_media/movies/"
|
|
33
|
+
"movie_compact.gif"
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ensure_docs_media(
|
|
39
|
+
*,
|
|
40
|
+
root: str | Path | None = None,
|
|
41
|
+
base_url: str | None = None,
|
|
42
|
+
force: bool = False,
|
|
43
|
+
) -> Path:
|
|
44
|
+
"""Restore release-backed documentation figures, movies, and NPZ arrays."""
|
|
45
|
+
|
|
46
|
+
resolved_root = Path(root) if root is not None else repo_root()
|
|
47
|
+
missing = [
|
|
48
|
+
relative_path
|
|
49
|
+
for relative_path in DOCS_MEDIA_SENTINELS
|
|
50
|
+
if not (resolved_root / relative_path).exists()
|
|
51
|
+
]
|
|
52
|
+
if not force and not missing:
|
|
53
|
+
return resolved_root / "docs" / "data"
|
|
54
|
+
if os.environ.get("DRBX_OFFLINE_ARTIFACTS", "").lower() in {"1", "true", "yes"}:
|
|
55
|
+
raise FileNotFoundError(
|
|
56
|
+
"Docs media are not present and DRBX_OFFLINE_ARTIFACTS is enabled."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
cache_dir = _artifact_cache_dir(resolved_root)
|
|
60
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
archive_path = cache_dir / DOCS_MEDIA_ASSET
|
|
62
|
+
if force or not archive_path.exists():
|
|
63
|
+
resolved_base_url = (
|
|
64
|
+
base_url or os.environ.get("DRBX_ARTIFACT_BASE_URL") or ARTIFACT_BASE_URL
|
|
65
|
+
).rstrip("/")
|
|
66
|
+
_download_release_asset(
|
|
67
|
+
f"{resolved_base_url}/{DOCS_MEDIA_ASSET}",
|
|
68
|
+
archive_path,
|
|
69
|
+
asset_name=DOCS_MEDIA_ASSET,
|
|
70
|
+
)
|
|
71
|
+
with zipfile.ZipFile(archive_path) as archive:
|
|
72
|
+
archive.extractall(resolved_root)
|
|
73
|
+
remaining = [
|
|
74
|
+
str(relative_path)
|
|
75
|
+
for relative_path in DOCS_MEDIA_SENTINELS
|
|
76
|
+
if not (resolved_root / relative_path).exists()
|
|
77
|
+
]
|
|
78
|
+
if remaining:
|
|
79
|
+
raise FileNotFoundError(
|
|
80
|
+
"Docs media artifact archive did not restore expected files under "
|
|
81
|
+
f"{resolved_root}: {', '.join(remaining)}"
|
|
82
|
+
)
|
|
83
|
+
return resolved_root / "docs" / "data"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _download_release_asset(url: str, destination: Path, *, asset_name: str) -> None:
|
|
87
|
+
if _download_with_gh(asset_name, destination):
|
|
88
|
+
return
|
|
89
|
+
_download_with_urllib(url, destination)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _artifact_cache_dir(root: Path) -> Path:
|
|
93
|
+
configured = (
|
|
94
|
+
os.environ.get("DRBX_ARTIFACT_CACHE_DIR")
|
|
95
|
+
or os.environ.get("DRBX_ARTIFACT_CACHE")
|
|
96
|
+
)
|
|
97
|
+
if configured:
|
|
98
|
+
return Path(configured).expanduser()
|
|
99
|
+
return root / ".drbx_artifact_cache"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _download_with_gh(asset_name: str, destination: Path) -> bool:
|
|
103
|
+
gh = shutil.which("gh")
|
|
104
|
+
if gh is None:
|
|
105
|
+
return False
|
|
106
|
+
completed = subprocess.run(
|
|
107
|
+
[
|
|
108
|
+
gh,
|
|
109
|
+
"release",
|
|
110
|
+
"download",
|
|
111
|
+
ARTIFACT_RELEASE_TAG,
|
|
112
|
+
"--repo",
|
|
113
|
+
"uwplasma/drbx",
|
|
114
|
+
"--pattern",
|
|
115
|
+
asset_name,
|
|
116
|
+
"--dir",
|
|
117
|
+
str(destination.parent),
|
|
118
|
+
"--clobber",
|
|
119
|
+
],
|
|
120
|
+
check=False,
|
|
121
|
+
stdout=subprocess.DEVNULL,
|
|
122
|
+
stderr=subprocess.DEVNULL,
|
|
123
|
+
text=True,
|
|
124
|
+
)
|
|
125
|
+
downloaded = destination.parent / asset_name
|
|
126
|
+
if completed.returncode == 0 and downloaded.exists():
|
|
127
|
+
if downloaded != destination:
|
|
128
|
+
downloaded.replace(destination)
|
|
129
|
+
return True
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _download_with_urllib(url: str, destination: Path) -> None:
|
|
134
|
+
temporary = destination.with_suffix(destination.suffix + ".tmp")
|
|
135
|
+
request = urllib.request.Request(url)
|
|
136
|
+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
137
|
+
if token:
|
|
138
|
+
request.add_header("Authorization", f"Bearer {token}")
|
|
139
|
+
timeout = float(os.environ.get("DRBX_ARTIFACT_DOWNLOAD_TIMEOUT", "120"))
|
|
140
|
+
attempts = max(1, int(os.environ.get("DRBX_ARTIFACT_DOWNLOAD_ATTEMPTS", "3")))
|
|
141
|
+
last_error: Exception | None = None
|
|
142
|
+
for attempt in range(1, attempts + 1):
|
|
143
|
+
try:
|
|
144
|
+
with urllib.request.urlopen(request, timeout=timeout) as response, temporary.open(
|
|
145
|
+
"wb"
|
|
146
|
+
) as output:
|
|
147
|
+
while True:
|
|
148
|
+
chunk = response.read(1024 * 1024)
|
|
149
|
+
if not chunk:
|
|
150
|
+
break
|
|
151
|
+
output.write(chunk)
|
|
152
|
+
temporary.replace(destination)
|
|
153
|
+
return
|
|
154
|
+
except Exception as error: # pragma: no cover - type depends on urllib backend
|
|
155
|
+
last_error = error
|
|
156
|
+
if temporary.exists():
|
|
157
|
+
temporary.unlink()
|
|
158
|
+
if attempt < attempts:
|
|
159
|
+
time.sleep(min(2.0, 0.25 * attempt))
|
|
160
|
+
assert last_error is not None
|
|
161
|
+
raise last_error
|
drbx/runtime/memory.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class PeakRssMeasurement:
|
|
16
|
+
start_rss_bytes: int | None
|
|
17
|
+
end_rss_bytes: int | None
|
|
18
|
+
peak_rss_bytes: int | None
|
|
19
|
+
peak_rss_delta_bytes: int | None
|
|
20
|
+
sample_count: int
|
|
21
|
+
sampling_interval_seconds: float
|
|
22
|
+
status: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def measure_peak_rss(
|
|
26
|
+
function: Callable[[], T],
|
|
27
|
+
*,
|
|
28
|
+
sampling_interval_seconds: float = 0.10,
|
|
29
|
+
) -> tuple[T, PeakRssMeasurement]:
|
|
30
|
+
samples: list[int] = []
|
|
31
|
+
sample_failures = 0
|
|
32
|
+
stop_event = threading.Event()
|
|
33
|
+
root_pid = os.getpid()
|
|
34
|
+
|
|
35
|
+
def append_sample() -> None:
|
|
36
|
+
nonlocal sample_failures
|
|
37
|
+
value = process_tree_rss_bytes(root_pid)
|
|
38
|
+
if value is None:
|
|
39
|
+
sample_failures += 1
|
|
40
|
+
return
|
|
41
|
+
samples.append(value)
|
|
42
|
+
|
|
43
|
+
append_sample()
|
|
44
|
+
|
|
45
|
+
def sample_loop() -> None:
|
|
46
|
+
while not stop_event.wait(sampling_interval_seconds):
|
|
47
|
+
append_sample()
|
|
48
|
+
|
|
49
|
+
sampler = threading.Thread(target=sample_loop, name="drbx-rss-sampler", daemon=True)
|
|
50
|
+
sampler.start()
|
|
51
|
+
try:
|
|
52
|
+
result = function()
|
|
53
|
+
finally:
|
|
54
|
+
stop_event.set()
|
|
55
|
+
sampler.join(timeout=max(1.0, 4.0 * sampling_interval_seconds))
|
|
56
|
+
append_sample()
|
|
57
|
+
start_rss_bytes = samples[0] if samples else None
|
|
58
|
+
end_rss_bytes = samples[-1] if samples else None
|
|
59
|
+
peak_rss_bytes = max(samples) if samples else None
|
|
60
|
+
peak_rss_delta_bytes = (
|
|
61
|
+
None
|
|
62
|
+
if peak_rss_bytes is None or start_rss_bytes is None
|
|
63
|
+
else max(int(peak_rss_bytes - start_rss_bytes), 0)
|
|
64
|
+
)
|
|
65
|
+
status = "sampled_process_tree_rss"
|
|
66
|
+
if not samples:
|
|
67
|
+
status = "unavailable"
|
|
68
|
+
elif sample_failures:
|
|
69
|
+
status = "sampled_process_tree_rss_with_partial_failures"
|
|
70
|
+
return result, PeakRssMeasurement(
|
|
71
|
+
start_rss_bytes=start_rss_bytes,
|
|
72
|
+
end_rss_bytes=end_rss_bytes,
|
|
73
|
+
peak_rss_bytes=peak_rss_bytes,
|
|
74
|
+
peak_rss_delta_bytes=peak_rss_delta_bytes,
|
|
75
|
+
sample_count=len(samples),
|
|
76
|
+
sampling_interval_seconds=float(sampling_interval_seconds),
|
|
77
|
+
status=status,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def process_tree_rss_bytes(root_pid: int) -> int | None:
|
|
82
|
+
pids = process_tree_pids(root_pid)
|
|
83
|
+
rss_kib = 0
|
|
84
|
+
observed = False
|
|
85
|
+
for pid in pids:
|
|
86
|
+
pid_rss = process_rss_kib(pid)
|
|
87
|
+
if pid_rss is None:
|
|
88
|
+
continue
|
|
89
|
+
rss_kib += pid_rss
|
|
90
|
+
observed = True
|
|
91
|
+
return int(rss_kib * 1024) if observed else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def process_tree_pids(root_pid: int) -> list[int]:
|
|
95
|
+
pids: list[int] = [int(root_pid)]
|
|
96
|
+
frontier: list[int] = [int(root_pid)]
|
|
97
|
+
while frontier:
|
|
98
|
+
parent = frontier.pop()
|
|
99
|
+
try:
|
|
100
|
+
completed = subprocess.run(
|
|
101
|
+
["pgrep", "-P", str(parent)],
|
|
102
|
+
check=False,
|
|
103
|
+
stdout=subprocess.PIPE,
|
|
104
|
+
stderr=subprocess.DEVNULL,
|
|
105
|
+
text=True,
|
|
106
|
+
)
|
|
107
|
+
except OSError:
|
|
108
|
+
continue
|
|
109
|
+
if completed.returncode not in (0, 1):
|
|
110
|
+
continue
|
|
111
|
+
children = [int(value) for value in completed.stdout.split() if value.isdigit()]
|
|
112
|
+
for child in children:
|
|
113
|
+
if child not in pids:
|
|
114
|
+
pids.append(child)
|
|
115
|
+
frontier.append(child)
|
|
116
|
+
return pids
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def process_rss_kib(pid: int) -> int | None:
|
|
120
|
+
try:
|
|
121
|
+
completed = subprocess.run(
|
|
122
|
+
["ps", "-o", "rss=", "-p", str(pid)],
|
|
123
|
+
check=False,
|
|
124
|
+
stdout=subprocess.PIPE,
|
|
125
|
+
stderr=subprocess.DEVNULL,
|
|
126
|
+
text=True,
|
|
127
|
+
)
|
|
128
|
+
except OSError:
|
|
129
|
+
return None
|
|
130
|
+
if completed.returncode != 0:
|
|
131
|
+
return None
|
|
132
|
+
text = completed.stdout.strip()
|
|
133
|
+
if not text:
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
return int(float(text.splitlines()[-1].strip()))
|
|
137
|
+
except ValueError:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def bytes_to_mebibytes(value: int | None) -> float | None:
|
|
142
|
+
if value is None:
|
|
143
|
+
return None
|
|
144
|
+
return float(value / (1024.0 * 1024.0))
|