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,255 @@
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 ..native.fci_neutral import compute_fci_neutral_reaction_diffusion
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class StellaratorNeutralPhysicsCampaignArtifacts:
17
+ report_json_path: Path
18
+ arrays_npz_path: Path
19
+ plot_png_path: Path
20
+
21
+
22
+ def create_stellarator_neutral_physics_campaign_package(
23
+ *,
24
+ output_root: str | Path,
25
+ case_label: str = "stellarator_neutral_physics_campaign",
26
+ nx: int = 34,
27
+ ny: int = 30,
28
+ nz: int = 60,
29
+ ) -> StellaratorNeutralPhysicsCampaignArtifacts:
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
+ report, arrays = build_stellarator_neutral_physics_campaign(nx=nx, ny=ny, nz=nz)
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
+ np.savez_compressed(arrays_npz_path, **arrays)
40
+ plot_png_path = images_dir / f"{case_label}.png"
41
+ save_stellarator_neutral_physics_plot(report, arrays, plot_png_path)
42
+ return StellaratorNeutralPhysicsCampaignArtifacts(
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_neutral_physics_campaign(
50
+ *,
51
+ nx: int = 34,
52
+ ny: int = 30,
53
+ nz: int = 60,
54
+ ) -> tuple[dict[str, Any], dict[str, np.ndarray]]:
55
+ geometry = build_synthetic_stellarator_geometry(nx=nx, ny=ny, nz=nz)
56
+ radial = np.asarray(geometry.radial, dtype=np.float64)
57
+ theta = np.asarray(geometry.poloidal_angle, dtype=np.float64)
58
+ phi = np.asarray(geometry.toroidal_angle, dtype=np.float64)
59
+
60
+ neutral_density = 0.12 + 0.62 * np.exp(-((radial - 0.93) / 0.08) ** 2) * (
61
+ 1.0 + 0.18 * np.cos(2.0 * theta - 5.0 * phi)
62
+ )
63
+ ion_density = 0.35 + 1.0 * np.exp(-((radial - 0.68) / 0.20) ** 2)
64
+ electron_density = ion_density * (1.0 + 0.015 * np.cos(theta - 5.0 * phi))
65
+ neutral_temperature = 0.035 + 0.020 * radial
66
+ ion_temperature = 0.075 + 0.12 * (1.0 - radial)
67
+ electron_temperature = 0.095 + 0.16 * (1.0 - radial)
68
+ neutral_pressure = neutral_density * neutral_temperature
69
+ ion_pressure = ion_density * ion_temperature
70
+ electron_pressure = electron_density * electron_temperature
71
+ neutral_velocity = 0.025 * np.sin(theta - 5.0 * phi)
72
+ ion_velocity = 0.040 * np.cos(2.0 * theta - 5.0 * phi)
73
+ neutral_momentum = neutral_density * neutral_velocity
74
+ ion_momentum = ion_density * ion_velocity
75
+
76
+ result = compute_fci_neutral_reaction_diffusion(
77
+ neutral_density=neutral_density,
78
+ neutral_pressure=neutral_pressure,
79
+ neutral_momentum=neutral_momentum,
80
+ ion_density=ion_density,
81
+ ion_pressure=ion_pressure,
82
+ ion_momentum=ion_momentum,
83
+ electron_density=electron_density,
84
+ electron_pressure=electron_pressure,
85
+ maps=geometry.maps,
86
+ metric=geometry.metric,
87
+ )
88
+
89
+ ionisation = np.asarray(result.ionisation_rate, dtype=np.float64)
90
+ recombination = np.asarray(result.recombination_rate, dtype=np.float64)
91
+ charge_exchange = np.asarray(result.charge_exchange_rate, dtype=np.float64)
92
+ neutral_diffusion = np.asarray(result.neutral_diffusion_source, dtype=np.float64)
93
+ neutral_source = np.asarray(result.neutral_density_source, dtype=np.float64)
94
+ ion_source = np.asarray(result.ion_density_source, dtype=np.float64)
95
+ momentum_pair = np.asarray(result.neutral_momentum_source + result.ion_momentum_source, dtype=np.float64)
96
+
97
+ total_ionisation = float(np.sum(ionisation))
98
+ total_recombination = float(np.sum(recombination))
99
+ total_charge_exchange = float(np.sum(charge_exchange))
100
+ particle_residual = float(np.asarray(result.total_particle_residual))
101
+ momentum_residual = float(np.asarray(result.total_momentum_residual))
102
+ particle_relative_error = abs(particle_residual) / max(total_ionisation + total_recombination, 1.0e-30)
103
+ momentum_relative_error = abs(momentum_residual) / max(float(np.sum(np.abs(momentum_pair))) + 1.0e-30, 1.0e-30)
104
+ jacobian = np.asarray(geometry.metric.J, dtype=np.float64)
105
+ diffusion_integral = float(np.sum(jacobian * neutral_diffusion))
106
+ diffusion_relative_integral = abs(diffusion_integral) / max(float(np.sum(np.abs(jacobian * neutral_diffusion))), 1.0e-30)
107
+ cx_to_ionisation_ratio = total_charge_exchange / max(total_ionisation, 1.0e-30)
108
+
109
+ report: dict[str, Any] = {
110
+ "case": "non_axisymmetric_neutral_diffusion_reaction_gate",
111
+ "geometry": geometry.metadata,
112
+ "total_ionisation": total_ionisation,
113
+ "total_recombination": total_recombination,
114
+ "total_charge_exchange": total_charge_exchange,
115
+ "cx_to_ionisation_ratio": cx_to_ionisation_ratio,
116
+ "particle_reaction_residual": particle_residual,
117
+ "particle_reaction_relative_error": particle_relative_error,
118
+ "momentum_reaction_residual": momentum_residual,
119
+ "momentum_reaction_relative_error": momentum_relative_error,
120
+ "neutral_diffusion_integral": diffusion_integral,
121
+ "neutral_diffusion_relative_integral": diffusion_relative_integral,
122
+ "max_ionisation_rate": float(np.max(ionisation)),
123
+ "max_charge_exchange_rate": float(np.max(charge_exchange)),
124
+ }
125
+ report["passed"] = (
126
+ total_ionisation > 0.0
127
+ and total_recombination > 0.0
128
+ and total_charge_exchange > 0.0
129
+ and particle_relative_error < 1.0e-12
130
+ and momentum_relative_error < 1.0e-12
131
+ and diffusion_relative_integral < 2.0e-2
132
+ and cx_to_ionisation_ratio > 0.05
133
+ )
134
+ arrays = {
135
+ "ionisation_toroidal": np.sum(ionisation, axis=0).astype(np.float32),
136
+ "recombination_toroidal": np.sum(recombination, axis=0).astype(np.float32),
137
+ "charge_exchange_toroidal": np.sum(charge_exchange, axis=0).astype(np.float32),
138
+ "neutral_source_toroidal": np.sum(neutral_source, axis=0).astype(np.float32),
139
+ "ion_source_toroidal": np.sum(ion_source, axis=0).astype(np.float32),
140
+ "neutral_density_slice": neutral_density[:, 0, :].astype(np.float32),
141
+ "radial_grid": np.mean(radial, axis=(1, 2)).astype(np.float32),
142
+ "radial_rates": np.stack(
143
+ [
144
+ np.mean(neutral_density, axis=(1, 2)),
145
+ np.sum(ionisation, axis=(1, 2)),
146
+ np.sum(recombination, axis=(1, 2)),
147
+ np.sum(charge_exchange, axis=(1, 2)),
148
+ ],
149
+ axis=1,
150
+ ).astype(np.float32),
151
+ "summary": np.asarray(
152
+ [
153
+ total_ionisation,
154
+ total_recombination,
155
+ total_charge_exchange,
156
+ abs(particle_residual),
157
+ abs(momentum_residual),
158
+ ],
159
+ dtype=np.float32,
160
+ ),
161
+ }
162
+ return report, arrays
163
+
164
+
165
+ def save_stellarator_neutral_physics_plot(
166
+ report: dict[str, Any],
167
+ arrays: dict[str, np.ndarray],
168
+ path: str | Path,
169
+ ) -> Path:
170
+ resolved = Path(path)
171
+ resolved.parent.mkdir(parents=True, exist_ok=True)
172
+ fig, axes = plt.subplots(2, 3, figsize=(15.6, 8.8), constrained_layout=True)
173
+ extent = [0.0, 2.0 * np.pi, 0.0, 2.0 * np.pi]
174
+
175
+ image0 = axes[0, 0].imshow(arrays["ionisation_toroidal"].T, origin="lower", aspect="auto", extent=extent, cmap="inferno")
176
+ axes[0, 0].set_title("ionisation source")
177
+ axes[0, 0].set_xlabel("toroidal angle")
178
+ axes[0, 0].set_ylabel("poloidal angle")
179
+ fig.colorbar(image0, ax=axes[0, 0])
180
+
181
+ image1 = axes[0, 1].imshow(
182
+ arrays["charge_exchange_toroidal"].T,
183
+ origin="lower",
184
+ aspect="auto",
185
+ extent=extent,
186
+ cmap="magma",
187
+ )
188
+ axes[0, 1].set_title("charge-exchange rate")
189
+ axes[0, 1].set_xlabel("toroidal angle")
190
+ axes[0, 1].set_ylabel("poloidal angle")
191
+ fig.colorbar(image1, ax=axes[0, 1])
192
+
193
+ signed = arrays["ion_source_toroidal"].T + arrays["neutral_source_toroidal"].T
194
+ vmax = float(np.max(np.abs(signed)))
195
+ image2 = axes[0, 2].imshow(
196
+ signed,
197
+ origin="lower",
198
+ aspect="auto",
199
+ extent=extent,
200
+ cmap="coolwarm",
201
+ vmin=-vmax,
202
+ vmax=vmax,
203
+ )
204
+ axes[0, 2].set_title("local plasma + neutral source")
205
+ axes[0, 2].set_xlabel("toroidal angle")
206
+ axes[0, 2].set_ylabel("poloidal angle")
207
+ fig.colorbar(image2, ax=axes[0, 2])
208
+
209
+ radial_grid = arrays["radial_grid"]
210
+ radial_rates = arrays["radial_rates"]
211
+ axes[1, 0].plot(radial_grid, radial_rates[:, 0] / np.max(radial_rates[:, 0]), lw=2.0, label="neutral density")
212
+ axes[1, 0].plot(radial_grid, radial_rates[:, 1] / np.max(radial_rates[:, 1]), lw=2.0, label="ionisation")
213
+ axes[1, 0].plot(radial_grid, radial_rates[:, 2] / np.max(radial_rates[:, 2]), lw=2.0, label="recombination")
214
+ axes[1, 0].plot(radial_grid, radial_rates[:, 3] / np.max(radial_rates[:, 3]), lw=2.0, label="charge exchange")
215
+ axes[1, 0].set_title("normalized radial reaction profiles")
216
+ axes[1, 0].set_xlabel("normalized radius")
217
+ axes[1, 0].grid(alpha=0.25)
218
+ axes[1, 0].legend(frameon=False, fontsize=8)
219
+
220
+ image3 = axes[1, 1].imshow(arrays["neutral_density_slice"], origin="lower", aspect="auto", cmap="viridis")
221
+ axes[1, 1].set_title("neutral density at one toroidal plane")
222
+ axes[1, 1].set_xlabel("poloidal index")
223
+ axes[1, 1].set_ylabel("radial index")
224
+ fig.colorbar(image3, ax=axes[1, 1])
225
+
226
+ summary = arrays["summary"]
227
+ labels = ["ion", "rec", "CX", "particle err", "mom err"]
228
+ axes[1, 2].bar(np.arange(summary.size), summary, color=["#9b2226", "#005f73", "#ee9b00", "#94d2bd", "#0a9396"])
229
+ axes[1, 2].set_xticks(np.arange(summary.size), labels, rotation=18, ha="right")
230
+ axes[1, 2].set_yscale("log")
231
+ axes[1, 2].grid(axis="y", alpha=0.25)
232
+ axes[1, 2].set_title("integrated reaction balance")
233
+ axes[1, 2].text(
234
+ 0.03,
235
+ 0.96,
236
+ "\n".join(
237
+ [
238
+ f"particle rel. err = {report['particle_reaction_relative_error']:.1e}",
239
+ f"momentum rel. err = {report['momentum_reaction_relative_error']:.1e}",
240
+ f"CX / ionisation = {report['cx_to_ionisation_ratio']:.2f}",
241
+ ]
242
+ ),
243
+ transform=axes[1, 2].transAxes,
244
+ va="top",
245
+ ha="left",
246
+ fontsize=8,
247
+ bbox={"facecolor": "white", "alpha": 0.82, "edgecolor": "0.8"},
248
+ )
249
+ fig.suptitle(
250
+ "Non-axisymmetric neutral gate: diffusion plus ionisation, recombination, and charge exchange",
251
+ fontsize=14,
252
+ )
253
+ fig.savefig(resolved, dpi=190)
254
+ plt.close(fig)
255
+ return resolved
@@ -0,0 +1,331 @@
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 ..native.fci_sheath_recycling import compute_fci_sheath_recycling
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class StellaratorSheathRecyclingCampaignArtifacts:
17
+ report_json_path: Path
18
+ arrays_npz_path: Path
19
+ plot_png_path: Path
20
+
21
+
22
+ def create_stellarator_sheath_recycling_campaign_package(
23
+ *,
24
+ output_root: str | Path,
25
+ case_label: str = "stellarator_sheath_recycling_campaign",
26
+ nx: int = 36,
27
+ ny: int = 32,
28
+ nz: int = 64,
29
+ ) -> StellaratorSheathRecyclingCampaignArtifacts:
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_sheath_recycling_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_sheath_recycling_plot(report, arrays, plot_png_path)
43
+ return StellaratorSheathRecyclingCampaignArtifacts(
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_sheath_recycling_campaign(
51
+ *,
52
+ nx: int = 36,
53
+ ny: int = 32,
54
+ nz: int = 64,
55
+ ) -> tuple[dict[str, Any], dict[str, np.ndarray]]:
56
+ geometry = build_synthetic_stellarator_geometry(nx=nx, ny=ny, nz=nz)
57
+ radial = np.asarray(geometry.radial, dtype=np.float64)
58
+ theta = np.asarray(geometry.poloidal_angle, dtype=np.float64)
59
+ phi = np.asarray(geometry.toroidal_angle, dtype=np.float64)
60
+ connection = np.asarray(geometry.connection_length, dtype=np.float64)
61
+
62
+ density = (
63
+ 0.28
64
+ + 1.10 * np.exp(-((radial - 0.70) / 0.19) ** 2)
65
+ + 0.15 * np.exp(-((radial - 0.91) / 0.06) ** 2) * (1.0 + np.cos(2.0 * theta - 5.0 * phi))
66
+ )
67
+ electron_temperature = (
68
+ 0.055
69
+ + 0.18 * (1.0 - radial) ** 1.25
70
+ + 0.018 * np.cos(theta - 5.0 * phi)
71
+ + 0.010 * np.sin(3.0 * theta + 4.0 * phi)
72
+ )
73
+ ion_temperature = (
74
+ 0.050
75
+ + 0.13 * (1.0 - radial) ** 1.10
76
+ + 0.015 * np.sin(2.0 * theta - 5.0 * phi + 0.35)
77
+ )
78
+ density = np.maximum(density, 1.0e-4)
79
+ electron_temperature = np.maximum(electron_temperature, 5.0e-3)
80
+ ion_temperature = np.maximum(ion_temperature, 5.0e-3)
81
+
82
+ result = compute_fci_sheath_recycling(
83
+ density,
84
+ electron_temperature,
85
+ ion_temperature,
86
+ geometry.maps,
87
+ recycling_fraction=0.97,
88
+ electron_sheath_transmission=5.0,
89
+ ion_sheath_transmission=3.5,
90
+ recycled_neutral_energy=0.025,
91
+ )
92
+
93
+ target_mask = np.asarray(result.masks.active, dtype=bool)
94
+ endpoint_count = np.asarray(result.masks.endpoint_count, dtype=np.float64)
95
+ heat_load = np.asarray(result.target_heat_load, dtype=np.float64)
96
+ particle_loss = np.asarray(result.ion_particle_loss, dtype=np.float64)
97
+ recycled_source = np.asarray(result.recycled_particle_source, dtype=np.float64)
98
+ neutral_energy = np.asarray(result.recycled_neutral_energy_source, dtype=np.float64)
99
+ current_residual = np.asarray(result.current_residual, dtype=np.float64)
100
+
101
+ total_particle_loss = float(np.asarray(result.total_ion_particle_loss))
102
+ total_recycled_particle_source = float(np.asarray(result.total_recycled_particle_source))
103
+ total_heat_load = float(np.asarray(result.total_target_heat_load))
104
+ total_recycled_neutral_energy = float(np.asarray(result.total_recycled_neutral_energy))
105
+ particle_recycling_relative_error = float(
106
+ abs(np.asarray(result.particle_recycling_residual)) / max(total_recycled_particle_source, 1.0e-30)
107
+ )
108
+ neutral_energy_relative_error = float(
109
+ abs(np.asarray(result.neutral_energy_recycling_residual)) / max(total_recycled_neutral_energy, 1.0e-30)
110
+ )
111
+ current_balance_relative_error = float(
112
+ abs(np.asarray(result.current_balance_residual)) / max(total_particle_loss, 1.0e-30)
113
+ )
114
+ target_fraction = float(np.mean(target_mask))
115
+ positive_heat = heat_load[heat_load > 0.0]
116
+ heat_load_contrast = float(np.percentile(positive_heat, 99.0) / max(np.percentile(positive_heat, 50.0), 1.0e-30))
117
+ short_connection_mask = connection <= np.percentile(connection, 35.0)
118
+ short_connection_heat_fraction = float(
119
+ np.sum(heat_load[short_connection_mask]) / max(np.sum(heat_load), 1.0e-30)
120
+ )
121
+ heat_connection_correlation = _finite_corrcoef(
122
+ np.ravel(heat_load[target_mask]),
123
+ np.ravel(1.0 / np.maximum(connection[target_mask], 1.0e-12)),
124
+ )
125
+ endpoint_heat_fraction = float(np.sum(heat_load[target_mask]) / max(np.sum(heat_load), 1.0e-30))
126
+ max_current_residual = float(np.max(np.abs(current_residual)))
127
+
128
+ report: dict[str, Any] = {
129
+ "case": "non_axisymmetric_fci_sheath_recycling",
130
+ "geometry": geometry.metadata,
131
+ "model": {
132
+ "recycling_fraction": 0.97,
133
+ "electron_sheath_transmission": 5.0,
134
+ "ion_sheath_transmission": 3.5,
135
+ "recycled_neutral_energy": 0.025,
136
+ "sound_speed": "sqrt((Te + Ti) / mi)",
137
+ "ion_particle_loss": "endpoint_count * n * sound_speed",
138
+ "electron_particle_loss": "ion_particle_loss by zero-current reconstruction",
139
+ "electron_heat_loss": "gamma_e * ion_particle_loss * Te",
140
+ "ion_heat_loss": "gamma_i * ion_particle_loss * Ti",
141
+ "recycled_particle_source": "recycling_fraction * ion_particle_loss",
142
+ },
143
+ "target_fraction": target_fraction,
144
+ "total_particle_loss": total_particle_loss,
145
+ "total_recycled_particle_source": total_recycled_particle_source,
146
+ "total_heat_load": total_heat_load,
147
+ "total_recycled_neutral_energy": total_recycled_neutral_energy,
148
+ "particle_recycling_relative_error": particle_recycling_relative_error,
149
+ "neutral_energy_relative_error": neutral_energy_relative_error,
150
+ "current_balance_relative_error": current_balance_relative_error,
151
+ "max_current_residual": max_current_residual,
152
+ "heat_load_contrast": heat_load_contrast,
153
+ "short_connection_heat_fraction": short_connection_heat_fraction,
154
+ "heat_inverse_connection_correlation": heat_connection_correlation,
155
+ "endpoint_heat_fraction": endpoint_heat_fraction,
156
+ }
157
+ report["passed"] = (
158
+ 0.002 < target_fraction < 0.35
159
+ and total_particle_loss > 0.0
160
+ and total_heat_load > 0.0
161
+ and particle_recycling_relative_error < 1.0e-12
162
+ and neutral_energy_relative_error < 1.0e-12
163
+ and current_balance_relative_error < 1.0e-12
164
+ and max_current_residual < 1.0e-12
165
+ and heat_load_contrast > 1.05
166
+ and short_connection_heat_fraction > 0.05
167
+ and endpoint_heat_fraction > 0.999999
168
+ )
169
+
170
+ heat_toroidal = np.sum(heat_load, axis=0)
171
+ loss_toroidal = np.sum(particle_loss, axis=0)
172
+ source_toroidal = np.sum(recycled_source, axis=0)
173
+ mask_toroidal = np.sum(endpoint_count, axis=0)
174
+ connection_heat_weighted = np.sum(connection * heat_load, axis=0) / np.maximum(np.sum(heat_load, axis=0), 1.0e-30)
175
+ radial_profile = np.stack(
176
+ [
177
+ np.mean(density, axis=(1, 2)),
178
+ np.sum(particle_loss, axis=(1, 2)),
179
+ np.sum(heat_load, axis=(1, 2)),
180
+ np.sum(recycled_source, axis=(1, 2)),
181
+ ],
182
+ axis=1,
183
+ )
184
+ toroidal_profile = np.stack(
185
+ [
186
+ np.sum(particle_loss, axis=(0, 2)),
187
+ np.sum(heat_load, axis=(0, 2)),
188
+ np.sum(recycled_source, axis=(0, 2)),
189
+ ],
190
+ axis=1,
191
+ )
192
+ summary_bars = np.asarray(
193
+ [
194
+ total_particle_loss,
195
+ total_recycled_particle_source,
196
+ total_heat_load,
197
+ total_recycled_neutral_energy,
198
+ ],
199
+ dtype=np.float64,
200
+ )
201
+ arrays = {
202
+ "target_mask_toroidal": mask_toroidal.astype(np.float32),
203
+ "particle_loss_toroidal": loss_toroidal.astype(np.float32),
204
+ "heat_load_toroidal": heat_toroidal.astype(np.float32),
205
+ "recycled_source_toroidal": source_toroidal.astype(np.float32),
206
+ "connection_toroidal_heat_weighted": connection_heat_weighted.astype(np.float32),
207
+ "radial_grid": np.mean(radial, axis=(1, 2)).astype(np.float32),
208
+ "radial_profile": radial_profile.astype(np.float32),
209
+ "toroidal_angle": np.mean(phi, axis=(0, 2)).astype(np.float32),
210
+ "toroidal_profile": toroidal_profile.astype(np.float32),
211
+ "summary_bars": summary_bars.astype(np.float32),
212
+ }
213
+ return report, arrays
214
+
215
+
216
+ def save_stellarator_sheath_recycling_plot(
217
+ report: dict[str, Any],
218
+ arrays: dict[str, np.ndarray],
219
+ path: str | Path,
220
+ ) -> Path:
221
+ resolved = Path(path)
222
+ resolved.parent.mkdir(parents=True, exist_ok=True)
223
+
224
+ fig, axes = plt.subplots(2, 3, figsize=(15.8, 9.2), constrained_layout=True)
225
+ extent = [0.0, 2.0 * np.pi, 0.0, 2.0 * np.pi]
226
+
227
+ loss_image = axes[0, 0].imshow(
228
+ arrays["particle_loss_toroidal"].T,
229
+ origin="lower",
230
+ aspect="auto",
231
+ extent=extent,
232
+ cmap="viridis",
233
+ )
234
+ axes[0, 0].set_title("integrated Bohm particle loss")
235
+ axes[0, 0].set_xlabel("toroidal angle")
236
+ axes[0, 0].set_ylabel("poloidal angle")
237
+ fig.colorbar(loss_image, ax=axes[0, 0], label="normalized particle flux")
238
+
239
+ connection_image = axes[0, 1].imshow(
240
+ arrays["connection_toroidal_heat_weighted"].T,
241
+ origin="lower",
242
+ aspect="auto",
243
+ extent=extent,
244
+ cmap="magma",
245
+ )
246
+ axes[0, 1].set_title("heat-weighted connection-length proxy")
247
+ axes[0, 1].set_xlabel("toroidal angle")
248
+ axes[0, 1].set_ylabel("poloidal angle")
249
+ fig.colorbar(connection_image, ax=axes[0, 1], label="length proxy")
250
+
251
+ heat_image = axes[0, 2].imshow(
252
+ arrays["heat_load_toroidal"].T,
253
+ origin="lower",
254
+ aspect="auto",
255
+ extent=extent,
256
+ cmap="inferno",
257
+ )
258
+ axes[0, 2].set_title("integrated target heat load")
259
+ axes[0, 2].set_xlabel("toroidal angle")
260
+ axes[0, 2].set_ylabel("poloidal angle")
261
+ fig.colorbar(heat_image, ax=axes[0, 2], label="normalized heat load")
262
+
263
+ radial_grid = arrays["radial_grid"]
264
+ radial_profile = arrays["radial_profile"]
265
+ normalized_density = radial_profile[:, 0] / max(float(np.max(radial_profile[:, 0])), 1.0e-30)
266
+ normalized_loss = radial_profile[:, 1] / max(float(np.max(radial_profile[:, 1])), 1.0e-30)
267
+ normalized_heat = radial_profile[:, 2] / max(float(np.max(radial_profile[:, 2])), 1.0e-30)
268
+ normalized_source = radial_profile[:, 3] / max(float(np.max(radial_profile[:, 3])), 1.0e-30)
269
+ axes[1, 0].plot(radial_grid, normalized_density, color="#005f73", lw=2.2, label="mean density")
270
+ axes[1, 0].plot(radial_grid, normalized_loss, color="#9b2226", lw=2.2, label="particle loss")
271
+ axes[1, 0].plot(radial_grid, normalized_heat, color="#ee9b00", lw=2.2, label="heat load")
272
+ axes[1, 0].plot(radial_grid, normalized_source, color="#0a9396", lw=2.2, label="recycled source")
273
+ axes[1, 0].set_title("normalized radial profiles")
274
+ axes[1, 0].set_xlabel("normalized radius")
275
+ axes[1, 0].set_ylim(-0.05, 1.08)
276
+ axes[1, 0].grid(alpha=0.25)
277
+ axes[1, 0].legend(frameon=False, fontsize=8)
278
+
279
+ toroidal_angle = arrays["toroidal_angle"]
280
+ toroidal_profile = arrays["toroidal_profile"]
281
+ axes[1, 1].plot(toroidal_angle, toroidal_profile[:, 0], color="#9b2226", lw=2.0, label="particle loss")
282
+ axes[1, 1].plot(toroidal_angle, toroidal_profile[:, 1], color="#ee9b00", lw=2.0, label="heat load")
283
+ axes[1, 1].plot(toroidal_angle, toroidal_profile[:, 2], color="#0a9396", lw=2.0, label="recycled source")
284
+ axes[1, 1].set_title("toroidal modulation of target response")
285
+ axes[1, 1].set_xlabel("toroidal angle")
286
+ axes[1, 1].grid(alpha=0.25)
287
+ axes[1, 1].legend(frameon=False, fontsize=8)
288
+
289
+ summary = arrays["summary_bars"]
290
+ labels = ["ion loss", "neutral source", "heat load", "neutral energy"]
291
+ axes[1, 2].bar(np.arange(summary.size), summary, color=["#9b2226", "#0a9396", "#ee9b00", "#005f73"])
292
+ axes[1, 2].set_xticks(np.arange(summary.size), labels, rotation=18, ha="right")
293
+ axes[1, 2].set_yscale("log")
294
+ axes[1, 2].set_title("integrated balance diagnostics")
295
+ axes[1, 2].grid(axis="y", alpha=0.25)
296
+ axes[1, 2].text(
297
+ 0.03,
298
+ 0.96,
299
+ "\n".join(
300
+ [
301
+ f"particle balance error = {report['particle_recycling_relative_error']:.1e}",
302
+ f"current balance error = {report['current_balance_relative_error']:.1e}",
303
+ f"short-L heat fraction = {report['short_connection_heat_fraction']:.2f}",
304
+ ]
305
+ ),
306
+ transform=axes[1, 2].transAxes,
307
+ va="top",
308
+ ha="left",
309
+ fontsize=8,
310
+ bbox={"facecolor": "white", "alpha": 0.82, "edgecolor": "0.8"},
311
+ )
312
+
313
+ fig.suptitle(
314
+ "Non-axisymmetric FCI sheath/recycling gate: "
315
+ "Bohm loss, zero-current closure, and exact recycled-source accounting",
316
+ fontsize=14,
317
+ )
318
+ fig.savefig(resolved, dpi=190)
319
+ plt.close(fig)
320
+ return resolved
321
+
322
+
323
+ def _finite_corrcoef(left: np.ndarray, right: np.ndarray) -> float:
324
+ mask = np.isfinite(left) & np.isfinite(right)
325
+ if int(np.sum(mask)) < 3:
326
+ return 0.0
327
+ left_valid = left[mask]
328
+ right_valid = right[mask]
329
+ if float(np.std(left_valid)) <= 0.0 or float(np.std(right_valid)) <= 0.0:
330
+ return 0.0
331
+ return float(np.corrcoef(left_valid, right_valid)[0, 1])