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,2826 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import jax
12
+ import jax.numpy as jnp
13
+ from matplotlib import cm
14
+ from matplotlib import colors
15
+ from matplotlib import pyplot as plt
16
+ import numpy as np
17
+ from PIL import Image
18
+
19
+ from ..geometry import (
20
+ EssosImportedFciGeometry,
21
+ build_essos_imported_fci_geometry,
22
+ build_essos_vmec_scaled_qa_coordinates,
23
+ resolve_essos_landreman_qa_wout,
24
+ )
25
+ from ..native.fci import conservative_perp_diffusion_xz, logical_exb_bracket_xz
26
+ from ..native.fci_drb_rhs import FciDrbRhsParameters, FciDrbState, compute_fci_drb_rhs
27
+ from ..native.fci_neutral import compute_fci_neutral_reaction_diffusion
28
+ from ..native.fci_sheath_recycling import compute_fci_sheath_recycling
29
+ from .essos_imported_pytree_campaign import initial_essos_imported_drb_state
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class EssosImportedDrbMovieArtifacts:
34
+ report_json_path: Path
35
+ arrays_npz_path: Path
36
+ snapshot_png_path: Path
37
+ diagnostics_png_path: Path
38
+ poster_png_path: Path
39
+ movie_gif_path: Path
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class EssosImportedDrbMovieRefinementArtifacts:
44
+ report_json_path: Path
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class EssosImportedDrbMovieRefinementCampaignArtifacts:
49
+ report_json_path: Path
50
+ grid_report_json_paths: tuple[Path, ...]
51
+ time_report_json_paths: tuple[Path, ...]
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class EssosImportedDrbMovieStationarityArtifacts:
56
+ report_json_path: Path
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class EssosImportedDrbMovieResult:
61
+ geometry: EssosImportedFciGeometry
62
+ report: dict[str, Any]
63
+ arrays: dict[str, np.ndarray]
64
+
65
+
66
+ ESSOS_IMPORTED_DRB_MOVIE_REFINEMENT_METRICS = (
67
+ "final_fluctuation_rms",
68
+ "max_fluctuation_rms",
69
+ "radial_flux_abs_mean",
70
+ "radial_flux_rms",
71
+ "low_mode_spectral_power_fraction",
72
+ "spectral_centroid_poloidal_fraction",
73
+ "spectral_centroid_toroidal_fraction",
74
+ "spectral_edge_band_power_fraction",
75
+ "final_potential_residual_l2",
76
+ )
77
+
78
+ ESSOS_IMPORTED_DRB_MOVIE_MAX_EDGE_BAND_FRACTION = 0.85
79
+ ESSOS_IMPORTED_DRB_MOVIE_REFINEMENT_METRIC_FLOORS = {
80
+ "final_potential_residual_l2": 1.0e-10,
81
+ }
82
+ ESSOS_IMPORTED_DRB_MOVIE_REFINEMENT_DEFAULT_METRIC_FLOOR = 1.0e-12
83
+ ESSOS_IMPORTED_DRB_MOVIE_REFINEMENT_NEAR_TOLERANCE_FACTOR = 1.05
84
+ ESSOS_IMPORTED_DRB_MOVIE_DEFAULT_POTENTIAL_ITERATIONS = 768
85
+ ESSOS_IMPORTED_DRB_MOVIE_DEFAULT_POTENTIAL_REGULARIZATION = 5.0
86
+ ESSOS_IMPORTED_DRB_MOVIE_DEFAULT_POTENTIAL_PRECONDITIONER: str | None = None
87
+
88
+
89
+ def _strict_json_payload(value: Any) -> Any:
90
+ """Return a JSON-standards-compliant payload with nonfinite values as null."""
91
+
92
+ if isinstance(value, dict):
93
+ return {str(key): _strict_json_payload(item) for key, item in value.items()}
94
+ if isinstance(value, (list, tuple)):
95
+ return [_strict_json_payload(item) for item in value]
96
+ if isinstance(value, np.ndarray):
97
+ return _strict_json_payload(value.tolist())
98
+ if isinstance(value, np.bool_):
99
+ return bool(value)
100
+ if isinstance(value, np.integer):
101
+ return int(value)
102
+ if isinstance(value, (float, np.floating)):
103
+ scalar = float(value)
104
+ return scalar if np.isfinite(scalar) else None
105
+ return value
106
+
107
+
108
+ def _write_strict_json(path: Path, payload: Any) -> None:
109
+ path.write_text(
110
+ json.dumps(
111
+ _strict_json_payload(payload),
112
+ indent=2,
113
+ sort_keys=True,
114
+ allow_nan=False,
115
+ )
116
+ + "\n",
117
+ encoding="utf-8",
118
+ )
119
+
120
+
121
+ def classify_essos_imported_drb_movie_evidence(map_source: str) -> dict[str, Any]:
122
+ """Classify whether an imported-field movie is publication evidence."""
123
+
124
+ normalized = str(map_source).strip().lower()
125
+ required_gates = [
126
+ "connection_length_refinement_summary_promotion_ready",
127
+ "movie_grid_refinement_passed",
128
+ "movie_time_refinement_passed",
129
+ "long_time_statistical_stationarity_or_convergence_passed",
130
+ ]
131
+ reasons = [
132
+ "movie_grid_refinement_not_passed",
133
+ "movie_time_refinement_not_passed",
134
+ "long_time_statistical_stationarity_not_demonstrated",
135
+ ]
136
+ if normalized == "coil":
137
+ evidence_role = "movie_showcase_pending_connection_grid_time_refinement"
138
+ reasons.insert(0, "coil_connection_length_refinement_not_promotion_ready")
139
+ elif normalized == "hybrid":
140
+ evidence_role = "movie_showcase_connection_control_pending_grid_time_refinement"
141
+ elif normalized == "vmec":
142
+ evidence_role = "closed_field_movie_control_pending_open_sol_endpoint_evidence"
143
+ reasons.insert(0, "closed_field_control_not_open_sol_endpoint_evidence")
144
+ else:
145
+ evidence_role = "movie_showcase_pending_validation"
146
+ reasons.insert(0, f"unknown_map_source:{normalized}")
147
+ return {
148
+ "publication_ready": False,
149
+ "movie_evidence_role": evidence_role,
150
+ "movie_promotion_rejection_reasons": reasons,
151
+ "required_publication_gates": required_gates,
152
+ }
153
+
154
+
155
+ def create_essos_imported_drb_movie_refinement_summary_package(
156
+ *,
157
+ output_root: str | Path,
158
+ case_label: str = "essos_imported_drb_movie_refinement_summary",
159
+ grid_report_json_paths: tuple[str | Path, ...] | list[str | Path] = (),
160
+ time_report_json_paths: tuple[str | Path, ...] | list[str | Path] = (),
161
+ relative_tolerance: float = 0.30,
162
+ ) -> EssosImportedDrbMovieRefinementArtifacts:
163
+ """Write a lightweight grid/time refinement summary from movie reports."""
164
+
165
+ root = Path(output_root)
166
+ data_dir = root / "data"
167
+ data_dir.mkdir(parents=True, exist_ok=True)
168
+ grid_reports = _load_movie_refinement_reports(grid_report_json_paths)
169
+ time_reports = _load_movie_refinement_reports(time_report_json_paths)
170
+ report = build_essos_imported_drb_movie_refinement_summary(
171
+ grid_reports=grid_reports,
172
+ time_reports=time_reports,
173
+ relative_tolerance=relative_tolerance,
174
+ )
175
+ report_json_path = data_dir / f"{case_label}.json"
176
+ _write_strict_json(report_json_path, report)
177
+ return EssosImportedDrbMovieRefinementArtifacts(report_json_path=report_json_path)
178
+
179
+
180
+ def create_essos_imported_drb_movie_refinement_campaign_package(
181
+ *,
182
+ output_root: str | Path,
183
+ case_label: str = "essos_imported_drb_movie_refinement_campaign",
184
+ coil_json_path: str | Path | None = None,
185
+ vmec_wout_path: str | Path | None = None,
186
+ essos_root: str | Path | None = None,
187
+ map_source: str = "hybrid",
188
+ grid_shapes: tuple[tuple[int, int, int], ...] | list[tuple[int, int, int]] = (
189
+ (3, 4, 8),
190
+ (4, 6, 12),
191
+ ),
192
+ time_shape: tuple[int, int, int] | list[int] | None = None,
193
+ time_dt_values: tuple[float, ...] | list[float] = (2.0e-3, 1.0e-3),
194
+ rho_min: float = 0.20,
195
+ rho_max: float = 0.60,
196
+ maxtime: float = 24.0,
197
+ times_to_trace: int = 80,
198
+ frames: int = 4,
199
+ substeps_per_frame: int = 2,
200
+ grid_dt: float = 2.0e-3,
201
+ relative_tolerance: float = 0.30,
202
+ potential_iterations: int = ESSOS_IMPORTED_DRB_MOVIE_DEFAULT_POTENTIAL_ITERATIONS,
203
+ potential_regularization: float = ESSOS_IMPORTED_DRB_MOVIE_DEFAULT_POTENTIAL_REGULARIZATION,
204
+ potential_preconditioner: str | None = (
205
+ ESSOS_IMPORTED_DRB_MOVIE_DEFAULT_POTENTIAL_PRECONDITIONER
206
+ ),
207
+ reuse_existing_reports: bool = False,
208
+ ) -> EssosImportedDrbMovieRefinementCampaignArtifacts:
209
+ """Run a lightweight report-only grid/time movie refinement campaign.
210
+
211
+ The campaign intentionally writes JSON reports only. It does not save NPZ,
212
+ PNG, or GIF artifacts, so it can be used to search for a publication-grade
213
+ grid/time configuration before committing or release-hosting heavy media.
214
+ If ``reuse_existing_reports`` is true, a report is reused only when its
215
+ recorded grid, timestep, geometry, transient, and potential-solver metadata
216
+ match the requested run.
217
+ """
218
+
219
+ root = Path(output_root)
220
+ data_dir = root / "data"
221
+ data_dir.mkdir(parents=True, exist_ok=True)
222
+ normalized_grids = tuple(_normalize_movie_grid_shape(shape) for shape in grid_shapes)
223
+ if len(normalized_grids) < 2:
224
+ raise ValueError("grid_shapes must contain at least two grid levels.")
225
+ normalized_time_shape = (
226
+ _normalize_movie_grid_shape(time_shape)
227
+ if time_shape is not None
228
+ else normalized_grids[-1]
229
+ )
230
+ normalized_time_dt_values = tuple(float(value) for value in time_dt_values)
231
+ if len(normalized_time_dt_values) < 2:
232
+ raise ValueError("time_dt_values must contain at least two timestep values.")
233
+ if any(value <= 0.0 for value in normalized_time_dt_values):
234
+ raise ValueError("time_dt_values must be positive.")
235
+
236
+ report_cache: dict[tuple[tuple[int, int, int], float], Path] = {}
237
+
238
+ def run_report(shape: tuple[int, int, int], dt: float, role: str, index: int) -> Path:
239
+ cache_key = (shape, float(dt))
240
+ if cache_key in report_cache:
241
+ return report_cache[cache_key]
242
+ nx, ny, nz = shape
243
+ report_json_path = data_dir / (
244
+ f"{case_label}_{role}_{index:02d}_{nx}x{ny}x{nz}_dt={float(dt):.6g}.json"
245
+ )
246
+ if reuse_existing_reports and _imported_drb_movie_report_matches_request(
247
+ report_json_path,
248
+ case_label=case_label,
249
+ role=role,
250
+ index=index,
251
+ shape=shape,
252
+ dt=float(dt),
253
+ map_source=map_source,
254
+ rho_min=rho_min,
255
+ rho_max=rho_max,
256
+ maxtime=maxtime,
257
+ times_to_trace=times_to_trace,
258
+ frames=frames,
259
+ substeps_per_frame=substeps_per_frame,
260
+ potential_iterations=potential_iterations,
261
+ potential_regularization=potential_regularization,
262
+ potential_preconditioner=potential_preconditioner,
263
+ ):
264
+ report_cache[cache_key] = report_json_path
265
+ return report_json_path
266
+ result = build_essos_imported_drb_movie_campaign(
267
+ coil_json_path=coil_json_path,
268
+ vmec_wout_path=vmec_wout_path,
269
+ essos_root=essos_root,
270
+ map_source=map_source,
271
+ nx=nx,
272
+ ny=ny,
273
+ nz=nz,
274
+ rho_min=rho_min,
275
+ rho_max=rho_max,
276
+ maxtime=maxtime,
277
+ times_to_trace=times_to_trace,
278
+ frames=frames,
279
+ substeps_per_frame=substeps_per_frame,
280
+ dt=float(dt),
281
+ potential_iterations=potential_iterations,
282
+ potential_regularization=potential_regularization,
283
+ potential_preconditioner=potential_preconditioner,
284
+ )
285
+ report = dict(result.report)
286
+ report.update(
287
+ {
288
+ "refinement_campaign_role": role,
289
+ "refinement_campaign_index": int(index),
290
+ "refinement_campaign_case_label": str(case_label),
291
+ }
292
+ )
293
+ _write_strict_json(report_json_path, report)
294
+ report_cache[cache_key] = report_json_path
295
+ return report_json_path
296
+
297
+ grid_report_json_paths = tuple(
298
+ run_report(shape, grid_dt, "grid", index)
299
+ for index, shape in enumerate(normalized_grids)
300
+ )
301
+ time_report_json_paths = tuple(
302
+ run_report(normalized_time_shape, dt, "time", index)
303
+ for index, dt in enumerate(normalized_time_dt_values)
304
+ )
305
+ artifacts = create_essos_imported_drb_movie_refinement_summary_package(
306
+ output_root=root,
307
+ case_label=f"{case_label}_summary",
308
+ grid_report_json_paths=grid_report_json_paths,
309
+ time_report_json_paths=time_report_json_paths,
310
+ relative_tolerance=relative_tolerance,
311
+ )
312
+ return EssosImportedDrbMovieRefinementCampaignArtifacts(
313
+ report_json_path=artifacts.report_json_path,
314
+ grid_report_json_paths=grid_report_json_paths,
315
+ time_report_json_paths=time_report_json_paths,
316
+ )
317
+
318
+
319
+ def _imported_drb_movie_report_matches_request(
320
+ path: Path,
321
+ *,
322
+ case_label: str,
323
+ role: str,
324
+ index: int,
325
+ shape: tuple[int, int, int],
326
+ dt: float,
327
+ map_source: str,
328
+ rho_min: float,
329
+ rho_max: float,
330
+ maxtime: float,
331
+ times_to_trace: int,
332
+ frames: int,
333
+ substeps_per_frame: int,
334
+ potential_iterations: int,
335
+ potential_regularization: float,
336
+ potential_preconditioner: str | None,
337
+ ) -> bool:
338
+ """Return true when an existing report is valid for a requested rerun."""
339
+
340
+ if not path.exists():
341
+ return False
342
+ try:
343
+ report = json.loads(path.read_text(encoding="utf-8"))
344
+ except (OSError, json.JSONDecodeError):
345
+ return False
346
+ geometry = report.get("geometry")
347
+ if not isinstance(geometry, dict):
348
+ geometry = {}
349
+ requested_preconditioner = _normalize_optional_report_string(
350
+ potential_preconditioner
351
+ )
352
+ report_preconditioner = _normalize_optional_report_string(
353
+ report.get("potential_preconditioner")
354
+ )
355
+ checks = (
356
+ tuple(report.get("movie_physics_grid", ())) == tuple(shape),
357
+ _same_optional_float(report.get("dt"), dt),
358
+ _same_optional_int(report.get("frames"), frames),
359
+ _same_optional_int(report.get("substeps_per_frame"), substeps_per_frame),
360
+ str(report.get("map_source", "")).strip().lower()
361
+ == str(map_source).strip().lower(),
362
+ _same_optional_int(report.get("potential_iterations"), potential_iterations),
363
+ _same_optional_float(
364
+ report.get("potential_regularization"), potential_regularization
365
+ ),
366
+ report_preconditioner == requested_preconditioner,
367
+ str(report.get("refinement_campaign_case_label", "")) == str(case_label),
368
+ str(report.get("refinement_campaign_role", "")) == str(role),
369
+ _same_optional_int(report.get("refinement_campaign_index"), index),
370
+ _same_optional_int(geometry.get("nx"), shape[0]),
371
+ _same_optional_int(geometry.get("ny"), shape[1]),
372
+ _same_optional_int(geometry.get("nz"), shape[2]),
373
+ _same_optional_float(geometry.get("rho_min"), rho_min),
374
+ _same_optional_float(geometry.get("rho_max"), rho_max),
375
+ _same_optional_float(geometry.get("maxtime"), maxtime),
376
+ _same_optional_int(geometry.get("times_to_trace"), times_to_trace),
377
+ str(geometry.get("map_source", report.get("map_source", ""))).strip().lower()
378
+ == str(map_source).strip().lower(),
379
+ )
380
+ return all(checks)
381
+
382
+
383
+ def _normalize_movie_grid_shape(
384
+ shape: tuple[int, int, int] | list[int] | None,
385
+ ) -> tuple[int, int, int]:
386
+ if not isinstance(shape, (tuple, list)) or len(shape) != 3:
387
+ raise ValueError("movie grid shapes must be length-3 tuples.")
388
+ normalized = tuple(int(value) for value in shape)
389
+ if any(value <= 0 for value in normalized):
390
+ raise ValueError("movie grid shapes must contain positive integers.")
391
+ return normalized
392
+
393
+
394
+ def build_essos_imported_drb_movie_refinement_summary(
395
+ *,
396
+ grid_reports: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
397
+ time_reports: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
398
+ relative_tolerance: float = 0.30,
399
+ ) -> dict[str, Any]:
400
+ """Build grid/time refinement diagnostics from movie report dictionaries."""
401
+
402
+ grid_diagnostics = build_essos_imported_drb_movie_refinement_diagnostics(
403
+ grid_reports,
404
+ refinement_axis="grid",
405
+ relative_tolerance=relative_tolerance,
406
+ )
407
+ time_diagnostics = build_essos_imported_drb_movie_refinement_diagnostics(
408
+ time_reports,
409
+ refinement_axis="time",
410
+ relative_tolerance=relative_tolerance,
411
+ )
412
+ grid_passed = bool(grid_diagnostics["passed"])
413
+ time_passed = bool(time_diagnostics["passed"])
414
+ report = {
415
+ "diagnostic": "essos_imported_drb_movie_refinement_summary",
416
+ "claim_scope": (
417
+ "report-only grid/time refinement gate for imported-field DRB "
418
+ "movies; this summary does not regenerate GIF or NPZ artifacts"
419
+ ),
420
+ "relative_tolerance": float(relative_tolerance),
421
+ "grid_refinement_passed": grid_passed,
422
+ "time_refinement_passed": time_passed,
423
+ "publication_ready": bool(grid_passed and time_passed),
424
+ "movie_promotion_rejection_reasons": _movie_refinement_rejection_reasons(
425
+ grid_diagnostics,
426
+ time_diagnostics,
427
+ ),
428
+ "grid_refinement_diagnostics": grid_diagnostics,
429
+ "time_refinement_diagnostics": time_diagnostics,
430
+ }
431
+ report["next_campaign_suggestion"] = (
432
+ build_essos_imported_drb_movie_refinement_next_campaign(report)
433
+ )
434
+ return report
435
+
436
+
437
+ def build_essos_imported_drb_movie_refinement_next_campaign(
438
+ summary_report: dict[str, Any],
439
+ *,
440
+ max_total_cells: int | None = None,
441
+ ) -> dict[str, Any]:
442
+ """Suggest the next report-only movie refinement campaign settings.
443
+
444
+ The suggestion is deterministic and deliberately conservative. It does not
445
+ weaken the publication gate; it translates the dominant failed metrics into
446
+ a concrete next grid/timestep candidate so high-resolution searches are
447
+ auditable and repeatable.
448
+ """
449
+
450
+ grid_diagnostics = dict(summary_report.get("grid_refinement_diagnostics", {}))
451
+ time_diagnostics = dict(summary_report.get("time_refinement_diagnostics", {}))
452
+ current_grid = _movie_refinement_finest_grid(grid_diagnostics)
453
+ grid_failed_metrics = _movie_refinement_failed_metric_names(grid_diagnostics)
454
+ time_failed_metrics = _movie_refinement_failed_metric_names(time_diagnostics)
455
+ grid_near_tolerance_metrics = _movie_refinement_near_tolerance_metric_names(
456
+ grid_diagnostics
457
+ )
458
+ time_near_tolerance_metrics = _movie_refinement_near_tolerance_metric_names(
459
+ time_diagnostics
460
+ )
461
+ current_potential_iterations = _movie_refinement_max_potential_iterations(
462
+ grid_diagnostics,
463
+ time_diagnostics,
464
+ )
465
+ potential_solve_action = _movie_refinement_potential_solve_action(
466
+ grid_failed_metrics=grid_failed_metrics,
467
+ time_failed_metrics=time_failed_metrics,
468
+ )
469
+ recommended_potential_iterations = (
470
+ int(max(1, current_potential_iterations) * 2)
471
+ if potential_solve_action != "no_potential_residual_blocker"
472
+ and current_potential_iterations is not None
473
+ else None
474
+ )
475
+ notes: list[str] = []
476
+ grid_multiplier = [1.0, 1.0, 1.0]
477
+ if current_grid is not None:
478
+ if {"radial_flux_abs_mean", "radial_flux_rms"} & grid_failed_metrics:
479
+ grid_multiplier = [
480
+ max(grid_multiplier[0], 2.0),
481
+ max(grid_multiplier[1], 1.5),
482
+ max(grid_multiplier[2], 1.5),
483
+ ]
484
+ notes.append(
485
+ "radial transport is grid-sensitive; refine radial and "
486
+ "field-line-following transverse resolution together"
487
+ )
488
+ if (
489
+ not bool(grid_diagnostics.get("spectral_resolution_passed", True))
490
+ or "low_mode_spectral_power_fraction" in grid_failed_metrics
491
+ or "spectral_edge_band_power_fraction" in grid_failed_metrics
492
+ ):
493
+ grid_multiplier = [
494
+ max(grid_multiplier[0], 1.25),
495
+ max(grid_multiplier[1], 2.0),
496
+ max(grid_multiplier[2], 2.0),
497
+ ]
498
+ notes.append(
499
+ "spectral content is too close to the grid edge; refine "
500
+ "poloidal and toroidal movie-grid resolution before promotion"
501
+ )
502
+ if "spectral_centroid_poloidal_fraction" in grid_failed_metrics:
503
+ grid_multiplier[1] = max(grid_multiplier[1], 2.0)
504
+ notes.append("poloidal spectral centroid moves under refinement")
505
+ if "spectral_centroid_toroidal_fraction" in grid_failed_metrics:
506
+ grid_multiplier[2] = max(grid_multiplier[2], 2.0)
507
+ notes.append("toroidal spectral centroid moves under refinement")
508
+ if {"final_fluctuation_rms", "max_fluctuation_rms"} & grid_failed_metrics:
509
+ grid_multiplier = [max(value, 1.5) for value in grid_multiplier]
510
+ notes.append("scalar transient metrics are grid-sensitive")
511
+ if "final_potential_residual_l2" in grid_failed_metrics:
512
+ notes.append(
513
+ "elliptic potential residual is grid-sensitive; rerun the same "
514
+ "grid pair with a larger potential_iterations budget before "
515
+ "escalating movie resolution solely because of the residual"
516
+ )
517
+
518
+ should_refine_grid = current_grid is not None and any(
519
+ float(value) > 1.0 for value in grid_multiplier
520
+ )
521
+ suggested_next_grid = (
522
+ _movie_refinement_scaled_grid(current_grid, tuple(grid_multiplier))
523
+ if should_refine_grid
524
+ else None
525
+ )
526
+ suggested_grid_shapes = (
527
+ [list(current_grid), list(suggested_next_grid)]
528
+ if current_grid is not None and suggested_next_grid is not None
529
+ else []
530
+ )
531
+ current_cells = (
532
+ int(np.prod(current_grid, dtype=np.int64)) if current_grid is not None else None
533
+ )
534
+ suggested_cells = (
535
+ int(np.prod(suggested_next_grid, dtype=np.int64))
536
+ if suggested_next_grid is not None
537
+ else None
538
+ )
539
+ fits_budget = (
540
+ None
541
+ if max_total_cells is None or suggested_cells is None
542
+ else bool(suggested_cells <= int(max_total_cells))
543
+ )
544
+
545
+ time_axis_values = [
546
+ float(value) for value in time_diagnostics.get("axis_values", [])
547
+ ]
548
+ current_effective_frame_dt = (
549
+ min(time_axis_values) if time_axis_values else None
550
+ )
551
+ recommended_time_values: list[float] = []
552
+ time_action = "no_time_refinement_reports_available"
553
+ if current_effective_frame_dt is not None:
554
+ if bool(time_diagnostics.get("passed", False)):
555
+ recommended_time_values = sorted(
556
+ {
557
+ float(current_effective_frame_dt),
558
+ float(current_effective_frame_dt * 2.0),
559
+ },
560
+ reverse=True,
561
+ )
562
+ time_action = "reuse_current_timestep_pair_after_grid_change"
563
+ elif not time_failed_metrics and not bool(
564
+ time_diagnostics.get("spectral_resolution_passed", True)
565
+ ):
566
+ recommended_time_values = sorted(
567
+ {float(value) for value in time_axis_values},
568
+ reverse=True,
569
+ )
570
+ time_action = "fix_grid_resolution_before_reducing_timestep"
571
+ notes.append(
572
+ "time gate has no scalar-metric offender; fix spectral grid "
573
+ "resolution before spending wall time on smaller timesteps"
574
+ )
575
+ else:
576
+ recommended_time_values = sorted(
577
+ {
578
+ float(current_effective_frame_dt),
579
+ float(current_effective_frame_dt * 0.5),
580
+ },
581
+ reverse=True,
582
+ )
583
+ time_action = "halve_effective_frame_dt_after_grid_candidate"
584
+ notes.append("time-refinement scalar metrics are not stable")
585
+
586
+ if current_grid is None:
587
+ notes.append("at least two grid reports are required before suggesting a grid")
588
+ if recommended_potential_iterations is not None:
589
+ notes.append(
590
+ "rerun the residual-blocked candidate with "
591
+ f"potential_iterations={recommended_potential_iterations} before "
592
+ "changing physics claims based on elliptic residual movement"
593
+ )
594
+ near_tolerance_metrics = grid_near_tolerance_metrics | time_near_tolerance_metrics
595
+ if {"radial_flux_abs_mean", "radial_flux_rms"} & near_tolerance_metrics:
596
+ notes.append(
597
+ "radial-flux convergence is a near-tolerance miss; rerun the same "
598
+ "grid with a longer transient or independent phase before treating "
599
+ "the next larger grid as mandatory"
600
+ )
601
+
602
+ return {
603
+ "diagnostic": "essos_imported_drb_movie_next_campaign_suggestion",
604
+ "claim_scope": (
605
+ "planning aid only; suggested settings are not validation evidence "
606
+ "until the regenerated refinement summary passes"
607
+ ),
608
+ "publication_ready_current": bool(summary_report.get("publication_ready", False)),
609
+ "grid_refinement_passed_current": bool(
610
+ summary_report.get("grid_refinement_passed", False)
611
+ ),
612
+ "time_refinement_passed_current": bool(
613
+ summary_report.get("time_refinement_passed", False)
614
+ ),
615
+ "dominant_grid_blockers": grid_diagnostics.get("dominant_failed_metrics", []),
616
+ "dominant_time_blockers": time_diagnostics.get("dominant_failed_metrics", []),
617
+ "near_tolerance_grid_blockers": grid_diagnostics.get(
618
+ "near_tolerance_failed_metric_reports", []
619
+ ),
620
+ "near_tolerance_time_blockers": time_diagnostics.get(
621
+ "near_tolerance_failed_metric_reports", []
622
+ ),
623
+ "current_finest_grid": list(current_grid) if current_grid is not None else None,
624
+ "suggested_grid_multiplier": [float(value) for value in grid_multiplier],
625
+ "suggested_next_grid": (
626
+ list(suggested_next_grid) if suggested_next_grid is not None else None
627
+ ),
628
+ "suggested_grid_shapes": suggested_grid_shapes,
629
+ "current_finest_grid_cell_count": current_cells,
630
+ "suggested_next_grid_cell_count": suggested_cells,
631
+ "max_total_cells": None if max_total_cells is None else int(max_total_cells),
632
+ "suggested_grid_fits_cell_budget": fits_budget,
633
+ "current_effective_frame_dt": current_effective_frame_dt,
634
+ "recommended_time_effective_frame_dt_values": recommended_time_values,
635
+ "time_refinement_action": time_action,
636
+ "current_potential_iterations": current_potential_iterations,
637
+ "recommended_potential_iterations": recommended_potential_iterations,
638
+ "potential_solve_action": potential_solve_action,
639
+ "recommendation_notes": notes,
640
+ }
641
+
642
+
643
+ def build_essos_imported_drb_movie_refinement_diagnostics(
644
+ reports: tuple[dict[str, Any], ...] | list[dict[str, Any]],
645
+ *,
646
+ refinement_axis: str,
647
+ relative_tolerance: float = 0.30,
648
+ ) -> dict[str, Any]:
649
+ """Compare scalar movie diagnostics across grid or timestep refinements."""
650
+
651
+ normalized_axis = str(refinement_axis).strip().lower().replace("-", "_")
652
+ if normalized_axis not in {"grid", "time"}:
653
+ raise ValueError("refinement_axis must be either 'grid' or 'time'.")
654
+ tolerance = float(relative_tolerance)
655
+ if tolerance <= 0.0:
656
+ raise ValueError("relative_tolerance must be positive.")
657
+ report_list = [dict(report) for report in reports]
658
+ base: dict[str, Any] = {
659
+ "diagnostic": "essos_imported_drb_movie_refinement",
660
+ "refinement_axis": normalized_axis,
661
+ "report_count": len(report_list),
662
+ "relative_tolerance": tolerance,
663
+ "metric_keys": list(ESSOS_IMPORTED_DRB_MOVIE_REFINEMENT_METRICS),
664
+ "pair_reports": [],
665
+ "max_relative_metric_change": None,
666
+ "axis_progression_passed": False,
667
+ "all_reports_passed": False,
668
+ "map_source_consistent": False,
669
+ "spectral_resolution_passed": False,
670
+ "spectral_resolution_reports": [],
671
+ "failed_metric_reports": [],
672
+ "near_tolerance_failed_metric_reports": [],
673
+ "dominant_failed_metrics": [],
674
+ "refinement_recommendations": [],
675
+ "passed": False,
676
+ }
677
+ if len(report_list) < 2:
678
+ return {
679
+ **base,
680
+ "reason": f"need_at_least_two_{normalized_axis}_reports",
681
+ }
682
+
683
+ sorted_reports = sorted(
684
+ report_list,
685
+ key=(
686
+ _movie_grid_refinement_key
687
+ if normalized_axis == "grid"
688
+ else _movie_time_refinement_key
689
+ ),
690
+ )
691
+ if normalized_axis == "time":
692
+ sorted_reports = list(reversed(sorted_reports))
693
+ map_sources = {str(report.get("map_source", "unknown")) for report in sorted_reports}
694
+ labels = [_movie_report_label(report, index) for index, report in enumerate(sorted_reports)]
695
+ axis_values = [
696
+ _movie_grid_refinement_key(report)
697
+ if normalized_axis == "grid"
698
+ else _movie_time_refinement_key(report)
699
+ for report in sorted_reports
700
+ ]
701
+ axis_progression_passed = all(
702
+ current > previous
703
+ for previous, current in zip(axis_values, axis_values[1:])
704
+ )
705
+ if normalized_axis == "time":
706
+ axis_progression_passed = all(
707
+ current < previous
708
+ for previous, current in zip(axis_values, axis_values[1:])
709
+ )
710
+
711
+ pair_reports = [
712
+ _build_movie_refinement_pair_report(
713
+ coarse=coarse,
714
+ fine=fine,
715
+ coarse_label=coarse_label,
716
+ fine_label=fine_label,
717
+ relative_tolerance=tolerance,
718
+ )
719
+ for coarse, fine, coarse_label, fine_label in zip(
720
+ sorted_reports,
721
+ sorted_reports[1:],
722
+ labels,
723
+ labels[1:],
724
+ )
725
+ ]
726
+ max_change_values = [
727
+ float(pair["max_relative_metric_change"])
728
+ for pair in pair_reports
729
+ if pair["max_relative_metric_change"] is not None
730
+ ]
731
+ all_reports_passed = all(bool(report.get("passed")) for report in sorted_reports)
732
+ map_source_consistent = len(map_sources) == 1
733
+ spectral_resolution_reports = [
734
+ _build_movie_spectral_resolution_report(report=report, label=label)
735
+ for report, label in zip(sorted_reports, labels)
736
+ ]
737
+ spectral_resolution_passed = all(
738
+ bool(report["passed"]) for report in spectral_resolution_reports
739
+ )
740
+ failed_metric_reports = _build_movie_failed_metric_reports(
741
+ pair_reports=pair_reports,
742
+ relative_tolerance=tolerance,
743
+ )
744
+ near_tolerance_failed_metric_reports = [
745
+ report
746
+ for report in failed_metric_reports
747
+ if bool(report.get("near_tolerance"))
748
+ ]
749
+ refinement_recommendations = _movie_refinement_recommendations(
750
+ refinement_axis=normalized_axis,
751
+ failed_metric_reports=failed_metric_reports,
752
+ near_tolerance_failed_metric_reports=near_tolerance_failed_metric_reports,
753
+ spectral_resolution_passed=spectral_resolution_passed,
754
+ )
755
+ passed = bool(
756
+ all_reports_passed
757
+ and map_source_consistent
758
+ and axis_progression_passed
759
+ and spectral_resolution_passed
760
+ and pair_reports
761
+ and all(bool(pair["passed"]) for pair in pair_reports)
762
+ )
763
+ return {
764
+ **base,
765
+ "map_source": next(iter(map_sources)) if map_source_consistent else None,
766
+ "map_sources": sorted(map_sources),
767
+ "labels": labels,
768
+ "axis_values": [float(value) for value in axis_values],
769
+ "axis_progression_passed": bool(axis_progression_passed),
770
+ "all_reports_passed": bool(all_reports_passed),
771
+ "map_source_consistent": bool(map_source_consistent),
772
+ "spectral_resolution_passed": bool(spectral_resolution_passed),
773
+ "spectral_resolution_reports": spectral_resolution_reports,
774
+ "pair_reports": pair_reports,
775
+ "failed_metric_reports": failed_metric_reports,
776
+ "near_tolerance_failed_metric_reports": near_tolerance_failed_metric_reports,
777
+ "dominant_failed_metrics": failed_metric_reports[:5],
778
+ "refinement_recommendations": refinement_recommendations,
779
+ "max_relative_metric_change": (
780
+ max(max_change_values) if max_change_values else None
781
+ ),
782
+ "passed": passed,
783
+ }
784
+
785
+
786
+ def _load_movie_refinement_reports(paths: tuple[str | Path, ...] | list[str | Path]) -> tuple[dict[str, Any], ...]:
787
+ reports: list[dict[str, Any]] = []
788
+ for path in paths:
789
+ resolved = Path(path)
790
+ reports.append(json.loads(resolved.read_text(encoding="utf-8")))
791
+ return tuple(reports)
792
+
793
+
794
+ def _movie_refinement_rejection_reasons(
795
+ grid_diagnostics: dict[str, Any],
796
+ time_diagnostics: dict[str, Any],
797
+ ) -> list[str]:
798
+ reasons: list[str] = []
799
+ if not bool(grid_diagnostics["passed"]):
800
+ reasons.append("movie_grid_refinement_not_passed")
801
+ if "reason" in grid_diagnostics:
802
+ reasons.append(str(grid_diagnostics["reason"]))
803
+ if grid_diagnostics.get("spectral_resolution_reports") and not bool(
804
+ grid_diagnostics.get("spectral_resolution_passed", False)
805
+ ):
806
+ reasons.append("movie_grid_spectral_resolution_not_passed")
807
+ reasons.extend(_movie_spectral_resolution_rejection_reasons(grid_diagnostics))
808
+ if not bool(time_diagnostics["passed"]):
809
+ reasons.append("movie_time_refinement_not_passed")
810
+ if "reason" in time_diagnostics:
811
+ reasons.append(str(time_diagnostics["reason"]))
812
+ if time_diagnostics.get("spectral_resolution_reports") and not bool(
813
+ time_diagnostics.get("spectral_resolution_passed", False)
814
+ ):
815
+ reasons.append("movie_time_spectral_resolution_not_passed")
816
+ reasons.extend(_movie_spectral_resolution_rejection_reasons(time_diagnostics))
817
+ return reasons
818
+
819
+
820
+ def _movie_spectral_resolution_rejection_reasons(diagnostics: dict[str, Any]) -> list[str]:
821
+ reasons: list[str] = []
822
+ for report in diagnostics.get("spectral_resolution_reports", []):
823
+ for reason in report.get("reasons", []):
824
+ reason_text = str(reason)
825
+ if reason_text not in reasons:
826
+ reasons.append(reason_text)
827
+ return reasons
828
+
829
+
830
+ def _build_movie_spectral_resolution_report(
831
+ *,
832
+ report: dict[str, Any],
833
+ label: str,
834
+ max_edge_band_fraction: float = ESSOS_IMPORTED_DRB_MOVIE_MAX_EDGE_BAND_FRACTION,
835
+ ) -> dict[str, Any]:
836
+ reasons: list[str] = []
837
+ low_mode_window_covers_grid = bool(report.get("low_mode_window_covers_grid", False))
838
+ if low_mode_window_covers_grid:
839
+ reasons.append("low_mode_window_covers_grid")
840
+ edge_fraction = _optional_float(report.get("spectral_edge_band_power_fraction"))
841
+ if edge_fraction is None:
842
+ reasons.append("missing_spectral_edge_band_power_fraction")
843
+ elif edge_fraction > max_edge_band_fraction:
844
+ reasons.append("spectral_edge_band_power_fraction_above_limit")
845
+ poloidal_fraction = _optional_float(report.get("spectral_centroid_poloidal_fraction"))
846
+ toroidal_fraction = _optional_float(report.get("spectral_centroid_toroidal_fraction"))
847
+ if poloidal_fraction is None:
848
+ reasons.append("missing_spectral_centroid_poloidal_fraction")
849
+ elif not 0.0 <= poloidal_fraction <= 1.0:
850
+ reasons.append("spectral_centroid_poloidal_fraction_out_of_bounds")
851
+ if toroidal_fraction is None:
852
+ reasons.append("missing_spectral_centroid_toroidal_fraction")
853
+ elif not 0.0 <= toroidal_fraction <= 1.0:
854
+ reasons.append("spectral_centroid_toroidal_fraction_out_of_bounds")
855
+ return {
856
+ "label": label,
857
+ "low_mode_window_covers_grid": bool(low_mode_window_covers_grid),
858
+ "spectral_edge_band_power_fraction": edge_fraction,
859
+ "max_spectral_edge_band_power_fraction": float(max_edge_band_fraction),
860
+ "spectral_centroid_poloidal_fraction": poloidal_fraction,
861
+ "spectral_centroid_toroidal_fraction": toroidal_fraction,
862
+ "reasons": reasons,
863
+ "passed": not reasons,
864
+ }
865
+
866
+
867
+ def _movie_grid_refinement_key(report: dict[str, Any]) -> float:
868
+ grid = report.get("movie_physics_grid", ())
869
+ if not isinstance(grid, (list, tuple)) or len(grid) != 3:
870
+ return 0.0
871
+ product = 1
872
+ for value in grid:
873
+ product *= int(value)
874
+ return float(product)
875
+
876
+
877
+ def _movie_time_refinement_key(report: dict[str, Any]) -> float:
878
+ return float(report.get("dt", 0.0)) * float(report.get("substeps_per_frame", 0.0))
879
+
880
+
881
+ def _movie_report_label(report: dict[str, Any], index: int) -> str:
882
+ case = report.get("case")
883
+ grid = report.get("movie_physics_grid")
884
+ grid_label = ""
885
+ if isinstance(grid, (list, tuple)) and len(grid) == 3:
886
+ grid_label = f"{int(grid[0])}x{int(grid[1])}x{int(grid[2])}"
887
+ frame_dt = _movie_time_refinement_key(report)
888
+ if case:
889
+ suffix = f"{grid_label}_frame_dt={frame_dt:g}" if grid_label else f"frame_dt={frame_dt:g}"
890
+ return f"{case}:{suffix}"
891
+ if grid_label:
892
+ return f"report_{index}_{grid_label}_frame_dt={frame_dt:g}"
893
+ return f"report_{index}"
894
+
895
+
896
+ def _build_movie_refinement_pair_report(
897
+ *,
898
+ coarse: dict[str, Any],
899
+ fine: dict[str, Any],
900
+ coarse_label: str,
901
+ fine_label: str,
902
+ relative_tolerance: float,
903
+ ) -> dict[str, Any]:
904
+ metric_reports: dict[str, dict[str, Any]] = {}
905
+ relative_changes: list[float] = []
906
+ for key in ESSOS_IMPORTED_DRB_MOVIE_REFINEMENT_METRICS:
907
+ coarse_value = _optional_float(coarse.get(key))
908
+ fine_value = _optional_float(fine.get(key))
909
+ if coarse_value is None or fine_value is None:
910
+ metric_reports[key] = {
911
+ "coarse": coarse_value,
912
+ "fine": fine_value,
913
+ "relative_change": None,
914
+ "sign_agreement": False,
915
+ "passed": False,
916
+ }
917
+ continue
918
+ denominator_floor = _movie_refinement_metric_floor(key)
919
+ denominator = max(abs(fine_value), denominator_floor)
920
+ relative_change = abs(coarse_value - fine_value) / denominator
921
+ relative_changes.append(float(relative_change))
922
+ metric_reports[key] = {
923
+ "coarse": coarse_value,
924
+ "fine": fine_value,
925
+ "denominator_floor": float(denominator_floor),
926
+ "relative_change": float(relative_change),
927
+ "sign_agreement": True,
928
+ "passed": bool(relative_change <= relative_tolerance),
929
+ }
930
+ max_change = max(relative_changes) if relative_changes else None
931
+ metric_passed = all(bool(item["passed"]) for item in metric_reports.values())
932
+ radial_flux_proxy_sign_agreement = _signed_metric_agrees(
933
+ coarse.get("radial_flux_proxy"),
934
+ fine.get("radial_flux_proxy"),
935
+ )
936
+ return {
937
+ "coarse_label": coarse_label,
938
+ "fine_label": fine_label,
939
+ "coarse_grid": list(coarse.get("movie_physics_grid", [])),
940
+ "fine_grid": list(fine.get("movie_physics_grid", [])),
941
+ "coarse_effective_frame_dt": _movie_time_refinement_key(coarse),
942
+ "fine_effective_frame_dt": _movie_time_refinement_key(fine),
943
+ "coarse_potential_iterations": _optional_int(
944
+ coarse.get("potential_iterations")
945
+ ),
946
+ "fine_potential_iterations": _optional_int(fine.get("potential_iterations")),
947
+ "coarse_potential_preconditioner": coarse.get("potential_preconditioner"),
948
+ "fine_potential_preconditioner": fine.get("potential_preconditioner"),
949
+ "metric_reports": metric_reports,
950
+ "max_relative_metric_change": max_change,
951
+ "radial_flux_proxy_sign_agreement": bool(radial_flux_proxy_sign_agreement),
952
+ "radial_flux_sign_passed": bool(radial_flux_proxy_sign_agreement),
953
+ "passed": bool(metric_passed),
954
+ }
955
+
956
+
957
+ def _build_movie_failed_metric_reports(
958
+ *,
959
+ pair_reports: list[dict[str, Any]],
960
+ relative_tolerance: float,
961
+ ) -> list[dict[str, Any]]:
962
+ failed: list[dict[str, Any]] = []
963
+ for pair_index, pair in enumerate(pair_reports):
964
+ for metric, report in pair.get("metric_reports", {}).items():
965
+ if bool(report.get("passed")):
966
+ continue
967
+ relative_change = report.get("relative_change")
968
+ near_tolerance = (
969
+ relative_change is not None
970
+ and float(relative_change)
971
+ <= float(relative_tolerance)
972
+ * ESSOS_IMPORTED_DRB_MOVIE_REFINEMENT_NEAR_TOLERANCE_FACTOR
973
+ )
974
+ failed.append(
975
+ {
976
+ "pair_index": int(pair_index),
977
+ "coarse_label": pair.get("coarse_label"),
978
+ "fine_label": pair.get("fine_label"),
979
+ "metric": str(metric),
980
+ "coarse": report.get("coarse"),
981
+ "fine": report.get("fine"),
982
+ "denominator_floor": report.get("denominator_floor"),
983
+ "relative_change": relative_change,
984
+ "relative_tolerance": float(relative_tolerance),
985
+ "near_tolerance": bool(near_tolerance),
986
+ "near_tolerance_factor": float(
987
+ ESSOS_IMPORTED_DRB_MOVIE_REFINEMENT_NEAR_TOLERANCE_FACTOR
988
+ ),
989
+ "reason": _movie_refinement_metric_failure_reason(
990
+ metric=str(metric),
991
+ report=report,
992
+ relative_tolerance=relative_tolerance,
993
+ ),
994
+ }
995
+ )
996
+ return sorted(
997
+ failed,
998
+ key=lambda item: (
999
+ item["relative_change"] is None,
1000
+ -float(item["relative_change"] or 0.0),
1001
+ str(item["metric"]),
1002
+ ),
1003
+ )
1004
+
1005
+
1006
+ def _movie_refinement_metric_failure_reason(
1007
+ *,
1008
+ metric: str,
1009
+ report: dict[str, Any],
1010
+ relative_tolerance: float,
1011
+ ) -> str:
1012
+ if report.get("relative_change") is None:
1013
+ return "missing_or_nonfinite_metric"
1014
+ if str(metric).startswith("spectral_") or str(metric) == "low_mode_spectral_power_fraction":
1015
+ return "spectral_content_not_grid_or_time_stable"
1016
+ if str(metric).startswith("radial_flux_"):
1017
+ return "radial_transport_not_grid_or_time_stable"
1018
+ if str(metric) == "final_potential_residual_l2":
1019
+ return "elliptic_residual_not_grid_or_time_stable"
1020
+ return f"relative_change_above_{float(relative_tolerance):g}"
1021
+
1022
+
1023
+ def _movie_refinement_recommendations(
1024
+ *,
1025
+ refinement_axis: str,
1026
+ failed_metric_reports: list[dict[str, Any]],
1027
+ near_tolerance_failed_metric_reports: list[dict[str, Any]],
1028
+ spectral_resolution_passed: bool,
1029
+ ) -> list[str]:
1030
+ recommendations: list[str] = []
1031
+ failed_metrics = {str(report["metric"]) for report in failed_metric_reports}
1032
+ near_tolerance_metrics = {
1033
+ str(report["metric"]) for report in near_tolerance_failed_metric_reports
1034
+ }
1035
+ if not spectral_resolution_passed:
1036
+ recommendations.append(
1037
+ "Increase the physics/movie grid or reduce the resolved low-mode window "
1038
+ "before promoting the movie; the spectrum is too close to the grid edge."
1039
+ )
1040
+ radial_metrics = {"radial_flux_abs_mean", "radial_flux_rms"} & failed_metrics
1041
+ if radial_metrics and radial_metrics <= near_tolerance_metrics:
1042
+ recommendations.append(
1043
+ "Radial transport is only marginally above the convergence tolerance; "
1044
+ "repeat the same grid with a longer transient or independent phase "
1045
+ "before paying for a larger radial/toroidal refinement."
1046
+ )
1047
+ elif radial_metrics:
1048
+ recommendations.append(
1049
+ "Treat radial transport as unresolved: refine the radial grid and the "
1050
+ "field-line-following transverse grid, then rerun the same transient."
1051
+ )
1052
+ if "spectral_centroid_toroidal_fraction" in failed_metrics:
1053
+ recommendations.append(
1054
+ "Refine toroidal resolution and map sampling; the turbulent spectrum is "
1055
+ "moving in toroidal-mode space across refinement levels."
1056
+ )
1057
+ if "spectral_centroid_poloidal_fraction" in failed_metrics:
1058
+ recommendations.append(
1059
+ "Refine poloidal resolution and interpolation order; the turbulent "
1060
+ "spectrum is moving in poloidal-mode space across refinement levels."
1061
+ )
1062
+ if "final_potential_residual_l2" in failed_metrics:
1063
+ recommendations.append(
1064
+ "Check the elliptic potential solve tolerance and conditioning only after "
1065
+ "transport and spectral metrics are stable."
1066
+ )
1067
+ if not recommendations and failed_metric_reports:
1068
+ recommendations.append(
1069
+ f"Rerun the {refinement_axis} refinement with the dominant failed metric "
1070
+ "as the primary convergence observable."
1071
+ )
1072
+ return recommendations
1073
+
1074
+
1075
+ def _movie_refinement_failed_metric_names(diagnostics: dict[str, Any]) -> set[str]:
1076
+ return {
1077
+ str(report.get("metric"))
1078
+ for report in diagnostics.get("failed_metric_reports", [])
1079
+ if report.get("metric") is not None
1080
+ }
1081
+
1082
+
1083
+ def _movie_refinement_near_tolerance_metric_names(
1084
+ diagnostics: dict[str, Any],
1085
+ ) -> set[str]:
1086
+ return {
1087
+ str(report.get("metric"))
1088
+ for report in diagnostics.get("near_tolerance_failed_metric_reports", [])
1089
+ if report.get("metric") is not None
1090
+ }
1091
+
1092
+
1093
+ def _movie_refinement_potential_solve_action(
1094
+ *,
1095
+ grid_failed_metrics: set[str],
1096
+ time_failed_metrics: set[str],
1097
+ ) -> str:
1098
+ residual_failed = "final_potential_residual_l2" in (
1099
+ set(grid_failed_metrics) | set(time_failed_metrics)
1100
+ )
1101
+ if not residual_failed:
1102
+ return "no_potential_residual_blocker"
1103
+ physics_metrics = (
1104
+ set(grid_failed_metrics) | set(time_failed_metrics)
1105
+ ) - {"final_potential_residual_l2"}
1106
+ if physics_metrics:
1107
+ return "check_potential_solver_after_primary_physics_metric_refinement"
1108
+ return "rerun_same_grid_time_pair_with_larger_potential_iterations"
1109
+
1110
+
1111
+ def _movie_refinement_max_potential_iterations(
1112
+ *diagnostics_items: dict[str, Any],
1113
+ ) -> int | None:
1114
+ values: list[int] = []
1115
+ for diagnostics in diagnostics_items:
1116
+ for pair in diagnostics.get("pair_reports", []):
1117
+ for key in ("coarse_potential_iterations", "fine_potential_iterations"):
1118
+ value = _optional_int(pair.get(key))
1119
+ if value is not None:
1120
+ values.append(value)
1121
+ if not values:
1122
+ return None
1123
+ return max(values)
1124
+
1125
+
1126
+ def _movie_refinement_finest_grid(
1127
+ diagnostics: dict[str, Any],
1128
+ ) -> tuple[int, int, int] | None:
1129
+ grids: list[tuple[int, int, int]] = []
1130
+ for pair in diagnostics.get("pair_reports", []):
1131
+ for key in ("coarse_grid", "fine_grid"):
1132
+ grid = pair.get(key)
1133
+ if isinstance(grid, (list, tuple)) and len(grid) == 3:
1134
+ try:
1135
+ grids.append(tuple(int(value) for value in grid))
1136
+ except (TypeError, ValueError):
1137
+ continue
1138
+ if not grids:
1139
+ return None
1140
+ return max(grids, key=lambda grid: int(np.prod(grid, dtype=np.int64)))
1141
+
1142
+
1143
+ def _movie_refinement_scaled_grid(
1144
+ grid: tuple[int, int, int],
1145
+ multipliers: tuple[float, float, float],
1146
+ ) -> tuple[int, int, int]:
1147
+ suggested = []
1148
+ for axis_size, multiplier in zip(grid, multipliers, strict=True):
1149
+ if float(multiplier) <= 1.0:
1150
+ scaled = int(axis_size)
1151
+ else:
1152
+ scaled = int(np.ceil(float(axis_size) * float(multiplier)))
1153
+ if scaled <= int(axis_size):
1154
+ scaled = int(axis_size) + 1
1155
+ suggested.append(scaled)
1156
+ # Keep the rFFT/toroidal direction even where possible for cleaner spectra.
1157
+ if suggested[2] % 2:
1158
+ suggested[2] += 1
1159
+ return tuple(suggested)
1160
+
1161
+
1162
+ def _movie_refinement_metric_floor(key: str) -> float:
1163
+ return float(
1164
+ ESSOS_IMPORTED_DRB_MOVIE_REFINEMENT_METRIC_FLOORS.get(
1165
+ key,
1166
+ ESSOS_IMPORTED_DRB_MOVIE_REFINEMENT_DEFAULT_METRIC_FLOOR,
1167
+ )
1168
+ )
1169
+
1170
+
1171
+ def _signed_metric_agrees(coarse_value: Any, fine_value: Any, *, floor: float = 1.0e-12) -> bool:
1172
+ coarse_float = _optional_float(coarse_value)
1173
+ fine_float = _optional_float(fine_value)
1174
+ if coarse_float is None or fine_float is None:
1175
+ return False
1176
+ if max(abs(coarse_float), abs(fine_float)) <= floor:
1177
+ return True
1178
+ return bool(np.sign(coarse_float) == np.sign(fine_float))
1179
+
1180
+
1181
+ def _optional_float(value: Any) -> float | None:
1182
+ try:
1183
+ result = float(value)
1184
+ except (TypeError, ValueError):
1185
+ return None
1186
+ return result if np.isfinite(result) else None
1187
+
1188
+
1189
+ def _optional_int(value: Any) -> int | None:
1190
+ try:
1191
+ result = int(value)
1192
+ except (TypeError, ValueError):
1193
+ return None
1194
+ return result
1195
+
1196
+
1197
+ def _same_optional_float(value: Any, expected: float, *, rtol: float = 1.0e-12) -> bool:
1198
+ result = _optional_float(value)
1199
+ if result is None:
1200
+ return False
1201
+ return bool(np.isclose(result, float(expected), rtol=rtol, atol=rtol))
1202
+
1203
+
1204
+ def _same_optional_int(value: Any, expected: int) -> bool:
1205
+ result = _optional_int(value)
1206
+ return result is not None and result == int(expected)
1207
+
1208
+
1209
+ def _normalize_optional_report_string(value: Any) -> str | None:
1210
+ if value is None:
1211
+ return None
1212
+ text = str(value).strip()
1213
+ if not text or text.lower() == "none":
1214
+ return None
1215
+ return text
1216
+
1217
+
1218
+ def create_essos_imported_drb_movie_package(
1219
+ *,
1220
+ output_root: str | Path,
1221
+ case_label: str = "essos_imported_drb_movie_campaign",
1222
+ coil_json_path: str | Path | None = None,
1223
+ vmec_wout_path: str | Path | None = None,
1224
+ essos_root: str | Path | None = None,
1225
+ map_source: str = "coil",
1226
+ nx: int = 8,
1227
+ ny: int = 28,
1228
+ nz: int = 80,
1229
+ rho_min: float = 0.20,
1230
+ rho_max: float = 0.92,
1231
+ maxtime: float = 135.0,
1232
+ times_to_trace: int = 720,
1233
+ frames: int = 32,
1234
+ substeps_per_frame: int = 6,
1235
+ dt: float = 1.2e-3,
1236
+ potential_iterations: int = ESSOS_IMPORTED_DRB_MOVIE_DEFAULT_POTENTIAL_ITERATIONS,
1237
+ potential_regularization: float = ESSOS_IMPORTED_DRB_MOVIE_DEFAULT_POTENTIAL_REGULARIZATION,
1238
+ potential_preconditioner: str | None = (
1239
+ ESSOS_IMPORTED_DRB_MOVIE_DEFAULT_POTENTIAL_PRECONDITIONER
1240
+ ),
1241
+ ) -> EssosImportedDrbMovieArtifacts:
1242
+ root = Path(output_root)
1243
+ data_dir = root / "data"
1244
+ images_dir = root / "images"
1245
+ movies_dir = root / "movies"
1246
+ data_dir.mkdir(parents=True, exist_ok=True)
1247
+ images_dir.mkdir(parents=True, exist_ok=True)
1248
+ movies_dir.mkdir(parents=True, exist_ok=True)
1249
+
1250
+ result = build_essos_imported_drb_movie_campaign(
1251
+ coil_json_path=coil_json_path,
1252
+ vmec_wout_path=vmec_wout_path,
1253
+ essos_root=essos_root,
1254
+ map_source=map_source,
1255
+ nx=nx,
1256
+ ny=ny,
1257
+ nz=nz,
1258
+ rho_min=rho_min,
1259
+ rho_max=rho_max,
1260
+ maxtime=maxtime,
1261
+ times_to_trace=times_to_trace,
1262
+ frames=frames,
1263
+ substeps_per_frame=substeps_per_frame,
1264
+ dt=dt,
1265
+ potential_iterations=potential_iterations,
1266
+ potential_regularization=potential_regularization,
1267
+ potential_preconditioner=potential_preconditioner,
1268
+ )
1269
+ report = dict(result.report)
1270
+ report_json_path = data_dir / f"{case_label}.json"
1271
+ arrays_npz_path = data_dir / f"{case_label}.npz"
1272
+ np.savez_compressed(arrays_npz_path, **result.arrays)
1273
+ snapshot_png_path = images_dir / f"{case_label}_snapshots.png"
1274
+ save_essos_imported_drb_snapshot_panel(result.geometry, result.arrays, snapshot_png_path)
1275
+ diagnostics_png_path = images_dir / f"{case_label}_diagnostics.png"
1276
+ save_essos_imported_drb_diagnostics_panel(result.geometry, result.report, result.arrays, diagnostics_png_path)
1277
+ poster_png_path = images_dir / f"{case_label}_poster.png"
1278
+ save_essos_imported_drb_3d_frame(
1279
+ result.geometry,
1280
+ result.arrays["density_fluctuation_history"][-1],
1281
+ float(result.arrays["time"][-1]),
1282
+ poster_png_path,
1283
+ vmax=float(result.arrays["movie_vmax"][0]),
1284
+ )
1285
+ movie_gif_path = movies_dir / f"{case_label}.gif"
1286
+ save_essos_imported_drb_3d_movie(result.geometry, result.arrays, movie_gif_path)
1287
+ report.update(_audit_movie_gif(movie_gif_path))
1288
+ _write_strict_json(report_json_path, report)
1289
+ return EssosImportedDrbMovieArtifacts(
1290
+ report_json_path=report_json_path,
1291
+ arrays_npz_path=arrays_npz_path,
1292
+ snapshot_png_path=snapshot_png_path,
1293
+ diagnostics_png_path=diagnostics_png_path,
1294
+ poster_png_path=poster_png_path,
1295
+ movie_gif_path=movie_gif_path,
1296
+ )
1297
+
1298
+
1299
+ def create_essos_imported_drb_movie_stationarity_package(
1300
+ *,
1301
+ output_root: str | Path,
1302
+ case_label: str = "essos_imported_drb_movie_stationarity",
1303
+ coil_json_path: str | Path | None = None,
1304
+ vmec_wout_path: str | Path | None = None,
1305
+ essos_root: str | Path | None = None,
1306
+ map_source: str = "hybrid",
1307
+ nx: int = 16,
1308
+ ny: int = 96,
1309
+ nz: int = 48,
1310
+ rho_min: float = 0.20,
1311
+ rho_max: float = 0.60,
1312
+ maxtime: float = 24.0,
1313
+ times_to_trace: int = 80,
1314
+ frames: int = 12,
1315
+ substeps_per_frame: int = 3,
1316
+ dt: float = 2.0e-3,
1317
+ potential_iterations: int = 3072,
1318
+ potential_regularization: float = ESSOS_IMPORTED_DRB_MOVIE_DEFAULT_POTENTIAL_REGULARIZATION,
1319
+ potential_preconditioner: str | None = "jacobi",
1320
+ tail_fraction: float = 0.50,
1321
+ relative_tolerance: float = 0.35,
1322
+ min_frames: int = 12,
1323
+ ) -> EssosImportedDrbMovieStationarityArtifacts:
1324
+ """Run a report-only long-window movie stationarity gate.
1325
+
1326
+ This function intentionally writes JSON only. Use
1327
+ ``create_essos_imported_drb_movie_package`` later, with the same physics and
1328
+ solver settings, only after this gate passes.
1329
+ """
1330
+
1331
+ root = Path(output_root)
1332
+ data_dir = root / "data"
1333
+ data_dir.mkdir(parents=True, exist_ok=True)
1334
+ result = build_essos_imported_drb_movie_campaign(
1335
+ coil_json_path=coil_json_path,
1336
+ vmec_wout_path=vmec_wout_path,
1337
+ essos_root=essos_root,
1338
+ map_source=map_source,
1339
+ nx=nx,
1340
+ ny=ny,
1341
+ nz=nz,
1342
+ rho_min=rho_min,
1343
+ rho_max=rho_max,
1344
+ maxtime=maxtime,
1345
+ times_to_trace=times_to_trace,
1346
+ frames=frames,
1347
+ substeps_per_frame=substeps_per_frame,
1348
+ dt=dt,
1349
+ potential_iterations=potential_iterations,
1350
+ potential_regularization=potential_regularization,
1351
+ potential_preconditioner=potential_preconditioner,
1352
+ )
1353
+ stationarity_report = build_essos_imported_drb_movie_stationarity_report(
1354
+ movie_report=result.report,
1355
+ arrays=result.arrays,
1356
+ tail_fraction=tail_fraction,
1357
+ relative_tolerance=relative_tolerance,
1358
+ min_frames=min_frames,
1359
+ )
1360
+ report_json_path = data_dir / f"{case_label}.json"
1361
+ _write_strict_json(report_json_path, stationarity_report)
1362
+ return EssosImportedDrbMovieStationarityArtifacts(
1363
+ report_json_path=report_json_path,
1364
+ )
1365
+
1366
+
1367
+ def build_essos_imported_drb_movie_stationarity_report(
1368
+ *,
1369
+ movie_report: dict[str, Any],
1370
+ arrays: dict[str, np.ndarray],
1371
+ tail_fraction: float = 0.50,
1372
+ relative_tolerance: float = 0.35,
1373
+ min_frames: int = 12,
1374
+ ) -> dict[str, Any]:
1375
+ """Evaluate whether a movie history has a stable long-window tail."""
1376
+
1377
+ diagnostics = np.asarray(arrays.get("diagnostics", []), dtype=np.float64)
1378
+ time_values = np.asarray(arrays.get("time", []), dtype=np.float64)
1379
+ history = np.asarray(
1380
+ arrays.get("density_fluctuation_history", []),
1381
+ dtype=np.float64,
1382
+ )
1383
+ if diagnostics.ndim != 2 or diagnostics.shape[1] < 7:
1384
+ return _build_failed_stationarity_report(
1385
+ movie_report=movie_report,
1386
+ frame_count=int(diagnostics.shape[0]) if diagnostics.ndim else 0,
1387
+ tail_fraction=tail_fraction,
1388
+ relative_tolerance=relative_tolerance,
1389
+ min_frames=min_frames,
1390
+ reason="diagnostics_shape_invalid",
1391
+ )
1392
+ frame_count = int(diagnostics.shape[0])
1393
+ tail_start = int(np.floor((1.0 - float(tail_fraction)) * frame_count))
1394
+ tail_start = min(max(tail_start, 0), max(frame_count - 2, 0))
1395
+ tail = diagnostics[tail_start:, :]
1396
+ split = tail.shape[0] // 2
1397
+ if split <= 0 or tail.shape[0] - split <= 0:
1398
+ return _build_failed_stationarity_report(
1399
+ movie_report=movie_report,
1400
+ frame_count=frame_count,
1401
+ tail_fraction=tail_fraction,
1402
+ relative_tolerance=relative_tolerance,
1403
+ min_frames=min_frames,
1404
+ reason="tail_window_too_short",
1405
+ )
1406
+
1407
+ first_tail = tail[:split, :]
1408
+ second_tail = tail[split:, :]
1409
+ metric_columns = {
1410
+ "fluctuation_rms": 0,
1411
+ "ion_density_mean": 1,
1412
+ "neutral_density_mean": 2,
1413
+ "vorticity_rms": 3,
1414
+ }
1415
+ metric_reports = {
1416
+ name: _stationarity_metric_report(
1417
+ name=name,
1418
+ first_values=first_tail[:, column],
1419
+ second_values=second_tail[:, column],
1420
+ relative_tolerance=relative_tolerance,
1421
+ )
1422
+ for name, column in metric_columns.items()
1423
+ }
1424
+ potential_tail = tail[:, 4]
1425
+ ion_min_tail = tail[:, 5]
1426
+ neutral_min_tail = tail[:, 6]
1427
+ finite = bool(
1428
+ np.all(np.isfinite(diagnostics))
1429
+ and np.all(np.isfinite(time_values))
1430
+ and np.all(np.isfinite(history))
1431
+ )
1432
+ frame_gate = frame_count >= int(min_frames)
1433
+ metric_gate = all(bool(report["passed"]) for report in metric_reports.values())
1434
+ potential_tail_max = float(np.max(potential_tail)) if potential_tail.size else None
1435
+ min_density_tail = float(
1436
+ min(np.min(ion_min_tail), np.min(neutral_min_tail))
1437
+ )
1438
+ potential_gate = (
1439
+ potential_tail_max is not None
1440
+ and np.isfinite(potential_tail_max)
1441
+ and potential_tail_max < 5.0
1442
+ )
1443
+ density_gate = bool(np.isfinite(min_density_tail) and min_density_tail > 0.0)
1444
+ movie_gate = bool(movie_report.get("passed", False))
1445
+ stationarity_passed = bool(
1446
+ finite
1447
+ and frame_gate
1448
+ and metric_gate
1449
+ and potential_gate
1450
+ and density_gate
1451
+ and movie_gate
1452
+ )
1453
+ rejection_reasons: list[str] = []
1454
+ if not finite:
1455
+ rejection_reasons.append("nonfinite_movie_or_diagnostic_history")
1456
+ if not frame_gate:
1457
+ rejection_reasons.append("frame_count_below_long_window_requirement")
1458
+ if not metric_gate:
1459
+ rejection_reasons.append("tail_statistical_metrics_not_stationary")
1460
+ if not potential_gate:
1461
+ rejection_reasons.append("tail_potential_residual_above_threshold")
1462
+ if not density_gate:
1463
+ rejection_reasons.append("tail_density_nonpositive")
1464
+ if not movie_gate:
1465
+ rejection_reasons.append("underlying_movie_gate_failed")
1466
+
1467
+ duration = (
1468
+ float(time_values[-1] - time_values[0])
1469
+ if time_values.size >= 2
1470
+ else 0.0
1471
+ )
1472
+ return {
1473
+ "diagnostic": "essos_imported_drb_movie_stationarity",
1474
+ "claim_scope": (
1475
+ "JSON-only long-window statistical stationarity gate for an "
1476
+ "imported-field DRB movie; it does not write GIF, PNG, or NPZ media"
1477
+ ),
1478
+ "case": movie_report.get("case"),
1479
+ "map_source": movie_report.get("map_source"),
1480
+ "movie_physics_grid": movie_report.get("movie_physics_grid"),
1481
+ "frames": frame_count,
1482
+ "min_frames": int(min_frames),
1483
+ "substeps_per_frame": movie_report.get("substeps_per_frame"),
1484
+ "dt": movie_report.get("dt"),
1485
+ "duration": duration,
1486
+ "tail_fraction": float(tail_fraction),
1487
+ "tail_start_index": int(tail_start),
1488
+ "tail_frame_count": int(tail.shape[0]),
1489
+ "relative_tolerance": float(relative_tolerance),
1490
+ "potential_iterations": movie_report.get("potential_iterations"),
1491
+ "potential_preconditioner": movie_report.get("potential_preconditioner"),
1492
+ "underlying_movie_passed": movie_gate,
1493
+ "finite_history": finite,
1494
+ "frame_gate_passed": frame_gate,
1495
+ "metric_gate_passed": metric_gate,
1496
+ "potential_tail_max": potential_tail_max,
1497
+ "potential_gate_passed": potential_gate,
1498
+ "min_density_tail": min_density_tail,
1499
+ "density_gate_passed": density_gate,
1500
+ "metric_reports": metric_reports,
1501
+ "stationarity_passed": stationarity_passed,
1502
+ "publication_ready": stationarity_passed,
1503
+ "movie_promotion_rejection_reasons": rejection_reasons,
1504
+ "movie_report": _stationarity_movie_report_subset(movie_report),
1505
+ }
1506
+
1507
+
1508
+ def _build_failed_stationarity_report(
1509
+ *,
1510
+ movie_report: dict[str, Any],
1511
+ frame_count: int,
1512
+ tail_fraction: float,
1513
+ relative_tolerance: float,
1514
+ min_frames: int,
1515
+ reason: str,
1516
+ ) -> dict[str, Any]:
1517
+ return {
1518
+ "diagnostic": "essos_imported_drb_movie_stationarity",
1519
+ "claim_scope": (
1520
+ "JSON-only long-window statistical stationarity gate for an "
1521
+ "imported-field DRB movie; it does not write GIF, PNG, or NPZ media"
1522
+ ),
1523
+ "case": movie_report.get("case"),
1524
+ "map_source": movie_report.get("map_source"),
1525
+ "movie_physics_grid": movie_report.get("movie_physics_grid"),
1526
+ "frames": int(frame_count),
1527
+ "min_frames": int(min_frames),
1528
+ "tail_fraction": float(tail_fraction),
1529
+ "relative_tolerance": float(relative_tolerance),
1530
+ "stationarity_passed": False,
1531
+ "publication_ready": False,
1532
+ "movie_promotion_rejection_reasons": [str(reason)],
1533
+ "movie_report": _stationarity_movie_report_subset(movie_report),
1534
+ }
1535
+
1536
+
1537
+ def _stationarity_metric_report(
1538
+ *,
1539
+ name: str,
1540
+ first_values: np.ndarray,
1541
+ second_values: np.ndarray,
1542
+ relative_tolerance: float,
1543
+ ) -> dict[str, Any]:
1544
+ first = np.asarray(first_values, dtype=np.float64)
1545
+ second = np.asarray(second_values, dtype=np.float64)
1546
+ first_mean = float(np.mean(first)) if first.size else None
1547
+ second_mean = float(np.mean(second)) if second.size else None
1548
+ tail_values = np.concatenate([first, second])
1549
+ tail_mean = float(np.mean(tail_values)) if tail_values.size else None
1550
+ tail_std = float(np.std(tail_values)) if tail_values.size else None
1551
+ if first_mean is None or second_mean is None or tail_mean is None:
1552
+ relative_drift = None
1553
+ passed = False
1554
+ else:
1555
+ denominator = max(abs(tail_mean), 1.0e-12)
1556
+ relative_drift = abs(second_mean - first_mean) / denominator
1557
+ passed = bool(relative_drift <= float(relative_tolerance))
1558
+ return {
1559
+ "metric": str(name),
1560
+ "first_tail_mean": first_mean,
1561
+ "second_tail_mean": second_mean,
1562
+ "tail_mean": tail_mean,
1563
+ "tail_std": tail_std,
1564
+ "tail_coefficient_of_variation": (
1565
+ None
1566
+ if tail_mean is None or tail_std is None
1567
+ else float(tail_std / max(abs(tail_mean), 1.0e-12))
1568
+ ),
1569
+ "relative_drift": (
1570
+ None if relative_drift is None else float(relative_drift)
1571
+ ),
1572
+ "relative_tolerance": float(relative_tolerance),
1573
+ "passed": passed,
1574
+ }
1575
+
1576
+
1577
+ def _stationarity_movie_report_subset(report: dict[str, Any]) -> dict[str, Any]:
1578
+ keys = (
1579
+ "passed",
1580
+ "case",
1581
+ "map_source",
1582
+ "movie_physics_grid",
1583
+ "frames",
1584
+ "substeps_per_frame",
1585
+ "dt",
1586
+ "potential_iterations",
1587
+ "potential_preconditioner",
1588
+ "final_fluctuation_rms",
1589
+ "max_fluctuation_rms",
1590
+ "final_potential_residual_l2",
1591
+ "final_min_density",
1592
+ "radial_flux_abs_mean",
1593
+ "particle_recycling_relative_error",
1594
+ "neutral_particle_relative_error",
1595
+ )
1596
+ return {key: report.get(key) for key in keys if key in report}
1597
+
1598
+
1599
+ def build_essos_imported_drb_movie_campaign(
1600
+ *,
1601
+ coil_json_path: str | Path | None = None,
1602
+ vmec_wout_path: str | Path | None = None,
1603
+ essos_root: str | Path | None = None,
1604
+ map_source: str = "coil",
1605
+ nx: int = 8,
1606
+ ny: int = 28,
1607
+ nz: int = 80,
1608
+ rho_min: float = 0.20,
1609
+ rho_max: float = 0.92,
1610
+ maxtime: float = 135.0,
1611
+ times_to_trace: int = 720,
1612
+ frames: int = 32,
1613
+ substeps_per_frame: int = 6,
1614
+ dt: float = 1.2e-3,
1615
+ potential_iterations: int = ESSOS_IMPORTED_DRB_MOVIE_DEFAULT_POTENTIAL_ITERATIONS,
1616
+ potential_regularization: float = ESSOS_IMPORTED_DRB_MOVIE_DEFAULT_POTENTIAL_REGULARIZATION,
1617
+ potential_preconditioner: str | None = (
1618
+ ESSOS_IMPORTED_DRB_MOVIE_DEFAULT_POTENTIAL_PRECONDITIONER
1619
+ ),
1620
+ ) -> EssosImportedDrbMovieResult:
1621
+ geometry = build_essos_imported_fci_geometry(
1622
+ coil_json_path=coil_json_path,
1623
+ vmec_wout_path=vmec_wout_path,
1624
+ essos_root=essos_root,
1625
+ map_source=map_source,
1626
+ nx=nx,
1627
+ ny=ny,
1628
+ nz=nz,
1629
+ rho_min=rho_min,
1630
+ rho_max=rho_max,
1631
+ maxtime=maxtime,
1632
+ times_to_trace=times_to_trace,
1633
+ )
1634
+ parameters = FciDrbRhsParameters(
1635
+ recycling_fraction=0.965,
1636
+ recycled_neutral_energy=0.026,
1637
+ vorticity_diffusivity=3.5e-4,
1638
+ potential_iterations=int(potential_iterations),
1639
+ potential_regularization=float(potential_regularization),
1640
+ potential_preconditioner=potential_preconditioner,
1641
+ )
1642
+ run_movie = _build_essos_imported_movie_scan(
1643
+ geometry,
1644
+ parameters=parameters,
1645
+ frames=frames,
1646
+ substeps_per_frame=substeps_per_frame,
1647
+ dt=dt,
1648
+ )
1649
+ initial = _seed_movie_multimode_fluctuations(initial_essos_imported_drb_state(geometry, drive_scale=1.08), geometry)
1650
+ t0 = time.perf_counter()
1651
+ final_state, movie_history, diagnostics = run_movie(initial)
1652
+ _block_until_ready((final_state, movie_history, diagnostics))
1653
+ execute_seconds = time.perf_counter() - t0
1654
+
1655
+ movie_history_np = np.asarray(movie_history, dtype=np.float64)
1656
+ diagnostics_np = np.asarray(diagnostics, dtype=np.float64)
1657
+ final_state_np = _state_to_numpy(final_state)
1658
+ final_sheath, final_neutral = _final_closure_diagnostics(geometry, final_state)
1659
+ report = _build_essos_imported_drb_movie_report(
1660
+ geometry=geometry,
1661
+ movie_history=movie_history_np,
1662
+ diagnostics=diagnostics_np,
1663
+ final_state=final_state_np,
1664
+ final_sheath=final_sheath,
1665
+ final_neutral=final_neutral,
1666
+ frames=frames,
1667
+ substeps_per_frame=substeps_per_frame,
1668
+ dt=dt,
1669
+ execute_seconds=execute_seconds,
1670
+ potential_iterations=int(parameters.potential_iterations),
1671
+ potential_regularization=float(parameters.potential_regularization),
1672
+ potential_preconditioner=parameters.potential_preconditioner,
1673
+ )
1674
+ arrays = _build_essos_imported_drb_movie_arrays(
1675
+ geometry=geometry,
1676
+ movie_history=movie_history_np,
1677
+ diagnostics=diagnostics_np,
1678
+ final_state=final_state_np,
1679
+ frame_dt=float(dt) * float(substeps_per_frame),
1680
+ )
1681
+ return EssosImportedDrbMovieResult(geometry=geometry, report=report, arrays=arrays)
1682
+
1683
+
1684
+ def save_essos_imported_drb_snapshot_panel(
1685
+ geometry: EssosImportedFciGeometry,
1686
+ arrays: dict[str, np.ndarray],
1687
+ path: str | Path,
1688
+ ) -> Path:
1689
+ resolved = Path(path)
1690
+ resolved.parent.mkdir(parents=True, exist_ok=True)
1691
+ history = np.asarray(arrays["density_fluctuation_history"], dtype=np.float64)
1692
+ time = np.asarray(arrays["time"], dtype=np.float64)
1693
+ major_radius, vertical = _major_radius_and_vertical(geometry)
1694
+ time_indices = np.asarray([0, history.shape[0] // 2, history.shape[0] - 1], dtype=int)
1695
+ toroidal_indices = np.linspace(0, geometry.shape[1] - 1, min(4, geometry.shape[1]), dtype=int)
1696
+ vmax = float(arrays["movie_vmax"][0])
1697
+ norm = colors.TwoSlopeNorm(vmin=-vmax, vcenter=0.0, vmax=vmax)
1698
+ fig, axes = plt.subplots(
1699
+ len(time_indices),
1700
+ len(toroidal_indices),
1701
+ figsize=(4.1 * len(toroidal_indices), 3.3 * len(time_indices)),
1702
+ constrained_layout=True,
1703
+ )
1704
+ axes = np.atleast_2d(axes)
1705
+ image = None
1706
+ for row, time_index in enumerate(time_indices):
1707
+ for col, toroidal_index in enumerate(toroidal_indices):
1708
+ axis = axes[row, col]
1709
+ image = axis.pcolormesh(
1710
+ major_radius[:, toroidal_index, :],
1711
+ vertical[:, toroidal_index, :],
1712
+ history[time_index, :, toroidal_index, :],
1713
+ shading="gouraud",
1714
+ cmap="coolwarm",
1715
+ norm=norm,
1716
+ )
1717
+ axis.plot(major_radius[0, toroidal_index, :], vertical[0, toroidal_index, :], color="white", lw=1.4)
1718
+ axis.plot(major_radius[-1, toroidal_index, :], vertical[-1, toroidal_index, :], color="0.20", lw=1.0)
1719
+ axis.set_aspect("equal", adjustable="box")
1720
+ phi_value = 2.0 * np.pi * toroidal_index / max(geometry.shape[1], 1)
1721
+ axis.set_title(rf"$t={time[time_index]:.3f}$, $\phi={phi_value:.2f}$")
1722
+ axis.set_xlabel("R")
1723
+ axis.set_ylabel("Z")
1724
+ if image is not None:
1725
+ fig.colorbar(image, ax=axes, shrink=0.76, label=r"$\tilde{n}_i/\langle n_i\rangle_\phi$")
1726
+ fig.suptitle(f"ESSOS-imported {_essos_imported_map_label(geometry)} DRB transient: density fluctuations on FCI planes", fontsize=15)
1727
+ fig.savefig(resolved, dpi=180)
1728
+ plt.close(fig)
1729
+ return resolved
1730
+
1731
+
1732
+ def save_essos_imported_drb_diagnostics_panel(
1733
+ geometry: EssosImportedFciGeometry,
1734
+ report: dict[str, Any],
1735
+ arrays: dict[str, np.ndarray],
1736
+ path: str | Path,
1737
+ ) -> Path:
1738
+ resolved = Path(path)
1739
+ resolved.parent.mkdir(parents=True, exist_ok=True)
1740
+ diagnostics = np.asarray(arrays["diagnostics"], dtype=np.float64)
1741
+ time = np.asarray(arrays["time"], dtype=np.float64)
1742
+ major_radius, vertical = _major_radius_and_vertical(geometry)
1743
+ toroidal_index = min(geometry.shape[1] // 3, geometry.shape[1] - 1)
1744
+ endpoint_count = np.asarray(arrays["endpoint_count_toroidal"], dtype=np.float64)
1745
+ spectrum = np.asarray(arrays["final_spectrum_log10"], dtype=np.float64)
1746
+
1747
+ fig, axes = plt.subplots(2, 3, figsize=(15.4, 8.5), constrained_layout=True)
1748
+ density_image = axes[0, 0].pcolormesh(
1749
+ major_radius[:, toroidal_index, :],
1750
+ vertical[:, toroidal_index, :],
1751
+ arrays["final_ion_density"][:, toroidal_index, :],
1752
+ shading="gouraud",
1753
+ cmap="turbo",
1754
+ )
1755
+ axes[0, 0].set_title("final ion density")
1756
+ fig.colorbar(density_image, ax=axes[0, 0], label=r"$N_i$")
1757
+
1758
+ neutral_image = axes[0, 1].pcolormesh(
1759
+ major_radius[:, toroidal_index, :],
1760
+ vertical[:, toroidal_index, :],
1761
+ arrays["final_neutral_density"][:, toroidal_index, :],
1762
+ shading="gouraud",
1763
+ cmap="magma",
1764
+ )
1765
+ axes[0, 1].set_title("final neutral density")
1766
+ fig.colorbar(neutral_image, ax=axes[0, 1], label=r"$N_n$")
1767
+
1768
+ endpoint_image = axes[0, 2].imshow(endpoint_count.T, origin="lower", aspect="auto", cmap="inferno")
1769
+ axes[0, 2].set_title("imported endpoint count")
1770
+ axes[0, 2].set_xlabel("toroidal index")
1771
+ axes[0, 2].set_ylabel("poloidal index")
1772
+ fig.colorbar(endpoint_image, ax=axes[0, 2], label="target crossings")
1773
+
1774
+ axes[1, 0].plot(time, diagnostics[:, 0], lw=2.2, label="fluctuation RMS")
1775
+ axes[1, 0].plot(time, diagnostics[:, 1], lw=2.0, label="mean ion density")
1776
+ axes[1, 0].plot(time, diagnostics[:, 2], lw=2.0, label="mean neutral density")
1777
+ axes[1, 0].set_title("global transient diagnostics")
1778
+ axes[1, 0].set_xlabel("normalized time")
1779
+ axes[1, 0].legend(frameon=False, fontsize=8)
1780
+ axes[1, 0].grid(alpha=0.25)
1781
+
1782
+ spectrum_image = axes[1, 1].imshow(spectrum.T, origin="lower", aspect="auto", cmap="viridis")
1783
+ axes[1, 1].set_title("final toroidal-poloidal spectrum")
1784
+ axes[1, 1].set_xlabel("toroidal mode index")
1785
+ axes[1, 1].set_ylabel("poloidal mode index")
1786
+ fig.colorbar(spectrum_image, ax=axes[1, 1], label=r"$\log_{10}$ power")
1787
+
1788
+ radial = np.asarray(arrays["radial_coordinate"], dtype=np.float64)
1789
+ axes[1, 2].plot(radial, arrays["final_radial_flux_proxy"], lw=2.2, color="#005f73")
1790
+ axes[1, 2].axhline(0.0, lw=0.9, color="0.35")
1791
+ axes[1, 2].set_title("final radial flux proxy")
1792
+ axes[1, 2].set_xlabel(r"$\rho$")
1793
+ axes[1, 2].set_ylabel(r"$\langle \tilde{n}_i \tilde{v}_\rho\rangle$")
1794
+ axes[1, 2].grid(alpha=0.25)
1795
+ axes[1, 2].text(
1796
+ 0.04,
1797
+ 0.95,
1798
+ "\n".join(
1799
+ [
1800
+ f"RMS = {report['final_fluctuation_rms']:.2e}",
1801
+ f"endpoint frac. = {report['endpoint_fraction']:.2f}",
1802
+ f"sheath residual = {report['particle_recycling_relative_error']:.1e}",
1803
+ f"neutral residual = {report['neutral_particle_relative_error']:.1e}",
1804
+ ]
1805
+ ),
1806
+ transform=axes[1, 2].transAxes,
1807
+ va="top",
1808
+ fontsize=8,
1809
+ bbox={"facecolor": "white", "alpha": 0.84, "edgecolor": "0.82"},
1810
+ )
1811
+
1812
+ for axis in (axes[0, 0], axes[0, 1]):
1813
+ axis.plot(major_radius[0, toroidal_index, :], vertical[0, toroidal_index, :], color="white", lw=1.4)
1814
+ axis.plot(major_radius[-1, toroidal_index, :], vertical[-1, toroidal_index, :], color="0.22", lw=1.0)
1815
+ axis.set_aspect("equal", adjustable="box")
1816
+ axis.set_xlabel("R")
1817
+ axis.set_ylabel("Z")
1818
+ fig.suptitle(f"ESSOS-imported {_essos_imported_map_label(geometry)} DRB transient: sheath, recycling, neutrals, and fluctuation gates", fontsize=15)
1819
+ fig.savefig(resolved, dpi=180)
1820
+ plt.close(fig)
1821
+ return resolved
1822
+
1823
+
1824
+ def save_essos_imported_drb_3d_movie(
1825
+ geometry: EssosImportedFciGeometry,
1826
+ arrays: dict[str, np.ndarray],
1827
+ path: str | Path,
1828
+ ) -> Path:
1829
+ resolved = Path(path)
1830
+ resolved.parent.mkdir(parents=True, exist_ok=True)
1831
+ history = np.asarray(arrays["density_fluctuation_history"], dtype=np.float64)
1832
+ time_values = np.asarray(arrays["time"], dtype=np.float64)
1833
+ frame_indices = np.linspace(0, history.shape[0] - 1, min(24, history.shape[0]), dtype=int)
1834
+ vmax = float(arrays["movie_vmax"][0])
1835
+ with tempfile.TemporaryDirectory(prefix="drbx_essos_drb_movie_") as temp_dir:
1836
+ frame_paths = []
1837
+ for local_index, frame_index in enumerate(frame_indices):
1838
+ frame_path = Path(temp_dir) / f"frame_{local_index:03d}.png"
1839
+ save_essos_imported_drb_3d_frame(
1840
+ geometry,
1841
+ history[frame_index],
1842
+ float(time_values[frame_index]),
1843
+ frame_path,
1844
+ vmax=vmax,
1845
+ )
1846
+ frame_paths.append(frame_path)
1847
+ first = Image.open(frame_paths[0]).convert("RGB").quantize(
1848
+ colors=256,
1849
+ method=Image.Quantize.MEDIANCUT,
1850
+ dither=Image.Dither.NONE,
1851
+ )
1852
+ images = [first]
1853
+ for frame_path in frame_paths[1:]:
1854
+ images.append(Image.open(frame_path).convert("RGB").quantize(palette=first, dither=Image.Dither.NONE))
1855
+ images[0].save(resolved, save_all=True, append_images=images[1:], duration=120, loop=0)
1856
+ for image in images:
1857
+ image.close()
1858
+ return resolved
1859
+
1860
+
1861
+ def save_essos_imported_drb_3d_frame(
1862
+ geometry: EssosImportedFciGeometry,
1863
+ field: np.ndarray,
1864
+ time_value: float,
1865
+ path: str | Path,
1866
+ *,
1867
+ vmax: float | None = None,
1868
+ ) -> Path:
1869
+ if max(geometry.shape) >= 16:
1870
+ try:
1871
+ return _save_essos_imported_drb_3d_frame_pyvista(geometry, field, time_value, path, vmax=vmax)
1872
+ except Exception:
1873
+ pass
1874
+ return _save_essos_imported_drb_3d_frame_matplotlib(geometry, field, time_value, path, vmax=vmax)
1875
+
1876
+
1877
+ def _build_essos_imported_movie_scan(
1878
+ geometry: EssosImportedFciGeometry,
1879
+ *,
1880
+ parameters: FciDrbRhsParameters,
1881
+ frames: int,
1882
+ substeps_per_frame: int,
1883
+ dt: float,
1884
+ ):
1885
+ radial = _normalized_minor_radius_jax(geometry)
1886
+ curvature_proxy = _magnetic_curvature_proxy_jax(geometry)
1887
+ source_envelope = jnp.exp(-jnp.square((radial - 0.30) / 0.20))
1888
+ neutral_puff_envelope = jnp.exp(-jnp.square((radial - 0.86) / 0.12))
1889
+ edge_sink_envelope = jnp.exp(-jnp.square((radial - 0.98) / 0.16))
1890
+ theta = geometry.poloidal_angle
1891
+ phi = geometry.toroidal_angle
1892
+ helical = jnp.sin(2.0 * theta - phi) + 0.35 * jnp.cos(3.0 * theta - 2.0 * phi)
1893
+ helical = helical / jnp.maximum(jnp.std(helical), 1.0e-12)
1894
+ fine_helical = (
1895
+ 0.65 * jnp.sin(5.0 * theta - 3.0 * phi + 0.45 * curvature_proxy)
1896
+ + 0.45 * jnp.cos(7.0 * theta + 2.0 * phi)
1897
+ + 0.25 * jnp.sin(11.0 * theta - 5.0 * phi)
1898
+ )
1899
+ fine_helical = fine_helical / jnp.maximum(jnp.std(fine_helical), 1.0e-12)
1900
+
1901
+ def step_state(state: FciDrbState, scalar_time: jax.Array) -> FciDrbState:
1902
+ result = compute_fci_drb_rhs(state, maps=geometry.maps, metric=geometry.metric, parameters=parameters)
1903
+ phi_field = result.potential
1904
+ pressure = state.ion_pressure + state.electron_pressure
1905
+ grad_pressure = _radial_derivative(pressure, geometry)
1906
+ fluctuation_drive = source_envelope * (
1907
+ 1.0
1908
+ + 0.18 * jnp.sin(31.0 * scalar_time + helical)
1909
+ + 0.120 * jnp.sin(53.0 * scalar_time + fine_helical)
1910
+ + 0.070 * fine_helical
1911
+ )
1912
+ neutral_puff = neutral_puff_envelope * (
1913
+ 1.0 + 0.16 * jnp.cos(17.0 * scalar_time - helical) + 0.055 * jnp.sin(37.0 * scalar_time + fine_helical)
1914
+ )
1915
+ ion_diffusion = conservative_perp_diffusion_xz(state.ion_density, 3.0e-4 * jnp.ones_like(state.ion_density), geometry.metric)
1916
+ electron_diffusion = conservative_perp_diffusion_xz(
1917
+ state.electron_density,
1918
+ 3.0e-4 * jnp.ones_like(state.electron_density),
1919
+ geometry.metric,
1920
+ )
1921
+ ion_pressure_diffusion = conservative_perp_diffusion_xz(
1922
+ state.ion_pressure,
1923
+ 2.5e-4 * jnp.ones_like(state.ion_pressure),
1924
+ geometry.metric,
1925
+ )
1926
+ electron_pressure_diffusion = conservative_perp_diffusion_xz(
1927
+ state.electron_pressure,
1928
+ 2.5e-4 * jnp.ones_like(state.electron_pressure),
1929
+ geometry.metric,
1930
+ )
1931
+ ion_adv = _logical_exb_advection(phi_field, state.ion_density, geometry)
1932
+ electron_adv = _logical_exb_advection(phi_field, state.electron_density, geometry)
1933
+ neutral_adv = 0.35 * _logical_exb_advection(phi_field, state.neutral_density, geometry)
1934
+ pressure_adv = _logical_exb_advection(phi_field, pressure, geometry)
1935
+ vorticity_adv = _logical_exb_advection(phi_field, state.vorticity, geometry)
1936
+ edge_particle_sink = 0.030 * edge_sink_envelope * state.ion_density
1937
+ edge_energy_sink = 0.030 * edge_sink_envelope * pressure
1938
+
1939
+ source_strength = 0.13
1940
+ neutral_puff_strength = 0.045
1941
+ rhs = FciDrbState(
1942
+ ion_density=(
1943
+ result.rhs.ion_density
1944
+ - 0.070 * ion_adv
1945
+ + ion_diffusion
1946
+ + source_strength * fluctuation_drive
1947
+ - edge_particle_sink
1948
+ ),
1949
+ electron_density=(
1950
+ result.rhs.electron_density
1951
+ - 0.070 * electron_adv
1952
+ + electron_diffusion
1953
+ + source_strength * fluctuation_drive
1954
+ - edge_particle_sink
1955
+ + 0.22 * (state.ion_density - state.electron_density)
1956
+ ),
1957
+ neutral_density=result.rhs.neutral_density - 0.022 * neutral_adv + neutral_puff_strength * neutral_puff,
1958
+ ion_pressure=(
1959
+ result.rhs.ion_pressure
1960
+ - 0.042 * pressure_adv
1961
+ + ion_pressure_diffusion
1962
+ + 0.026 * fluctuation_drive
1963
+ - 0.45 * edge_energy_sink
1964
+ ),
1965
+ electron_pressure=(
1966
+ result.rhs.electron_pressure
1967
+ - 0.042 * pressure_adv
1968
+ + electron_pressure_diffusion
1969
+ + 0.035 * fluctuation_drive
1970
+ - 0.55 * edge_energy_sink
1971
+ ),
1972
+ neutral_pressure=result.rhs.neutral_pressure + 0.012 * neutral_puff - 0.010 * neutral_adv,
1973
+ ion_momentum=result.rhs.ion_momentum - 0.018 * _logical_exb_advection(phi_field, state.ion_momentum, geometry),
1974
+ neutral_momentum=result.rhs.neutral_momentum - 0.010 * neutral_adv,
1975
+ vorticity=(
1976
+ result.rhs.vorticity
1977
+ - 0.050 * vorticity_adv
1978
+ + 0.034 * curvature_proxy * grad_pressure
1979
+ + 0.011 * curvature_proxy * fluctuation_drive
1980
+ + 0.010 * source_envelope * fine_helical
1981
+ - 0.028 * state.vorticity
1982
+ ),
1983
+ )
1984
+ return _clip_movie_state(_add_scaled_state(state, rhs, dt))
1985
+
1986
+ def run(initial_state: FciDrbState) -> tuple[FciDrbState, jax.Array, jax.Array]:
1987
+ def frame_step(state: FciDrbState, frame_index: jax.Array) -> tuple[FciDrbState, tuple[jax.Array, jax.Array]]:
1988
+ def substep(local_index: int, carry: FciDrbState) -> FciDrbState:
1989
+ scalar_time = (frame_index * int(substeps_per_frame) + local_index) * float(dt)
1990
+ return step_state(carry, scalar_time)
1991
+
1992
+ next_state = jax.lax.fori_loop(0, int(substeps_per_frame), substep, state)
1993
+ movie_field = _density_fluctuation(next_state.ion_density)
1994
+ result = compute_fci_drb_rhs(next_state, maps=geometry.maps, metric=geometry.metric, parameters=parameters)
1995
+ diagnostics = jnp.asarray(
1996
+ [
1997
+ jnp.sqrt(jnp.mean(jnp.square(movie_field))),
1998
+ jnp.mean(next_state.ion_density),
1999
+ jnp.mean(next_state.neutral_density),
2000
+ jnp.sqrt(jnp.mean(jnp.square(next_state.vorticity))),
2001
+ result.potential_residual_l2,
2002
+ jnp.min(next_state.ion_density),
2003
+ jnp.min(next_state.neutral_density),
2004
+ ],
2005
+ dtype=jnp.float64,
2006
+ )
2007
+ return next_state, (movie_field, diagnostics)
2008
+
2009
+ final_state, (movie_history, diagnostics) = jax.lax.scan(
2010
+ frame_step,
2011
+ initial_state,
2012
+ jnp.arange(int(frames), dtype=jnp.int32),
2013
+ )
2014
+ return final_state, movie_history, diagnostics
2015
+
2016
+ return jax.jit(run)
2017
+
2018
+
2019
+ def _build_essos_imported_drb_movie_report(
2020
+ *,
2021
+ geometry: EssosImportedFciGeometry,
2022
+ movie_history: np.ndarray,
2023
+ diagnostics: np.ndarray,
2024
+ final_state: dict[str, np.ndarray],
2025
+ final_sheath: dict[str, float],
2026
+ final_neutral: dict[str, float],
2027
+ frames: int,
2028
+ substeps_per_frame: int,
2029
+ dt: float,
2030
+ execute_seconds: float,
2031
+ potential_iterations: int,
2032
+ potential_regularization: float,
2033
+ potential_preconditioner: str | None,
2034
+ ) -> dict[str, Any]:
2035
+ final_fluctuation = movie_history[-1]
2036
+ spectrum = np.abs(np.fft.rfftn(final_fluctuation, axes=(1, 2))) ** 2
2037
+ total_power = float(np.sum(spectrum))
2038
+ low_mode_fraction = float(np.sum(spectrum[:, :4, :6]) / max(total_power, 1.0e-30))
2039
+ spectral_stats = _spectral_mode_statistics(spectrum)
2040
+ mode_power = np.mean(spectrum, axis=0)
2041
+ if mode_power.size:
2042
+ mode_power[0, 0] = 0.0
2043
+ peak_mode = np.unravel_index(int(np.argmax(mode_power)), mode_power.shape)
2044
+ endpoint_fraction = float(
2045
+ np.mean(np.asarray(geometry.maps.forward_boundary, dtype=bool) | np.asarray(geometry.maps.backward_boundary, dtype=bool))
2046
+ )
2047
+ map_source = str(geometry.metadata.get("map_source", "coil"))
2048
+ endpoint_gate = endpoint_fraction < 1.0e-12 if map_source == "vmec" else 0.05 < endpoint_fraction <= 1.0
2049
+ b_modulation_gate = 1.01 if map_source == "vmec" else 1.05
2050
+ if map_source == "coil":
2051
+ case = "essos_imported_qa_coil_drb_transient_movie"
2052
+ source = "ESSOS-imported Landreman-Paul QA coil FCI maps with DRBX fixed-layout DRB transient"
2053
+ elif map_source == "vmec":
2054
+ case = "essos_imported_qa_vmec_drb_transient_movie"
2055
+ source = "ESSOS-imported Landreman-Paul QA VMEC-coordinate FCI maps with DRBX fixed-layout DRB transient"
2056
+ else:
2057
+ case = "essos_imported_qa_hybrid_drb_transient_movie"
2058
+ source = "ESSOS-imported Landreman-Paul QA hybrid FCI maps with DRBX fixed-layout DRB transient"
2059
+ bmag = np.asarray(geometry.magnetic_field_magnitude, dtype=np.float64)
2060
+ finite = all(np.all(np.isfinite(value)) for value in [movie_history, diagnostics, *final_state.values()])
2061
+ min_density = float(min(np.min(final_state["ion_density"]), np.min(final_state["neutral_density"])))
2062
+ radial_flux = _radial_flux_proxy(movie_history, geometry)
2063
+ radial_flux_stats = _radial_flux_profile_statistics(radial_flux)
2064
+ report: dict[str, Any] = {
2065
+ "case": case,
2066
+ "source": source,
2067
+ "map_source": map_source,
2068
+ "claim_scope": (
2069
+ "movie-grade reduced DRB transient on a near-boundary VMEC-shaped physics grid; "
2070
+ "coil and hybrid map sources include open-field sheath/recycling endpoints, while "
2071
+ "the VMEC map source is a closed-field coordinate-map reference"
2072
+ ),
2073
+ **classify_essos_imported_drb_movie_evidence(map_source),
2074
+ "geometry": geometry.metadata,
2075
+ "movie_physics_grid": [int(value) for value in geometry.shape],
2076
+ "movie_render_coordinate_model": "raw_vmec_fourier_surface_registered_to_vmec_jax_plot",
2077
+ "frames": int(frames),
2078
+ "substeps_per_frame": int(substeps_per_frame),
2079
+ "dt": float(dt),
2080
+ "potential_solver": "fixed_iteration_metric_weighted_cg",
2081
+ "potential_iterations": int(potential_iterations),
2082
+ "potential_regularization": float(potential_regularization),
2083
+ "potential_preconditioner": potential_preconditioner,
2084
+ "execute_seconds": float(execute_seconds),
2085
+ "endpoint_fraction": endpoint_fraction,
2086
+ "magnetic_field_modulation": float(np.max(bmag) / max(float(np.min(bmag)), 1.0e-30)),
2087
+ "connection_length_mean": float(np.mean(np.asarray(geometry.connection_length, dtype=np.float64))),
2088
+ "final_min_density": min_density,
2089
+ "initial_fluctuation_rms": float(diagnostics[0, 0]),
2090
+ "final_fluctuation_rms": float(diagnostics[-1, 0]),
2091
+ "max_fluctuation_rms": float(np.max(diagnostics[:, 0])),
2092
+ "final_ion_density_mean": float(np.mean(final_state["ion_density"])),
2093
+ "final_neutral_density_mean": float(np.mean(final_state["neutral_density"])),
2094
+ "final_vorticity_rms": float(diagnostics[-1, 3]),
2095
+ "final_potential_residual_l2": float(diagnostics[-1, 4]),
2096
+ "radial_flux_proxy": radial_flux_stats["mean"],
2097
+ "radial_flux_abs_mean": radial_flux_stats["abs_mean"],
2098
+ "radial_flux_rms": radial_flux_stats["rms"],
2099
+ "radial_flux_peak_abs": radial_flux_stats["peak_abs"],
2100
+ "radial_flux_cancellation_ratio": radial_flux_stats["cancellation_ratio"],
2101
+ "radial_flux_positive_fraction": radial_flux_stats["positive_fraction"],
2102
+ "low_mode_spectral_power_fraction": low_mode_fraction,
2103
+ **spectral_stats,
2104
+ "dominant_poloidal_mode_index": int(peak_mode[1]),
2105
+ "dominant_toroidal_mode_index": int(peak_mode[0]),
2106
+ **final_sheath,
2107
+ **final_neutral,
2108
+ }
2109
+ report["passed"] = (
2110
+ finite
2111
+ and min_density > 0.0
2112
+ and endpoint_gate
2113
+ and report["magnetic_field_modulation"] > b_modulation_gate
2114
+ and report["final_fluctuation_rms"] > 1.0e-4
2115
+ and report["final_potential_residual_l2"] < 5.0
2116
+ and report["max_fluctuation_rms"] > report["initial_fluctuation_rms"] * 0.80
2117
+ and report["radial_flux_abs_mean"] > 1.0e-8
2118
+ and 0.0 < low_mode_fraction <= 1.0
2119
+ and report["particle_recycling_relative_error"] < 1.0e-10
2120
+ and report["current_balance_relative_error"] < 1.0e-10
2121
+ and report["neutral_particle_relative_error"] < 1.0e-10
2122
+ and report["neutral_momentum_relative_error"] < 1.0e-10
2123
+ )
2124
+ return report
2125
+
2126
+
2127
+ def _build_essos_imported_drb_movie_arrays(
2128
+ *,
2129
+ geometry: EssosImportedFciGeometry,
2130
+ movie_history: np.ndarray,
2131
+ diagnostics: np.ndarray,
2132
+ final_state: dict[str, np.ndarray],
2133
+ frame_dt: float,
2134
+ ) -> dict[str, np.ndarray]:
2135
+ vmax = float(np.nanpercentile(np.abs(movie_history), 95.0))
2136
+ if not np.isfinite(vmax) or vmax <= 0.0:
2137
+ vmax = 1.0
2138
+ final_spectrum = np.mean(np.abs(np.fft.rfftn(movie_history[-1], axes=(1, 2))) ** 2, axis=0)
2139
+ final_spectrum[0, 0] = 0.0
2140
+ return {
2141
+ "density_fluctuation_history": movie_history.astype(np.float16),
2142
+ "diagnostics": diagnostics.astype(np.float32),
2143
+ "time": (np.arange(movie_history.shape[0], dtype=np.float64) * float(frame_dt)).astype(np.float64),
2144
+ "movie_vmax": np.asarray([vmax], dtype=np.float32),
2145
+ "x": np.asarray(geometry.coordinates_x, dtype=np.float32),
2146
+ "y": np.asarray(geometry.coordinates_y, dtype=np.float32),
2147
+ "z": np.asarray(geometry.coordinates_z, dtype=np.float32),
2148
+ "radial_coordinate": np.mean(np.asarray(geometry.minor_radius, dtype=np.float64), axis=(1, 2)).astype(np.float32),
2149
+ "magnetic_field_section": np.asarray(geometry.magnetic_field_magnitude[:, 0, :], dtype=np.float32),
2150
+ "endpoint_count_toroidal": (
2151
+ np.asarray(geometry.maps.forward_boundary, dtype=np.float64)
2152
+ + np.asarray(geometry.maps.backward_boundary, dtype=np.float64)
2153
+ ).sum(axis=0).astype(np.float32),
2154
+ "final_ion_density": final_state["ion_density"].astype(np.float32),
2155
+ "final_neutral_density": final_state["neutral_density"].astype(np.float32),
2156
+ "final_vorticity": final_state["vorticity"].astype(np.float32),
2157
+ "final_radial_flux_proxy": _radial_flux_proxy(movie_history, geometry).astype(np.float32),
2158
+ "final_spectrum_log10": np.log10(np.maximum(final_spectrum, 1.0e-18)).astype(np.float32),
2159
+ }
2160
+
2161
+
2162
+ def _final_closure_diagnostics(
2163
+ geometry: EssosImportedFciGeometry,
2164
+ state: FciDrbState,
2165
+ ) -> tuple[dict[str, float], dict[str, float]]:
2166
+ ion_density = jnp.asarray(state.ion_density, dtype=jnp.float64)
2167
+ electron_density = jnp.asarray(state.electron_density, dtype=jnp.float64)
2168
+ ion_temperature = state.ion_pressure / jnp.maximum(ion_density, 1.0e-12)
2169
+ electron_temperature = state.electron_pressure / jnp.maximum(electron_density, 1.0e-12)
2170
+ sheath = compute_fci_sheath_recycling(
2171
+ ion_density,
2172
+ electron_temperature,
2173
+ ion_temperature,
2174
+ geometry.maps,
2175
+ recycling_fraction=0.965,
2176
+ recycled_neutral_energy=0.026,
2177
+ )
2178
+ neutral = compute_fci_neutral_reaction_diffusion(
2179
+ neutral_density=state.neutral_density,
2180
+ neutral_pressure=state.neutral_pressure,
2181
+ neutral_momentum=state.neutral_momentum,
2182
+ ion_density=state.ion_density,
2183
+ ion_pressure=state.ion_pressure,
2184
+ ion_momentum=state.ion_momentum,
2185
+ electron_density=state.electron_density,
2186
+ electron_pressure=state.electron_pressure,
2187
+ maps=geometry.maps,
2188
+ metric=geometry.metric,
2189
+ )
2190
+ sheath_report = {
2191
+ "total_particle_loss": float(sheath.total_ion_particle_loss),
2192
+ "total_target_heat_load": float(sheath.total_target_heat_load),
2193
+ "particle_recycling_relative_error": float(
2194
+ jnp.abs(sheath.particle_recycling_residual) / jnp.maximum(jnp.abs(sheath.total_recycled_particle_source), 1.0e-30)
2195
+ ),
2196
+ "current_balance_relative_error": float(
2197
+ jnp.abs(sheath.current_balance_residual) / jnp.maximum(jnp.abs(sheath.total_ion_particle_loss), 1.0e-30)
2198
+ ),
2199
+ }
2200
+ neutral_report = {
2201
+ "total_ionisation": float(jnp.sum(neutral.ionisation_rate)),
2202
+ "total_recombination": float(jnp.sum(neutral.recombination_rate)),
2203
+ "total_charge_exchange": float(jnp.sum(neutral.charge_exchange_rate)),
2204
+ "neutral_particle_relative_error": float(
2205
+ jnp.abs(neutral.total_particle_residual)
2206
+ / jnp.maximum(jnp.sum(jnp.abs(neutral.ion_density_source)), 1.0e-30)
2207
+ ),
2208
+ "neutral_momentum_relative_error": float(
2209
+ jnp.abs(neutral.total_momentum_residual)
2210
+ / jnp.maximum(jnp.sum(jnp.abs(neutral.ion_momentum_source)), 1.0e-30)
2211
+ ),
2212
+ }
2213
+ return sheath_report, neutral_report
2214
+
2215
+
2216
+ def _save_essos_imported_drb_3d_frame_pyvista(
2217
+ geometry: EssosImportedFciGeometry,
2218
+ field: np.ndarray,
2219
+ time_value: float,
2220
+ path: str | Path,
2221
+ *,
2222
+ vmax: float | None,
2223
+ ) -> Path:
2224
+ import pyvista as pv
2225
+
2226
+ resolved = Path(path)
2227
+ resolved.parent.mkdir(parents=True, exist_ok=True)
2228
+ render = _build_movie_render_coordinates(geometry, raw_vmec_scale=True)
2229
+ x = render["x"]
2230
+ y = render["y"]
2231
+ z = render["z"]
2232
+ phi = render["phi"]
2233
+ theta = render["theta"]
2234
+ values = np.asarray(field, dtype=np.float64)
2235
+ value_limit = _movie_value_limit(values, vmax)
2236
+ scalar_name = "ion density fluctuation"
2237
+ nr, nphi, ntheta = x.shape
2238
+ phi_window = np.arange(0, max(3, nphi - max(1, nphi // 4)), dtype=int)
2239
+ theta_window = np.arange(0, ntheta, dtype=int)
2240
+ radial_window = np.arange(0, nr, dtype=int)
2241
+ outer_i = max(nr - 1, 0)
2242
+ middle_i = max(int(0.58 * (nr - 1)), 0)
2243
+
2244
+ plotter = pv.Plotter(off_screen=True, window_size=(1280, 900))
2245
+ plotter.set_background("white")
2246
+ plotter.enable_anti_aliasing("ssaa")
2247
+
2248
+ def add_surface(
2249
+ x_surface: np.ndarray,
2250
+ y_surface: np.ndarray,
2251
+ z_surface: np.ndarray,
2252
+ scalar_values: np.ndarray,
2253
+ *,
2254
+ opacity: float,
2255
+ show_scalar_bar: bool,
2256
+ ) -> None:
2257
+ mesh = pv.StructuredGrid(x_surface, y_surface, z_surface)
2258
+ mesh[scalar_name] = np.asarray(scalar_values, dtype=np.float64).ravel(order="F")
2259
+ plotter.add_mesh(
2260
+ mesh,
2261
+ scalars=scalar_name,
2262
+ cmap="coolwarm",
2263
+ clim=(-value_limit, value_limit),
2264
+ opacity=opacity,
2265
+ smooth_shading=True,
2266
+ show_edges=False,
2267
+ show_scalar_bar=show_scalar_bar,
2268
+ scalar_bar_args={
2269
+ "title": scalar_name,
2270
+ "title_font_size": 18,
2271
+ "label_font_size": 14,
2272
+ "fmt": "%.2e",
2273
+ "shadow": False,
2274
+ },
2275
+ )
2276
+
2277
+ for radial_index, opacity, show_bar in ((outer_i, 0.74, True), (middle_i, 0.46, False)):
2278
+ radial_fraction = float(radial_index) / max(float(nr - 1), 1.0)
2279
+ surface_phi = phi[np.ix_([radial_index], phi_window, theta_window)][0]
2280
+ surface_theta = theta[np.ix_([radial_index], phi_window, theta_window)][0]
2281
+ add_surface(
2282
+ x[np.ix_([radial_index], phi_window, theta_window)][0],
2283
+ y[np.ix_([radial_index], phi_window, theta_window)][0],
2284
+ z[np.ix_([radial_index], phi_window, theta_window)][0],
2285
+ _interpolate_movie_field_surface(
2286
+ values,
2287
+ radial_fraction=radial_fraction,
2288
+ phi=surface_phi,
2289
+ theta=surface_theta,
2290
+ ),
2291
+ opacity=opacity,
2292
+ show_scalar_bar=show_bar,
2293
+ )
2294
+
2295
+ for cut_j in (max(1, nphi // 10), max(2, 3 * nphi // 5)):
2296
+ cut_phi = phi[np.ix_(radial_window, [cut_j], theta_window)][:, 0, :]
2297
+ cut_theta = theta[np.ix_(radial_window, [cut_j], theta_window)][:, 0, :]
2298
+ add_surface(
2299
+ x[np.ix_(radial_window, [cut_j], theta_window)][:, 0, :],
2300
+ y[np.ix_(radial_window, [cut_j], theta_window)][:, 0, :],
2301
+ z[np.ix_(radial_window, [cut_j], theta_window)][:, 0, :],
2302
+ _interpolate_movie_field_cut(
2303
+ values,
2304
+ radial_fractions=np.linspace(0.0, 1.0, nr)[:, None],
2305
+ phi=cut_phi,
2306
+ theta=cut_theta,
2307
+ ),
2308
+ opacity=0.95,
2309
+ show_scalar_bar=False,
2310
+ )
2311
+
2312
+ _add_boundary_wire(plotter, x, y, z, radial_index=0, color="#333333", opacity=0.30)
2313
+ _add_boundary_wire(plotter, x, y, z, radial_index=outer_i, color="black", opacity=0.45)
2314
+ plotter.add_text(
2315
+ f"ESSOS-imported {_essos_imported_map_label(geometry)} DRB transient on Landreman-Paul QA VMEC surfaces\n"
2316
+ f"sheath + recycling + neutral closures, t = {time_value:.3f}",
2317
+ position=(32, 830),
2318
+ font_size=14,
2319
+ color="black",
2320
+ )
2321
+ plotter.add_text(
2322
+ "Fixed camera; opened toroidal sector exposes radial cuts and non-axisymmetric fluctuation structure",
2323
+ position="lower_left",
2324
+ font_size=11,
2325
+ color="black",
2326
+ )
2327
+ center = (float(np.nanmean(x)), float(np.nanmean(y)), float(np.nanmean(z)))
2328
+ radius = 1.55 * max(float(np.nanmax(x) - np.nanmin(x)), float(np.nanmax(y) - np.nanmin(y)))
2329
+ angle = np.deg2rad(-42.0)
2330
+ camera = (
2331
+ center[0] + radius * np.cos(angle),
2332
+ center[1] + radius * np.sin(angle),
2333
+ center[2] + 0.48 * radius,
2334
+ )
2335
+ plotter.camera_position = [camera, center, (0.0, 0.0, 1.0)]
2336
+ plotter.screenshot(str(resolved))
2337
+ plotter.close()
2338
+ return resolved
2339
+
2340
+
2341
+ def _save_essos_imported_drb_3d_frame_matplotlib(
2342
+ geometry: EssosImportedFciGeometry,
2343
+ field: np.ndarray,
2344
+ time_value: float,
2345
+ path: str | Path,
2346
+ *,
2347
+ vmax: float | None,
2348
+ ) -> Path:
2349
+ resolved = Path(path)
2350
+ resolved.parent.mkdir(parents=True, exist_ok=True)
2351
+ values = np.asarray(field, dtype=np.float64)
2352
+ value_limit = _movie_value_limit(values, vmax)
2353
+ norm = colors.TwoSlopeNorm(vmin=-value_limit, vcenter=0.0, vmax=value_limit)
2354
+ cmap = plt.get_cmap("coolwarm")
2355
+ render = _build_movie_render_coordinates(geometry)
2356
+ x = render["x"]
2357
+ y = render["y"]
2358
+ z = render["z"]
2359
+ phi = render["phi"]
2360
+ theta = render["theta"]
2361
+ nr, nphi, _ntheta = x.shape
2362
+ outer_i = nr - 1
2363
+ middle_i = max(int(0.54 * (nr - 1)), 0)
2364
+ phi_segments = ((0, int(0.54 * nphi)), (int(0.70 * nphi), nphi))
2365
+ cut_indices = (int(0.54 * nphi), int(0.70 * nphi))
2366
+
2367
+ fig = plt.figure(figsize=(9.2, 7.4), constrained_layout=False)
2368
+ axis = fig.add_axes([0.00, 0.04, 0.84, 0.88], projection="3d")
2369
+ for start, stop in phi_segments:
2370
+ if stop - start < 3:
2371
+ continue
2372
+ segment = np.s_[start:stop, :]
2373
+ outer_values = _interpolate_movie_field_surface(
2374
+ values,
2375
+ radial_fraction=1.0,
2376
+ phi=phi[outer_i][segment],
2377
+ theta=theta[outer_i][segment],
2378
+ )
2379
+ axis.plot_surface(
2380
+ x[outer_i][segment],
2381
+ y[outer_i][segment],
2382
+ z[outer_i][segment],
2383
+ facecolors=cmap(norm(outer_values)),
2384
+ linewidth=0,
2385
+ antialiased=True,
2386
+ alpha=0.90,
2387
+ shade=False,
2388
+ )
2389
+ middle_values = _interpolate_movie_field_surface(
2390
+ values,
2391
+ radial_fraction=0.54,
2392
+ phi=phi[middle_i][segment],
2393
+ theta=theta[middle_i][segment],
2394
+ )
2395
+ axis.plot_surface(
2396
+ x[middle_i][segment],
2397
+ y[middle_i][segment],
2398
+ z[middle_i][segment],
2399
+ facecolors=cmap(norm(middle_values)),
2400
+ linewidth=0,
2401
+ antialiased=True,
2402
+ alpha=0.48,
2403
+ shade=False,
2404
+ )
2405
+
2406
+ for cut_index in cut_indices:
2407
+ cut_index = int(np.clip(cut_index, 0, nphi - 1))
2408
+ cut_values = _interpolate_movie_field_cut(
2409
+ values,
2410
+ radial_fractions=np.linspace(0.0, 1.0, nr)[:, None],
2411
+ phi=phi[:, cut_index, :],
2412
+ theta=theta[:, cut_index, :],
2413
+ )
2414
+ axis.plot_surface(
2415
+ x[:, cut_index, :],
2416
+ y[:, cut_index, :],
2417
+ z[:, cut_index, :],
2418
+ facecolors=cmap(norm(cut_values)),
2419
+ linewidth=0,
2420
+ antialiased=True,
2421
+ alpha=0.98,
2422
+ shade=False,
2423
+ )
2424
+
2425
+ _plot_movie_boundary_rings(axis, x, y, z, color="0.08", alpha=0.48)
2426
+ scalar = cm.ScalarMappable(norm=norm, cmap=cmap)
2427
+ scalar.set_array([])
2428
+ colorbar_axis = fig.add_axes([0.86, 0.18, 0.028, 0.62])
2429
+ fig.colorbar(scalar, cax=colorbar_axis, label="ion density fluctuation")
2430
+ fig.text(
2431
+ 0.03,
2432
+ 0.955,
2433
+ f"ESSOS-imported {_essos_imported_map_label(geometry)} DRB transient on Landreman-Paul QA VMEC surfaces",
2434
+ ha="left",
2435
+ va="top",
2436
+ fontsize=15,
2437
+ )
2438
+ fig.text(
2439
+ 0.03,
2440
+ 0.918,
2441
+ f"fixed camera, opened toroidal/radial sector, t = {time_value:.3f}",
2442
+ ha="left",
2443
+ va="top",
2444
+ fontsize=11,
2445
+ )
2446
+ fig.text(
2447
+ 0.03,
2448
+ 0.035,
2449
+ "The non-axisymmetric QA boundary is seeded from the VMEC Fourier surface; colors show ion-density fluctuations.",
2450
+ ha="left",
2451
+ va="bottom",
2452
+ fontsize=9,
2453
+ )
2454
+ axis.set_axis_off()
2455
+ axis.grid(False)
2456
+ axis.view_init(elev=21.0, azim=-49.0)
2457
+ extent = float(np.max(np.sqrt(x * x + y * y)))
2458
+ axis.set_xlim(-extent, extent)
2459
+ axis.set_ylim(-extent, extent)
2460
+ axis.set_zlim(float(np.min(z)) * 1.1, float(np.max(z)) * 1.1)
2461
+ try:
2462
+ axis.set_box_aspect((1.0, 1.0, 0.34), zoom=1.45)
2463
+ except TypeError:
2464
+ axis.set_box_aspect((1.0, 1.0, 0.34))
2465
+ fig.savefig(resolved, dpi=170, facecolor="white")
2466
+ plt.close(fig)
2467
+ return resolved
2468
+
2469
+
2470
+ def _build_movie_render_coordinates_impl(
2471
+ geometry: EssosImportedFciGeometry,
2472
+ *,
2473
+ raw_vmec_scale: bool,
2474
+ ) -> dict[str, np.ndarray]:
2475
+ metadata = dict(geometry.metadata)
2476
+ if metadata.get("coordinate_model") == "scaled_vmec_fourier_flux_surfaces":
2477
+ try:
2478
+ wout_path = resolve_essos_landreman_qa_wout(essos_root=os.environ.get("DRBX_ESSOS_ROOT"))
2479
+ if raw_vmec_scale:
2480
+ axis_major_radius = float(metadata.get("vmec_raw_axis_major_radius", metadata["axis_major_radius"]))
2481
+ axis_vertical = float(metadata.get("vmec_raw_axis_vertical", metadata["axis_vertical"]))
2482
+ else:
2483
+ axis_major_radius = float(metadata["axis_major_radius"])
2484
+ axis_vertical = float(metadata["axis_vertical"])
2485
+ return build_essos_vmec_scaled_qa_coordinates(
2486
+ wout_path,
2487
+ nx=max(int(geometry.shape[0]), 18),
2488
+ ny=max(4 * int(geometry.shape[1]), 96),
2489
+ nz=max(2 * int(geometry.shape[2]), 112),
2490
+ rho_min=float(metadata["rho_min"]),
2491
+ rho_max=float(metadata["rho_max"]),
2492
+ axis_major_radius=axis_major_radius,
2493
+ axis_vertical=axis_vertical,
2494
+ )
2495
+ except Exception:
2496
+ pass
2497
+ return {
2498
+ "x": np.asarray(geometry.coordinates_x, dtype=np.float64),
2499
+ "y": np.asarray(geometry.coordinates_y, dtype=np.float64),
2500
+ "z": np.asarray(geometry.coordinates_z, dtype=np.float64),
2501
+ "phi": np.asarray(geometry.toroidal_angle, dtype=np.float64),
2502
+ "theta": np.asarray(geometry.poloidal_angle, dtype=np.float64),
2503
+ }
2504
+
2505
+
2506
+ def _build_movie_render_coordinates(geometry: EssosImportedFciGeometry, *, raw_vmec_scale: bool = False) -> dict[str, np.ndarray]:
2507
+ return _build_movie_render_coordinates_impl(geometry, raw_vmec_scale=raw_vmec_scale)
2508
+
2509
+
2510
+ def _interpolate_movie_field_surface(
2511
+ values: np.ndarray,
2512
+ *,
2513
+ radial_fraction: float,
2514
+ phi: np.ndarray,
2515
+ theta: np.ndarray,
2516
+ ) -> np.ndarray:
2517
+ from scipy.ndimage import map_coordinates
2518
+
2519
+ nx, ny, nz = values.shape
2520
+ radial = np.full_like(phi, float(radial_fraction) * float(nx - 1), dtype=np.float64)
2521
+ if radial_fraction >= 1.0:
2522
+ radial = np.full_like(phi, np.nextafter(float(nx - 1), 0.0), dtype=np.float64)
2523
+ coords = np.asarray(
2524
+ [
2525
+ radial,
2526
+ np.mod(phi, 2.0 * np.pi) / (2.0 * np.pi) * float(ny),
2527
+ np.mod(theta, 2.0 * np.pi) / (2.0 * np.pi) * float(nz),
2528
+ ],
2529
+ dtype=np.float64,
2530
+ )
2531
+ return map_coordinates(values, coords, order=1, mode="wrap")
2532
+
2533
+
2534
+ def _interpolate_movie_field_cut(
2535
+ values: np.ndarray,
2536
+ *,
2537
+ radial_fractions: np.ndarray,
2538
+ phi: np.ndarray,
2539
+ theta: np.ndarray,
2540
+ ) -> np.ndarray:
2541
+ from scipy.ndimage import map_coordinates
2542
+
2543
+ nx, ny, nz = values.shape
2544
+ radial = np.broadcast_to(radial_fractions * float(nx - 1), theta.shape)
2545
+ coords = np.asarray(
2546
+ [
2547
+ radial,
2548
+ np.mod(phi, 2.0 * np.pi) / (2.0 * np.pi) * float(ny),
2549
+ np.mod(theta, 2.0 * np.pi) / (2.0 * np.pi) * float(nz),
2550
+ ],
2551
+ dtype=np.float64,
2552
+ )
2553
+ return map_coordinates(values, coords, order=1, mode="wrap")
2554
+
2555
+
2556
+ def _plot_movie_boundary_rings(
2557
+ axis: Any,
2558
+ x: np.ndarray,
2559
+ y: np.ndarray,
2560
+ z: np.ndarray,
2561
+ *,
2562
+ color: str,
2563
+ alpha: float,
2564
+ ) -> None:
2565
+ for theta_index in np.linspace(0, x.shape[2] - 1, 7, dtype=int):
2566
+ axis.plot(x[-1, :, theta_index], y[-1, :, theta_index], z[-1, :, theta_index], color=color, alpha=alpha, lw=0.55)
2567
+ for phi_index in np.linspace(0, x.shape[1] - 1, 9, dtype=int):
2568
+ axis.plot(x[-1, phi_index, :], y[-1, phi_index, :], z[-1, phi_index, :], color=color, alpha=alpha, lw=0.55)
2569
+
2570
+
2571
+ def _add_boundary_wire(plotter: Any, x: np.ndarray, y: np.ndarray, z: np.ndarray, *, radial_index: int, color: str, opacity: float) -> None:
2572
+ try:
2573
+ import pyvista as pv
2574
+
2575
+ for theta_index in np.linspace(0, x.shape[2] - 1, min(5, x.shape[2]), dtype=int):
2576
+ points = np.column_stack([x[radial_index, :, theta_index], y[radial_index, :, theta_index], z[radial_index, :, theta_index]])
2577
+ points = np.vstack([points, points[:1]])
2578
+ line = pv.PolyData(points)
2579
+ line.lines = np.hstack([[points.shape[0]], np.arange(points.shape[0])])
2580
+ plotter.add_mesh(line, color=color, line_width=1.4, opacity=opacity)
2581
+ except Exception:
2582
+ return
2583
+
2584
+
2585
+ def _state_to_numpy(state: FciDrbState) -> dict[str, np.ndarray]:
2586
+ return {
2587
+ "ion_density": np.asarray(state.ion_density, dtype=np.float64),
2588
+ "electron_density": np.asarray(state.electron_density, dtype=np.float64),
2589
+ "neutral_density": np.asarray(state.neutral_density, dtype=np.float64),
2590
+ "ion_pressure": np.asarray(state.ion_pressure, dtype=np.float64),
2591
+ "electron_pressure": np.asarray(state.electron_pressure, dtype=np.float64),
2592
+ "neutral_pressure": np.asarray(state.neutral_pressure, dtype=np.float64),
2593
+ "ion_momentum": np.asarray(state.ion_momentum, dtype=np.float64),
2594
+ "neutral_momentum": np.asarray(state.neutral_momentum, dtype=np.float64),
2595
+ "vorticity": np.asarray(state.vorticity, dtype=np.float64),
2596
+ }
2597
+
2598
+
2599
+ def _density_fluctuation(ion_density: jax.Array) -> jax.Array:
2600
+ mean = jnp.mean(ion_density, axis=1, keepdims=True)
2601
+ return (ion_density - mean) / jnp.maximum(mean, 1.0e-12)
2602
+
2603
+
2604
+ def _seed_movie_multimode_fluctuations(state: FciDrbState, geometry: EssosImportedFciGeometry) -> FciDrbState:
2605
+ radial = _normalized_minor_radius_jax(geometry)
2606
+ theta = geometry.poloidal_angle
2607
+ phi = geometry.toroidal_angle
2608
+ envelope = jnp.exp(-jnp.square((radial - 0.58) / 0.34))
2609
+ modes = (
2610
+ jnp.sin(3.0 * theta - 2.0 * phi)
2611
+ + 0.55 * jnp.cos(5.0 * theta + 3.0 * phi)
2612
+ + 0.35 * jnp.sin(8.0 * theta - 5.0 * phi)
2613
+ + 0.20 * jnp.cos(13.0 * theta + 4.0 * phi)
2614
+ )
2615
+ modes = modes / jnp.maximum(jnp.std(modes), 1.0e-12)
2616
+ perturbation = 0.038 * envelope * modes
2617
+ ion_density = jnp.maximum(state.ion_density * (1.0 + perturbation), 1.0e-6)
2618
+ electron_density = jnp.maximum(state.electron_density * (1.0 + 0.85 * perturbation), 1.0e-6)
2619
+ neutral_density = jnp.maximum(state.neutral_density * (1.0 + 0.35 * perturbation), 1.0e-8)
2620
+ return FciDrbState(
2621
+ ion_density=ion_density,
2622
+ electron_density=electron_density,
2623
+ neutral_density=neutral_density,
2624
+ ion_pressure=jnp.maximum(state.ion_pressure * (1.0 + 0.75 * perturbation), 1.0e-7 * ion_density),
2625
+ electron_pressure=jnp.maximum(state.electron_pressure * (1.0 + 0.65 * perturbation), 1.0e-7 * electron_density),
2626
+ neutral_pressure=jnp.maximum(state.neutral_pressure * (1.0 + 0.25 * perturbation), 1.0e-8 * neutral_density),
2627
+ ion_momentum=state.ion_momentum + 0.012 * ion_density * envelope * modes,
2628
+ neutral_momentum=state.neutral_momentum + 0.004 * neutral_density * envelope * modes,
2629
+ vorticity=state.vorticity + 0.018 * envelope * modes,
2630
+ )
2631
+
2632
+
2633
+ def _logical_exb_advection(potential: jax.Array, field: jax.Array, geometry: EssosImportedFciGeometry) -> jax.Array:
2634
+ bracket = logical_exb_bracket_xz(potential, field, geometry.metric)
2635
+ scale = jnp.maximum(jnp.mean(jnp.abs(bracket)), 1.0e-8)
2636
+ return bracket / scale
2637
+
2638
+
2639
+ def _radial_derivative(field: jax.Array, geometry: EssosImportedFciGeometry) -> jax.Array:
2640
+ values = jnp.asarray(field, dtype=jnp.float64)
2641
+ spacing = jnp.asarray(geometry.metric.dx, dtype=jnp.float64)
2642
+ centered = (jnp.roll(values, -1, axis=0) - jnp.roll(values, 1, axis=0)) / jnp.maximum(2.0 * spacing, 1.0e-30)
2643
+ first = (values[1, :, :] - values[0, :, :]) / jnp.maximum(spacing[0, :, :], 1.0e-30)
2644
+ last = (values[-1, :, :] - values[-2, :, :]) / jnp.maximum(spacing[-1, :, :], 1.0e-30)
2645
+ return centered.at[0, :, :].set(first).at[-1, :, :].set(last)
2646
+
2647
+
2648
+ def _normalized_minor_radius_jax(geometry: EssosImportedFciGeometry) -> jax.Array:
2649
+ rho = jnp.asarray(geometry.minor_radius, dtype=jnp.float64)
2650
+ return (rho - jnp.min(rho)) / jnp.maximum(jnp.max(rho) - jnp.min(rho), 1.0e-12)
2651
+
2652
+
2653
+ def _magnetic_curvature_proxy_jax(geometry: EssosImportedFciGeometry) -> jax.Array:
2654
+ bmag = jnp.asarray(geometry.magnetic_field_magnitude, dtype=jnp.float64)
2655
+ proxy = (bmag - jnp.mean(bmag, axis=1, keepdims=True)) / jnp.maximum(jnp.mean(bmag), 1.0e-12)
2656
+ return proxy / jnp.maximum(jnp.std(proxy), 1.0e-12)
2657
+
2658
+
2659
+ def _clip_movie_state(state: FciDrbState) -> FciDrbState:
2660
+ ion_density = jnp.maximum(state.ion_density, 1.0e-6)
2661
+ electron_density = jnp.maximum(state.electron_density, 1.0e-6)
2662
+ neutral_density = jnp.maximum(state.neutral_density, 1.0e-8)
2663
+ return FciDrbState(
2664
+ ion_density=ion_density,
2665
+ electron_density=electron_density,
2666
+ neutral_density=neutral_density,
2667
+ ion_pressure=jnp.maximum(state.ion_pressure, 1.0e-7 * ion_density),
2668
+ electron_pressure=jnp.maximum(state.electron_pressure, 1.0e-7 * electron_density),
2669
+ neutral_pressure=jnp.maximum(state.neutral_pressure, 1.0e-8 * neutral_density),
2670
+ ion_momentum=jnp.clip(state.ion_momentum, -2.0, 2.0),
2671
+ neutral_momentum=jnp.clip(state.neutral_momentum, -1.0, 1.0),
2672
+ vorticity=jnp.clip(state.vorticity, -2.0, 2.0),
2673
+ )
2674
+
2675
+
2676
+ def _add_scaled_state(state: FciDrbState, rhs: FciDrbState, scale: float) -> FciDrbState:
2677
+ return jax.tree_util.tree_map(lambda value, increment: value + float(scale) * increment, state, rhs)
2678
+
2679
+
2680
+ def _radial_flux_proxy(movie_history: np.ndarray, geometry: EssosImportedFciGeometry) -> np.ndarray:
2681
+ final = np.asarray(movie_history[-1], dtype=np.float64)
2682
+ potential_proxy = np.roll(final, 2, axis=2)
2683
+ dz = np.asarray(geometry.metric.dz, dtype=np.float64)
2684
+ radial_velocity = -(np.roll(potential_proxy, -1, axis=2) - np.roll(potential_proxy, 1, axis=2)) / np.maximum(2.0 * dz, 1.0e-30)
2685
+ return np.mean(final * radial_velocity, axis=(1, 2))
2686
+
2687
+
2688
+ def _radial_flux_profile_statistics(profile: np.ndarray) -> dict[str, float]:
2689
+ values = np.asarray(profile, dtype=np.float64)
2690
+ finite = values[np.isfinite(values)]
2691
+ if finite.size == 0:
2692
+ return {
2693
+ "mean": 0.0,
2694
+ "abs_mean": 0.0,
2695
+ "rms": 0.0,
2696
+ "peak_abs": 0.0,
2697
+ "cancellation_ratio": 0.0,
2698
+ "positive_fraction": 0.0,
2699
+ }
2700
+ abs_values = np.abs(finite)
2701
+ mean = float(np.mean(finite))
2702
+ abs_mean = float(np.mean(abs_values))
2703
+ return {
2704
+ "mean": mean,
2705
+ "abs_mean": abs_mean,
2706
+ "rms": float(np.sqrt(np.mean(np.square(finite)))),
2707
+ "peak_abs": float(np.max(abs_values)),
2708
+ "cancellation_ratio": float(abs(mean) / max(abs_mean, 1.0e-30)),
2709
+ "positive_fraction": float(np.mean(finite > 0.0)),
2710
+ }
2711
+
2712
+
2713
+ def _spectral_mode_statistics(spectrum: np.ndarray) -> dict[str, Any]:
2714
+ mode_power = np.mean(np.asarray(spectrum, dtype=np.float64), axis=0)
2715
+ if mode_power.ndim != 2 or mode_power.size == 0:
2716
+ return {
2717
+ "spectral_poloidal_mode_count": 0,
2718
+ "spectral_toroidal_mode_count": 0,
2719
+ "spectral_centroid_poloidal_index": 0.0,
2720
+ "spectral_centroid_toroidal_index": 0.0,
2721
+ "spectral_edge_band_power_fraction": 0.0,
2722
+ "low_mode_window_covers_grid": False,
2723
+ }
2724
+ fluctuation_power = np.array(mode_power, copy=True)
2725
+ fluctuation_power[0, 0] = 0.0
2726
+ total = float(np.sum(fluctuation_power))
2727
+ poloidal_count, toroidal_count = fluctuation_power.shape
2728
+ poloidal_index, toroidal_index = np.indices(fluctuation_power.shape)
2729
+ if total <= 1.0e-30:
2730
+ poloidal_centroid = 0.0
2731
+ toroidal_centroid = 0.0
2732
+ edge_fraction = 0.0
2733
+ else:
2734
+ poloidal_centroid = float(np.sum(poloidal_index * fluctuation_power) / total)
2735
+ toroidal_centroid = float(np.sum(toroidal_index * fluctuation_power) / total)
2736
+ poloidal_edge_start = max(1, int(np.floor(0.75 * max(poloidal_count - 1, 1))))
2737
+ toroidal_edge_start = max(1, int(np.floor(0.75 * max(toroidal_count - 1, 1))))
2738
+ edge_mask = (poloidal_index >= poloidal_edge_start) | (
2739
+ toroidal_index >= toroidal_edge_start
2740
+ )
2741
+ edge_fraction = float(np.sum(fluctuation_power[edge_mask]) / total)
2742
+ return {
2743
+ "spectral_poloidal_mode_count": int(poloidal_count),
2744
+ "spectral_toroidal_mode_count": int(toroidal_count),
2745
+ "spectral_centroid_poloidal_index": poloidal_centroid,
2746
+ "spectral_centroid_toroidal_index": toroidal_centroid,
2747
+ "spectral_centroid_poloidal_fraction": float(
2748
+ poloidal_centroid / max(float(poloidal_count - 1), 1.0)
2749
+ ),
2750
+ "spectral_centroid_toroidal_fraction": float(
2751
+ toroidal_centroid / max(float(toroidal_count - 1), 1.0)
2752
+ ),
2753
+ "spectral_edge_band_power_fraction": edge_fraction,
2754
+ "low_mode_window_covers_grid": bool(poloidal_count <= 4 and toroidal_count <= 6),
2755
+ }
2756
+
2757
+
2758
+ def _major_radius_and_vertical(geometry: EssosImportedFciGeometry) -> tuple[np.ndarray, np.ndarray]:
2759
+ x = np.asarray(geometry.coordinates_x, dtype=np.float64)
2760
+ y = np.asarray(geometry.coordinates_y, dtype=np.float64)
2761
+ z = np.asarray(geometry.coordinates_z, dtype=np.float64)
2762
+ return np.sqrt(x * x + y * y), z
2763
+
2764
+
2765
+ def _essos_imported_map_label(geometry: EssosImportedFciGeometry) -> str:
2766
+ map_source = str(geometry.metadata.get("map_source", "coil"))
2767
+ labels = {
2768
+ "coil": "QA-coil",
2769
+ "vmec": "QA VMEC-coordinate",
2770
+ "hybrid": "QA hybrid",
2771
+ }
2772
+ return labels.get(map_source, f"QA {map_source}")
2773
+
2774
+
2775
+ def _movie_value_limit(values: np.ndarray, vmax: float | None) -> float:
2776
+ if vmax is None:
2777
+ vmax = float(np.nanpercentile(np.abs(values), 99.0))
2778
+ if not np.isfinite(vmax) or vmax <= 0.0:
2779
+ return 1.0
2780
+ return float(vmax)
2781
+
2782
+
2783
+ def _audit_movie_gif(path: str | Path) -> dict[str, Any]:
2784
+ resolved = Path(path)
2785
+ if not resolved.exists():
2786
+ return {"movie_audit_passed": False, "movie_audit_reason": "missing_gif"}
2787
+ frames: list[Image.Image] = []
2788
+ try:
2789
+ image = Image.open(resolved)
2790
+ frame_index = 0
2791
+ while True:
2792
+ frames.append(image.convert("RGB"))
2793
+ frame_index += 1
2794
+ image.seek(frame_index)
2795
+ except EOFError:
2796
+ pass
2797
+ except Exception as exc:
2798
+ return {"movie_audit_passed": False, "movie_audit_reason": type(exc).__name__}
2799
+ if len(frames) < 2:
2800
+ return {"movie_audit_passed": False, "movie_frame_count": len(frames), "movie_audit_reason": "too_few_frames"}
2801
+ from PIL import ImageChops, ImageStat
2802
+
2803
+ white = Image.new("RGB", frames[0].size, "white")
2804
+ bboxes = [ImageChops.difference(frame, white).getbbox() for frame in frames]
2805
+ rms_values = []
2806
+ for left, right in zip(frames[:-1], frames[1:], strict=True):
2807
+ stat = ImageStat.Stat(ImageChops.difference(left, right))
2808
+ rms_values.append(float(np.sqrt(np.mean(np.square(stat.rms)))))
2809
+ unique_bbox_count = len({str(value) for value in bboxes})
2810
+ rms_array = np.asarray(rms_values, dtype=np.float64)
2811
+ return {
2812
+ "movie_audit_passed": bool(unique_bbox_count <= 3 and float(np.max(rms_array)) < 12.0),
2813
+ "movie_frame_count": len(frames),
2814
+ "movie_frame_size": [int(frames[0].size[0]), int(frames[0].size[1])],
2815
+ "movie_file_size_bytes": int(resolved.stat().st_size),
2816
+ "movie_bbox_unique_count": int(unique_bbox_count),
2817
+ "movie_frame_rms_min": float(np.min(rms_array)),
2818
+ "movie_frame_rms_median": float(np.median(rms_array)),
2819
+ "movie_frame_rms_max": float(np.max(rms_array)),
2820
+ }
2821
+
2822
+
2823
+ def _block_until_ready(value: object) -> None:
2824
+ for leaf in jax.tree_util.tree_leaves(value):
2825
+ if hasattr(leaf, "block_until_ready"):
2826
+ leaf.block_until_ready()