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,365 @@
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 FciMaps
12
+ from ..geometry.vmec_extender_import import (
13
+ VmecExtenderGrid,
14
+ build_vmec_extender_fci_maps,
15
+ load_vmec_extender_grid_netcdf,
16
+ )
17
+ from ..native.fci import conservative_parallel_diffusion_fci
18
+ from .publication_plotting import save_publication_figure, style_axis
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class VmecExtenderSolSmokeResult:
23
+ history: np.ndarray
24
+ time: np.ndarray
25
+ source: np.ndarray
26
+ loss_profile: np.ndarray
27
+ endpoint_mask: np.ndarray
28
+ fci_map_identity_max_abs_error: float
29
+ parallel_mode_decay_relative_error: float | None
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class VmecExtenderSolSmokeArtifacts:
34
+ summary_json_path: Path
35
+ arrays_npz_path: Path
36
+ plot_png_path: Path
37
+
38
+
39
+ def create_vmec_extender_sol_smoke_package(
40
+ *,
41
+ output_root: str | Path,
42
+ field_grid_path: str | Path,
43
+ case_label: str = "vmec_extender_sol_smoke",
44
+ strict_metadata: bool = True,
45
+ ) -> VmecExtenderSolSmokeArtifacts:
46
+ """Run a compact imported-field SOL smoke campaign and write artifacts."""
47
+
48
+ root = Path(output_root)
49
+ data_dir = root / "data"
50
+ images_dir = root / "images"
51
+ data_dir.mkdir(parents=True, exist_ok=True)
52
+ images_dir.mkdir(parents=True, exist_ok=True)
53
+
54
+ grid = load_vmec_extender_grid_netcdf(field_grid_path, strict_metadata=strict_metadata)
55
+ result = simulate_vmec_extender_scalar_sol_smoke(grid)
56
+ report = build_vmec_extender_sol_smoke_report(grid, result)
57
+
58
+ summary_json_path = data_dir / f"{case_label}.json"
59
+ summary_json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
60
+
61
+ arrays_npz_path = data_dir / f"{case_label}.npz"
62
+ np.savez_compressed(
63
+ arrays_npz_path,
64
+ history=np.asarray(result.history, dtype=np.float32),
65
+ time=np.asarray(result.time, dtype=np.float64),
66
+ source=np.asarray(result.source, dtype=np.float32),
67
+ loss_profile=np.asarray(result.loss_profile, dtype=np.float32),
68
+ endpoint_mask=np.asarray(result.endpoint_mask, dtype=bool),
69
+ )
70
+
71
+ plot_png_path = save_vmec_extender_sol_smoke_plot(grid, result, report, images_dir / f"{case_label}.png")
72
+ return VmecExtenderSolSmokeArtifacts(
73
+ summary_json_path=summary_json_path,
74
+ arrays_npz_path=arrays_npz_path,
75
+ plot_png_path=plot_png_path,
76
+ )
77
+
78
+
79
+ def simulate_vmec_extender_scalar_sol_smoke(
80
+ grid: VmecExtenderGrid,
81
+ *,
82
+ frames: int = 14,
83
+ substeps_per_frame: int = 4,
84
+ dt: float = 0.006,
85
+ parallel_diffusivity: float = 1.0e-2,
86
+ perpendicular_diffusivity: float = 2.5e-4,
87
+ source_strength: float = 3.0e-2,
88
+ radial_loss_rate: float = 2.4e-2,
89
+ endpoint_loss_rate: float = 8.0e-2,
90
+ ) -> VmecExtenderSolSmokeResult:
91
+ """Advance a small scalar SOL model on imported VMEC-extender FCI maps.
92
+
93
+ The model is intentionally modest: field-aligned conservative diffusion on
94
+ the imported FCI map, open-boundary perpendicular R-Z diffusion, a localized
95
+ source, and edge/endpoint losses. It is a geometry-coupling gate, not a
96
+ self-consistent turbulence claim.
97
+ """
98
+
99
+ maps = build_vmec_extender_fci_maps(grid)
100
+ R, phi, Z = _mesh(grid)
101
+ Rn = _normalised(R)
102
+ Zn = _normalised_symmetric(Z)
103
+ phase = 2.0 * np.pi * (phi - grid.phi[0]) / float(grid.phi_period)
104
+ source = source_strength * jnp.exp(-((Rn - 0.40) / 0.22) ** 2 - (Zn / 0.46) ** 2)
105
+ source = source * (1.0 + 0.08 * jnp.cos(phase))
106
+ radial_loss = radial_loss_rate * jnp.clip((Rn - 0.65) / 0.35, 0.0, 1.0) ** 2
107
+ endpoint_mask = jnp.asarray(maps.forward_boundary | maps.backward_boundary, dtype=bool)
108
+ loss_profile = radial_loss + endpoint_loss_rate * endpoint_mask.astype(jnp.float64)
109
+
110
+ state = 1.0 + 0.045 * jnp.cos(phase) + 0.035 * jnp.sin(np.pi * Rn) * jnp.cos(np.pi * Zn)
111
+ state = jnp.maximum(state, 1.0e-6)
112
+ jacobian = jnp.broadcast_to(R, state.shape)
113
+ parallel_coefficient = jnp.ones_like(state) * float(parallel_diffusivity)
114
+ dR = float(np.mean(np.diff(np.asarray(grid.R, dtype=np.float64))))
115
+ dZ = float(np.mean(np.diff(np.asarray(grid.Z, dtype=np.float64))))
116
+
117
+ history: list[np.ndarray] = []
118
+ time: list[float] = []
119
+ for frame in range(int(frames)):
120
+ history.append(np.asarray(state, dtype=np.float64))
121
+ time.append(float(frame * int(substeps_per_frame) * dt))
122
+ for _ in range(int(substeps_per_frame)):
123
+ parallel = conservative_parallel_diffusion_fci(
124
+ state,
125
+ parallel_coefficient,
126
+ maps,
127
+ jacobian=jacobian,
128
+ )
129
+ perpendicular = float(perpendicular_diffusivity) * _laplace_open_RZ(state, dR=dR, dZ=dZ)
130
+ rhs = parallel + perpendicular + source - loss_profile * state
131
+ state = jnp.maximum(state + float(dt) * rhs, 1.0e-8)
132
+
133
+ map_identity_error = _fci_map_identity_max_abs_error(maps)
134
+ mode_error = _parallel_mode_decay_relative_error(
135
+ grid,
136
+ maps,
137
+ diffusivity=float(parallel_diffusivity),
138
+ )
139
+ return VmecExtenderSolSmokeResult(
140
+ history=np.asarray(history, dtype=np.float64),
141
+ time=np.asarray(time, dtype=np.float64),
142
+ source=np.asarray(source, dtype=np.float64),
143
+ loss_profile=np.asarray(loss_profile, dtype=np.float64),
144
+ endpoint_mask=np.asarray(endpoint_mask, dtype=bool),
145
+ fci_map_identity_max_abs_error=float(map_identity_error),
146
+ parallel_mode_decay_relative_error=mode_error,
147
+ )
148
+
149
+
150
+ def build_vmec_extender_sol_smoke_report(
151
+ grid: VmecExtenderGrid,
152
+ result: VmecExtenderSolSmokeResult,
153
+ ) -> dict[str, object]:
154
+ """Summarise scalar SOL smoke diagnostics from an imported field grid."""
155
+
156
+ history = np.asarray(result.history, dtype=np.float64)
157
+ final = history[-1]
158
+ mass = np.mean(history, axis=(1, 2, 3))
159
+ rms = np.sqrt(np.mean((history - mass[:, None, None, None]) ** 2, axis=(1, 2, 3)))
160
+ source_integral = float(np.mean(result.source))
161
+ loss_integral = float(np.mean(result.loss_profile * final))
162
+ source_loss_mismatch = abs(source_integral - loss_integral) / max(abs(source_integral), 1.0e-30)
163
+ mode_error = result.parallel_mode_decay_relative_error
164
+ passed = (
165
+ bool(np.all(np.isfinite(history)))
166
+ and float(np.min(final)) > 0.0
167
+ and float(np.max(final)) < 2.0
168
+ and float(rms[-1]) > 1.0e-4
169
+ and abs(float(mass[-1] - mass[0]) / max(abs(float(mass[0])), 1.0e-30)) < 0.10
170
+ and (mode_error is None or float(mode_error) <= 1.0e-10)
171
+ )
172
+ return {
173
+ "family": "vmec_extender_sol_smoke",
174
+ "case": "imported_field_scalar_sol_smoke",
175
+ "source": str(grid.metadata.get("source", "unknown")),
176
+ "grid_shape": [int(value) for value in grid.shape],
177
+ "nfp": int(grid.nfp),
178
+ "phi_period": float(grid.phi_period),
179
+ "frame_count": int(history.shape[0]),
180
+ "time_start": float(result.time[0]),
181
+ "time_end": float(result.time[-1]),
182
+ "final_min": float(np.min(final)),
183
+ "final_max": float(np.max(final)),
184
+ "final_mean": float(np.mean(final)),
185
+ "final_rms_fluctuation": float(rms[-1]),
186
+ "mass_relative_change": float((mass[-1] - mass[0]) / max(abs(mass[0]), 1.0e-30)),
187
+ "source_integral": source_integral,
188
+ "loss_integral_final": loss_integral,
189
+ "source_loss_relative_mismatch_final": float(source_loss_mismatch),
190
+ "endpoint_fraction": float(np.mean(result.endpoint_mask)),
191
+ "fci_map_identity_max_abs_error": float(result.fci_map_identity_max_abs_error),
192
+ "parallel_mode_decay_relative_error": None if mode_error is None else float(mode_error),
193
+ "passed": bool(passed),
194
+ "notes": (
195
+ "Compact imported-field SOL smoke gate with conservative FCI parallel "
196
+ "diffusion, open R-Z perpendicular diffusion, localized source, and "
197
+ "edge/endpoint losses. It validates geometry coupling only."
198
+ ),
199
+ }
200
+
201
+
202
+ def save_vmec_extender_sol_smoke_plot(
203
+ grid: VmecExtenderGrid,
204
+ result: VmecExtenderSolSmokeResult,
205
+ report: dict[str, object],
206
+ path: str | Path,
207
+ ) -> Path:
208
+ """Save a compact summary figure for the imported-field SOL smoke gate."""
209
+
210
+ history = np.asarray(result.history, dtype=np.float64)
211
+ time = np.asarray(result.time, dtype=np.float64)
212
+ final = history[-1]
213
+ mass = np.mean(history, axis=(1, 2, 3))
214
+ rms = np.sqrt(np.mean((history - mass[:, None, None, None]) ** 2, axis=(1, 2, 3)))
215
+ R = np.asarray(grid.R, dtype=np.float64)
216
+ Z = np.asarray(grid.Z, dtype=np.float64)
217
+ RR, ZZ = np.meshgrid(R, Z, indexing="ij")
218
+ toroidal_index = 0
219
+
220
+ figure, axes = plt.subplots(1, 3, figsize=(11.2, 3.65), constrained_layout=True)
221
+ image = axes[0].pcolormesh(RR, ZZ, final[:, toroidal_index, :], shading="auto", cmap="viridis")
222
+ axes[0].contour(
223
+ RR,
224
+ ZZ,
225
+ result.source[:, toroidal_index, :],
226
+ levels=4,
227
+ colors="white",
228
+ linewidths=0.7,
229
+ alpha=0.82,
230
+ )
231
+ axes[0].set_title(rf"final scalar, $\phi={float(grid.phi[toroidal_index]):.2f}$", fontsize=10.5)
232
+ axes[0].set_xlabel("R")
233
+ axes[0].set_ylabel("Z")
234
+ figure.colorbar(image, ax=axes[0], shrink=0.86, label="density proxy")
235
+
236
+ axes[1].plot(time, mass, color="#005f73", lw=2.0, label="mean")
237
+ axes[1].plot(time, rms, color="#bb3e03", lw=2.0, label="RMS")
238
+ axes[1].plot(time, np.min(history, axis=(1, 2, 3)), color="#6a4c93", lw=1.7, label="min")
239
+ style_axis(axes[1], title="global traces", xlabel="time", ylabel="scalar metric")
240
+ axes[1].legend(frameon=False, fontsize=8.0)
241
+
242
+ metric_names = ["mode\ndecay", "map\nidentity", "mass\nchange", "endpoint\nfraction"]
243
+ mode_error = report["parallel_mode_decay_relative_error"]
244
+ metric_values = np.asarray(
245
+ [
246
+ np.nan if mode_error is None else float(mode_error),
247
+ report["fci_map_identity_max_abs_error"],
248
+ abs(float(report["mass_relative_change"])),
249
+ max(report["endpoint_fraction"], 1.0e-16),
250
+ ],
251
+ dtype=np.float64,
252
+ )
253
+ finite_metrics = np.where(np.isfinite(metric_values), np.maximum(metric_values, 1.0e-16), np.nan)
254
+ axes[2].bar(np.arange(len(metric_names)), finite_metrics, color="#0a9396", alpha=0.92)
255
+ axes[2].set_xticks(np.arange(len(metric_names)), metric_names)
256
+ style_axis(axes[2], title="validation metrics", ylabel="value", yscale="log")
257
+ axes[2].tick_params(axis="x", labelsize=8.0)
258
+ axes[2].text(
259
+ 0.96,
260
+ 0.94,
261
+ "passed" if report["passed"] else "failed",
262
+ transform=axes[2].transAxes,
263
+ va="top",
264
+ ha="right",
265
+ fontsize=9.0,
266
+ bbox={"facecolor": "white", "alpha": 0.9, "edgecolor": "#d9d9d9", "linewidth": 0.6},
267
+ )
268
+ figure.suptitle("VMEC-extender imported-field SOL smoke gate", fontsize=12.5, fontweight="semibold")
269
+ save_publication_figure(figure, path)
270
+ return Path(path)
271
+
272
+
273
+ def _mesh(grid: VmecExtenderGrid) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
274
+ return jnp.meshgrid(grid.R, grid.phi, grid.Z, indexing="ij")
275
+
276
+
277
+ def _normalised(values: jnp.ndarray) -> jnp.ndarray:
278
+ return (values - jnp.min(values)) / jnp.maximum(jnp.max(values) - jnp.min(values), 1.0e-30)
279
+
280
+
281
+ def _normalised_symmetric(values: jnp.ndarray) -> jnp.ndarray:
282
+ centred = values - 0.5 * (jnp.max(values) + jnp.min(values))
283
+ return centred / jnp.maximum(0.5 * (jnp.max(values) - jnp.min(values)), 1.0e-30)
284
+
285
+
286
+ def _laplace_open_RZ(values: jnp.ndarray, *, dR: float, dZ: float) -> jnp.ndarray:
287
+ return _second_derivative_zero_flux(values, spacing=float(dR), axis=0) + _second_derivative_zero_flux(
288
+ values,
289
+ spacing=float(dZ),
290
+ axis=2,
291
+ )
292
+
293
+
294
+ def _second_derivative_zero_flux(values: jnp.ndarray, *, spacing: float, axis: int) -> jnp.ndarray:
295
+ h2 = float(spacing) * float(spacing)
296
+ centred = (jnp.roll(values, -1, axis=axis) - 2.0 * values + jnp.roll(values, 1, axis=axis)) / h2
297
+ first = _axis_index(axis, 0)
298
+ second = _axis_index(axis, 1)
299
+ last = _axis_index(axis, -1)
300
+ penultimate = _axis_index(axis, -2)
301
+ first_value = 2.0 * (values[second] - values[first]) / h2
302
+ last_value = 2.0 * (values[penultimate] - values[last]) / h2
303
+ return centred.at[first].set(first_value).at[last].set(last_value)
304
+
305
+
306
+ def _axis_index(axis: int, index: int) -> tuple[object, object, object]:
307
+ slices: list[object] = [slice(None), slice(None), slice(None)]
308
+ slices[int(axis)] = index
309
+ return tuple(slices)
310
+
311
+
312
+ def _fci_map_identity_max_abs_error(maps: FciMaps) -> float:
313
+ nx, ny, nz = maps.shape
314
+ x = jnp.broadcast_to(jnp.arange(nx, dtype=jnp.float64)[:, None, None], (nx, ny, nz))
315
+ z = jnp.broadcast_to(jnp.arange(nz, dtype=jnp.float64)[None, None, :], (nx, ny, nz))
316
+ error = jnp.max(
317
+ jnp.asarray(
318
+ [
319
+ jnp.max(jnp.abs(maps.forward_x - x)),
320
+ jnp.max(jnp.abs(maps.backward_x - x)),
321
+ jnp.max(jnp.abs(maps.forward_z - z)),
322
+ jnp.max(jnp.abs(maps.backward_z - z)),
323
+ ]
324
+ )
325
+ )
326
+ return float(error)
327
+
328
+
329
+ def _parallel_mode_decay_relative_error(
330
+ grid: VmecExtenderGrid,
331
+ maps: FciMaps,
332
+ *,
333
+ diffusivity: float,
334
+ mode: int = 1,
335
+ dt: float = 0.004,
336
+ steps: int = 12,
337
+ ) -> float | None:
338
+ if _fci_map_identity_max_abs_error(maps) > 1.0e-10:
339
+ return None
340
+ if bool(np.any(np.asarray(maps.forward_boundary | maps.backward_boundary, dtype=bool))):
341
+ return None
342
+ phi = np.asarray(grid.phi, dtype=np.float64)
343
+ if phi.size < 4:
344
+ return None
345
+ dphi_values = np.diff(phi)
346
+ if float(np.max(np.abs(dphi_values - np.mean(dphi_values)))) > 1.0e-12:
347
+ return None
348
+ _, P, _ = _mesh(grid)
349
+ phase = 2.0 * np.pi * int(mode) * (P - grid.phi[0]) / float(grid.phi_period)
350
+ basis = jnp.cos(phase)
351
+ state = basis
352
+ coefficient = jnp.ones_like(state) * float(diffusivity)
353
+ R, _, _ = _mesh(grid)
354
+ jacobian = jnp.broadcast_to(R, state.shape)
355
+ for _ in range(int(steps)):
356
+ state = state + float(dt) * conservative_parallel_diffusion_fci(
357
+ state,
358
+ coefficient,
359
+ maps,
360
+ jacobian=jacobian,
361
+ )
362
+ amplitude = jnp.sum(state * basis) / jnp.maximum(jnp.sum(basis * basis), 1.0e-30)
363
+ eigenvalue = -4.0 * np.sin(np.pi * int(mode) / int(phi.size)) ** 2 / (float(maps.dphi) ** 2)
364
+ expected = (1.0 + float(dt) * float(diffusivity) * eigenvalue) ** int(steps)
365
+ return float(jnp.abs(amplitude - expected) / max(abs(expected), 1.0e-30))