drbx 2.0.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. drbx/__init__.py +20 -0
  2. drbx/__main__.py +5 -0
  3. drbx/cli.py +586 -0
  4. drbx/config/__init__.py +20 -0
  5. drbx/config/boutinp.py +556 -0
  6. drbx/config/model.py +34 -0
  7. drbx/config/normalization.py +66 -0
  8. drbx/data/atomic_rates/__init__.py +1 -0
  9. drbx/data/atomic_rates/iz_AMJUEL_H.x_2.1.5.json +213 -0
  10. drbx/data/atomic_rates/iz_AMJUEL_H.x_2.3.9a.json +213 -0
  11. drbx/data/atomic_rates/rec_AMJUEL_H.x_2.1.8.json +213 -0
  12. drbx/data/atomic_rates/rec_AMJUEL_H.x_2.3.13a.json +213 -0
  13. drbx/geometry/__init__.py +207 -0
  14. drbx/geometry/embedding.py +56 -0
  15. drbx/geometry/essos_import.py +1385 -0
  16. drbx/geometry/fci_geometry.py +4622 -0
  17. drbx/geometry/fci_maps.py +85 -0
  18. drbx/geometry/island_divertor.py +291 -0
  19. drbx/geometry/metric_tensor.py +99 -0
  20. drbx/geometry/open_slab.py +150 -0
  21. drbx/geometry/rotating_ellipse.py +253 -0
  22. drbx/geometry/shifted_torus.py +225 -0
  23. drbx/geometry/stellarator.py +287 -0
  24. drbx/geometry/vmec_extender_import.py +499 -0
  25. drbx/geometry/vmec_jax_import.py +306 -0
  26. drbx/linear/__init__.py +37 -0
  27. drbx/linear/dispersion.py +138 -0
  28. drbx/linear/eigen.py +91 -0
  29. drbx/native/__init__.py +224 -0
  30. drbx/native/array_backend.py +64 -0
  31. drbx/native/deck_runner.py +779 -0
  32. drbx/native/electromagnetic.py +250 -0
  33. drbx/native/expression.py +173 -0
  34. drbx/native/fci.py +295 -0
  35. drbx/native/fci_2_field_rhs.py +182 -0
  36. drbx/native/fci_4_field_rhs.py +1267 -0
  37. drbx/native/fci_boundaries.py +2494 -0
  38. drbx/native/fci_differentiable_case.py +304 -0
  39. drbx/native/fci_drb_EB_rhs.py +1243 -0
  40. drbx/native/fci_drb_rhs.py +190 -0
  41. drbx/native/fci_halo.py +1575 -0
  42. drbx/native/fci_helpers.py +350 -0
  43. drbx/native/fci_model.py +294 -0
  44. drbx/native/fci_neutral.py +139 -0
  45. drbx/native/fci_operators.py +4081 -0
  46. drbx/native/fci_sharding.py +597 -0
  47. drbx/native/fci_sheath_recycling.py +206 -0
  48. drbx/native/fci_time_integrator.py +96 -0
  49. drbx/native/fci_vorticity.py +198 -0
  50. drbx/native/fluid_1d.py +330 -0
  51. drbx/native/hasegawa_wakatani.py +196 -0
  52. drbx/native/limiters.py +57 -0
  53. drbx/native/mesh.py +238 -0
  54. drbx/native/metrics.py +234 -0
  55. drbx/native/neutrals/__init__.py +58 -0
  56. drbx/native/neutrals/atomic_rates.py +134 -0
  57. drbx/native/neutrals/detachment_sol_model.py +221 -0
  58. drbx/native/neutrals/reactions.py +164 -0
  59. drbx/native/neutrals/recycling_sol_model.py +197 -0
  60. drbx/native/sol_flux_tube.py +133 -0
  61. drbx/native/stellarator_turbulence.py +343 -0
  62. drbx/native/transport.py +134 -0
  63. drbx/native/units.py +32 -0
  64. drbx/native/vorticity.py +252 -0
  65. drbx/runtime/__init__.py +53 -0
  66. drbx/runtime/artifacts.py +161 -0
  67. drbx/runtime/memory.py +144 -0
  68. drbx/runtime/output.py +374 -0
  69. drbx/runtime/paths.py +9 -0
  70. drbx/runtime/performance.py +161 -0
  71. drbx/runtime/run_config.py +184 -0
  72. drbx/runtime/scheduler.py +99 -0
  73. drbx/runtime/state.py +40 -0
  74. drbx/validation/__init__.py +424 -0
  75. drbx/validation/autodiff_diffusion.py +329 -0
  76. drbx/validation/autodiff_diffusion_uncertainty.py +235 -0
  77. drbx/validation/diverted_tokamak_movie.py +558 -0
  78. drbx/validation/essos_fieldline_import_campaign.py +181 -0
  79. drbx/validation/essos_imported_artifact_audit.py +535 -0
  80. drbx/validation/essos_imported_drb_movie_campaign.py +2826 -0
  81. drbx/validation/essos_imported_fci_campaign.py +5241 -0
  82. drbx/validation/essos_imported_pytree_campaign.py +406 -0
  83. drbx/validation/essos_vmec_closed_field_campaign.py +314 -0
  84. drbx/validation/essos_vmec_closed_field_transient_campaign.py +629 -0
  85. drbx/validation/essos_vmec_fieldline_surface_campaign.py +620 -0
  86. drbx/validation/fluid_1d_mms_convergence.py +250 -0
  87. drbx/validation/geometry_lineouts.py +136 -0
  88. drbx/validation/geometry_slices.py +178 -0
  89. drbx/validation/publication_plotting.py +91 -0
  90. drbx/validation/stellarator_drb_pytree_campaign.py +621 -0
  91. drbx/validation/stellarator_fci_geometry_campaign.py +200 -0
  92. drbx/validation/stellarator_fci_operator_campaign.py +304 -0
  93. drbx/validation/stellarator_fci_suite_campaign.py +264 -0
  94. drbx/validation/stellarator_metric_mms_campaign.py +289 -0
  95. drbx/validation/stellarator_neutral_physics_campaign.py +255 -0
  96. drbx/validation/stellarator_sheath_recycling_campaign.py +331 -0
  97. drbx/validation/stellarator_sol_showcase.py +628 -0
  98. drbx/validation/stellarator_vorticity_campaign.py +304 -0
  99. drbx/validation/vmec_extender_edge_field_campaign.py +260 -0
  100. drbx/validation/vmec_extender_sol_smoke_campaign.py +365 -0
  101. drbx-2.0.0.dev0.dist-info/METADATA +380 -0
  102. drbx-2.0.0.dev0.dist-info/RECORD +106 -0
  103. drbx-2.0.0.dev0.dist-info/WHEEL +5 -0
  104. drbx-2.0.0.dev0.dist-info/entry_points.txt +2 -0
  105. drbx-2.0.0.dev0.dist-info/licenses/LICENSE +21 -0
  106. drbx-2.0.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)