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,1575 @@
1
+ """Halo exchange for shard-local 3D fields.
2
+
3
+ This stage only fills halos at interfaces between logical shards. It does not
4
+ decide how a global physical boundary, topology boundary, or cut wall should
5
+ be represented. Those operations belong to later field-preparer stages.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Protocol
12
+
13
+ import jax
14
+ import jax.numpy as jnp
15
+ from jax import lax
16
+
17
+ from ..geometry.fci_geometry import (
18
+ FCI_DEP_CUT_WALL,
19
+ FCI_DEP_FIELD_INTERIOR,
20
+ FCI_DEP_INVALID,
21
+ FCI_DEP_PHYSICAL_BOUNDARY,
22
+ SIDE_AXIS_REGULAR,
23
+ SIDE_SIMPLE_PERIODIC,
24
+ LocalDomain3D,
25
+ LocalFciDirectionMap,
26
+ LocalFciGeometry3D,
27
+ StencilBuilderContext,
28
+ _DataclassPyTreeMixin,
29
+ )
30
+ from .fci_boundaries import (
31
+ BC_DIRICHLET,
32
+ BC_NEUMANN,
33
+ LocalBoundaryFaceBC3D,
34
+ LocalBoundaryConditionBuilder,
35
+ LocalBoundaryData3D,
36
+ LocalCutWallBC3D,
37
+ LocalCutWallGeometry3D,
38
+ LocalCutWallValueReconstructor3D,
39
+ )
40
+ from .fci_model import (
41
+ FciFieldBundle,
42
+ FciModelState,
43
+ FciModelStateT,
44
+ assert_matching_field_names,
45
+ inject_owned_state_to_halo,
46
+ )
47
+
48
+
49
+ _pytree_base = jax.tree_util.register_pytree_node_class
50
+
51
+
52
+ def _validate_halo_spatial_prefix(
53
+ field_halo: jnp.ndarray,
54
+ domain: LocalDomain3D,
55
+ *,
56
+ name: str = "field_halo",
57
+ ) -> jnp.ndarray:
58
+ """Validate the spatial part of a scalar or trailing-axis halo field."""
59
+
60
+ field_halo = jnp.asarray(field_halo)
61
+ expected_shape = tuple(int(value) for value in domain.layout.cell_halo_shape)
62
+ if field_halo.ndim < 3 or tuple(field_halo.shape[:3]) != expected_shape:
63
+ raise ValueError(
64
+ f"{name} leading shape must match domain.layout.cell_halo_shape; "
65
+ f"got {field_halo.shape}, expected prefix {expected_shape}"
66
+ )
67
+ return field_halo
68
+
69
+
70
+ def _trailing_slices(ndim: int) -> tuple[slice, ...]:
71
+ """Return full slices for every non-spatial axis of a field."""
72
+
73
+ if ndim < 3:
74
+ raise ValueError(f"a halo field must have at least three axes, got ndim={ndim}")
75
+ return (slice(None),) * (ndim - 3)
76
+
77
+
78
+ @_pytree_base
79
+ @dataclass(frozen=True)
80
+ class HaloExchange3D(_DataclassPyTreeMixin):
81
+ """Exchange face halos using JAX SPMD collectives.
82
+
83
+ This backend is intended to run inside ``shard_map``/``pmap``-style SPMD
84
+ code where each configured ``LocalDomain3D.mesh_axis_names`` entry is a
85
+ valid collective axis name. It exchanges only the six face slabs of a
86
+ cell-centered field. Trailing component axes are carried unchanged, so a
87
+ vector field can be exchanged in one call:
88
+ halo edges and corners are deliberately left for a later topology or
89
+ ghost-cell stage.
90
+
91
+ Global side kinds in ``ShardSpec3D`` control whether a wrapped collective
92
+ is allowed. Internal shard interfaces are always exchanged; global sides
93
+ are exchanged only when marked ``SIDE_SIMPLE_PERIODIC``. Physical,
94
+ axis-regular, topology-mapped, and unused sides are left unchanged for
95
+ their owning later stage. With one shard on an axis, no collective is
96
+ issued; topology and physical stages own those sides.
97
+
98
+ For halo width ``h``, one direct neighbor exchange is sufficient when the
99
+ owned extent on every exchanged axis is at least ``h``. Wider halos that
100
+ span multiple neighboring shards require a different communication plan
101
+ and are rejected here rather than being silently filled incorrectly.
102
+ """
103
+
104
+ exchange_axes: tuple[bool, bool, bool] = (True, True, True)
105
+
106
+ def __post_init__(self) -> None:
107
+ exchange_axes = tuple(bool(value) for value in self.exchange_axes)
108
+
109
+ if len(exchange_axes) != 3:
110
+ raise ValueError("HaloExchange3D.exchange_axes must have length 3")
111
+ object.__setattr__(self, "exchange_axes", exchange_axes)
112
+
113
+ def __call__(
114
+ self,
115
+ field_halo: jnp.ndarray,
116
+ domain: LocalDomain3D,
117
+ ) -> jnp.ndarray:
118
+ """Fill only regular-neighbor and decomposed simple-periodic face halos."""
119
+
120
+ if not isinstance(domain, LocalDomain3D):
121
+ raise TypeError("HaloExchange3D.domain must be a LocalDomain3D instance")
122
+
123
+ shard_counts = tuple(int(value) for value in domain.shard_spec.shard_counts)
124
+ if len(domain.mesh_axis_names) != 3:
125
+ raise ValueError("domain.mesh_axis_names must have length 3")
126
+ for axis, (enabled, count, name) in enumerate(
127
+ zip(self.exchange_axes, shard_counts, domain.mesh_axis_names)
128
+ ):
129
+ if enabled and count > 1 and not name:
130
+ raise ValueError(
131
+ "HaloExchange3D requires a mesh axis name in "
132
+ f"domain.mesh_axis_names[{axis}] for decomposed exchange"
133
+ )
134
+
135
+ field_halo = _validate_halo_spatial_prefix(field_halo, domain)
136
+
137
+ h = int(domain.layout.halo_width)
138
+ if h == 0:
139
+ return field_halo
140
+
141
+ for axis, (enabled, shard_count) in enumerate(zip(self.exchange_axes, shard_counts)):
142
+ if enabled and shard_count > 1 and h > domain.owned_shape[axis]:
143
+ raise ValueError(
144
+ "HaloExchange3D requires halo_width no larger than the owned "
145
+ "extent on each exchanged axis; "
146
+ f"axis={axis}, halo_width={h}, "
147
+ f"owned_extent={domain.owned_shape[axis]}"
148
+ )
149
+
150
+ result = field_halo
151
+ for axis in range(3):
152
+ result = self._exchange_axis(result, domain, axis=axis)
153
+ return result
154
+
155
+ def _exchange_axis(
156
+ self,
157
+ field_halo: jnp.ndarray,
158
+ domain: LocalDomain3D,
159
+ *,
160
+ axis: int,
161
+ ) -> jnp.ndarray:
162
+ """Exchange one coordinate-direction face slab."""
163
+
164
+ axis = int(axis)
165
+ if axis < 0 or axis > 2:
166
+ raise ValueError(f"axis must be 0, 1, or 2, got {axis}")
167
+
168
+ if not self.exchange_axes[axis]:
169
+ return field_halo
170
+
171
+ axis_name = domain.mesh_axis_names[axis]
172
+ shard_count = int(domain.shard_spec.shard_counts[axis])
173
+
174
+ # A one-shard axis has no internal interface. Its periodic or physical
175
+ # outer halos are filled by later stages.
176
+ if shard_count <= 1 or axis_name is None:
177
+ return field_halo
178
+
179
+ layout = domain.layout
180
+ h = int(layout.halo_width)
181
+ nx, ny, nz = layout.owned_shape
182
+
183
+ i = slice(h, h + nx)
184
+ j = slice(h, h + ny)
185
+ k = slice(h, h + nz)
186
+ trailing = _trailing_slices(field_halo.ndim)
187
+
188
+ if axis == 0:
189
+ lower_owned_slab = field_halo[(slice(h, h + h), j, k) + trailing]
190
+ upper_owned_slab = field_halo[(slice(h + nx - h, h + nx), j, k) + trailing]
191
+ lower_halo_index = (slice(0, h), j, k) + trailing
192
+ upper_halo_index = (slice(h + nx, h + nx + h), j, k) + trailing
193
+ elif axis == 1:
194
+ lower_owned_slab = field_halo[(i, slice(h, h + h), k) + trailing]
195
+ upper_owned_slab = field_halo[(i, slice(h + ny - h, h + ny), k) + trailing]
196
+ lower_halo_index = (i, slice(0, h), k) + trailing
197
+ upper_halo_index = (i, slice(h + ny, h + ny + h), k) + trailing
198
+ else:
199
+ lower_owned_slab = field_halo[(i, j, slice(h, h + h)) + trailing]
200
+ upper_owned_slab = field_halo[(i, j, slice(h + nz - h, h + nz)) + trailing]
201
+ lower_halo_index = (i, j, slice(0, h)) + trailing
202
+ upper_halo_index = (i, j, slice(h + nz, h + nz + h)) + trailing
203
+
204
+ shard_id = lax.axis_index(axis_name)
205
+ # The domain metadata may be closed over by an SPMD function and can
206
+ # therefore describe one representative shard. Use the runtime
207
+ # collective index to decide whether this rank is on a global side;
208
+ # the side-kind metadata describes those global sides.
209
+ lower_global_allowed = (
210
+ domain.shard_spec.lower_side_kind(axis) == SIDE_SIMPLE_PERIODIC
211
+ )
212
+ upper_global_allowed = (
213
+ domain.shard_spec.upper_side_kind(axis) == SIDE_SIMPLE_PERIODIC
214
+ )
215
+ fill_lower_halo = jnp.where(
216
+ shard_id > 0,
217
+ True,
218
+ lower_global_allowed,
219
+ )
220
+ fill_upper_halo = jnp.where(
221
+ shard_id < shard_count - 1,
222
+ True,
223
+ upper_global_allowed,
224
+ )
225
+
226
+ # ppermute maps source shard -> destination shard. Sending the upper
227
+ # slab toward increasing shard ids supplies a lower halo; sending the
228
+ # lower slab toward decreasing shard ids supplies an upper halo.
229
+ recv_from_minus = lax.ppermute(
230
+ upper_owned_slab,
231
+ axis_name=axis_name,
232
+ perm=[(source, (source + 1) % shard_count) for source in range(shard_count)],
233
+ )
234
+ recv_from_plus = lax.ppermute(
235
+ lower_owned_slab,
236
+ axis_name=axis_name,
237
+ perm=[(source, (source - 1) % shard_count) for source in range(shard_count)],
238
+ )
239
+
240
+ old_lower_halo = field_halo[lower_halo_index]
241
+ old_upper_halo = field_halo[upper_halo_index]
242
+ new_lower_halo = jnp.where(
243
+ fill_lower_halo, recv_from_minus, old_lower_halo
244
+ )
245
+ new_upper_halo = jnp.where(
246
+ fill_upper_halo, recv_from_plus, old_upper_halo
247
+ )
248
+
249
+ field_halo = field_halo.at[lower_halo_index].set(new_lower_halo)
250
+ field_halo = field_halo.at[upper_halo_index].set(new_upper_halo)
251
+ return field_halo
252
+
253
+ def tree_flatten(self):
254
+ return (), (self.exchange_axes,)
255
+
256
+ @classmethod
257
+ def tree_unflatten(cls, aux_data, children):
258
+ del children
259
+ (exchange_axes,) = aux_data
260
+ return cls(exchange_axes=exchange_axes)
261
+
262
+
263
+ class FciCutWallValueEvaluator(Protocol):
264
+ """Protocol for owner-local FCI cut-wall value evaluation."""
265
+
266
+ def __call__(
267
+ self,
268
+ *,
269
+ field_halo: jnp.ndarray,
270
+ cut_wall_geometry: LocalCutWallGeometry3D | None,
271
+ cut_wall_bc: LocalCutWallBC3D | None,
272
+ context: StencilBuilderContext,
273
+ value_slot: jnp.ndarray,
274
+ active: jnp.ndarray,
275
+ ) -> jnp.ndarray:
276
+ """Return one scalar cut-wall value per requested slot."""
277
+ ...
278
+
279
+
280
+ @_pytree_base
281
+ @dataclass(frozen=True)
282
+ class LocalFciCutWallValueEvaluator(_DataclassPyTreeMixin):
283
+ """Evaluate owner-local FCI cut-wall endpoint values.
284
+
285
+ ``value_slot`` indexes padded owner-local cut-wall metadata. The concrete
286
+ wall-value reconstruction is delegated to
287
+ ``context.cut_wall_value_reconstructor`` so the exchange uses the same
288
+ prepared-halo stencil data as the local FCI stencil builder. Dirichlet rows
289
+ override that reconstructed value with their prescribed wall value;
290
+ Neumann and flux-style rows use the reconstructed value directly.
291
+ """
292
+
293
+ def __call__(
294
+ self,
295
+ *,
296
+ field_halo: jnp.ndarray,
297
+ cut_wall_geometry: LocalCutWallGeometry3D | None,
298
+ cut_wall_bc: LocalCutWallBC3D | None,
299
+ context: StencilBuilderContext,
300
+ value_slot: jnp.ndarray,
301
+ active: jnp.ndarray,
302
+ ) -> jnp.ndarray:
303
+ if not isinstance(context, StencilBuilderContext):
304
+ raise TypeError("context must be a StencilBuilderContext instance")
305
+
306
+ field_halo = jnp.asarray(field_halo)
307
+ if field_halo.ndim != 3:
308
+ raise ValueError(
309
+ "LocalFciCutWallValueEvaluator currently supports scalar "
310
+ f"halo fields only; got shape {field_halo.shape}"
311
+ )
312
+ if field_halo.shape != context.layout.cell_halo_shape:
313
+ raise ValueError(
314
+ "field_halo must match context.layout.cell_halo_shape; "
315
+ f"got {field_halo.shape}, expected {context.layout.cell_halo_shape}"
316
+ )
317
+
318
+ value_slot = jnp.asarray(value_slot, dtype=jnp.int32)
319
+ active = jnp.asarray(active, dtype=bool)
320
+ if active.shape != value_slot.shape:
321
+ raise ValueError(
322
+ "active must have the same shape as value_slot; "
323
+ f"got active={active.shape}, value_slot={value_slot.shape}"
324
+ )
325
+
326
+ if cut_wall_geometry is None or cut_wall_bc is None:
327
+ return jnp.zeros(value_slot.shape, dtype=field_halo.dtype)
328
+ if not isinstance(cut_wall_geometry, LocalCutWallGeometry3D):
329
+ raise TypeError(
330
+ "cut_wall_geometry must be a LocalCutWallGeometry3D or None"
331
+ )
332
+ if not isinstance(cut_wall_bc, LocalCutWallBC3D):
333
+ raise TypeError("cut_wall_bc must be a LocalCutWallBC3D or None")
334
+ if cut_wall_geometry.max_wall_faces != cut_wall_bc.max_wall_faces:
335
+ raise ValueError(
336
+ "cut_wall_geometry and cut_wall_bc must have the same "
337
+ "max_wall_faces"
338
+ )
339
+
340
+ max_wall_faces = int(cut_wall_geometry.max_wall_faces)
341
+ if max_wall_faces == 0:
342
+ return jnp.zeros(value_slot.shape, dtype=field_halo.dtype)
343
+ value_reconstructor = context.cut_wall_value_reconstructor
344
+ if value_reconstructor is None:
345
+ raise ValueError(
346
+ "context.cut_wall_value_reconstructor is required for "
347
+ "nonempty FCI cut-wall value evaluation"
348
+ )
349
+ if not isinstance(value_reconstructor, LocalCutWallValueReconstructor3D):
350
+ raise TypeError(
351
+ "context.cut_wall_value_reconstructor must be a "
352
+ "LocalCutWallValueReconstructor3D"
353
+ )
354
+ if value_reconstructor.max_wall_faces != max_wall_faces:
355
+ raise ValueError(
356
+ "cut_wall_value_reconstructor.max_wall_faces must match "
357
+ "cut_wall_geometry.max_wall_faces"
358
+ )
359
+
360
+ safe_slot = jnp.clip(value_slot, 0, max_wall_faces - 1)
361
+ slot_active = (
362
+ jnp.asarray(cut_wall_geometry.active, dtype=bool)[safe_slot]
363
+ & jnp.asarray(cut_wall_bc.active, dtype=bool)[safe_slot]
364
+ & jnp.asarray(value_reconstructor.active, dtype=bool)[safe_slot]
365
+ )
366
+
367
+ wall_values = jnp.asarray(
368
+ value_reconstructor.extrapolate(field_halo),
369
+ dtype=field_halo.dtype,
370
+ )
371
+ cut_wall_kind = jnp.asarray(cut_wall_bc.kind, dtype=jnp.int32)
372
+ cut_wall_value = jnp.asarray(cut_wall_bc.value, dtype=field_halo.dtype)
373
+ wall_values = jnp.where(
374
+ cut_wall_kind == BC_DIRICHLET,
375
+ cut_wall_value,
376
+ wall_values,
377
+ )
378
+ values = wall_values[safe_slot]
379
+ return jnp.where(active & slot_active, values, jnp.zeros_like(values))
380
+
381
+ def tree_flatten(self):
382
+ return (), None
383
+
384
+ @classmethod
385
+ def tree_unflatten(cls, _aux_data, children):
386
+ del children
387
+ return cls()
388
+
389
+
390
+ @_pytree_base
391
+ @dataclass(frozen=True)
392
+ class RemoteFciDependencyExchange(_DataclassPyTreeMixin):
393
+ """Populate remote FCI receive values for one trace direction.
394
+
395
+ The exchange consumes the request side of ``LocalFciRemoteDependencyTable``
396
+ and returns a local vector where request row ``q`` is receive slot ``q``.
397
+ It communicates scalar values only; the FCI stencil builder owns endpoint
398
+ assembly from local rows plus these returned remote values.
399
+ """
400
+
401
+ cut_wall_evaluator: FciCutWallValueEvaluator = LocalFciCutWallValueEvaluator()
402
+
403
+ def __call__(
404
+ self,
405
+ *,
406
+ field_halo: jnp.ndarray,
407
+ direction: LocalFciDirectionMap,
408
+ context: StencilBuilderContext,
409
+ ) -> jnp.ndarray:
410
+ if not isinstance(direction, LocalFciDirectionMap):
411
+ raise TypeError("direction must be a LocalFciDirectionMap instance")
412
+ if not isinstance(context, StencilBuilderContext):
413
+ raise TypeError("context must be a StencilBuilderContext instance")
414
+ if context.domain is None:
415
+ raise ValueError("context.domain is required for remote FCI exchange")
416
+ if context.layout != direction.layout:
417
+ raise ValueError("context and direction must share the same HaloLayout3D")
418
+
419
+ table = direction.remote
420
+ field_halo = jnp.asarray(field_halo)
421
+ if field_halo.ndim != 3:
422
+ raise ValueError(
423
+ "RemoteFciDependencyExchange currently supports scalar halo "
424
+ f"fields only; got shape {field_halo.shape}"
425
+ )
426
+ if field_halo.shape != direction.layout.cell_halo_shape:
427
+ raise ValueError(
428
+ "field_halo must match direction.layout.cell_halo_shape; "
429
+ f"got {field_halo.shape}, expected {direction.layout.cell_halo_shape}"
430
+ )
431
+ if table is None:
432
+ return jnp.zeros((0,), dtype=field_halo.dtype)
433
+
434
+ domain = context.domain
435
+ self._validate_mesh_axes(domain)
436
+
437
+ shard_counts = tuple(int(value) for value in domain.shard_spec.shard_counts)
438
+ n_shards = shard_counts[0] * shard_counts[1] * shard_counts[2]
439
+ n_requests = int(table.max_receive_values)
440
+
441
+ shard_x = jnp.asarray(domain.runtime_shard_id(0), dtype=jnp.int32)
442
+ shard_y = jnp.asarray(domain.runtime_shard_id(1), dtype=jnp.int32)
443
+ shard_z = jnp.asarray(domain.runtime_shard_id(2), dtype=jnp.int32)
444
+ my_shard_linear = (
445
+ shard_z * (shard_counts[1] * shard_counts[0])
446
+ + shard_y * shard_counts[0]
447
+ + shard_x
448
+ ).astype(jnp.int32)
449
+
450
+ request_active = self._all_gather_flat(table.request_active, domain)
451
+ request_kind = self._all_gather_flat(table.request_dependency_kind, domain)
452
+ request_owner_linear = self._all_gather_flat(
453
+ table.request_source_shard_linear,
454
+ domain,
455
+ )
456
+ request_i = self._all_gather_flat(
457
+ table.request_source_owner_local_i,
458
+ domain,
459
+ )
460
+ request_j = self._all_gather_flat(
461
+ table.request_source_owner_local_j,
462
+ domain,
463
+ )
464
+ request_k = self._all_gather_flat(
465
+ table.request_source_owner_local_k,
466
+ domain,
467
+ )
468
+ request_value_slot = self._all_gather_flat(
469
+ table.request_value_slot,
470
+ domain,
471
+ )
472
+
473
+ owned_by_me = request_owner_linear == my_shard_linear
474
+ supported_kind = (
475
+ (request_kind == FCI_DEP_FIELD_INTERIOR)
476
+ | (request_kind == FCI_DEP_PHYSICAL_BOUNDARY)
477
+ | (request_kind == FCI_DEP_CUT_WALL)
478
+ )
479
+ valid_request = request_active & owned_by_me & supported_kind
480
+ field_request = valid_request & (
481
+ (request_kind == FCI_DEP_FIELD_INTERIOR)
482
+ | (request_kind == FCI_DEP_PHYSICAL_BOUNDARY)
483
+ )
484
+ cut_wall_request = valid_request & (request_kind == FCI_DEP_CUT_WALL)
485
+
486
+ field_values = self._sample_field_halo(
487
+ field_halo=field_halo,
488
+ source_i=request_i,
489
+ source_j=request_j,
490
+ source_k=request_k,
491
+ )
492
+ cut_wall_values = self.cut_wall_evaluator(
493
+ field_halo=field_halo,
494
+ cut_wall_geometry=context.cut_wall_geometry,
495
+ cut_wall_bc=context.cut_wall_bc,
496
+ context=context,
497
+ value_slot=request_value_slot,
498
+ active=cut_wall_request,
499
+ )
500
+ if cut_wall_values.shape != cut_wall_request.shape:
501
+ raise ValueError(
502
+ "cut_wall_evaluator must return an array with shape "
503
+ f"{cut_wall_request.shape}, got {cut_wall_values.shape}"
504
+ )
505
+
506
+ owner_responses = jnp.zeros((n_shards, n_requests), dtype=field_halo.dtype)
507
+ owner_responses = jnp.where(field_request, field_values, owner_responses)
508
+ owner_responses = jnp.where(cut_wall_request, cut_wall_values, owner_responses)
509
+
510
+ responses_by_requester = self._psum_over_mesh_axes(owner_responses, domain)
511
+ remote_values = jnp.take(responses_by_requester, my_shard_linear, axis=0)
512
+ active_remote = table.request_active & (
513
+ table.request_dependency_kind != FCI_DEP_INVALID
514
+ )
515
+ return jnp.where(active_remote, remote_values, jnp.zeros_like(remote_values))
516
+
517
+ @staticmethod
518
+ def _validate_mesh_axes(domain: LocalDomain3D) -> None:
519
+ for axis, (count, name) in enumerate(
520
+ zip(domain.shard_spec.shard_counts, domain.mesh_axis_names)
521
+ ):
522
+ if int(count) > 1 and name is None:
523
+ raise ValueError(
524
+ "RemoteFciDependencyExchange requires a mesh axis name in "
525
+ f"domain.mesh_axis_names[{axis}] for decomposed exchange"
526
+ )
527
+
528
+ @staticmethod
529
+ def _sample_field_halo(
530
+ *,
531
+ field_halo: jnp.ndarray,
532
+ source_i: jnp.ndarray,
533
+ source_j: jnp.ndarray,
534
+ source_k: jnp.ndarray,
535
+ ) -> jnp.ndarray:
536
+ nx, ny, nz = field_halo.shape
537
+ safe_i = jnp.clip(source_i, 0, nx - 1)
538
+ safe_j = jnp.clip(source_j, 0, ny - 1)
539
+ safe_k = jnp.clip(source_k, 0, nz - 1)
540
+ return field_halo[safe_i, safe_j, safe_k]
541
+
542
+ @staticmethod
543
+ def _all_gather_flat(value: jnp.ndarray, domain: LocalDomain3D) -> jnp.ndarray:
544
+ gathered = value
545
+ for shard_count, axis_name in zip(
546
+ domain.shard_spec.shard_counts,
547
+ domain.mesh_axis_names,
548
+ ):
549
+ if int(shard_count) > 1 and axis_name is not None:
550
+ gathered = lax.all_gather(
551
+ gathered,
552
+ axis_name=axis_name,
553
+ axis=0,
554
+ tiled=False,
555
+ )
556
+
557
+ shard_counts = tuple(int(v) for v in domain.shard_spec.shard_counts)
558
+ n_shards = shard_counts[0] * shard_counts[1] * shard_counts[2]
559
+ return gathered.reshape((n_shards,) + value.shape)
560
+
561
+ @staticmethod
562
+ def _psum_over_mesh_axes(value: jnp.ndarray, domain: LocalDomain3D) -> jnp.ndarray:
563
+ result = value
564
+ for shard_count, axis_name in zip(
565
+ domain.shard_spec.shard_counts,
566
+ domain.mesh_axis_names,
567
+ ):
568
+ if int(shard_count) > 1 and axis_name is not None:
569
+ result = lax.psum(result, axis_name=axis_name)
570
+ return result
571
+
572
+ def tree_flatten(self):
573
+ return (), self.cut_wall_evaluator
574
+
575
+ @classmethod
576
+ def tree_unflatten(cls, aux_data, children):
577
+ del children
578
+ return cls(cut_wall_evaluator=aux_data)
579
+
580
+
581
+ @_pytree_base
582
+ @dataclass(frozen=True)
583
+ class TopologyHaloFiller3D(_DataclassPyTreeMixin):
584
+ """Apply ordered topology rules to a halo field.
585
+
586
+ The first three axes are spatial; any trailing component axes are carried
587
+ through the rule pipeline unchanged.
588
+
589
+ Rules have the callable interface ``rule(field_halo, domain)``. The runner
590
+ itself performs no communication directly; an individual rule may be
591
+ local-only or may perform distributed collectives such as ``ppermute`` or
592
+ ``all_to_all``. Each rule owns its side-kind checks, communication plan,
593
+ index remaps, and write masks.
594
+
595
+ The runner never fills physical ghost cells and does not promise
596
+ edge/corner values unless an individual rule explicitly provides them.
597
+ Rules are applied in order, so later rules may intentionally overwrite
598
+ values written by earlier rules.
599
+ """
600
+
601
+ rules: tuple[object, ...]
602
+ error_if_no_rules: bool = False
603
+
604
+ def __post_init__(self) -> None:
605
+ rules = tuple(self.rules)
606
+ if self.error_if_no_rules and not rules:
607
+ raise ValueError(
608
+ "TopologyHaloFiller3D.rules must be nonempty when "
609
+ "error_if_no_rules=True"
610
+ )
611
+ for index, rule in enumerate(rules):
612
+ if not callable(rule):
613
+ raise TypeError(
614
+ f"TopologyHaloFiller3D.rules[{index}] must be callable"
615
+ )
616
+ object.__setattr__(self, "rules", rules)
617
+ object.__setattr__(self, "error_if_no_rules", bool(self.error_if_no_rules))
618
+
619
+ def __call__(self, field_halo: jnp.ndarray, domain: LocalDomain3D) -> jnp.ndarray:
620
+ if not isinstance(domain, LocalDomain3D):
621
+ raise TypeError("TopologyHaloFiller3D.domain must be a LocalDomain3D instance")
622
+ field_halo = _validate_halo_spatial_prefix(field_halo, domain)
623
+ expected_shape = tuple(int(value) for value in domain.layout.cell_halo_shape)
624
+ expected_trailing_shape = tuple(field_halo.shape[3:])
625
+ result = field_halo
626
+ for rule in self.rules:
627
+ result = rule(result, domain)
628
+ if (
629
+ result.ndim < 3
630
+ or tuple(result.shape[:3]) != expected_shape
631
+ or tuple(result.shape[3:]) != expected_trailing_shape
632
+ ):
633
+ raise ValueError(
634
+ "Topology rule returned an invalid field shape; expected "
635
+ f"prefix {expected_shape} with trailing shape "
636
+ f"{expected_trailing_shape}, got {result.shape}"
637
+ )
638
+ return result
639
+
640
+ def tree_flatten(self):
641
+ return (self.rules,), self.error_if_no_rules
642
+
643
+ @classmethod
644
+ def tree_unflatten(cls, aux_data, children):
645
+ (rules,) = children
646
+ return cls(rules=rules, error_if_no_rules=aux_data)
647
+
648
+
649
+ @_pytree_base
650
+ @dataclass(frozen=True)
651
+ class LocalPeriodicTopologyRule3D(_DataclassPyTreeMixin):
652
+ """Fill undecomposed ``SIDE_SIMPLE_PERIODIC`` face halos locally."""
653
+
654
+ fill_axes: tuple[bool, bool, bool] = (True, True, True)
655
+
656
+ def __post_init__(self) -> None:
657
+ fill_axes = tuple(bool(value) for value in self.fill_axes)
658
+ if len(fill_axes) != 3:
659
+ raise ValueError(
660
+ "LocalPeriodicTopologyRule3D.fill_axes must have length 3"
661
+ )
662
+ object.__setattr__(self, "fill_axes", fill_axes)
663
+
664
+ def __call__(self, field_halo: jnp.ndarray, domain: LocalDomain3D) -> jnp.ndarray:
665
+ field_halo = _validate_halo_spatial_prefix(field_halo, domain)
666
+ result = field_halo
667
+ for axis in range(3):
668
+ result = self._fill_axis(result, domain, axis=axis)
669
+ return result
670
+
671
+ def _fill_axis(self, field_halo, domain, *, axis: int):
672
+ if not self.fill_axes[axis]:
673
+ return field_halo
674
+ spec = domain.shard_spec
675
+ if spec.shard_counts[axis] > 1:
676
+ return field_halo
677
+
678
+ lower = domain.runtime_touches_lower(axis) & (
679
+ spec.lower_side_kind(axis) == SIDE_SIMPLE_PERIODIC
680
+ )
681
+ upper = domain.runtime_touches_upper(axis) & (
682
+ spec.upper_side_kind(axis) == SIDE_SIMPLE_PERIODIC
683
+ )
684
+ if domain.layout.halo_width == 0:
685
+ return field_halo
686
+
687
+ h = domain.layout.halo_width
688
+ ext = domain.layout.owned_shape
689
+ owned = tuple(slice(h, h + size) for size in ext)
690
+ trailing = _trailing_slices(field_halo.ndim)
691
+ result = field_halo
692
+ if axis == 0:
693
+ lower_index = (slice(0, h), owned[1], owned[2]) + trailing
694
+ upper_index = (slice(h + ext[0], h + ext[0] + h), owned[1], owned[2]) + trailing
695
+ old_lower = result[lower_index]
696
+ old_upper = result[upper_index]
697
+ lower_value = result[(slice(h + ext[0] - h, h + ext[0]), owned[1], owned[2]) + trailing]
698
+ upper_value = result[(slice(h, h + h), owned[1], owned[2]) + trailing]
699
+ result = result.at[lower_index].set(jnp.where(lower, lower_value, old_lower))
700
+ result = result.at[upper_index].set(jnp.where(upper, upper_value, old_upper))
701
+ elif axis == 1:
702
+ lower_index = (owned[0], slice(0, h), owned[2]) + trailing
703
+ upper_index = (owned[0], slice(h + ext[1], h + ext[1] + h), owned[2]) + trailing
704
+ old_lower = result[lower_index]
705
+ old_upper = result[upper_index]
706
+ lower_value = result[(owned[0], slice(h + ext[1] - h, h + ext[1]), owned[2]) + trailing]
707
+ upper_value = result[(owned[0], slice(h, h + h), owned[2]) + trailing]
708
+ result = result.at[lower_index].set(jnp.where(lower, lower_value, old_lower))
709
+ result = result.at[upper_index].set(jnp.where(upper, upper_value, old_upper))
710
+ elif axis == 2:
711
+ lower_index = (owned[0], owned[1], slice(0, h)) + trailing
712
+ upper_index = (owned[0], owned[1], slice(h + ext[2], h + ext[2] + h)) + trailing
713
+ old_lower = result[lower_index]
714
+ old_upper = result[upper_index]
715
+ lower_value = result[(owned[0], owned[1], slice(h + ext[2] - h, h + ext[2])) + trailing]
716
+ upper_value = result[(owned[0], owned[1], slice(h, h + h)) + trailing]
717
+ result = result.at[lower_index].set(jnp.where(lower, lower_value, old_lower))
718
+ result = result.at[upper_index].set(jnp.where(upper, upper_value, old_upper))
719
+ else:
720
+ raise ValueError(f"axis must be 0, 1, or 2, got {axis}")
721
+ return result
722
+
723
+ def tree_flatten(self):
724
+ return (), self.fill_axes
725
+
726
+ @classmethod
727
+ def tree_unflatten(cls, aux_data, children):
728
+ del children
729
+ return cls(fill_axes=aux_data)
730
+
731
+
732
+ @_pytree_base
733
+ @dataclass(frozen=True)
734
+ class PolarAxisRegularScalarRule3D(_DataclassPyTreeMixin):
735
+ """Fill scalar polar-axis regularity halos with optional angle sharding."""
736
+
737
+ angle_axis_name: str | None
738
+ radial_axis: int = 0
739
+ angle_axis: int = 1
740
+ passive_axis: int = 2
741
+ fill_lower: bool = True
742
+ fill_upper: bool = False
743
+ require_even_global_angle: bool = True
744
+
745
+ def __post_init__(self) -> None:
746
+ axes = (int(self.radial_axis), int(self.angle_axis), int(self.passive_axis))
747
+ if axes != (0, 1, 2):
748
+ raise NotImplementedError(
749
+ "PolarAxisRegularScalarRule3D currently supports only "
750
+ "(radial_axis, angle_axis, passive_axis) = (0, 1, 2)"
751
+ )
752
+ if self.angle_axis_name is not None and not isinstance(self.angle_axis_name, str):
753
+ raise TypeError(
754
+ "angle_axis_name must be a string or None, got "
755
+ f"{self.angle_axis_name!r}"
756
+ )
757
+ object.__setattr__(self, "radial_axis", axes[0])
758
+ object.__setattr__(self, "angle_axis", axes[1])
759
+ object.__setattr__(self, "passive_axis", axes[2])
760
+ object.__setattr__(self, "fill_lower", bool(self.fill_lower))
761
+ object.__setattr__(self, "fill_upper", bool(self.fill_upper))
762
+ object.__setattr__(
763
+ self,
764
+ "require_even_global_angle",
765
+ bool(self.require_even_global_angle),
766
+ )
767
+
768
+ def __call__(self, field_halo: jnp.ndarray, domain: LocalDomain3D) -> jnp.ndarray:
769
+ if not isinstance(domain, LocalDomain3D):
770
+ raise TypeError(
771
+ "PolarAxisRegularScalarRule3D.domain must be a LocalDomain3D instance"
772
+ )
773
+ field_halo = jnp.asarray(field_halo)
774
+ expected_shape = domain.layout.cell_halo_shape
775
+ if field_halo.shape != expected_shape:
776
+ raise ValueError(
777
+ f"field_halo must have shape {expected_shape}, got {field_halo.shape}"
778
+ )
779
+ result = field_halo
780
+ # Both calls are made on every SPMD shard. Each call performs any
781
+ # required angle collective before applying its runtime radial-side
782
+ # write mask.
783
+ result = self._fill_radial_side(result, domain, side="lower")
784
+ result = self._fill_radial_side(result, domain, side="upper")
785
+ return result
786
+
787
+ def _fill_radial_side(self, field_halo, domain, *, side: str):
788
+ spec = domain.shard_spec
789
+ if side == "lower":
790
+ do_side = bool(self.fill_lower) & domain.runtime_touches_lower(
791
+ self.radial_axis
792
+ ) & (
793
+ spec.lower_side_kind(self.radial_axis) == SIDE_AXIS_REGULAR
794
+ )
795
+ elif side == "upper":
796
+ do_side = bool(self.fill_upper) & domain.runtime_touches_upper(
797
+ self.radial_axis
798
+ ) & (
799
+ spec.upper_side_kind(self.radial_axis) == SIDE_AXIS_REGULAR
800
+ )
801
+ else:
802
+ raise ValueError(f"side must be 'lower' or 'upper', got {side!r}")
803
+
804
+ angle_axis = self.angle_axis
805
+ if spec.shard_counts[self.radial_axis] > 1 and domain.mesh_axis_names[self.radial_axis] is None:
806
+ raise ValueError(
807
+ "PolarAxisRegularScalarRule3D requires a mesh axis name for a "
808
+ "decomposed radial axis"
809
+ )
810
+ if (
811
+ spec.lower_side_kind(angle_axis) != SIDE_SIMPLE_PERIODIC
812
+ or spec.upper_side_kind(angle_axis) != SIDE_SIMPLE_PERIODIC
813
+ ):
814
+ raise ValueError(
815
+ "PolarAxisRegularScalarRule3D requires SIDE_SIMPLE_PERIODIC "
816
+ "on both global angle sides"
817
+ )
818
+
819
+ h = int(domain.layout.halo_width)
820
+ if h == 0:
821
+ return field_halo
822
+ nx, ny, nz = domain.layout.owned_shape
823
+ if h > nx:
824
+ raise ValueError(
825
+ "PolarAxisRegularScalarRule3D requires halo_width <= local "
826
+ f"radial extent; got halo_width={h}, nx={nx}"
827
+ )
828
+
829
+ global_angle = int(spec.global_shape[angle_axis])
830
+ local_angle = int(domain.layout.owned_shape[angle_axis])
831
+ angle_count = int(spec.shard_counts[angle_axis])
832
+ if self.require_even_global_angle and global_angle % 2:
833
+ raise ValueError(
834
+ "PolarAxisRegularScalarRule3D requires an even global angle "
835
+ f"count; got global_angle={global_angle}"
836
+ )
837
+ if global_angle != angle_count * local_angle:
838
+ raise ValueError(
839
+ "PolarAxisRegularScalarRule3D requires equal angle sharding; "
840
+ f"got global_angle={global_angle}, angle_count={angle_count}, "
841
+ f"local_angle={local_angle}"
842
+ )
843
+ if angle_count > 1 and self.angle_axis_name is None:
844
+ raise ValueError(
845
+ "PolarAxisRegularScalarRule3D requires angle_axis_name when "
846
+ "the angle axis is sharded"
847
+ )
848
+
849
+ shard_shift, local_shift = divmod(global_angle // 2, local_angle)
850
+ if angle_count == 1:
851
+ values = self._fill_local_angle(field_halo, domain, side, local_shift)
852
+ else:
853
+ values = self._fill_distributed_angle(
854
+ field_halo,
855
+ domain,
856
+ side=side,
857
+ shard_shift=shard_shift,
858
+ local_shift=local_shift,
859
+ )
860
+ return self._write_radial_ghost(
861
+ field_halo,
862
+ domain,
863
+ side=side,
864
+ values=values,
865
+ do_side=do_side,
866
+ )
867
+
868
+ def _radial_mirror_source(self, field_halo, domain, *, side: str):
869
+ h = int(domain.layout.halo_width)
870
+ nx, ny, nz = domain.layout.owned_shape
871
+ j = slice(h, h + ny)
872
+ k = slice(h, h + nz)
873
+ if side == "lower":
874
+ return field_halo[h : h + h, j, k]
875
+ if side == "upper":
876
+ return field_halo[h + nx - h : h + nx, j, k][::-1, :, :]
877
+ raise ValueError(f"side must be 'lower' or 'upper', got {side!r}")
878
+
879
+ def _write_radial_ghost(
880
+ self,
881
+ field_halo,
882
+ domain,
883
+ *,
884
+ side: str,
885
+ values,
886
+ do_side,
887
+ ):
888
+ h = int(domain.layout.halo_width)
889
+ nx, ny, nz = domain.layout.owned_shape
890
+ j = slice(h, h + ny)
891
+ k = slice(h, h + nz)
892
+ if side == "lower":
893
+ index = (slice(0, h), j, k)
894
+ old = field_halo[index]
895
+ return field_halo.at[index].set(
896
+ jnp.where(do_side, values[::-1, :, :], old)
897
+ )
898
+ if side == "upper":
899
+ index = (slice(h + nx, h + nx + h), j, k)
900
+ old = field_halo[index]
901
+ return field_halo.at[index].set(jnp.where(do_side, values, old))
902
+ raise ValueError(f"side must be 'lower' or 'upper', got {side!r}")
903
+
904
+ def _fill_local_angle(self, field_halo, domain, side, local_shift):
905
+ source = self._radial_mirror_source(field_halo, domain, side=side)
906
+ return jnp.roll(source, shift=-int(local_shift), axis=1)
907
+
908
+ def _fill_distributed_angle(self, field_halo, domain, *, side, shard_shift, local_shift):
909
+ count = int(domain.shard_spec.shard_counts[self.angle_axis])
910
+ source = self._radial_mirror_source(field_halo, domain, side=side)
911
+ shift = int(shard_shift) % count
912
+ received = lax.ppermute(
913
+ source,
914
+ axis_name=self.angle_axis_name,
915
+ perm=[
916
+ (source_id, (source_id - shift) % count)
917
+ for source_id in range(count)
918
+ ],
919
+ )
920
+ if int(local_shift) != 0:
921
+ received = jnp.roll(received, shift=-int(local_shift), axis=1)
922
+ return received
923
+
924
+ def tree_flatten(self):
925
+ return (), (
926
+ self.angle_axis_name,
927
+ self.radial_axis,
928
+ self.angle_axis,
929
+ self.passive_axis,
930
+ self.fill_lower,
931
+ self.fill_upper,
932
+ self.require_even_global_angle,
933
+ )
934
+
935
+ @classmethod
936
+ def tree_unflatten(cls, aux_data, children):
937
+ del children
938
+ return cls(*aux_data)
939
+
940
+
941
+ @_pytree_base
942
+ @dataclass(frozen=True)
943
+ class PolarAxisRegularVectorRule3D(_DataclassPyTreeMixin):
944
+ """Fill polar/axis-regularity halos for contravariant 3-vectors.
945
+
946
+ The first three axes of ``field_halo`` are spatial and the final axis has
947
+ length three. The rule performs the same radial mirror, angular shift, and
948
+ optional inter-shard permutation as the scalar polar rule, then applies
949
+ ``component_transform``:
950
+
951
+ ``V_target^i = T^i_j V_source^j``
952
+
953
+ For contravariant logical components, ``T`` should be the Jacobian of the
954
+ target logical coordinates with respect to the source logical coordinates.
955
+ A constant ``(3, 3)`` transform is supported, as is a transform whose
956
+ leading spatial dimensions broadcast to the target slab.
957
+
958
+ This rule is intentionally separate from
959
+ :class:`PolarAxisRegularScalarRule3D`; applying scalar polar regularity to
960
+ a vector field would copy the components without the required transform.
961
+ """
962
+
963
+ axis: int
964
+ side: str
965
+ angular_axis: int
966
+ mesh_axis_name: str | None
967
+ source_shard_offset: int
968
+ local_shift_cells: int
969
+ component_transform: jnp.ndarray
970
+ halo_width: int | None = None
971
+
972
+ def __post_init__(self) -> None:
973
+ axis = int(self.axis)
974
+ angular_axis = int(self.angular_axis)
975
+ if axis not in (0, 1, 2):
976
+ raise ValueError(f"axis must be 0, 1, or 2, got {axis}")
977
+ if angular_axis not in (0, 1, 2):
978
+ raise ValueError(
979
+ f"angular_axis must be 0, 1, or 2, got {angular_axis}"
980
+ )
981
+ if axis == angular_axis:
982
+ raise ValueError(
983
+ "angular_axis should be distinct from the polar/axis coordinate"
984
+ )
985
+ if self.side not in ("lower", "upper"):
986
+ raise ValueError(
987
+ f"side must be 'lower' or 'upper', got {self.side!r}"
988
+ )
989
+ if self.mesh_axis_name is not None and not isinstance(
990
+ self.mesh_axis_name, str
991
+ ):
992
+ raise TypeError("mesh_axis_name must be a string or None")
993
+
994
+ component_transform = jnp.asarray(
995
+ self.component_transform,
996
+ dtype=jnp.float64,
997
+ )
998
+ if component_transform.ndim < 2 or component_transform.shape[-2:] != (3, 3):
999
+ raise ValueError(
1000
+ "component_transform must have shape (3, 3) or a leading-spatial "
1001
+ f"shape ending in (3, 3), got {component_transform.shape}"
1002
+ )
1003
+
1004
+ halo_width = None if self.halo_width is None else int(self.halo_width)
1005
+ if halo_width is not None and halo_width < 0:
1006
+ raise ValueError(f"halo_width must be nonnegative, got {halo_width}")
1007
+
1008
+ object.__setattr__(self, "axis", axis)
1009
+ object.__setattr__(self, "angular_axis", angular_axis)
1010
+ object.__setattr__(self, "source_shard_offset", int(self.source_shard_offset))
1011
+ object.__setattr__(self, "local_shift_cells", int(self.local_shift_cells))
1012
+ object.__setattr__(self, "component_transform", component_transform)
1013
+ object.__setattr__(self, "halo_width", halo_width)
1014
+
1015
+ def __call__(
1016
+ self,
1017
+ field_halo: jnp.ndarray,
1018
+ domain: LocalDomain3D,
1019
+ ) -> jnp.ndarray:
1020
+ if not isinstance(domain, LocalDomain3D):
1021
+ raise TypeError(
1022
+ "PolarAxisRegularVectorRule3D requires LocalDomain3D, "
1023
+ f"got {type(domain).__name__}"
1024
+ )
1025
+
1026
+ field_halo = _validate_halo_spatial_prefix(field_halo, domain)
1027
+ if field_halo.ndim != 4 or field_halo.shape[-1] != 3:
1028
+ raise ValueError(
1029
+ "PolarAxisRegularVectorRule3D requires a field with shape "
1030
+ "cell_halo_shape + (3,), "
1031
+ f"got {field_halo.shape}"
1032
+ )
1033
+
1034
+ axis = self.axis
1035
+ angular_axis = self.angular_axis
1036
+ spec = domain.shard_spec
1037
+ if spec.shard_counts[axis] > 1 and domain.mesh_axis_names[axis] is None:
1038
+ raise ValueError(
1039
+ "PolarAxisRegularVectorRule3D requires a mesh axis name for a "
1040
+ "decomposed polar axis"
1041
+ )
1042
+ if (
1043
+ spec.lower_side_kind(angular_axis) != SIDE_SIMPLE_PERIODIC
1044
+ or spec.upper_side_kind(angular_axis) != SIDE_SIMPLE_PERIODIC
1045
+ ):
1046
+ raise ValueError(
1047
+ "PolarAxisRegularVectorRule3D requires SIDE_SIMPLE_PERIODIC "
1048
+ "on both global angular sides"
1049
+ )
1050
+
1051
+ side_kind = (
1052
+ spec.lower_side_kind(axis)
1053
+ if self.side == "lower"
1054
+ else spec.upper_side_kind(axis)
1055
+ )
1056
+ if side_kind != SIDE_AXIS_REGULAR:
1057
+ raise ValueError(
1058
+ "PolarAxisRegularVectorRule3D requires SIDE_AXIS_REGULAR on "
1059
+ f"the selected {self.side} side of axis {axis}"
1060
+ )
1061
+
1062
+ h = int(domain.layout.halo_width if self.halo_width is None else self.halo_width)
1063
+ if h == 0:
1064
+ return field_halo
1065
+ if h > domain.layout.halo_width:
1066
+ raise ValueError(
1067
+ "PolarAxisRegularVectorRule3D.halo_width cannot exceed the "
1068
+ f"field halo width; got {h}, layout halo width={domain.layout.halo_width}"
1069
+ )
1070
+ if h > domain.owned_shape[axis]:
1071
+ raise ValueError(
1072
+ "PolarAxisRegularVectorRule3D requires halo_width no larger "
1073
+ f"than the local polar extent; got h={h}, extent={domain.owned_shape[axis]}"
1074
+ )
1075
+
1076
+ global_angle = int(spec.global_shape[angular_axis])
1077
+ local_angle = int(domain.owned_shape[angular_axis])
1078
+ angle_count = int(spec.shard_counts[angular_axis])
1079
+ if global_angle % 2:
1080
+ raise ValueError(
1081
+ "PolarAxisRegularVectorRule3D requires an even global angular "
1082
+ f"count; got {global_angle}"
1083
+ )
1084
+ if global_angle != angle_count * local_angle:
1085
+ raise ValueError(
1086
+ "PolarAxisRegularVectorRule3D requires equal angular sharding; "
1087
+ f"got global_angle={global_angle}, angle_count={angle_count}, "
1088
+ f"local_angle={local_angle}"
1089
+ )
1090
+ if angle_count > 1 and self.mesh_axis_name is None:
1091
+ raise ValueError(
1092
+ "PolarAxisRegularVectorRule3D requires mesh_axis_name when the "
1093
+ "angular axis is sharded"
1094
+ )
1095
+ if (
1096
+ angle_count > 1
1097
+ and self.mesh_axis_name != domain.mesh_axis_names[angular_axis]
1098
+ ):
1099
+ raise ValueError(
1100
+ "PolarAxisRegularVectorRule3D.mesh_axis_name must match the "
1101
+ f"domain mesh name for angular axis {angular_axis}; "
1102
+ f"got rule={self.mesh_axis_name!r}, "
1103
+ f"domain={domain.mesh_axis_names[angular_axis]!r}"
1104
+ )
1105
+
1106
+ ext = tuple(int(value) for value in domain.layout.owned_shape)
1107
+ spatial_owned = tuple(slice(h, h + size) for size in ext)
1108
+ source_spatial = list(spatial_owned)
1109
+ target_spatial = list(spatial_owned)
1110
+ if self.side == "lower":
1111
+ do_write = domain.runtime_touches_lower(axis)
1112
+ source_spatial[axis] = slice(h, h + h)
1113
+ target_spatial[axis] = slice(0, h)
1114
+ else:
1115
+ do_write = domain.runtime_touches_upper(axis)
1116
+ source_spatial[axis] = slice(h + ext[axis] - h, h + ext[axis])
1117
+ target_spatial[axis] = slice(h + ext[axis], h + ext[axis] + h)
1118
+
1119
+ trailing = _trailing_slices(field_halo.ndim)
1120
+ source_index = tuple(source_spatial) + trailing
1121
+ target_index = tuple(target_spatial) + trailing
1122
+ recv = field_halo[source_index]
1123
+
1124
+ if angle_count > 1:
1125
+ recv = lax.ppermute(
1126
+ recv,
1127
+ axis_name=self.mesh_axis_name,
1128
+ perm=[
1129
+ (
1130
+ source,
1131
+ (source + self.source_shard_offset) % angle_count,
1132
+ )
1133
+ for source in range(angle_count)
1134
+ ],
1135
+ )
1136
+
1137
+ if self.local_shift_cells:
1138
+ recv = jnp.roll(
1139
+ recv,
1140
+ shift=-self.local_shift_cells,
1141
+ axis=angular_axis,
1142
+ )
1143
+
1144
+ # Match the scalar rule's radial ordering. The lower and upper source
1145
+ # slabs are both reversed before being written into the corresponding
1146
+ # ghost slab; this is essential when halo_width > 1.
1147
+ recv = jnp.flip(recv, axis=axis)
1148
+
1149
+ transform = jnp.asarray(self.component_transform, dtype=field_halo.dtype)
1150
+ target_transform_shape = recv.shape[:-1] + (3, 3)
1151
+ try:
1152
+ transform = jnp.broadcast_to(transform, target_transform_shape)
1153
+ except ValueError as exc:
1154
+ raise ValueError(
1155
+ "component_transform must be broadcastable to the target polar "
1156
+ f"slab matrix shape {target_transform_shape}, got {transform.shape}"
1157
+ ) from exc
1158
+ recv = jnp.einsum("...ij,...j->...i", transform, recv)
1159
+
1160
+ old = field_halo[target_index]
1161
+ return field_halo.at[target_index].set(jnp.where(do_write, recv, old))
1162
+
1163
+ def tree_flatten(self):
1164
+ return (self.component_transform,), (
1165
+ self.axis,
1166
+ self.side,
1167
+ self.angular_axis,
1168
+ self.mesh_axis_name,
1169
+ self.source_shard_offset,
1170
+ self.local_shift_cells,
1171
+ self.halo_width,
1172
+ )
1173
+
1174
+ @classmethod
1175
+ def tree_unflatten(cls, aux_data, children):
1176
+ (
1177
+ axis,
1178
+ side,
1179
+ angular_axis,
1180
+ mesh_axis_name,
1181
+ source_shard_offset,
1182
+ local_shift_cells,
1183
+ halo_width,
1184
+ ) = aux_data
1185
+ (component_transform,) = children
1186
+ return cls(
1187
+ axis=axis,
1188
+ side=side,
1189
+ angular_axis=angular_axis,
1190
+ mesh_axis_name=mesh_axis_name,
1191
+ source_shard_offset=source_shard_offset,
1192
+ local_shift_cells=local_shift_cells,
1193
+ component_transform=component_transform,
1194
+ halo_width=halo_width,
1195
+ )
1196
+
1197
+
1198
+ def make_default_topology_halo_filler_3d(
1199
+ *,
1200
+ angle_axis_name: str | None = None,
1201
+ radial_axis: int = 0,
1202
+ theta_axis: int = 1,
1203
+ radial_axis_lower_regular: bool = True,
1204
+ radial_axis_upper_regular: bool = False,
1205
+ fill_periodic_axes: tuple[bool, bool, bool] = (True, True, True),
1206
+ ) -> TopologyHaloFiller3D:
1207
+ """Build the default local periodic and scalar radial topology pipeline."""
1208
+
1209
+ return TopologyHaloFiller3D(
1210
+ rules=(
1211
+ LocalPeriodicTopologyRule3D(fill_axes=fill_periodic_axes),
1212
+ PolarAxisRegularScalarRule3D(
1213
+ angle_axis_name=angle_axis_name,
1214
+ radial_axis=radial_axis,
1215
+ angle_axis=theta_axis,
1216
+ fill_lower=radial_axis_lower_regular,
1217
+ fill_upper=radial_axis_upper_regular,
1218
+ ),
1219
+ )
1220
+ )
1221
+
1222
+
1223
+ @_pytree_base
1224
+ @dataclass(frozen=True)
1225
+ class GhostFillWeights1D(_DataclassPyTreeMixin):
1226
+ """Weights for one coordinate direction and one ghost-fill rule.
1227
+
1228
+ ``owned_weights[r, m]`` multiplies the ``m``-th owned cell inward from
1229
+ the boundary when constructing ghost layer ``r``. ``bc_weights[r]``
1230
+ multiplies the supplied boundary value. The weights are deliberately
1231
+ supplied by the caller so they can encode nonuniform spacing and any
1232
+ desired reconstruction order.
1233
+ """
1234
+
1235
+ owned_weights: jnp.ndarray
1236
+ bc_weights: jnp.ndarray
1237
+
1238
+ def __post_init__(self) -> None:
1239
+ owned_weights = jnp.asarray(self.owned_weights, dtype=jnp.float64)
1240
+ bc_weights = jnp.asarray(self.bc_weights, dtype=jnp.float64)
1241
+ if owned_weights.ndim != 2:
1242
+ raise ValueError(
1243
+ "GhostFillWeights1D.owned_weights must have shape "
1244
+ "(halo_width, stencil_width)"
1245
+ )
1246
+ if bc_weights.ndim != 1:
1247
+ raise ValueError(
1248
+ "GhostFillWeights1D.bc_weights must have shape (halo_width,)"
1249
+ )
1250
+ if bc_weights.shape[0] != owned_weights.shape[0]:
1251
+ raise ValueError(
1252
+ "GhostFillWeights1D.bc_weights length must match halo_width"
1253
+ )
1254
+ object.__setattr__(self, "owned_weights", owned_weights)
1255
+ object.__setattr__(self, "bc_weights", bc_weights)
1256
+
1257
+ @property
1258
+ def halo_width(self) -> int:
1259
+ return int(self.owned_weights.shape[0])
1260
+
1261
+ @property
1262
+ def stencil_width(self) -> int:
1263
+ return int(self.owned_weights.shape[1])
1264
+
1265
+ def tree_flatten(self):
1266
+ return (self.owned_weights, self.bc_weights), None
1267
+
1268
+ @classmethod
1269
+ def tree_unflatten(cls, _aux_data, children):
1270
+ owned_weights, bc_weights = children
1271
+ return cls(
1272
+ owned_weights=owned_weights,
1273
+ bc_weights=bc_weights,
1274
+ )
1275
+
1276
+
1277
+ def _validate_axis_weights(
1278
+ weights: tuple[GhostFillWeights1D, GhostFillWeights1D, GhostFillWeights1D],
1279
+ name: str,
1280
+ ) -> tuple[GhostFillWeights1D, GhostFillWeights1D, GhostFillWeights1D]:
1281
+ weights = tuple(weights)
1282
+ if len(weights) != 3:
1283
+ raise ValueError(f"{name} must contain one weight set per axis")
1284
+ if not all(isinstance(value, GhostFillWeights1D) for value in weights):
1285
+ raise TypeError(f"{name} entries must be GhostFillWeights1D instances")
1286
+ return weights # type: ignore[return-value]
1287
+
1288
+
1289
+ @_pytree_base
1290
+ @dataclass(frozen=True)
1291
+ class PhysicalGhostCellFiller3D(_DataclassPyTreeMixin):
1292
+ """Fill regular-coordinate physical face ghost slabs.
1293
+
1294
+ The BC payload is passed to ``__call__`` and is therefore dynamic: it may
1295
+ depend on the evolving state. The three weight collections are static
1296
+ reconstruction configuration, with one entry for each coordinate axis.
1297
+
1298
+ This stage fills only slabs with exactly one ghost-coordinate direction.
1299
+ It intentionally does not fill halo edge or corner pieces (cells with two
1300
+ or three ghost-coordinate directions); current operators do not require
1301
+ those values. A future topology/corner stage can own that policy.
1302
+
1303
+ Only ``BC_DIRICHLET`` and ``BC_NEUMANN`` are materialized. Flux-level BCs
1304
+ are not scalar ghost-cell rules and remain available to flux operators.
1305
+ """
1306
+
1307
+ dirichlet: tuple[GhostFillWeights1D, GhostFillWeights1D, GhostFillWeights1D]
1308
+ neumann_lower: tuple[GhostFillWeights1D, GhostFillWeights1D, GhostFillWeights1D]
1309
+ neumann_upper: tuple[GhostFillWeights1D, GhostFillWeights1D, GhostFillWeights1D]
1310
+
1311
+ def __post_init__(self) -> None:
1312
+ dirichlet = _validate_axis_weights(self.dirichlet, "dirichlet")
1313
+ neumann_lower = _validate_axis_weights(self.neumann_lower, "neumann_lower")
1314
+ neumann_upper = _validate_axis_weights(self.neumann_upper, "neumann_upper")
1315
+ for axis in range(3):
1316
+ h = dirichlet[axis].halo_width
1317
+ if neumann_lower[axis].halo_width != h:
1318
+ raise ValueError(f"neumann_lower[{axis}] halo_width must match dirichlet")
1319
+ if neumann_upper[axis].halo_width != h:
1320
+ raise ValueError(f"neumann_upper[{axis}] halo_width must match dirichlet")
1321
+ object.__setattr__(self, "dirichlet", dirichlet)
1322
+ object.__setattr__(self, "neumann_lower", neumann_lower)
1323
+ object.__setattr__(self, "neumann_upper", neumann_upper)
1324
+
1325
+ def __call__(
1326
+ self,
1327
+ field_halo: jnp.ndarray,
1328
+ domain: LocalDomain3D,
1329
+ face_bc: LocalBoundaryFaceBC3D | None,
1330
+ ) -> jnp.ndarray:
1331
+ if face_bc is None:
1332
+ return field_halo
1333
+ if not isinstance(domain, LocalDomain3D):
1334
+ raise TypeError("PhysicalGhostCellFiller3D.domain must be a LocalDomain3D")
1335
+ if not isinstance(face_bc, LocalBoundaryFaceBC3D):
1336
+ raise TypeError(
1337
+ "PhysicalGhostCellFiller3D.face_bc must be a LocalBoundaryFaceBC3D"
1338
+ )
1339
+ if face_bc.layout != domain.layout:
1340
+ raise ValueError("face_bc and domain must share the same HaloLayout3D")
1341
+
1342
+ field_halo = jnp.asarray(field_halo)
1343
+ expected_shape = domain.layout.cell_halo_shape
1344
+ if field_halo.shape != expected_shape:
1345
+ raise ValueError(f"field_halo must have shape {expected_shape}, got {field_halo.shape}")
1346
+
1347
+ h = int(domain.layout.halo_width)
1348
+ if h == 0:
1349
+ return field_halo
1350
+ for axis in range(3):
1351
+ if self.dirichlet[axis].halo_width != h:
1352
+ raise ValueError(f"ghost-fill weights[{axis}] halo_width must match domain halo_width")
1353
+ if self.dirichlet[axis].stencil_width > domain.owned_shape[axis]:
1354
+ raise ValueError(f"dirichlet[{axis}] stencil exceeds owned extent")
1355
+ if self.neumann_lower[axis].stencil_width > domain.owned_shape[axis]:
1356
+ raise ValueError(f"neumann_lower[{axis}] stencil exceeds owned extent")
1357
+ if self.neumann_upper[axis].stencil_width > domain.owned_shape[axis]:
1358
+ raise ValueError(f"neumann_upper[{axis}] stencil exceeds owned extent")
1359
+
1360
+ result = field_halo
1361
+ for axis in range(3):
1362
+ result = self._fill_axis_side(result, domain, face_bc, axis, "lower")
1363
+ result = self._fill_axis_side(result, domain, face_bc, axis, "upper")
1364
+ return result
1365
+
1366
+ def _fill_axis_side(self, field_halo, domain, face_bc, axis, side):
1367
+ if side == "lower":
1368
+ side_active = domain.runtime_has_physical_lower(axis)
1369
+ elif side == "upper":
1370
+ side_active = domain.runtime_has_physical_upper(axis)
1371
+ else:
1372
+ raise ValueError(f"side must be 'lower' or 'upper', got {side!r}")
1373
+
1374
+ kind, value, mask = self._bc_plane(face_bc, axis, side)
1375
+ old = self._ghost_slab(field_halo, domain.layout, axis, side)
1376
+ stencil_width = max(
1377
+ self.dirichlet[axis].stencil_width,
1378
+ (self.neumann_lower if side == "lower" else self.neumann_upper)[axis].stencil_width,
1379
+ )
1380
+ owned = self._owned_stencil(field_halo, domain.layout, axis, side, stencil_width)
1381
+ dghost = self._apply(self.dirichlet[axis], owned[: self.dirichlet[axis].stencil_width], value)
1382
+ nweights = (self.neumann_lower if side == "lower" else self.neumann_upper)[axis]
1383
+ nghost = self._apply(nweights, owned[: nweights.stencil_width], value)
1384
+ active_mask = side_active & mask
1385
+ new = jnp.where(
1386
+ (active_mask & (kind == BC_DIRICHLET))[None, ...],
1387
+ dghost,
1388
+ old,
1389
+ )
1390
+ new = jnp.where(
1391
+ (active_mask & (kind == BC_NEUMANN))[None, ...],
1392
+ nghost,
1393
+ new,
1394
+ )
1395
+ return self._set_ghost_slab(field_halo, new, domain.layout, axis, side)
1396
+
1397
+ @staticmethod
1398
+ def _apply(weights, owned, value):
1399
+ ghost = jnp.tensordot(weights.owned_weights, owned, axes=((1,), (0,)))
1400
+ return ghost + weights.bc_weights.reshape((weights.halo_width,) + (1,) * value.ndim) * value[None, ...]
1401
+
1402
+ @staticmethod
1403
+ def _bc_plane(face_bc, axis, side):
1404
+ index = 0 if side == "lower" else -1
1405
+ if axis == 0:
1406
+ return face_bc.kind_x[index], face_bc.value_x[index], face_bc.mask_x[index]
1407
+ if axis == 1:
1408
+ return face_bc.kind_y[:, index, :], face_bc.value_y[:, index, :], face_bc.mask_y[:, index, :]
1409
+ return face_bc.kind_z[:, :, index], face_bc.value_z[:, :, index], face_bc.mask_z[:, :, index]
1410
+
1411
+ @staticmethod
1412
+ def _owned_stencil(field, layout, axis, side, width):
1413
+ h = layout.halo_width
1414
+ ext = layout.owned_shape
1415
+ slices = [slice(h, h + n) for n in ext]
1416
+ if side == "lower":
1417
+ slices[axis] = slice(h, h + width)
1418
+ slab = field[tuple(slices)]
1419
+ else:
1420
+ slices[axis] = slice(h + ext[axis] - width, h + ext[axis])
1421
+ slab = field[tuple(slices)]
1422
+ slab = jnp.flip(slab, axis=axis)
1423
+ return jnp.moveaxis(slab, axis, 0)
1424
+
1425
+ @staticmethod
1426
+ def _ghost_slab(field, layout, axis, side):
1427
+ h = layout.halo_width
1428
+ ext = layout.owned_shape
1429
+ slices = [slice(h, h + n) for n in ext]
1430
+ slices[axis] = slice(0, h) if side == "lower" else slice(h + ext[axis], h + ext[axis] + h)
1431
+ slab = field[tuple(slices)]
1432
+ if side == "lower":
1433
+ slab = jnp.flip(slab, axis=axis)
1434
+ return jnp.moveaxis(slab, axis, 0)
1435
+
1436
+ @staticmethod
1437
+ def _set_ghost_slab(field, slab, layout, axis, side):
1438
+ h = layout.halo_width
1439
+ ext = layout.owned_shape
1440
+ raw = jnp.moveaxis(slab, 0, axis)
1441
+ if side == "lower":
1442
+ raw = jnp.flip(raw, axis=axis)
1443
+ slices = [slice(h, h + n) for n in ext]
1444
+ slices[axis] = slice(0, h) if side == "lower" else slice(h + ext[axis], h + ext[axis] + h)
1445
+ return field.at[tuple(slices)].set(raw)
1446
+
1447
+ def tree_flatten(self):
1448
+ return (self.dirichlet, self.neumann_lower, self.neumann_upper), None
1449
+
1450
+ @classmethod
1451
+ def tree_unflatten(cls, _aux_data, children):
1452
+ return cls(*children)
1453
+
1454
+
1455
+ @_pytree_base
1456
+ @dataclass(frozen=True)
1457
+ class PreparedLocalState3D(_DataclassPyTreeMixin):
1458
+ """Fully prepared local state and its model-shaped boundary payloads."""
1459
+
1460
+ state_halo: FciModelState
1461
+ boundary_data: LocalBoundaryData3D
1462
+
1463
+ def __post_init__(self) -> None:
1464
+ if not isinstance(self.state_halo, FciModelState):
1465
+ raise TypeError("PreparedLocalState3D.state_halo must be an FciModelState")
1466
+ if not isinstance(self.boundary_data, LocalBoundaryData3D):
1467
+ raise TypeError(
1468
+ "PreparedLocalState3D.boundary_data must be a LocalBoundaryData3D"
1469
+ )
1470
+
1471
+
1472
+ @_pytree_base
1473
+ @dataclass(frozen=True)
1474
+ class LocalStateAndBoundaryPreparer3D(_DataclassPyTreeMixin):
1475
+ """Prepare state halos, coupled BCs, and physical ghost cells in order.
1476
+
1477
+ The pre-BC stages operate field-by-field, while the boundary builder sees
1478
+ the complete topology-prepared state so it can construct coupled payloads.
1479
+ Physical ghost filling then applies the matching face BC to each field.
1480
+ Halo edges and corners remain governed by the individual field stages;
1481
+ this orchestrator does not add corner handling.
1482
+ """
1483
+
1484
+ boundary_builder: LocalBoundaryConditionBuilder
1485
+ physical_ghost_filler: PhysicalGhostCellFiller3D
1486
+ halo_exchange: HaloExchange3D | None = None
1487
+ topology_filler: TopologyHaloFiller3D | None = None
1488
+
1489
+ def __post_init__(self) -> None:
1490
+ if not isinstance(self.boundary_builder, LocalBoundaryConditionBuilder):
1491
+ raise TypeError(
1492
+ "boundary_builder must be a LocalBoundaryConditionBuilder"
1493
+ )
1494
+ if not isinstance(self.physical_ghost_filler, PhysicalGhostCellFiller3D):
1495
+ raise TypeError(
1496
+ "physical_ghost_filler must be a PhysicalGhostCellFiller3D"
1497
+ )
1498
+ if self.halo_exchange is not None and not isinstance(
1499
+ self.halo_exchange, HaloExchange3D
1500
+ ):
1501
+ raise TypeError("halo_exchange must be a HaloExchange3D or None")
1502
+ if self.topology_filler is not None and not isinstance(
1503
+ self.topology_filler, TopologyHaloFiller3D
1504
+ ):
1505
+ raise TypeError(
1506
+ "topology_filler must be a TopologyHaloFiller3D or None"
1507
+ )
1508
+
1509
+ def __call__(
1510
+ self,
1511
+ state_owned: FciModelStateT,
1512
+ geometry: LocalFciGeometry3D,
1513
+ domain: LocalDomain3D,
1514
+ cut_wall_geometry: LocalCutWallGeometry3D | None = None,
1515
+ ) -> PreparedLocalState3D:
1516
+ if not isinstance(state_owned, FciModelState):
1517
+ raise TypeError("state_owned must be an FciModelState instance")
1518
+ if not isinstance(domain, LocalDomain3D):
1519
+ raise TypeError("domain must be a LocalDomain3D instance")
1520
+
1521
+ state_halo = inject_owned_state_to_halo(state_owned, domain.layout)
1522
+
1523
+ if self.halo_exchange is not None:
1524
+ state_halo = state_halo.map_fields(
1525
+ lambda field_halo: self.halo_exchange(field_halo, domain)
1526
+ )
1527
+ if self.topology_filler is not None:
1528
+ state_halo = state_halo.map_fields(
1529
+ lambda field_halo: self.topology_filler(field_halo, domain)
1530
+ )
1531
+
1532
+ boundary_data = self.boundary_builder(
1533
+ state_halo,
1534
+ geometry,
1535
+ domain,
1536
+ cut_wall_geometry,
1537
+ )
1538
+ if boundary_data.face_bc is None:
1539
+ raise ValueError(
1540
+ "boundary_builder must return face_bc for every state field "
1541
+ "before physical ghost preparation"
1542
+ )
1543
+ face_bc_bundle = boundary_data.face_bc
1544
+ assert_matching_field_names(state_halo, face_bc_bundle)
1545
+
1546
+ state_halo_full = state_halo.replace(
1547
+ **{
1548
+ name: self.physical_ghost_filler(
1549
+ getattr(state_halo, name),
1550
+ domain,
1551
+ getattr(face_bc_bundle, name),
1552
+ )
1553
+ for name in state_halo.field_names()
1554
+ }
1555
+ )
1556
+ return PreparedLocalState3D(
1557
+ state_halo=state_halo_full,
1558
+ boundary_data=boundary_data,
1559
+ )
1560
+
1561
+ __all__ = [
1562
+ "FciCutWallValueEvaluator",
1563
+ "GhostFillWeights1D",
1564
+ "HaloExchange3D",
1565
+ "LocalFciCutWallValueEvaluator",
1566
+ "LocalPeriodicTopologyRule3D",
1567
+ "LocalStateAndBoundaryPreparer3D",
1568
+ "PreparedLocalState3D",
1569
+ "PhysicalGhostCellFiller3D",
1570
+ "PolarAxisRegularScalarRule3D",
1571
+ "PolarAxisRegularVectorRule3D",
1572
+ "RemoteFciDependencyExchange",
1573
+ "TopologyHaloFiller3D",
1574
+ "make_default_topology_halo_filler_3d",
1575
+ ]