drbx 2.0.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- drbx/__init__.py +20 -0
- drbx/__main__.py +5 -0
- drbx/cli.py +586 -0
- drbx/config/__init__.py +20 -0
- drbx/config/boutinp.py +556 -0
- drbx/config/model.py +34 -0
- drbx/config/normalization.py +66 -0
- drbx/data/atomic_rates/__init__.py +1 -0
- drbx/data/atomic_rates/iz_AMJUEL_H.x_2.1.5.json +213 -0
- drbx/data/atomic_rates/iz_AMJUEL_H.x_2.3.9a.json +213 -0
- drbx/data/atomic_rates/rec_AMJUEL_H.x_2.1.8.json +213 -0
- drbx/data/atomic_rates/rec_AMJUEL_H.x_2.3.13a.json +213 -0
- drbx/geometry/__init__.py +207 -0
- drbx/geometry/embedding.py +56 -0
- drbx/geometry/essos_import.py +1385 -0
- drbx/geometry/fci_geometry.py +4622 -0
- drbx/geometry/fci_maps.py +85 -0
- drbx/geometry/island_divertor.py +291 -0
- drbx/geometry/metric_tensor.py +99 -0
- drbx/geometry/open_slab.py +150 -0
- drbx/geometry/rotating_ellipse.py +253 -0
- drbx/geometry/shifted_torus.py +225 -0
- drbx/geometry/stellarator.py +287 -0
- drbx/geometry/vmec_extender_import.py +499 -0
- drbx/geometry/vmec_jax_import.py +306 -0
- drbx/linear/__init__.py +37 -0
- drbx/linear/dispersion.py +138 -0
- drbx/linear/eigen.py +91 -0
- drbx/native/__init__.py +224 -0
- drbx/native/array_backend.py +64 -0
- drbx/native/deck_runner.py +779 -0
- drbx/native/electromagnetic.py +250 -0
- drbx/native/expression.py +173 -0
- drbx/native/fci.py +295 -0
- drbx/native/fci_2_field_rhs.py +182 -0
- drbx/native/fci_4_field_rhs.py +1267 -0
- drbx/native/fci_boundaries.py +2494 -0
- drbx/native/fci_differentiable_case.py +304 -0
- drbx/native/fci_drb_EB_rhs.py +1243 -0
- drbx/native/fci_drb_rhs.py +190 -0
- drbx/native/fci_halo.py +1575 -0
- drbx/native/fci_helpers.py +350 -0
- drbx/native/fci_model.py +294 -0
- drbx/native/fci_neutral.py +139 -0
- drbx/native/fci_operators.py +4081 -0
- drbx/native/fci_sharding.py +597 -0
- drbx/native/fci_sheath_recycling.py +206 -0
- drbx/native/fci_time_integrator.py +96 -0
- drbx/native/fci_vorticity.py +198 -0
- drbx/native/fluid_1d.py +330 -0
- drbx/native/hasegawa_wakatani.py +196 -0
- drbx/native/limiters.py +57 -0
- drbx/native/mesh.py +238 -0
- drbx/native/metrics.py +234 -0
- drbx/native/neutrals/__init__.py +58 -0
- drbx/native/neutrals/atomic_rates.py +134 -0
- drbx/native/neutrals/detachment_sol_model.py +221 -0
- drbx/native/neutrals/reactions.py +164 -0
- drbx/native/neutrals/recycling_sol_model.py +197 -0
- drbx/native/sol_flux_tube.py +133 -0
- drbx/native/stellarator_turbulence.py +343 -0
- drbx/native/transport.py +134 -0
- drbx/native/units.py +32 -0
- drbx/native/vorticity.py +252 -0
- drbx/runtime/__init__.py +53 -0
- drbx/runtime/artifacts.py +161 -0
- drbx/runtime/memory.py +144 -0
- drbx/runtime/output.py +374 -0
- drbx/runtime/paths.py +9 -0
- drbx/runtime/performance.py +161 -0
- drbx/runtime/run_config.py +184 -0
- drbx/runtime/scheduler.py +99 -0
- drbx/runtime/state.py +40 -0
- drbx/validation/__init__.py +424 -0
- drbx/validation/autodiff_diffusion.py +329 -0
- drbx/validation/autodiff_diffusion_uncertainty.py +235 -0
- drbx/validation/diverted_tokamak_movie.py +558 -0
- drbx/validation/essos_fieldline_import_campaign.py +181 -0
- drbx/validation/essos_imported_artifact_audit.py +535 -0
- drbx/validation/essos_imported_drb_movie_campaign.py +2826 -0
- drbx/validation/essos_imported_fci_campaign.py +5241 -0
- drbx/validation/essos_imported_pytree_campaign.py +406 -0
- drbx/validation/essos_vmec_closed_field_campaign.py +314 -0
- drbx/validation/essos_vmec_closed_field_transient_campaign.py +629 -0
- drbx/validation/essos_vmec_fieldline_surface_campaign.py +620 -0
- drbx/validation/fluid_1d_mms_convergence.py +250 -0
- drbx/validation/geometry_lineouts.py +136 -0
- drbx/validation/geometry_slices.py +178 -0
- drbx/validation/publication_plotting.py +91 -0
- drbx/validation/stellarator_drb_pytree_campaign.py +621 -0
- drbx/validation/stellarator_fci_geometry_campaign.py +200 -0
- drbx/validation/stellarator_fci_operator_campaign.py +304 -0
- drbx/validation/stellarator_fci_suite_campaign.py +264 -0
- drbx/validation/stellarator_metric_mms_campaign.py +289 -0
- drbx/validation/stellarator_neutral_physics_campaign.py +255 -0
- drbx/validation/stellarator_sheath_recycling_campaign.py +331 -0
- drbx/validation/stellarator_sol_showcase.py +628 -0
- drbx/validation/stellarator_vorticity_campaign.py +304 -0
- drbx/validation/vmec_extender_edge_field_campaign.py +260 -0
- drbx/validation/vmec_extender_sol_smoke_campaign.py +365 -0
- drbx-2.0.0.dev0.dist-info/METADATA +380 -0
- drbx-2.0.0.dev0.dist-info/RECORD +106 -0
- drbx-2.0.0.dev0.dist-info/WHEEL +5 -0
- drbx-2.0.0.dev0.dist-info/entry_points.txt +2 -0
- drbx-2.0.0.dev0.dist-info/licenses/LICENSE +21 -0
- drbx-2.0.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -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()
|