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,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}.")
@@ -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))