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,200 @@
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
+
10
+ from ..geometry import SyntheticStellaratorGeometry, build_metric_report, build_synthetic_stellarator_geometry
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class StellaratorFciGeometryCampaignArtifacts:
15
+ report_json_path: Path
16
+ arrays_npz_path: Path
17
+ plot_png_path: Path
18
+
19
+
20
+ def create_stellarator_fci_geometry_campaign_package(
21
+ *,
22
+ output_root: str | Path,
23
+ case_label: str = "stellarator_fci_geometry_campaign",
24
+ nx: int = 36,
25
+ ny: int = 32,
26
+ nz: int = 64,
27
+ ) -> StellaratorFciGeometryCampaignArtifacts:
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
+ geometry = build_synthetic_stellarator_geometry(nx=nx, ny=ny, nz=nz)
35
+ report = build_stellarator_fci_geometry_report(geometry)
36
+ report_json_path = data_dir / f"{case_label}.json"
37
+ report_json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
38
+ arrays_npz_path = data_dir / f"{case_label}.npz"
39
+ _write_geometry_arrays(geometry, arrays_npz_path)
40
+ plot_png_path = images_dir / f"{case_label}.png"
41
+ save_stellarator_fci_geometry_plot(geometry, report, plot_png_path)
42
+ return StellaratorFciGeometryCampaignArtifacts(
43
+ report_json_path=report_json_path,
44
+ arrays_npz_path=arrays_npz_path,
45
+ plot_png_path=plot_png_path,
46
+ )
47
+
48
+
49
+ def build_stellarator_fci_geometry_report(geometry: SyntheticStellaratorGeometry) -> dict[str, object]:
50
+ metric_report = build_metric_report(geometry.metric)
51
+ maps = geometry.maps
52
+ forward_dx = np.asarray(maps.forward_x) - np.arange(maps.shape[0], dtype=np.float64)[:, None, None]
53
+ forward_z0 = np.arange(maps.shape[2], dtype=np.float64)[None, None, :]
54
+ forward_dz = np.mod(
55
+ np.asarray(maps.forward_z) - forward_z0 + maps.shape[2] / 2.0,
56
+ maps.shape[2],
57
+ ) - maps.shape[2] / 2.0
58
+ connection = np.asarray(geometry.connection_length, dtype=np.float64)
59
+ curvature = np.asarray(geometry.curvature, dtype=np.float64)
60
+ bmag = np.asarray(geometry.metric.Bxy, dtype=np.float64)
61
+ iota = np.asarray(geometry.iota, dtype=np.float64)
62
+ report = {
63
+ "case": "non_axisymmetric_fci_geometry",
64
+ "geometry": geometry.metadata,
65
+ "metric": metric_report,
66
+ "map_diagnostics": {
67
+ "forward_boundary_fraction": float(np.mean(np.asarray(maps.forward_boundary))),
68
+ "backward_boundary_fraction": float(np.mean(np.asarray(maps.backward_boundary))),
69
+ "radial_shift_linf_cells": float(np.max(np.abs(forward_dx))),
70
+ "poloidal_shift_mean_cells": float(np.mean(forward_dz)),
71
+ "poloidal_shift_std_cells": float(np.std(forward_dz)),
72
+ },
73
+ "connection_length": {
74
+ "minimum": float(np.min(connection)),
75
+ "maximum": float(np.max(connection)),
76
+ "mean": float(np.mean(connection)),
77
+ "std": float(np.std(connection)),
78
+ "outer_midplane_mean": float(np.mean(connection[-4:, :, :])),
79
+ },
80
+ "curvature": {
81
+ "minimum": float(np.min(curvature)),
82
+ "maximum": float(np.max(curvature)),
83
+ "mean": float(np.mean(curvature)),
84
+ "std": float(np.std(curvature)),
85
+ },
86
+ "magnetic_field": {
87
+ "minimum": float(np.min(bmag)),
88
+ "maximum": float(np.max(bmag)),
89
+ "mirror_ratio": float((np.max(bmag) - np.min(bmag)) / np.mean(bmag)),
90
+ },
91
+ "rotational_transform": {
92
+ "axis_proxy": float(np.mean(iota[0, :, :])),
93
+ "edge_proxy": float(np.mean(iota[-1, :, :])),
94
+ },
95
+ }
96
+ report["passed"] = (
97
+ bool(metric_report["passed"])
98
+ and report["magnetic_field"]["minimum"] > 0.0
99
+ and report["connection_length"]["minimum"] > 0.0
100
+ and report["map_diagnostics"]["radial_shift_linf_cells"] > 0.0
101
+ )
102
+ return report
103
+
104
+
105
+ def save_stellarator_fci_geometry_plot(
106
+ geometry: SyntheticStellaratorGeometry,
107
+ report: dict[str, object],
108
+ path: str | Path,
109
+ ) -> Path:
110
+ resolved = Path(path)
111
+ resolved.parent.mkdir(parents=True, exist_ok=True)
112
+ x = np.asarray(geometry.coordinates_x)
113
+ y = np.asarray(geometry.coordinates_y)
114
+ z = np.asarray(geometry.coordinates_z)
115
+ r_major = np.sqrt(x * x + y * y)
116
+ bmag = np.asarray(geometry.metric.Bxy)
117
+ connection = np.asarray(geometry.connection_length)
118
+ curvature = np.asarray(geometry.curvature)
119
+ maps = geometry.maps
120
+ radial_shift = np.asarray(maps.forward_x) - np.arange(maps.shape[0], dtype=np.float64)[:, None, None]
121
+ theta = np.asarray(geometry.poloidal_angle[:, 0, :])
122
+ radial = np.asarray(geometry.radial[:, 0, :])
123
+
124
+ fig, axes = plt.subplots(2, 2, figsize=(11.0, 8.2), constrained_layout=True)
125
+ toroidal_indices = [0, max(1, maps.shape[1] // 8), max(2, maps.shape[1] // 4), max(3, 3 * maps.shape[1] // 8)]
126
+ colors = ["#005f73", "#0a9396", "#ee9b00", "#9b2226"]
127
+ for index, color in zip(toroidal_indices, colors, strict=False):
128
+ for surface in (maps.shape[0] // 4, maps.shape[0] // 2, maps.shape[0] - 2):
129
+ axes[0, 0].plot(
130
+ r_major[surface, index, :],
131
+ z[surface, index, :],
132
+ color=color,
133
+ linewidth=1.3,
134
+ alpha=0.75,
135
+ )
136
+ axes[0, 0].set_title("Rotating cross-sections and sampled flux surfaces")
137
+ axes[0, 0].set_xlabel("major radius")
138
+ axes[0, 0].set_ylabel("vertical coordinate")
139
+ axes[0, 0].set_aspect("equal", adjustable="box")
140
+
141
+ image0 = axes[0, 1].pcolormesh(
142
+ theta,
143
+ radial,
144
+ bmag[:, 0, :],
145
+ shading="auto",
146
+ cmap="viridis",
147
+ )
148
+ axes[0, 1].set_title("Magnetic-field strength at one toroidal plane")
149
+ axes[0, 1].set_xlabel("poloidal angle")
150
+ axes[0, 1].set_ylabel("normalized radius")
151
+ fig.colorbar(image0, ax=axes[0, 1], label="normalized |B|")
152
+
153
+ image1 = axes[1, 0].pcolormesh(theta, radial, connection[:, 0, :], shading="auto", cmap="magma")
154
+ axes[1, 0].set_title("Field-line connection-length proxy")
155
+ axes[1, 0].set_xlabel("poloidal angle")
156
+ axes[1, 0].set_ylabel("normalized radius")
157
+ fig.colorbar(image1, ax=axes[1, 0], label="length proxy")
158
+
159
+ image2 = axes[1, 1].pcolormesh(
160
+ theta,
161
+ radial,
162
+ curvature[:, 0, :] * radial_shift[:, 0, :],
163
+ shading="auto",
164
+ cmap="coolwarm",
165
+ )
166
+ axes[1, 1].set_title("Curvature-weighted radial map displacement")
167
+ axes[1, 1].set_xlabel("poloidal angle")
168
+ axes[1, 1].set_ylabel("normalized radius")
169
+ fig.colorbar(image2, ax=axes[1, 1], label="signed proxy")
170
+ fig.suptitle(
171
+ "3D non-axisymmetric geometry gate: "
172
+ f"metric residual {float(report['metric']['inverse_residual_linf']):.1e}, "
173
+ f"mirror ratio {float(report['magnetic_field']['mirror_ratio']):.2f}",
174
+ fontsize=12,
175
+ )
176
+ fig.savefig(resolved, dpi=180)
177
+ plt.close(fig)
178
+ return resolved
179
+
180
+
181
+ def _write_geometry_arrays(geometry: SyntheticStellaratorGeometry, path: Path) -> Path:
182
+ np.savez_compressed(
183
+ path,
184
+ x=np.asarray(geometry.coordinates_x, dtype=np.float32),
185
+ y=np.asarray(geometry.coordinates_y, dtype=np.float32),
186
+ z=np.asarray(geometry.coordinates_z, dtype=np.float32),
187
+ radial=np.asarray(geometry.radial, dtype=np.float32),
188
+ toroidal_angle=np.asarray(geometry.toroidal_angle, dtype=np.float32),
189
+ poloidal_angle=np.asarray(geometry.poloidal_angle, dtype=np.float32),
190
+ Bxy=np.asarray(geometry.metric.Bxy, dtype=np.float32),
191
+ J=np.asarray(geometry.metric.J, dtype=np.float32),
192
+ g_22=np.asarray(geometry.metric.g_22, dtype=np.float32),
193
+ curvature=np.asarray(geometry.curvature, dtype=np.float32),
194
+ connection_length=np.asarray(geometry.connection_length, dtype=np.float32),
195
+ forward_x=np.asarray(geometry.maps.forward_x, dtype=np.float32),
196
+ forward_z=np.asarray(geometry.maps.forward_z, dtype=np.float32),
197
+ backward_x=np.asarray(geometry.maps.backward_x, dtype=np.float32),
198
+ backward_z=np.asarray(geometry.maps.backward_z, dtype=np.float32),
199
+ )
200
+ return path
@@ -0,0 +1,304 @@
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 build_synthetic_stellarator_geometry
12
+ from ..native.fci import (
13
+ conservative_parallel_diffusion_fci,
14
+ conservative_perp_diffusion_xz,
15
+ fci_ydown,
16
+ fci_yup,
17
+ grad_parallel_fci,
18
+ laplace_parallel_fci,
19
+ laplace_perp_xz,
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class StellaratorFciOperatorCampaignArtifacts:
25
+ report_json_path: Path
26
+ arrays_npz_path: Path
27
+ plot_png_path: Path
28
+
29
+
30
+ def create_stellarator_fci_operator_campaign_package(
31
+ *,
32
+ output_root: str | Path,
33
+ case_label: str = "stellarator_fci_operator_campaign",
34
+ ) -> StellaratorFciOperatorCampaignArtifacts:
35
+ root = Path(output_root)
36
+ data_dir = root / "data"
37
+ images_dir = root / "images"
38
+ data_dir.mkdir(parents=True, exist_ok=True)
39
+ images_dir.mkdir(parents=True, exist_ok=True)
40
+
41
+ report, arrays = build_stellarator_fci_operator_campaign()
42
+ report_json_path = data_dir / f"{case_label}.json"
43
+ report_json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
44
+ arrays_npz_path = data_dir / f"{case_label}.npz"
45
+ np.savez_compressed(arrays_npz_path, **arrays)
46
+ plot_png_path = images_dir / f"{case_label}.png"
47
+ save_stellarator_fci_operator_plot(report, arrays, plot_png_path)
48
+ return StellaratorFciOperatorCampaignArtifacts(
49
+ report_json_path=report_json_path,
50
+ arrays_npz_path=arrays_npz_path,
51
+ plot_png_path=plot_png_path,
52
+ )
53
+
54
+
55
+ def build_stellarator_fci_operator_campaign() -> tuple[dict[str, object], dict[str, np.ndarray]]:
56
+ resolutions = np.asarray([16, 24, 32, 48], dtype=np.int64)
57
+ rms_errors = []
58
+ max_errors = []
59
+ grad_rms_errors = []
60
+ for resolution in resolutions:
61
+ geometry = build_synthetic_stellarator_geometry(nx=resolution, ny=resolution, nz=2 * resolution)
62
+ field = _analytic_field_on_grid(geometry)
63
+ expected_up = _analytic_field_at_maps(geometry, direction=1)
64
+ expected_down = _analytic_field_at_maps(geometry, direction=-1)
65
+ actual_up = np.asarray(fci_yup(jnp.asarray(field), geometry.maps))
66
+ actual_down = np.asarray(fci_ydown(jnp.asarray(field), geometry.maps))
67
+ expected_grad = (expected_up - expected_down) / (2.0 * geometry.maps.dphi)
68
+ actual_grad = np.asarray(grad_parallel_fci(jnp.asarray(field), geometry.maps))
69
+ valid = np.isfinite(expected_up) & np.isfinite(expected_down)
70
+ interp_error = np.concatenate([(actual_up - expected_up)[valid], (actual_down - expected_down)[valid]])
71
+ grad_error = (actual_grad - expected_grad)[valid]
72
+ rms_errors.append(float(np.sqrt(np.mean(interp_error * interp_error))))
73
+ max_errors.append(float(np.max(np.abs(interp_error))))
74
+ grad_rms_errors.append(float(np.sqrt(np.mean(grad_error * grad_error))))
75
+
76
+ h = 1.0 / resolutions.astype(np.float64)
77
+ interp_slope = _fit_convergence_slope(h, np.asarray(rms_errors, dtype=np.float64))
78
+ grad_slope = _fit_convergence_slope(h, np.asarray(grad_rms_errors, dtype=np.float64))
79
+
80
+ diffusion_geometry = build_synthetic_stellarator_geometry(nx=30, ny=28, nz=56)
81
+ diffusion_history, energy_history = _run_parallel_diffusion_probe(diffusion_geometry)
82
+ conservative_history, conservative_energy_history, constant_residual = _run_conservative_diffusion_probe(diffusion_geometry)
83
+ final_geometry = build_synthetic_stellarator_geometry(nx=32, ny=32, nz=64)
84
+ final_field = _analytic_field_on_grid(final_geometry)
85
+ final_up_error = np.asarray(
86
+ fci_yup(jnp.asarray(final_field), final_geometry.maps)
87
+ ) - _analytic_field_at_maps(final_geometry, direction=1)
88
+ report = {
89
+ "case": "non_axisymmetric_fci_operator_validation",
90
+ "resolution": resolutions.tolist(),
91
+ "interpolation_rms_error": rms_errors,
92
+ "interpolation_max_error": max_errors,
93
+ "gradient_rms_error": grad_rms_errors,
94
+ "interpolation_convergence_slope": float(interp_slope),
95
+ "gradient_convergence_slope": float(grad_slope),
96
+ "diffusion_energy_initial": float(energy_history[0]),
97
+ "diffusion_energy_final": float(energy_history[-1]),
98
+ "diffusion_energy_monotone_fraction": float(np.mean(np.diff(energy_history) <= 1.0e-12)),
99
+ "diffusion_energy_drop_fraction": float(1.0 - energy_history[-1] / energy_history[0]),
100
+ "conservative_diffusion_energy_initial": float(conservative_energy_history[0]),
101
+ "conservative_diffusion_energy_final": float(conservative_energy_history[-1]),
102
+ "conservative_diffusion_energy_monotone_fraction": float(np.mean(np.diff(conservative_energy_history) <= 1.0e-12)),
103
+ "conservative_diffusion_energy_drop_fraction": float(
104
+ 1.0 - conservative_energy_history[-1] / conservative_energy_history[0]
105
+ ),
106
+ "conservative_constant_residual_linf": float(constant_residual),
107
+ }
108
+ report["passed"] = (
109
+ report["interpolation_convergence_slope"] > 1.55
110
+ and report["gradient_convergence_slope"] > 1.35
111
+ and report["diffusion_energy_final"] < report["diffusion_energy_initial"]
112
+ and report["diffusion_energy_monotone_fraction"] > 0.95
113
+ and report["conservative_diffusion_energy_final"] < report["conservative_diffusion_energy_initial"]
114
+ and report["conservative_diffusion_energy_monotone_fraction"] > 0.95
115
+ and report["conservative_constant_residual_linf"] < 1.0e-10
116
+ )
117
+ arrays = {
118
+ "resolution": resolutions,
119
+ "mesh_spacing": h,
120
+ "interpolation_rms_error": np.asarray(rms_errors, dtype=np.float64),
121
+ "interpolation_max_error": np.asarray(max_errors, dtype=np.float64),
122
+ "gradient_rms_error": np.asarray(grad_rms_errors, dtype=np.float64),
123
+ "diffusion_final_slice": diffusion_history[-1, :, 0, :].astype(np.float32),
124
+ "diffusion_energy_history": energy_history,
125
+ "conservative_diffusion_final_slice": conservative_history[-1, :, 0, :].astype(np.float32),
126
+ "conservative_diffusion_energy_history": conservative_energy_history,
127
+ "operator_error_slice": final_up_error[:, 0, :].astype(np.float32),
128
+ "operator_field_slice": final_field[:, 0, :].astype(np.float32),
129
+ }
130
+ return report, arrays
131
+
132
+
133
+ def save_stellarator_fci_operator_plot(
134
+ report: dict[str, object],
135
+ arrays: dict[str, np.ndarray],
136
+ path: str | Path,
137
+ ) -> Path:
138
+ resolved = Path(path)
139
+ resolved.parent.mkdir(parents=True, exist_ok=True)
140
+ fig, axes = plt.subplots(2, 3, figsize=(15.8, 8.6), constrained_layout=True)
141
+ axes[0, 0].loglog(
142
+ arrays["mesh_spacing"],
143
+ arrays["interpolation_rms_error"],
144
+ "o-",
145
+ color="#005f73",
146
+ linewidth=2.0,
147
+ label="interpolation",
148
+ )
149
+ axes[0, 0].loglog(
150
+ arrays["mesh_spacing"],
151
+ arrays["gradient_rms_error"],
152
+ "s-",
153
+ color="#ca6702",
154
+ linewidth=2.0,
155
+ label="parallel gradient",
156
+ )
157
+ axes[0, 0].invert_xaxis()
158
+ axes[0, 0].set_xlabel("logical mesh spacing")
159
+ axes[0, 0].set_ylabel("RMS error")
160
+ axes[0, 0].set_title("FCI operator convergence on analytic field-line maps")
161
+ axes[0, 0].legend(frameon=False)
162
+
163
+ time_index = np.arange(arrays["diffusion_energy_history"].size)
164
+ axes[0, 1].plot(time_index, arrays["diffusion_energy_history"], color="#9b2226", linewidth=2.0)
165
+ axes[0, 1].set_xlabel("explicit diffusion step")
166
+ axes[0, 1].set_ylabel("mean fluctuation energy")
167
+ axes[0, 1].set_title("compact parallel diffusion energy")
168
+
169
+ conservative_time_index = np.arange(arrays["conservative_diffusion_energy_history"].size)
170
+ axes[0, 2].plot(
171
+ conservative_time_index,
172
+ arrays["conservative_diffusion_energy_history"],
173
+ color="#0a9396",
174
+ linewidth=2.0,
175
+ )
176
+ axes[0, 2].set_xlabel("explicit diffusion step")
177
+ axes[0, 2].set_ylabel("mean fluctuation energy")
178
+ axes[0, 2].set_title("metric-weighted conservative diffusion")
179
+
180
+ image0 = axes[1, 0].imshow(arrays["operator_field_slice"], origin="lower", aspect="auto", cmap="viridis")
181
+ axes[1, 0].set_title("Analytic validation field at one toroidal plane")
182
+ axes[1, 0].set_xlabel("poloidal index")
183
+ axes[1, 0].set_ylabel("radial index")
184
+ fig.colorbar(image0, ax=axes[1, 0])
185
+
186
+ image1 = axes[1, 1].imshow(arrays["operator_error_slice"], origin="lower", aspect="auto", cmap="coolwarm")
187
+ axes[1, 1].set_title("Forward-map interpolation error")
188
+ axes[1, 1].set_xlabel("poloidal index")
189
+ axes[1, 1].set_ylabel("radial index")
190
+ fig.colorbar(image1, ax=axes[1, 1])
191
+
192
+ image2 = axes[1, 2].imshow(arrays["conservative_diffusion_final_slice"], origin="lower", aspect="auto", cmap="magma")
193
+ axes[1, 2].set_title("Final conservative-diffusion field")
194
+ axes[1, 2].set_xlabel("poloidal index")
195
+ axes[1, 2].set_ylabel("radial index")
196
+ fig.colorbar(image2, ax=axes[1, 2])
197
+ fig.suptitle(
198
+ "3D FCI operator gate: "
199
+ f"slope {float(report['interpolation_convergence_slope']):.2f}, "
200
+ f"conservative drop {100.0 * float(report['conservative_diffusion_energy_drop_fraction']):.1f}%",
201
+ fontsize=12,
202
+ )
203
+ fig.savefig(resolved, dpi=180)
204
+ plt.close(fig)
205
+ return resolved
206
+
207
+
208
+ def _analytic_field_on_grid(geometry: object) -> np.ndarray:
209
+ s = np.asarray(geometry.radial, dtype=np.float64)
210
+ phi = np.asarray(geometry.toroidal_angle, dtype=np.float64)
211
+ theta = np.asarray(geometry.poloidal_angle, dtype=np.float64)
212
+ return _analytic_field(s=s, phi=phi, theta=theta)
213
+
214
+
215
+ def _analytic_field_at_maps(geometry: object, *, direction: int) -> np.ndarray:
216
+ maps = geometry.maps
217
+ nx, ny, nz = maps.shape
218
+ if direction > 0:
219
+ x_index = np.asarray(maps.forward_x, dtype=np.float64)
220
+ z_index = np.asarray(maps.forward_z, dtype=np.float64)
221
+ y_index = (np.arange(ny)[None, :, None] + 1) % ny
222
+ else:
223
+ x_index = np.asarray(maps.backward_x, dtype=np.float64)
224
+ z_index = np.asarray(maps.backward_z, dtype=np.float64)
225
+ y_index = (np.arange(ny)[None, :, None] - 1) % ny
226
+ s = 0.08 + np.clip(x_index, 0.0, nx - 1.0) / float(nx - 1) * (1.0 - 0.08)
227
+ phi = 2.0 * np.pi * y_index / float(ny)
228
+ theta = 2.0 * np.pi * np.mod(z_index, nz) / float(nz)
229
+ values = _analytic_field(s=s, phi=phi, theta=theta)
230
+ valid = (x_index >= 0.0) & (x_index <= nx - 1.0)
231
+ return np.where(valid, values, 0.0)
232
+
233
+
234
+ def _analytic_field(*, s: np.ndarray, phi: np.ndarray, theta: np.ndarray) -> np.ndarray:
235
+ return (
236
+ np.sin(np.pi * s) * np.cos(2.0 * theta - 5.0 * phi)
237
+ + 0.28 * s**2 * np.sin(3.0 * theta + 2.0 * phi)
238
+ + 0.11 * np.cos(theta - phi) * (1.0 - s)
239
+ )
240
+
241
+
242
+ def _run_parallel_diffusion_probe(geometry: object) -> tuple[np.ndarray, np.ndarray]:
243
+ field = jnp.asarray(_analytic_field_on_grid(geometry), dtype=jnp.float64)
244
+ dx = float(1.0 / (geometry.maps.shape[0] - 1))
245
+ dz = float(2.0 * np.pi / geometry.maps.shape[2])
246
+ dt = 3.0e-4
247
+ chi_parallel = 0.018
248
+ chi_perp = 4.0e-5
249
+ snapshots = []
250
+ energy = []
251
+ for step in range(80):
252
+ if step % 4 == 0:
253
+ snapshots.append(np.asarray(field))
254
+ energy.append(float(jnp.mean(jnp.square(field))))
255
+ rhs = chi_parallel * laplace_parallel_fci(field, geometry.maps) + chi_perp * laplace_perp_xz(
256
+ field,
257
+ dx=dx,
258
+ dz=dz,
259
+ )
260
+ field = field + dt * rhs
261
+ snapshots.append(np.asarray(field))
262
+ energy.append(float(jnp.mean(jnp.square(field))))
263
+ return np.asarray(snapshots, dtype=np.float64), np.asarray(energy, dtype=np.float64)
264
+
265
+
266
+ def _run_conservative_diffusion_probe(geometry: object) -> tuple[np.ndarray, np.ndarray, float]:
267
+ field = jnp.asarray(_analytic_field_on_grid(geometry), dtype=jnp.float64)
268
+ radial = jnp.asarray(geometry.radial, dtype=jnp.float64)
269
+ coefficient = 0.015 + 0.010 * radial
270
+ constant = jnp.ones_like(field, dtype=jnp.float64)
271
+ constant_residual = jnp.max(
272
+ jnp.abs(
273
+ conservative_parallel_diffusion_fci(
274
+ constant,
275
+ coefficient,
276
+ geometry.maps,
277
+ jacobian=geometry.metric.J,
278
+ )
279
+ + conservative_perp_diffusion_xz(constant, 2.5e-4 * coefficient, geometry.metric)
280
+ )
281
+ )
282
+ dt = 1.0e-4
283
+ snapshots = []
284
+ energy = []
285
+ for step in range(80):
286
+ if step % 4 == 0:
287
+ snapshots.append(np.asarray(field))
288
+ energy.append(float(jnp.mean(jnp.square(field))))
289
+ rhs = conservative_parallel_diffusion_fci(
290
+ field,
291
+ coefficient,
292
+ geometry.maps,
293
+ jacobian=geometry.metric.J,
294
+ ) + conservative_perp_diffusion_xz(field, 2.5e-4 * coefficient, geometry.metric)
295
+ field = field + dt * rhs
296
+ snapshots.append(np.asarray(field))
297
+ energy.append(float(jnp.mean(jnp.square(field))))
298
+ return np.asarray(snapshots, dtype=np.float64), np.asarray(energy, dtype=np.float64), float(constant_residual)
299
+
300
+
301
+ def _fit_convergence_slope(h: np.ndarray, error: np.ndarray) -> float:
302
+ safe = np.maximum(error, 1.0e-16)
303
+ slope, _ = np.polyfit(np.log(h), np.log(safe), 1)
304
+ return float(slope)