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,629 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import tempfile
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import jax.numpy as jnp
10
+ from matplotlib import colors
11
+ from matplotlib import pyplot as plt
12
+ import numpy as np
13
+ from PIL import Image
14
+
15
+ from ..geometry import EssosImportedFciGeometry, build_essos_imported_fci_geometry
16
+ from ..native.fci import (
17
+ conservative_parallel_diffusion_fci,
18
+ conservative_perp_diffusion_xz,
19
+ grad_parallel_fci,
20
+ logical_exb_bracket_xz,
21
+ )
22
+ from .publication_plotting import save_publication_figure, style_axis
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class EssosVmecClosedFieldTransientArtifacts:
27
+ report_json_path: Path
28
+ arrays_npz_path: Path
29
+ plot_png_path: Path
30
+ movie_gif_path: Path | None
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class EssosVmecClosedFieldTransientDryRunArtifacts:
35
+ contract_json_path: Path
36
+
37
+
38
+ def create_essos_vmec_closed_field_transient_package(
39
+ *,
40
+ output_root: str | Path,
41
+ case_label: str = "essos_vmec_closed_field_transient",
42
+ coil_json_path: str | Path | None = None,
43
+ vmec_wout_path: str | Path | None = None,
44
+ essos_root: str | Path | None = None,
45
+ nx: int = 6,
46
+ ny: int = 10,
47
+ nz: int = 24,
48
+ rho_min: float = 0.20,
49
+ rho_max: float = 0.82,
50
+ frames: int = 14,
51
+ substeps_per_frame: int = 3,
52
+ dt: float = 2.0e-3,
53
+ write_movie: bool = True,
54
+ ) -> EssosVmecClosedFieldTransientArtifacts:
55
+ """Write a live VMEC closed-field reduced-transient package."""
56
+
57
+ geometry = build_essos_imported_fci_geometry(
58
+ coil_json_path=coil_json_path,
59
+ vmec_wout_path=vmec_wout_path,
60
+ essos_root=essos_root,
61
+ map_source="vmec",
62
+ nx=nx,
63
+ ny=ny,
64
+ nz=nz,
65
+ rho_min=rho_min,
66
+ rho_max=rho_max,
67
+ )
68
+ return create_essos_vmec_closed_field_transient_package_from_geometry(
69
+ geometry,
70
+ output_root=output_root,
71
+ case_label=case_label,
72
+ frames=frames,
73
+ substeps_per_frame=substeps_per_frame,
74
+ dt=dt,
75
+ write_movie=write_movie,
76
+ )
77
+
78
+
79
+ def create_essos_vmec_closed_field_transient_package_from_geometry(
80
+ geometry: EssosImportedFciGeometry | Any,
81
+ *,
82
+ output_root: str | Path,
83
+ case_label: str = "essos_vmec_closed_field_transient",
84
+ frames: int = 14,
85
+ substeps_per_frame: int = 3,
86
+ dt: float = 2.0e-3,
87
+ write_movie: bool = True,
88
+ ) -> EssosVmecClosedFieldTransientArtifacts:
89
+ """Write a closed-field transient package from an already-built geometry."""
90
+
91
+ root = Path(output_root)
92
+ data_dir = root / "data"
93
+ images_dir = root / "images"
94
+ movies_dir = root / "movies"
95
+ data_dir.mkdir(parents=True, exist_ok=True)
96
+ images_dir.mkdir(parents=True, exist_ok=True)
97
+ if write_movie:
98
+ movies_dir.mkdir(parents=True, exist_ok=True)
99
+
100
+ report, arrays = build_essos_vmec_closed_field_transient_campaign(
101
+ geometry,
102
+ frames=frames,
103
+ substeps_per_frame=substeps_per_frame,
104
+ dt=dt,
105
+ )
106
+ report_json_path = data_dir / f"{case_label}.json"
107
+ report_json_path.write_text(
108
+ json.dumps(_strict_json_payload(report), indent=2, sort_keys=True, allow_nan=False)
109
+ + "\n",
110
+ encoding="utf-8",
111
+ )
112
+ arrays_npz_path = data_dir / f"{case_label}.npz"
113
+ np.savez_compressed(arrays_npz_path, **arrays)
114
+ plot_png_path = images_dir / f"{case_label}.png"
115
+ save_essos_vmec_closed_field_transient_plot(report, arrays, plot_png_path)
116
+ movie_gif_path: Path | None = None
117
+ if write_movie:
118
+ movie_gif_path = movies_dir / f"{case_label}.gif"
119
+ save_essos_vmec_closed_field_transient_movie(report, arrays, movie_gif_path)
120
+ return EssosVmecClosedFieldTransientArtifacts(
121
+ report_json_path=report_json_path,
122
+ arrays_npz_path=arrays_npz_path,
123
+ plot_png_path=plot_png_path,
124
+ movie_gif_path=movie_gif_path,
125
+ )
126
+
127
+
128
+ def create_essos_vmec_closed_field_transient_dry_run_package(
129
+ *,
130
+ output_root: str | Path,
131
+ case_label: str = "essos_vmec_closed_field_transient",
132
+ nx: int = 6,
133
+ ny: int = 10,
134
+ nz: int = 24,
135
+ rho_min: float = 0.20,
136
+ rho_max: float = 0.82,
137
+ frames: int = 14,
138
+ substeps_per_frame: int = 3,
139
+ dt: float = 2.0e-3,
140
+ write_movie: bool = True,
141
+ ) -> EssosVmecClosedFieldTransientDryRunArtifacts:
142
+ """Write a self-contained contract for the live VMEC transient gate."""
143
+
144
+ root = Path(output_root)
145
+ data_dir = root / "data"
146
+ data_dir.mkdir(parents=True, exist_ok=True)
147
+ contract = {
148
+ "case": "essos_vmec_closed_field_transient_dry_run_contract",
149
+ "schema_version": 1,
150
+ "self_contained": True,
151
+ "execution_mode": "dry_run",
152
+ "requires_essos_runtime": False,
153
+ "live_run_requires_essos_runtime": True,
154
+ "map_source": "vmec",
155
+ "case_label": str(case_label),
156
+ "output_root": str(root),
157
+ "planned_artifacts": {
158
+ "report_json": str(data_dir / f"{case_label}.json"),
159
+ "arrays_npz": str(data_dir / f"{case_label}.npz"),
160
+ "plot_png": str(root / "images" / f"{case_label}.png"),
161
+ "movie_gif": str(root / "movies" / f"{case_label}.gif") if write_movie else None,
162
+ "dry_run_contract_json": str(data_dir / f"{case_label}_dry_run_contract.json"),
163
+ },
164
+ "grid": {
165
+ "shape": [int(nx), int(ny), int(nz)],
166
+ "rho_min": float(rho_min),
167
+ "rho_max": float(rho_max),
168
+ "frames": int(frames),
169
+ "substeps_per_frame": int(substeps_per_frame),
170
+ "dt": float(dt),
171
+ },
172
+ "claim_scope": (
173
+ "VMEC closed-field reduced transient: periodic FCI maps, profile "
174
+ "and spectrum diagnostics, no target endpoints, no sheath losses, "
175
+ "no recycling, and no neutral-loss semantics."
176
+ ),
177
+ "required_live_gates": [
178
+ "endpoint_fraction == 0",
179
+ "target_semantics_applied is false",
180
+ "sheath_recycling_semantics_applied is false",
181
+ "neutral_loss_semantics_applied is false",
182
+ "mass_relative_drift < tolerance",
183
+ "final_fluctuation_rms > 0",
184
+ "spectrum_finite is true",
185
+ ],
186
+ "passed": True,
187
+ }
188
+ contract_json_path = data_dir / f"{case_label}_dry_run_contract.json"
189
+ contract_json_path.write_text(
190
+ json.dumps(contract, indent=2, sort_keys=True) + "\n",
191
+ encoding="utf-8",
192
+ )
193
+ return EssosVmecClosedFieldTransientDryRunArtifacts(contract_json_path=contract_json_path)
194
+
195
+
196
+ def build_essos_vmec_closed_field_transient_campaign(
197
+ geometry: EssosImportedFciGeometry | Any,
198
+ *,
199
+ frames: int = 14,
200
+ substeps_per_frame: int = 3,
201
+ dt: float = 2.0e-3,
202
+ parallel_diffusivity: float = 2.0e-2,
203
+ perpendicular_diffusivity: float = 3.0e-4,
204
+ advection_strength: float = 0.055,
205
+ drive_strength: float = 0.025,
206
+ ) -> tuple[dict[str, Any], dict[str, np.ndarray]]:
207
+ """Run a compact closed-field density transient on periodic VMEC FCI maps."""
208
+
209
+ shape = tuple(int(value) for value in geometry.shape)
210
+ if len(shape) != 3:
211
+ raise ValueError(f"VMEC closed-field transient requires a 3D geometry, got {shape!r}.")
212
+ endpoint = np.asarray(geometry.maps.forward_boundary, dtype=bool) | np.asarray(
213
+ geometry.maps.backward_boundary,
214
+ dtype=bool,
215
+ )
216
+ endpoint_fraction = float(np.mean(endpoint))
217
+ if endpoint_fraction > 0.0:
218
+ raise ValueError(
219
+ "VMEC closed-field transient requires zero endpoint masks; use an open/hybrid "
220
+ "SOL campaign for target, sheath, recycling, or neutral-loss semantics."
221
+ )
222
+
223
+ density = _initial_closed_field_density(geometry)
224
+ jacobian = jnp.asarray(geometry.metric.J, dtype=jnp.float64)
225
+ initial_mass = _weighted_sum(density, jacobian)
226
+ history: list[np.ndarray] = []
227
+ profile_history: list[np.ndarray] = []
228
+ rms_history: list[float] = []
229
+ mass_history: list[float] = []
230
+ grad_history: list[float] = []
231
+
232
+ def record(state: jnp.ndarray) -> None:
233
+ profile = _radial_profile(state, jacobian)
234
+ fluctuation = state - profile[:, None, None]
235
+ grad = grad_parallel_fci(state, geometry.maps)
236
+ history.append(np.asarray(fluctuation, dtype=np.float32))
237
+ profile_history.append(np.asarray(profile, dtype=np.float64))
238
+ rms_history.append(float(jnp.sqrt(jnp.mean(jnp.square(fluctuation)))))
239
+ mass_history.append(float(_weighted_sum(state, jacobian)))
240
+ grad_history.append(float(jnp.sqrt(jnp.mean(jnp.square(grad)))))
241
+
242
+ record(density)
243
+ for frame_index in range(int(frames)):
244
+ for local_index in range(int(substeps_per_frame)):
245
+ step_index = frame_index * int(substeps_per_frame) + local_index
246
+ scalar_time = float(step_index) * float(dt)
247
+ density = _advance_closed_field_density(
248
+ geometry,
249
+ density,
250
+ scalar_time=scalar_time,
251
+ dt=dt,
252
+ parallel_diffusivity=parallel_diffusivity,
253
+ perpendicular_diffusivity=perpendicular_diffusivity,
254
+ advection_strength=advection_strength,
255
+ drive_strength=drive_strength,
256
+ )
257
+ record(density)
258
+
259
+ density_history = np.asarray(history, dtype=np.float32)
260
+ profile_array = np.asarray(profile_history, dtype=np.float64)
261
+ rms_array = np.asarray(rms_history, dtype=np.float64)
262
+ mass_array = np.asarray(mass_history, dtype=np.float64)
263
+ grad_array = np.asarray(grad_history, dtype=np.float64)
264
+ time = np.arange(int(frames) + 1, dtype=np.float64) * float(substeps_per_frame) * float(dt)
265
+ final_density = np.asarray(density, dtype=np.float64)
266
+ final_profile = profile_array[-1]
267
+ initial_profile = profile_array[0]
268
+ final_fluctuation = final_density - final_profile[:, None, None]
269
+ spectrum = np.abs(np.fft.rfft2(final_fluctuation[shape[0] // 2], axes=(0, 1))) ** 2
270
+ if spectrum.size:
271
+ spectrum[0, 0] = 0.0
272
+ total_spectrum_power = float(np.sum(spectrum))
273
+ low_mode_power = float(np.sum(spectrum[: min(4, spectrum.shape[0]), : min(6, spectrum.shape[1])]))
274
+ peak_mode = (
275
+ tuple(int(value) for value in np.unravel_index(int(np.argmax(spectrum)), spectrum.shape))
276
+ if spectrum.size
277
+ else (0, 0)
278
+ )
279
+ mass_relative_drift = float(
280
+ np.max(np.abs(mass_array - float(initial_mass))) / max(abs(float(initial_mass)), 1.0e-30)
281
+ )
282
+ profile_l2_change = float(
283
+ np.linalg.norm(final_profile - initial_profile)
284
+ / max(float(np.linalg.norm(initial_profile)), 1.0e-30)
285
+ )
286
+ finite = bool(
287
+ np.all(np.isfinite(density_history))
288
+ and np.all(np.isfinite(profile_array))
289
+ and np.all(np.isfinite(spectrum))
290
+ and np.all(np.isfinite(mass_array))
291
+ )
292
+ bmag = np.asarray(geometry.magnetic_field_magnitude, dtype=np.float64)
293
+ report: dict[str, Any] = {
294
+ "case": "essos_vmec_closed_field_transient",
295
+ "source": "DRBX reduced closed-field transient on ESSOS/VMEC periodic FCI maps",
296
+ "map_source": "vmec",
297
+ "geometry": dict(getattr(geometry, "metadata", {})),
298
+ "shape": [int(value) for value in shape],
299
+ "claim_scope": (
300
+ "Closed-field VMEC control: periodic FCI coupling, profile and "
301
+ "spectrum diagnostics, and no target/sheath/recycling/neutral-loss semantics."
302
+ ),
303
+ "target_semantics_applied": False,
304
+ "sheath_recycling_semantics_applied": False,
305
+ "neutral_loss_semantics_applied": False,
306
+ "frames": int(frames),
307
+ "substeps_per_frame": int(substeps_per_frame),
308
+ "dt": float(dt),
309
+ "parallel_diffusivity": float(parallel_diffusivity),
310
+ "perpendicular_diffusivity": float(perpendicular_diffusivity),
311
+ "advection_strength": float(advection_strength),
312
+ "drive_strength": float(drive_strength),
313
+ "endpoint_fraction": endpoint_fraction,
314
+ "magnetic_field_modulation": float(np.max(bmag) / max(float(np.min(bmag)), 1.0e-30)),
315
+ "initial_fluctuation_rms": float(rms_array[0]),
316
+ "final_fluctuation_rms": float(rms_array[-1]),
317
+ "max_fluctuation_rms": float(np.max(rms_array)),
318
+ "mass_relative_drift": mass_relative_drift,
319
+ "profile_l2_change": profile_l2_change,
320
+ "final_parallel_gradient_rms": float(grad_array[-1]),
321
+ "final_min_density": float(np.min(final_density)),
322
+ "final_max_density": float(np.max(final_density)),
323
+ "spectrum_finite": bool(np.all(np.isfinite(spectrum))),
324
+ "low_mode_spectral_power_fraction": float(low_mode_power / max(total_spectrum_power, 1.0e-30)),
325
+ "dominant_toroidal_mode_index": int(peak_mode[0]),
326
+ "dominant_poloidal_mode_index": int(peak_mode[1]),
327
+ "closed_field_control_ready": False,
328
+ "fixed_camera": True,
329
+ "fixed_color_limits": True,
330
+ "movie_visual_qa_passed": False,
331
+ "open_sol_publication_ready": False,
332
+ "open_sol_rejection_reason": "closed_vmec_map_has_no_target_endpoint_sheath_or_recycling_semantics",
333
+ }
334
+ report["closed_field_control_ready"] = bool(
335
+ finite
336
+ and endpoint_fraction < 1.0e-12
337
+ and report["final_min_density"] > 0.0
338
+ and report["final_fluctuation_rms"] > 1.0e-5
339
+ and mass_relative_drift < 2.0e-2
340
+ and report["spectrum_finite"]
341
+ and 0.0 <= report["low_mode_spectral_power_fraction"] <= 1.0
342
+ )
343
+ report["passed"] = bool(report["closed_field_control_ready"])
344
+ report["movie_visual_qa_passed"] = bool(report["closed_field_control_ready"])
345
+
346
+ major_radius = np.sqrt(
347
+ np.asarray(geometry.coordinates_x, dtype=np.float64) ** 2
348
+ + np.asarray(geometry.coordinates_y, dtype=np.float64) ** 2
349
+ )
350
+ movie_vmax = float(np.nanpercentile(np.abs(density_history), 97.0))
351
+ if not np.isfinite(movie_vmax) or movie_vmax <= 0.0:
352
+ movie_vmax = 1.0
353
+ arrays = {
354
+ "time": time.astype(np.float64),
355
+ "density_fluctuation_history": density_history,
356
+ "profile_history": profile_array.astype(np.float32),
357
+ "fluctuation_rms_history": rms_array.astype(np.float64),
358
+ "mass_history": mass_array.astype(np.float64),
359
+ "parallel_gradient_rms_history": grad_array.astype(np.float64),
360
+ "radial_coordinate": np.mean(np.asarray(geometry.minor_radius, dtype=np.float64), axis=(1, 2)).astype(np.float64),
361
+ "major_radius_section": major_radius[:, 0, :].astype(np.float32),
362
+ "vertical_section": np.asarray(geometry.coordinates_z, dtype=np.float64)[:, 0, :].astype(np.float32),
363
+ "magnetic_field_section": bmag[:, 0, :].astype(np.float32),
364
+ "initial_density_section": np.asarray(_initial_closed_field_density(geometry), dtype=np.float64)[:, 0, :].astype(np.float32),
365
+ "final_density_section": final_density[:, 0, :].astype(np.float32),
366
+ "final_fluctuation_section": final_fluctuation[:, 0, :].astype(np.float32),
367
+ "final_spectrum_log10": np.log10(spectrum + max(total_spectrum_power, 1.0e-30) * 1.0e-16).astype(np.float32),
368
+ "movie_vmax": np.asarray([movie_vmax], dtype=np.float64),
369
+ "summary": np.asarray(
370
+ [
371
+ report["final_fluctuation_rms"],
372
+ report["mass_relative_drift"],
373
+ report["profile_l2_change"],
374
+ float(report["closed_field_control_ready"]),
375
+ ],
376
+ dtype=np.float64,
377
+ ),
378
+ }
379
+ return report, arrays
380
+
381
+
382
+ def save_essos_vmec_closed_field_transient_plot(
383
+ report: dict[str, Any],
384
+ arrays: dict[str, np.ndarray],
385
+ path: str | Path,
386
+ ) -> Path:
387
+ """Save the closed-field transient profile/spectrum diagnostic figure."""
388
+
389
+ major = np.asarray(arrays["major_radius_section"], dtype=np.float64)
390
+ vertical = np.asarray(arrays["vertical_section"], dtype=np.float64)
391
+ initial = np.asarray(arrays["initial_density_section"], dtype=np.float64)
392
+ final = np.asarray(arrays["final_density_section"], dtype=np.float64)
393
+ radial = np.asarray(arrays["radial_coordinate"], dtype=np.float64)
394
+ profiles = np.asarray(arrays["profile_history"], dtype=np.float64)
395
+ time = np.asarray(arrays["time"], dtype=np.float64)
396
+ rms = np.asarray(arrays["fluctuation_rms_history"], dtype=np.float64)
397
+ mass = np.asarray(arrays["mass_history"], dtype=np.float64)
398
+ spectrum = np.asarray(arrays["final_spectrum_log10"], dtype=np.float64)
399
+
400
+ fig, axes = plt.subplots(2, 3, figsize=(16.2, 8.7), constrained_layout=True)
401
+ axes = axes.ravel()
402
+ axes[0].plot(major.T, vertical.T, color="0.36", lw=0.7, alpha=0.70)
403
+ axes[0].pcolormesh(major, vertical, final - initial, shading="gouraud", cmap="coolwarm")
404
+ axes[0].set_aspect("equal", adjustable="box")
405
+ style_axis(axes[0], title="closed VMEC section fluctuation", xlabel="R", ylabel="Z", grid="both")
406
+
407
+ image = axes[1].pcolormesh(major, vertical, final, shading="gouraud", cmap="turbo")
408
+ fig.colorbar(image, ax=axes[1], label="density")
409
+ axes[1].set_aspect("equal", adjustable="box")
410
+ style_axis(axes[1], title="final density on closed map", xlabel="R", ylabel="Z", grid="both")
411
+
412
+ axes[2].plot(radial, profiles[0], lw=2.0, label="initial")
413
+ axes[2].plot(radial, profiles[-1], lw=2.0, label="final")
414
+ axes[2].legend(frameon=False, fontsize=9)
415
+ style_axis(axes[2], title="radial profile", xlabel=r"$\rho$", ylabel=r"$\langle n\rangle$")
416
+
417
+ axes[3].plot(time, rms, lw=2.0, color="#005f73", label="fluctuation RMS")
418
+ mass_drift = (mass - mass[0]) / max(abs(float(mass[0])), 1.0e-30)
419
+ axes[3].plot(time, mass_drift, lw=1.8, color="#bb3e03", label="relative mass drift")
420
+ axes[3].legend(frameon=False, fontsize=9)
421
+ style_axis(axes[3], title="closed-field scalar controls", xlabel="time")
422
+
423
+ image = axes[4].imshow(spectrum.T, origin="lower", aspect="auto", cmap="viridis")
424
+ fig.colorbar(image, ax=axes[4], label=r"$\log_{10}$ power")
425
+ style_axis(axes[4], title="final toroidal-poloidal spectrum", xlabel="toroidal mode", ylabel="poloidal mode", grid="both")
426
+
427
+ axes[5].axis("off")
428
+ axes[5].text(
429
+ 0.02,
430
+ 0.96,
431
+ "\n".join(
432
+ [
433
+ "VMEC closed-field reduced transient",
434
+ f"shape: {tuple(report['shape'])}",
435
+ f"frames: {report['frames']}",
436
+ f"endpoint fraction: {report['endpoint_fraction']:.1e}",
437
+ f"final RMS: {report['final_fluctuation_rms']:.2e}",
438
+ f"mass drift: {report['mass_relative_drift']:.2e}",
439
+ f"profile change: {report['profile_l2_change']:.2e}",
440
+ f"closed control ready: {report['closed_field_control_ready']}",
441
+ "No target, sheath, recycling, or neutral-loss terms.",
442
+ ]
443
+ ),
444
+ transform=axes[5].transAxes,
445
+ va="top",
446
+ fontsize=11,
447
+ bbox={"facecolor": "white", "edgecolor": "0.82", "alpha": 0.96},
448
+ )
449
+ fig.suptitle("ESSOS VMEC closed-field transient control", fontsize=15, fontweight="semibold")
450
+ save_publication_figure(fig, path)
451
+ return Path(path)
452
+
453
+
454
+ def save_essos_vmec_closed_field_transient_movie(
455
+ report: dict[str, Any],
456
+ arrays: dict[str, np.ndarray],
457
+ path: str | Path,
458
+ ) -> Path:
459
+ """Save a fixed-camera GIF for the VMEC closed-field transient."""
460
+
461
+ resolved = Path(path)
462
+ resolved.parent.mkdir(parents=True, exist_ok=True)
463
+ history = np.asarray(arrays["density_fluctuation_history"], dtype=np.float64)
464
+ major = np.asarray(arrays["major_radius_section"], dtype=np.float64)
465
+ vertical = np.asarray(arrays["vertical_section"], dtype=np.float64)
466
+ time = np.asarray(arrays["time"], dtype=np.float64)
467
+ vmax = float(np.asarray(arrays["movie_vmax"], dtype=np.float64)[0])
468
+ norm = colors.TwoSlopeNorm(vmin=-vmax, vcenter=0.0, vmax=vmax)
469
+ frame_indices = np.linspace(0, history.shape[0] - 1, min(18, history.shape[0]), dtype=int)
470
+ with tempfile.TemporaryDirectory(prefix="drbx_vmec_closed_movie_") as temp_dir:
471
+ frame_paths: list[Path] = []
472
+ for local_index, frame_index in enumerate(frame_indices):
473
+ frame_path = Path(temp_dir) / f"frame_{local_index:03d}.png"
474
+ fig, axis = plt.subplots(figsize=(6.4, 5.0), constrained_layout=True)
475
+ image = axis.pcolormesh(
476
+ major,
477
+ vertical,
478
+ history[frame_index, :, 0, :],
479
+ shading="gouraud",
480
+ cmap="coolwarm",
481
+ norm=norm,
482
+ )
483
+ axis.plot(major[0, :], vertical[0, :], color="white", lw=1.2)
484
+ axis.plot(major[-1, :], vertical[-1, :], color="0.20", lw=1.0)
485
+ axis.set_aspect("equal", adjustable="box")
486
+ axis.set_xlabel("R")
487
+ axis.set_ylabel("Z")
488
+ axis.set_title(
489
+ "Closed VMEC field: periodic density fluctuation\n"
490
+ f"t={time[frame_index]:.3f}, no target/sheath/recycling losses",
491
+ fontsize=11,
492
+ )
493
+ fig.colorbar(image, ax=axis, label=r"$\tilde{n}$")
494
+ fig.savefig(frame_path, dpi=150, facecolor="white")
495
+ plt.close(fig)
496
+ frame_paths.append(frame_path)
497
+ first = Image.open(frame_paths[0]).convert("RGB").quantize(
498
+ colors=256,
499
+ method=Image.Quantize.MEDIANCUT,
500
+ dither=Image.Dither.NONE,
501
+ )
502
+ images = [first]
503
+ for frame_path in frame_paths[1:]:
504
+ images.append(Image.open(frame_path).convert("RGB").quantize(palette=first, dither=Image.Dither.NONE))
505
+ images[0].save(resolved, save_all=True, append_images=images[1:], duration=130, loop=0)
506
+ for image in images:
507
+ image.close()
508
+ return resolved
509
+
510
+
511
+ def _initial_closed_field_density(geometry: EssosImportedFciGeometry | Any) -> jnp.ndarray:
512
+ rho = jnp.asarray(geometry.minor_radius, dtype=jnp.float64)
513
+ theta = jnp.asarray(geometry.poloidal_angle, dtype=jnp.float64)
514
+ phi = jnp.asarray(geometry.toroidal_angle, dtype=jnp.float64)
515
+ radial = _normalized_radius(rho)
516
+ envelope = jnp.exp(-jnp.square((radial - 0.55) / 0.30))
517
+ bnorm = _normalized_field(jnp.asarray(geometry.magnetic_field_magnitude, dtype=jnp.float64))
518
+ fluctuation = envelope * (
519
+ jnp.sin(2.0 * theta - phi)
520
+ + 0.32 * jnp.cos(3.0 * theta + 2.0 * phi)
521
+ + 0.20 * bnorm
522
+ )
523
+ fluctuation = fluctuation / jnp.maximum(jnp.std(fluctuation), 1.0e-12)
524
+ return 1.0 + 0.07 * (1.0 - radial) + 0.035 * fluctuation
525
+
526
+
527
+ def _advance_closed_field_density(
528
+ geometry: EssosImportedFciGeometry | Any,
529
+ density: jnp.ndarray,
530
+ *,
531
+ scalar_time: float,
532
+ dt: float,
533
+ parallel_diffusivity: float,
534
+ perpendicular_diffusivity: float,
535
+ advection_strength: float,
536
+ drive_strength: float,
537
+ ) -> jnp.ndarray:
538
+ jacobian = jnp.asarray(geometry.metric.J, dtype=jnp.float64)
539
+ potential = _closed_field_potential(geometry, scalar_time)
540
+ parallel = conservative_parallel_diffusion_fci(
541
+ density,
542
+ jnp.ones_like(density) * float(parallel_diffusivity),
543
+ geometry.maps,
544
+ jacobian=jacobian,
545
+ )
546
+ perpendicular = conservative_perp_diffusion_xz(
547
+ density,
548
+ jnp.ones_like(density) * float(perpendicular_diffusivity),
549
+ geometry.metric,
550
+ )
551
+ advection = -float(advection_strength) * logical_exb_bracket_xz(
552
+ potential,
553
+ density,
554
+ geometry.metric,
555
+ )
556
+ drive = _closed_field_drive(geometry, scalar_time)
557
+ rhs = advection + parallel + perpendicular + float(drive_strength) * drive
558
+ rhs = _remove_weighted_mean(rhs, jacobian)
559
+ return jnp.maximum(density + float(dt) * rhs, 1.0e-8)
560
+
561
+
562
+ def _closed_field_potential(geometry: EssosImportedFciGeometry | Any, scalar_time: float) -> jnp.ndarray:
563
+ theta = jnp.asarray(geometry.poloidal_angle, dtype=jnp.float64)
564
+ phi = jnp.asarray(geometry.toroidal_angle, dtype=jnp.float64)
565
+ rho = jnp.asarray(geometry.minor_radius, dtype=jnp.float64)
566
+ radial = _normalized_radius(rho)
567
+ envelope = jnp.exp(-jnp.square((radial - 0.55) / 0.35))
568
+ bnorm = _normalized_field(jnp.asarray(geometry.magnetic_field_magnitude, dtype=jnp.float64))
569
+ return 0.060 * envelope * (
570
+ jnp.sin(2.0 * theta - phi + 4.0 * float(scalar_time))
571
+ + 0.28 * jnp.cos(3.0 * theta + 2.0 * phi - 2.5 * float(scalar_time))
572
+ + 0.18 * bnorm
573
+ )
574
+
575
+
576
+ def _closed_field_drive(geometry: EssosImportedFciGeometry | Any, scalar_time: float) -> jnp.ndarray:
577
+ theta = jnp.asarray(geometry.poloidal_angle, dtype=jnp.float64)
578
+ phi = jnp.asarray(geometry.toroidal_angle, dtype=jnp.float64)
579
+ rho = jnp.asarray(geometry.minor_radius, dtype=jnp.float64)
580
+ radial = _normalized_radius(rho)
581
+ envelope = jnp.exp(-jnp.square((radial - 0.48) / 0.22))
582
+ pattern = envelope * (
583
+ jnp.sin(4.0 * theta - 3.0 * phi + 3.0 * float(scalar_time))
584
+ + 0.35 * jnp.cos(5.0 * theta + phi - 1.5 * float(scalar_time))
585
+ )
586
+ return _remove_weighted_mean(pattern, jnp.asarray(geometry.metric.J, dtype=jnp.float64))
587
+
588
+
589
+ def _normalized_radius(rho: jnp.ndarray) -> jnp.ndarray:
590
+ rho_min = jnp.min(rho)
591
+ rho_max = jnp.max(rho)
592
+ return (rho - rho_min) / jnp.maximum(rho_max - rho_min, 1.0e-12)
593
+
594
+
595
+ def _normalized_field(field: jnp.ndarray) -> jnp.ndarray:
596
+ return (field - jnp.mean(field)) / jnp.maximum(jnp.std(field), 1.0e-12)
597
+
598
+
599
+ def _weighted_sum(values: jnp.ndarray, weights: jnp.ndarray) -> jnp.ndarray:
600
+ return jnp.sum(jnp.asarray(values, dtype=jnp.float64) * jnp.asarray(weights, dtype=jnp.float64))
601
+
602
+
603
+ def _remove_weighted_mean(values: jnp.ndarray, weights: jnp.ndarray) -> jnp.ndarray:
604
+ weights = jnp.asarray(weights, dtype=jnp.float64)
605
+ mean = _weighted_sum(values, weights) / jnp.maximum(jnp.sum(weights), 1.0e-30)
606
+ return jnp.asarray(values, dtype=jnp.float64) - mean
607
+
608
+
609
+ def _radial_profile(values: jnp.ndarray, weights: jnp.ndarray) -> jnp.ndarray:
610
+ numerator = jnp.sum(values * weights, axis=(1, 2))
611
+ denominator = jnp.maximum(jnp.sum(weights, axis=(1, 2)), 1.0e-30)
612
+ return numerator / denominator
613
+
614
+
615
+ def _strict_json_payload(value: Any) -> Any:
616
+ if isinstance(value, dict):
617
+ return {str(key): _strict_json_payload(item) for key, item in value.items()}
618
+ if isinstance(value, (list, tuple)):
619
+ return [_strict_json_payload(item) for item in value]
620
+ if isinstance(value, np.ndarray):
621
+ return _strict_json_payload(value.tolist())
622
+ if isinstance(value, np.bool_):
623
+ return bool(value)
624
+ if isinstance(value, np.integer):
625
+ return int(value)
626
+ if isinstance(value, (float, np.floating)):
627
+ scalar = float(value)
628
+ return scalar if np.isfinite(scalar) else None
629
+ return value