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,264 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from matplotlib import pyplot as plt
9
+ import numpy as np
10
+
11
+ from ..geometry import build_synthetic_stellarator_geometry
12
+ from .stellarator_fci_geometry_campaign import build_stellarator_fci_geometry_report
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class StellaratorFciSuiteCampaignArtifacts:
17
+ report_json_path: Path
18
+ arrays_npz_path: Path
19
+ plot_png_path: Path
20
+
21
+
22
+ def create_stellarator_fci_suite_campaign_package(
23
+ *,
24
+ output_root: str | Path,
25
+ case_label: str = "stellarator_fci_suite_campaign",
26
+ nx: int = 30,
27
+ ny: int = 28,
28
+ nz: int = 56,
29
+ ) -> StellaratorFciSuiteCampaignArtifacts:
30
+ root = Path(output_root)
31
+ data_dir = root / "data"
32
+ images_dir = root / "images"
33
+ data_dir.mkdir(parents=True, exist_ok=True)
34
+ images_dir.mkdir(parents=True, exist_ok=True)
35
+
36
+ report, arrays = build_stellarator_fci_suite_campaign(nx=nx, ny=ny, nz=nz)
37
+ report_json_path = data_dir / f"{case_label}.json"
38
+ report_json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
39
+ arrays_npz_path = data_dir / f"{case_label}.npz"
40
+ np.savez_compressed(arrays_npz_path, **arrays)
41
+ plot_png_path = images_dir / f"{case_label}.png"
42
+ save_stellarator_fci_suite_plot(report, arrays, plot_png_path)
43
+ return StellaratorFciSuiteCampaignArtifacts(
44
+ report_json_path=report_json_path,
45
+ arrays_npz_path=arrays_npz_path,
46
+ plot_png_path=plot_png_path,
47
+ )
48
+
49
+
50
+ def build_stellarator_fci_suite_campaign(
51
+ *,
52
+ nx: int = 30,
53
+ ny: int = 28,
54
+ nz: int = 56,
55
+ ) -> tuple[dict[str, Any], dict[str, np.ndarray]]:
56
+ configs = _suite_configs()
57
+ reports: list[dict[str, Any]] = []
58
+ row_labels: list[str] = []
59
+ cross_r: list[np.ndarray] = []
60
+ cross_z: list[np.ndarray] = []
61
+ bmag_slices: list[np.ndarray] = []
62
+ connection_slices: list[np.ndarray] = []
63
+ curvature_map_slices: list[np.ndarray] = []
64
+ summary_rows: list[list[float]] = []
65
+ for config in configs:
66
+ geometry = build_synthetic_stellarator_geometry(nx=nx, ny=ny, nz=nz, **config["parameters"])
67
+ report = build_stellarator_fci_geometry_report(geometry)
68
+ reports.append(report)
69
+ row_labels.append(str(config["label"]))
70
+ x = np.asarray(geometry.coordinates_x)
71
+ y = np.asarray(geometry.coordinates_y)
72
+ z = np.asarray(geometry.coordinates_z)
73
+ r_major = np.sqrt(x * x + y * y)
74
+ toroidal_index = max(1, ny // 6)
75
+ cross_r.append(r_major[:, toroidal_index, :].astype(np.float32))
76
+ cross_z.append(z[:, toroidal_index, :].astype(np.float32))
77
+ bmag_slices.append(np.asarray(geometry.metric.Bxy[:, 0, :], dtype=np.float32))
78
+ connection_slices.append(np.asarray(geometry.connection_length[:, 0, :], dtype=np.float32))
79
+ radial_shift = np.asarray(geometry.maps.forward_x) - np.arange(
80
+ geometry.maps.shape[0],
81
+ dtype=np.float64,
82
+ )[:, None, None]
83
+ curvature_map_slices.append(np.asarray(geometry.curvature[:, 0, :] * radial_shift[:, 0, :], dtype=np.float32))
84
+ map_report = report["map_diagnostics"]
85
+ summary_rows.append(
86
+ [
87
+ float(report["magnetic_field"]["mirror_ratio"]),
88
+ float(report["connection_length"]["mean"]),
89
+ float(report["connection_length"]["std"]),
90
+ float(report["curvature"]["std"]),
91
+ float(map_report["radial_shift_linf_cells"]),
92
+ float(map_report["forward_boundary_fraction"] + map_report["backward_boundary_fraction"]),
93
+ ]
94
+ )
95
+ summary = np.asarray(summary_rows, dtype=np.float64)
96
+ report = {
97
+ "case": "stellarator_fci_multi_configuration_suite",
98
+ "configuration_labels": row_labels,
99
+ "configurations": reports,
100
+ "summary_columns": [
101
+ "mirror_ratio",
102
+ "connection_length_mean",
103
+ "connection_length_std",
104
+ "curvature_std",
105
+ "radial_shift_linf_cells",
106
+ "two_sided_boundary_fraction",
107
+ ],
108
+ "summary": summary_rows,
109
+ "passed": all(bool(item["passed"]) for item in reports)
110
+ and float(np.min(summary[:, 1])) > 0.0
111
+ and float(np.max(summary[:, 4])) > 0.0
112
+ and float(np.max(summary[:, 5])) < 0.20,
113
+ }
114
+ arrays = {
115
+ "labels": np.asarray(row_labels),
116
+ "cross_r": np.asarray(cross_r, dtype=np.float32),
117
+ "cross_z": np.asarray(cross_z, dtype=np.float32),
118
+ "bmag_slices": np.asarray(bmag_slices, dtype=np.float32),
119
+ "connection_slices": np.asarray(connection_slices, dtype=np.float32),
120
+ "curvature_map_slices": np.asarray(curvature_map_slices, dtype=np.float32),
121
+ "summary": summary.astype(np.float32),
122
+ }
123
+ return report, arrays
124
+
125
+
126
+ def save_stellarator_fci_suite_plot(
127
+ report: dict[str, Any],
128
+ arrays: dict[str, np.ndarray],
129
+ path: str | Path,
130
+ ) -> Path:
131
+ resolved = Path(path)
132
+ resolved.parent.mkdir(parents=True, exist_ok=True)
133
+ labels = [str(label) for label in arrays["labels"]]
134
+ nrows = len(labels)
135
+ fig, axes = plt.subplots(
136
+ nrows + 1,
137
+ 4,
138
+ figsize=(15.5, 11.2),
139
+ constrained_layout=True,
140
+ gridspec_kw={"height_ratios": [1.0, 1.0, 1.0, 0.72]},
141
+ )
142
+ bmag_vmin = float(np.min(arrays["bmag_slices"]))
143
+ bmag_vmax = float(np.max(arrays["bmag_slices"]))
144
+ connection_vmin = float(np.min(arrays["connection_slices"]))
145
+ connection_vmax = float(np.max(arrays["connection_slices"]))
146
+ curvature_vmax = float(np.max(np.abs(arrays["curvature_map_slices"])))
147
+ for row, label in enumerate(labels):
148
+ cross_r = arrays["cross_r"][row]
149
+ cross_z = arrays["cross_z"][row]
150
+ for surface in (max(1, cross_r.shape[0] // 4), max(2, cross_r.shape[0] // 2), cross_r.shape[0] - 2):
151
+ axes[row, 0].plot(cross_r[surface], cross_z[surface], linewidth=1.2)
152
+ if row == 0:
153
+ axes[row, 0].set_title("sampled cross-sections")
154
+ axes[row, 0].set_xlabel("major radius" if row == nrows - 1 else "")
155
+ axes[row, 0].set_ylabel(f"{label}\nvertical")
156
+ axes[row, 0].set_aspect("equal", adjustable="box")
157
+
158
+ image0 = axes[row, 1].imshow(
159
+ arrays["bmag_slices"][row],
160
+ origin="lower",
161
+ aspect="auto",
162
+ cmap="viridis",
163
+ vmin=bmag_vmin,
164
+ vmax=bmag_vmax,
165
+ )
166
+ if row == 0:
167
+ axes[row, 1].set_title("magnetic-field strength")
168
+ axes[row, 1].set_xlabel("poloidal index" if row == nrows - 1 else "")
169
+ axes[row, 1].set_ylabel("radial index")
170
+
171
+ image1 = axes[row, 2].imshow(
172
+ arrays["connection_slices"][row],
173
+ origin="lower",
174
+ aspect="auto",
175
+ cmap="magma",
176
+ vmin=connection_vmin,
177
+ vmax=connection_vmax,
178
+ )
179
+ if row == 0:
180
+ axes[row, 2].set_title("connection-length proxy")
181
+ axes[row, 2].set_xlabel("poloidal index" if row == nrows - 1 else "")
182
+ axes[row, 2].set_ylabel("radial index")
183
+
184
+ image2 = axes[row, 3].imshow(
185
+ arrays["curvature_map_slices"][row],
186
+ origin="lower",
187
+ aspect="auto",
188
+ cmap="coolwarm",
189
+ vmin=-curvature_vmax,
190
+ vmax=curvature_vmax,
191
+ )
192
+ if row == 0:
193
+ axes[row, 3].set_title("curvature x radial map shift")
194
+ axes[row, 3].set_xlabel("poloidal index" if row == nrows - 1 else "")
195
+ axes[row, 3].set_ylabel("radial index")
196
+
197
+ fig.colorbar(image0, ax=axes[:nrows, 1], label="normalized |B|", shrink=0.72)
198
+ fig.colorbar(image1, ax=axes[:nrows, 2], label="length proxy", shrink=0.72)
199
+ fig.colorbar(image2, ax=axes[:nrows, 3], label="signed proxy", shrink=0.72)
200
+
201
+ summary = arrays["summary"]
202
+ x_locations = np.arange(nrows)
203
+ bar_axes = axes[nrows]
204
+ for axis in bar_axes:
205
+ axis.set_xticks(x_locations, labels, rotation=18, ha="right", fontsize=8)
206
+ bar_axes[0].bar(x_locations, summary[:, 0], color="#005f73")
207
+ bar_axes[0].set_title("mirror ratio")
208
+ bar_axes[1].bar(x_locations, summary[:, 1], color="#9b2226")
209
+ bar_axes[1].set_title("mean connection length")
210
+ bar_axes[2].bar(x_locations, summary[:, 3], color="#ca6702")
211
+ bar_axes[2].set_title("curvature RMS")
212
+ bar_axes[3].bar(x_locations, summary[:, 4], color="#0a9396")
213
+ bar_axes[3].set_title("max radial map shift")
214
+ for axis in bar_axes:
215
+ axis.grid(axis="y", alpha=0.25)
216
+ fig.suptitle(
217
+ "Multi-configuration 3D stellarator field-line-map geometry suite: "
218
+ f"{len(labels)} passing metric/map gates",
219
+ fontsize=14,
220
+ )
221
+ fig.savefig(resolved, dpi=180)
222
+ plt.close(fig)
223
+ return resolved
224
+
225
+
226
+ def _suite_configs() -> list[dict[str, Any]]:
227
+ return [
228
+ {
229
+ "label": "baseline island SOL",
230
+ "parameters": {
231
+ "elongation": 1.45,
232
+ "field_periods": 5,
233
+ "island_mode": 2,
234
+ "island_amplitude": 0.030,
235
+ "mirror_amplitude": 0.16,
236
+ "iota_axis": 0.38,
237
+ "iota_edge": 0.58,
238
+ },
239
+ },
240
+ {
241
+ "label": "strong island map",
242
+ "parameters": {
243
+ "elongation": 1.35,
244
+ "field_periods": 5,
245
+ "island_mode": 2,
246
+ "island_amplitude": 0.055,
247
+ "mirror_amplitude": 0.18,
248
+ "iota_axis": 0.34,
249
+ "iota_edge": 0.64,
250
+ },
251
+ },
252
+ {
253
+ "label": "high mirror shear",
254
+ "parameters": {
255
+ "elongation": 1.65,
256
+ "field_periods": 4,
257
+ "island_mode": 3,
258
+ "island_amplitude": 0.026,
259
+ "mirror_amplitude": 0.24,
260
+ "iota_axis": 0.30,
261
+ "iota_edge": 0.70,
262
+ },
263
+ },
264
+ ]
@@ -0,0 +1,289 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import jax.numpy as jnp
8
+ from matplotlib import pyplot as plt
9
+ import numpy as np
10
+
11
+ from ..geometry import MetricTensor3D, build_synthetic_stellarator_geometry
12
+ from ..native.fci import metric_weighted_scalar_laplacian_3d
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class StellaratorMetricMmsCampaignArtifacts:
17
+ report_json_path: Path
18
+ arrays_npz_path: Path
19
+ plot_png_path: Path
20
+
21
+
22
+ def create_stellarator_metric_mms_campaign_package(
23
+ *,
24
+ output_root: str | Path,
25
+ case_label: str = "stellarator_metric_mms_campaign",
26
+ resolutions: tuple[int, ...] = (16, 24, 32, 48),
27
+ ) -> StellaratorMetricMmsCampaignArtifacts:
28
+ root = Path(output_root)
29
+ data_dir = root / "data"
30
+ images_dir = root / "images"
31
+ data_dir.mkdir(parents=True, exist_ok=True)
32
+ images_dir.mkdir(parents=True, exist_ok=True)
33
+
34
+ report, arrays = build_stellarator_metric_mms_campaign(resolutions=resolutions)
35
+ report_json_path = data_dir / f"{case_label}.json"
36
+ report_json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
37
+ arrays_npz_path = data_dir / f"{case_label}.npz"
38
+ np.savez_compressed(arrays_npz_path, **arrays)
39
+ plot_png_path = images_dir / f"{case_label}.png"
40
+ save_stellarator_metric_mms_plot(report, arrays, plot_png_path)
41
+ return StellaratorMetricMmsCampaignArtifacts(
42
+ report_json_path=report_json_path,
43
+ arrays_npz_path=arrays_npz_path,
44
+ plot_png_path=plot_png_path,
45
+ )
46
+
47
+
48
+ def build_stellarator_metric_mms_campaign(
49
+ *,
50
+ resolutions: tuple[int, ...] = (16, 24, 32, 48),
51
+ ) -> tuple[dict[str, object], dict[str, np.ndarray]]:
52
+ rms_errors = []
53
+ max_errors = []
54
+ final_numeric = np.empty((1, 1), dtype=np.float64)
55
+ final_exact = np.empty((1, 1), dtype=np.float64)
56
+ final_error = np.empty((1, 1), dtype=np.float64)
57
+ for resolution in resolutions:
58
+ result = _identity_metric_mms_result(resolution)
59
+ rms_errors.append(result["rms_error"])
60
+ max_errors.append(result["max_error"])
61
+ final_numeric = result["numeric_slice"]
62
+ final_exact = result["exact_slice"]
63
+ final_error = result["error_slice"]
64
+
65
+ resolution_array = np.asarray(resolutions, dtype=np.int64)
66
+ mesh_spacing = 1.0 / resolution_array.astype(np.float64)
67
+ observed_order = _fit_convergence_slope(mesh_spacing, np.asarray(rms_errors, dtype=np.float64))
68
+
69
+ synthetic_report, synthetic_arrays = _run_synthetic_metric_probe()
70
+ report: dict[str, object] = {
71
+ "case": "stellarator_metric_weighted_scalar_laplacian_mms",
72
+ "operator": "J^-1 partial_i(J K g^ij partial_j f)",
73
+ "resolutions": resolution_array.tolist(),
74
+ "identity_mms_rms_error": rms_errors,
75
+ "identity_mms_max_error": max_errors,
76
+ "identity_mms_observed_order": float(observed_order),
77
+ **synthetic_report,
78
+ }
79
+ report["passed"] = (
80
+ float(report["identity_mms_observed_order"]) > 1.75
81
+ and float(report["synthetic_constant_residual_linf"]) < 1.0e-12
82
+ and float(report["synthetic_energy_rate"]) < 0.0
83
+ and float(report["synthetic_energy_monotone_fraction"]) > 0.95
84
+ and float(report["synthetic_cross_term_fraction"]) > 1.0e-3
85
+ )
86
+ arrays: dict[str, np.ndarray] = {
87
+ "resolution": resolution_array,
88
+ "mesh_spacing": mesh_spacing,
89
+ "identity_mms_rms_error": np.asarray(rms_errors, dtype=np.float64),
90
+ "identity_mms_max_error": np.asarray(max_errors, dtype=np.float64),
91
+ "identity_numeric_slice": final_numeric.astype(np.float32),
92
+ "identity_exact_slice": final_exact.astype(np.float32),
93
+ "identity_error_slice": final_error.astype(np.float32),
94
+ **synthetic_arrays,
95
+ }
96
+ return report, arrays
97
+
98
+
99
+ def save_stellarator_metric_mms_plot(
100
+ report: dict[str, object],
101
+ arrays: dict[str, np.ndarray],
102
+ path: str | Path,
103
+ ) -> Path:
104
+ resolved = Path(path)
105
+ resolved.parent.mkdir(parents=True, exist_ok=True)
106
+ fig, axes = plt.subplots(2, 3, figsize=(15.8, 8.6), constrained_layout=True)
107
+
108
+ axes[0, 0].loglog(
109
+ arrays["mesh_spacing"],
110
+ arrays["identity_mms_rms_error"],
111
+ "o-",
112
+ color="#005f73",
113
+ linewidth=2.0,
114
+ label="RMS error",
115
+ )
116
+ reference = arrays["identity_mms_rms_error"][-1] * (arrays["mesh_spacing"] / arrays["mesh_spacing"][-1]) ** 2
117
+ axes[0, 0].loglog(arrays["mesh_spacing"], reference, "--", color="#6c757d", linewidth=1.6, label="$h^2$")
118
+ axes[0, 0].invert_xaxis()
119
+ axes[0, 0].set_xlabel("logical mesh spacing")
120
+ axes[0, 0].set_ylabel("error")
121
+ axes[0, 0].set_title("Manufactured solution: full metric scalar operator")
122
+ axes[0, 0].legend(frameon=False)
123
+
124
+ image0 = axes[0, 1].imshow(arrays["identity_exact_slice"], origin="lower", aspect="auto", cmap="viridis")
125
+ axes[0, 1].set_title("Exact $\\nabla^2 f$ slice")
126
+ axes[0, 1].set_xlabel("toroidal index")
127
+ axes[0, 1].set_ylabel("radial index")
128
+ fig.colorbar(image0, ax=axes[0, 1])
129
+
130
+ image1 = axes[0, 2].imshow(arrays["identity_error_slice"], origin="lower", aspect="auto", cmap="coolwarm")
131
+ axes[0, 2].set_title("MMS error slice")
132
+ axes[0, 2].set_xlabel("toroidal index")
133
+ axes[0, 2].set_ylabel("radial index")
134
+ fig.colorbar(image1, ax=axes[0, 2])
135
+
136
+ image2 = axes[1, 0].imshow(arrays["synthetic_jacobian_slice"], origin="lower", aspect="auto", cmap="magma")
137
+ axes[1, 0].set_title("Synthetic non-axisymmetric Jacobian")
138
+ axes[1, 0].set_xlabel("poloidal index")
139
+ axes[1, 0].set_ylabel("radial index")
140
+ fig.colorbar(image2, ax=axes[1, 0])
141
+
142
+ image3 = axes[1, 1].imshow(arrays["synthetic_cross_term_slice"], origin="lower", aspect="auto", cmap="coolwarm")
143
+ axes[1, 1].set_title("Full minus diagonal metric operator")
144
+ axes[1, 1].set_xlabel("poloidal index")
145
+ axes[1, 1].set_ylabel("radial index")
146
+ fig.colorbar(image3, ax=axes[1, 1])
147
+
148
+ normalized_energy = arrays["synthetic_energy_history"] / arrays["synthetic_energy_history"][0]
149
+ axes[1, 2].plot(1.0 - normalized_energy, color="#0a9396", linewidth=2.0)
150
+ axes[1, 2].set_xlabel("explicit metric-diffusion step")
151
+ axes[1, 2].set_ylabel("fractional energy dissipated")
152
+ axes[1, 2].set_title("Dissipation on synthetic stellarator metric")
153
+
154
+ fig.suptitle(
155
+ "Full 3D metric operator gate: "
156
+ f"MMS order {float(report['identity_mms_observed_order']):.2f}, "
157
+ f"cross-term fraction {100.0 * float(report['synthetic_cross_term_fraction']):.1f}%",
158
+ fontsize=12,
159
+ )
160
+ fig.savefig(resolved, dpi=180)
161
+ plt.close(fig)
162
+ return resolved
163
+
164
+
165
+ def _identity_metric_mms_result(resolution: int) -> dict[str, object]:
166
+ nx = int(resolution)
167
+ ny = int(resolution)
168
+ nz = 2 * int(resolution)
169
+ metric = _identity_metric(nx=nx, ny=ny, nz=nz)
170
+ x = jnp.arange(nx, dtype=jnp.float64) / float(nx)
171
+ y = 2.0 * jnp.pi * jnp.arange(ny, dtype=jnp.float64) / float(ny)
172
+ z = 2.0 * jnp.pi * jnp.arange(nz, dtype=jnp.float64) / float(nz)
173
+ X, Y, Z = jnp.meshgrid(x, y, z, indexing="ij")
174
+ field = jnp.sin(2.0 * jnp.pi * X) * jnp.cos(3.0 * Y) * jnp.sin(2.0 * Z)
175
+ exact = -((2.0 * jnp.pi) ** 2 + 3.0**2 + 2.0**2) * field
176
+ numeric = metric_weighted_scalar_laplacian_3d(
177
+ field,
178
+ metric,
179
+ coefficient=1.0,
180
+ periodic_axes=(True, True, True),
181
+ )
182
+ error = numeric - exact
183
+ slice_index = max(1, ny // 6)
184
+ return {
185
+ "rms_error": float(jnp.sqrt(jnp.mean(jnp.square(error)))),
186
+ "max_error": float(jnp.max(jnp.abs(error))),
187
+ "numeric_slice": np.asarray(numeric[:, slice_index, :], dtype=np.float64),
188
+ "exact_slice": np.asarray(exact[:, slice_index, :], dtype=np.float64),
189
+ "error_slice": np.asarray(error[:, slice_index, :], dtype=np.float64),
190
+ }
191
+
192
+
193
+ def _run_synthetic_metric_probe() -> tuple[dict[str, object], dict[str, np.ndarray]]:
194
+ geometry = build_synthetic_stellarator_geometry(nx=22, ny=18, nz=36)
195
+ field = (
196
+ jnp.sin(jnp.pi * geometry.radial) * jnp.cos(2.0 * geometry.poloidal_angle - 5.0 * geometry.toroidal_angle)
197
+ + 0.18 * jnp.sin(3.0 * geometry.poloidal_angle + 2.0 * geometry.toroidal_angle)
198
+ )
199
+ coefficient = 8.0e-4 * (1.0 + 0.25 * geometry.radial)
200
+ constant = jnp.ones_like(field, dtype=jnp.float64)
201
+ rhs = metric_weighted_scalar_laplacian_3d(field, geometry.metric, coefficient)
202
+ diagonal_rhs = metric_weighted_scalar_laplacian_3d(field, _diagonal_metric(geometry.metric), coefficient)
203
+ cross_term = rhs - diagonal_rhs
204
+ constant_residual = metric_weighted_scalar_laplacian_3d(constant, geometry.metric, coefficient)
205
+ energy_rate = jnp.mean(geometry.metric.J * field * rhs)
206
+
207
+ dt = 1.0e-3
208
+ energy_history = []
209
+ evolved = field
210
+ for _ in range(60):
211
+ energy_history.append(float(jnp.mean(geometry.metric.J * jnp.square(evolved))))
212
+ evolved = evolved + dt * metric_weighted_scalar_laplacian_3d(evolved, geometry.metric, coefficient)
213
+ energy_history.append(float(jnp.mean(geometry.metric.J * jnp.square(evolved))))
214
+ energy_history_array = np.asarray(energy_history, dtype=np.float64)
215
+ cross_fraction = float(
216
+ jnp.sqrt(jnp.mean(jnp.square(cross_term))) / jnp.maximum(jnp.sqrt(jnp.mean(jnp.square(rhs))), 1.0e-30)
217
+ )
218
+ report = {
219
+ "synthetic_shape": list(geometry.shape),
220
+ "synthetic_constant_residual_linf": float(jnp.max(jnp.abs(constant_residual))),
221
+ "synthetic_energy_rate": float(energy_rate),
222
+ "synthetic_energy_initial": float(energy_history_array[0]),
223
+ "synthetic_energy_final": float(energy_history_array[-1]),
224
+ "synthetic_energy_monotone_fraction": float(np.mean(np.diff(energy_history_array) <= 1.0e-14)),
225
+ "synthetic_energy_drop_fraction": float(1.0 - energy_history_array[-1] / energy_history_array[0]),
226
+ "synthetic_cross_term_fraction": cross_fraction,
227
+ }
228
+ arrays = {
229
+ "synthetic_energy_history": energy_history_array,
230
+ "synthetic_jacobian_slice": np.asarray(geometry.metric.J[:, 0, :], dtype=np.float32),
231
+ "synthetic_rhs_slice": np.asarray(rhs[:, 0, :], dtype=np.float32),
232
+ "synthetic_cross_term_slice": np.asarray(cross_term[:, 0, :], dtype=np.float32),
233
+ "synthetic_constant_residual_slice": np.asarray(constant_residual[:, 0, :], dtype=np.float32),
234
+ }
235
+ return report, arrays
236
+
237
+
238
+ def _identity_metric(*, nx: int, ny: int, nz: int) -> MetricTensor3D:
239
+ shape = (nx, ny, nz)
240
+ ones = jnp.ones(shape, dtype=jnp.float64)
241
+ zeros = jnp.zeros(shape, dtype=jnp.float64)
242
+ return MetricTensor3D(
243
+ dx=ones * (1.0 / float(nx)),
244
+ dy=ones * (2.0 * np.pi / float(ny)),
245
+ dz=ones * (2.0 * np.pi / float(nz)),
246
+ J=ones,
247
+ Bxy=ones,
248
+ g11=ones,
249
+ g22=ones,
250
+ g33=ones,
251
+ g12=zeros,
252
+ g13=zeros,
253
+ g23=zeros,
254
+ g_11=ones,
255
+ g_22=ones,
256
+ g_33=ones,
257
+ g_12=zeros,
258
+ g_13=zeros,
259
+ g_23=zeros,
260
+ )
261
+
262
+
263
+ def _diagonal_metric(metric: MetricTensor3D) -> MetricTensor3D:
264
+ zeros = jnp.zeros_like(metric.J, dtype=jnp.float64)
265
+ return MetricTensor3D(
266
+ dx=metric.dx,
267
+ dy=metric.dy,
268
+ dz=metric.dz,
269
+ J=metric.J,
270
+ Bxy=metric.Bxy,
271
+ g11=metric.g11,
272
+ g22=metric.g22,
273
+ g33=metric.g33,
274
+ g12=zeros,
275
+ g13=zeros,
276
+ g23=zeros,
277
+ g_11=metric.g_11,
278
+ g_22=metric.g_22,
279
+ g_33=metric.g_33,
280
+ g_12=zeros,
281
+ g_13=zeros,
282
+ g_23=zeros,
283
+ )
284
+
285
+
286
+ def _fit_convergence_slope(h: np.ndarray, error: np.ndarray) -> float:
287
+ safe = np.maximum(error, 1.0e-16)
288
+ slope, _ = np.polyfit(np.log(h), np.log(safe), 1)
289
+ return float(slope)