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,304 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from matplotlib import pyplot as plt
9
+ import jax.numpy as jnp
10
+ import numpy as np
11
+
12
+ from ..geometry import build_synthetic_stellarator_geometry
13
+ from ..native.fci_vorticity import apply_fci_vorticity_operator, solve_fci_vorticity_potential_cg
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class StellaratorVorticityCampaignArtifacts:
18
+ report_json_path: Path
19
+ arrays_npz_path: Path
20
+ plot_png_path: Path
21
+
22
+
23
+ def create_stellarator_vorticity_campaign_package(
24
+ *,
25
+ output_root: str | Path,
26
+ case_label: str = "stellarator_vorticity_campaign",
27
+ nx: int = 28,
28
+ ny: int = 26,
29
+ nz: int = 52,
30
+ ) -> StellaratorVorticityCampaignArtifacts:
31
+ root = Path(output_root)
32
+ data_dir = root / "data"
33
+ images_dir = root / "images"
34
+ data_dir.mkdir(parents=True, exist_ok=True)
35
+ images_dir.mkdir(parents=True, exist_ok=True)
36
+ report, arrays = build_stellarator_vorticity_campaign(nx=nx, ny=ny, nz=nz)
37
+ report_json_path = data_dir / f"{case_label}.json"
38
+ report_json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
39
+ arrays_npz_path = data_dir / f"{case_label}.npz"
40
+ np.savez_compressed(arrays_npz_path, **arrays)
41
+ plot_png_path = images_dir / f"{case_label}.png"
42
+ save_stellarator_vorticity_plot(report, arrays, plot_png_path)
43
+ return StellaratorVorticityCampaignArtifacts(
44
+ report_json_path=report_json_path,
45
+ arrays_npz_path=arrays_npz_path,
46
+ plot_png_path=plot_png_path,
47
+ )
48
+
49
+
50
+ def build_stellarator_vorticity_campaign(
51
+ *,
52
+ nx: int = 28,
53
+ ny: int = 26,
54
+ nz: int = 52,
55
+ ) -> tuple[dict[str, Any], dict[str, np.ndarray]]:
56
+ geometry = build_synthetic_stellarator_geometry(nx=nx, ny=ny, nz=nz)
57
+ phi_exact = _manufactured_potential(geometry)
58
+ density = _manufactured_density(geometry)
59
+ boussinesq_vorticity = apply_fci_vorticity_operator(
60
+ phi_exact,
61
+ density,
62
+ geometry.metric,
63
+ boussinesq=True,
64
+ )
65
+ non_boussinesq_vorticity = apply_fci_vorticity_operator(
66
+ phi_exact,
67
+ density,
68
+ geometry.metric,
69
+ boussinesq=False,
70
+ )
71
+ boussinesq_solve = solve_fci_vorticity_potential_cg(
72
+ boussinesq_vorticity,
73
+ density,
74
+ geometry.metric,
75
+ iterations=600,
76
+ boussinesq=True,
77
+ )
78
+ non_boussinesq_solve = solve_fci_vorticity_potential_cg(
79
+ non_boussinesq_vorticity,
80
+ density,
81
+ geometry.metric,
82
+ iterations=600,
83
+ boussinesq=False,
84
+ )
85
+ phi_centered = _remove_weighted_mean(np.asarray(phi_exact), np.asarray(geometry.metric.J))
86
+ boussinesq_phi = np.asarray(boussinesq_solve.potential, dtype=np.float64)
87
+ non_boussinesq_phi = np.asarray(non_boussinesq_solve.potential, dtype=np.float64)
88
+ boussinesq_error = boussinesq_phi - phi_centered
89
+ non_boussinesq_error = non_boussinesq_phi - phi_centered
90
+ phi_norm = max(np.sqrt(np.mean(phi_centered * phi_centered)), 1.0e-30)
91
+ boussinesq_relative_l2_error = float(
92
+ np.sqrt(np.mean(boussinesq_error * boussinesq_error)) / phi_norm
93
+ )
94
+ non_boussinesq_relative_l2_error = float(
95
+ np.sqrt(np.mean(non_boussinesq_error * non_boussinesq_error)) / phi_norm
96
+ )
97
+ boussinesq_residual_l2 = float(np.asarray(boussinesq_solve.residual_l2))
98
+ non_boussinesq_residual_l2 = float(np.asarray(non_boussinesq_solve.residual_l2))
99
+ boussinesq_vorticity_np = np.asarray(boussinesq_vorticity, dtype=np.float64)
100
+ non_boussinesq_vorticity_np = np.asarray(non_boussinesq_vorticity, dtype=np.float64)
101
+ vorticity_difference = non_boussinesq_vorticity_np - boussinesq_vorticity_np
102
+ operator_difference_relative_l2 = float(
103
+ np.sqrt(np.mean(vorticity_difference * vorticity_difference))
104
+ / max(np.sqrt(np.mean(boussinesq_vorticity_np * boussinesq_vorticity_np)), 1.0e-30)
105
+ )
106
+ coefficient = np.asarray(density / jnp.maximum(jnp.square(geometry.metric.Bxy), 1.0e-30))
107
+ coefficient_contrast = float(np.max(coefficient) / max(float(np.min(coefficient)), 1.0e-30))
108
+ constant_coefficient_density = 1.7 * jnp.square(geometry.metric.Bxy)
109
+ constant_boussinesq_operator = apply_fci_vorticity_operator(
110
+ phi_exact,
111
+ constant_coefficient_density,
112
+ geometry.metric,
113
+ boussinesq=True,
114
+ )
115
+ constant_non_boussinesq_operator = apply_fci_vorticity_operator(
116
+ phi_exact,
117
+ constant_coefficient_density,
118
+ geometry.metric,
119
+ boussinesq=False,
120
+ )
121
+ constant_coefficient_operator_linf = float(
122
+ np.max(
123
+ np.abs(
124
+ np.asarray(constant_boussinesq_operator)
125
+ - np.asarray(constant_non_boussinesq_operator)
126
+ )
127
+ )
128
+ )
129
+ exb_radial = _radial_exb_proxy(non_boussinesq_phi, geometry)
130
+ report: dict[str, Any] = {
131
+ "case": "non_axisymmetric_boussinesq_non_boussinesq_vorticity_inversion",
132
+ "geometry": geometry.metadata,
133
+ "iterations": int(non_boussinesq_solve.iterations),
134
+ "boussinesq_relative_l2_potential_error": boussinesq_relative_l2_error,
135
+ "non_boussinesq_relative_l2_potential_error": non_boussinesq_relative_l2_error,
136
+ "relative_l2_potential_error": non_boussinesq_relative_l2_error,
137
+ "boussinesq_relative_residual_l2": boussinesq_residual_l2,
138
+ "non_boussinesq_relative_residual_l2": non_boussinesq_residual_l2,
139
+ "relative_residual_l2": non_boussinesq_residual_l2,
140
+ "operator_difference_relative_l2": operator_difference_relative_l2,
141
+ "constant_coefficient_operator_linf": constant_coefficient_operator_linf,
142
+ "density_min": float(np.min(np.asarray(density))),
143
+ "density_max": float(np.max(np.asarray(density))),
144
+ "density_over_b_squared_contrast": coefficient_contrast,
145
+ "radial_exb_proxy_rms": float(np.sqrt(np.mean(exb_radial * exb_radial))),
146
+ "potential_rms": float(np.sqrt(np.mean(non_boussinesq_phi * non_boussinesq_phi))),
147
+ "boussinesq_vorticity_rms": float(
148
+ np.sqrt(np.mean(boussinesq_vorticity_np * boussinesq_vorticity_np))
149
+ ),
150
+ "non_boussinesq_vorticity_rms": float(
151
+ np.sqrt(np.mean(non_boussinesq_vorticity_np * non_boussinesq_vorticity_np))
152
+ ),
153
+ "vorticity_rms": float(
154
+ np.sqrt(np.mean(non_boussinesq_vorticity_np * non_boussinesq_vorticity_np))
155
+ ),
156
+ }
157
+ report["passed"] = (
158
+ boussinesq_relative_l2_error < 2.5e-2
159
+ and non_boussinesq_relative_l2_error < 2.5e-2
160
+ and boussinesq_residual_l2 < 5.0e-3
161
+ and non_boussinesq_residual_l2 < 5.0e-3
162
+ and operator_difference_relative_l2 > 5.0e-2
163
+ and constant_coefficient_operator_linf < 1.0e-8
164
+ and report["radial_exb_proxy_rms"] > 1.0e-4
165
+ )
166
+ arrays = {
167
+ "phi_exact_slice": phi_centered[:, 0, :].astype(np.float32),
168
+ "phi_solved_slice": non_boussinesq_phi[:, 0, :].astype(np.float32),
169
+ "boussinesq_phi_solved_slice": boussinesq_phi[:, 0, :].astype(np.float32),
170
+ "non_boussinesq_phi_solved_slice": non_boussinesq_phi[:, 0, :].astype(np.float32),
171
+ "phi_error_slice": non_boussinesq_error[:, 0, :].astype(np.float32),
172
+ "boussinesq_phi_error_slice": boussinesq_error[:, 0, :].astype(np.float32),
173
+ "non_boussinesq_phi_error_slice": non_boussinesq_error[:, 0, :].astype(np.float32),
174
+ "vorticity_slice": non_boussinesq_vorticity_np[:, 0, :].astype(np.float32),
175
+ "boussinesq_vorticity_slice": boussinesq_vorticity_np[:, 0, :].astype(np.float32),
176
+ "non_boussinesq_vorticity_slice": non_boussinesq_vorticity_np[:, 0, :].astype(np.float32),
177
+ "vorticity_difference_slice": vorticity_difference[:, 0, :].astype(np.float32),
178
+ "density_over_b_squared_slice": coefficient[:, 0, :].astype(np.float32),
179
+ "radial_exb_slice": exb_radial[:, 0, :].astype(np.float32),
180
+ "summary": np.asarray(
181
+ [
182
+ boussinesq_relative_l2_error,
183
+ non_boussinesq_relative_l2_error,
184
+ boussinesq_residual_l2,
185
+ non_boussinesq_residual_l2,
186
+ operator_difference_relative_l2,
187
+ constant_coefficient_operator_linf,
188
+ report["radial_exb_proxy_rms"],
189
+ report["potential_rms"],
190
+ ],
191
+ dtype=np.float32,
192
+ ),
193
+ }
194
+ return report, arrays
195
+
196
+
197
+ def save_stellarator_vorticity_plot(
198
+ report: dict[str, Any],
199
+ arrays: dict[str, np.ndarray],
200
+ path: str | Path,
201
+ ) -> Path:
202
+ resolved = Path(path)
203
+ resolved.parent.mkdir(parents=True, exist_ok=True)
204
+ fig, axes = plt.subplots(3, 3, figsize=(16.2, 12.2), constrained_layout=True)
205
+ image0 = axes[0, 0].imshow(arrays["phi_exact_slice"], origin="lower", aspect="auto", cmap="viridis")
206
+ axes[0, 0].set_title("manufactured potential")
207
+ fig.colorbar(image0, ax=axes[0, 0])
208
+ image1 = axes[0, 1].imshow(arrays["boussinesq_phi_solved_slice"], origin="lower", aspect="auto", cmap="viridis")
209
+ axes[0, 1].set_title("Boussinesq reconstructed potential")
210
+ fig.colorbar(image1, ax=axes[0, 1])
211
+ image2 = axes[0, 2].imshow(arrays["non_boussinesq_phi_solved_slice"], origin="lower", aspect="auto", cmap="viridis")
212
+ axes[0, 2].set_title("non-Boussinesq reconstructed potential")
213
+ fig.colorbar(image2, ax=axes[0, 2])
214
+ image3 = axes[1, 0].imshow(arrays["boussinesq_vorticity_slice"], origin="lower", aspect="auto", cmap="magma")
215
+ axes[1, 0].set_title("Boussinesq vorticity")
216
+ fig.colorbar(image3, ax=axes[1, 0])
217
+ image4 = axes[1, 1].imshow(arrays["non_boussinesq_vorticity_slice"], origin="lower", aspect="auto", cmap="magma")
218
+ axes[1, 1].set_title("non-Boussinesq vorticity")
219
+ fig.colorbar(image4, ax=axes[1, 1])
220
+ vmax_vort = float(np.max(np.abs(arrays["vorticity_difference_slice"])))
221
+ image5 = axes[1, 2].imshow(
222
+ arrays["vorticity_difference_slice"],
223
+ origin="lower",
224
+ aspect="auto",
225
+ cmap="coolwarm",
226
+ vmin=-vmax_vort,
227
+ vmax=vmax_vort,
228
+ )
229
+ axes[1, 2].set_title("non-Boussinesq minus Boussinesq")
230
+ fig.colorbar(image5, ax=axes[1, 2])
231
+ vmax_bouss = float(np.max(np.abs(arrays["boussinesq_phi_error_slice"])))
232
+ image6 = axes[2, 0].imshow(
233
+ arrays["boussinesq_phi_error_slice"],
234
+ origin="lower",
235
+ aspect="auto",
236
+ cmap="coolwarm",
237
+ vmin=-vmax_bouss,
238
+ vmax=vmax_bouss,
239
+ )
240
+ axes[2, 0].set_title("Boussinesq potential error")
241
+ fig.colorbar(image6, ax=axes[2, 0])
242
+ vmax_non_bouss = float(np.max(np.abs(arrays["non_boussinesq_phi_error_slice"])))
243
+ image7 = axes[2, 1].imshow(
244
+ arrays["non_boussinesq_phi_error_slice"],
245
+ origin="lower",
246
+ aspect="auto",
247
+ cmap="coolwarm",
248
+ vmin=-vmax_non_bouss,
249
+ vmax=vmax_non_bouss,
250
+ )
251
+ axes[2, 1].set_title("non-Boussinesq potential error")
252
+ fig.colorbar(image7, ax=axes[2, 1])
253
+ labels = [
254
+ "Bq err",
255
+ "non-Bq err",
256
+ "Bq res",
257
+ "non-Bq res",
258
+ "op diff",
259
+ "const eq",
260
+ "ExB rms",
261
+ "phi rms",
262
+ ]
263
+ colors = ["#9b2226", "#bb3e03", "#0a9396", "#005f73", "#ee9b00", "#94d2bd", "#ca6702", "#001219"]
264
+ axes[2, 2].bar(np.arange(len(labels)), arrays["summary"], color=colors)
265
+ axes[2, 2].set_xticks(np.arange(len(labels)), labels, rotation=28, ha="right")
266
+ axes[2, 2].set_yscale("log")
267
+ axes[2, 2].grid(axis="y", alpha=0.25)
268
+ axes[2, 2].set_title("model and inversion metrics")
269
+ for axis in axes.ravel()[:8]:
270
+ axis.set_xlabel("poloidal index")
271
+ axis.set_ylabel("radial index")
272
+ fig.suptitle(
273
+ "Non-axisymmetric vorticity gate: Boussinesq vs non-Boussinesq perpendicular polarization",
274
+ fontsize=14,
275
+ )
276
+ fig.savefig(resolved, dpi=190)
277
+ plt.close(fig)
278
+ return resolved
279
+
280
+
281
+ def _manufactured_potential(geometry: object) -> jnp.ndarray:
282
+ return (
283
+ jnp.sin(jnp.pi * geometry.radial) * jnp.cos(2.0 * geometry.poloidal_angle - 5.0 * geometry.toroidal_angle)
284
+ + 0.20 * geometry.radial * jnp.sin(3.0 * geometry.poloidal_angle + 2.0 * geometry.toroidal_angle)
285
+ )
286
+
287
+
288
+ def _manufactured_density(geometry: object) -> jnp.ndarray:
289
+ density = (
290
+ 1.0
291
+ + 0.35 * geometry.radial
292
+ + 0.12 * jnp.cos(geometry.poloidal_angle - 2.0 * geometry.toroidal_angle)
293
+ + 0.08 * jnp.cos(3.0 * geometry.toroidal_angle)
294
+ )
295
+ return jnp.maximum(density, 0.1)
296
+
297
+
298
+ def _remove_weighted_mean(value: np.ndarray, weights: np.ndarray) -> np.ndarray:
299
+ return value - float(np.sum(weights * value) / np.sum(weights))
300
+
301
+
302
+ def _radial_exb_proxy(phi: np.ndarray, geometry: object) -> np.ndarray:
303
+ dz = float(2.0 * np.pi / geometry.shape[2])
304
+ return -(np.roll(phi, -1, axis=2) - np.roll(phi, 1, axis=2)) / (2.0 * dz)
@@ -0,0 +1,260 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from matplotlib import pyplot as plt
8
+ import numpy as np
9
+
10
+ from ..geometry.vmec_extender_import import (
11
+ VmecExtenderGrid,
12
+ interpolate_vmec_extender_B_cyl,
13
+ load_vmec_extender_grid_netcdf,
14
+ vmec_extender_fieldline_rhs_RZ_phi,
15
+ )
16
+ from .publication_plotting import annotate_bars, save_publication_figure, style_axis
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class VmecExtenderEdgeFieldCampaignArtifacts:
21
+ summary_json_path: Path
22
+ arrays_npz_path: Path
23
+ plot_png_path: Path
24
+
25
+
26
+ def create_vmec_extender_edge_field_campaign_package(
27
+ *,
28
+ output_root: str | Path,
29
+ field_grid_path: str | Path,
30
+ case_label: str = "vmec_extender_edge_field_campaign",
31
+ strict_metadata: bool = True,
32
+ ) -> VmecExtenderEdgeFieldCampaignArtifacts:
33
+ """Run the imported-field verification campaign and write public artifacts."""
34
+
35
+ root = Path(output_root)
36
+ data_dir = root / "data"
37
+ images_dir = root / "images"
38
+ data_dir.mkdir(parents=True, exist_ok=True)
39
+ images_dir.mkdir(parents=True, exist_ok=True)
40
+
41
+ report = build_vmec_extender_edge_field_campaign_report(
42
+ field_grid_path=field_grid_path,
43
+ strict_metadata=strict_metadata,
44
+ )
45
+ summary_json_path = data_dir / f"{case_label}.json"
46
+ summary_json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
47
+
48
+ arrays_npz_path = data_dir / f"{case_label}.npz"
49
+ np.savez_compressed(
50
+ arrays_npz_path,
51
+ metric_names=np.asarray(
52
+ [
53
+ "node_interpolation_max_abs_error",
54
+ "midpoint_interpolation_max_abs_error",
55
+ "field_period_relative_l2",
56
+ "fieldline_rhs_max_abs_error",
57
+ "absB_consistency_max_abs_error",
58
+ ],
59
+ dtype=object,
60
+ ),
61
+ metric_values=np.asarray(
62
+ [
63
+ report["node_interpolation_max_abs_error"],
64
+ report["midpoint_interpolation_max_abs_error"],
65
+ report["field_period_relative_l2"],
66
+ report["fieldline_rhs_max_abs_error"],
67
+ report["absB_consistency_max_abs_error"],
68
+ ],
69
+ dtype=np.float64,
70
+ ),
71
+ node_points=np.asarray(report["node_sample_points"], dtype=np.float64),
72
+ node_expected_B=np.asarray(report["node_sample_expected_B"], dtype=np.float64),
73
+ node_interpolated_B=np.asarray(report["node_sample_interpolated_B"], dtype=np.float64),
74
+ )
75
+
76
+ plot_png_path = save_vmec_extender_edge_field_campaign_plot(report, images_dir / f"{case_label}.png")
77
+ return VmecExtenderEdgeFieldCampaignArtifacts(
78
+ summary_json_path=summary_json_path,
79
+ arrays_npz_path=arrays_npz_path,
80
+ plot_png_path=plot_png_path,
81
+ )
82
+
83
+
84
+ def build_vmec_extender_edge_field_campaign_report(
85
+ *,
86
+ field_grid_path: str | Path,
87
+ strict_metadata: bool = True,
88
+ ) -> dict[str, object]:
89
+ """Build a numerical verification report for an imported edge-field grid."""
90
+
91
+ grid = load_vmec_extender_grid_netcdf(field_grid_path, strict_metadata=strict_metadata)
92
+ node_points = _node_points(grid)
93
+ node_expected = _field_stack(grid)
94
+ node_interpolated = np.asarray(interpolate_vmec_extender_B_cyl(grid, node_points), dtype=np.float64)
95
+ node_error = float(np.max(np.abs(node_interpolated - node_expected)))
96
+
97
+ midpoint_points, midpoint_expected = _midpoint_trilinear_reference(grid)
98
+ midpoint_interpolated = np.asarray(interpolate_vmec_extender_B_cyl(grid, midpoint_points), dtype=np.float64)
99
+ midpoint_error = float(np.max(np.abs(midpoint_interpolated - midpoint_expected))) if midpoint_expected.size else 0.0
100
+
101
+ period_points = _periodicity_probe_points(grid)
102
+ period_base = np.asarray(interpolate_vmec_extender_B_cyl(grid, period_points), dtype=np.float64)
103
+ shifted_points = period_points.copy()
104
+ shifted_points[..., 1] = shifted_points[..., 1] + float(grid.phi_period)
105
+ period_shifted = np.asarray(interpolate_vmec_extender_B_cyl(grid, shifted_points), dtype=np.float64)
106
+ period_relative_l2 = _relative_l2(period_base, period_shifted)
107
+
108
+ rhs_points = midpoint_points.reshape((-1, 3))[: max(1, min(32, midpoint_points.reshape((-1, 3)).shape[0]))]
109
+ rhs_B = np.asarray(interpolate_vmec_extender_B_cyl(grid, rhs_points), dtype=np.float64)
110
+ rhs_expected = np.stack(
111
+ (
112
+ rhs_points[:, 0] * rhs_B[:, 0] / rhs_B[:, 1],
113
+ rhs_points[:, 0] * rhs_B[:, 2] / rhs_B[:, 1],
114
+ ),
115
+ axis=-1,
116
+ )
117
+ rhs_actual = np.asarray(vmec_extender_fieldline_rhs_RZ_phi(grid, rhs_points), dtype=np.float64)
118
+ rhs_error = float(np.max(np.abs(rhs_actual - rhs_expected)))
119
+
120
+ absB_error = float(grid.metadata.get("absB_consistency_max_abs_error", np.nan))
121
+ passed = (
122
+ node_error <= 1.0e-10
123
+ and midpoint_error <= 1.0e-10
124
+ and period_relative_l2 <= 1.0e-12
125
+ and rhs_error <= 1.0e-10
126
+ and absB_error <= 1.0e-8
127
+ )
128
+ sample_count = min(8, node_points.reshape((-1, 3)).shape[0])
129
+ return {
130
+ "family": "vmec_extender_edge_field_campaign",
131
+ "case": Path(field_grid_path).stem,
132
+ "source": str(grid.metadata.get("source", "unknown")),
133
+ "grid_shape": [int(value) for value in grid.shape],
134
+ "nfp": int(grid.nfp),
135
+ "phi_period": float(grid.phi_period),
136
+ "metadata_passed": True,
137
+ "node_interpolation_max_abs_error": node_error,
138
+ "midpoint_interpolation_max_abs_error": midpoint_error,
139
+ "field_period_relative_l2": period_relative_l2,
140
+ "fieldline_rhs_max_abs_error": rhs_error,
141
+ "absB_consistency_max_abs_error": absB_error,
142
+ "passed": bool(passed),
143
+ "notes": (
144
+ "The campaign verifies the gridded VMEC-extender import contract, "
145
+ "physical-phi wrapping, trilinear interpolation, and field-line RHS."
146
+ ),
147
+ "node_sample_points": node_points.reshape((-1, 3))[:sample_count].tolist(),
148
+ "node_sample_expected_B": node_expected.reshape((-1, 3))[:sample_count].tolist(),
149
+ "node_sample_interpolated_B": node_interpolated.reshape((-1, 3))[:sample_count].tolist(),
150
+ }
151
+
152
+
153
+ def save_vmec_extender_edge_field_campaign_plot(report: dict[str, object], path: str | Path) -> Path:
154
+ """Save a compact publication-style summary plot for the import campaign."""
155
+
156
+ metrics = np.asarray(
157
+ [
158
+ report["node_interpolation_max_abs_error"],
159
+ report["midpoint_interpolation_max_abs_error"],
160
+ report["field_period_relative_l2"],
161
+ report["fieldline_rhs_max_abs_error"],
162
+ report["absB_consistency_max_abs_error"],
163
+ ],
164
+ dtype=np.float64,
165
+ )
166
+ labels = [
167
+ "node\ninterp",
168
+ "midpoint\ninterp",
169
+ "field-period\nwrap",
170
+ "field-line\nRHS",
171
+ "|B|\nclosure",
172
+ ]
173
+ floor = np.maximum(metrics, 1.0e-16)
174
+ colors = ["#0a9396" if bool(report["passed"]) else "#bb3e03"] * len(metrics)
175
+ figure, axis = plt.subplots(figsize=(7.2, 3.8), constrained_layout=True)
176
+ x = np.arange(len(metrics), dtype=np.float64)
177
+ axis.bar(x, floor, color=colors, width=0.66, alpha=0.92)
178
+ axis.set_xticks(x, labels)
179
+ style_axis(
180
+ axis,
181
+ title="VMEC-extender edge-field import",
182
+ ylabel="error metric",
183
+ yscale="log",
184
+ )
185
+ axis.tick_params(axis="x", labelsize=8.5)
186
+ axis.tick_params(axis="y", labelsize=8.5)
187
+ axis.set_title("VMEC-extender edge-field import", fontsize=11.0, fontweight="semibold", pad=7.0)
188
+ axis.set_ylabel("error metric", fontsize=9.5)
189
+ annotate_bars(axis, x, floor, fmt="{:.1e}", fontsize=7.5)
190
+ axis.text(
191
+ 0.98,
192
+ 0.95,
193
+ f"shape={tuple(report['grid_shape'])}\nnfp={report['nfp']}, period={float(report['phi_period']):.4f}",
194
+ transform=axis.transAxes,
195
+ va="top",
196
+ ha="right",
197
+ fontsize=8.0,
198
+ linespacing=1.25,
199
+ bbox={"facecolor": "white", "alpha": 0.9, "edgecolor": "#d9d9d9", "linewidth": 0.6},
200
+ )
201
+ save_publication_figure(figure, path)
202
+ return Path(path)
203
+
204
+
205
+ def _node_points(grid: VmecExtenderGrid) -> np.ndarray:
206
+ R, phi, Z = np.meshgrid(np.asarray(grid.R), np.asarray(grid.phi), np.asarray(grid.Z), indexing="ij")
207
+ return np.stack((R, phi, Z), axis=-1)
208
+
209
+
210
+ def _field_stack(grid: VmecExtenderGrid) -> np.ndarray:
211
+ return np.stack(
212
+ (
213
+ np.asarray(grid.BR, dtype=np.float64),
214
+ np.asarray(grid.Bphi, dtype=np.float64),
215
+ np.asarray(grid.BZ, dtype=np.float64),
216
+ ),
217
+ axis=-1,
218
+ )
219
+
220
+
221
+ def _midpoint_trilinear_reference(grid: VmecExtenderGrid) -> tuple[np.ndarray, np.ndarray]:
222
+ R = np.asarray(grid.R, dtype=np.float64)
223
+ phi = np.asarray(grid.phi, dtype=np.float64)
224
+ Z = np.asarray(grid.Z, dtype=np.float64)
225
+ if min(R.size, phi.size, Z.size) < 2:
226
+ return np.zeros((0, 3), dtype=np.float64), np.zeros((0, 3), dtype=np.float64)
227
+ R_mid = 0.5 * (R[:-1] + R[1:])
228
+ phi_mid = 0.5 * (phi[:-1] + phi[1:])
229
+ Z_mid = 0.5 * (Z[:-1] + Z[1:])
230
+ RR, PP, ZZ = np.meshgrid(R_mid, phi_mid, Z_mid, indexing="ij")
231
+ points = np.stack((RR, PP, ZZ), axis=-1)
232
+ fields = _field_stack(grid)
233
+ expected = 0.125 * (
234
+ fields[:-1, :-1, :-1, :]
235
+ + fields[1:, :-1, :-1, :]
236
+ + fields[:-1, 1:, :-1, :]
237
+ + fields[1:, 1:, :-1, :]
238
+ + fields[:-1, :-1, 1:, :]
239
+ + fields[1:, :-1, 1:, :]
240
+ + fields[:-1, 1:, 1:, :]
241
+ + fields[1:, 1:, 1:, :]
242
+ )
243
+ return points, expected
244
+
245
+
246
+ def _periodicity_probe_points(grid: VmecExtenderGrid) -> np.ndarray:
247
+ R = np.asarray(grid.R, dtype=np.float64)
248
+ phi = np.asarray(grid.phi, dtype=np.float64)
249
+ Z = np.asarray(grid.Z, dtype=np.float64)
250
+ R_probe = 0.5 * (R[:-1] + R[1:])
251
+ phi_probe = phi[: max(1, min(3, phi.size))]
252
+ Z_probe = 0.5 * (Z[:-1] + Z[1:])
253
+ RR, PP, ZZ = np.meshgrid(R_probe, phi_probe, Z_probe, indexing="ij")
254
+ return np.stack((RR, PP, ZZ), axis=-1)
255
+
256
+
257
+ def _relative_l2(expected: np.ndarray, actual: np.ndarray) -> float:
258
+ numerator = float(np.linalg.norm(np.ravel(actual - expected)))
259
+ denominator = float(max(np.linalg.norm(np.ravel(expected)), np.finfo(np.float64).tiny))
260
+ return numerator / denominator