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,4622 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, fields
4
+ from functools import lru_cache
5
+ from typing import Callable
6
+ import jax
7
+ import jax.numpy as jnp
8
+ from jax import lax
9
+
10
+
11
+ _pytree_base = jax.tree_util.register_pytree_node_class
12
+
13
+ def _normalize_same_shape_fields(instance, field_names: tuple[str, ...], *, expected_shape: tuple[int, ...], label: str) -> None:
14
+ for name in field_names:
15
+ value = jnp.asarray(getattr(instance, name), dtype=jnp.float64)
16
+ if value.shape != expected_shape:
17
+ raise ValueError(f"{label}.{name} must have shape {expected_shape}, got {value.shape}")
18
+ object.__setattr__(instance, name, value)
19
+
20
+ class _DataclassPyTreeMixin:
21
+ """Generic PyTree support for frozen dataclasses.
22
+ All dataclass fields with init=True are treated as dynamic PyTree children.
23
+ Computed fields with init=False are rebuilt in __post_init__.
24
+ """
25
+ def tree_flatten(self):
26
+ children = tuple(getattr(self, f.name) for f in fields(self) if f.init)
27
+ return children, None
28
+
29
+ @classmethod
30
+ def tree_unflatten(cls, aux_data, children):
31
+ init_names = [f.name for f in fields(cls) if f.init]
32
+ return cls(**dict(zip(init_names, children)))
33
+
34
+ def _as_float_array(value, name: str):
35
+ return jnp.asarray(value, dtype=jnp.float64)
36
+
37
+ def _require_shape(value, expected_shape: tuple[int, ...], name: str):
38
+ arr = jnp.asarray(value)
39
+ if arr.shape != expected_shape:
40
+ raise ValueError(f"{name} must have shape {expected_shape}, got {arr.shape}")
41
+ return arr
42
+
43
+ def _require_float_shape(value, expected_shape: tuple[int, ...], name: str):
44
+ arr = jnp.asarray(value, dtype=jnp.float64)
45
+ if arr.shape != expected_shape:
46
+ raise ValueError(f"{name} must have shape {expected_shape}, got {arr.shape}")
47
+ return arr
48
+
49
+
50
+ def _normalize_periodic_axes(
51
+ periodic_axes: tuple[bool | None, bool | None, bool | None] | None,
52
+ *,
53
+ default: tuple[bool, bool, bool] = (False, True, True),
54
+ ) -> tuple[bool, bool, bool]:
55
+ if periodic_axes is None:
56
+ periodic_axes = default
57
+ if len(periodic_axes) != 3:
58
+ raise ValueError(f"periodic_axes must have length 3, got {periodic_axes}")
59
+ return tuple(False if axis is None else bool(axis) for axis in periodic_axes)
60
+
61
+ def _metric_from_components(
62
+ g11: jnp.ndarray,
63
+ g22: jnp.ndarray,
64
+ g33: jnp.ndarray,
65
+ g12: jnp.ndarray,
66
+ g13: jnp.ndarray,
67
+ g23: jnp.ndarray,
68
+ ) -> jnp.ndarray:
69
+ return jnp.stack(
70
+ [
71
+ jnp.stack([g11, g12, g13], axis=-1),
72
+ jnp.stack([g12, g22, g23], axis=-1),
73
+ jnp.stack([g13, g23, g33], axis=-1),
74
+ ],
75
+ axis=-2,
76
+ )
77
+
78
+ def _bmag_from_contravariant_components(
79
+ B_contra: jnp.ndarray,
80
+ g_cov: jnp.ndarray,
81
+ ) -> jnp.ndarray:
82
+ bmag_sq = jnp.einsum("...i,...ij,...j->...", B_contra, g_cov, B_contra)
83
+ return jnp.sqrt(jnp.maximum(bmag_sq, 0.0))
84
+
85
+ def logical_grid_from_axis_vectors(
86
+ x_axis: jnp.ndarray,
87
+ y_axis: jnp.ndarray,
88
+ z_axis: jnp.ndarray,
89
+ ) -> jnp.ndarray:
90
+ x = jnp.asarray(x_axis, dtype=jnp.float64)
91
+ y = jnp.asarray(y_axis, dtype=jnp.float64)
92
+ z = jnp.asarray(z_axis, dtype=jnp.float64)
93
+ xx = jnp.broadcast_to(x[:, None, None], (x.size, y.size, z.size))
94
+ yy = jnp.broadcast_to(y[None, :, None], (x.size, y.size, z.size))
95
+ zz = jnp.broadcast_to(z[None, None, :], (x.size, y.size, z.size))
96
+ return jnp.stack((xx, yy, zz), axis=-1)
97
+
98
+
99
+ @_pytree_base
100
+ @dataclass(frozen=True)
101
+ class Grid1D(_DataclassPyTreeMixin):
102
+ centers: jnp.ndarray # (n,)
103
+ faces: jnp.ndarray # (n + 1,)
104
+ def __post_init__(self) -> None:
105
+ centers = _as_float_array(self.centers, "centers")
106
+ faces = _as_float_array(self.faces, "faces")
107
+ if centers.ndim != 1:
108
+ raise ValueError(f"centers must be one-dimensional, got {centers.shape}")
109
+ if faces.ndim != 1:
110
+ raise ValueError(f"faces must be one-dimensional, got {faces.shape}")
111
+ if faces.size != centers.size + 1:
112
+ raise ValueError(
113
+ f"faces must have length centers.size + 1; got centers={centers.shape}, faces={faces.shape}"
114
+ )
115
+ object.__setattr__(self, "centers", centers)
116
+ object.__setattr__(self, "faces", faces)
117
+
118
+ @classmethod
119
+ def from_centers(cls, centers: jnp.ndarray) -> "Grid1D":
120
+ centers = jnp.asarray(centers, dtype=jnp.float64)
121
+ if centers.ndim != 1:
122
+ raise ValueError(f"centers must be one-dimensional, got {centers.shape}")
123
+ if centers.size == 0:
124
+ raise ValueError("centers must contain at least one point")
125
+ if centers.size == 1:
126
+ spacing = jnp.asarray(1.0, dtype=jnp.float64)
127
+ faces = jnp.array([centers[0] - 0.5 * spacing, centers[0] + 0.5 * spacing], dtype=jnp.float64)
128
+ else:
129
+ faces = jnp.empty(centers.size + 1, dtype=jnp.float64)
130
+ faces = faces.at[1:-1].set(0.5 * (centers[:-1] + centers[1:]))
131
+ faces = faces.at[0].set(centers[0] - 0.5 * (centers[1] - centers[0]))
132
+ faces = faces.at[-1].set(centers[-1] + 0.5 * (centers[-1] - centers[-2]))
133
+ return cls(centers=centers, faces=faces)
134
+
135
+ @property
136
+ def n(self) -> int:
137
+ return int(self.centers.size)
138
+
139
+ @property
140
+ def widths(self) -> jnp.ndarray:
141
+ return self.faces[1:] - self.faces[:-1]
142
+
143
+ @property
144
+ def center_deltas(self) -> jnp.ndarray:
145
+ return self.centers[1:] - self.centers[:-1]
146
+
147
+ @property
148
+ def lower_center_to_face(self):
149
+ return self.centers[0] - self.faces[0]
150
+
151
+ @property
152
+ def upper_center_to_face(self):
153
+ return self.faces[-1] - self.centers[-1]
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class HaloLayout3D:
158
+ """Shared halo metadata for shard-local 3D geometry."""
159
+
160
+ owned_shape: tuple[int, int, int]
161
+ halo_width: int
162
+
163
+ def __post_init__(self) -> None:
164
+ owned_shape = tuple(int(v) for v in self.owned_shape)
165
+ if len(owned_shape) != 3:
166
+ raise ValueError(f"HaloLayout3D.owned_shape must have length 3, got {owned_shape}")
167
+ if any(size <= 0 for size in owned_shape):
168
+ raise ValueError(f"HaloLayout3D.owned_shape must contain positive integers, got {owned_shape}")
169
+ halo_width = int(self.halo_width)
170
+ if halo_width < 0:
171
+ raise ValueError(f"HaloLayout3D.halo_width must be non-negative, got {halo_width}")
172
+ object.__setattr__(self, "owned_shape", owned_shape)
173
+ object.__setattr__(self, "halo_width", halo_width)
174
+
175
+ @property
176
+ def cell_halo_shape(self) -> tuple[int, int, int]:
177
+ h = self.halo_width
178
+ nx, ny, nz = self.owned_shape
179
+ return nx + 2 * h, ny + 2 * h, nz + 2 * h
180
+
181
+ @property
182
+ def owned_slices_cell(self) -> tuple[slice, slice, slice]:
183
+ h = self.halo_width
184
+ nx, ny, nz = self.owned_shape
185
+ return (
186
+ slice(h, h + nx),
187
+ slice(h, h + ny),
188
+ slice(h, h + nz),
189
+ )
190
+
191
+ def face_halo_shape(self, axis: int) -> tuple[int, int, int]:
192
+ axis = int(axis)
193
+ if axis < 0 or axis > 2:
194
+ raise ValueError(f"axis must be 0, 1, or 2, got {axis}")
195
+ shape = list(self.cell_halo_shape)
196
+ shape[axis] += 1
197
+ return tuple(shape)
198
+
199
+ def face_control_shape(self, axis: int) -> tuple[int, int, int]:
200
+ axis = int(axis)
201
+ if axis < 0 or axis > 2:
202
+ raise ValueError(f"axis must be 0, 1, or 2, got {axis}")
203
+ nx, ny, nz = self.owned_shape
204
+ shape = [nx, ny, nz]
205
+ shape[axis] += 1
206
+ return tuple(shape)
207
+
208
+ def face_control_slices(self, axis: int) -> tuple[slice, slice, slice]:
209
+ axis = int(axis)
210
+ if axis < 0 or axis > 2:
211
+ raise ValueError(f"axis must be 0, 1, or 2, got {axis}")
212
+ h = self.halo_width
213
+ nx, ny, nz = self.owned_shape
214
+ extents = [nx, ny, nz]
215
+ extents[axis] += 1
216
+ return tuple(slice(h, h + n) for n in extents)
217
+
218
+ def location_halo_shape(self, location: str) -> tuple[int, int, int]:
219
+ if location == "cell":
220
+ return self.cell_halo_shape
221
+ if location == "x_face":
222
+ return self.face_halo_shape(0)
223
+ if location == "y_face":
224
+ return self.face_halo_shape(1)
225
+ if location == "z_face":
226
+ return self.face_halo_shape(2)
227
+ raise ValueError(
228
+ 'location must be one of "cell", "x_face", "y_face", or "z_face", '
229
+ f"got {location!r}"
230
+ )
231
+
232
+ def location_owned_slices(self, location: str) -> tuple[slice, slice, slice]:
233
+ if location == "cell":
234
+ return self.owned_slices_cell
235
+ if location == "x_face":
236
+ return self.face_control_slices(0)
237
+ if location == "y_face":
238
+ return self.face_control_slices(1)
239
+ if location == "z_face":
240
+ return self.face_control_slices(2)
241
+ raise ValueError(
242
+ 'location must be one of "cell", "x_face", "y_face", or "z_face", '
243
+ f"got {location!r}"
244
+ )
245
+
246
+ def location_owned_shape(self, location: str) -> tuple[int, int, int]:
247
+ if location == "cell":
248
+ return self.owned_shape
249
+ if location == "x_face":
250
+ return self.face_control_shape(0)
251
+ if location == "y_face":
252
+ return self.face_control_shape(1)
253
+ if location == "z_face":
254
+ return self.face_control_shape(2)
255
+ raise ValueError(
256
+ 'location must be one of "cell", "x_face", "y_face", or "z_face", '
257
+ f"got {location!r}"
258
+ )
259
+
260
+
261
+ @_pytree_base
262
+ @dataclass(frozen=True)
263
+ class LocalGrid1D:
264
+ """Shard-local 1D grid with owned and halo coordinate storage."""
265
+
266
+ layout: HaloLayout3D
267
+ axis: int
268
+ centers_halo: jnp.ndarray
269
+ faces_halo: jnp.ndarray
270
+ owned_start_global: int
271
+ owned_stop_global: int
272
+
273
+ def __post_init__(self) -> None:
274
+ if not isinstance(self.layout, HaloLayout3D):
275
+ raise TypeError("layout must be a HaloLayout3D instance")
276
+ axis = int(self.axis)
277
+ if axis < 0 or axis > 2:
278
+ raise ValueError(f"axis must be 0, 1, or 2, got {axis}")
279
+ centers_halo = _as_float_array(self.centers_halo, "centers_halo")
280
+ faces_halo = _as_float_array(self.faces_halo, "faces_halo")
281
+ if centers_halo.ndim != 1:
282
+ raise ValueError(f"centers_halo must be one-dimensional, got {centers_halo.shape}")
283
+ if faces_halo.ndim != 1:
284
+ raise ValueError(f"faces_halo must be one-dimensional, got {faces_halo.shape}")
285
+
286
+ owned_start_global = int(self.owned_start_global)
287
+ owned_stop_global = int(self.owned_stop_global)
288
+ halo_width = self.layout.halo_width
289
+ if owned_start_global < 0:
290
+ raise ValueError(f"owned_start_global must be non-negative, got {owned_start_global}")
291
+ if owned_stop_global < owned_start_global:
292
+ raise ValueError(
293
+ "owned_stop_global must be greater than or equal to owned_start_global, "
294
+ f"got start={owned_start_global}, stop={owned_stop_global}"
295
+ )
296
+ owned_size = self.layout.owned_shape[axis]
297
+ if owned_stop_global - owned_start_global != owned_size:
298
+ raise ValueError(
299
+ "owned_start_global and owned_stop_global must span the owned size from the layout; "
300
+ f"axis={axis}, expected size={owned_size}, got start={owned_start_global}, stop={owned_stop_global}"
301
+ )
302
+ expected_center_size = owned_size + 2 * halo_width
303
+ expected_face_size = expected_center_size + 1
304
+ if centers_halo.size != expected_center_size:
305
+ raise ValueError(
306
+ "centers_halo must contain owned cells plus both halo layers; "
307
+ f"expected {expected_center_size}, got {centers_halo.size}"
308
+ )
309
+ if faces_halo.size != expected_face_size:
310
+ raise ValueError(
311
+ "faces_halo must contain one more entry than centers_halo; "
312
+ f"expected {expected_face_size}, got {faces_halo.size}"
313
+ )
314
+
315
+ object.__setattr__(self, "centers_halo", centers_halo)
316
+ object.__setattr__(self, "faces_halo", faces_halo)
317
+ object.__setattr__(self, "axis", axis)
318
+ object.__setattr__(self, "owned_start_global", owned_start_global)
319
+ object.__setattr__(self, "owned_stop_global", owned_stop_global)
320
+
321
+ @property
322
+ def centers(self) -> jnp.ndarray:
323
+ return self.centers_halo
324
+
325
+ @property
326
+ def faces(self) -> jnp.ndarray:
327
+ return self.faces_halo
328
+
329
+ @property
330
+ def n(self) -> int:
331
+ return self.n_local
332
+
333
+ @property
334
+ def n_owned(self) -> int:
335
+ return self.layout.owned_shape[self.axis]
336
+
337
+ @property
338
+ def n_halo(self) -> int:
339
+ return self.layout.halo_width
340
+
341
+ @property
342
+ def n_local(self) -> int:
343
+ return int(self.centers_halo.size)
344
+
345
+ @property
346
+ def shape(self) -> tuple[int]:
347
+ return (self.n_local,)
348
+
349
+ @property
350
+ def halo_start_global(self) -> int:
351
+ return self.owned_start_global - self.layout.halo_width
352
+
353
+ @property
354
+ def halo_stop_global(self) -> int:
355
+ return self.owned_stop_global + self.layout.halo_width
356
+
357
+ @property
358
+ def owned_center_slice(self) -> slice:
359
+ h = self.layout.halo_width
360
+ return slice(h, h + self.n_owned)
361
+
362
+ @property
363
+ def owned_face_slice(self) -> slice:
364
+ h = self.layout.halo_width
365
+ return slice(h, h + self.n_owned + 1)
366
+
367
+ @property
368
+ def centers_owned(self) -> jnp.ndarray:
369
+ return self.centers_halo[self.owned_center_slice]
370
+
371
+ @property
372
+ def faces_owned(self) -> jnp.ndarray:
373
+ return self.faces_halo[self.owned_face_slice]
374
+
375
+ @property
376
+ def widths(self) -> jnp.ndarray:
377
+ return self.faces_halo[1:] - self.faces_halo[:-1]
378
+
379
+ @property
380
+ def center_deltas(self) -> jnp.ndarray:
381
+ return self.centers_halo[1:] - self.centers_halo[:-1]
382
+
383
+ @property
384
+ def lower_center_to_face(self):
385
+ return self.centers_halo[0] - self.faces_halo[0]
386
+
387
+ @property
388
+ def upper_center_to_face(self):
389
+ return self.faces_halo[-1] - self.centers_halo[-1]
390
+
391
+ def tree_flatten(self):
392
+ return (
393
+ (self.centers_halo, self.faces_halo),
394
+ (self.layout, self.axis, self.owned_start_global, self.owned_stop_global),
395
+ )
396
+
397
+ @classmethod
398
+ def tree_unflatten(cls, aux_data, children):
399
+ layout, axis, owned_start_global, owned_stop_global = aux_data
400
+ centers_halo, faces_halo = children
401
+ return cls(
402
+ layout=layout,
403
+ axis=axis,
404
+ centers_halo=centers_halo,
405
+ faces_halo=faces_halo,
406
+ owned_start_global=owned_start_global,
407
+ owned_stop_global=owned_stop_global,
408
+ )
409
+
410
+ @_pytree_base
411
+ @dataclass(frozen=True)
412
+ class CellCenteredGrid3D(_DataclassPyTreeMixin):
413
+ x: Grid1D
414
+ y: Grid1D
415
+ z: Grid1D
416
+
417
+ @property
418
+ def shape(self) -> tuple[int, int, int]:
419
+ return self.x.n, self.y.n, self.z.n
420
+
421
+ @property
422
+ def x_centers(self) -> jnp.ndarray:
423
+ return self.x.centers
424
+
425
+ @property
426
+ def y_centers(self) -> jnp.ndarray:
427
+ return self.y.centers
428
+
429
+ @property
430
+ def z_centers(self) -> jnp.ndarray:
431
+ return self.z.centers
432
+
433
+ @property
434
+ def x_faces(self) -> jnp.ndarray:
435
+ return self.x.faces
436
+
437
+ @property
438
+ def y_faces(self) -> jnp.ndarray:
439
+ return self.y.faces
440
+
441
+ @property
442
+ def z_faces(self) -> jnp.ndarray:
443
+ return self.z.faces
444
+
445
+ @property
446
+ def logical_axis_vectors(self) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
447
+ return self.x.centers, self.y.centers, self.z.centers
448
+
449
+ @property
450
+ def logical_face_vectors(self) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
451
+ return self.x.faces, self.y.faces, self.z.faces
452
+
453
+
454
+ @_pytree_base
455
+ @dataclass(frozen=True)
456
+ class LocalCellCenteredGrid3D(_DataclassPyTreeMixin):
457
+ """Shard-local cell-centered grid with halo-aware 1D axes."""
458
+
459
+ layout: HaloLayout3D
460
+ x: LocalGrid1D
461
+ y: LocalGrid1D
462
+ z: LocalGrid1D
463
+
464
+ def __post_init__(self) -> None:
465
+ if not isinstance(self.layout, HaloLayout3D):
466
+ raise TypeError("layout must be a HaloLayout3D instance")
467
+ if not isinstance(self.x, LocalGrid1D):
468
+ raise TypeError("x must be a LocalGrid1D instance")
469
+ if not isinstance(self.y, LocalGrid1D):
470
+ raise TypeError("y must be a LocalGrid1D instance")
471
+ if not isinstance(self.z, LocalGrid1D):
472
+ raise TypeError("z must be a LocalGrid1D instance")
473
+ if self.x.layout != self.layout or self.y.layout != self.layout or self.z.layout != self.layout:
474
+ raise ValueError("LocalCellCenteredGrid3D axes must share the same HaloLayout3D")
475
+ if self.x.axis != 0 or self.y.axis != 1 or self.z.axis != 2:
476
+ raise ValueError("LocalCellCenteredGrid3D axes must be ordered as x=0, y=1, z=2")
477
+
478
+ @property
479
+ def shape(self) -> tuple[int, int, int]:
480
+ return self.layout.cell_halo_shape
481
+
482
+ @property
483
+ def owned_shape(self) -> tuple[int, int, int]:
484
+ return self.layout.owned_shape
485
+
486
+ @property
487
+ def halo_shape(self) -> tuple[int, int, int]:
488
+ return self.shape
489
+
490
+ @property
491
+ def local_owned_shape(self) -> tuple[int, int, int]:
492
+ return self.owned_shape
493
+
494
+ @property
495
+ def owned_slices_in_halo(self) -> tuple[slice, slice, slice]:
496
+ return self.layout.owned_slices_cell
497
+
498
+ @property
499
+ def local_halo_shape(self) -> tuple[int, int, int]:
500
+ return self.shape
501
+
502
+ @property
503
+ def x_centers(self) -> jnp.ndarray:
504
+ return self.x.centers
505
+
506
+ @property
507
+ def y_centers(self) -> jnp.ndarray:
508
+ return self.y.centers
509
+
510
+ @property
511
+ def z_centers(self) -> jnp.ndarray:
512
+ return self.z.centers
513
+
514
+ @property
515
+ def x_faces(self) -> jnp.ndarray:
516
+ return self.x.faces
517
+
518
+ @property
519
+ def y_faces(self) -> jnp.ndarray:
520
+ return self.y.faces
521
+
522
+ @property
523
+ def z_faces(self) -> jnp.ndarray:
524
+ return self.z.faces
525
+
526
+ @property
527
+ def logical_axis_vectors(self) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
528
+ return self.x.centers, self.y.centers, self.z.centers
529
+
530
+ @property
531
+ def logical_face_vectors(self) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
532
+ return self.x.faces, self.y.faces, self.z.faces
533
+
534
+ @property
535
+ def x_centers_owned(self) -> jnp.ndarray:
536
+ return self.x.centers_owned
537
+
538
+ @property
539
+ def y_centers_owned(self) -> jnp.ndarray:
540
+ return self.y.centers_owned
541
+
542
+ @property
543
+ def z_centers_owned(self) -> jnp.ndarray:
544
+ return self.z.centers_owned
545
+
546
+ @_pytree_base
547
+ @dataclass(frozen=True)
548
+ class FciMaps3D(_DataclassPyTreeMixin):
549
+ # Fractional interpolation indices into cell-centered field arrays.
550
+ # These are meaningful for non-boundary traces. For boundary traces,
551
+ # use the boundary mask and endpoint coordinates instead.
552
+ forward_x: jnp.ndarray
553
+ forward_y: jnp.ndarray
554
+ backward_x: jnp.ndarray
555
+ backward_y: jnp.ndarray
556
+
557
+ # Logical endpoint coordinates of the trace.
558
+ # If boundary=False: endpoint is on the target toroidal plane.
559
+ # If boundary=True: endpoint is the estimated physical boundary hit point.
560
+ forward_endpoint_x: jnp.ndarray
561
+ forward_endpoint_y: jnp.ndarray
562
+ forward_endpoint_z: jnp.ndarray
563
+ backward_endpoint_x: jnp.ndarray
564
+ backward_endpoint_y: jnp.ndarray
565
+ backward_endpoint_z: jnp.ndarray
566
+
567
+ # Physical arclengths from cell center to endpoint.
568
+ forward_length: jnp.ndarray
569
+ backward_length: jnp.ndarray
570
+
571
+ # True if the trace hit/exited a nonperiodic physical boundary before
572
+ # reaching the target toroidal plane.
573
+ forward_boundary: jnp.ndarray
574
+ backward_boundary: jnp.ndarray
575
+
576
+ def __post_init__(self) -> None:
577
+ forward_x = jnp.asarray(self.forward_x, dtype=jnp.float64)
578
+ shape = tuple(int(v) for v in forward_x.shape)
579
+
580
+ if len(shape) != 3:
581
+ raise ValueError(f"FciMaps3D fields must have shape (nx, ny, nz), got {shape}")
582
+
583
+ object.__setattr__(self, "forward_x", forward_x)
584
+
585
+ float_fields = (
586
+ "forward_y",
587
+ "backward_x",
588
+ "backward_y",
589
+ "forward_endpoint_x",
590
+ "forward_endpoint_y",
591
+ "forward_endpoint_z",
592
+ "backward_endpoint_x",
593
+ "backward_endpoint_y",
594
+ "backward_endpoint_z",
595
+ "forward_length",
596
+ "backward_length",
597
+ )
598
+ bool_fields = (
599
+ "forward_boundary",
600
+ "backward_boundary",
601
+ )
602
+ for name in float_fields:
603
+ value = _require_float_shape(getattr(self, name), shape, f"FciMaps3D.{name}")
604
+ object.__setattr__(self, name, value)
605
+ for name in bool_fields:
606
+ value = jnp.asarray(getattr(self, name), dtype=bool)
607
+ if value.shape != shape:
608
+ raise ValueError(f"FciMaps3D.{name} must have shape {shape}, got {value.shape}")
609
+ object.__setattr__(self, name, value)
610
+
611
+ @property
612
+ def shape(self) -> tuple[int, int, int]:
613
+ return tuple(int(v) for v in self.forward_x.shape)
614
+
615
+
616
+ # Dependency kinds are shared by local and remote FCI dependency metadata.
617
+ FCI_DEP_INVALID = 0
618
+ FCI_DEP_FIELD_INTERIOR = 1
619
+ FCI_DEP_PHYSICAL_BOUNDARY = 2
620
+ FCI_DEP_CUT_WALL = 3
621
+
622
+
623
+ @_pytree_base
624
+ @dataclass(frozen=True)
625
+ class LocalFciLocalDependencyTable(_DataclassPyTreeMixin):
626
+ """Sparse interpolation rows that can be satisfied locally.
627
+
628
+ Interior rows read from the local field halo. Boundary and cut-wall rows
629
+ may instead use a prepared value identified by ``value_slot``. The rows
630
+ are padded to a fixed maximum length so the object stays JAX compilation
631
+ friendly. Only the ``active`` rows participate in interpolation.
632
+ """
633
+
634
+ target_flat: jnp.ndarray # (max_entries,)
635
+ source_i: jnp.ndarray # (max_entries,)
636
+ source_j: jnp.ndarray # (max_entries,)
637
+ source_k: jnp.ndarray # (max_entries,)
638
+ weight: jnp.ndarray # (max_entries,)
639
+ active: jnp.ndarray # (max_entries,)
640
+ dependency_kind: jnp.ndarray | None = None # (max_entries,), int32
641
+ value_slot: jnp.ndarray | None = None # (max_entries,), int32
642
+
643
+ def __post_init__(self) -> None:
644
+ target_flat = jnp.asarray(self.target_flat, dtype=jnp.int32)
645
+ shape = tuple(int(v) for v in target_flat.shape)
646
+ if target_flat.ndim != 1:
647
+ raise ValueError(f"LocalFciLocalDependencyTable.target_flat must be 1D, got {target_flat.shape}")
648
+ object.__setattr__(self, "target_flat", target_flat)
649
+ for name in ("source_i", "source_j", "source_k"):
650
+ object.__setattr__(self, name, _require_shape(getattr(self, name), shape, f"LocalFciLocalDependencyTable.{name}"))
651
+ object.__setattr__(self, "weight", _require_float_shape(self.weight, shape, "LocalFciLocalDependencyTable.weight"))
652
+ active = jnp.asarray(self.active, dtype=bool)
653
+ if active.shape != shape:
654
+ raise ValueError(f"LocalFciLocalDependencyTable.active must have shape {shape}, got {active.shape}")
655
+ object.__setattr__(self, "active", active)
656
+
657
+ if self.dependency_kind is None:
658
+ dependency_kind = jnp.full(shape, FCI_DEP_FIELD_INTERIOR, dtype=jnp.int32)
659
+ else:
660
+ dependency_kind = jnp.asarray(self.dependency_kind, dtype=jnp.int32)
661
+ if dependency_kind.shape != shape:
662
+ raise ValueError(
663
+ "LocalFciLocalDependencyTable.dependency_kind must have "
664
+ f"shape {shape}, got {dependency_kind.shape}"
665
+ )
666
+ object.__setattr__(self, "dependency_kind", dependency_kind)
667
+
668
+ if self.value_slot is None:
669
+ value_slot = jnp.zeros(shape, dtype=jnp.int32)
670
+ else:
671
+ value_slot = jnp.asarray(self.value_slot, dtype=jnp.int32)
672
+ if value_slot.shape != shape:
673
+ raise ValueError(
674
+ "LocalFciLocalDependencyTable.value_slot must have "
675
+ f"shape {shape}, got {value_slot.shape}"
676
+ )
677
+ object.__setattr__(self, "value_slot", value_slot)
678
+
679
+ @property
680
+ def max_entries(self) -> int:
681
+ return int(self.target_flat.size)
682
+
683
+ @_pytree_base
684
+ @dataclass(frozen=True)
685
+ class LocalFciRemoteDependencyTable(_DataclassPyTreeMixin):
686
+ """Sparse FCI interpolation rows satisfied by remote value exchange.
687
+
688
+ This table has two logical parts:
689
+
690
+ 1. Interpolation rows, length max_entries
691
+ These are used by LocalFciStencilBuilder.
692
+
693
+ endpoint[target_flat[r]] += weight[r] * remote_values[receive_slot[r]]
694
+
695
+ 2. Request rows, length max_receive_values
696
+ These are used by RemoteFciDependencyExchange.
697
+
698
+ remote_values[q] = value requested by request row q
699
+
700
+ Consolidation convention
701
+ ------------------------
702
+
703
+ The request row index is the receive slot:
704
+
705
+ remote_values[q] contains the scalar returned for request row q
706
+
707
+ Therefore:
708
+
709
+ receive_slot[r] points directly to one request row q.
710
+
711
+ This lets multiple interpolation rows reuse the same remote scalar by sharing
712
+ the same receive_slot.
713
+
714
+ Request dependency kinds tell the owner/source shard what kind of scalar to
715
+ return:
716
+
717
+ FCI_DEP_FIELD_INTERIOR:
718
+ return a field value from the owner shard's local/halo field data.
719
+
720
+ FCI_DEP_PHYSICAL_BOUNDARY:
721
+ return a prepared physical-boundary value from the owner shard.
722
+
723
+ FCI_DEP_CUT_WALL:
724
+ return a prepared cut-wall value from the owner shard.
725
+
726
+ The stencil builder does not interpret request_dependency_kind. It only uses
727
+ target_flat, weight, receive_slot, and active. The exchange object uses the
728
+ request_* arrays.
729
+ """
730
+
731
+ # -------------------------------------------------------------------------
732
+ # Interpolation rows on the requesting shard.
733
+ # Shape: (max_entries,)
734
+ # Used by LocalFciStencilBuilder.
735
+ # -------------------------------------------------------------------------
736
+
737
+ target_flat: jnp.ndarray
738
+ weight: jnp.ndarray
739
+ receive_slot: jnp.ndarray
740
+ active: jnp.ndarray
741
+
742
+ # -------------------------------------------------------------------------
743
+ # Request rows.
744
+ # Shape: (max_receive_values,)
745
+ # Used by RemoteFciDependencyExchange.
746
+ #
747
+ # Consolidation convention:
748
+ #
749
+ # remote_values[q] corresponds to request row q
750
+ #
751
+ # Therefore active interpolation rows must satisfy:
752
+ #
753
+ # 0 <= receive_slot[r] < max_receive_values
754
+ # -------------------------------------------------------------------------
755
+
756
+ request_active: jnp.ndarray
757
+ request_dependency_kind: jnp.ndarray
758
+
759
+ request_source_global_i: jnp.ndarray
760
+ request_source_global_j: jnp.ndarray
761
+ request_source_global_k: jnp.ndarray
762
+
763
+ request_source_shard_index: jnp.ndarray # (max_receive_values, 3)
764
+ request_source_shard_linear: jnp.ndarray
765
+
766
+ request_source_owner_local_i: jnp.ndarray
767
+ request_source_owner_local_j: jnp.ndarray
768
+ request_source_owner_local_k: jnp.ndarray
769
+
770
+ # Used for PHYSICAL_BOUNDARY / CUT_WALL requests.
771
+ # Dummy zero for FIELD_INTERIOR requests.
772
+ request_value_slot: jnp.ndarray
773
+
774
+ def __post_init__(self) -> None:
775
+ # ---------------------------------------------------------------------
776
+ # Interpolation-row arrays.
777
+ # ---------------------------------------------------------------------
778
+ target_flat = jnp.asarray(self.target_flat, dtype=jnp.int32)
779
+ row_shape = tuple(int(v) for v in target_flat.shape)
780
+
781
+ if target_flat.ndim != 1:
782
+ raise ValueError(
783
+ "LocalFciRemoteDependencyTable.target_flat must be 1D, "
784
+ f"got {target_flat.shape}"
785
+ )
786
+
787
+ object.__setattr__(self, "target_flat", target_flat)
788
+
789
+ object.__setattr__(
790
+ self,
791
+ "weight",
792
+ _require_float_shape(
793
+ self.weight,
794
+ row_shape,
795
+ "LocalFciRemoteDependencyTable.weight",
796
+ ),
797
+ )
798
+
799
+ object.__setattr__(
800
+ self,
801
+ "receive_slot",
802
+ _require_shape(
803
+ self.receive_slot,
804
+ row_shape,
805
+ "LocalFciRemoteDependencyTable.receive_slot",
806
+ ),
807
+ )
808
+
809
+ active = jnp.asarray(self.active, dtype=bool)
810
+ if active.shape != row_shape:
811
+ raise ValueError(
812
+ "LocalFciRemoteDependencyTable.active must have shape "
813
+ f"{row_shape}, got {active.shape}"
814
+ )
815
+ object.__setattr__(self, "active", active)
816
+
817
+ # ---------------------------------------------------------------------
818
+ # Request-row arrays.
819
+ # ---------------------------------------------------------------------
820
+ request_active = jnp.asarray(self.request_active, dtype=bool)
821
+ request_shape = tuple(int(v) for v in request_active.shape)
822
+
823
+ if request_active.ndim != 1:
824
+ raise ValueError(
825
+ "LocalFciRemoteDependencyTable.request_active must be 1D, "
826
+ f"got {request_active.shape}"
827
+ )
828
+
829
+ object.__setattr__(self, "request_active", request_active)
830
+
831
+ object.__setattr__(
832
+ self,
833
+ "request_dependency_kind",
834
+ _require_shape(
835
+ self.request_dependency_kind,
836
+ request_shape,
837
+ "LocalFciRemoteDependencyTable.request_dependency_kind",
838
+ ),
839
+ )
840
+
841
+ for name in (
842
+ "request_source_global_i",
843
+ "request_source_global_j",
844
+ "request_source_global_k",
845
+ "request_source_shard_linear",
846
+ "request_source_owner_local_i",
847
+ "request_source_owner_local_j",
848
+ "request_source_owner_local_k",
849
+ "request_value_slot",
850
+ ):
851
+ object.__setattr__(
852
+ self,
853
+ name,
854
+ _require_shape(
855
+ getattr(self, name),
856
+ request_shape,
857
+ f"LocalFciRemoteDependencyTable.{name}",
858
+ ),
859
+ )
860
+
861
+ request_source_shard_index = jnp.asarray(
862
+ self.request_source_shard_index,
863
+ dtype=jnp.int32,
864
+ )
865
+
866
+ if (
867
+ request_source_shard_index.ndim != 2
868
+ or request_source_shard_index.shape[1] != 3
869
+ ):
870
+ raise ValueError(
871
+ "LocalFciRemoteDependencyTable.request_source_shard_index "
872
+ "must have shape (max_receive_values, 3), got "
873
+ f"{request_source_shard_index.shape}"
874
+ )
875
+
876
+ if int(request_source_shard_index.shape[0]) != request_shape[0]:
877
+ raise ValueError(
878
+ "LocalFciRemoteDependencyTable.request_source_shard_index "
879
+ "must match request_active length; got "
880
+ f"{request_source_shard_index.shape[0]}, "
881
+ f"expected {request_shape[0]}"
882
+ )
883
+
884
+ object.__setattr__(
885
+ self,
886
+ "request_source_shard_index",
887
+ request_source_shard_index,
888
+ )
889
+
890
+ @property
891
+ def max_entries(self) -> int:
892
+ """Maximum number of interpolation rows."""
893
+ return int(self.target_flat.size)
894
+
895
+ @property
896
+ def max_receive_values(self) -> int:
897
+ """Maximum number of requested/received scalar values."""
898
+ return int(self.request_active.size)
899
+
900
+ @property
901
+ def has_requests(self) -> bool:
902
+ """Static-size table may still contain no active requests at runtime."""
903
+ return self.max_receive_values > 0
904
+
905
+
906
+
907
+ @_pytree_base
908
+ @dataclass(frozen=True)
909
+ class LocalFciDirectionMap(_DataclassPyTreeMixin):
910
+ """One directional FCI dependency map for owned target cells."""
911
+
912
+ layout: HaloLayout3D
913
+ local: LocalFciLocalDependencyTable
914
+ remote: LocalFciRemoteDependencyTable | None = None
915
+ target_valid: jnp.ndarray | None = None # (nx_owned, ny_owned, nz_owned)
916
+ connection_length: jnp.ndarray | None = None # (nx_owned, ny_owned, nz_owned)
917
+ endpoint_kind: jnp.ndarray | None = None # (nx_owned, ny_owned, nz_owned), int32
918
+
919
+ def __post_init__(self) -> None:
920
+ if not isinstance(self.layout, HaloLayout3D):
921
+ raise TypeError("layout must be a HaloLayout3D instance")
922
+ if not isinstance(self.local, LocalFciLocalDependencyTable):
923
+ raise TypeError("local must be a LocalFciLocalDependencyTable instance")
924
+ if self.remote is not None and not isinstance(self.remote, LocalFciRemoteDependencyTable):
925
+ raise TypeError("remote must be a LocalFciRemoteDependencyTable instance or None")
926
+ if self.target_valid is None:
927
+ target_valid = jnp.ones(self.layout.owned_shape, dtype=bool)
928
+ else:
929
+ target_valid = jnp.asarray(self.target_valid, dtype=bool)
930
+ if target_valid.shape != self.layout.owned_shape:
931
+ raise ValueError(
932
+ "LocalFciDirectionMap.target_valid must match layout.owned_shape; "
933
+ f"got {target_valid.shape}, expected {self.layout.owned_shape}"
934
+ )
935
+ object.__setattr__(self, "target_valid", target_valid)
936
+
937
+ if self.endpoint_kind is None:
938
+ endpoint_kind = jnp.where(
939
+ target_valid,
940
+ FCI_DEP_FIELD_INTERIOR,
941
+ FCI_DEP_INVALID,
942
+ ).astype(jnp.int32)
943
+ else:
944
+ endpoint_kind = jnp.asarray(self.endpoint_kind, dtype=jnp.int32)
945
+ if endpoint_kind.shape != self.layout.owned_shape:
946
+ raise ValueError(
947
+ "LocalFciDirectionMap.endpoint_kind must match "
948
+ f"layout.owned_shape; got {endpoint_kind.shape}, expected "
949
+ f"{self.layout.owned_shape}"
950
+ )
951
+ object.__setattr__(self, "endpoint_kind", endpoint_kind)
952
+
953
+ if self.connection_length is not None:
954
+ connection_length = _require_float_shape(
955
+ self.connection_length,
956
+ self.layout.owned_shape,
957
+ "LocalFciDirectionMap.connection_length",
958
+ )
959
+ object.__setattr__(self, "connection_length", connection_length)
960
+
961
+ @property
962
+ def owned_shape(self) -> tuple[int, int, int]:
963
+ return self.layout.owned_shape
964
+
965
+ @property
966
+ def has_remote_dependencies(self) -> bool:
967
+ return self.remote is not None
968
+
969
+ @property
970
+ def has_local_dependencies(self) -> jnp.ndarray:
971
+ return jnp.any(self.local.active)
972
+
973
+
974
+ @_pytree_base
975
+ @dataclass(frozen=True)
976
+ class LocalFciMaps3D(_DataclassPyTreeMixin):
977
+ """Shard-local FCI dependency maps over owned target cells.
978
+
979
+ This is a static description of interpolation dependencies only.
980
+ It does not perform communication and does not own field data.
981
+ """
982
+
983
+ layout: HaloLayout3D
984
+ forward: LocalFciDirectionMap
985
+ backward: LocalFciDirectionMap
986
+ mode: str = "remote_dependencies"
987
+
988
+ def __post_init__(self) -> None:
989
+ if not isinstance(self.layout, HaloLayout3D):
990
+ raise TypeError("layout must be a HaloLayout3D instance")
991
+ if not isinstance(self.forward, LocalFciDirectionMap):
992
+ raise TypeError("forward must be a LocalFciDirectionMap instance")
993
+ if not isinstance(self.backward, LocalFciDirectionMap):
994
+ raise TypeError("backward must be a LocalFciDirectionMap instance")
995
+ if self.forward.layout != self.layout or self.backward.layout != self.layout:
996
+ raise ValueError("LocalFciMaps3D directions must share the same HaloLayout3D")
997
+ mode = str(self.mode)
998
+ if mode not in ("local_halo_only", "remote_dependencies"):
999
+ raise ValueError(
1000
+ 'mode must be either "local_halo_only" or "remote_dependencies", '
1001
+ f"got {mode!r}"
1002
+ )
1003
+ if mode == "local_halo_only" and (self.forward.remote is not None or self.backward.remote is not None):
1004
+ raise ValueError("local_halo_only mode cannot include remote dependency tables")
1005
+ object.__setattr__(self, "mode", mode)
1006
+
1007
+ @property
1008
+ def shape(self) -> tuple[int, int, int]:
1009
+ return self.layout.owned_shape
1010
+
1011
+ @property
1012
+ def owned_shape(self) -> tuple[int, int, int]:
1013
+ return self.layout.owned_shape
1014
+
1015
+ @property
1016
+ def halo_shape(self) -> tuple[int, int, int]:
1017
+ return self.layout.cell_halo_shape
1018
+
1019
+ @property
1020
+ def local_owned_shape(self) -> tuple[int, int, int]:
1021
+ return self.layout.owned_shape
1022
+
1023
+ @property
1024
+ def local_halo_shape(self) -> tuple[int, int, int]:
1025
+ return self.layout.cell_halo_shape
1026
+
1027
+ @property
1028
+ def local_halo_only(self) -> bool:
1029
+ return self.mode == "local_halo_only"
1030
+
1031
+
1032
+ @_pytree_base
1033
+ @dataclass(frozen=True)
1034
+ class LocalFciGeometry3D(_DataclassPyTreeMixin):
1035
+ """Shard-local FCI geometry bundle.
1036
+
1037
+ This is the local counterpart to `FciGeometry3D`.
1038
+ It carries the shard-owned cell grid, the halo-padded local geometry
1039
+ fields, the local FCI dependency maps, and the owned-only conservative
1040
+ measures used by downstream operators.
1041
+ """
1042
+
1043
+ layout: HaloLayout3D
1044
+ grid: LocalCellCenteredGrid3D
1045
+ maps: LocalFciMaps3D
1046
+ spacing: LocalSpacing3D
1047
+ cell_metric: LocalMetricGeometry
1048
+ face_metric: LocalFaceMetricGeometry
1049
+ cell_bfield: LocalBFieldGeometry
1050
+ face_bfield: LocalFaceBFieldGeometry
1051
+ regular_face_geometry: LocalRegularFaceGeometry3D
1052
+ cell_volume_geometry: LocalCellVolumeGeometry3D
1053
+
1054
+ def __post_init__(self) -> None:
1055
+ if not isinstance(self.layout, HaloLayout3D):
1056
+ raise TypeError("layout must be a HaloLayout3D instance")
1057
+ if not isinstance(self.grid, LocalCellCenteredGrid3D):
1058
+ raise TypeError("grid must be a LocalCellCenteredGrid3D instance")
1059
+ if not isinstance(self.maps, LocalFciMaps3D):
1060
+ raise TypeError("maps must be a LocalFciMaps3D instance")
1061
+ if not isinstance(self.spacing, LocalSpacing3D):
1062
+ raise TypeError("spacing must be a LocalSpacing3D instance")
1063
+ if not isinstance(self.cell_metric, LocalMetricGeometry):
1064
+ raise TypeError("cell_metric must be a LocalMetricGeometry instance")
1065
+ if not isinstance(self.face_metric, LocalFaceMetricGeometry):
1066
+ raise TypeError("face_metric must be a LocalFaceMetricGeometry instance")
1067
+ if not isinstance(self.cell_bfield, LocalBFieldGeometry):
1068
+ raise TypeError("cell_bfield must be a LocalBFieldGeometry instance")
1069
+ if not isinstance(self.face_bfield, LocalFaceBFieldGeometry):
1070
+ raise TypeError("face_bfield must be a LocalFaceBFieldGeometry instance")
1071
+ if not isinstance(self.regular_face_geometry, LocalRegularFaceGeometry3D):
1072
+ raise TypeError("regular_face_geometry must be a LocalRegularFaceGeometry3D instance")
1073
+ if not isinstance(self.cell_volume_geometry, LocalCellVolumeGeometry3D):
1074
+ raise TypeError("cell_volume_geometry must be a LocalCellVolumeGeometry3D instance")
1075
+
1076
+ for name, value in (
1077
+ ("grid", self.grid.layout),
1078
+ ("maps", self.maps.layout),
1079
+ ("spacing", self.spacing.layout),
1080
+ ("cell_metric", self.cell_metric.layout),
1081
+ ("face_metric", self.face_metric.layout),
1082
+ ("cell_bfield", self.cell_bfield.layout),
1083
+ ("face_bfield", self.face_bfield.layout),
1084
+ ("regular_face_geometry", self.regular_face_geometry.layout),
1085
+ ("cell_volume_geometry", self.cell_volume_geometry.layout),
1086
+ ):
1087
+ if value != self.layout:
1088
+ raise ValueError(f"LocalFciGeometry3D.{name} must share the same HaloLayout3D")
1089
+
1090
+ @property
1091
+ def shape(self) -> tuple[int, int, int]:
1092
+ return self.layout.owned_shape
1093
+
1094
+ @property
1095
+ def owned_shape(self) -> tuple[int, int, int]:
1096
+ return self.layout.owned_shape
1097
+
1098
+ @property
1099
+ def halo_shape(self) -> tuple[int, int, int]:
1100
+ return self.layout.cell_halo_shape
1101
+
1102
+ @property
1103
+ def local_owned_shape(self) -> tuple[int, int, int]:
1104
+ return self.layout.owned_shape
1105
+
1106
+ @property
1107
+ def local_halo_shape(self) -> tuple[int, int, int]:
1108
+ return self.layout.cell_halo_shape
1109
+
1110
+ @property
1111
+ def x_centers(self) -> jnp.ndarray:
1112
+ return self.grid.x_centers
1113
+
1114
+ @property
1115
+ def y_centers(self) -> jnp.ndarray:
1116
+ return self.grid.y_centers
1117
+
1118
+ @property
1119
+ def z_centers(self) -> jnp.ndarray:
1120
+ return self.grid.z_centers
1121
+
1122
+ @property
1123
+ def x_faces(self) -> jnp.ndarray:
1124
+ return self.grid.x_faces
1125
+
1126
+ @property
1127
+ def y_faces(self) -> jnp.ndarray:
1128
+ return self.grid.y_faces
1129
+
1130
+ @property
1131
+ def z_faces(self) -> jnp.ndarray:
1132
+ return self.grid.z_faces
1133
+
1134
+ @property
1135
+ def logical_axis_vectors(self) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
1136
+ return self.grid.logical_axis_vectors
1137
+
1138
+ @property
1139
+ def logical_face_vectors(self) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
1140
+ return self.grid.logical_face_vectors
1141
+
1142
+ @property
1143
+ def x_centers_owned(self) -> jnp.ndarray:
1144
+ return self.grid.x_centers_owned
1145
+
1146
+ @property
1147
+ def y_centers_owned(self) -> jnp.ndarray:
1148
+ return self.grid.y_centers_owned
1149
+
1150
+ @property
1151
+ def z_centers_owned(self) -> jnp.ndarray:
1152
+ return self.grid.z_centers_owned
1153
+
1154
+ @property
1155
+ def cell_volume(self) -> LocalCellVolumeGeometry3D:
1156
+ return self.cell_volume_geometry
1157
+
1158
+ @property
1159
+ def regular_face(self) -> LocalRegularFaceGeometry3D:
1160
+ return self.regular_face_geometry
1161
+
1162
+ NeighborIndex3D = tuple[int, int, int]
1163
+ OptionalNeighborIndex3D = NeighborIndex3D | None
1164
+
1165
+
1166
+ # Meaning of a global lower/upper side in a ShardSpec3D. These values are
1167
+ # metadata for later halo stages; HaloExchange3D only owns regular-neighbor
1168
+ # and SIDE_SIMPLE_PERIODIC data.
1169
+ SIDE_PHYSICAL = 1
1170
+ SIDE_SIMPLE_PERIODIC = 2
1171
+ SIDE_AXIS_REGULAR = 3
1172
+ SIDE_TOPOLOGY_MAPPED = 4
1173
+ SIDE_UNUSED = 5
1174
+ _VALID_SIDE_KINDS = frozenset(
1175
+ {
1176
+ SIDE_PHYSICAL,
1177
+ SIDE_SIMPLE_PERIODIC,
1178
+ SIDE_AXIS_REGULAR,
1179
+ SIDE_TOPOLOGY_MAPPED,
1180
+ SIDE_UNUSED,
1181
+ }
1182
+ )
1183
+
1184
+
1185
+ @_pytree_base
1186
+ @dataclass(frozen=True)
1187
+ class ShardSpec3D(_DataclassPyTreeMixin):
1188
+ """Static metadata describing one shard's owned-cell block."""
1189
+
1190
+ global_shape: tuple[int, int, int]
1191
+ owned_start: tuple[int, int, int]
1192
+ owned_stop: tuple[int, int, int]
1193
+ shard_index: tuple[int, int, int]
1194
+ shard_counts: tuple[int, int, int]
1195
+ periodic_axes: tuple[bool, bool, bool]
1196
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False)
1197
+ halo_width: int = 1
1198
+ side_kind_lower: tuple[int, int, int] | None = None
1199
+ side_kind_upper: tuple[int, int, int] | None = None
1200
+
1201
+ def __post_init__(self) -> None:
1202
+ global_shape = tuple(int(v) for v in self.global_shape)
1203
+ owned_start = tuple(int(v) for v in self.owned_start)
1204
+ owned_stop = tuple(int(v) for v in self.owned_stop)
1205
+ shard_index = tuple(int(v) for v in self.shard_index)
1206
+ shard_counts = tuple(int(v) for v in self.shard_counts)
1207
+ periodic_axes = tuple(bool(v) for v in self.periodic_axes)
1208
+ axis_regular_axes = tuple(bool(v) for v in self.axis_regular_axes)
1209
+ halo_width = int(self.halo_width)
1210
+
1211
+ if self.side_kind_lower is None:
1212
+ side_kind_lower = tuple(
1213
+ SIDE_SIMPLE_PERIODIC if periodic else SIDE_PHYSICAL
1214
+ for periodic in periodic_axes
1215
+ )
1216
+ else:
1217
+ side_kind_lower = tuple(int(v) for v in self.side_kind_lower)
1218
+ if self.side_kind_upper is None:
1219
+ side_kind_upper = tuple(
1220
+ SIDE_SIMPLE_PERIODIC if periodic else SIDE_PHYSICAL
1221
+ for periodic in periodic_axes
1222
+ )
1223
+ else:
1224
+ side_kind_upper = tuple(int(v) for v in self.side_kind_upper)
1225
+
1226
+ for name, value in (
1227
+ ("global_shape", global_shape),
1228
+ ("owned_start", owned_start),
1229
+ ("owned_stop", owned_stop),
1230
+ ("shard_index", shard_index),
1231
+ ("shard_counts", shard_counts),
1232
+ ):
1233
+ if len(value) != 3:
1234
+ raise ValueError(f"ShardSpec3D.{name} must have length 3, got {value}")
1235
+ if len(periodic_axes) != 3:
1236
+ raise ValueError(f"ShardSpec3D.periodic_axes must have length 3, got {periodic_axes}")
1237
+ if len(axis_regular_axes) != 3:
1238
+ raise ValueError(f"ShardSpec3D.axis_regular_axes must have length 3, got {axis_regular_axes}")
1239
+ if len(side_kind_lower) != 3:
1240
+ raise ValueError(f"ShardSpec3D.side_kind_lower must have length 3, got {side_kind_lower}")
1241
+ if len(side_kind_upper) != 3:
1242
+ raise ValueError(f"ShardSpec3D.side_kind_upper must have length 3, got {side_kind_upper}")
1243
+ if any(kind not in _VALID_SIDE_KINDS for kind in side_kind_lower + side_kind_upper):
1244
+ raise ValueError(
1245
+ "ShardSpec3D side kinds must be one of "
1246
+ f"{sorted(_VALID_SIDE_KINDS)}, got lower={side_kind_lower}, "
1247
+ f"upper={side_kind_upper}"
1248
+ )
1249
+ for axis, periodic in enumerate(periodic_axes):
1250
+ if periodic and (
1251
+ side_kind_lower[axis] != SIDE_SIMPLE_PERIODIC
1252
+ or side_kind_upper[axis] != SIDE_SIMPLE_PERIODIC
1253
+ ):
1254
+ raise ValueError(
1255
+ "periodic_axes requires SIDE_SIMPLE_PERIODIC on both global "
1256
+ f"sides; axis={axis}, lower={side_kind_lower[axis]}, "
1257
+ f"upper={side_kind_upper[axis]}"
1258
+ )
1259
+ if any(size <= 0 for size in global_shape):
1260
+ raise ValueError(f"ShardSpec3D.global_shape must contain positive integers, got {global_shape}")
1261
+ if any(start < 0 for start in owned_start):
1262
+ raise ValueError(f"ShardSpec3D.owned_start must be non-negative, got {owned_start}")
1263
+ if any(stop <= start for start, stop in zip(owned_start, owned_stop)):
1264
+ raise ValueError(
1265
+ "ShardSpec3D.owned_stop must be strictly greater than owned_start on every axis; "
1266
+ f"got start={owned_start}, stop={owned_stop}"
1267
+ )
1268
+ if any(stop > size for stop, size in zip(owned_stop, global_shape)):
1269
+ raise ValueError(
1270
+ "ShardSpec3D.owned_stop must not exceed global_shape; "
1271
+ f"got stop={owned_stop}, global_shape={global_shape}"
1272
+ )
1273
+ if any(count <= 0 for count in shard_counts):
1274
+ raise ValueError(f"ShardSpec3D.shard_counts must contain positive integers, got {shard_counts}")
1275
+ if any(index < 0 or index >= count for index, count in zip(shard_index, shard_counts)):
1276
+ raise ValueError(
1277
+ "ShardSpec3D.shard_index must lie within shard_counts; "
1278
+ f"got shard_index={shard_index}, shard_counts={shard_counts}"
1279
+ )
1280
+ if halo_width < 0:
1281
+ raise ValueError(f"ShardSpec3D.halo_width must be non-negative, got {halo_width}")
1282
+
1283
+ object.__setattr__(self, "global_shape", global_shape)
1284
+ object.__setattr__(self, "owned_start", owned_start)
1285
+ object.__setattr__(self, "owned_stop", owned_stop)
1286
+ object.__setattr__(self, "shard_index", shard_index)
1287
+ object.__setattr__(self, "shard_counts", shard_counts)
1288
+ object.__setattr__(self, "periodic_axes", periodic_axes)
1289
+ object.__setattr__(self, "axis_regular_axes", axis_regular_axes)
1290
+ object.__setattr__(self, "halo_width", halo_width)
1291
+ object.__setattr__(self, "side_kind_lower", side_kind_lower)
1292
+ object.__setattr__(self, "side_kind_upper", side_kind_upper)
1293
+
1294
+ @property
1295
+ def owned_shape(self) -> tuple[int, int, int]:
1296
+ return (
1297
+ self.owned_stop[0] - self.owned_start[0],
1298
+ self.owned_stop[1] - self.owned_start[1],
1299
+ self.owned_stop[2] - self.owned_start[2],
1300
+ )
1301
+
1302
+ def touches_lower(self, axis: int) -> bool:
1303
+ """Static host/debug check for a per-shard domain description."""
1304
+ return self.owned_start[int(axis)] == 0
1305
+
1306
+ def touches_upper(self, axis: int) -> bool:
1307
+ """Static host/debug check for a per-shard domain description."""
1308
+ return self.owned_stop[int(axis)] == self.global_shape[int(axis)]
1309
+
1310
+ @staticmethod
1311
+ def _check_axis(axis: int) -> int:
1312
+ axis = int(axis)
1313
+ if axis < 0 or axis > 2:
1314
+ raise ValueError(f"axis must be 0, 1, or 2, got {axis}")
1315
+ return axis
1316
+
1317
+ def lower_side_kind(self, axis: int) -> int:
1318
+ return int(self.side_kind_lower[self._check_axis(axis)])
1319
+
1320
+ def upper_side_kind(self, axis: int) -> int:
1321
+ return int(self.side_kind_upper[self._check_axis(axis)])
1322
+
1323
+ def has_physical_lower(self, axis: int) -> bool:
1324
+ """Static host/debug helper; not runtime SPMD ownership."""
1325
+ axis = int(axis)
1326
+ return self.touches_lower(axis) and self.lower_side_kind(axis) == SIDE_PHYSICAL
1327
+
1328
+ def has_physical_upper(self, axis: int) -> bool:
1329
+ """Static host/debug helper; not runtime SPMD ownership."""
1330
+ axis = int(axis)
1331
+ return self.touches_upper(axis) and self.upper_side_kind(axis) == SIDE_PHYSICAL
1332
+
1333
+ def allows_regular_exchange_lower(self, axis: int) -> bool:
1334
+ axis = self._check_axis(axis)
1335
+ return not self.touches_lower(axis) or self.lower_side_kind(axis) == SIDE_SIMPLE_PERIODIC
1336
+
1337
+ def allows_regular_exchange_upper(self, axis: int) -> bool:
1338
+ axis = self._check_axis(axis)
1339
+ return not self.touches_upper(axis) or self.upper_side_kind(axis) == SIDE_SIMPLE_PERIODIC
1340
+
1341
+ def has_topology_lower(self, axis: int) -> bool:
1342
+ return self.touches_lower(axis) and self.lower_side_kind(axis) in (
1343
+ SIDE_SIMPLE_PERIODIC,
1344
+ SIDE_AXIS_REGULAR,
1345
+ SIDE_TOPOLOGY_MAPPED,
1346
+ )
1347
+
1348
+ def has_topology_upper(self, axis: int) -> bool:
1349
+ return self.touches_upper(axis) and self.upper_side_kind(axis) in (
1350
+ SIDE_SIMPLE_PERIODIC,
1351
+ SIDE_AXIS_REGULAR,
1352
+ SIDE_TOPOLOGY_MAPPED,
1353
+ )
1354
+
1355
+ def tree_flatten(self):
1356
+ return (), (
1357
+ self.global_shape,
1358
+ self.owned_start,
1359
+ self.owned_stop,
1360
+ self.shard_index,
1361
+ self.shard_counts,
1362
+ self.periodic_axes,
1363
+ self.axis_regular_axes,
1364
+ self.side_kind_lower,
1365
+ self.side_kind_upper,
1366
+ self.halo_width,
1367
+ )
1368
+
1369
+ @classmethod
1370
+ def tree_unflatten(cls, aux_data, children):
1371
+ del children
1372
+ (
1373
+ global_shape,
1374
+ owned_start,
1375
+ owned_stop,
1376
+ shard_index,
1377
+ shard_counts,
1378
+ periodic_axes,
1379
+ axis_regular_axes,
1380
+ side_kind_lower,
1381
+ side_kind_upper,
1382
+ halo_width,
1383
+ ) = aux_data
1384
+ return cls(
1385
+ global_shape=global_shape,
1386
+ owned_start=owned_start,
1387
+ owned_stop=owned_stop,
1388
+ shard_index=shard_index,
1389
+ shard_counts=shard_counts,
1390
+ periodic_axes=periodic_axes,
1391
+ axis_regular_axes=axis_regular_axes,
1392
+ halo_width=halo_width,
1393
+ side_kind_lower=side_kind_lower,
1394
+ side_kind_upper=side_kind_upper,
1395
+ )
1396
+
1397
+
1398
+ @_pytree_base
1399
+ @dataclass(frozen=True)
1400
+ class NeighborMap3D(_DataclassPyTreeMixin):
1401
+ """Shard-adjacency metadata for one local 3D domain."""
1402
+
1403
+ minus: tuple[OptionalNeighborIndex3D, OptionalNeighborIndex3D, OptionalNeighborIndex3D]
1404
+ plus: tuple[OptionalNeighborIndex3D, OptionalNeighborIndex3D, OptionalNeighborIndex3D]
1405
+
1406
+ def __post_init__(self) -> None:
1407
+ minus = tuple(self.minus)
1408
+ plus = tuple(self.plus)
1409
+ if len(minus) != 3:
1410
+ raise ValueError(f"NeighborMap3D.minus must have length 3, got {minus}")
1411
+ if len(plus) != 3:
1412
+ raise ValueError(f"NeighborMap3D.plus must have length 3, got {plus}")
1413
+ normalized_minus = []
1414
+ normalized_plus = []
1415
+ for name, side, normalized in (
1416
+ ("minus", minus, normalized_minus),
1417
+ ("plus", plus, normalized_plus),
1418
+ ):
1419
+ for entry in side:
1420
+ if entry is None:
1421
+ normalized.append(None)
1422
+ continue
1423
+ if len(entry) != 3:
1424
+ raise ValueError(f"NeighborMap3D.{name} entries must be length-3 tuples or None, got {entry}")
1425
+ normalized.append(tuple(int(v) for v in entry))
1426
+ object.__setattr__(self, "minus", tuple(normalized_minus))
1427
+ object.__setattr__(self, "plus", tuple(normalized_plus))
1428
+
1429
+ def tree_flatten(self):
1430
+ return (), (self.minus, self.plus)
1431
+
1432
+ @classmethod
1433
+ def tree_unflatten(cls, aux_data, children):
1434
+ del children
1435
+ minus, plus = aux_data
1436
+ return cls(minus=minus, plus=plus)
1437
+
1438
+
1439
+ @_pytree_base
1440
+ @dataclass(frozen=True)
1441
+ class LocalDomain3D(_DataclassPyTreeMixin):
1442
+ """Metadata for one local shard/domain.
1443
+
1444
+ ``mesh_axis_names`` describes the execution mesh used by SPMD-facing
1445
+ helpers. It is deliberately kept on ``LocalDomain3D`` rather than
1446
+ ``ShardSpec3D`` because collective axis names are execution metadata, not
1447
+ geometric metadata.
1448
+
1449
+ The existing ``touches_*`` and ``has_*`` methods are host/debug helpers
1450
+ based on the static per-shard metadata in ``ShardSpec3D``. Code executing
1451
+ inside ``pmap``/``shard_map`` should use the ``runtime_*`` methods below.
1452
+ """
1453
+
1454
+ shard_spec: ShardSpec3D
1455
+ layout: HaloLayout3D
1456
+ neighbor_map: NeighborMap3D | None = None
1457
+ mesh_axis_names: tuple[str | None, str | None, str | None] = (
1458
+ None,
1459
+ None,
1460
+ None,
1461
+ )
1462
+
1463
+ def __post_init__(self) -> None:
1464
+ if not isinstance(self.shard_spec, ShardSpec3D):
1465
+ raise TypeError("shard_spec must be a ShardSpec3D instance")
1466
+ if not isinstance(self.layout, HaloLayout3D):
1467
+ raise TypeError("layout must be a HaloLayout3D instance")
1468
+ if self.layout.owned_shape != self.shard_spec.owned_shape:
1469
+ raise ValueError(
1470
+ "LocalDomain3D.layout.owned_shape must match shard_spec.owned_shape; "
1471
+ f"got layout={self.layout.owned_shape}, shard_spec={self.shard_spec.owned_shape}"
1472
+ )
1473
+ if self.layout.halo_width != self.shard_spec.halo_width:
1474
+ raise ValueError(
1475
+ "LocalDomain3D.layout.halo_width must match shard_spec.halo_width; "
1476
+ f"got layout={self.layout.halo_width}, shard_spec={self.shard_spec.halo_width}"
1477
+ )
1478
+ if self.neighbor_map is not None and not isinstance(self.neighbor_map, NeighborMap3D):
1479
+ raise TypeError("neighbor_map must be a NeighborMap3D instance or None")
1480
+ mesh_axis_names = tuple(self.mesh_axis_names)
1481
+ if len(mesh_axis_names) != 3:
1482
+ raise ValueError(
1483
+ "LocalDomain3D.mesh_axis_names must have length 3, "
1484
+ f"got {mesh_axis_names}"
1485
+ )
1486
+ for axis, name in enumerate(mesh_axis_names):
1487
+ if name is not None and not isinstance(name, str):
1488
+ raise TypeError(
1489
+ "LocalDomain3D.mesh_axis_names entries must be strings or None; "
1490
+ f"axis={axis}, value={name!r}"
1491
+ )
1492
+ object.__setattr__(self, "mesh_axis_names", mesh_axis_names)
1493
+
1494
+ @property
1495
+ def periodic_axes(self) -> tuple[bool, bool, bool]:
1496
+ return self.shard_spec.periodic_axes
1497
+
1498
+ @property
1499
+ def axis_regular_axes(self) -> tuple[bool, bool, bool]:
1500
+ return self.shard_spec.axis_regular_axes
1501
+
1502
+ @property
1503
+ def owned_shape(self) -> tuple[int, int, int]:
1504
+ return self.shard_spec.owned_shape
1505
+
1506
+ def has_physical_lower(self, axis: int) -> bool:
1507
+ """Static host/debug helper; use ``runtime_has_physical_lower`` in SPMD."""
1508
+ return self.shard_spec.has_physical_lower(axis)
1509
+
1510
+ def has_physical_upper(self, axis: int) -> bool:
1511
+ """Static host/debug helper; use ``runtime_has_physical_upper`` in SPMD."""
1512
+ return self.shard_spec.has_physical_upper(axis)
1513
+
1514
+ def allows_regular_exchange_lower(self, axis: int) -> bool:
1515
+ return self.shard_spec.allows_regular_exchange_lower(axis)
1516
+
1517
+ def allows_regular_exchange_upper(self, axis: int) -> bool:
1518
+ return self.shard_spec.allows_regular_exchange_upper(axis)
1519
+
1520
+ def has_topology_lower(self, axis: int) -> bool:
1521
+ return self.shard_spec.has_topology_lower(axis)
1522
+
1523
+ def has_topology_upper(self, axis: int) -> bool:
1524
+ return self.shard_spec.has_topology_upper(axis)
1525
+
1526
+ def runtime_shard_id(self, axis: int) -> int | jnp.ndarray:
1527
+ """Return the current SPMD shard index for a logical axis.
1528
+
1529
+ An axis without a configured mesh name is treated as undecomposed and
1530
+ returns the Python integer ``0``. A configured name must be valid in
1531
+ the surrounding ``pmap``/``shard_map`` context.
1532
+ """
1533
+
1534
+ axis = int(axis)
1535
+ if axis < 0 or axis > 2:
1536
+ raise ValueError(f"axis must be 0, 1, or 2, got {axis}")
1537
+ name = self.mesh_axis_names[axis]
1538
+ if name is None:
1539
+ return 0
1540
+ return lax.axis_index(name)
1541
+
1542
+ def runtime_touches_lower(self, axis: int) -> bool | jnp.ndarray:
1543
+ axis = int(axis)
1544
+ return self.runtime_shard_id(axis) == 0
1545
+
1546
+ def runtime_touches_upper(self, axis: int) -> bool | jnp.ndarray:
1547
+ axis = int(axis)
1548
+ return self.runtime_shard_id(axis) == self.shard_spec.shard_counts[axis] - 1
1549
+
1550
+ def runtime_has_physical_lower(self, axis: int) -> bool | jnp.ndarray:
1551
+ axis = int(axis)
1552
+ return self.runtime_touches_lower(axis) & (
1553
+ self.shard_spec.lower_side_kind(axis) == SIDE_PHYSICAL
1554
+ )
1555
+
1556
+ def runtime_has_physical_upper(self, axis: int) -> bool | jnp.ndarray:
1557
+ axis = int(axis)
1558
+ return self.runtime_touches_upper(axis) & (
1559
+ self.shard_spec.upper_side_kind(axis) == SIDE_PHYSICAL
1560
+ )
1561
+
1562
+ def runtime_has_axis_regular_lower(self, axis: int) -> bool | jnp.ndarray:
1563
+ axis = int(axis)
1564
+ return self.runtime_touches_lower(axis) & (
1565
+ self.shard_spec.lower_side_kind(axis) == SIDE_AXIS_REGULAR
1566
+ )
1567
+
1568
+ def runtime_has_axis_regular_upper(self, axis: int) -> bool | jnp.ndarray:
1569
+ axis = int(axis)
1570
+ return self.runtime_touches_upper(axis) & (
1571
+ self.shard_spec.upper_side_kind(axis) == SIDE_AXIS_REGULAR
1572
+ )
1573
+
1574
+ def runtime_has_side_kind_lower(
1575
+ self,
1576
+ axis: int,
1577
+ side_kind: int,
1578
+ ) -> bool | jnp.ndarray:
1579
+ axis = int(axis)
1580
+ return self.runtime_touches_lower(axis) & (
1581
+ self.shard_spec.lower_side_kind(axis) == int(side_kind)
1582
+ )
1583
+
1584
+ def runtime_has_side_kind_upper(
1585
+ self,
1586
+ axis: int,
1587
+ side_kind: int,
1588
+ ) -> bool | jnp.ndarray:
1589
+ axis = int(axis)
1590
+ return self.runtime_touches_upper(axis) & (
1591
+ self.shard_spec.upper_side_kind(axis) == int(side_kind)
1592
+ )
1593
+
1594
+ def tree_flatten(self):
1595
+ return (), (
1596
+ self.shard_spec,
1597
+ self.layout,
1598
+ self.neighbor_map,
1599
+ self.mesh_axis_names,
1600
+ )
1601
+
1602
+ @classmethod
1603
+ def tree_unflatten(cls, aux_data, children):
1604
+ del children
1605
+ shard_spec, layout, neighbor_map, mesh_axis_names = aux_data
1606
+ return cls(
1607
+ shard_spec=shard_spec,
1608
+ layout=layout,
1609
+ neighbor_map=neighbor_map,
1610
+ mesh_axis_names=mesh_axis_names,
1611
+ )
1612
+
1613
+
1614
+ @_pytree_base
1615
+ @dataclass(frozen=True)
1616
+ class StencilBuilderContext(_DataclassPyTreeMixin):
1617
+ layout: HaloLayout3D
1618
+ domain: LocalDomain3D | None = None
1619
+ cut_wall_geometry: "LocalCutWallGeometry3D | None" = None
1620
+ cut_wall_bc: "LocalCutWallBC3D | None" = None
1621
+ cut_wall_value_reconstructor: "LocalCutWallValueReconstructor3D | None" = None
1622
+ cut_wall_normal_derivative_constructor: (
1623
+ "LocalCutWallNormalDerivativeConstructor3D | None"
1624
+ ) = None
1625
+
1626
+ def __post_init__(self) -> None:
1627
+ if not isinstance(self.layout, HaloLayout3D):
1628
+ raise TypeError("layout must be a HaloLayout3D instance")
1629
+ if self.domain is not None and self.domain.layout != self.layout:
1630
+ raise ValueError("StencilBuilderContext.domain must share the same layout")
1631
+
1632
+
1633
+ # Backward-compatible aliases. Both historical context names now refer to the
1634
+ # same canonical PyTree type.
1635
+ LocalStencilBuilderContext = StencilBuilderContext
1636
+ ConservativeStencilBuilderContext = StencilBuilderContext
1637
+
1638
+
1639
+ @_pytree_base
1640
+ @dataclass(frozen=True)
1641
+ class Spacing3D(_DataclassPyTreeMixin):
1642
+ """Logical spacings evaluated at active cell centers.
1643
+ These are usually broadcast arrays with shape (nx, ny, nz)."""
1644
+ dx: jnp.ndarray
1645
+ dy: jnp.ndarray
1646
+ dz: jnp.ndarray
1647
+ def __post_init__(self) -> None:
1648
+ dx = jnp.asarray(self.dx, dtype=jnp.float64)
1649
+ if dx.ndim != 3:
1650
+ raise ValueError(f"Spacing3D.dx must have shape (nx, ny, nz), got {dx.shape}")
1651
+ shape = tuple(int(v) for v in dx.shape)
1652
+ object.__setattr__(self, "dx", dx)
1653
+ object.__setattr__(self, "dy", _require_float_shape(self.dy, shape, "Spacing3D.dy"))
1654
+ object.__setattr__(self, "dz", _require_float_shape(self.dz, shape, "Spacing3D.dz"))
1655
+ @property
1656
+ def shape(self) -> tuple[int, int, int]:
1657
+ return tuple(int(v) for v in self.dx.shape)
1658
+
1659
+
1660
+ @_pytree_base
1661
+ @dataclass(frozen=True)
1662
+ class LocalSpacing3D(_DataclassPyTreeMixin):
1663
+ """Shard-local logical spacings with halo and owned views."""
1664
+
1665
+ layout: HaloLayout3D
1666
+ dx_halo: jnp.ndarray # (nx + 2*h, ny + 2*h, nz + 2*h)
1667
+ dy_halo: jnp.ndarray # (nx + 2*h, ny + 2*h, nz + 2*h)
1668
+ dz_halo: jnp.ndarray # (nx + 2*h, ny + 2*h, nz + 2*h)
1669
+
1670
+ def __post_init__(self) -> None:
1671
+ if not isinstance(self.layout, HaloLayout3D):
1672
+ raise TypeError("layout must be a HaloLayout3D instance")
1673
+ dx_halo = jnp.asarray(self.dx_halo, dtype=jnp.float64)
1674
+ if dx_halo.ndim != 3:
1675
+ raise ValueError(f"LocalSpacing3D.dx_halo must have shape (nx, ny, nz), got {dx_halo.shape}")
1676
+ shape = tuple(int(v) for v in dx_halo.shape)
1677
+ if shape != self.layout.cell_halo_shape:
1678
+ raise ValueError(
1679
+ "LocalSpacing3D.dx_halo must match layout.cell_halo_shape; "
1680
+ f"got {shape}, expected {self.layout.cell_halo_shape}"
1681
+ )
1682
+
1683
+ dy_halo = _require_float_shape(self.dy_halo, shape, "LocalSpacing3D.dy_halo")
1684
+ dz_halo = _require_float_shape(self.dz_halo, shape, "LocalSpacing3D.dz_halo")
1685
+
1686
+ object.__setattr__(self, "dx_halo", dx_halo)
1687
+ object.__setattr__(self, "dy_halo", dy_halo)
1688
+ object.__setattr__(self, "dz_halo", dz_halo)
1689
+
1690
+ @property
1691
+ def dx(self) -> jnp.ndarray:
1692
+ return self.dx_halo
1693
+
1694
+ @property
1695
+ def dy(self) -> jnp.ndarray:
1696
+ return self.dy_halo
1697
+
1698
+ @property
1699
+ def dz(self) -> jnp.ndarray:
1700
+ return self.dz_halo
1701
+
1702
+ @property
1703
+ def dx_owned(self) -> jnp.ndarray:
1704
+ return self.dx_halo[self.owned_slices_in_halo]
1705
+
1706
+ @property
1707
+ def dy_owned(self) -> jnp.ndarray:
1708
+ return self.dy_halo[self.owned_slices_in_halo]
1709
+
1710
+ @property
1711
+ def dz_owned(self) -> jnp.ndarray:
1712
+ return self.dz_halo[self.owned_slices_in_halo]
1713
+
1714
+ @property
1715
+ def shape(self) -> tuple[int, int, int]:
1716
+ return tuple(int(v) for v in self.dx_halo.shape)
1717
+
1718
+ @property
1719
+ def halo_shape(self) -> tuple[int, int, int]:
1720
+ return self.shape
1721
+
1722
+ @property
1723
+ def owned_shape(self) -> tuple[int, int, int]:
1724
+ return self.layout.owned_shape
1725
+
1726
+ @property
1727
+ def local_halo_shape(self) -> tuple[int, int, int]:
1728
+ return self.shape
1729
+
1730
+ @property
1731
+ def local_owned_shape(self) -> tuple[int, int, int]:
1732
+ return self.owned_shape
1733
+
1734
+ @property
1735
+ def owned_slices_in_halo(self) -> tuple[slice, slice, slice]:
1736
+ return self.layout.owned_slices_cell
1737
+
1738
+ @_pytree_base
1739
+ @dataclass(frozen=True)
1740
+ class LocalMetricGeometry(_DataclassPyTreeMixin):
1741
+ """Metric coefficients on a local halo-padded cell or face grid.
1742
+
1743
+ These arrays are local arrays, not views into a global MetricGeometry.
1744
+ The `location` metadata determines which local shape convention applies:
1745
+ cell-centered or one of the three face families.
1746
+ """
1747
+ #field_halo shaped arrays
1748
+ layout: HaloLayout3D
1749
+ J_halo: jnp.ndarray
1750
+ g11_halo: jnp.ndarray
1751
+ g22_halo: jnp.ndarray
1752
+ g33_halo: jnp.ndarray
1753
+ g12_halo: jnp.ndarray
1754
+ g13_halo: jnp.ndarray
1755
+ g23_halo: jnp.ndarray
1756
+ g_11_halo: jnp.ndarray
1757
+ g_22_halo: jnp.ndarray
1758
+ g_33_halo: jnp.ndarray
1759
+ g_12_halo: jnp.ndarray
1760
+ g_13_halo: jnp.ndarray
1761
+ g_23_halo: jnp.ndarray
1762
+ location: str # "cell", "x_face", "y_face", or "z_face"
1763
+
1764
+ def __post_init__(self) -> None:
1765
+ if not isinstance(self.layout, HaloLayout3D):
1766
+ raise TypeError("layout must be a HaloLayout3D instance")
1767
+ location = str(self.location)
1768
+ expected_shape = self.layout.location_halo_shape(location)
1769
+ J_halo = jnp.asarray(self.J_halo, dtype=jnp.float64)
1770
+ if J_halo.ndim != 3:
1771
+ raise ValueError(f"LocalMetricGeometry.J_halo must be 3D, got {J_halo.shape}")
1772
+ if tuple(int(v) for v in J_halo.shape) != expected_shape:
1773
+ raise ValueError(
1774
+ "LocalMetricGeometry.J_halo must match the expected halo shape for the location; "
1775
+ f"got {J_halo.shape}, expected {expected_shape} for location={location!r}"
1776
+ )
1777
+
1778
+ object.__setattr__(self, "J_halo", J_halo)
1779
+ for name in (
1780
+ "g11_halo",
1781
+ "g22_halo",
1782
+ "g33_halo",
1783
+ "g12_halo",
1784
+ "g13_halo",
1785
+ "g23_halo",
1786
+ "g_11_halo",
1787
+ "g_22_halo",
1788
+ "g_33_halo",
1789
+ "g_12_halo",
1790
+ "g_13_halo",
1791
+ "g_23_halo",
1792
+ ):
1793
+ object.__setattr__(self, name, _require_float_shape(getattr(self, name), expected_shape, f"LocalMetricGeometry.{name}"))
1794
+ object.__setattr__(self, "location", location)
1795
+
1796
+ @property
1797
+ def shape(self) -> tuple[int, int, int]:
1798
+ return tuple(int(v) for v in self.J_halo.shape)
1799
+
1800
+ @property
1801
+ def halo_shape(self) -> tuple[int, int, int]:
1802
+ return self.shape
1803
+
1804
+ @property
1805
+ def local_halo_shape(self) -> tuple[int, int, int]:
1806
+ return self.shape
1807
+
1808
+ @property
1809
+ def local_owned_shape(self) -> tuple[int, int, int]:
1810
+ return self.layout.location_owned_shape(self.location)
1811
+
1812
+ @property
1813
+ def owned_slices_in_halo(self) -> tuple[slice, slice, slice]:
1814
+ return self.layout.location_owned_slices(self.location)
1815
+
1816
+ @property
1817
+ def J(self) -> jnp.ndarray:
1818
+ return self.J_halo
1819
+
1820
+ @property
1821
+ def g11(self) -> jnp.ndarray:
1822
+ return self.g11_halo
1823
+
1824
+ @property
1825
+ def g22(self) -> jnp.ndarray:
1826
+ return self.g22_halo
1827
+
1828
+ @property
1829
+ def g33(self) -> jnp.ndarray:
1830
+ return self.g33_halo
1831
+
1832
+ @property
1833
+ def g12(self) -> jnp.ndarray:
1834
+ return self.g12_halo
1835
+
1836
+ @property
1837
+ def g13(self) -> jnp.ndarray:
1838
+ return self.g13_halo
1839
+
1840
+ @property
1841
+ def g23(self) -> jnp.ndarray:
1842
+ return self.g23_halo
1843
+
1844
+ @property
1845
+ def g_11(self) -> jnp.ndarray:
1846
+ return self.g_11_halo
1847
+
1848
+ @property
1849
+ def g_22(self) -> jnp.ndarray:
1850
+ return self.g_22_halo
1851
+
1852
+ @property
1853
+ def g_33(self) -> jnp.ndarray:
1854
+ return self.g_33_halo
1855
+
1856
+ @property
1857
+ def g_12(self) -> jnp.ndarray:
1858
+ return self.g_12_halo
1859
+
1860
+ @property
1861
+ def g_13(self) -> jnp.ndarray:
1862
+ return self.g_13_halo
1863
+
1864
+ @property
1865
+ def g_23(self) -> jnp.ndarray:
1866
+ return self.g_23_halo
1867
+
1868
+ @property
1869
+ def g_contra(self) -> jnp.ndarray:
1870
+ return _metric_from_components(self.g11_halo, self.g22_halo, self.g33_halo, self.g12_halo, self.g13_halo, self.g23_halo)
1871
+
1872
+ @property
1873
+ def g_cov(self) -> jnp.ndarray:
1874
+ return _metric_from_components(self.g_11_halo, self.g_22_halo, self.g_33_halo, self.g_12_halo, self.g_13_halo, self.g_23_halo)
1875
+
1876
+ @property
1877
+ def g_contra_owned(self) -> jnp.ndarray:
1878
+ s = self.owned_slices_in_halo
1879
+ return _metric_from_components(
1880
+ self.g11_halo[s],
1881
+ self.g22_halo[s],
1882
+ self.g33_halo[s],
1883
+ self.g12_halo[s],
1884
+ self.g13_halo[s],
1885
+ self.g23_halo[s],
1886
+ )
1887
+
1888
+ @property
1889
+ def g_cov_owned(self) -> jnp.ndarray:
1890
+ s = self.owned_slices_in_halo
1891
+ return _metric_from_components(
1892
+ self.g_11_halo[s],
1893
+ self.g_22_halo[s],
1894
+ self.g_33_halo[s],
1895
+ self.g_12_halo[s],
1896
+ self.g_13_halo[s],
1897
+ self.g_23_halo[s],
1898
+ )
1899
+
1900
+ @property
1901
+ def J_owned(self) -> jnp.ndarray:
1902
+ return self.J_halo[self.owned_slices_in_halo]
1903
+
1904
+ @property
1905
+ def g11_owned(self) -> jnp.ndarray:
1906
+ return self.g11_halo[self.owned_slices_in_halo]
1907
+
1908
+ @property
1909
+ def g22_owned(self) -> jnp.ndarray:
1910
+ return self.g22_halo[self.owned_slices_in_halo]
1911
+
1912
+ @property
1913
+ def g33_owned(self) -> jnp.ndarray:
1914
+ return self.g33_halo[self.owned_slices_in_halo]
1915
+
1916
+ @property
1917
+ def g12_owned(self) -> jnp.ndarray:
1918
+ return self.g12_halo[self.owned_slices_in_halo]
1919
+
1920
+ @property
1921
+ def g13_owned(self) -> jnp.ndarray:
1922
+ return self.g13_halo[self.owned_slices_in_halo]
1923
+
1924
+ @property
1925
+ def g23_owned(self) -> jnp.ndarray:
1926
+ return self.g23_halo[self.owned_slices_in_halo]
1927
+
1928
+ @property
1929
+ def g_11_owned(self) -> jnp.ndarray:
1930
+ return self.g_11_halo[self.owned_slices_in_halo]
1931
+
1932
+ @property
1933
+ def g_22_owned(self) -> jnp.ndarray:
1934
+ return self.g_22_halo[self.owned_slices_in_halo]
1935
+
1936
+ @property
1937
+ def g_33_owned(self) -> jnp.ndarray:
1938
+ return self.g_33_halo[self.owned_slices_in_halo]
1939
+
1940
+ @property
1941
+ def g_12_owned(self) -> jnp.ndarray:
1942
+ return self.g_12_halo[self.owned_slices_in_halo]
1943
+
1944
+ @property
1945
+ def g_13_owned(self) -> jnp.ndarray:
1946
+ return self.g_13_halo[self.owned_slices_in_halo]
1947
+
1948
+ @property
1949
+ def g_23_owned(self) -> jnp.ndarray:
1950
+ return self.g_23_halo[self.owned_slices_in_halo]
1951
+
1952
+ @_pytree_base
1953
+ @dataclass(frozen=True)
1954
+ class MetricGeometry(_DataclassPyTreeMixin):
1955
+ """Metric/Jacobian data on one grid location family.
1956
+ This class is used both for cell centers and for each face family."""
1957
+ J: jnp.ndarray
1958
+ g11: jnp.ndarray
1959
+ g22: jnp.ndarray
1960
+ g33: jnp.ndarray
1961
+ g12: jnp.ndarray
1962
+ g13: jnp.ndarray
1963
+ g23: jnp.ndarray
1964
+ g_11: jnp.ndarray
1965
+ g_22: jnp.ndarray
1966
+ g_33: jnp.ndarray
1967
+ g_12: jnp.ndarray
1968
+ g_13: jnp.ndarray
1969
+ g_23: jnp.ndarray
1970
+ def __post_init__(self) -> None:
1971
+ J = jnp.asarray(self.J, dtype=jnp.float64)
1972
+ if J.ndim != 3:
1973
+ raise ValueError(f"MetricGeometry.J must have shape (a, b, c), got {J.shape}")
1974
+ shape = tuple(int(v) for v in J.shape)
1975
+ object.__setattr__(self, "J", J)
1976
+ for name in ("g11", "g22", "g33", "g12", "g13", "g23", "g_11", "g_22", "g_33", "g_12", "g_13", "g_23"):
1977
+ value = _require_float_shape(getattr(self, name), shape, f"MetricGeometry.{name}")
1978
+ object.__setattr__(self, name, value)
1979
+ @property
1980
+ def shape(self) -> tuple[int, int, int]:
1981
+ return tuple(int(v) for v in self.J.shape)
1982
+ @property
1983
+ def g_contra(self) -> jnp.ndarray:
1984
+ return _metric_from_components(self.g11, self.g22, self.g33, self.g12, self.g13, self.g23)
1985
+ @property
1986
+ def g_cov(self) -> jnp.ndarray:
1987
+ return _metric_from_components(self.g_11, self.g_22, self.g_33, self.g_12, self.g_13, self.g_23)
1988
+
1989
+ @_pytree_base
1990
+ @dataclass(frozen=True)
1991
+ class FaceMetricGeometry(_DataclassPyTreeMixin):
1992
+ x: MetricGeometry # (nx + 1, ny, nz)
1993
+ y: MetricGeometry # (nx, ny + 1, nz)
1994
+ z: MetricGeometry # (nx, ny, nz + 1)
1995
+ @property
1996
+ def axes(self) -> tuple[MetricGeometry, MetricGeometry, MetricGeometry]:
1997
+ return self.x, self.y, self.z
1998
+
1999
+
2000
+ @_pytree_base
2001
+ @dataclass(frozen=True)
2002
+ class LocalFaceMetricGeometry(_DataclassPyTreeMixin):
2003
+ """Local metric bundles on the x/y/z face families.
2004
+
2005
+ Each field stores a halo-padded local metric object for that face family.
2006
+ The shape annotations below describe the expected local array extent
2007
+ when the underlying face family is built from a shard-local owned region
2008
+ with halo width `h`.
2009
+ """
2010
+
2011
+ layout: HaloLayout3D
2012
+ x: LocalMetricGeometry # (nx_owned + 2*h + 1, ny_owned + 2*h, nz_owned + 2*h)
2013
+ y: LocalMetricGeometry # (nx_owned + 2*h, ny_owned + 2*h + 1, nz_owned + 2*h)
2014
+ z: LocalMetricGeometry # (nx_owned + 2*h, ny_owned + 2*h, nz_owned + 2*h + 1)
2015
+
2016
+ def __post_init__(self) -> None:
2017
+ if not isinstance(self.layout, HaloLayout3D):
2018
+ raise TypeError("layout must be a HaloLayout3D instance")
2019
+ if not isinstance(self.x, LocalMetricGeometry):
2020
+ raise TypeError("x must be a LocalMetricGeometry instance")
2021
+ if not isinstance(self.y, LocalMetricGeometry):
2022
+ raise TypeError("y must be a LocalMetricGeometry instance")
2023
+ if not isinstance(self.z, LocalMetricGeometry):
2024
+ raise TypeError("z must be a LocalMetricGeometry instance")
2025
+ if self.x.layout != self.layout or self.y.layout != self.layout or self.z.layout != self.layout:
2026
+ raise ValueError("LocalFaceMetricGeometry axes must share the same HaloLayout3D")
2027
+ if self.x.location != "x_face":
2028
+ raise ValueError(f"LocalFaceMetricGeometry.x.location must be 'x_face', got {self.x.location!r}")
2029
+ if self.y.location != "y_face":
2030
+ raise ValueError(f"LocalFaceMetricGeometry.y.location must be 'y_face', got {self.y.location!r}")
2031
+ if self.z.location != "z_face":
2032
+ raise ValueError(f"LocalFaceMetricGeometry.z.location must be 'z_face', got {self.z.location!r}")
2033
+
2034
+ @property
2035
+ def axes(self) -> tuple[LocalMetricGeometry, LocalMetricGeometry, LocalMetricGeometry]:
2036
+ return self.x, self.y, self.z
2037
+
2038
+ @property
2039
+ def shape(self) -> tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]]:
2040
+ return self.x.shape, self.y.shape, self.z.shape
2041
+
2042
+ @_pytree_base
2043
+ @dataclass(frozen=True)
2044
+ class BFieldGeometry(_DataclassPyTreeMixin):
2045
+ B_contra: jnp.ndarray
2046
+ Bmag: jnp.ndarray
2047
+ def __post_init__(self) -> None:
2048
+ B_contra = jnp.asarray(self.B_contra, dtype=jnp.float64)
2049
+ if B_contra.ndim != 4 or B_contra.shape[-1] != 3:
2050
+ raise ValueError(f"BFieldGeometry.B_contra must have shape (a, b, c, 3), got {B_contra.shape}")
2051
+ shape = tuple(int(v) for v in B_contra.shape[:-1])
2052
+ Bmag = _require_float_shape(self.Bmag, shape, "BFieldGeometry.Bmag")
2053
+ object.__setattr__(self, "B_contra", B_contra)
2054
+ object.__setattr__(self, "Bmag", Bmag)
2055
+
2056
+ @property
2057
+ def shape(self) -> tuple[int, int, int]:
2058
+ return tuple(int(v) for v in self.Bmag.shape)
2059
+
2060
+ @property
2061
+ def b_contra(self) -> jnp.ndarray:
2062
+ return self.B_contra / self.Bmag[..., None]
2063
+
2064
+ @_pytree_base
2065
+ @dataclass(frozen=True)
2066
+ class FaceBFieldGeometry(_DataclassPyTreeMixin):
2067
+ x: BFieldGeometry # (nx + 1, ny, nz, 3)
2068
+ y: BFieldGeometry # (nx, ny + 1, nz, 3)
2069
+ z: BFieldGeometry # (nx, ny, nz + 1, 3)
2070
+
2071
+ @property
2072
+ def axes(self) -> tuple[BFieldGeometry, BFieldGeometry, BFieldGeometry]:
2073
+ return self.x, self.y, self.z
2074
+
2075
+
2076
+ @_pytree_base
2077
+ @dataclass(frozen=True)
2078
+ class LocalBFieldGeometry(_DataclassPyTreeMixin):
2079
+ """Local magnetic field bundle on a halo-padded cell or face grid."""
2080
+
2081
+ layout: HaloLayout3D
2082
+ B_contra_halo: jnp.ndarray # (..., 3) with leading halo_shape / face_halo_shape
2083
+ Bmag_halo: jnp.ndarray # halo_shape / face_halo_shape
2084
+ location: str # "cell", "x_face", "y_face", or "z_face"
2085
+
2086
+ def __post_init__(self) -> None:
2087
+ if not isinstance(self.layout, HaloLayout3D):
2088
+ raise TypeError("layout must be a HaloLayout3D instance")
2089
+ location = str(self.location)
2090
+ expected_shape = self.layout.location_halo_shape(location)
2091
+ B_contra_halo = jnp.asarray(self.B_contra_halo, dtype=jnp.float64)
2092
+ if B_contra_halo.ndim != 4 or B_contra_halo.shape[-1] != 3:
2093
+ raise ValueError(
2094
+ "LocalBFieldGeometry.B_contra_halo must have shape "
2095
+ f"{expected_shape + (3,)}, got {B_contra_halo.shape}"
2096
+ )
2097
+ if tuple(int(v) for v in B_contra_halo.shape[:-1]) != expected_shape:
2098
+ raise ValueError(
2099
+ "LocalBFieldGeometry.B_contra_halo must match the expected halo shape for the location; "
2100
+ f"got {B_contra_halo.shape[:-1]}, expected {expected_shape} for location={location!r}"
2101
+ )
2102
+
2103
+ object.__setattr__(self, "B_contra_halo", B_contra_halo)
2104
+ object.__setattr__(self, "Bmag_halo", _require_float_shape(self.Bmag_halo, expected_shape, "LocalBFieldGeometry.Bmag_halo"))
2105
+ object.__setattr__(self, "location", location)
2106
+
2107
+ @property
2108
+ def shape(self) -> tuple[int, int, int]:
2109
+ return tuple(int(v) for v in self.Bmag_halo.shape)
2110
+
2111
+ @property
2112
+ def halo_shape(self) -> tuple[int, int, int]:
2113
+ return self.shape
2114
+
2115
+ @property
2116
+ def local_halo_shape(self) -> tuple[int, int, int]:
2117
+ return self.shape
2118
+
2119
+ @property
2120
+ def local_owned_shape(self) -> tuple[int, int, int]:
2121
+ return self.layout.location_owned_shape(self.location)
2122
+
2123
+ @property
2124
+ def owned_slices_in_halo(self) -> tuple[slice, slice, slice]:
2125
+ return self.layout.location_owned_slices(self.location)
2126
+
2127
+ @property
2128
+ def B_contra(self) -> jnp.ndarray:
2129
+ return self.B_contra_halo
2130
+
2131
+ @property
2132
+ def Bmag(self) -> jnp.ndarray:
2133
+ return self.Bmag_halo
2134
+
2135
+ @property
2136
+ def b_contra(self) -> jnp.ndarray:
2137
+ return self.B_contra_halo / self.Bmag_halo[..., None]
2138
+
2139
+ @property
2140
+ def B_contra_owned(self) -> jnp.ndarray:
2141
+ return self.B_contra_halo[self.owned_slices_in_halo]
2142
+
2143
+ @property
2144
+ def Bmag_owned(self) -> jnp.ndarray:
2145
+ return self.Bmag_halo[self.owned_slices_in_halo]
2146
+
2147
+
2148
+ @_pytree_base
2149
+ @dataclass(frozen=True)
2150
+ class LocalFaceBFieldGeometry(_DataclassPyTreeMixin):
2151
+ """Local B-field bundles on the x/y/z face families.
2152
+
2153
+ Shape annotations:
2154
+ x: (nx_owned + 2*h + 1, ny_owned + 2*h, nz_owned + 2*h, 3)
2155
+ y: (nx_owned + 2*h, ny_owned + 2*h + 1, nz_owned + 2*h, 3)
2156
+ z: (nx_owned + 2*h, ny_owned + 2*h, nz_owned + 2*h + 1, 3)
2157
+ """
2158
+
2159
+ layout: HaloLayout3D
2160
+ x: LocalBFieldGeometry # (nx_owned + 2*h + 1, ny_owned + 2*h, nz_owned + 2*h, 3)
2161
+ y: LocalBFieldGeometry # (nx_owned + 2*h, ny_owned + 2*h + 1, nz_owned + 2*h, 3)
2162
+ z: LocalBFieldGeometry # (nx_owned + 2*h, ny_owned + 2*h, nz_owned + 2*h + 1, 3)
2163
+
2164
+ def __post_init__(self) -> None:
2165
+ if not isinstance(self.layout, HaloLayout3D):
2166
+ raise TypeError("layout must be a HaloLayout3D instance")
2167
+ if not isinstance(self.x, LocalBFieldGeometry):
2168
+ raise TypeError("x must be a LocalBFieldGeometry instance")
2169
+ if not isinstance(self.y, LocalBFieldGeometry):
2170
+ raise TypeError("y must be a LocalBFieldGeometry instance")
2171
+ if not isinstance(self.z, LocalBFieldGeometry):
2172
+ raise TypeError("z must be a LocalBFieldGeometry instance")
2173
+ if self.x.layout != self.layout or self.y.layout != self.layout or self.z.layout != self.layout:
2174
+ raise ValueError("LocalFaceBFieldGeometry axes must share the same HaloLayout3D")
2175
+ if self.x.location != "x_face":
2176
+ raise ValueError(f"LocalFaceBFieldGeometry.x.location must be 'x_face', got {self.x.location!r}")
2177
+ if self.y.location != "y_face":
2178
+ raise ValueError(f"LocalFaceBFieldGeometry.y.location must be 'y_face', got {self.y.location!r}")
2179
+ if self.z.location != "z_face":
2180
+ raise ValueError(f"LocalFaceBFieldGeometry.z.location must be 'z_face', got {self.z.location!r}")
2181
+
2182
+ @property
2183
+ def axes(self) -> tuple[LocalBFieldGeometry, LocalBFieldGeometry, LocalBFieldGeometry]:
2184
+ return self.x, self.y, self.z
2185
+
2186
+ @property
2187
+ def shape(self) -> tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]]:
2188
+ return self.x.shape, self.y.shape, self.z.shape
2189
+
2190
+
2191
+ @_pytree_base
2192
+ @dataclass(frozen=True)
2193
+ class LocalRegularFaceGeometry3D(_DataclassPyTreeMixin):
2194
+ """Shard-local regular face measures for conservative fluxes.
2195
+
2196
+ Design notes
2197
+ ------------
2198
+ Unlike the reconstruction and metric objects, this is intentionally
2199
+ owned-face only. Conservative flux operators only need the faces that
2200
+ belong to the shard, so we do not store halo-padded face measures here.
2201
+ The shared `HaloLayout3D` still lives on the object so the owned face
2202
+ shapes remain tied to the shard decomposition in one place.
2203
+ """
2204
+
2205
+ layout: HaloLayout3D
2206
+ x_area: jnp.ndarray # (nx_owned + 1, ny_owned, nz_owned)
2207
+ y_area: jnp.ndarray # (nx_owned, ny_owned + 1, nz_owned)
2208
+ z_area: jnp.ndarray # (nx_owned, ny_owned, nz_owned + 1)
2209
+ x_area_fraction: jnp.ndarray # (nx_owned + 1, ny_owned, nz_owned)
2210
+ y_area_fraction: jnp.ndarray # (nx_owned, ny_owned + 1, nz_owned)
2211
+ z_area_fraction: jnp.ndarray # (nx_owned, ny_owned, nz_owned + 1)
2212
+ x_open_mask: jnp.ndarray # (nx_owned + 1, ny_owned, nz_owned)
2213
+ y_open_mask: jnp.ndarray # (nx_owned, ny_owned + 1, nz_owned)
2214
+ z_open_mask: jnp.ndarray # (nx_owned, ny_owned, nz_owned + 1)
2215
+
2216
+ def __post_init__(self) -> None:
2217
+ if not isinstance(self.layout, HaloLayout3D):
2218
+ raise TypeError("layout must be a HaloLayout3D instance")
2219
+
2220
+ expected_x = self.layout.face_control_shape(0)
2221
+ expected_y = self.layout.face_control_shape(1)
2222
+ expected_z = self.layout.face_control_shape(2)
2223
+
2224
+ x_area = jnp.asarray(self.x_area, dtype=jnp.float64)
2225
+ y_area = jnp.asarray(self.y_area, dtype=jnp.float64)
2226
+ z_area = jnp.asarray(self.z_area, dtype=jnp.float64)
2227
+ if x_area.shape != expected_x or y_area.shape != expected_y or z_area.shape != expected_z:
2228
+ raise ValueError(
2229
+ "LocalRegularFaceGeometry3D face shapes must match the owned face layout; "
2230
+ f"expected x={expected_x}, y={expected_y}, z={expected_z}, got "
2231
+ f"x={x_area.shape}, y={y_area.shape}, z={z_area.shape}"
2232
+ )
2233
+
2234
+ object.__setattr__(self, "x_area", x_area)
2235
+ object.__setattr__(self, "y_area", y_area)
2236
+ object.__setattr__(self, "z_area", z_area)
2237
+ object.__setattr__(self, "x_area_fraction", _require_float_shape(self.x_area_fraction, expected_x, "LocalRegularFaceGeometry3D.x_area_fraction"))
2238
+ object.__setattr__(self, "y_area_fraction", _require_float_shape(self.y_area_fraction, expected_y, "LocalRegularFaceGeometry3D.y_area_fraction"))
2239
+ object.__setattr__(self, "z_area_fraction", _require_float_shape(self.z_area_fraction, expected_z, "LocalRegularFaceGeometry3D.z_area_fraction"))
2240
+ for name, shape in (("x_open_mask", expected_x), ("y_open_mask", expected_y), ("z_open_mask", expected_z)):
2241
+ value = jnp.asarray(getattr(self, name), dtype=bool)
2242
+ if value.shape != shape:
2243
+ raise ValueError(f"LocalRegularFaceGeometry3D.{name} must have shape {shape}, got {value.shape}")
2244
+ object.__setattr__(self, name, value)
2245
+
2246
+ @property
2247
+ def axes(self) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
2248
+ return self.x_area, self.y_area, self.z_area
2249
+
2250
+ @property
2251
+ def shape(self) -> tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]]:
2252
+ return self.x_area.shape, self.y_area.shape, self.z_area.shape
2253
+
2254
+ @property
2255
+ def local_owned_shape(self) -> tuple[int, int, int]:
2256
+ return self.layout.owned_shape
2257
+
2258
+ @property
2259
+ def local_halo_shape(self) -> tuple[int, int, int]:
2260
+ return self.layout.cell_halo_shape
2261
+
2262
+
2263
+ @_pytree_base
2264
+ @dataclass(frozen=True)
2265
+ class RegularFaceGeometry3D(_DataclassPyTreeMixin):
2266
+ """Regular coordinate-face measures for conservative fluxes."""
2267
+
2268
+ x_area: jnp.ndarray
2269
+ y_area: jnp.ndarray
2270
+ z_area: jnp.ndarray
2271
+ x_area_fraction: jnp.ndarray
2272
+ y_area_fraction: jnp.ndarray
2273
+ z_area_fraction: jnp.ndarray
2274
+ x_open_mask: jnp.ndarray
2275
+ y_open_mask: jnp.ndarray
2276
+ z_open_mask: jnp.ndarray
2277
+
2278
+ def __post_init__(self) -> None:
2279
+ x_area = jnp.asarray(self.x_area, dtype=jnp.float64)
2280
+ y_area = jnp.asarray(self.y_area, dtype=jnp.float64)
2281
+ z_area = jnp.asarray(self.z_area, dtype=jnp.float64)
2282
+ if x_area.ndim != 3 or y_area.ndim != 3 or z_area.ndim != 3:
2283
+ raise ValueError(
2284
+ "RegularFaceGeometry3D areas must be 3D arrays with face-grid shapes"
2285
+ )
2286
+
2287
+ x_shape = tuple(int(v) for v in x_area.shape)
2288
+ y_shape = tuple(int(v) for v in y_area.shape)
2289
+ z_shape = tuple(int(v) for v in z_area.shape)
2290
+ cell_shape = (x_shape[0] - 1, y_shape[1] - 1, z_shape[2] - 1)
2291
+ expected_x = (cell_shape[0] + 1, cell_shape[1], cell_shape[2])
2292
+ expected_y = (cell_shape[0], cell_shape[1] + 1, cell_shape[2])
2293
+ expected_z = (cell_shape[0], cell_shape[1], cell_shape[2] + 1)
2294
+ if x_shape != expected_x or y_shape != expected_y or z_shape != expected_z:
2295
+ raise ValueError(
2296
+ "RegularFaceGeometry3D face shapes must match the face-grid layout; "
2297
+ f"expected x={expected_x}, y={expected_y}, z={expected_z}, got "
2298
+ f"x={x_shape}, y={y_shape}, z={z_shape}"
2299
+ )
2300
+
2301
+ object.__setattr__(self, "x_area", x_area)
2302
+ object.__setattr__(self, "y_area", y_area)
2303
+ object.__setattr__(self, "z_area", z_area)
2304
+ object.__setattr__(self, "x_area_fraction", _require_float_shape(self.x_area_fraction, x_shape, "RegularFaceGeometry3D.x_area_fraction"))
2305
+ object.__setattr__(self, "y_area_fraction", _require_float_shape(self.y_area_fraction, y_shape, "RegularFaceGeometry3D.y_area_fraction"))
2306
+ object.__setattr__(self, "z_area_fraction", _require_float_shape(self.z_area_fraction, z_shape, "RegularFaceGeometry3D.z_area_fraction"))
2307
+ for name, shape in (("x_open_mask", x_shape), ("y_open_mask", y_shape), ("z_open_mask", z_shape)):
2308
+ value = jnp.asarray(getattr(self, name), dtype=bool)
2309
+ if value.shape != shape:
2310
+ raise ValueError(f"RegularFaceGeometry3D.{name} must have shape {shape}, got {value.shape}")
2311
+ object.__setattr__(self, name, value)
2312
+
2313
+ @classmethod
2314
+ def unit(cls, geometry: "FciGeometry3D") -> "RegularFaceGeometry3D":
2315
+ shape = geometry.shape
2316
+ x_shape = (shape[0] + 1, shape[1], shape[2])
2317
+ y_shape = (shape[0], shape[1] + 1, shape[2])
2318
+ z_shape = (shape[0], shape[1], shape[2] + 1)
2319
+ return cls(
2320
+ x_area=jnp.ones(x_shape, dtype=jnp.float64),
2321
+ y_area=jnp.ones(y_shape, dtype=jnp.float64),
2322
+ z_area=jnp.ones(z_shape, dtype=jnp.float64),
2323
+ x_area_fraction=jnp.ones(x_shape, dtype=jnp.float64),
2324
+ y_area_fraction=jnp.ones(y_shape, dtype=jnp.float64),
2325
+ z_area_fraction=jnp.ones(z_shape, dtype=jnp.float64),
2326
+ x_open_mask=jnp.ones(x_shape, dtype=bool),
2327
+ y_open_mask=jnp.ones(y_shape, dtype=bool),
2328
+ z_open_mask=jnp.ones(z_shape, dtype=bool),
2329
+ )
2330
+
2331
+ @property
2332
+ def shape(self) -> tuple[int, int, int]:
2333
+ return (int(self.x_area.shape[0] - 1), int(self.y_area.shape[1] - 1), int(self.z_area.shape[2] - 1))
2334
+
2335
+
2336
+ @_pytree_base
2337
+ @dataclass(frozen=True)
2338
+ class CellVolumeGeometry3D(_DataclassPyTreeMixin):
2339
+ """Effective cell-volume measure for conservative operators."""
2340
+
2341
+ volume: jnp.ndarray
2342
+ volume_fraction: jnp.ndarray
2343
+
2344
+ def __post_init__(self) -> None:
2345
+ volume = jnp.asarray(self.volume, dtype=jnp.float64)
2346
+ if volume.ndim != 3:
2347
+ raise ValueError(f"CellVolumeGeometry3D.volume must be 3D, got {volume.shape}")
2348
+ shape = tuple(int(v) for v in volume.shape)
2349
+ object.__setattr__(self, "volume", volume)
2350
+ object.__setattr__(self, "volume_fraction", _require_float_shape(self.volume_fraction, shape, "CellVolumeGeometry3D.volume_fraction"))
2351
+
2352
+ @classmethod
2353
+ def unit(cls, geometry: "FciGeometry3D") -> "CellVolumeGeometry3D":
2354
+ volume = jnp.asarray(geometry.cell_metric.J, dtype=jnp.float64)
2355
+ return cls(volume=volume, volume_fraction=jnp.ones_like(volume, dtype=jnp.float64))
2356
+
2357
+ @property
2358
+ def shape(self) -> tuple[int, int, int]:
2359
+ return tuple(int(v) for v in self.volume.shape)
2360
+
2361
+
2362
+ @_pytree_base
2363
+ @dataclass(frozen=True)
2364
+ class LocalCellVolumeGeometry3D(_DataclassPyTreeMixin):
2365
+ """Shard-local cell-volume measure for conservative operators.
2366
+
2367
+ Design notes
2368
+ ------------
2369
+ This object is intentionally owned-cell only.
2370
+ Conservative operators use it to normalize or weight the cells that the
2371
+ shard owns; halo exchange is handled separately by the stencil/field
2372
+ objects, not by the volume measure itself.
2373
+ """
2374
+
2375
+ layout: HaloLayout3D
2376
+ volume: jnp.ndarray # (nx_owned, ny_owned, nz_owned)
2377
+ volume_fraction: jnp.ndarray # (nx_owned, ny_owned, nz_owned)
2378
+
2379
+ def __post_init__(self) -> None:
2380
+ if not isinstance(self.layout, HaloLayout3D):
2381
+ raise TypeError("layout must be a HaloLayout3D instance")
2382
+ expected_shape = self.layout.owned_shape
2383
+ volume = jnp.asarray(self.volume, dtype=jnp.float64)
2384
+ if volume.ndim != 3:
2385
+ raise ValueError(f"LocalCellVolumeGeometry3D.volume must be 3D, got {volume.shape}")
2386
+ if tuple(int(v) for v in volume.shape) != expected_shape:
2387
+ raise ValueError(
2388
+ "LocalCellVolumeGeometry3D.volume must match layout.owned_shape; "
2389
+ f"got {volume.shape}, expected {expected_shape}"
2390
+ )
2391
+ object.__setattr__(self, "volume", volume)
2392
+ object.__setattr__(
2393
+ self,
2394
+ "volume_fraction",
2395
+ _require_float_shape(self.volume_fraction, expected_shape, "LocalCellVolumeGeometry3D.volume_fraction"),
2396
+ )
2397
+
2398
+ @property
2399
+ def shape(self) -> tuple[int, int, int]:
2400
+ return self.layout.owned_shape
2401
+
2402
+ @property
2403
+ def owned_shape(self) -> tuple[int, int, int]:
2404
+ return self.layout.owned_shape
2405
+
2406
+ @property
2407
+ def local_owned_shape(self) -> tuple[int, int, int]:
2408
+ return self.layout.owned_shape
2409
+
2410
+ @property
2411
+ def local_halo_shape(self) -> tuple[int, int, int]:
2412
+ return self.layout.cell_halo_shape
2413
+
2414
+ @property
2415
+ def volume_owned(self) -> jnp.ndarray:
2416
+ return self.volume
2417
+
2418
+ @property
2419
+ def volume_fraction_owned(self) -> jnp.ndarray:
2420
+ return self.volume_fraction
2421
+
2422
+
2423
+ @_pytree_base
2424
+ @dataclass(frozen=True)
2425
+ class FciGeometry3D(_DataclassPyTreeMixin):
2426
+ """Centralized cell-centered FCI geometry payload.
2427
+ Geometry is stored only on active cell centers and physical faces.
2428
+ Computational ghost cells are not part of this object.
2429
+ Native FCI coordinates:
2430
+ (x, y, z) = (radial, poloidal, toroidal)
2431
+ """
2432
+ grid: CellCenteredGrid3D
2433
+ maps: FciMaps3D
2434
+ spacing: Spacing3D
2435
+ cell_metric: MetricGeometry
2436
+ face_metric: FaceMetricGeometry
2437
+ cell_bfield: BFieldGeometry
2438
+ face_bfield: FaceBFieldGeometry
2439
+
2440
+ def __post_init__(self) -> None:
2441
+ shape = self.grid.shape
2442
+ xface_shape = (shape[0] + 1, shape[1], shape[2])
2443
+ yface_shape = (shape[0], shape[1] + 1, shape[2])
2444
+ zface_shape = (shape[0], shape[1], shape[2] + 1)
2445
+ if self.maps.shape != shape:
2446
+ raise ValueError(f"maps shape must be {shape}, got {self.maps.shape}")
2447
+ if self.spacing.shape != shape:
2448
+ raise ValueError(f"spacing shape must be {shape}, got {self.spacing.shape}")
2449
+ if self.cell_metric.shape != shape:
2450
+ raise ValueError(f"cell_metric shape must be {shape}, got {self.cell_metric.shape}")
2451
+ if self.cell_bfield.shape != shape:
2452
+ raise ValueError(f"cell_bfield shape must be {shape}, got {self.cell_bfield.shape}")
2453
+ if self.face_metric.x.shape != xface_shape:
2454
+ raise ValueError(f"face_metric.x shape must be {xface_shape}, got {self.face_metric.x.shape}")
2455
+ if self.face_metric.y.shape != yface_shape:
2456
+ raise ValueError(f"face_metric.y shape must be {yface_shape}, got {self.face_metric.y.shape}")
2457
+ if self.face_metric.z.shape != zface_shape:
2458
+ raise ValueError(f"face_metric.z shape must be {zface_shape}, got {self.face_metric.z.shape}")
2459
+ if self.face_bfield.x.shape != xface_shape:
2460
+ raise ValueError(f"face_bfield.x shape must be {xface_shape}, got {self.face_bfield.x.shape}")
2461
+ if self.face_bfield.y.shape != yface_shape:
2462
+ raise ValueError(f"face_bfield.y shape must be {yface_shape}, got {self.face_bfield.y.shape}")
2463
+ if self.face_bfield.z.shape != zface_shape:
2464
+ raise ValueError(f"face_bfield.z shape must be {zface_shape}, got {self.face_bfield.z.shape}")
2465
+
2466
+ @property
2467
+ def shape(self) -> tuple[int, int, int]:
2468
+ return self.grid.shape
2469
+
2470
+ @property
2471
+ def logical_axis_vectors(self) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
2472
+ return self.grid.logical_axis_vectors
2473
+
2474
+ @property
2475
+ def logical_face_vectors(self) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
2476
+ return self.grid.logical_face_vectors
2477
+
2478
+
2479
+ @lru_cache(maxsize=1)
2480
+ def _stencil_types():
2481
+ from ..native.fci_boundaries import (
2482
+ FaceGradientStencil3D,
2483
+ ConservativeStencil3D,
2484
+ LocalStencil1D,
2485
+ LocalStencil3D,
2486
+ )
2487
+
2488
+ return ConservativeStencil3D, FaceGradientStencil3D, LocalStencil1D, LocalStencil3D
2489
+
2490
+
2491
+ def _shift_owned_slices(layout: HaloLayout3D, axis: int, offset: int) -> tuple[slice, slice, slice]:
2492
+ h = layout.halo_width
2493
+ nx, ny, nz = layout.owned_shape
2494
+ extents = [nx, ny, nz]
2495
+ start = h + offset
2496
+ stop = start + extents[axis]
2497
+ slices = [slice(h, h + ext) for ext in extents]
2498
+ slices[axis] = slice(start, stop)
2499
+ return tuple(slices)
2500
+
2501
+
2502
+ def _local_axis_stencil_from_halo(
2503
+ values_halo: jnp.ndarray,
2504
+ geometry: LocalFciGeometry3D,
2505
+ *,
2506
+ axis: int,
2507
+ ) -> "LocalStencil1D":
2508
+ ConservativeStencil3D, _, LocalStencil1D, _ = _stencil_types()
2509
+ del ConservativeStencil3D
2510
+
2511
+ values_halo = jnp.asarray(values_halo, dtype=jnp.float64)
2512
+ if values_halo.shape != geometry.halo_shape:
2513
+ raise ValueError(
2514
+ "field_halo must have shape geometry.halo_shape; "
2515
+ f"got {values_halo.shape}, expected {geometry.halo_shape}"
2516
+ )
2517
+
2518
+ h = geometry.layout.halo_width
2519
+ owned_shape = geometry.owned_shape
2520
+ if h < 1:
2521
+ raise ValueError("local stencil reconstruction requires halo_width >= 1")
2522
+
2523
+ center = values_halo[geometry.layout.owned_slices_cell]
2524
+ minus = values_halo[_shift_owned_slices(geometry.layout, axis, -1)]
2525
+ plus = values_halo[_shift_owned_slices(geometry.layout, axis, +1)]
2526
+
2527
+ grid_axis = (geometry.grid.x, geometry.grid.y, geometry.grid.z)[axis]
2528
+ centers_halo = jnp.asarray(grid_axis.centers_halo, dtype=jnp.float64)
2529
+ owned_slice = grid_axis.owned_center_slice
2530
+ owned_centers = centers_halo[owned_slice]
2531
+ lower_centers = centers_halo[slice(owned_slice.start - 1, owned_slice.stop - 1)]
2532
+ upper_centers = centers_halo[slice(owned_slice.start + 1, owned_slice.stop + 1)]
2533
+
2534
+ lower_width_1d = owned_centers - lower_centers
2535
+ upper_width_1d = upper_centers - owned_centers
2536
+
2537
+ if axis == 0:
2538
+ dx_min = jnp.broadcast_to(lower_width_1d[:, None, None], owned_shape)
2539
+ dx_plus = jnp.broadcast_to(upper_width_1d[:, None, None], owned_shape)
2540
+ elif axis == 1:
2541
+ dx_min = jnp.broadcast_to(lower_width_1d[None, :, None], owned_shape)
2542
+ dx_plus = jnp.broadcast_to(upper_width_1d[None, :, None], owned_shape)
2543
+ else:
2544
+ dx_min = jnp.broadcast_to(lower_width_1d[None, None, :], owned_shape)
2545
+ dx_plus = jnp.broadcast_to(upper_width_1d[None, None, :], owned_shape)
2546
+
2547
+ return LocalStencil1D(center=center, minus=minus, plus=plus, dx_min=dx_min, dx_plus=dx_plus)
2548
+
2549
+
2550
+ def _lift_cell_field_to_faces(field: jnp.ndarray, *, axis: int, periodic: bool) -> jnp.ndarray:
2551
+ """Map a cell-centered field onto the corresponding face grid along one axis."""
2552
+
2553
+ values_3d = jnp.asarray(field, dtype=jnp.float64)
2554
+ axis_n = values_3d.shape[axis]
2555
+ face_shape = list(values_3d.shape)
2556
+ face_shape[axis] += 1
2557
+
2558
+ if axis_n == 1:
2559
+ return jnp.broadcast_to(values_3d, tuple(face_shape))
2560
+
2561
+ first = jnp.take(values_3d, 0, axis=axis)
2562
+ second = jnp.take(values_3d, 1, axis=axis)
2563
+ last = jnp.take(values_3d, -1, axis=axis)
2564
+ penultimate = jnp.take(values_3d, -2, axis=axis)
2565
+
2566
+ if periodic:
2567
+ lower_ghost = last
2568
+ upper_ghost = first
2569
+ elif axis_n >= 3:
2570
+ # Quadratic (three-point) ghost extrapolation keeps the boundary-face
2571
+ # average reconstruction second-order accurate for curved profiles.
2572
+ third = jnp.take(values_3d, 2, axis=axis)
2573
+ antepenultimate = jnp.take(values_3d, -3, axis=axis)
2574
+ lower_ghost = 3.0 * first - 3.0 * second + third
2575
+ upper_ghost = 3.0 * last - 3.0 * penultimate + antepenultimate
2576
+ else:
2577
+ lower_ghost = 2.0 * first - second
2578
+ upper_ghost = 2.0 * last - penultimate
2579
+
2580
+ ext = jnp.concatenate(
2581
+ (
2582
+ jnp.expand_dims(lower_ghost, axis=axis),
2583
+ values_3d,
2584
+ jnp.expand_dims(upper_ghost, axis=axis),
2585
+ ),
2586
+ axis=axis,
2587
+ )
2588
+ return 0.5 * (
2589
+ jnp.take(ext, jnp.arange(axis_n + 1), axis=axis)
2590
+ + jnp.take(ext, jnp.arange(1, axis_n + 2), axis=axis)
2591
+ )
2592
+
2593
+
2594
+ def _global_axis_stencil_from_field(
2595
+ field: jnp.ndarray,
2596
+ geometry: FciGeometry3D,
2597
+ *,
2598
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
2599
+ ) -> "ConservativeStencil3D":
2600
+ ConservativeStencil3D, FaceGradientStencil3D, LocalStencil1D, _ = _stencil_types()
2601
+
2602
+ values = jnp.asarray(field, dtype=jnp.float64)
2603
+ if values.shape != geometry.shape:
2604
+ raise ValueError(f"field must have shape {geometry.shape}, got {values.shape}")
2605
+
2606
+ periodic_axes = _normalize_periodic_axes(periodic_axes)
2607
+
2608
+ def _face_spacing(field_spacing: jnp.ndarray, *, face_axis: int) -> jnp.ndarray:
2609
+ return _lift_cell_field_to_faces(field_spacing, axis=face_axis, periodic=periodic_axes[face_axis])
2610
+
2611
+ def _face_gradient_for_axis(face_axis: int) -> jnp.ndarray:
2612
+ face_values = _lift_cell_field_to_faces(values, axis=face_axis, periodic=periodic_axes[face_axis])
2613
+ face_spacings = (
2614
+ _face_spacing(geometry.spacing.dx, face_axis=face_axis),
2615
+ _face_spacing(geometry.spacing.dy, face_axis=face_axis),
2616
+ _face_spacing(geometry.spacing.dz, face_axis=face_axis),
2617
+ )
2618
+ components = tuple(
2619
+ _first_derivative_3d(
2620
+ face_values,
2621
+ face_spacings[component],
2622
+ axis=component,
2623
+ periodic=periodic_axes[component],
2624
+ )
2625
+ for component in range(3)
2626
+ )
2627
+ return jnp.stack(components, axis=-1)
2628
+
2629
+ def _axis_stencil(axis: int, grid_axis, periodic: bool) -> LocalStencil1D:
2630
+ axis_n = values.shape[axis]
2631
+ if axis_n == 1:
2632
+ repeated = jnp.broadcast_to(values, geometry.shape)
2633
+ width = jnp.asarray(grid_axis.faces[-1] - grid_axis.faces[0], dtype=jnp.float64)
2634
+ width = jnp.broadcast_to(width, geometry.shape)
2635
+ return LocalStencil1D(center=values, minus=repeated, plus=repeated, dx_min=width, dx_plus=width)
2636
+
2637
+ if periodic:
2638
+ minus = jnp.concatenate(
2639
+ (
2640
+ jnp.expand_dims(jnp.take(values, -1, axis=axis), axis=axis),
2641
+ jnp.take(values, jnp.arange(axis_n - 1), axis=axis),
2642
+ ),
2643
+ axis=axis,
2644
+ )
2645
+ plus = jnp.concatenate(
2646
+ (
2647
+ jnp.take(values, jnp.arange(1, axis_n), axis=axis),
2648
+ jnp.expand_dims(jnp.take(values, 0, axis=axis), axis=axis),
2649
+ ),
2650
+ axis=axis,
2651
+ )
2652
+ period = jnp.asarray(grid_axis.faces[-1] - grid_axis.faces[0], dtype=jnp.float64)
2653
+ deltas = jnp.asarray(grid_axis.centers, dtype=jnp.float64)
2654
+ dx_min_1d = jnp.concatenate((jnp.asarray([deltas[0] - (deltas[-1] - period)], dtype=jnp.float64), deltas[1:] - deltas[:-1]))
2655
+ dx_plus_1d = jnp.concatenate((deltas[1:] - deltas[:-1], jnp.expand_dims((deltas[0] + period) - deltas[-1], axis=0)))
2656
+ else:
2657
+ first = jnp.take(values, 0, axis=axis)
2658
+ second = jnp.take(values, 1, axis=axis)
2659
+ last = jnp.take(values, -1, axis=axis)
2660
+ penultimate = jnp.take(values, -2, axis=axis)
2661
+ minus = jnp.concatenate((jnp.expand_dims(2.0 * first - second, axis=axis), jnp.take(values, jnp.arange(axis_n - 1), axis=axis)), axis=axis)
2662
+ plus = jnp.concatenate((jnp.take(values, jnp.arange(1, axis_n), axis=axis), jnp.expand_dims(2.0 * last - penultimate, axis=axis)), axis=axis)
2663
+ deltas = jnp.asarray(grid_axis.centers, dtype=jnp.float64)
2664
+ lower_width = 2.0 * jnp.asarray(grid_axis.lower_center_to_face, dtype=jnp.float64)
2665
+ upper_width = 2.0 * jnp.asarray(grid_axis.upper_center_to_face, dtype=jnp.float64)
2666
+ dx_min_1d = jnp.concatenate((jnp.expand_dims(lower_width, axis=0), deltas[1:] - deltas[:-1]))
2667
+ dx_plus_1d = jnp.concatenate((deltas[1:] - deltas[:-1], jnp.expand_dims(upper_width, axis=0)))
2668
+
2669
+ if axis == 0:
2670
+ dx_min = jnp.broadcast_to(dx_min_1d[:, None, None], geometry.shape)
2671
+ dx_plus = jnp.broadcast_to(dx_plus_1d[:, None, None], geometry.shape)
2672
+ elif axis == 1:
2673
+ dx_min = jnp.broadcast_to(dx_min_1d[None, :, None], geometry.shape)
2674
+ dx_plus = jnp.broadcast_to(dx_plus_1d[None, :, None], geometry.shape)
2675
+ else:
2676
+ dx_min = jnp.broadcast_to(dx_min_1d[None, None, :], geometry.shape)
2677
+ dx_plus = jnp.broadcast_to(dx_plus_1d[None, None, :], geometry.shape)
2678
+
2679
+ return LocalStencil1D(center=values, minus=minus, plus=plus, dx_min=dx_min, dx_plus=dx_plus)
2680
+
2681
+ return ConservativeStencil3D(
2682
+ x=_axis_stencil(0, geometry.grid.x, periodic_axes[0]),
2683
+ y=_axis_stencil(1, geometry.grid.y, periodic_axes[1]),
2684
+ z=_axis_stencil(2, geometry.grid.z, periodic_axes[2]),
2685
+ face_grad=FaceGradientStencil3D(
2686
+ x=_face_gradient_for_axis(0),
2687
+ y=_face_gradient_for_axis(1),
2688
+ z=_face_gradient_for_axis(2),
2689
+ ),
2690
+ )
2691
+
2692
+
2693
+ def _one_sided_physical_global_axis_stencil(
2694
+ stencil: "LocalStencil1D",
2695
+ values: jnp.ndarray,
2696
+ grid_axis: Grid1D,
2697
+ *,
2698
+ axis: int,
2699
+ ) -> "LocalStencil1D":
2700
+ """Patch global physical side planes with nonuniform three-point formulas."""
2701
+
2702
+ axis = int(axis)
2703
+ axis_n = int(values.shape[axis])
2704
+ if axis_n < 3:
2705
+ return stencil
2706
+
2707
+ centers = jnp.asarray(grid_axis.centers, dtype=jnp.float64)
2708
+
2709
+ minus = jnp.asarray(stencil.minus, dtype=jnp.float64)
2710
+ center = jnp.asarray(stencil.center, dtype=jnp.float64)
2711
+ plus = jnp.asarray(stencil.plus, dtype=jnp.float64)
2712
+ dx_min = jnp.asarray(stencil.dx_min, dtype=jnp.float64)
2713
+ dx_plus = jnp.asarray(stencil.dx_plus, dtype=jnp.float64)
2714
+ c_minus = jnp.asarray(stencil.derivative_minus_weight, dtype=jnp.float64)
2715
+ c_center = jnp.asarray(stencil.derivative_center_weight, dtype=jnp.float64)
2716
+ c_plus = jnp.asarray(stencil.derivative_plus_weight, dtype=jnp.float64)
2717
+
2718
+ lower_plane = _axis_index_nd(axis, 0, values.ndim)
2719
+ lower_f0 = jnp.take(values, 0, axis=axis)
2720
+ lower_f1 = jnp.take(values, 1, axis=axis)
2721
+ lower_f2 = jnp.take(values, 2, axis=axis)
2722
+ lower_weights = _three_point_first_derivative_weights(
2723
+ centers[0],
2724
+ centers[1],
2725
+ centers[2],
2726
+ )
2727
+ minus = minus.at[lower_plane].set(lower_f2)
2728
+ center = center.at[lower_plane].set(lower_f0)
2729
+ plus = plus.at[lower_plane].set(lower_f1)
2730
+ c_minus = c_minus.at[lower_plane].set(jnp.full_like(lower_f0, lower_weights[2]))
2731
+ c_center = c_center.at[lower_plane].set(jnp.full_like(lower_f0, lower_weights[0]))
2732
+ c_plus = c_plus.at[lower_plane].set(jnp.full_like(lower_f0, lower_weights[1]))
2733
+ dx_min = dx_min.at[lower_plane].set(jnp.full_like(lower_f0, jnp.abs(centers[2] - centers[0])))
2734
+ dx_plus = dx_plus.at[lower_plane].set(jnp.full_like(lower_f0, jnp.abs(centers[1] - centers[0])))
2735
+
2736
+ upper_plane = _axis_index_nd(axis, axis_n - 1, values.ndim)
2737
+ upper_f0 = jnp.take(values, axis_n - 1, axis=axis)
2738
+ upper_f1 = jnp.take(values, axis_n - 2, axis=axis)
2739
+ upper_f2 = jnp.take(values, axis_n - 3, axis=axis)
2740
+ upper_weights = _three_point_first_derivative_weights(
2741
+ centers[axis_n - 1],
2742
+ centers[axis_n - 2],
2743
+ centers[axis_n - 3],
2744
+ )
2745
+ minus = minus.at[upper_plane].set(upper_f1)
2746
+ center = center.at[upper_plane].set(upper_f0)
2747
+ plus = plus.at[upper_plane].set(upper_f2)
2748
+ c_minus = c_minus.at[upper_plane].set(jnp.full_like(upper_f0, upper_weights[1]))
2749
+ c_center = c_center.at[upper_plane].set(jnp.full_like(upper_f0, upper_weights[0]))
2750
+ c_plus = c_plus.at[upper_plane].set(jnp.full_like(upper_f0, upper_weights[2]))
2751
+ dx_min = dx_min.at[upper_plane].set(jnp.full_like(upper_f0, jnp.abs(centers[axis_n - 2] - centers[axis_n - 1])))
2752
+ dx_plus = dx_plus.at[upper_plane].set(jnp.full_like(upper_f0, jnp.abs(centers[axis_n - 3] - centers[axis_n - 1])))
2753
+
2754
+ return stencil.replace(
2755
+ minus=minus,
2756
+ center=center,
2757
+ plus=plus,
2758
+ dx_min=dx_min,
2759
+ dx_plus=dx_plus,
2760
+ derivative_minus_weight=c_minus,
2761
+ derivative_center_weight=c_center,
2762
+ derivative_plus_weight=c_plus,
2763
+ )
2764
+
2765
+
2766
+ def _build_conservative_stencil_from_field(
2767
+ field_halo: jnp.ndarray,
2768
+ geometry: LocalFciGeometry3D | FciGeometry3D,
2769
+ context: StencilBuilderContext | tuple[bool | None, bool | None, bool | None] | None = None,
2770
+ face_bc: object | None = None,
2771
+ cut_wall_geometry: object | None = None,
2772
+ cut_wall_bc: object | None = None,
2773
+ *,
2774
+ periodic_axes: tuple[bool | None, bool | None, bool | None] | None = None,
2775
+ ) -> "ConservativeStencil3D":
2776
+ ConservativeStencil3D, FaceGradientStencil3D, _, _ = _stencil_types()
2777
+ if isinstance(geometry, FciGeometry3D):
2778
+ # Global single-device path (legacy call convention:
2779
+ # ``(field, geometry, periodic_axes, face_bc[, cut_wall...])``).
2780
+ # Boundary conditions are applied by the downstream flux/operator
2781
+ # assembly, not during stencil reconstruction.
2782
+ del face_bc, cut_wall_geometry, cut_wall_bc
2783
+ if periodic_axes is None:
2784
+ periodic_axes = context
2785
+ return _global_axis_stencil_from_field(
2786
+ field_halo,
2787
+ geometry,
2788
+ periodic_axes=_normalize_periodic_axes(periodic_axes),
2789
+ )
2790
+ if not isinstance(geometry, LocalFciGeometry3D):
2791
+ raise TypeError("geometry must be a LocalFciGeometry3D instance")
2792
+ if not isinstance(context, StencilBuilderContext):
2793
+ raise TypeError("context must be a StencilBuilderContext instance")
2794
+ if context.layout != geometry.layout:
2795
+ raise ValueError("geometry and context must share the same HaloLayout3D")
2796
+ if context.domain is None:
2797
+ raise ValueError("context.domain is required for the local conservative stencil builder")
2798
+ field_halo = jnp.asarray(field_halo, dtype=jnp.float64)
2799
+ if field_halo.shape != geometry.halo_shape:
2800
+ raise ValueError(
2801
+ "field_halo must match geometry.halo_shape; "
2802
+ f"got {field_halo.shape}, expected {geometry.halo_shape}"
2803
+ )
2804
+ return ConservativeStencil3D(
2805
+ x=_local_axis_stencil_from_halo(field_halo, geometry, axis=0),
2806
+ y=_local_axis_stencil_from_halo(field_halo, geometry, axis=1),
2807
+ z=_local_axis_stencil_from_halo(field_halo, geometry, axis=2),
2808
+ face_grad=_build_local_face_gradient_from_halo(
2809
+ field_halo,
2810
+ geometry,
2811
+ context.domain,
2812
+ ),
2813
+ )
2814
+
2815
+
2816
+ def _build_local_stencil_from_field(
2817
+ field_halo: jnp.ndarray,
2818
+ geometry: LocalFciGeometry3D | FciGeometry3D,
2819
+ context: StencilBuilderContext | tuple[bool | None, bool | None, bool | None] | None = None,
2820
+ face_bc: object | None = None,
2821
+ cut_wall_geometry: object | None = None,
2822
+ cut_wall_bc: object | None = None,
2823
+ *,
2824
+ periodic_axes: tuple[bool | None, bool | None, bool | None] | None = None,
2825
+ ) -> "LocalStencil3D":
2826
+ _, _, _, LocalStencil3D = _stencil_types()
2827
+
2828
+ if isinstance(geometry, FciGeometry3D):
2829
+ # Global single-device path (legacy call convention:
2830
+ # ``(field, geometry, periodic_axes, face_bc[, cut_wall...])``).
2831
+ # Boundary conditions are applied by the downstream operators, not
2832
+ # during stencil reconstruction. Physical (non-periodic) side planes
2833
+ # use nonuniform second-order one-sided derivative stencils, matching
2834
+ # the shard-local one-sided physical closure.
2835
+ del face_bc, cut_wall_geometry, cut_wall_bc
2836
+ if periodic_axes is None:
2837
+ periodic_axes = context
2838
+ normalized_periodic_axes = _normalize_periodic_axes(periodic_axes)
2839
+ conservative = _global_axis_stencil_from_field(
2840
+ field_halo,
2841
+ geometry,
2842
+ periodic_axes=normalized_periodic_axes,
2843
+ )
2844
+ values = jnp.asarray(field_halo, dtype=jnp.float64)
2845
+ axis_stencils = [conservative.x, conservative.y, conservative.z]
2846
+ grid_axes = (geometry.grid.x, geometry.grid.y, geometry.grid.z)
2847
+ for axis in range(3):
2848
+ if not normalized_periodic_axes[axis]:
2849
+ axis_stencils[axis] = _one_sided_physical_global_axis_stencil(
2850
+ axis_stencils[axis],
2851
+ values,
2852
+ grid_axes[axis],
2853
+ axis=axis,
2854
+ )
2855
+ return LocalStencil3D(
2856
+ x=axis_stencils[0],
2857
+ y=axis_stencils[1],
2858
+ z=axis_stencils[2],
2859
+ )
2860
+ if not isinstance(geometry, LocalFciGeometry3D):
2861
+ raise TypeError("geometry must be a LocalFciGeometry3D instance")
2862
+ if not isinstance(context, StencilBuilderContext):
2863
+ raise TypeError("context must be a StencilBuilderContext instance")
2864
+ if context.layout != geometry.layout:
2865
+ raise ValueError("geometry and context must share the same HaloLayout3D")
2866
+ field_halo = jnp.asarray(field_halo, dtype=jnp.float64)
2867
+ if field_halo.shape != geometry.halo_shape:
2868
+ raise ValueError(
2869
+ "field_halo must match geometry.halo_shape; "
2870
+ f"got {field_halo.shape}, expected {geometry.halo_shape}"
2871
+ )
2872
+ return LocalStencil3D(
2873
+ x=_local_axis_stencil_from_halo(field_halo, geometry, axis=0),
2874
+ y=_local_axis_stencil_from_halo(field_halo, geometry, axis=1),
2875
+ z=_local_axis_stencil_from_halo(field_halo, geometry, axis=2),
2876
+ )
2877
+
2878
+
2879
+ @_pytree_base
2880
+ @dataclass(frozen=True)
2881
+ class ConservativeStencilBuilder(_DataclassPyTreeMixin):
2882
+ """Callable adapter that delegates conservative-stencil construction to an injected function."""
2883
+
2884
+ build_fn: Callable[
2885
+ [
2886
+ jnp.ndarray,
2887
+ "LocalFciGeometry3D",
2888
+ "StencilBuilderContext",
2889
+ ],
2890
+ "ConservativeStencil3D",
2891
+ ]
2892
+
2893
+ def __call__(self, *args: object, **kwargs: object) -> "ConservativeStencil3D":
2894
+ # Signature-agnostic delegation: the shard-local halo contract is
2895
+ # ``(field_halo, geometry, context)`` while the global single-device
2896
+ # contract is ``(field, geometry, periodic_axes, face_bc[, cut_wall...])``.
2897
+ return self.build_fn(*args, **kwargs)
2898
+
2899
+ def tree_flatten(self):
2900
+ return (), self.build_fn
2901
+
2902
+ @classmethod
2903
+ def tree_unflatten(cls, aux_data, children):
2904
+ return cls(aux_data)
2905
+
2906
+
2907
+ build_conservative_stencil_from_field = ConservativeStencilBuilder(_build_conservative_stencil_from_field)
2908
+
2909
+
2910
+ @_pytree_base
2911
+ @dataclass(frozen=True)
2912
+ class LocalStencilBuilder(_DataclassPyTreeMixin):
2913
+ """Callable adapter that delegates local-stencil construction to an injected function."""
2914
+
2915
+ build_fn: Callable[
2916
+ [
2917
+ jnp.ndarray,
2918
+ "LocalFciGeometry3D",
2919
+ "StencilBuilderContext",
2920
+ ],
2921
+ "LocalStencil3D",
2922
+ ]
2923
+
2924
+ def __call__(self, *args: object, **kwargs: object) -> "LocalStencil3D":
2925
+ # Signature-agnostic delegation: the shard-local halo contract is
2926
+ # ``(field_halo, geometry, context)`` while the global single-device
2927
+ # contract is ``(field, geometry, periodic_axes, face_bc[, cut_wall...])``.
2928
+ return self.build_fn(*args, **kwargs)
2929
+
2930
+ def tree_flatten(self):
2931
+ return (), self.build_fn
2932
+
2933
+ @classmethod
2934
+ def tree_unflatten(cls, aux_data, children):
2935
+ return cls(aux_data)
2936
+
2937
+
2938
+ build_local_stencil_from_field = LocalStencilBuilder(_build_local_stencil_from_field)
2939
+
2940
+
2941
+ def _build_local_face_gradient_from_halo(
2942
+ field_halo: jnp.ndarray,
2943
+ geometry: LocalFciGeometry3D,
2944
+ domain: LocalDomain3D,
2945
+ ) -> "FaceGradientStencil3D":
2946
+ _, FaceGradientStencil3D, _, _ = _stencil_types()
2947
+
2948
+ field_halo = jnp.asarray(field_halo, dtype=jnp.float64)
2949
+ if field_halo.shape != geometry.halo_shape:
2950
+ raise ValueError(
2951
+ "field_halo must match geometry.halo_shape; "
2952
+ f"got {field_halo.shape}, expected {geometry.halo_shape}"
2953
+ )
2954
+ if not isinstance(domain, LocalDomain3D):
2955
+ raise TypeError(
2956
+ "domain must be a LocalDomain3D instance, "
2957
+ f"got {type(domain).__name__}"
2958
+ )
2959
+ if domain.layout != geometry.layout:
2960
+ raise ValueError("geometry and domain must share the same HaloLayout3D")
2961
+
2962
+ face_locations = ("x_face", "y_face", "z_face")
2963
+ expected_face_shapes = tuple(
2964
+ geometry.layout.location_owned_shape(location) for location in face_locations
2965
+ )
2966
+ spacing_fields = (
2967
+ geometry.spacing.dx_halo,
2968
+ geometry.spacing.dy_halo,
2969
+ geometry.spacing.dz_halo,
2970
+ )
2971
+
2972
+ def _build_for_face_axis(face_axis: int) -> jnp.ndarray:
2973
+ face_values = _lift_cell_field_to_faces(
2974
+ field_halo,
2975
+ axis=face_axis,
2976
+ periodic=domain.periodic_axes[face_axis],
2977
+ )
2978
+ face_slices = geometry.layout.location_owned_slices(face_locations[face_axis])
2979
+ components = tuple(
2980
+ _first_derivative_3d(
2981
+ face_values,
2982
+ _lift_cell_field_to_faces(
2983
+ spacing_fields[component],
2984
+ axis=face_axis,
2985
+ periodic=domain.periodic_axes[face_axis],
2986
+ ),
2987
+ axis=component,
2988
+ periodic=domain.periodic_axes[component],
2989
+ )[face_slices]
2990
+ for component in range(3)
2991
+ )
2992
+ return jnp.stack(components, axis=-1)
2993
+
2994
+ face_grad = FaceGradientStencil3D(
2995
+ x=_build_for_face_axis(0),
2996
+ y=_build_for_face_axis(1),
2997
+ z=_build_for_face_axis(2),
2998
+ )
2999
+ if face_grad.x.shape[:-1] != expected_face_shapes[0]:
3000
+ raise ValueError(
3001
+ f"face_grad.x must have shape {expected_face_shapes[0] + (3,)}, got {face_grad.x.shape}"
3002
+ )
3003
+ if face_grad.y.shape[:-1] != expected_face_shapes[1]:
3004
+ raise ValueError(
3005
+ f"face_grad.y must have shape {expected_face_shapes[1] + (3,)}, got {face_grad.y.shape}"
3006
+ )
3007
+ if face_grad.z.shape[:-1] != expected_face_shapes[2]:
3008
+ raise ValueError(
3009
+ f"face_grad.z must have shape {expected_face_shapes[2] + (3,)}, got {face_grad.z.shape}"
3010
+ )
3011
+ return face_grad
3012
+
3013
+
3014
+ @_pytree_base
3015
+ @dataclass(frozen=True)
3016
+ class LocalConservativeStencilBuilder(_DataclassPyTreeMixin):
3017
+ """Callable adapter that delegates local conservative-stencil construction."""
3018
+
3019
+ build_fn: Callable[
3020
+ [
3021
+ jnp.ndarray,
3022
+ "LocalFciGeometry3D",
3023
+ "StencilBuilderContext",
3024
+ ],
3025
+ "ConservativeStencil3D",
3026
+ ]
3027
+
3028
+ def __call__(
3029
+ self,
3030
+ field_halo: jnp.ndarray,
3031
+ geometry: "LocalFciGeometry3D",
3032
+ context: "StencilBuilderContext",
3033
+ ) -> "ConservativeStencil3D":
3034
+ return self.build_fn(field_halo, geometry, context)
3035
+
3036
+ def tree_flatten(self):
3037
+ return (), self.build_fn
3038
+
3039
+ @classmethod
3040
+ def tree_unflatten(cls, aux_data, children):
3041
+ return cls(aux_data)
3042
+
3043
+
3044
+ build_local_conservative_stencil_from_field = LocalConservativeStencilBuilder(
3045
+ _build_conservative_stencil_from_field
3046
+ )
3047
+
3048
+
3049
+ def _local_axis_plane_slice(axis: int, index: int | slice) -> tuple[object, object, object]:
3050
+ """Return a 3D slice tuple with ``index`` applied along one axis."""
3051
+
3052
+ axis = int(axis)
3053
+ if axis == 0:
3054
+ return index, slice(None), slice(None)
3055
+ if axis == 1:
3056
+ return slice(None), index, slice(None)
3057
+ if axis == 2:
3058
+ return slice(None), slice(None), index
3059
+ raise ValueError(f"axis must be 0, 1, or 2, got {axis}")
3060
+
3061
+
3062
+ def _local_halo_axis_slice(
3063
+ layout: HaloLayout3D,
3064
+ axis: int,
3065
+ owned_axis_offset: int,
3066
+ ) -> tuple[object, object, object]:
3067
+ """Slice a local halo field at an owned-axis-relative cell offset."""
3068
+
3069
+ axis = int(axis)
3070
+ if axis < 0 or axis > 2:
3071
+ raise ValueError(f"axis must be 0, 1, or 2, got {axis}")
3072
+
3073
+ h = int(layout.halo_width)
3074
+ slices: list[object] = [
3075
+ slice(h, h + layout.owned_shape[0]),
3076
+ slice(h, h + layout.owned_shape[1]),
3077
+ slice(h, h + layout.owned_shape[2]),
3078
+ ]
3079
+ slices[axis] = h + int(owned_axis_offset)
3080
+ return tuple(slices)
3081
+
3082
+
3083
+ def _three_point_first_derivative_weights(
3084
+ target: jnp.ndarray,
3085
+ first: jnp.ndarray,
3086
+ second: jnp.ndarray,
3087
+ ) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
3088
+ """Return first-derivative weights at ``target`` for three coordinates."""
3089
+
3090
+ target = jnp.asarray(target, dtype=jnp.float64)
3091
+ first = jnp.asarray(first, dtype=jnp.float64)
3092
+ second = jnp.asarray(second, dtype=jnp.float64)
3093
+
3094
+ w_target = (2.0 * target - first - second) / (
3095
+ (target - first) * (target - second)
3096
+ )
3097
+ w_first = (target - second) / ((first - target) * (first - second))
3098
+ w_second = (target - first) / ((second - target) * (second - first))
3099
+ return w_target, w_first, w_second
3100
+
3101
+
3102
+ def _patch_local_physical_one_sided_axis_stencil(
3103
+ stencil: "LocalStencil1D",
3104
+ field_halo: jnp.ndarray,
3105
+ geometry: LocalFciGeometry3D,
3106
+ domain: LocalDomain3D,
3107
+ *,
3108
+ axis: int,
3109
+ ) -> "LocalStencil1D":
3110
+ """Patch physical side planes with nonuniform three-point formulas."""
3111
+
3112
+ ConservativeStencil3D, _, LocalStencil1D, _ = _stencil_types()
3113
+ del ConservativeStencil3D
3114
+
3115
+ axis = int(axis)
3116
+ layout = domain.layout
3117
+ if layout != geometry.layout:
3118
+ raise ValueError("geometry and domain must share the same HaloLayout3D")
3119
+
3120
+ n_axis = int(layout.owned_shape[axis])
3121
+ if n_axis < 3:
3122
+ has_physical_side = (
3123
+ domain.shard_spec.lower_side_kind(axis) == SIDE_PHYSICAL
3124
+ or domain.shard_spec.upper_side_kind(axis) == SIDE_PHYSICAL
3125
+ )
3126
+ if has_physical_side:
3127
+ raise ValueError(
3128
+ "second-order one-sided derivative requires at least 3 owned "
3129
+ f"cells along physical axis {axis}; got {n_axis}"
3130
+ )
3131
+ return stencil
3132
+
3133
+ field_halo = jnp.asarray(field_halo, dtype=jnp.float64)
3134
+ if field_halo.shape != layout.cell_halo_shape:
3135
+ raise ValueError(
3136
+ "field_halo must match domain.layout.cell_halo_shape; "
3137
+ f"got {field_halo.shape}, expected {layout.cell_halo_shape}"
3138
+ )
3139
+
3140
+ minus = jnp.asarray(stencil.minus, dtype=jnp.float64)
3141
+ center = jnp.asarray(stencil.center, dtype=jnp.float64)
3142
+ plus = jnp.asarray(stencil.plus, dtype=jnp.float64)
3143
+ dx_min = jnp.asarray(stencil.dx_min, dtype=jnp.float64)
3144
+ dx_plus = jnp.asarray(stencil.dx_plus, dtype=jnp.float64)
3145
+ c_minus = jnp.asarray(stencil.derivative_minus_weight, dtype=jnp.float64)
3146
+ c_center = jnp.asarray(stencil.derivative_center_weight, dtype=jnp.float64)
3147
+ c_plus = jnp.asarray(stencil.derivative_plus_weight, dtype=jnp.float64)
3148
+
3149
+ grid_axis = (geometry.grid.x, geometry.grid.y, geometry.grid.z)[axis]
3150
+ centers_halo = jnp.asarray(grid_axis.centers_halo, dtype=jnp.float64)
3151
+ h = int(layout.halo_width)
3152
+
3153
+ lower_target = centers_halo[h]
3154
+ lower_first = centers_halo[h + 1]
3155
+ lower_second = centers_halo[h + 2]
3156
+ lower_weights = _three_point_first_derivative_weights(
3157
+ lower_target,
3158
+ lower_first,
3159
+ lower_second,
3160
+ )
3161
+
3162
+ lower_plane = _local_axis_plane_slice(axis, 0)
3163
+ lower_f0 = field_halo[
3164
+ _local_halo_axis_slice(layout, axis, 0)
3165
+ ]
3166
+ lower_f1 = field_halo[
3167
+ _local_halo_axis_slice(layout, axis, 1)
3168
+ ]
3169
+ lower_f2 = field_halo[
3170
+ _local_halo_axis_slice(layout, axis, 2)
3171
+ ]
3172
+ do_lower = domain.runtime_has_physical_lower(axis)
3173
+
3174
+ minus = minus.at[lower_plane].set(
3175
+ jnp.where(do_lower, lower_f2, minus[lower_plane])
3176
+ )
3177
+ center = center.at[lower_plane].set(
3178
+ jnp.where(do_lower, lower_f0, center[lower_plane])
3179
+ )
3180
+ plus = plus.at[lower_plane].set(
3181
+ jnp.where(do_lower, lower_f1, plus[lower_plane])
3182
+ )
3183
+ c_minus = c_minus.at[lower_plane].set(
3184
+ jnp.where(do_lower, lower_weights[2], c_minus[lower_plane])
3185
+ )
3186
+ c_center = c_center.at[lower_plane].set(
3187
+ jnp.where(do_lower, lower_weights[0], c_center[lower_plane])
3188
+ )
3189
+ c_plus = c_plus.at[lower_plane].set(
3190
+ jnp.where(do_lower, lower_weights[1], c_plus[lower_plane])
3191
+ )
3192
+ dx_min = dx_min.at[lower_plane].set(
3193
+ jnp.where(
3194
+ do_lower,
3195
+ jnp.abs(lower_second - lower_target),
3196
+ dx_min[lower_plane],
3197
+ )
3198
+ )
3199
+ dx_plus = dx_plus.at[lower_plane].set(
3200
+ jnp.where(
3201
+ do_lower,
3202
+ jnp.abs(lower_first - lower_target),
3203
+ dx_plus[lower_plane],
3204
+ )
3205
+ )
3206
+
3207
+ upper_target = centers_halo[h + n_axis - 1]
3208
+ upper_first = centers_halo[h + n_axis - 2]
3209
+ upper_second = centers_halo[h + n_axis - 3]
3210
+ upper_weights = _three_point_first_derivative_weights(
3211
+ upper_target,
3212
+ upper_first,
3213
+ upper_second,
3214
+ )
3215
+
3216
+ upper_plane = _local_axis_plane_slice(axis, n_axis - 1)
3217
+ upper_f0 = field_halo[
3218
+ _local_halo_axis_slice(layout, axis, n_axis - 1)
3219
+ ]
3220
+ upper_f1 = field_halo[
3221
+ _local_halo_axis_slice(layout, axis, n_axis - 2)
3222
+ ]
3223
+ upper_f2 = field_halo[
3224
+ _local_halo_axis_slice(layout, axis, n_axis - 3)
3225
+ ]
3226
+ do_upper = domain.runtime_has_physical_upper(axis)
3227
+
3228
+ minus = minus.at[upper_plane].set(
3229
+ jnp.where(do_upper, upper_f1, minus[upper_plane])
3230
+ )
3231
+ center = center.at[upper_plane].set(
3232
+ jnp.where(do_upper, upper_f0, center[upper_plane])
3233
+ )
3234
+ plus = plus.at[upper_plane].set(
3235
+ jnp.where(do_upper, upper_f2, plus[upper_plane])
3236
+ )
3237
+ c_minus = c_minus.at[upper_plane].set(
3238
+ jnp.where(do_upper, upper_weights[1], c_minus[upper_plane])
3239
+ )
3240
+ c_center = c_center.at[upper_plane].set(
3241
+ jnp.where(do_upper, upper_weights[0], c_center[upper_plane])
3242
+ )
3243
+ c_plus = c_plus.at[upper_plane].set(
3244
+ jnp.where(do_upper, upper_weights[2], c_plus[upper_plane])
3245
+ )
3246
+ dx_min = dx_min.at[upper_plane].set(
3247
+ jnp.where(
3248
+ do_upper,
3249
+ jnp.abs(upper_first - upper_target),
3250
+ dx_min[upper_plane],
3251
+ )
3252
+ )
3253
+ dx_plus = dx_plus.at[upper_plane].set(
3254
+ jnp.where(
3255
+ do_upper,
3256
+ jnp.abs(upper_second - upper_target),
3257
+ dx_plus[upper_plane],
3258
+ )
3259
+ )
3260
+
3261
+ return LocalStencil1D(
3262
+ center=center,
3263
+ minus=minus,
3264
+ plus=plus,
3265
+ dx_min=dx_min,
3266
+ dx_plus=dx_plus,
3267
+ derivative_minus_weight=c_minus,
3268
+ derivative_center_weight=c_center,
3269
+ derivative_plus_weight=c_plus,
3270
+ )
3271
+
3272
+
3273
+ def build_local_direct_stencil_one_sided_physical_from_halo(
3274
+ field_halo: jnp.ndarray,
3275
+ geometry: LocalFciGeometry3D,
3276
+ context: StencilBuilderContext,
3277
+ ) -> "LocalStencil3D":
3278
+ """Build function for a one-sided physical-boundary local stencil.
3279
+
3280
+ This follows the ``LocalStencilBuilder`` call signature so callers can
3281
+ construct the intermediate builder explicitly. The domain is taken from
3282
+ ``context.domain``.
3283
+
3284
+ Interior, shard-interface, and topology-side cells use the normal local
3285
+ centered stencil. True regular-coordinate physical side planes are
3286
+ replaced by three-point one-sided formulas whose weights are computed from
3287
+ the local coordinate-center positions. This is intended for intermediate
3288
+ fields such as ``q = grad_parallel(f)`` after halo exchange and topology
3289
+ filling, but before physical ghost filling.
3290
+ """
3291
+
3292
+ _, _, _, LocalStencil3D = _stencil_types()
3293
+
3294
+ if not isinstance(geometry, LocalFciGeometry3D):
3295
+ raise TypeError(
3296
+ "build_local_direct_stencil_one_sided_physical_from_halo requires "
3297
+ f"LocalFciGeometry3D, got {type(geometry).__name__}"
3298
+ )
3299
+ if not isinstance(context, StencilBuilderContext):
3300
+ raise TypeError(
3301
+ "build_local_direct_stencil_one_sided_physical_from_halo requires "
3302
+ "StencilBuilderContext, "
3303
+ f"got {type(context).__name__}"
3304
+ )
3305
+ domain = context.domain
3306
+ if domain is None:
3307
+ raise ValueError(
3308
+ "build_local_direct_stencil_one_sided_physical_from_halo requires "
3309
+ "context.domain"
3310
+ )
3311
+ if domain.layout != geometry.layout:
3312
+ raise ValueError("geometry and domain must share the same HaloLayout3D")
3313
+
3314
+ field_halo = jnp.asarray(field_halo, dtype=jnp.float64)
3315
+ if field_halo.shape != geometry.halo_shape:
3316
+ raise ValueError(
3317
+ "field_halo must match geometry.halo_shape; "
3318
+ f"got {field_halo.shape}, expected {geometry.halo_shape}"
3319
+ )
3320
+
3321
+ centered = tuple(
3322
+ _local_axis_stencil_from_halo(field_halo, geometry, axis=axis)
3323
+ for axis in range(3)
3324
+ )
3325
+ patched = tuple(
3326
+ _patch_local_physical_one_sided_axis_stencil(
3327
+ centered[axis],
3328
+ field_halo,
3329
+ geometry,
3330
+ domain,
3331
+ axis=axis,
3332
+ )
3333
+ for axis in range(3)
3334
+ )
3335
+ return LocalStencil3D(x=patched[0], y=patched[1], z=patched[2])
3336
+
3337
+
3338
+ def _axis_index_nd(axis: int, index: int, ndim: int) -> tuple[object, ...]:
3339
+ slices: list[object] = [slice(None)] * ndim
3340
+ slices[axis] = index
3341
+ return tuple(slices)
3342
+
3343
+
3344
+ def _first_derivative_3d(
3345
+ values: jnp.ndarray,
3346
+ spacing: jnp.ndarray | float,
3347
+ *,
3348
+ axis: int,
3349
+ periodic: bool,
3350
+ ) -> jnp.ndarray:
3351
+ """Centered first derivative with periodic or second-order edge treatment."""
3352
+
3353
+ values = jnp.asarray(values, dtype=jnp.float64)
3354
+ h = jnp.asarray(spacing, dtype=jnp.float64)
3355
+ if h.ndim == 0:
3356
+ h = jnp.ones_like(values) * h
3357
+ centered = (jnp.roll(values, -1, axis=axis) - jnp.roll(values, 1, axis=axis)) / jnp.maximum(2.0 * h, 1.0e-30)
3358
+ if periodic:
3359
+ return centered
3360
+
3361
+ first = _axis_index_nd(axis, 0, values.ndim)
3362
+ second = _axis_index_nd(axis, 1, values.ndim)
3363
+ third = _axis_index_nd(axis, 2, values.ndim)
3364
+ last = _axis_index_nd(axis, -1, values.ndim)
3365
+ penultimate = _axis_index_nd(axis, -2, values.ndim)
3366
+ antepenultimate = _axis_index_nd(axis, -3, values.ndim)
3367
+ forward = (-3.0 * values[first] + 4.0 * values[second] - values[third]) / jnp.maximum(2.0 * h[first], 1.0e-30)
3368
+ backward = (3.0 * values[last] - 4.0 * values[penultimate] + values[antepenultimate]) / jnp.maximum(2.0 * h[last], 1.0e-30)
3369
+ return centered.at[first].set(forward).at[last].set(backward)
3370
+
3371
+
3372
+ def build_curvature_coefficients(
3373
+ geometry: "FciGeometry3D",
3374
+ *,
3375
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
3376
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
3377
+ b_floor: float = 1.0e-30,
3378
+ jacobian_floor: float = 1.0e-30,
3379
+ ) -> jnp.ndarray:
3380
+ """Build geometry-dependent curvature coefficients for a given geometry."""
3381
+
3382
+ periodic_axes = tuple(bool(value) for value in periodic_axes)
3383
+ axis_regular_axes = tuple(bool(value) for value in axis_regular_axes)
3384
+ if any(periodic and axis_regular for periodic, axis_regular in zip(periodic_axes, axis_regular_axes)):
3385
+ raise ValueError(
3386
+ "periodic_axes and axis_regular_axes cannot both be True on the same axis; "
3387
+ f"got periodic_axes={periodic_axes}, axis_regular_axes={axis_regular_axes}"
3388
+ )
3389
+ if axis_regular_axes[1] or axis_regular_axes[2]:
3390
+ raise NotImplementedError(
3391
+ "axis_regular_axes currently only supports the lower x axis for curvature coefficients; "
3392
+ f"got axis_regular_axes={axis_regular_axes}"
3393
+ )
3394
+ if axis_regular_axes[0] and geometry.shape[1] % 2:
3395
+ raise ValueError("axis-regular lower-x curvature coefficients require an even poloidal grid")
3396
+
3397
+ def _covariant_field(metric: MetricGeometry, bfield: BFieldGeometry) -> jnp.ndarray:
3398
+ b = jnp.asarray(bfield.B_contra, dtype=jnp.float64)
3399
+ bmag = jnp.maximum(jnp.asarray(bfield.Bmag, dtype=jnp.float64), float(b_floor))
3400
+ b_unit = b / bmag[..., None]
3401
+ return jnp.einsum("...ij,...j->...i", metric.g_cov, b_unit) / bmag[..., None]
3402
+
3403
+ def _boundary_corrected_derivative(
3404
+ values: jnp.ndarray,
3405
+ spacing: jnp.ndarray,
3406
+ *,
3407
+ axis: int,
3408
+ component: int,
3409
+ periodic: bool,
3410
+ lower_face_value: jnp.ndarray,
3411
+ upper_face_value: jnp.ndarray,
3412
+ lower_center_to_face: float,
3413
+ upper_center_to_face: float,
3414
+ lower_center_to_center: float,
3415
+ upper_center_to_center: float,
3416
+ axis_regular_lower_parity: float | None = None,
3417
+ ) -> jnp.ndarray:
3418
+ deriv = _first_derivative_3d(values, spacing, axis=axis, periodic=periodic)
3419
+ if periodic:
3420
+ return deriv
3421
+
3422
+ if values.shape[axis] < 3:
3423
+ raise ValueError("curvature coefficient construction requires at least 3 cells along each axis")
3424
+
3425
+ lower_center = values[_axis_index_nd(axis, 0, values.ndim)]
3426
+ if axis == 0 and axis_regular_axes[0]:
3427
+ half_turn = values.shape[1] // 2
3428
+ component_parity = float(axis_regular_lower_parity) if axis_regular_lower_parity is not None else (-1.0 if int(component) == 0 else 1.0)
3429
+ lower_ghost = component_parity * jnp.roll(lower_center, shift=-half_turn, axis=0)
3430
+ else:
3431
+ lower_ghost = 2.0 * lower_face_value - lower_center
3432
+ upper_ghost = 2.0 * upper_face_value - values[_axis_index_nd(axis, -1, values.ndim)]
3433
+
3434
+ lower_dx_min = jnp.asarray(2.0 * lower_center_to_face, dtype=jnp.float64)
3435
+ upper_dx_plus = jnp.asarray(2.0 * upper_center_to_face, dtype=jnp.float64)
3436
+ lower_dx_plus = jnp.asarray(lower_center_to_center, dtype=jnp.float64)
3437
+ upper_dx_min = jnp.asarray(upper_center_to_center, dtype=jnp.float64)
3438
+
3439
+ def _fd(minus: jnp.ndarray, center: jnp.ndarray, plus: jnp.ndarray, dx_min: jnp.ndarray, dx_plus: jnp.ndarray) -> jnp.ndarray:
3440
+ denom = jnp.maximum(dx_min * dx_plus * (dx_min + dx_plus), 1.0e-30)
3441
+ c_minus = -dx_plus * dx_plus / denom
3442
+ c_center = (dx_plus * dx_plus - dx_min * dx_min) / denom
3443
+ c_plus = dx_min * dx_min / denom
3444
+ return c_minus * minus + c_center * center + c_plus * plus
3445
+
3446
+ lower_deriv = _fd(
3447
+ lower_ghost,
3448
+ lower_center,
3449
+ values[_axis_index_nd(axis, 1, values.ndim)],
3450
+ lower_dx_min,
3451
+ lower_dx_plus,
3452
+ )
3453
+ upper_deriv = _fd(
3454
+ values[_axis_index_nd(axis, -2, values.ndim)],
3455
+ values[_axis_index_nd(axis, -1, values.ndim)],
3456
+ upper_ghost,
3457
+ upper_dx_min,
3458
+ upper_dx_plus,
3459
+ )
3460
+ return deriv.at[_axis_index_nd(axis, 0, values.ndim)].set(lower_deriv).at[_axis_index_nd(axis, -1, values.ndim)].set(upper_deriv)
3461
+
3462
+ metric = geometry.cell_metric
3463
+ cell_bfield = geometry.cell_bfield
3464
+ bmag = jnp.maximum(jnp.asarray(cell_bfield.Bmag, dtype=jnp.float64), float(b_floor))
3465
+ covariant_field = _covariant_field(metric, cell_bfield)
3466
+
3467
+ face_covariant_x = _covariant_field(geometry.face_metric.x, geometry.face_bfield.x)
3468
+ face_covariant_y = _covariant_field(geometry.face_metric.y, geometry.face_bfield.y)
3469
+ face_covariant_z = _covariant_field(geometry.face_metric.z, geometry.face_bfield.z)
3470
+ if axis_regular_axes[0]:
3471
+ # The collapsed lower-x face is topological rather than physical. It is
3472
+ # not used by the axis-regular lower derivative, but overwriting it keeps
3473
+ # singular face geometry from lingering in the traced computation graph.
3474
+ face_covariant_x = face_covariant_x.at[0].set(jnp.zeros_like(face_covariant_x[0]))
3475
+
3476
+ x_lower_center_to_face = jnp.asarray(geometry.grid.x.lower_center_to_face, dtype=jnp.float64)
3477
+ x_upper_center_to_face = jnp.asarray(geometry.grid.x.upper_center_to_face, dtype=jnp.float64)
3478
+ x_lower_center_to_center = jnp.asarray(geometry.grid.x.center_deltas[0], dtype=jnp.float64)
3479
+ x_upper_center_to_center = jnp.asarray(geometry.grid.x.center_deltas[-1], dtype=jnp.float64)
3480
+ y_lower_center_to_face = jnp.asarray(geometry.grid.y.lower_center_to_face, dtype=jnp.float64)
3481
+ y_upper_center_to_face = jnp.asarray(geometry.grid.y.upper_center_to_face, dtype=jnp.float64)
3482
+ y_lower_center_to_center = jnp.asarray(geometry.grid.y.center_deltas[0], dtype=jnp.float64)
3483
+ y_upper_center_to_center = jnp.asarray(geometry.grid.y.center_deltas[-1], dtype=jnp.float64)
3484
+ z_lower_center_to_face = jnp.asarray(geometry.grid.z.lower_center_to_face, dtype=jnp.float64)
3485
+ z_upper_center_to_face = jnp.asarray(geometry.grid.z.upper_center_to_face, dtype=jnp.float64)
3486
+ z_lower_center_to_center = jnp.asarray(geometry.grid.z.center_deltas[0], dtype=jnp.float64)
3487
+ z_upper_center_to_center = jnp.asarray(geometry.grid.z.center_deltas[-1], dtype=jnp.float64)
3488
+
3489
+ dcov_dx = jnp.stack(
3490
+ [
3491
+ _boundary_corrected_derivative(
3492
+ covariant_field[..., 0],
3493
+ geometry.spacing.dx,
3494
+ axis=0,
3495
+ component=0,
3496
+ periodic=periodic_axes[0],
3497
+ lower_face_value=face_covariant_x[0, ..., 0],
3498
+ upper_face_value=face_covariant_x[-1, ..., 0],
3499
+ lower_center_to_face=x_lower_center_to_face,
3500
+ upper_center_to_face=x_upper_center_to_face,
3501
+ lower_center_to_center=x_lower_center_to_center,
3502
+ upper_center_to_center=x_upper_center_to_center,
3503
+ ),
3504
+ _boundary_corrected_derivative(
3505
+ covariant_field[..., 1],
3506
+ geometry.spacing.dx,
3507
+ axis=0,
3508
+ component=1,
3509
+ periodic=periodic_axes[0],
3510
+ lower_face_value=face_covariant_x[0, ..., 1],
3511
+ upper_face_value=face_covariant_x[-1, ..., 1],
3512
+ lower_center_to_face=x_lower_center_to_face,
3513
+ upper_center_to_face=x_upper_center_to_face,
3514
+ lower_center_to_center=x_lower_center_to_center,
3515
+ upper_center_to_center=x_upper_center_to_center,
3516
+ ),
3517
+ _boundary_corrected_derivative(
3518
+ covariant_field[..., 2],
3519
+ geometry.spacing.dx,
3520
+ axis=0,
3521
+ component=2,
3522
+ periodic=periodic_axes[0],
3523
+ lower_face_value=face_covariant_x[0, ..., 2],
3524
+ upper_face_value=face_covariant_x[-1, ..., 2],
3525
+ lower_center_to_face=x_lower_center_to_face,
3526
+ upper_center_to_face=x_upper_center_to_face,
3527
+ lower_center_to_center=x_lower_center_to_center,
3528
+ upper_center_to_center=x_upper_center_to_center,
3529
+ ),
3530
+ ],
3531
+ axis=-1,
3532
+ )
3533
+ dcov_dy = jnp.stack(
3534
+ [
3535
+ _boundary_corrected_derivative(
3536
+ covariant_field[..., 0],
3537
+ geometry.spacing.dy,
3538
+ axis=1,
3539
+ component=0,
3540
+ periodic=periodic_axes[1],
3541
+ lower_face_value=face_covariant_y[:, 0, ..., 0],
3542
+ upper_face_value=face_covariant_y[:, -1, ..., 0],
3543
+ lower_center_to_face=y_lower_center_to_face,
3544
+ upper_center_to_face=y_upper_center_to_face,
3545
+ lower_center_to_center=y_lower_center_to_center,
3546
+ upper_center_to_center=y_upper_center_to_center,
3547
+ ),
3548
+ _boundary_corrected_derivative(
3549
+ covariant_field[..., 1],
3550
+ geometry.spacing.dy,
3551
+ axis=1,
3552
+ component=1,
3553
+ periodic=periodic_axes[1],
3554
+ lower_face_value=face_covariant_y[:, 0, ..., 1],
3555
+ upper_face_value=face_covariant_y[:, -1, ..., 1],
3556
+ lower_center_to_face=y_lower_center_to_face,
3557
+ upper_center_to_face=y_upper_center_to_face,
3558
+ lower_center_to_center=y_lower_center_to_center,
3559
+ upper_center_to_center=y_upper_center_to_center,
3560
+ ),
3561
+ _boundary_corrected_derivative(
3562
+ covariant_field[..., 2],
3563
+ geometry.spacing.dy,
3564
+ axis=1,
3565
+ component=2,
3566
+ periodic=periodic_axes[1],
3567
+ lower_face_value=face_covariant_y[:, 0, ..., 2],
3568
+ upper_face_value=face_covariant_y[:, -1, ..., 2],
3569
+ lower_center_to_face=y_lower_center_to_face,
3570
+ upper_center_to_face=y_upper_center_to_face,
3571
+ lower_center_to_center=y_lower_center_to_center,
3572
+ upper_center_to_center=y_upper_center_to_center,
3573
+ ),
3574
+ ],
3575
+ axis=-1,
3576
+ )
3577
+ dcov_dz = jnp.stack(
3578
+ [
3579
+ _boundary_corrected_derivative(
3580
+ covariant_field[..., 0],
3581
+ geometry.spacing.dz,
3582
+ axis=2,
3583
+ component=0,
3584
+ periodic=periodic_axes[2],
3585
+ lower_face_value=face_covariant_z[:, :, 0, 0],
3586
+ upper_face_value=face_covariant_z[:, :, -1, 0],
3587
+ lower_center_to_face=z_lower_center_to_face,
3588
+ upper_center_to_face=z_upper_center_to_face,
3589
+ lower_center_to_center=z_lower_center_to_center,
3590
+ upper_center_to_center=z_upper_center_to_center,
3591
+ ),
3592
+ _boundary_corrected_derivative(
3593
+ covariant_field[..., 1],
3594
+ geometry.spacing.dz,
3595
+ axis=2,
3596
+ component=1,
3597
+ periodic=periodic_axes[2],
3598
+ lower_face_value=face_covariant_z[:, :, 0, 1],
3599
+ upper_face_value=face_covariant_z[:, :, -1, 1],
3600
+ lower_center_to_face=z_lower_center_to_face,
3601
+ upper_center_to_face=z_upper_center_to_face,
3602
+ lower_center_to_center=z_lower_center_to_center,
3603
+ upper_center_to_center=z_upper_center_to_center,
3604
+ ),
3605
+ _boundary_corrected_derivative(
3606
+ covariant_field[..., 2],
3607
+ geometry.spacing.dz,
3608
+ axis=2,
3609
+ component=2,
3610
+ periodic=periodic_axes[2],
3611
+ lower_face_value=face_covariant_z[:, :, 0, 2],
3612
+ upper_face_value=face_covariant_z[:, :, -1, 2],
3613
+ lower_center_to_face=z_lower_center_to_face,
3614
+ upper_center_to_face=z_upper_center_to_face,
3615
+ lower_center_to_center=z_lower_center_to_center,
3616
+ upper_center_to_center=z_upper_center_to_center,
3617
+ ),
3618
+ ],
3619
+ axis=-1,
3620
+ )
3621
+
3622
+ curl = jnp.stack(
3623
+ (
3624
+ dcov_dy[..., 2] - dcov_dz[..., 1],
3625
+ dcov_dz[..., 0] - dcov_dx[..., 2],
3626
+ dcov_dx[..., 1] - dcov_dy[..., 0],
3627
+ ),
3628
+ axis=-1,
3629
+ )
3630
+ coefficient = bmag / (2.0 * jnp.maximum(jnp.asarray(metric.J, dtype=jnp.float64), float(jacobian_floor)))
3631
+ curvature_coefficients = coefficient[..., None] * curl
3632
+
3633
+ if axis_regular_axes[0]:
3634
+ rho = jnp.asarray(geometry.grid.x.centers, dtype=jnp.float64)
3635
+ theta = jnp.asarray(geometry.grid.y.centers, dtype=jnp.float64)
3636
+ rho_values = rho[:, None, None]
3637
+ rho_safe = jnp.maximum(rho_values, 1.0e-30)
3638
+ theta_values = theta[None, :, None]
3639
+ cos_theta = jnp.cos(theta_values)
3640
+ sin_theta = jnp.sin(theta_values)
3641
+
3642
+ A_rho = covariant_field[..., 0]
3643
+ A_theta = covariant_field[..., 1]
3644
+ A_zeta = covariant_field[..., 2]
3645
+
3646
+ A_X = A_rho * cos_theta - A_theta * sin_theta / rho_safe
3647
+ A_Y = A_rho * sin_theta + A_theta * cos_theta / rho_safe
3648
+ A_Z = A_zeta
3649
+
3650
+ x_upper_face_rho = jnp.asarray(geometry.grid.x.faces[-1], dtype=jnp.float64)
3651
+ x_upper_face_rho_safe = jnp.maximum(x_upper_face_rho, 1.0e-30)
3652
+ x_upper_A_X = face_covariant_x[-1, ..., 0] * cos_theta[0] - face_covariant_x[-1, ..., 1] * sin_theta[0] / x_upper_face_rho_safe
3653
+ x_upper_A_Y = face_covariant_x[-1, ..., 0] * sin_theta[0] + face_covariant_x[-1, ..., 1] * cos_theta[0] / x_upper_face_rho_safe
3654
+ x_upper_A_Z = face_covariant_x[-1, ..., 2]
3655
+
3656
+ def _axis_regular_cartesian_x_derivative(values: jnp.ndarray, upper_face_value: jnp.ndarray) -> jnp.ndarray:
3657
+ return _boundary_corrected_derivative(
3658
+ values,
3659
+ geometry.spacing.dx,
3660
+ axis=0,
3661
+ component=0,
3662
+ periodic=False,
3663
+ lower_face_value=jnp.zeros_like(upper_face_value),
3664
+ upper_face_value=upper_face_value,
3665
+ lower_center_to_face=x_lower_center_to_face,
3666
+ upper_center_to_face=x_upper_center_to_face,
3667
+ lower_center_to_center=x_lower_center_to_center,
3668
+ upper_center_to_center=x_upper_center_to_center,
3669
+ axis_regular_lower_parity=1.0,
3670
+ )
3671
+
3672
+ dA_X_drho = _axis_regular_cartesian_x_derivative(A_X, x_upper_A_X)
3673
+ dA_Y_drho = _axis_regular_cartesian_x_derivative(A_Y, x_upper_A_Y)
3674
+ dA_Z_drho = _axis_regular_cartesian_x_derivative(A_Z, x_upper_A_Z)
3675
+
3676
+ rho_xz_safe = jnp.maximum(rho[:, None], 1.0e-30)
3677
+ y_lower_theta = jnp.asarray(geometry.grid.y.faces[0], dtype=jnp.float64)
3678
+ y_upper_theta = jnp.asarray(geometry.grid.y.faces[-1], dtype=jnp.float64)
3679
+ y_lower_cos_theta = jnp.cos(y_lower_theta)
3680
+ y_lower_sin_theta = jnp.sin(y_lower_theta)
3681
+ y_upper_cos_theta = jnp.cos(y_upper_theta)
3682
+ y_upper_sin_theta = jnp.sin(y_upper_theta)
3683
+ y_lower_A_X = face_covariant_y[:, 0, :, 0] * y_lower_cos_theta - face_covariant_y[:, 0, :, 1] * y_lower_sin_theta / rho_xz_safe
3684
+ y_upper_A_X = face_covariant_y[:, -1, :, 0] * y_upper_cos_theta - face_covariant_y[:, -1, :, 1] * y_upper_sin_theta / rho_xz_safe
3685
+ y_lower_A_Y = face_covariant_y[:, 0, :, 0] * y_lower_sin_theta + face_covariant_y[:, 0, :, 1] * y_lower_cos_theta / rho_xz_safe
3686
+ y_upper_A_Y = face_covariant_y[:, -1, :, 0] * y_upper_sin_theta + face_covariant_y[:, -1, :, 1] * y_upper_cos_theta / rho_xz_safe
3687
+ y_lower_A_Z = face_covariant_y[:, 0, :, 2]
3688
+ y_upper_A_Z = face_covariant_y[:, -1, :, 2]
3689
+
3690
+ dA_X_dtheta = _boundary_corrected_derivative(
3691
+ A_X,
3692
+ geometry.spacing.dy,
3693
+ axis=1,
3694
+ component=0,
3695
+ periodic=periodic_axes[1],
3696
+ lower_face_value=y_lower_A_X,
3697
+ upper_face_value=y_upper_A_X,
3698
+ lower_center_to_face=y_lower_center_to_face,
3699
+ upper_center_to_face=y_upper_center_to_face,
3700
+ lower_center_to_center=y_lower_center_to_center,
3701
+ upper_center_to_center=y_upper_center_to_center,
3702
+ )
3703
+ dA_Y_dtheta = _boundary_corrected_derivative(
3704
+ A_Y,
3705
+ geometry.spacing.dy,
3706
+ axis=1,
3707
+ component=1,
3708
+ periodic=periodic_axes[1],
3709
+ lower_face_value=y_lower_A_Y,
3710
+ upper_face_value=y_upper_A_Y,
3711
+ lower_center_to_face=y_lower_center_to_face,
3712
+ upper_center_to_face=y_upper_center_to_face,
3713
+ lower_center_to_center=y_lower_center_to_center,
3714
+ upper_center_to_center=y_upper_center_to_center,
3715
+ )
3716
+ dA_Z_dtheta = _boundary_corrected_derivative(
3717
+ A_Z,
3718
+ geometry.spacing.dy,
3719
+ axis=1,
3720
+ component=2,
3721
+ periodic=periodic_axes[1],
3722
+ lower_face_value=y_lower_A_Z,
3723
+ upper_face_value=y_upper_A_Z,
3724
+ lower_center_to_face=y_lower_center_to_face,
3725
+ upper_center_to_face=y_upper_center_to_face,
3726
+ lower_center_to_center=y_lower_center_to_center,
3727
+ upper_center_to_center=y_upper_center_to_center,
3728
+ )
3729
+
3730
+ rho_xy_safe = jnp.maximum(rho[:, None], 1.0e-30)
3731
+ theta_xy = theta[None, :]
3732
+ cos_theta_xy = jnp.cos(theta_xy)
3733
+ sin_theta_xy = jnp.sin(theta_xy)
3734
+ z_lower_A_X = face_covariant_z[:, :, 0, 0] * cos_theta_xy - face_covariant_z[:, :, 0, 1] * sin_theta_xy / rho_xy_safe
3735
+ z_upper_A_X = face_covariant_z[:, :, -1, 0] * cos_theta_xy - face_covariant_z[:, :, -1, 1] * sin_theta_xy / rho_xy_safe
3736
+ z_lower_A_Y = face_covariant_z[:, :, 0, 0] * sin_theta_xy + face_covariant_z[:, :, 0, 1] * cos_theta_xy / rho_xy_safe
3737
+ z_upper_A_Y = face_covariant_z[:, :, -1, 0] * sin_theta_xy + face_covariant_z[:, :, -1, 1] * cos_theta_xy / rho_xy_safe
3738
+ z_lower_A_Z = face_covariant_z[:, :, 0, 2]
3739
+ z_upper_A_Z = face_covariant_z[:, :, -1, 2]
3740
+
3741
+ dA_X_dzeta = _boundary_corrected_derivative(
3742
+ A_X,
3743
+ geometry.spacing.dz,
3744
+ axis=2,
3745
+ component=0,
3746
+ periodic=periodic_axes[2],
3747
+ lower_face_value=z_lower_A_X,
3748
+ upper_face_value=z_upper_A_X,
3749
+ lower_center_to_face=z_lower_center_to_face,
3750
+ upper_center_to_face=z_upper_center_to_face,
3751
+ lower_center_to_center=z_lower_center_to_center,
3752
+ upper_center_to_center=z_upper_center_to_center,
3753
+ )
3754
+ dA_Y_dzeta = _boundary_corrected_derivative(
3755
+ A_Y,
3756
+ geometry.spacing.dz,
3757
+ axis=2,
3758
+ component=1,
3759
+ periodic=periodic_axes[2],
3760
+ lower_face_value=z_lower_A_Y,
3761
+ upper_face_value=z_upper_A_Y,
3762
+ lower_center_to_face=z_lower_center_to_face,
3763
+ upper_center_to_face=z_upper_center_to_face,
3764
+ lower_center_to_center=z_lower_center_to_center,
3765
+ upper_center_to_center=z_upper_center_to_center,
3766
+ )
3767
+ inv_rho = 1.0 / rho_safe
3768
+ dA_X_dY = sin_theta * dA_X_drho + cos_theta * inv_rho * dA_X_dtheta
3769
+ dA_Y_dX = cos_theta * dA_Y_drho - sin_theta * inv_rho * dA_Y_dtheta
3770
+ dA_Z_dX = cos_theta * dA_Z_drho - sin_theta * inv_rho * dA_Z_dtheta
3771
+ dA_Z_dY = sin_theta * dA_Z_drho + cos_theta * inv_rho * dA_Z_dtheta
3772
+
3773
+ cartesian_curl = jnp.stack(
3774
+ (
3775
+ dA_Z_dY - dA_Y_dzeta,
3776
+ dA_X_dzeta - dA_Z_dX,
3777
+ dA_Y_dX - dA_X_dY,
3778
+ ),
3779
+ axis=-1,
3780
+ )
3781
+ cartesian_coefficient = (
3782
+ bmag
3783
+ * rho_safe
3784
+ / (2.0 * jnp.maximum(jnp.asarray(metric.J, dtype=jnp.float64), float(jacobian_floor)))
3785
+ )
3786
+ C_X = cartesian_coefficient * cartesian_curl[..., 0]
3787
+ C_Y = cartesian_coefficient * cartesian_curl[..., 1]
3788
+ C_Z = cartesian_coefficient * cartesian_curl[..., 2]
3789
+ axis_regular_lower_coefficients = jnp.stack(
3790
+ (
3791
+ C_X * cos_theta + C_Y * sin_theta,
3792
+ (-C_X * sin_theta + C_Y * cos_theta) / rho_safe,
3793
+ C_Z,
3794
+ ),
3795
+ axis=-1,
3796
+ )
3797
+ curvature_coefficients = curvature_coefficients.at[0].set(axis_regular_lower_coefficients[0])
3798
+
3799
+ return curvature_coefficients
3800
+
3801
+
3802
+ def _physical_domain_valid_mask(
3803
+ grid: CellCenteredGrid3D,
3804
+ x: jnp.ndarray,
3805
+ y: jnp.ndarray,
3806
+ z: jnp.ndarray,
3807
+ *,
3808
+ periodic_axes: tuple[bool, bool, bool],
3809
+ ) -> jnp.ndarray:
3810
+ valid_x = jnp.isfinite(x) if periodic_axes[0] else (jnp.isfinite(x) & (x >= grid.x.faces[0]) & (x <= grid.x.faces[-1]))
3811
+ valid_y = jnp.isfinite(y) if periodic_axes[1] else (jnp.isfinite(y) & (y >= grid.y.faces[0]) & (y <= grid.y.faces[-1]))
3812
+ valid_z = jnp.isfinite(z) if periodic_axes[2] else (jnp.isfinite(z) & (z >= grid.z.faces[0]) & (z <= grid.z.faces[-1]))
3813
+ return valid_x & valid_y & valid_z
3814
+
3815
+
3816
+ def _extend_axis_with_ghost_cells(grid_axis: Grid1D) -> jnp.ndarray:
3817
+ centers = grid_axis.centers
3818
+ faces = grid_axis.faces
3819
+ lower = 2.0 * faces[0] - centers[0]
3820
+ upper = 2.0 * faces[-1] - centers[-1]
3821
+ return jnp.concatenate([
3822
+ jnp.asarray([lower], dtype=jnp.float64),
3823
+ centers,
3824
+ jnp.asarray([upper], dtype=jnp.float64),
3825
+ ])
3826
+
3827
+ def _extend_values_x(values: jnp.ndarray, grid_axis: Grid1D) -> jnp.ndarray:
3828
+ centers = grid_axis.centers
3829
+ faces = grid_axis.faces
3830
+ xg_lower = 2.0 * faces[0] - centers[0]
3831
+ xg_upper = 2.0 * faces[-1] - centers[-1]
3832
+
3833
+ if values.shape[0] == 1:
3834
+ lower = values[0:1, :, :]
3835
+ upper = values[-1:, :, :]
3836
+ else:
3837
+ lower_slope = (values[1, :, :] - values[0, :, :]) / (centers[1] - centers[0])
3838
+ upper_slope = (values[-1, :, :] - values[-2, :, :]) / (centers[-1] - centers[-2])
3839
+
3840
+ lower = (values[0, :, :] + (xg_lower - centers[0]) * lower_slope)[None, :, :]
3841
+ upper = (values[-1, :, :] + (xg_upper - centers[-1]) * upper_slope)[None, :, :]
3842
+
3843
+ return jnp.concatenate([lower, values, upper], axis=0)
3844
+
3845
+
3846
+ def _extend_values_y(values: jnp.ndarray, grid_axis: Grid1D) -> jnp.ndarray:
3847
+ centers = grid_axis.centers
3848
+ faces = grid_axis.faces
3849
+ yg_lower = 2.0 * faces[0] - centers[0]
3850
+ yg_upper = 2.0 * faces[-1] - centers[-1]
3851
+
3852
+ if values.shape[1] == 1:
3853
+ lower = values[:, 0:1, :]
3854
+ upper = values[:, -1:, :]
3855
+ else:
3856
+ lower_slope = (values[:, 1, :] - values[:, 0, :]) / (centers[1] - centers[0])
3857
+ upper_slope = (values[:, -1, :] - values[:, -2, :]) / (centers[-1] - centers[-2])
3858
+
3859
+ lower = (values[:, 0, :] + (yg_lower - centers[0]) * lower_slope)[:, None, :]
3860
+ upper = (values[:, -1, :] + (yg_upper - centers[-1]) * upper_slope)[:, None, :]
3861
+
3862
+ return jnp.concatenate([lower, values, upper], axis=1)
3863
+
3864
+
3865
+ def _extend_values_z(values: jnp.ndarray, grid_axis: Grid1D) -> jnp.ndarray:
3866
+ centers = grid_axis.centers
3867
+ faces = grid_axis.faces
3868
+ zg_lower = 2.0 * faces[0] - centers[0]
3869
+ zg_upper = 2.0 * faces[-1] - centers[-1]
3870
+
3871
+ if values.shape[2] == 1:
3872
+ lower = values[:, :, 0:1]
3873
+ upper = values[:, :, -1:]
3874
+ else:
3875
+ lower_slope = (values[:, :, 1] - values[:, :, 0]) / (centers[1] - centers[0])
3876
+ upper_slope = (values[:, :, -1] - values[:, :, -2]) / (centers[-1] - centers[-2])
3877
+
3878
+ lower = (values[:, :, 0] + (zg_lower - centers[0]) * lower_slope)[:, :, None]
3879
+ upper = (values[:, :, -1] + (zg_upper - centers[-1]) * upper_slope)[:, :, None]
3880
+
3881
+ return jnp.concatenate([lower, values, upper], axis=2)
3882
+
3883
+ def _interpolate_scalar_cell_centered(
3884
+ values: jnp.ndarray,
3885
+ x: jnp.ndarray,
3886
+ y: jnp.ndarray,
3887
+ z: jnp.ndarray,
3888
+ *,
3889
+ grid: CellCenteredGrid3D,
3890
+ periodic_axes: tuple[bool, bool, bool],
3891
+ boundary_value: float,
3892
+ ) -> jnp.ndarray:
3893
+ values = jnp.asarray(values, dtype=jnp.float64)
3894
+ x = jnp.asarray(x, dtype=jnp.float64)
3895
+ y = jnp.asarray(y, dtype=jnp.float64)
3896
+ z = jnp.asarray(z, dtype=jnp.float64)
3897
+
3898
+ if values.shape != grid.shape:
3899
+ raise ValueError(f"values must have shape {grid.shape}, got {values.shape}")
3900
+
3901
+ valid = _physical_domain_valid_mask(
3902
+ grid,
3903
+ x,
3904
+ y,
3905
+ z,
3906
+ periodic_axes=periodic_axes,
3907
+ )
3908
+
3909
+ if periodic_axes[0]:
3910
+ x_axis = grid.x.centers
3911
+ x0, x1, wx, _ = _bracket_axis(x_axis, x, periodic=True)
3912
+ else:
3913
+ values = _extend_values_x(values, grid.x)
3914
+ x_axis = _extend_axis_with_ghost_cells(grid.x)
3915
+ x0, x1, wx, _ = _bracket_axis(x_axis, x, periodic=False)
3916
+
3917
+ if periodic_axes[1]:
3918
+ y_axis = grid.y.centers
3919
+ y0, y1, wy, _ = _bracket_axis(y_axis, y, periodic=True)
3920
+ else:
3921
+ values = _extend_values_y(values, grid.y)
3922
+ y_axis = _extend_axis_with_ghost_cells(grid.y)
3923
+ y0, y1, wy, _ = _bracket_axis(y_axis, y, periodic=False)
3924
+
3925
+ if periodic_axes[2]:
3926
+ z_axis = grid.z.centers
3927
+ z0, z1, wz, _ = _bracket_axis(z_axis, z, periodic=True)
3928
+ else:
3929
+ values = _extend_values_z(values, grid.z)
3930
+ z_axis = _extend_axis_with_ghost_cells(grid.z)
3931
+ z0, z1, wz, _ = _bracket_axis(z_axis, z, periodic=False)
3932
+
3933
+ interpolated = _trilinear_sample(values, x0, x1, wx, y0, y1, wy, z0, z1, wz)
3934
+ return jnp.where(
3935
+ valid,
3936
+ interpolated,
3937
+ jnp.asarray(boundary_value, dtype=jnp.float64),
3938
+ )
3939
+
3940
+
3941
+ def _interpolate_B_contravariant_cell_centered(
3942
+ grid: CellCenteredGrid3D,
3943
+ B_contra_cell: jnp.ndarray,
3944
+ points: jnp.ndarray,
3945
+ *,
3946
+ periodic_axes: tuple[bool, bool, bool],
3947
+ boundary_value: float,
3948
+ ) -> jnp.ndarray:
3949
+ sampled_points = jnp.asarray(points, dtype=jnp.float64)
3950
+ if sampled_points.shape == (3,):
3951
+ sampled_points = sampled_points[None, :]
3952
+ squeeze = True
3953
+ else:
3954
+ if sampled_points.ndim < 1 or sampled_points.shape[-1] != 3:
3955
+ raise ValueError(f"points must have shape (3,) or (..., 3), got {sampled_points.shape}")
3956
+ squeeze = False
3957
+
3958
+ B_contra_cell = jnp.asarray(B_contra_cell, dtype=jnp.float64)
3959
+ if B_contra_cell.shape != grid.shape + (3,):
3960
+ raise ValueError(f"B_contra_cell must have shape {grid.shape + (3,)}, got {B_contra_cell.shape}")
3961
+
3962
+ samples = []
3963
+ for component in range(3):
3964
+ samples.append(
3965
+ _interpolate_scalar_cell_centered(
3966
+ B_contra_cell[..., component],
3967
+ sampled_points[..., 0],
3968
+ sampled_points[..., 1],
3969
+ sampled_points[..., 2],
3970
+ grid=grid,
3971
+ periodic_axes=periodic_axes,
3972
+ boundary_value=boundary_value,
3973
+ )
3974
+ )
3975
+ result = jnp.stack(samples, axis=-1)
3976
+ return result[0] if squeeze else result
3977
+
3978
+
3979
+ def _rk4_step_cell_centered(
3980
+ grid: CellCenteredGrid3D,
3981
+ B_contra_cell: jnp.ndarray,
3982
+ point: jnp.ndarray,
3983
+ step: float,
3984
+ *,
3985
+ periodic_axes: tuple[bool, bool, bool],
3986
+ min_abs_bz: float,
3987
+ boundary_value: float,
3988
+ ) -> jnp.ndarray:
3989
+ state = jnp.asarray(point, dtype=jnp.float64)
3990
+ if state.shape != (3,):
3991
+ raise ValueError(f"point must have shape (3,), got {state.shape}")
3992
+ h = float(step)
3993
+
3994
+ def rhs(value: jnp.ndarray) -> jnp.ndarray:
3995
+ b = _interpolate_B_contravariant_cell_centered(
3996
+ grid,
3997
+ B_contra_cell,
3998
+ value,
3999
+ periodic_axes=periodic_axes,
4000
+ boundary_value=boundary_value,
4001
+ )
4002
+ bz = jnp.asarray(b[2], dtype=jnp.float64)
4003
+ safe_bz = jnp.where(jnp.abs(bz) < min_abs_bz, jnp.where(bz < 0.0, -1.0, 1.0) * (min_abs_bz), bz)
4004
+ return jnp.stack((b[0] / safe_bz, b[1] / safe_bz, jnp.array(1.0, dtype=jnp.float64)))
4005
+
4006
+ k1 = rhs(state)
4007
+ k2 = rhs(state + 0.5 * h * k1)
4008
+ k3 = rhs(state + 0.5 * h * k2)
4009
+ k4 = rhs(state + h * k3)
4010
+ return state + (h / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
4011
+
4012
+
4013
+ def _trace_fieldline_to_plane_cell_centered(
4014
+ grid: CellCenteredGrid3D,
4015
+ B_contra_cell: jnp.ndarray,
4016
+ Bmag_cell: jnp.ndarray,
4017
+ seed_points: jnp.ndarray,
4018
+ *,
4019
+ step: float,
4020
+ substeps: int,
4021
+ periodic_axes: tuple[bool, bool, bool],
4022
+ min_abs_bz: float,
4023
+ boundary_value: float,
4024
+ ) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
4025
+ points = jnp.asarray(seed_points, dtype=jnp.float64)
4026
+ if points.ndim != 2 or points.shape[-1] != 3:
4027
+ raise ValueError(f"seed_points must have shape (n, 3), got {points.shape}")
4028
+
4029
+ nseed = int(points.shape[0])
4030
+ step_size = float(step) / float(max(int(substeps), 1))
4031
+ length = jnp.zeros(nseed, dtype=jnp.float64)
4032
+ alive = jnp.ones(nseed, dtype=bool)
4033
+ state = points
4034
+
4035
+ def _speed(sampled_b: jnp.ndarray, sampled_bmag: jnp.ndarray) -> jnp.ndarray:
4036
+ bz = sampled_b[..., 2]
4037
+ safe_bz = jnp.where(
4038
+ jnp.abs(bz) < min_abs_bz,
4039
+ jnp.where(bz < 0.0, -1.0, 1.0) * min_abs_bz,
4040
+ bz,
4041
+ )
4042
+ return jnp.asarray(sampled_bmag, dtype=jnp.float64) / jnp.maximum(jnp.abs(safe_bz), 1.0e-30)
4043
+
4044
+ for _ in range(max(int(substeps), 1)):
4045
+ b0 = jax.vmap(
4046
+ lambda point: _interpolate_B_contravariant_cell_centered(
4047
+ grid,
4048
+ B_contra_cell,
4049
+ point,
4050
+ periodic_axes=periodic_axes,
4051
+ boundary_value=boundary_value,
4052
+ )
4053
+ )(state)
4054
+ bmag0 = _interpolate_scalar_cell_centered(
4055
+ Bmag_cell,
4056
+ state[:, 0],
4057
+ state[:, 1],
4058
+ state[:, 2],
4059
+ grid=grid,
4060
+ periodic_axes=periodic_axes,
4061
+ boundary_value=boundary_value,
4062
+ )
4063
+ next_state = jax.vmap(
4064
+ lambda point: _rk4_step_cell_centered(
4065
+ grid,
4066
+ B_contra_cell,
4067
+ point,
4068
+ step_size,
4069
+ periodic_axes=periodic_axes,
4070
+ min_abs_bz=min_abs_bz,
4071
+ boundary_value=boundary_value,
4072
+ )
4073
+ )(state)
4074
+ bmag1 = _interpolate_scalar_cell_centered(
4075
+ Bmag_cell,
4076
+ next_state[:, 0],
4077
+ next_state[:, 1],
4078
+ next_state[:, 2],
4079
+ grid=grid,
4080
+ periodic_axes=periodic_axes,
4081
+ boundary_value=boundary_value,
4082
+ )
4083
+ b1 = jax.vmap(
4084
+ lambda point: _interpolate_B_contravariant_cell_centered(
4085
+ grid,
4086
+ B_contra_cell,
4087
+ point,
4088
+ periodic_axes=periodic_axes,
4089
+ boundary_value=boundary_value,
4090
+ )
4091
+ )(next_state)
4092
+ finite = jnp.all(jnp.isfinite(next_state), axis=-1)
4093
+ valid = _physical_domain_valid_mask(
4094
+ grid,
4095
+ next_state[:, 0],
4096
+ next_state[:, 1],
4097
+ next_state[:, 2],
4098
+ periodic_axes=periodic_axes,
4099
+ )
4100
+ increment = 0.5 * abs(step_size) * (_speed(b0, bmag0) + _speed(b1, bmag1))
4101
+ increment = jnp.where(alive & finite & valid, increment, 0.0)
4102
+ length = length + increment
4103
+ state = jnp.where((alive & finite & valid)[..., None], next_state, state)
4104
+ alive = alive & finite & valid
4105
+
4106
+ return state, length, ~alive
4107
+
4108
+
4109
+ def build_fci_maps_from_b_contravariant(
4110
+ grid: CellCenteredGrid3D,
4111
+ B_contra_cell: jnp.ndarray,
4112
+ Bmag_cell: jnp.ndarray,
4113
+ *,
4114
+ substeps: int = 4,
4115
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
4116
+ min_abs_bz: float = 1.0e-30,
4117
+ boundary_value: float = jnp.nan,
4118
+ ) -> dict[str, jnp.ndarray]:
4119
+ """JIT-compatible FCI map builder for a cell-centered grid.
4120
+
4121
+ Improved boundary behavior:
4122
+ - Traces start from active cell centers.
4123
+ - If a trace reaches the target toroidal plane, normal FCI maps are stored.
4124
+ - If a trace exits a nonperiodic physical boundary first, the boundary
4125
+ mask is set and the endpoint is estimated at the boundary face.
4126
+ - forward_length/backward_length are distances to the actual endpoint:
4127
+ target plane if no boundary hit,
4128
+ boundary hit point if boundary=True.
4129
+
4130
+ For jitting:
4131
+
4132
+ build_maps_jit = jax.jit(
4133
+ build_fci_maps_from_b_contravariant,
4134
+ static_argnames=("substeps", "periodic_axes"),
4135
+ )
4136
+ """
4137
+
4138
+ shape = grid.shape
4139
+ nx, ny, nz = shape
4140
+ nxy = nx * ny
4141
+
4142
+ B_contra_cell = jnp.asarray(B_contra_cell, dtype=jnp.float64)
4143
+ Bmag_cell = jnp.asarray(Bmag_cell, dtype=jnp.float64)
4144
+
4145
+ if B_contra_cell.shape != shape + (3,):
4146
+ raise ValueError(
4147
+ f"B_contra_cell must have shape {shape + (3,)}, got {B_contra_cell.shape}"
4148
+ )
4149
+ if Bmag_cell.shape != shape:
4150
+ raise ValueError(f"Bmag_cell must have shape {shape}, got {Bmag_cell.shape}")
4151
+
4152
+ n_substeps = int(substeps)
4153
+ if n_substeps < 1:
4154
+ raise ValueError(f"substeps must be >= 1, got {substeps}")
4155
+
4156
+ x_axis = grid.x.centers
4157
+ y_axis = grid.y.centers
4158
+ z_axis = grid.z.centers
4159
+
4160
+ xx = jnp.broadcast_to(x_axis[:, None], (nx, ny)).reshape(-1)
4161
+ yy = jnp.broadcast_to(y_axis[None, :], (nx, ny)).reshape(-1)
4162
+
4163
+ periodic_x = bool(periodic_axes[0])
4164
+ periodic_y = bool(periodic_axes[1])
4165
+ periodic_z = bool(periodic_axes[2])
4166
+
4167
+ min_bz = jnp.asarray(min_abs_bz, dtype=jnp.float64)
4168
+
4169
+ def _wrap_periodic_coord(coord: jnp.ndarray, axis: Grid1D, periodic: bool) -> jnp.ndarray:
4170
+ if periodic:
4171
+ period = axis.faces[-1] - axis.faces[0]
4172
+ return jnp.mod(coord - axis.faces[0], period) + axis.faces[0]
4173
+ return coord
4174
+
4175
+ def _wrap_points(points: jnp.ndarray) -> jnp.ndarray:
4176
+ return jnp.stack(
4177
+ (
4178
+ _wrap_periodic_coord(points[..., 0], grid.x, periodic_x),
4179
+ _wrap_periodic_coord(points[..., 1], grid.y, periodic_y),
4180
+ _wrap_periodic_coord(points[..., 2], grid.z, periodic_z),
4181
+ ),
4182
+ axis=-1,
4183
+ )
4184
+
4185
+ def _plane_step_jit(k: jnp.ndarray, direction: int) -> jnp.ndarray:
4186
+ """Signed z step from plane k to neighboring plane."""
4187
+
4188
+ if nz < 2:
4189
+ return jnp.asarray(direction, dtype=jnp.float64)
4190
+
4191
+ dz_lower = z_axis[1] - z_axis[0]
4192
+ dz_upper = z_axis[-1] - z_axis[-2]
4193
+ period = (z_axis[-1] - z_axis[0]) + dz_lower
4194
+
4195
+ if direction >= 0:
4196
+ k_next = jnp.minimum(k + 1, nz - 1)
4197
+ interior_step = z_axis[k_next] - z_axis[k]
4198
+
4199
+ if periodic_z:
4200
+ boundary_step = (z_axis[0] + period) - z_axis[-1]
4201
+ else:
4202
+ boundary_step = dz_upper
4203
+
4204
+ return jnp.where(k < nz - 1, interior_step, boundary_step)
4205
+
4206
+ k_prev = jnp.maximum(k - 1, 0)
4207
+ interior_step = -(z_axis[k] - z_axis[k_prev])
4208
+
4209
+ if periodic_z:
4210
+ boundary_step = (z_axis[-1] - period) - z_axis[0]
4211
+ else:
4212
+ boundary_step = -dz_lower
4213
+
4214
+ return jnp.where(k > 0, interior_step, boundary_step)
4215
+
4216
+ def _interp_B(points: jnp.ndarray) -> jnp.ndarray:
4217
+ return _interpolate_B_contravariant_cell_centered(
4218
+ grid,
4219
+ B_contra_cell,
4220
+ points,
4221
+ periodic_axes=periodic_axes,
4222
+ boundary_value=boundary_value,
4223
+ )
4224
+
4225
+ def _interp_Bmag(points: jnp.ndarray) -> jnp.ndarray:
4226
+ return _interpolate_scalar_cell_centered(
4227
+ Bmag_cell,
4228
+ points[..., 0],
4229
+ points[..., 1],
4230
+ points[..., 2],
4231
+ grid=grid,
4232
+ periodic_axes=periodic_axes,
4233
+ boundary_value=boundary_value,
4234
+ )
4235
+
4236
+ def _safe_bz(bz: jnp.ndarray) -> jnp.ndarray:
4237
+ return jnp.where(
4238
+ jnp.abs(bz) < min_bz,
4239
+ jnp.where(bz < 0.0, -1.0, 1.0) * min_bz,
4240
+ bz,
4241
+ )
4242
+
4243
+ def _rhs(points: jnp.ndarray) -> jnp.ndarray:
4244
+ b = _interp_B(points)
4245
+ bz = _safe_bz(b[..., 2])
4246
+ return jnp.stack(
4247
+ (
4248
+ b[..., 0] / bz,
4249
+ b[..., 1] / bz,
4250
+ jnp.ones_like(bz),
4251
+ ),
4252
+ axis=-1,
4253
+ )
4254
+
4255
+ def _rk4_batch(points: jnp.ndarray, h: jnp.ndarray) -> jnp.ndarray:
4256
+ k1 = _rhs(points)
4257
+ k2 = _rhs(points + 0.5 * h * k1)
4258
+ k3 = _rhs(points + 0.5 * h * k2)
4259
+ k4 = _rhs(points + h * k3)
4260
+ return points + (h / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
4261
+
4262
+ def _speed(sampled_b: jnp.ndarray, sampled_bmag: jnp.ndarray) -> jnp.ndarray:
4263
+ bz = _safe_bz(sampled_b[..., 2])
4264
+ return sampled_bmag / jnp.maximum(jnp.abs(bz), 1.0e-30)
4265
+
4266
+ def _axis_crossing_fraction(
4267
+ old: jnp.ndarray,
4268
+ new: jnp.ndarray,
4269
+ lower: jnp.ndarray,
4270
+ upper: jnp.ndarray,
4271
+ periodic: bool,
4272
+ ) -> jnp.ndarray:
4273
+ """Fraction along old->new where a nonperiodic axis hits a boundary.
4274
+
4275
+ Returns inf if this axis does not produce a boundary crossing.
4276
+ """
4277
+
4278
+ if periodic:
4279
+ return jnp.full_like(old, jnp.inf, dtype=jnp.float64)
4280
+
4281
+ denom = new - old
4282
+ safe_denom = jnp.where(jnp.abs(denom) < 1.0e-300, 1.0, denom)
4283
+
4284
+ crosses_lower = new < lower
4285
+ crosses_upper = new > upper
4286
+
4287
+ t_lower = (lower - old) / safe_denom
4288
+ t_upper = (upper - old) / safe_denom
4289
+
4290
+ t = jnp.where(crosses_lower, t_lower, jnp.inf)
4291
+ t = jnp.minimum(t, jnp.where(crosses_upper, t_upper, jnp.inf))
4292
+
4293
+ valid_t = (t >= 0.0) & (t <= 1.0)
4294
+ return jnp.where(valid_t, t, jnp.inf)
4295
+
4296
+ def _boundary_hit_state(
4297
+ old_state: jnp.ndarray,
4298
+ new_state: jnp.ndarray,
4299
+ finite_new: jnp.ndarray,
4300
+ valid_new: jnp.ndarray,
4301
+ ) -> tuple[jnp.ndarray, jnp.ndarray]:
4302
+ """Estimate physical boundary hit location between old_state and new_state.
4303
+
4304
+ Assumes old_state is inside the domain. If new_state leaves the domain,
4305
+ compute the first face intersection along the straight RK substep chord.
4306
+ """
4307
+
4308
+ tx = _axis_crossing_fraction(
4309
+ old_state[:, 0],
4310
+ new_state[:, 0],
4311
+ grid.x.faces[0],
4312
+ grid.x.faces[-1],
4313
+ periodic_x,
4314
+ )
4315
+ ty = _axis_crossing_fraction(
4316
+ old_state[:, 1],
4317
+ new_state[:, 1],
4318
+ grid.y.faces[0],
4319
+ grid.y.faces[-1],
4320
+ periodic_y,
4321
+ )
4322
+ tz = _axis_crossing_fraction(
4323
+ old_state[:, 2],
4324
+ new_state[:, 2],
4325
+ grid.z.faces[0],
4326
+ grid.z.faces[-1],
4327
+ periodic_z,
4328
+ )
4329
+
4330
+ t_hit = jnp.minimum(jnp.minimum(tx, ty), tz)
4331
+ has_hit = finite_new & (~valid_new) & jnp.isfinite(t_hit)
4332
+
4333
+ t_used = jnp.where(has_hit, t_hit, 1.0)
4334
+ hit_state = old_state + t_used[:, None] * (new_state - old_state)
4335
+
4336
+ # Clamp nonperiodic hit coordinates exactly to the face-bounded domain.
4337
+ hx = hit_state[:, 0] if periodic_x else jnp.clip(hit_state[:, 0], grid.x.faces[0], grid.x.faces[-1])
4338
+ hy = hit_state[:, 1] if periodic_y else jnp.clip(hit_state[:, 1], grid.y.faces[0], grid.y.faces[-1])
4339
+ hz = hit_state[:, 2] if periodic_z else jnp.clip(hit_state[:, 2], grid.z.faces[0], grid.z.faces[-1])
4340
+
4341
+ hit_state = jnp.stack((hx, hy, hz), axis=-1)
4342
+ hit_state = _wrap_points(hit_state)
4343
+
4344
+ return hit_state, jnp.where(has_hit, t_hit, 1.0)
4345
+
4346
+ def _trace_to_plane(seed_points: jnp.ndarray, step: jnp.ndarray):
4347
+ step_size = step / jnp.asarray(n_substeps, dtype=jnp.float64)
4348
+
4349
+ init_state = seed_points
4350
+ init_length = jnp.zeros(seed_points.shape[0], dtype=jnp.float64)
4351
+ init_alive = jnp.ones(seed_points.shape[0], dtype=bool)
4352
+ init_boundary = jnp.zeros(seed_points.shape[0], dtype=bool)
4353
+
4354
+ def substep_body(carry, _):
4355
+ state, length, alive, boundary = carry
4356
+
4357
+ b0 = _interp_B(state)
4358
+ bmag0 = _interp_Bmag(state)
4359
+ speed0 = _speed(b0, bmag0)
4360
+
4361
+ raw_next_state = _rk4_batch(state, step_size)
4362
+ finite_next = jnp.all(jnp.isfinite(raw_next_state), axis=-1)
4363
+
4364
+ # For nonfinite results, keep the old state to avoid NaN pollution.
4365
+ next_state_finite = jnp.where(finite_next[:, None], raw_next_state, state)
4366
+
4367
+ valid_next = _physical_domain_valid_mask(
4368
+ grid,
4369
+ next_state_finite[:, 0],
4370
+ next_state_finite[:, 1],
4371
+ next_state_finite[:, 2],
4372
+ periodic_axes=periodic_axes,
4373
+ )
4374
+
4375
+ active_full = alive & finite_next & valid_next
4376
+ active_exit = alive & finite_next & (~valid_next)
4377
+ active_bad = alive & (~finite_next)
4378
+
4379
+ hit_state, t_hit = _boundary_hit_state(
4380
+ state,
4381
+ next_state_finite,
4382
+ finite_next,
4383
+ valid_next,
4384
+ )
4385
+
4386
+ b1 = _interp_B(next_state_finite)
4387
+ bmag1 = _interp_Bmag(next_state_finite)
4388
+ speed1 = _speed(b1, bmag1)
4389
+
4390
+ b_hit = _interp_B(hit_state)
4391
+ bmag_hit = _interp_Bmag(hit_state)
4392
+ speed_hit = _speed(b_hit, bmag_hit)
4393
+
4394
+ full_increment = 0.5 * jnp.abs(step_size) * (speed0 + speed1)
4395
+ hit_increment = 0.5 * jnp.abs(step_size) * t_hit * (speed0 + speed_hit)
4396
+
4397
+ increment = jnp.where(active_full, full_increment, 0.0)
4398
+ increment = increment + jnp.where(active_exit, hit_increment, 0.0)
4399
+
4400
+ new_length = length + increment
4401
+
4402
+ endpoint_state = jnp.where(active_full[:, None], next_state_finite, state)
4403
+ endpoint_state = jnp.where(active_exit[:, None], hit_state, endpoint_state)
4404
+ endpoint_state = _wrap_points(endpoint_state)
4405
+
4406
+ new_alive = alive & finite_next & valid_next
4407
+ new_boundary = boundary | active_exit | active_bad
4408
+
4409
+ return (endpoint_state, new_length, new_alive, new_boundary), None
4410
+
4411
+ final_state, final_length, final_alive, final_boundary = jax.lax.scan(
4412
+ substep_body,
4413
+ (init_state, init_length, init_alive, init_boundary),
4414
+ xs=None,
4415
+ length=n_substeps,
4416
+ )[0]
4417
+
4418
+ del final_alive
4419
+ return final_state, final_length, final_boundary
4420
+
4421
+ def plane_body(_carry, k):
4422
+ z_k = jnp.full((nxy,), z_axis[k], dtype=jnp.float64)
4423
+ seed_points = jnp.stack((xx, yy, z_k), axis=-1)
4424
+
4425
+ forward_step = _plane_step_jit(k, direction=1)
4426
+ backward_step = _plane_step_jit(k, direction=-1)
4427
+
4428
+ forward_points, forward_length, forward_boundary = _trace_to_plane(
4429
+ seed_points,
4430
+ forward_step,
4431
+ )
4432
+ backward_points, backward_length, backward_boundary = _trace_to_plane(
4433
+ seed_points,
4434
+ backward_step,
4435
+ )
4436
+
4437
+ forward_x = _logical_coordinate_to_index(
4438
+ x_axis,
4439
+ forward_points[:, 0],
4440
+ periodic=periodic_x,
4441
+ )
4442
+ forward_y = _logical_coordinate_to_index(
4443
+ y_axis,
4444
+ forward_points[:, 1],
4445
+ periodic=periodic_y,
4446
+ )
4447
+ backward_x = _logical_coordinate_to_index(
4448
+ x_axis,
4449
+ backward_points[:, 0],
4450
+ periodic=periodic_x,
4451
+ )
4452
+ backward_y = _logical_coordinate_to_index(
4453
+ y_axis,
4454
+ backward_points[:, 1],
4455
+ periodic=periodic_y,
4456
+ )
4457
+
4458
+ dz_plane = jnp.full((nxy,), jnp.abs(forward_step), dtype=jnp.float64)
4459
+
4460
+ outputs = (
4461
+ forward_x,
4462
+ forward_y,
4463
+ backward_x,
4464
+ backward_y,
4465
+ forward_points[:, 0],
4466
+ forward_points[:, 1],
4467
+ forward_points[:, 2],
4468
+ backward_points[:, 0],
4469
+ backward_points[:, 1],
4470
+ backward_points[:, 2],
4471
+ forward_length,
4472
+ backward_length,
4473
+ forward_boundary,
4474
+ backward_boundary,
4475
+ dz_plane,
4476
+ )
4477
+
4478
+ return None, outputs
4479
+
4480
+ _, scanned = jax.lax.scan(
4481
+ plane_body,
4482
+ None,
4483
+ jnp.arange(nz),
4484
+ )
4485
+
4486
+ (
4487
+ forward_x_k,
4488
+ forward_y_k,
4489
+ backward_x_k,
4490
+ backward_y_k,
4491
+ forward_endpoint_x_k,
4492
+ forward_endpoint_y_k,
4493
+ forward_endpoint_z_k,
4494
+ backward_endpoint_x_k,
4495
+ backward_endpoint_y_k,
4496
+ backward_endpoint_z_k,
4497
+ forward_length_k,
4498
+ backward_length_k,
4499
+ forward_boundary_k,
4500
+ backward_boundary_k,
4501
+ dz_k,
4502
+ ) = scanned
4503
+
4504
+ def _planes_to_grid(arr_k_nxy: jnp.ndarray) -> jnp.ndarray:
4505
+ return jnp.swapaxes(arr_k_nxy, 0, 1).reshape(shape)
4506
+
4507
+ return {
4508
+ "forward_x": _planes_to_grid(forward_x_k),
4509
+ "forward_y": _planes_to_grid(forward_y_k),
4510
+ "backward_x": _planes_to_grid(backward_x_k),
4511
+ "backward_y": _planes_to_grid(backward_y_k),
4512
+ "forward_endpoint_x": _planes_to_grid(forward_endpoint_x_k),
4513
+ "forward_endpoint_y": _planes_to_grid(forward_endpoint_y_k),
4514
+ "forward_endpoint_z": _planes_to_grid(forward_endpoint_z_k),
4515
+ "backward_endpoint_x": _planes_to_grid(backward_endpoint_x_k),
4516
+ "backward_endpoint_y": _planes_to_grid(backward_endpoint_y_k),
4517
+ "backward_endpoint_z": _planes_to_grid(backward_endpoint_z_k),
4518
+ "forward_length": _planes_to_grid(forward_length_k),
4519
+ "backward_length": _planes_to_grid(backward_length_k),
4520
+ "forward_boundary": _planes_to_grid(forward_boundary_k),
4521
+ "backward_boundary": _planes_to_grid(backward_boundary_k),
4522
+ "dz": _planes_to_grid(dz_k),
4523
+ }
4524
+
4525
+ def _bracket_axis(
4526
+ axis: jnp.ndarray,
4527
+ values: jnp.ndarray,
4528
+ *,
4529
+ periodic: bool,
4530
+ ) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray, jnp.ndarray]:
4531
+ axis = jnp.asarray(axis, dtype=jnp.float64)
4532
+ values = jnp.asarray(values, dtype=jnp.float64)
4533
+ if axis.size == 1:
4534
+ zero = jnp.zeros_like(values, dtype=jnp.int32)
4535
+ return zero, zero, jnp.zeros_like(values, dtype=jnp.float64), jnp.isfinite(values)
4536
+ if axis.size < 1:
4537
+ raise ValueError("Each logical axis must contain at least one point for interpolation.")
4538
+
4539
+ if periodic:
4540
+ spacing = axis[1] - axis[0]
4541
+ period = (axis[-1] - axis[0]) + spacing
4542
+ wrapped = jnp.mod(values - axis[0], period) + axis[0]
4543
+ upper = jnp.searchsorted(axis, wrapped, side="right")
4544
+ lower = jnp.clip(upper - 1, 0, int(axis.size) - 1)
4545
+ next_index = jnp.mod(lower + 1, int(axis.size))
4546
+ lower_coord = axis[lower]
4547
+ upper_coord = jnp.where(lower == int(axis.size) - 1, axis[0] + period, axis[next_index])
4548
+ weight = (wrapped - lower_coord) / (upper_coord - lower_coord)
4549
+ valid = jnp.isfinite(values)
4550
+ return lower, next_index, jnp.clip(weight, 0.0, 1.0), valid
4551
+
4552
+ upper = jnp.searchsorted(axis, values, side="right")
4553
+ lower = jnp.clip(upper - 1, 0, int(axis.size) - 2)
4554
+ upper = lower + 1
4555
+ lower_coord = axis[lower]
4556
+ upper_coord = axis[upper]
4557
+ weight = (values - lower_coord) / (upper_coord - lower_coord)
4558
+ valid = (values >= axis[0]) & (values <= axis[-1]) & jnp.isfinite(values)
4559
+ return lower, upper, jnp.clip(weight, 0.0, 1.0), valid
4560
+
4561
+
4562
+ def _trilinear_sample(
4563
+ values: jnp.ndarray,
4564
+ x0: jnp.ndarray,
4565
+ x1: jnp.ndarray,
4566
+ wx: jnp.ndarray,
4567
+ y0: jnp.ndarray,
4568
+ y1: jnp.ndarray,
4569
+ wy: jnp.ndarray,
4570
+ z0: jnp.ndarray,
4571
+ z1: jnp.ndarray,
4572
+ wz: jnp.ndarray,
4573
+ ) -> jnp.ndarray:
4574
+ c000 = values[x0, y0, z0]
4575
+ c100 = values[x1, y0, z0]
4576
+ c010 = values[x0, y1, z0]
4577
+ c110 = values[x1, y1, z0]
4578
+ c001 = values[x0, y0, z1]
4579
+ c101 = values[x1, y0, z1]
4580
+ c011 = values[x0, y1, z1]
4581
+ c111 = values[x1, y1, z1]
4582
+ c00 = c000 * (1.0 - wx) + c100 * wx
4583
+ c10 = c010 * (1.0 - wx) + c110 * wx
4584
+ c01 = c001 * (1.0 - wx) + c101 * wx
4585
+ c11 = c011 * (1.0 - wx) + c111 * wx
4586
+ c0 = c00 * (1.0 - wy) + c10 * wy
4587
+ c1 = c01 * (1.0 - wy) + c11 * wy
4588
+ return c0 * (1.0 - wz) + c1 * wz
4589
+
4590
+
4591
+ def _logical_coordinate_to_index(
4592
+ axis: jnp.ndarray,
4593
+ values: jnp.ndarray,
4594
+ *,
4595
+ periodic: bool,
4596
+ ) -> jnp.ndarray:
4597
+ """Convert logical coordinate values to fractional grid indices."""
4598
+
4599
+ axis = jnp.asarray(axis, dtype=jnp.float64)
4600
+ values = jnp.asarray(values, dtype=jnp.float64)
4601
+ if axis.size < 2:
4602
+ return jnp.zeros_like(values, dtype=jnp.float64)
4603
+
4604
+ if periodic:
4605
+ spacing = axis[1] - axis[0]
4606
+ period = (axis[-1] - axis[0]) + spacing
4607
+ wrapped = jnp.mod(values - axis[0], period) + axis[0]
4608
+ upper = jnp.searchsorted(axis, wrapped, side="right")
4609
+ lower = jnp.clip(upper - 1, 0, int(axis.size) - 1)
4610
+ next_index = jnp.mod(lower + 1, int(axis.size))
4611
+ lower_coord = axis[lower]
4612
+ upper_coord = jnp.where(lower == int(axis.size) - 1, axis[0] + period, axis[next_index])
4613
+ weight = (wrapped - lower_coord) / (upper_coord - lower_coord)
4614
+ return jnp.asarray(lower, dtype=jnp.float64) + jnp.clip(weight, 0.0, 1.0)
4615
+
4616
+ upper = jnp.searchsorted(axis, values, side="right")
4617
+ lower = jnp.clip(upper - 1, 0, int(axis.size) - 2)
4618
+ upper = lower + 1
4619
+ lower_coord = axis[lower]
4620
+ upper_coord = axis[upper]
4621
+ weight = (values - lower_coord) / (upper_coord - lower_coord)
4622
+ return jnp.asarray(lower, dtype=jnp.float64) + jnp.clip(weight, 0.0, 1.0)