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,250 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from matplotlib import pyplot as plt
|
|
8
|
+
import numpy as np
|
|
9
|
+
from .publication_plotting import annotate_bars, save_publication_figure, style_axis
|
|
10
|
+
|
|
11
|
+
from ..config.boutinp import parse_bout_input
|
|
12
|
+
from ..native.fluid_1d import advance_mms_history, evaluate_field_option
|
|
13
|
+
from ..native.mesh import build_structured_mesh
|
|
14
|
+
from ..native.metrics import build_structured_metrics
|
|
15
|
+
from ..runtime.run_config import RunConfiguration
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_MMS_TEMPLATE = """
|
|
19
|
+
nout = 50
|
|
20
|
+
timestep = {timestep}
|
|
21
|
+
|
|
22
|
+
MXG = 0
|
|
23
|
+
|
|
24
|
+
[mesh]
|
|
25
|
+
nx = 1
|
|
26
|
+
ny = {ny}
|
|
27
|
+
nz = 1
|
|
28
|
+
Ly = 10
|
|
29
|
+
dy = Ly / ny
|
|
30
|
+
J = 1
|
|
31
|
+
|
|
32
|
+
[solver]
|
|
33
|
+
mxstep = 10000
|
|
34
|
+
rtol = 1e-7
|
|
35
|
+
mms = true
|
|
36
|
+
|
|
37
|
+
[model]
|
|
38
|
+
components = i
|
|
39
|
+
normalise_metric = false
|
|
40
|
+
Nnorm = 1e18
|
|
41
|
+
Bnorm = 1
|
|
42
|
+
Tnorm = 5
|
|
43
|
+
|
|
44
|
+
[i]
|
|
45
|
+
type = evolve_density, evolve_pressure, evolve_momentum
|
|
46
|
+
charge = 1.0
|
|
47
|
+
AA = 2.0
|
|
48
|
+
thermal_conduction = false
|
|
49
|
+
|
|
50
|
+
[Ni]
|
|
51
|
+
solution = 1 - 0.1*sin(t - 2.0*y)
|
|
52
|
+
source = -0.1*cos(t - 2.0*y) + 0.0628318530717959*cos(2*t + y)
|
|
53
|
+
|
|
54
|
+
[Pi]
|
|
55
|
+
solution = 0.1*cos(t + 3.0*y) + 1
|
|
56
|
+
source = (0.0628318530717959*cos(2*t + y)/(1 - 0.1*sin(t - 2.0*y)) - 0.0125663706143592*sin(2*t + y)*cos(t - 2.0*y)/(1 - 0.1*sin(t - 2.0*y))^2)*(0.0666666666666667*cos(t + 3.0*y) + 0.666666666666667) - 0.1*sin(t + 3.0*y) + 0.0628318530717959*(0.1*cos(t + 3.0*y) + 1)*cos(2*t + y)/(1 - 0.1*sin(t - 2.0*y)) - 0.0188495559215388*sin(t + 3.0*y)*sin(2*t + y)/(1 - 0.1*sin(t - 2.0*y)) - 0.0125663706143592*(0.1*cos(t + 3.0*y) + 1)*sin(2*t + y)*cos(t - 2.0*y)/(1 - 0.1*sin(t - 2.0*y))^2
|
|
57
|
+
|
|
58
|
+
[NVi]
|
|
59
|
+
solution = 0.2*sin(2*t + y)
|
|
60
|
+
source = -0.188495559215388*sin(t + 3.0*y) + 0.4*cos(2*t + y) + 0.0251327412287183*sin(2*t + y)*cos(2*t + y)/(1 - 0.1*sin(t - 2.0*y)) - 0.00251327412287184*sin(2*t + y)^2*cos(t - 2.0*y)/(1 - 0.1*sin(t - 2.0*y))^2
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Fluid1DMmsConvergenceArtifacts:
|
|
66
|
+
summary_json_path: Path
|
|
67
|
+
arrays_npz_path: Path
|
|
68
|
+
summary_plot_png_path: Path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def create_fluid_1d_mms_convergence_package(
|
|
72
|
+
*,
|
|
73
|
+
output_root: str | Path,
|
|
74
|
+
case_label: str = "fluid_1d_mms_convergence",
|
|
75
|
+
resolutions: tuple[int, ...] = (32, 64, 128),
|
|
76
|
+
timestep: float = 0.05,
|
|
77
|
+
steps: int = 2,
|
|
78
|
+
substeps: int = 20,
|
|
79
|
+
) -> Fluid1DMmsConvergenceArtifacts:
|
|
80
|
+
root = Path(output_root)
|
|
81
|
+
data_dir = root / "data"
|
|
82
|
+
images_dir = root / "images"
|
|
83
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
images_dir.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
report = build_fluid_1d_mms_convergence_report(
|
|
87
|
+
resolutions=resolutions,
|
|
88
|
+
timestep=timestep,
|
|
89
|
+
steps=steps,
|
|
90
|
+
substeps=substeps,
|
|
91
|
+
)
|
|
92
|
+
summary_json_path = data_dir / f"{case_label}.json"
|
|
93
|
+
summary_json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
|
|
94
|
+
|
|
95
|
+
arrays_npz_path = data_dir / f"{case_label}.npz"
|
|
96
|
+
np.savez_compressed(
|
|
97
|
+
arrays_npz_path,
|
|
98
|
+
resolutions=np.asarray(report["resolutions"], dtype=np.int64),
|
|
99
|
+
density_l2=np.asarray([entry["errors"]["density_l2"] for entry in report["runs"]], dtype=np.float64),
|
|
100
|
+
pressure_l2=np.asarray([entry["errors"]["pressure_l2"] for entry in report["runs"]], dtype=np.float64),
|
|
101
|
+
momentum_l2=np.asarray([entry["errors"]["momentum_l2"] for entry in report["runs"]], dtype=np.float64),
|
|
102
|
+
density_order=np.asarray([entry["density_order"] for entry in report["observed_orders"]], dtype=np.float64),
|
|
103
|
+
pressure_order=np.asarray([entry["pressure_order"] for entry in report["observed_orders"]], dtype=np.float64),
|
|
104
|
+
momentum_order=np.asarray([entry["momentum_order"] for entry in report["observed_orders"]], dtype=np.float64),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
summary_plot_png_path = save_fluid_1d_mms_convergence_plot(report, images_dir / f"{case_label}.png")
|
|
108
|
+
return Fluid1DMmsConvergenceArtifacts(
|
|
109
|
+
summary_json_path=summary_json_path,
|
|
110
|
+
arrays_npz_path=arrays_npz_path,
|
|
111
|
+
summary_plot_png_path=summary_plot_png_path,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_mms_config(*, ny: int, timestep: float) -> str:
|
|
116
|
+
return _MMS_TEMPLATE.format(ny=int(ny), timestep=float(timestep))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def l2_error(numerical: np.ndarray, exact: np.ndarray, *, start: int, end: int) -> float:
|
|
120
|
+
interior_numerical = np.asarray(numerical[:, start : end + 1, :], dtype=np.float64)
|
|
121
|
+
interior_exact = np.asarray(exact[:, start : end + 1, :], dtype=np.float64)
|
|
122
|
+
return float(np.sqrt(np.mean(np.square(interior_numerical - interior_exact))))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def observed_order(coarse_error: float, fine_error: float) -> float:
|
|
126
|
+
if coarse_error <= 0.0 or fine_error <= 0.0:
|
|
127
|
+
return float("inf")
|
|
128
|
+
return float(np.log(coarse_error / fine_error) / np.log(2.0))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def run_mms_resolution(*, ny: int, timestep: float, steps: int, substeps: int) -> dict[str, object]:
|
|
132
|
+
config = parse_bout_input(build_mms_config(ny=ny, timestep=timestep))
|
|
133
|
+
run_config = RunConfiguration.from_config(config)
|
|
134
|
+
mesh = build_structured_mesh(config, run_config)
|
|
135
|
+
metrics = build_structured_metrics(config, run_config, mesh)
|
|
136
|
+
history = advance_mms_history(
|
|
137
|
+
config,
|
|
138
|
+
section="i",
|
|
139
|
+
mesh=mesh,
|
|
140
|
+
metrics=metrics,
|
|
141
|
+
atomic_mass=2.0,
|
|
142
|
+
timestep=timestep,
|
|
143
|
+
steps=steps,
|
|
144
|
+
substeps=substeps,
|
|
145
|
+
)
|
|
146
|
+
final_time = float(steps * timestep)
|
|
147
|
+
exact_density = evaluate_field_option(config, "Ni", "solution", mesh=mesh, time=final_time)
|
|
148
|
+
exact_pressure = evaluate_field_option(config, "Pi", "solution", mesh=mesh, time=final_time)
|
|
149
|
+
exact_momentum = evaluate_field_option(config, "NVi", "solution", mesh=mesh, time=final_time)
|
|
150
|
+
|
|
151
|
+
density_error = l2_error(np.asarray(history.density_history[-1]), np.asarray(exact_density), start=mesh.ystart, end=mesh.yend)
|
|
152
|
+
pressure_error = l2_error(np.asarray(history.pressure_history[-1]), np.asarray(exact_pressure), start=mesh.ystart, end=mesh.yend)
|
|
153
|
+
momentum_error = l2_error(np.asarray(history.momentum_history[-1]), np.asarray(exact_momentum), start=mesh.ystart, end=mesh.yend)
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"ny": int(ny),
|
|
157
|
+
"timestep": float(timestep),
|
|
158
|
+
"steps": int(steps),
|
|
159
|
+
"substeps": int(substeps),
|
|
160
|
+
"final_time": final_time,
|
|
161
|
+
"errors": {
|
|
162
|
+
"density_l2": density_error,
|
|
163
|
+
"pressure_l2": pressure_error,
|
|
164
|
+
"momentum_l2": momentum_error,
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def build_fluid_1d_mms_convergence_report(
|
|
170
|
+
*,
|
|
171
|
+
resolutions: tuple[int, ...] = (32, 64, 128),
|
|
172
|
+
timestep: float = 0.05,
|
|
173
|
+
steps: int = 2,
|
|
174
|
+
substeps: int = 20,
|
|
175
|
+
) -> dict[str, object]:
|
|
176
|
+
runs = [run_mms_resolution(ny=ny, timestep=timestep, steps=steps, substeps=substeps) for ny in resolutions]
|
|
177
|
+
orders: list[dict[str, float | int]] = []
|
|
178
|
+
for coarse, fine in zip(runs[:-1], runs[1:], strict=False):
|
|
179
|
+
coarse_errors = coarse["errors"]
|
|
180
|
+
fine_errors = fine["errors"]
|
|
181
|
+
orders.append(
|
|
182
|
+
{
|
|
183
|
+
"from_ny": int(coarse["ny"]),
|
|
184
|
+
"to_ny": int(fine["ny"]),
|
|
185
|
+
"density_order": observed_order(float(coarse_errors["density_l2"]), float(fine_errors["density_l2"])),
|
|
186
|
+
"pressure_order": observed_order(float(coarse_errors["pressure_l2"]), float(fine_errors["pressure_l2"])),
|
|
187
|
+
"momentum_order": observed_order(float(coarse_errors["momentum_l2"]), float(fine_errors["momentum_l2"])),
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
return {
|
|
191
|
+
"family": "manufactured_solution_convergence",
|
|
192
|
+
"case": "fluid_1d_mms_convergence",
|
|
193
|
+
"operator_family": "fluid_1d_density_pressure_momentum",
|
|
194
|
+
"literature_anchor": "Standard code-verification literature uses refinement studies and observed-order plots to separate operator correctness from benchmark validation.",
|
|
195
|
+
"resolutions": [int(ny) for ny in resolutions],
|
|
196
|
+
"timestep": float(timestep),
|
|
197
|
+
"steps": int(steps),
|
|
198
|
+
"substeps": int(substeps),
|
|
199
|
+
"runs": runs,
|
|
200
|
+
"observed_orders": orders,
|
|
201
|
+
"min_observed_order": {
|
|
202
|
+
"density": float(min(entry["density_order"] for entry in orders)),
|
|
203
|
+
"pressure": float(min(entry["pressure_order"] for entry in orders)),
|
|
204
|
+
"momentum": float(min(entry["momentum_order"] for entry in orders)),
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def save_fluid_1d_mms_convergence_plot(report: dict[str, object], path: str | Path) -> Path:
|
|
210
|
+
target = Path(path)
|
|
211
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
runs = list(report["runs"])
|
|
213
|
+
resolutions = np.asarray([entry["ny"] for entry in runs], dtype=np.float64)
|
|
214
|
+
density_error = np.asarray([entry["errors"]["density_l2"] for entry in runs], dtype=np.float64)
|
|
215
|
+
pressure_error = np.asarray([entry["errors"]["pressure_l2"] for entry in runs], dtype=np.float64)
|
|
216
|
+
momentum_error = np.asarray([entry["errors"]["momentum_l2"] for entry in runs], dtype=np.float64)
|
|
217
|
+
orders = list(report["observed_orders"])
|
|
218
|
+
|
|
219
|
+
figure, axes = plt.subplots(1, 2, figsize=(13.5, 5.2), constrained_layout=True)
|
|
220
|
+
|
|
221
|
+
axes[0].loglog(resolutions, density_error, marker="o", linewidth=2.0, color="#005f73", label="density")
|
|
222
|
+
axes[0].loglog(resolutions, pressure_error, marker="o", linewidth=2.0, color="#ca6702", label="pressure")
|
|
223
|
+
axes[0].loglog(resolutions, momentum_error, marker="o", linewidth=2.0, color="#3a86ff", label="momentum")
|
|
224
|
+
style_axis(
|
|
225
|
+
axes[0],
|
|
226
|
+
title="Fluid 1D MMS refinement errors",
|
|
227
|
+
xlabel="interior Ny resolution",
|
|
228
|
+
ylabel="L2 error",
|
|
229
|
+
xscale="log",
|
|
230
|
+
yscale="log",
|
|
231
|
+
grid="both",
|
|
232
|
+
)
|
|
233
|
+
axes[0].legend(frameon=False)
|
|
234
|
+
|
|
235
|
+
order_labels = [f"{entry['from_ny']}→{entry['to_ny']}" for entry in orders]
|
|
236
|
+
x = np.arange(len(order_labels))
|
|
237
|
+
width = 0.25
|
|
238
|
+
axes[1].bar(x - width, [entry["density_order"] for entry in orders], width=width, color="#005f73", label="density")
|
|
239
|
+
axes[1].bar(x, [entry["pressure_order"] for entry in orders], width=width, color="#ca6702", label="pressure")
|
|
240
|
+
axes[1].bar(x + width, [entry["momentum_order"] for entry in orders], width=width, color="#3a86ff", label="momentum")
|
|
241
|
+
axes[1].axhline(2.0, color="#bb3e03", linestyle="--", linewidth=1.5, label="second order")
|
|
242
|
+
axes[1].set_xticks(x, order_labels)
|
|
243
|
+
style_axis(axes[1], title="Observed MMS refinement order", ylabel="observed order")
|
|
244
|
+
axes[1].legend(frameon=False)
|
|
245
|
+
annotate_bars(axes[1], x - width, np.asarray([entry["density_order"] for entry in orders], dtype=np.float64), fmt="{:.2f}", fontsize=8.5)
|
|
246
|
+
annotate_bars(axes[1], x, np.asarray([entry["pressure_order"] for entry in orders], dtype=np.float64), fmt="{:.2f}", fontsize=8.5)
|
|
247
|
+
annotate_bars(axes[1], x + width, np.asarray([entry["momentum_order"] for entry in orders], dtype=np.float64), fmt="{:.2f}", fontsize=8.5)
|
|
248
|
+
figure.suptitle("Fluid 1D manufactured-solution convergence audit", fontsize=14.0, fontweight="semibold")
|
|
249
|
+
save_publication_figure(figure, target)
|
|
250
|
+
return target
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from matplotlib import pyplot as plt
|
|
8
|
+
from matplotlib.ticker import ScalarFormatter
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class LineoutSpec:
|
|
14
|
+
name: str
|
|
15
|
+
axis: int
|
|
16
|
+
coordinate_name: str
|
|
17
|
+
coordinate_values: np.ndarray
|
|
18
|
+
fixed_indices: tuple[int, int]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_lineout_report(
|
|
22
|
+
*,
|
|
23
|
+
fields: dict[str, np.ndarray],
|
|
24
|
+
specs: tuple[LineoutSpec, ...],
|
|
25
|
+
) -> dict[str, object]:
|
|
26
|
+
diagnostics: dict[str, dict[str, object]] = {}
|
|
27
|
+
for spec in specs:
|
|
28
|
+
diagnostics[spec.name] = {}
|
|
29
|
+
for field_name, values in fields.items():
|
|
30
|
+
line = _extract_line(values, spec)
|
|
31
|
+
diagnostics[spec.name][field_name] = {
|
|
32
|
+
"coordinate_name": spec.coordinate_name,
|
|
33
|
+
"coordinate_values": np.asarray(spec.coordinate_values, dtype=np.float64).tolist(),
|
|
34
|
+
"mean": line.tolist(),
|
|
35
|
+
"minimum": float(np.min(line)),
|
|
36
|
+
"maximum": float(np.max(line)),
|
|
37
|
+
}
|
|
38
|
+
return {"available": True, "parse_status": "ok", "diagnostics": diagnostics}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def write_lineout_arrays_npz(report: dict[str, object], path: str | Path) -> Path:
|
|
42
|
+
target = Path(path)
|
|
43
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
payload: dict[str, np.ndarray] = {}
|
|
45
|
+
diagnostics = report.get("diagnostics", {})
|
|
46
|
+
if isinstance(diagnostics, dict):
|
|
47
|
+
for diagnostic_name, fields in diagnostics.items():
|
|
48
|
+
if not isinstance(fields, dict):
|
|
49
|
+
continue
|
|
50
|
+
for field_name, field_report in fields.items():
|
|
51
|
+
if not isinstance(field_report, dict):
|
|
52
|
+
continue
|
|
53
|
+
key_prefix = f"{diagnostic_name}:{field_name}"
|
|
54
|
+
payload[f"{key_prefix}:coords"] = np.asarray(field_report.get("coordinate_values", []), dtype=np.float64)
|
|
55
|
+
payload[f"{key_prefix}:mean"] = np.asarray(field_report.get("mean", []), dtype=np.float64)
|
|
56
|
+
payload["__metadata__"] = np.asarray(json.dumps(report, sort_keys=True), dtype=np.str_)
|
|
57
|
+
np.savez_compressed(target, **payload)
|
|
58
|
+
return target
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def save_lineout_summary_plot(
|
|
62
|
+
report: dict[str, object],
|
|
63
|
+
path: str | Path,
|
|
64
|
+
*,
|
|
65
|
+
field_names: tuple[str, ...],
|
|
66
|
+
title: str,
|
|
67
|
+
) -> Path:
|
|
68
|
+
target = Path(path)
|
|
69
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
diagnostics = report.get("diagnostics", {})
|
|
71
|
+
diagnostic_names = tuple(diagnostics.keys()) if isinstance(diagnostics, dict) else ()
|
|
72
|
+
figure, axes = plt.subplots(
|
|
73
|
+
len(field_names),
|
|
74
|
+
max(1, len(diagnostic_names)),
|
|
75
|
+
figsize=(13.5, max(6.0, 2.35 * len(field_names))),
|
|
76
|
+
constrained_layout=True,
|
|
77
|
+
squeeze=False,
|
|
78
|
+
)
|
|
79
|
+
for col, diagnostic_name in enumerate(diagnostic_names):
|
|
80
|
+
diagnostic = diagnostics.get(diagnostic_name, {})
|
|
81
|
+
for row, field_name in enumerate(field_names):
|
|
82
|
+
axis = axes[row, col]
|
|
83
|
+
field_report = diagnostic.get(field_name) if isinstance(diagnostic, dict) else None
|
|
84
|
+
if not isinstance(field_report, dict):
|
|
85
|
+
axis.set_visible(False)
|
|
86
|
+
continue
|
|
87
|
+
coords = np.asarray(field_report.get("coordinate_values", []), dtype=np.float64)
|
|
88
|
+
line = np.asarray(field_report.get("mean", []), dtype=np.float64)
|
|
89
|
+
axis.plot(coords, line, color="#005f73", linewidth=2.2)
|
|
90
|
+
axis.grid(alpha=0.25)
|
|
91
|
+
axis.set_title(f"{_display_label(diagnostic_name)} · {_display_label(field_name)}", fontsize=10)
|
|
92
|
+
axis.set_xlabel(str(field_report.get("coordinate_name", "coord")))
|
|
93
|
+
axis.set_ylabel(_display_label(field_name))
|
|
94
|
+
span = float(np.ptp(line))
|
|
95
|
+
scale = max(float(np.max(np.abs(line))), 1.0e-12)
|
|
96
|
+
formatter = ScalarFormatter(useOffset=False)
|
|
97
|
+
formatter.set_powerlimits((-2, 2))
|
|
98
|
+
if span / scale < 1.0e-3:
|
|
99
|
+
formatter.set_scientific(False)
|
|
100
|
+
axis.yaxis.set_major_formatter(formatter)
|
|
101
|
+
figure.suptitle(title, fontsize=16, fontweight="bold")
|
|
102
|
+
figure.savefig(target, dpi=180)
|
|
103
|
+
plt.close(figure)
|
|
104
|
+
return target
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _extract_line(values: np.ndarray, spec: LineoutSpec) -> np.ndarray:
|
|
108
|
+
array = np.asarray(values, dtype=np.float64)
|
|
109
|
+
if array.ndim != 3:
|
|
110
|
+
raise ValueError(f"Expected 3D array for lineout extraction, got shape {array.shape}")
|
|
111
|
+
if spec.axis == 0:
|
|
112
|
+
return array[:, spec.fixed_indices[0], spec.fixed_indices[1]]
|
|
113
|
+
if spec.axis == 1:
|
|
114
|
+
return array[spec.fixed_indices[0], :, spec.fixed_indices[1]]
|
|
115
|
+
if spec.axis == 2:
|
|
116
|
+
return array[spec.fixed_indices[0], spec.fixed_indices[1], :]
|
|
117
|
+
raise ValueError(f"Unsupported axis {spec.axis}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _display_label(name: str) -> str:
|
|
121
|
+
labels = {
|
|
122
|
+
"radial_midplane": "Radial midplane",
|
|
123
|
+
"toroidal_cut": "Toroidal cut",
|
|
124
|
+
"poloidal_cut": "Poloidal cut",
|
|
125
|
+
"Bxy": "B",
|
|
126
|
+
"Bmag": "|B|",
|
|
127
|
+
"J": "Jacobian",
|
|
128
|
+
"jacobian": "Jacobian",
|
|
129
|
+
"g11": "g11",
|
|
130
|
+
"g22": "g22",
|
|
131
|
+
"g33": "g33",
|
|
132
|
+
"g_11": "g^11",
|
|
133
|
+
"g_22": "g^22",
|
|
134
|
+
"g_33": "g^33",
|
|
135
|
+
}
|
|
136
|
+
return labels.get(name, name.replace("_", " "))
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from matplotlib import pyplot as plt
|
|
8
|
+
from matplotlib import animation
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class SliceSpec:
|
|
14
|
+
name: str
|
|
15
|
+
axis: int
|
|
16
|
+
coordinate_name: str
|
|
17
|
+
coordinate_values: np.ndarray
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_slice_report(
|
|
21
|
+
*,
|
|
22
|
+
field_name: str,
|
|
23
|
+
values: np.ndarray,
|
|
24
|
+
spec: SliceSpec,
|
|
25
|
+
) -> dict[str, object]:
|
|
26
|
+
array = np.asarray(values, dtype=np.float64)
|
|
27
|
+
if array.ndim != 3:
|
|
28
|
+
raise ValueError(f"Expected 3D array for slice diagnostics, got shape {array.shape}")
|
|
29
|
+
frames = []
|
|
30
|
+
reference_plane = _extract_plane(array, spec.axis, 0)
|
|
31
|
+
for index, coord in enumerate(np.asarray(spec.coordinate_values, dtype=np.float64)):
|
|
32
|
+
plane = _extract_plane(array, spec.axis, index)
|
|
33
|
+
delta = plane - reference_plane
|
|
34
|
+
frames.append(
|
|
35
|
+
{
|
|
36
|
+
"index": int(index),
|
|
37
|
+
"coordinate_value": float(coord),
|
|
38
|
+
"minimum": float(np.min(plane)),
|
|
39
|
+
"maximum": float(np.max(plane)),
|
|
40
|
+
"mean": float(np.mean(plane)),
|
|
41
|
+
"standard_deviation": float(np.std(plane)),
|
|
42
|
+
"rms_delta_from_first": float(np.sqrt(np.mean(delta**2))),
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
return {
|
|
46
|
+
"available": True,
|
|
47
|
+
"parse_status": "ok",
|
|
48
|
+
"field_name": field_name,
|
|
49
|
+
"slice_name": spec.name,
|
|
50
|
+
"coordinate_name": spec.coordinate_name,
|
|
51
|
+
"coordinate_values": np.asarray(spec.coordinate_values, dtype=np.float64).tolist(),
|
|
52
|
+
"frames": frames,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def write_slice_arrays_npz(
|
|
57
|
+
*,
|
|
58
|
+
field_name: str,
|
|
59
|
+
values: np.ndarray,
|
|
60
|
+
spec: SliceSpec,
|
|
61
|
+
path: str | Path,
|
|
62
|
+
) -> Path:
|
|
63
|
+
target = Path(path)
|
|
64
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
array = np.asarray(values, dtype=np.float64)
|
|
66
|
+
payload = {
|
|
67
|
+
"field_name": np.asarray(field_name),
|
|
68
|
+
"coordinate_name": np.asarray(spec.coordinate_name),
|
|
69
|
+
"coordinate_values": np.asarray(spec.coordinate_values, dtype=np.float64),
|
|
70
|
+
"values": array,
|
|
71
|
+
}
|
|
72
|
+
np.savez_compressed(target, **payload)
|
|
73
|
+
return target
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def save_slice_summary_plot(
|
|
77
|
+
report: dict[str, object],
|
|
78
|
+
path: str | Path,
|
|
79
|
+
*,
|
|
80
|
+
title: str,
|
|
81
|
+
) -> Path:
|
|
82
|
+
target = Path(path)
|
|
83
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
frames = report.get("frames", [])
|
|
85
|
+
coords = np.asarray([frame["coordinate_value"] for frame in frames], dtype=np.float64)
|
|
86
|
+
mins = np.asarray([frame["minimum"] for frame in frames], dtype=np.float64)
|
|
87
|
+
maxs = np.asarray([frame["maximum"] for frame in frames], dtype=np.float64)
|
|
88
|
+
means = np.asarray([frame["mean"] for frame in frames], dtype=np.float64)
|
|
89
|
+
stds = np.asarray([frame.get("standard_deviation", 0.0) for frame in frames], dtype=np.float64)
|
|
90
|
+
rms_deltas = np.asarray([frame.get("rms_delta_from_first", 0.0) for frame in frames], dtype=np.float64)
|
|
91
|
+
figure, axes = plt.subplots(2, 1, figsize=(10.5, 7.2), constrained_layout=True, sharex=True)
|
|
92
|
+
axes[0].plot(coords, means, color="#005f73", linewidth=2.0, label="mean")
|
|
93
|
+
axes[0].fill_between(coords, mins, maxs, color="#94d2bd", alpha=0.35, label="min/max band")
|
|
94
|
+
axes[0].set_ylabel(_display_label(str(report.get("field_name", "field"))))
|
|
95
|
+
axes[0].set_title(title)
|
|
96
|
+
axes[0].grid(alpha=0.25)
|
|
97
|
+
axes[0].legend(frameon=False)
|
|
98
|
+
axes[0].ticklabel_format(axis="y", style="sci", scilimits=(-2, 2), useOffset=False)
|
|
99
|
+
axes[1].plot(coords, stds, color="#0a9396", linewidth=1.9, label="plane std")
|
|
100
|
+
axes[1].plot(coords, rms_deltas, color="#ae2012", linewidth=1.9, label="RMS delta from first")
|
|
101
|
+
axes[1].set_xlabel(str(report.get("coordinate_name", "coord")))
|
|
102
|
+
axes[1].set_ylabel("diagnostic amplitude")
|
|
103
|
+
axes[1].grid(alpha=0.25)
|
|
104
|
+
axes[1].legend(frameon=False)
|
|
105
|
+
axes[1].ticklabel_format(axis="y", style="sci", scilimits=(-2, 2), useOffset=False)
|
|
106
|
+
figure.savefig(target, dpi=180)
|
|
107
|
+
plt.close(figure)
|
|
108
|
+
return target
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def save_slice_gif(
|
|
112
|
+
*,
|
|
113
|
+
field_name: str,
|
|
114
|
+
values: np.ndarray,
|
|
115
|
+
spec: SliceSpec,
|
|
116
|
+
path: str | Path,
|
|
117
|
+
fps: int = 8,
|
|
118
|
+
) -> Path:
|
|
119
|
+
target = Path(path)
|
|
120
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
array = np.asarray(values, dtype=np.float64)
|
|
122
|
+
frames = [_extract_plane(array, spec.axis, index) for index in range(array.shape[spec.axis])]
|
|
123
|
+
vmin = float(np.min(array))
|
|
124
|
+
vmax = float(np.max(array))
|
|
125
|
+
figure, axis = plt.subplots(figsize=(6.4, 5.0), constrained_layout=True)
|
|
126
|
+
image = axis.imshow(frames[0], origin="lower", aspect="auto", cmap="viridis", vmin=vmin, vmax=vmax)
|
|
127
|
+
title = axis.set_title("")
|
|
128
|
+
axis.set_xlabel("poloidal index")
|
|
129
|
+
axis.set_ylabel("radial index")
|
|
130
|
+
figure.colorbar(image, ax=axis, shrink=0.88, pad=0.02, label=_display_label(field_name))
|
|
131
|
+
|
|
132
|
+
def _update(frame_index: int):
|
|
133
|
+
image.set_data(frames[frame_index])
|
|
134
|
+
coord = float(np.asarray(spec.coordinate_values, dtype=np.float64)[frame_index])
|
|
135
|
+
title.set_text(f"{_display_label(field_name)} · {_display_label(spec.name)} · {spec.coordinate_name}={coord:.3f}")
|
|
136
|
+
return (image, title)
|
|
137
|
+
|
|
138
|
+
anim = animation.FuncAnimation(figure, _update, frames=len(frames), interval=max(1, int(1000 / fps)), blit=False)
|
|
139
|
+
writer = animation.PillowWriter(fps=fps)
|
|
140
|
+
anim.save(target, writer=writer)
|
|
141
|
+
plt.close(figure)
|
|
142
|
+
return target
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def write_slice_report_json(report: dict[str, object], path: str | Path) -> Path:
|
|
146
|
+
target = Path(path)
|
|
147
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
target.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
|
|
149
|
+
return target
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _extract_plane(values: np.ndarray, axis: int, index: int) -> np.ndarray:
|
|
153
|
+
if axis == 0:
|
|
154
|
+
return np.asarray(values[index, :, :], dtype=np.float64)
|
|
155
|
+
if axis == 1:
|
|
156
|
+
return np.asarray(values[:, index, :], dtype=np.float64)
|
|
157
|
+
if axis == 2:
|
|
158
|
+
return np.asarray(values[:, :, index], dtype=np.float64)
|
|
159
|
+
raise ValueError(f"Unsupported slice axis {axis}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _display_label(name: str) -> str:
|
|
163
|
+
labels = {
|
|
164
|
+
"Bxy": "B",
|
|
165
|
+
"Bmag": "|B|",
|
|
166
|
+
"J": "Jacobian",
|
|
167
|
+
"jacobian": "Jacobian",
|
|
168
|
+
"g11": "g11",
|
|
169
|
+
"g22": "g22",
|
|
170
|
+
"g33": "g33",
|
|
171
|
+
"g_11": "g^11",
|
|
172
|
+
"g_22": "g^22",
|
|
173
|
+
"g_33": "g^33",
|
|
174
|
+
"radial_index_planes": "Radial slices",
|
|
175
|
+
"toroidal_index_planes": "Toroidal slices",
|
|
176
|
+
"poloidal_index_planes": "Poloidal slices",
|
|
177
|
+
}
|
|
178
|
+
return labels.get(name, name.replace("_", " "))
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from matplotlib import pyplot as plt
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
PUBLICATION_DPI = 320
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def style_axis(
|
|
13
|
+
axis,
|
|
14
|
+
*,
|
|
15
|
+
title: str,
|
|
16
|
+
xlabel: str | None = None,
|
|
17
|
+
ylabel: str | None = None,
|
|
18
|
+
yscale: str | None = None,
|
|
19
|
+
xscale: str | None = None,
|
|
20
|
+
grid: str = "y",
|
|
21
|
+
) -> None:
|
|
22
|
+
axis.set_title(title, fontsize=13.5, fontweight="semibold", pad=8.0)
|
|
23
|
+
if xlabel is not None:
|
|
24
|
+
axis.set_xlabel(xlabel, fontsize=12.0)
|
|
25
|
+
if ylabel is not None:
|
|
26
|
+
axis.set_ylabel(ylabel, fontsize=12.0)
|
|
27
|
+
if yscale is not None:
|
|
28
|
+
axis.set_yscale(yscale)
|
|
29
|
+
if xscale is not None:
|
|
30
|
+
axis.set_xscale(xscale)
|
|
31
|
+
axis.grid(alpha=0.22, axis=grid, linewidth=0.8)
|
|
32
|
+
axis.tick_params(labelsize=11.0)
|
|
33
|
+
axis.spines["top"].set_visible(False)
|
|
34
|
+
axis.spines["right"].set_visible(False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def annotate_bars(
|
|
38
|
+
axis,
|
|
39
|
+
x: np.ndarray,
|
|
40
|
+
values: np.ndarray,
|
|
41
|
+
*,
|
|
42
|
+
fmt: str = "{:.2e}",
|
|
43
|
+
fontsize: float = 9.0,
|
|
44
|
+
rotation: float = 0.0,
|
|
45
|
+
) -> None:
|
|
46
|
+
values = np.asarray(values, dtype=np.float64)
|
|
47
|
+
if values.size == 0:
|
|
48
|
+
return
|
|
49
|
+
positive = np.abs(values[np.nonzero(values)])
|
|
50
|
+
scale = float(np.max(positive)) if positive.size else 1.0
|
|
51
|
+
offset = 0.03 * scale
|
|
52
|
+
for xi, value in zip(np.asarray(x, dtype=np.float64), values, strict=True):
|
|
53
|
+
va = "bottom" if value >= 0.0 else "top"
|
|
54
|
+
delta = offset if value >= 0.0 else -offset
|
|
55
|
+
axis.text(
|
|
56
|
+
float(xi),
|
|
57
|
+
float(value + delta),
|
|
58
|
+
fmt.format(float(value)),
|
|
59
|
+
ha="center",
|
|
60
|
+
va=va,
|
|
61
|
+
fontsize=fontsize,
|
|
62
|
+
rotation=rotation,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def support_window_slice(
|
|
67
|
+
*arrays: np.ndarray,
|
|
68
|
+
padding: int = 4,
|
|
69
|
+
threshold: float = 1.0e-12,
|
|
70
|
+
) -> slice:
|
|
71
|
+
support = np.zeros_like(np.asarray(arrays[0], dtype=np.float64), dtype=bool)
|
|
72
|
+
for array in arrays:
|
|
73
|
+
support |= np.abs(np.asarray(array, dtype=np.float64)) > threshold
|
|
74
|
+
indices = np.flatnonzero(support)
|
|
75
|
+
if indices.size == 0:
|
|
76
|
+
return slice(0, support.size)
|
|
77
|
+
start = max(0, int(indices[0]) - padding)
|
|
78
|
+
stop = min(support.size, int(indices[-1]) + padding + 1)
|
|
79
|
+
return slice(start, stop)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def save_publication_figure(figure, output_path: str | Path) -> None:
|
|
83
|
+
output = Path(output_path)
|
|
84
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
figure.savefig(
|
|
86
|
+
output,
|
|
87
|
+
dpi=PUBLICATION_DPI,
|
|
88
|
+
bbox_inches="tight",
|
|
89
|
+
facecolor="white",
|
|
90
|
+
)
|
|
91
|
+
plt.close(figure)
|