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,2494 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Callable, Generic, TypeVar
5
+
6
+ import jax
7
+ import jax.numpy as jnp
8
+
9
+ from ..geometry.fci_geometry import (
10
+ _DataclassPyTreeMixin,
11
+ FciGeometry3D,
12
+ LocalDomain3D,
13
+ LocalFciGeometry3D,
14
+ HaloLayout3D,
15
+ LocalRegularFaceGeometry3D,
16
+ LocalCellVolumeGeometry3D,
17
+ RegularFaceGeometry3D,
18
+ CellVolumeGeometry3D,
19
+ )
20
+ from .fci_model import (
21
+ FciFieldBundle,
22
+ FciModelState,
23
+ assert_matching_field_names,
24
+ )
25
+ from .fci_helpers import (
26
+ _as_bool_face_array,
27
+ _as_coordinate_derivative_weight_array,
28
+ _as_coordinate_face_tuple,
29
+ _as_face_flux_array,
30
+ _as_float64_array,
31
+ _as_int_face_array,
32
+ _as_int_stencil_array,
33
+ _as_wall_face_array,
34
+ _as_weight_stencil_array,
35
+ _axis_regular_lower_x_face,
36
+ _normalize_axis_flags,
37
+ _local_cell_halo_array,
38
+ _as_local_wall_array,
39
+ _as_local_wall_int_array,
40
+ _as_local_wall_bool_array,
41
+ _as_local_wall_stencil_index_array,
42
+ _as_local_wall_stencil_weight_array,
43
+ )
44
+
45
+
46
+ _pytree_base = jax.tree_util.register_pytree_node_class
47
+
48
+
49
+ BC_NONE = 0
50
+ BC_DIRICHLET = 1
51
+ BC_NEUMANN = 2
52
+ BC_NORMALFLUX = 3
53
+ BC_NOFLUX = 4
54
+
55
+
56
+ BoundaryPayloadT = TypeVar("BoundaryPayloadT")
57
+
58
+
59
+ @_pytree_base
60
+ @dataclass(frozen=True)
61
+ class LocalBoundaryData3D(_DataclassPyTreeMixin):
62
+ """Model-shaped local boundary payload bundle.
63
+
64
+ ``face_bc`` and ``cut_wall_bc`` are field bundles: each bundle field names
65
+ the model field whose boundary payload it contains. This allows a
66
+ boundary builder to construct coupled BCs from the complete pre-BC state
67
+ while preserving an unambiguous field-to-BC association.
68
+ """
69
+
70
+ face_bc: FciFieldBundle | None = None
71
+ cut_wall_bc: FciFieldBundle | None = None
72
+
73
+ def __post_init__(self) -> None:
74
+ if self.face_bc is not None and not isinstance(self.face_bc, FciFieldBundle):
75
+ raise TypeError("LocalBoundaryData3D.face_bc must be an FciFieldBundle or None")
76
+ if self.cut_wall_bc is not None and not isinstance(self.cut_wall_bc, FciFieldBundle):
77
+ raise TypeError(
78
+ "LocalBoundaryData3D.cut_wall_bc must be an FciFieldBundle or None"
79
+ )
80
+
81
+ def tree_flatten(self):
82
+ return ((self.face_bc, self.cut_wall_bc), None)
83
+
84
+ @classmethod
85
+ def tree_unflatten(cls, _aux_data, children):
86
+ face_bc, cut_wall_bc = children
87
+ return cls(face_bc=face_bc, cut_wall_bc=cut_wall_bc)
88
+
89
+
90
+ @_pytree_base
91
+ @dataclass(frozen=True)
92
+ class LocalBoundaryConditionBuilder(_DataclassPyTreeMixin):
93
+ """Build all field boundary payloads from a complete pre-BC state."""
94
+
95
+ build_fn: Callable[
96
+ [
97
+ FciModelState,
98
+ LocalFciGeometry3D,
99
+ LocalDomain3D,
100
+ LocalCutWallGeometry3D | None,
101
+ ],
102
+ LocalBoundaryData3D,
103
+ ]
104
+
105
+ def __call__(
106
+ self,
107
+ state_halo_pre_bc: FciModelState,
108
+ geometry: LocalFciGeometry3D,
109
+ domain: LocalDomain3D,
110
+ cut_wall_geometry: LocalCutWallGeometry3D | None,
111
+ ) -> LocalBoundaryData3D:
112
+ if not isinstance(state_halo_pre_bc, FciModelState):
113
+ raise TypeError(
114
+ "LocalBoundaryConditionBuilder requires an FciModelState "
115
+ "with physical ghost cells not yet filled"
116
+ )
117
+ state_halo_pre_bc.assert_field_shape(domain.layout.cell_halo_shape)
118
+ result = self.build_fn(
119
+ state_halo_pre_bc,
120
+ geometry,
121
+ domain,
122
+ cut_wall_geometry,
123
+ )
124
+ if not isinstance(result, LocalBoundaryData3D):
125
+ raise TypeError(
126
+ "LocalBoundaryConditionBuilder.build_fn must return "
127
+ "LocalBoundaryData3D"
128
+ )
129
+ if result.face_bc is not None:
130
+ assert_matching_field_names(state_halo_pre_bc, result.face_bc)
131
+ if result.cut_wall_bc is not None:
132
+ assert_matching_field_names(state_halo_pre_bc, result.cut_wall_bc)
133
+ return result
134
+
135
+ def tree_flatten(self):
136
+ return (), self.build_fn
137
+
138
+ @classmethod
139
+ def tree_unflatten(cls, aux_data, children):
140
+ return cls(aux_data)
141
+
142
+
143
+
144
+ @_pytree_base
145
+ @dataclass(frozen=True)
146
+ class CoordinateFaceValueReconstructor3D:
147
+ """Dense coordinate-face value reconstructor for cell-centered fields."""
148
+
149
+ def extrapolate(
150
+ self,
151
+ field: jnp.ndarray,
152
+ geometry: FciGeometry3D,
153
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
154
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
155
+ ) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
156
+ values = _as_float64_array(field, "CoordinateFaceValueReconstructor3D.field")
157
+ if values.shape != geometry.shape:
158
+ raise ValueError(f"field must have shape {geometry.shape}, got {values.shape}")
159
+ periodic_axes = _normalize_axis_flags(periodic_axes, "periodic_axes")
160
+ axis_regular_axes = _normalize_axis_flags(axis_regular_axes, "axis_regular_axes")
161
+ if any(periodic and axis_regular for periodic, axis_regular in zip(periodic_axes, axis_regular_axes)):
162
+ raise ValueError(
163
+ "periodic_axes and axis_regular_axes cannot both be True on the same axis; "
164
+ f"got periodic_axes={periodic_axes}, axis_regular_axes={axis_regular_axes}"
165
+ )
166
+ if axis_regular_axes[1] or axis_regular_axes[2]:
167
+ raise ValueError(
168
+ "axis_regular_axes currently only supports the lower x axis; "
169
+ f"got axis_regular_axes={axis_regular_axes}"
170
+ )
171
+
172
+ x_faces = jnp.empty((values.shape[0] + 1, values.shape[1], values.shape[2]), dtype=jnp.float64)
173
+ y_faces = jnp.empty((values.shape[0], values.shape[1] + 1, values.shape[2]), dtype=jnp.float64)
174
+ z_faces = jnp.empty((values.shape[0], values.shape[1], values.shape[2] + 1), dtype=jnp.float64)
175
+
176
+ x_faces = x_faces.at[1:-1].set(0.5 * (values[:-1] + values[1:]))
177
+ if periodic_axes[0]:
178
+ x_periodic = 0.5 * (values[0] + values[-1])
179
+ x_faces = x_faces.at[0].set(x_periodic)
180
+ x_faces = x_faces.at[-1].set(x_periodic)
181
+ elif axis_regular_axes[0]:
182
+ x_faces = x_faces.at[0].set(_axis_regular_lower_x_face(values))
183
+ x_faces = x_faces.at[-1].set(values[-1])
184
+ else:
185
+ x_faces = x_faces.at[0].set(values[0])
186
+ x_faces = x_faces.at[-1].set(values[-1])
187
+
188
+ y_faces = y_faces.at[:, 1:-1, :].set(0.5 * (values[:, :-1, :] + values[:, 1:, :]))
189
+ if periodic_axes[1]:
190
+ y_periodic = 0.5 * (values[:, 0, :] + values[:, -1, :])
191
+ y_faces = y_faces.at[:, 0, :].set(y_periodic)
192
+ y_faces = y_faces.at[:, -1, :].set(y_periodic)
193
+ else:
194
+ y_faces = y_faces.at[:, 0, :].set(values[:, 0, :])
195
+ y_faces = y_faces.at[:, -1, :].set(values[:, -1, :])
196
+
197
+ z_faces = z_faces.at[:, :, 1:-1].set(0.5 * (values[:, :, :-1] + values[:, :, 1:]))
198
+ if periodic_axes[2]:
199
+ z_periodic = 0.5 * (values[:, :, 0] + values[:, :, -1])
200
+ z_faces = z_faces.at[:, :, 0].set(z_periodic)
201
+ z_faces = z_faces.at[:, :, -1].set(z_periodic)
202
+ else:
203
+ z_faces = z_faces.at[:, :, 0].set(values[:, :, 0])
204
+ z_faces = z_faces.at[:, :, -1].set(values[:, :, -1])
205
+ return x_faces, y_faces, z_faces
206
+
207
+ def extrapolate_neumann(
208
+ self,
209
+ field: jnp.ndarray,
210
+ geometry: FciGeometry3D,
211
+ normal_derivative: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray],
212
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
213
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
214
+ ) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
215
+ values = _as_float64_array(field, "CoordinateFaceValueReconstructor3D.field")
216
+ if values.shape != geometry.shape:
217
+ raise ValueError(f"field must have shape {geometry.shape}, got {values.shape}")
218
+ periodic_axes = _normalize_axis_flags(periodic_axes, "periodic_axes")
219
+ axis_regular_axes = _normalize_axis_flags(axis_regular_axes, "axis_regular_axes")
220
+ if any(periodic and axis_regular for periodic, axis_regular in zip(periodic_axes, axis_regular_axes)):
221
+ raise ValueError(
222
+ "periodic_axes and axis_regular_axes cannot both be True on the same axis; "
223
+ f"got periodic_axes={periodic_axes}, axis_regular_axes={axis_regular_axes}"
224
+ )
225
+ if axis_regular_axes[1] or axis_regular_axes[2]:
226
+ raise ValueError(
227
+ "axis_regular_axes currently only supports the lower x axis; "
228
+ f"got axis_regular_axes={axis_regular_axes}"
229
+ )
230
+ # normal_derivative is interpreted as the outward-pointing normal derivative
231
+ # on the nonperiodic boundary faces. The lower x face is topological when
232
+ # axis_regular_axes[0] is True, so it is left untouched.
233
+ gx, gy, gz = _as_coordinate_face_tuple(
234
+ normal_derivative,
235
+ geometry,
236
+ "CoordinateFaceValueReconstructor3D.normal_derivative",
237
+ )
238
+ x_faces, y_faces, z_faces = self.extrapolate(
239
+ values,
240
+ geometry,
241
+ periodic_axes=periodic_axes,
242
+ axis_regular_axes=axis_regular_axes,
243
+ )
244
+ dx = jnp.asarray(geometry.spacing.dx, dtype=jnp.float64)
245
+ dy = jnp.asarray(geometry.spacing.dy, dtype=jnp.float64)
246
+ dz = jnp.asarray(geometry.spacing.dz, dtype=jnp.float64)
247
+
248
+ if not periodic_axes[0] and not axis_regular_axes[0]:
249
+ x_faces = x_faces.at[0].set(values[0] + 0.5 * dx[0] * gx[0])
250
+ x_faces = x_faces.at[-1].set(values[-1] + 0.5 * dx[-1] * gx[-1])
251
+ elif not periodic_axes[0]:
252
+ x_faces = x_faces.at[-1].set(values[-1] + 0.5 * dx[-1] * gx[-1])
253
+ if not periodic_axes[1]:
254
+ y_faces = y_faces.at[:, 0, :].set(values[:, 0, :] + 0.5 * dy[:, 0, :] * gy[:, 0, :])
255
+ y_faces = y_faces.at[:, -1, :].set(values[:, -1, :] + 0.5 * dy[:, -1, :] * gy[:, -1, :])
256
+ if not periodic_axes[2]:
257
+ z_faces = z_faces.at[:, :, 0].set(values[:, :, 0] + 0.5 * dz[:, :, 0] * gz[:, :, 0])
258
+ z_faces = z_faces.at[:, :, -1].set(values[:, :, -1] + 0.5 * dz[:, :, -1] * gz[:, :, -1])
259
+ return x_faces, y_faces, z_faces
260
+
261
+ def tree_flatten(self):
262
+ return (), None
263
+
264
+ @classmethod
265
+ def tree_unflatten(cls, _aux_data, children):
266
+ return cls()
267
+
268
+
269
+ def _local_coordinate_face_values_from_halo(
270
+ field_halo: jnp.ndarray,
271
+ geometry: LocalFciGeometry3D,
272
+ layout: HaloLayout3D,
273
+ ) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
274
+ values = _as_float64_array(field_halo, "LocalCoordinateFaceValueReconstructor3D.field_halo")
275
+ if values.shape != layout.cell_halo_shape:
276
+ raise ValueError(
277
+ "field_halo must have shape layout.cell_halo_shape; "
278
+ f"got {values.shape}, expected {layout.cell_halo_shape}"
279
+ )
280
+ if geometry.layout != layout:
281
+ raise ValueError("geometry and layout must share the same HaloLayout3D")
282
+ if layout.halo_width < 1:
283
+ raise ValueError("local face reconstruction requires halo_width >= 1")
284
+
285
+ h = layout.halo_width
286
+ nx, ny, nz = layout.owned_shape
287
+
288
+ x_faces = 0.5 * (
289
+ values[h - 1 : h + nx, h : h + ny, h : h + nz]
290
+ + values[h : h + nx + 1, h : h + ny, h : h + nz]
291
+ )
292
+ y_faces = 0.5 * (
293
+ values[h : h + nx, h - 1 : h + ny, h : h + nz]
294
+ + values[h : h + nx, h : h + ny + 1, h : h + nz]
295
+ )
296
+ z_faces = 0.5 * (
297
+ values[h : h + nx, h : h + ny, h - 1 : h + nz]
298
+ + values[h : h + nx, h : h + ny, h : h + nz + 1]
299
+ )
300
+ return x_faces, y_faces, z_faces
301
+
302
+
303
+ @_pytree_base
304
+ @dataclass(frozen=True)
305
+ class LocalCoordinateFaceValueReconstructor3D(_DataclassPyTreeMixin):
306
+ """Reconstruct coordinate control-face values from a complete local halo field."""
307
+
308
+ reconstruct_fn: Callable[
309
+ [
310
+ jnp.ndarray,
311
+ "LocalFciGeometry3D",
312
+ "HaloLayout3D",
313
+ ],
314
+ tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray],
315
+ ] = _local_coordinate_face_values_from_halo
316
+
317
+ def extrapolate(
318
+ self,
319
+ field_halo: jnp.ndarray,
320
+ geometry: "LocalFciGeometry3D",
321
+ layout: "HaloLayout3D",
322
+ ) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
323
+ return self.reconstruct_fn(field_halo, geometry, layout)
324
+
325
+ def tree_flatten(self):
326
+ return (), self.reconstruct_fn
327
+
328
+ @classmethod
329
+ def tree_unflatten(cls, aux_data, children):
330
+ return cls(aux_data)
331
+
332
+
333
+ @_pytree_base
334
+ @dataclass(frozen=True)
335
+ class LocalCoordinateSideValues1D(_DataclassPyTreeMixin):
336
+ """Lower/upper side-plane payloads for one coordinate axis."""
337
+
338
+ lower: jnp.ndarray
339
+ upper: jnp.ndarray
340
+ mask_lower: jnp.ndarray
341
+ mask_upper: jnp.ndarray
342
+
343
+ def __post_init__(self) -> None:
344
+ lower = jnp.asarray(self.lower, dtype=jnp.float64)
345
+ upper = jnp.asarray(self.upper, dtype=jnp.float64)
346
+ mask_lower = jnp.asarray(self.mask_lower, dtype=bool)
347
+ mask_upper = jnp.asarray(self.mask_upper, dtype=bool)
348
+ if lower.shape != upper.shape:
349
+ raise ValueError(
350
+ "LocalCoordinateSideValues1D.lower and upper must have the same shape; "
351
+ f"got lower={lower.shape}, upper={upper.shape}"
352
+ )
353
+ if mask_lower.shape != lower.shape or mask_upper.shape != lower.shape:
354
+ raise ValueError(
355
+ "LocalCoordinateSideValues1D masks must match the side-plane shape; "
356
+ f"got lower={lower.shape}, mask_lower={mask_lower.shape}, mask_upper={mask_upper.shape}"
357
+ )
358
+ object.__setattr__(self, "lower", lower)
359
+ object.__setattr__(self, "upper", upper)
360
+ object.__setattr__(self, "mask_lower", mask_lower)
361
+ object.__setattr__(self, "mask_upper", mask_upper)
362
+
363
+ def replace(self, **updates: object) -> "LocalCoordinateSideValues1D":
364
+ allowed = {"lower", "upper", "mask_lower", "mask_upper"}
365
+ unknown = set(updates) - allowed
366
+ if unknown:
367
+ names = ", ".join(sorted(unknown))
368
+ raise ValueError(f"Unknown LocalCoordinateSideValues1D field(s): {names}")
369
+ return LocalCoordinateSideValues1D(
370
+ lower=updates.get("lower", self.lower),
371
+ upper=updates.get("upper", self.upper),
372
+ mask_lower=updates.get("mask_lower", self.mask_lower),
373
+ mask_upper=updates.get("mask_upper", self.mask_upper),
374
+ )
375
+
376
+ def tree_flatten(self):
377
+ return ((self.lower, self.upper, self.mask_lower, self.mask_upper), None)
378
+
379
+ @classmethod
380
+ def tree_unflatten(cls, _aux_data, children):
381
+ return cls(*children)
382
+
383
+
384
+ @_pytree_base
385
+ @dataclass(frozen=True)
386
+ class LocalCoordinateSideValues3D(_DataclassPyTreeMixin):
387
+ """Lower/upper coordinate-side payloads for the three axes."""
388
+
389
+ x: LocalCoordinateSideValues1D
390
+ y: LocalCoordinateSideValues1D
391
+ z: LocalCoordinateSideValues1D
392
+
393
+ def __post_init__(self) -> None:
394
+ if not isinstance(self.x, LocalCoordinateSideValues1D):
395
+ raise TypeError("LocalCoordinateSideValues3D.x must be a LocalCoordinateSideValues1D")
396
+ if not isinstance(self.y, LocalCoordinateSideValues1D):
397
+ raise TypeError("LocalCoordinateSideValues3D.y must be a LocalCoordinateSideValues1D")
398
+ if not isinstance(self.z, LocalCoordinateSideValues1D):
399
+ raise TypeError("LocalCoordinateSideValues3D.z must be a LocalCoordinateSideValues1D")
400
+
401
+ def replace(self, **updates: object) -> "LocalCoordinateSideValues3D":
402
+ allowed = {"x", "y", "z"}
403
+ unknown = set(updates) - allowed
404
+ if unknown:
405
+ names = ", ".join(sorted(unknown))
406
+ raise ValueError(f"Unknown LocalCoordinateSideValues3D field(s): {names}")
407
+ return LocalCoordinateSideValues3D(
408
+ x=updates.get("x", self.x),
409
+ y=updates.get("y", self.y),
410
+ z=updates.get("z", self.z),
411
+ )
412
+
413
+ def tree_flatten(self):
414
+ return ((self.x, self.y, self.z), None)
415
+
416
+ @classmethod
417
+ def tree_unflatten(cls, _aux_data, children):
418
+ return cls(*children)
419
+
420
+
421
+ def _local_side_samples(
422
+ field_halo: jnp.ndarray,
423
+ layout: HaloLayout3D,
424
+ axis: int,
425
+ side: int,
426
+ sample_count: int,
427
+ ) -> list[jnp.ndarray]:
428
+ values = _local_cell_halo_array(field_halo, layout, "field_halo")
429
+ axis = int(axis)
430
+ side = int(side)
431
+ if sample_count < 1:
432
+ raise ValueError(f"sample_count must be positive, got {sample_count}")
433
+ h = layout.halo_width
434
+ nx, ny, nz = layout.owned_shape
435
+ if axis == 0:
436
+ base = h if side == 0 else h + nx - 1
437
+ step = 1 if side == 0 else -1
438
+ return [values[base + step * i, h : h + ny, h : h + nz] for i in range(sample_count)]
439
+ if axis == 1:
440
+ base = h if side == 0 else h + ny - 1
441
+ step = 1 if side == 0 else -1
442
+ return [values[h : h + nx, base + step * i, h : h + nz] for i in range(sample_count)]
443
+ base = h if side == 0 else h + nz - 1
444
+ step = 1 if side == 0 else -1
445
+ return [values[h : h + nx, h : h + ny, base + step * i] for i in range(sample_count)]
446
+
447
+
448
+ def _local_coordinate_side_values_from_array(
449
+ values: jnp.ndarray,
450
+ geometry: LocalFciGeometry3D,
451
+ layout: HaloLayout3D,
452
+ *,
453
+ name: str,
454
+ ) -> LocalCoordinateSideValues3D:
455
+ values = _as_float64_array(values, name)
456
+ if values.shape != layout.cell_halo_shape:
457
+ raise ValueError(f"{name} must have shape {layout.cell_halo_shape}, got {values.shape}")
458
+ if geometry.layout != layout:
459
+ raise ValueError("geometry and layout must share the same HaloLayout3D")
460
+ nx, ny, nz = layout.owned_shape
461
+ h = layout.halo_width
462
+ return LocalCoordinateSideValues3D(
463
+ x=LocalCoordinateSideValues1D(
464
+ lower=values[h - 1 : h, h : h + ny, h : h + nz][0],
465
+ upper=values[h + nx : h + nx + 1, h : h + ny, h : h + nz][0],
466
+ mask_lower=jnp.ones((ny, nz), dtype=bool),
467
+ mask_upper=jnp.ones((ny, nz), dtype=bool),
468
+ ),
469
+ y=LocalCoordinateSideValues1D(
470
+ lower=values[h : h + nx, h - 1 : h, h : h + nz][:, 0, :],
471
+ upper=values[h : h + nx, h + ny : h + ny + 1, h : h + nz][:, 0, :],
472
+ mask_lower=jnp.ones((nx, nz), dtype=bool),
473
+ mask_upper=jnp.ones((nx, nz), dtype=bool),
474
+ ),
475
+ z=LocalCoordinateSideValues1D(
476
+ lower=values[h : h + nx, h : h + ny, h - 1 : h][:, :, 0],
477
+ upper=values[h : h + nx, h : h + ny, h + nz : h + nz + 1][:, :, 0],
478
+ mask_lower=jnp.ones((nx, ny), dtype=bool),
479
+ mask_upper=jnp.ones((nx, ny), dtype=bool),
480
+ ),
481
+ )
482
+
483
+
484
+ @_pytree_base
485
+ @dataclass(frozen=True)
486
+ class LocalCoordinateNormalDerivativeConstructor3D(_DataclassPyTreeMixin):
487
+ """Construct coordinate-side normal derivatives using local halo data."""
488
+
489
+ dnormal_weights: jnp.ndarray
490
+ d2normal_weights: jnp.ndarray
491
+
492
+ def __post_init__(self) -> None:
493
+ dnormal_weights = jnp.asarray(self.dnormal_weights, dtype=jnp.float64)
494
+ d2normal_weights = jnp.asarray(self.d2normal_weights, dtype=jnp.float64)
495
+ if dnormal_weights.ndim != 3 or d2normal_weights.ndim != 3:
496
+ raise ValueError(
497
+ "LocalCoordinateNormalDerivativeConstructor3D weights must have shape (3, 2, stencil_width)"
498
+ )
499
+ if dnormal_weights.shape != d2normal_weights.shape:
500
+ raise ValueError(
501
+ "LocalCoordinateNormalDerivativeConstructor3D weight tensors must have the same shape; "
502
+ f"got {dnormal_weights.shape} and {d2normal_weights.shape}"
503
+ )
504
+ if dnormal_weights.shape[0] != 3 or dnormal_weights.shape[1] != 2:
505
+ raise ValueError(
506
+ "LocalCoordinateNormalDerivativeConstructor3D weights must have leading shape (3, 2, stencil_width); "
507
+ f"got {dnormal_weights.shape}"
508
+ )
509
+ if dnormal_weights.shape[2] < 2:
510
+ raise ValueError(
511
+ "LocalCoordinateNormalDerivativeConstructor3D requires at least two stencil points"
512
+ )
513
+ object.__setattr__(self, "dnormal_weights", dnormal_weights)
514
+ object.__setattr__(self, "d2normal_weights", d2normal_weights)
515
+
516
+ @property
517
+ def stencil_width(self) -> int:
518
+ return int(self.dnormal_weights.shape[2])
519
+
520
+ @classmethod
521
+ def from_geometry(cls, geometry: FciGeometry3D) -> "LocalCoordinateNormalDerivativeConstructor3D":
522
+ base = CoordinateNormalDerivativeConstructor3D.from_geometry(geometry)
523
+ return cls(dnormal_weights=base.dnormal_weights, d2normal_weights=base.d2normal_weights)
524
+
525
+ def _wall_side_derivatives(
526
+ self,
527
+ field_halo: jnp.ndarray,
528
+ wall_value: LocalCoordinateSideValues1D,
529
+ geometry: LocalFciGeometry3D,
530
+ layout: HaloLayout3D,
531
+ *,
532
+ axis: int,
533
+ ) -> tuple[jnp.ndarray, jnp.ndarray]:
534
+ sample_count = self.stencil_width - 1
535
+ lower_samples = _local_side_samples(field_halo, layout, axis, 0, sample_count)
536
+ upper_samples = _local_side_samples(field_halo, layout, axis, 1, sample_count)
537
+ dnormal_lower = self.dnormal_weights[axis, 0, 0] * wall_value.lower
538
+ d2normal_lower = self.d2normal_weights[axis, 0, 0] * wall_value.lower
539
+ dnormal_upper = self.dnormal_weights[axis, 1, 0] * wall_value.upper
540
+ d2normal_upper = self.d2normal_weights[axis, 1, 0] * wall_value.upper
541
+ for idx, sample in enumerate(lower_samples, start=1):
542
+ dnormal_lower = dnormal_lower + self.dnormal_weights[axis, 0, idx] * sample
543
+ d2normal_lower = d2normal_lower + self.d2normal_weights[axis, 0, idx] * sample
544
+ for idx, sample in enumerate(upper_samples, start=1):
545
+ dnormal_upper = dnormal_upper + self.dnormal_weights[axis, 1, idx] * sample
546
+ d2normal_upper = d2normal_upper + self.d2normal_weights[axis, 1, idx] * sample
547
+ dnormal_lower = jnp.where(wall_value.mask_lower, dnormal_lower, 0.0)
548
+ d2normal_lower = jnp.where(wall_value.mask_lower, d2normal_lower, 0.0)
549
+ dnormal_upper = jnp.where(wall_value.mask_upper, dnormal_upper, 0.0)
550
+ d2normal_upper = jnp.where(wall_value.mask_upper, d2normal_upper, 0.0)
551
+ return dnormal_lower, dnormal_upper, d2normal_lower, d2normal_upper
552
+
553
+ def dnormal_from_wall_value(
554
+ self,
555
+ field_halo: jnp.ndarray,
556
+ wall_value: LocalCoordinateSideValues3D,
557
+ geometry: "LocalFciGeometry3D",
558
+ layout: "HaloLayout3D",
559
+ ) -> "LocalCoordinateSideValues3D":
560
+ dnormal, _d2normal = self.normal_derivatives_from_wall_value(field_halo, wall_value, geometry, layout)
561
+ return dnormal
562
+
563
+ def d2normal_from_wall_value(
564
+ self,
565
+ field_halo: jnp.ndarray,
566
+ wall_value: LocalCoordinateSideValues3D,
567
+ geometry: "LocalFciGeometry3D",
568
+ layout: "HaloLayout3D",
569
+ ) -> "LocalCoordinateSideValues3D":
570
+ _dnormal, d2normal = self.normal_derivatives_from_wall_value(field_halo, wall_value, geometry, layout)
571
+ return d2normal
572
+
573
+ def normal_derivatives_from_wall_value(
574
+ self,
575
+ field_halo: jnp.ndarray,
576
+ wall_value: LocalCoordinateSideValues3D,
577
+ geometry: "LocalFciGeometry3D",
578
+ layout: "HaloLayout3D",
579
+ ) -> tuple["LocalCoordinateSideValues3D", "LocalCoordinateSideValues3D"]:
580
+ values = _local_cell_halo_array(field_halo, layout, "field_halo")
581
+ if geometry.layout != layout:
582
+ raise ValueError("geometry and layout must share the same HaloLayout3D")
583
+ if layout.halo_width < 1:
584
+ raise ValueError("local normal-derivative reconstruction requires halo_width >= 1")
585
+ if not isinstance(wall_value, LocalCoordinateSideValues3D):
586
+ raise TypeError("wall_value must be a LocalCoordinateSideValues3D instance")
587
+
588
+ x_dnormal_lower, x_dnormal_upper, x_d2_lower, x_d2_upper = self._wall_side_derivatives(
589
+ values, wall_value.x, geometry, layout, axis=0
590
+ )
591
+ y_dnormal_lower, y_dnormal_upper, y_d2_lower, y_d2_upper = self._wall_side_derivatives(
592
+ values, wall_value.y, geometry, layout, axis=1
593
+ )
594
+ z_dnormal_lower, z_dnormal_upper, z_d2_lower, z_d2_upper = self._wall_side_derivatives(
595
+ values, wall_value.z, geometry, layout, axis=2
596
+ )
597
+
598
+ return (
599
+ LocalCoordinateSideValues3D(
600
+ x=LocalCoordinateSideValues1D(
601
+ lower=x_dnormal_lower,
602
+ upper=x_dnormal_upper,
603
+ mask_lower=wall_value.x.mask_lower,
604
+ mask_upper=wall_value.x.mask_upper,
605
+ ),
606
+ y=LocalCoordinateSideValues1D(
607
+ lower=y_dnormal_lower,
608
+ upper=y_dnormal_upper,
609
+ mask_lower=wall_value.y.mask_lower,
610
+ mask_upper=wall_value.y.mask_upper,
611
+ ),
612
+ z=LocalCoordinateSideValues1D(
613
+ lower=z_dnormal_lower,
614
+ upper=z_dnormal_upper,
615
+ mask_lower=wall_value.z.mask_lower,
616
+ mask_upper=wall_value.z.mask_upper,
617
+ ),
618
+ ),
619
+ LocalCoordinateSideValues3D(
620
+ x=LocalCoordinateSideValues1D(
621
+ lower=x_d2_lower,
622
+ upper=x_d2_upper,
623
+ mask_lower=wall_value.x.mask_lower,
624
+ mask_upper=wall_value.x.mask_upper,
625
+ ),
626
+ y=LocalCoordinateSideValues1D(
627
+ lower=y_d2_lower,
628
+ upper=y_d2_upper,
629
+ mask_lower=wall_value.y.mask_lower,
630
+ mask_upper=wall_value.y.mask_upper,
631
+ ),
632
+ z=LocalCoordinateSideValues1D(
633
+ lower=z_d2_lower,
634
+ upper=z_d2_upper,
635
+ mask_lower=wall_value.z.mask_lower,
636
+ mask_upper=wall_value.z.mask_upper,
637
+ ),
638
+ ),
639
+ )
640
+
641
+ def tree_flatten(self):
642
+ return ((self.dnormal_weights, self.d2normal_weights), None)
643
+
644
+ @classmethod
645
+ def tree_unflatten(cls, _aux_data, children):
646
+ return cls(*children)
647
+
648
+
649
+ @_pytree_base
650
+ @dataclass(frozen=True)
651
+ class CoordinateNormalDerivativeConstructor3D:
652
+ """Dense coordinate-boundary normal derivative constructor.
653
+
654
+ The stored weights have shape ``(3, 2, 4)``:
655
+ axis 0 is x/y/z, axis 1 is lower/upper wall, and axis 2 multiplies
656
+ ``(wall_value, first interior cell, second interior cell, third interior cell)``.
657
+ These weights are the coordinate-face analogue of the cut-wall least-squares
658
+ weights below: construction is geometry-dependent, application is just a
659
+ linear combination of wall and cell-centered values.
660
+
661
+ Sign convention:
662
+ dnormal_from_wall_value(...) returns the outward normal derivative
663
+ d/dn, not the inward coordinate derivative d/ds. The weight factory
664
+ applies the sign flip once during construction so callers can use the
665
+ physical outward-normal convention directly.
666
+ """
667
+
668
+ dnormal_weights: jnp.ndarray # (axis=3, side=2, wall+3 interior nodes=4)
669
+ d2normal_weights: jnp.ndarray # (axis=3, side=2, wall+3 interior nodes=4)
670
+
671
+ def __post_init__(self) -> None:
672
+ object.__setattr__(
673
+ self,
674
+ "dnormal_weights",
675
+ _as_coordinate_derivative_weight_array(
676
+ self.dnormal_weights,
677
+ "CoordinateNormalDerivativeConstructor3D.dnormal_weights",
678
+ ),
679
+ )
680
+ object.__setattr__(
681
+ self,
682
+ "d2normal_weights",
683
+ _as_coordinate_derivative_weight_array(
684
+ self.d2normal_weights,
685
+ "CoordinateNormalDerivativeConstructor3D.d2normal_weights",
686
+ ),
687
+ )
688
+
689
+ @classmethod
690
+ def from_geometry(cls, geometry: FciGeometry3D) -> "CoordinateNormalDerivativeConstructor3D":
691
+ """Build second-order wall-normal derivative weights from grid geometry."""
692
+
693
+ if geometry.shape[0] < 3 or geometry.shape[1] < 3 or geometry.shape[2] < 3:
694
+ raise ValueError(
695
+ "CoordinateNormalDerivativeConstructor3D.from_geometry requires at least "
696
+ f"three cells along every axis, got {geometry.shape}"
697
+ )
698
+
699
+ def _weights_for_side(a, b, c):
700
+ nodes = jnp.asarray((0.0, a, b, c), dtype=jnp.float64)
701
+ vandermonde = jnp.stack((nodes**0, nodes, nodes**2, nodes**3), axis=0)
702
+ # Local coordinate s points inward from the wall towards computational domain.
703
+ #The returned first-derivative weights are already converted to the outward
704
+ # normal convention pointing away from the computational domain,
705
+ #so d/dn = -d/ds is baked in once here.
706
+ d1_weights = -jnp.linalg.solve(
707
+ vandermonde,
708
+ jnp.asarray((0.0, 1.0, 0.0, 0.0), dtype=jnp.float64),
709
+ )
710
+ # The second normal derivative has no sign flip:
711
+ # d2/dn2 = d2/ds2.
712
+ d2_weights = jnp.linalg.solve(
713
+ vandermonde,
714
+ jnp.asarray((0.0, 0.0, 2.0, 0.0), dtype=jnp.float64),
715
+ )
716
+ return d1_weights, d2_weights
717
+
718
+ def _axis_weights(axis_grid):
719
+ lower_a = jnp.asarray(axis_grid.centers[0] - axis_grid.faces[0], dtype=jnp.float64)
720
+ lower_b = jnp.asarray(axis_grid.centers[1] - axis_grid.faces[0], dtype=jnp.float64)
721
+ lower_c = jnp.asarray(axis_grid.centers[2] - axis_grid.faces[0], dtype=jnp.float64)
722
+ upper_a = jnp.asarray(axis_grid.faces[-1] - axis_grid.centers[-1], dtype=jnp.float64)
723
+ upper_b = jnp.asarray(axis_grid.faces[-1] - axis_grid.centers[-2], dtype=jnp.float64)
724
+ upper_c = jnp.asarray(axis_grid.faces[-1] - axis_grid.centers[-3], dtype=jnp.float64)
725
+ lower_d1, lower_d2 = _weights_for_side(lower_a, lower_b, lower_c)
726
+ upper_d1, upper_d2 = _weights_for_side(upper_a, upper_b, upper_c)
727
+ return jnp.stack((lower_d1, upper_d1), axis=0), jnp.stack((lower_d2, upper_d2), axis=0)
728
+
729
+ x_d1, x_d2 = _axis_weights(geometry.grid.x)
730
+ y_d1, y_d2 = _axis_weights(geometry.grid.y)
731
+ z_d1, z_d2 = _axis_weights(geometry.grid.z)
732
+ return cls(
733
+ dnormal_weights=jnp.stack((x_d1, y_d1, z_d1), axis=0),
734
+ d2normal_weights=jnp.stack((x_d2, y_d2, z_d2), axis=0),
735
+ )
736
+
737
+ def dnormal_from_wall_value(
738
+ self,
739
+ field: jnp.ndarray,
740
+ wall_value: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray],
741
+ geometry: FciGeometry3D,
742
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
743
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
744
+ ) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
745
+ dnormal, _d2normal = self.normal_derivatives_from_wall_value(
746
+ field,
747
+ wall_value,
748
+ geometry,
749
+ periodic_axes=periodic_axes,
750
+ axis_regular_axes=axis_regular_axes,
751
+ )
752
+ return dnormal
753
+
754
+ def d2normal_from_wall_value(
755
+ self,
756
+ field: jnp.ndarray,
757
+ wall_value: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray],
758
+ geometry: FciGeometry3D,
759
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
760
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
761
+ ) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
762
+ _dnormal, d2normal = self.normal_derivatives_from_wall_value(
763
+ field,
764
+ wall_value,
765
+ geometry,
766
+ periodic_axes=periodic_axes,
767
+ axis_regular_axes=axis_regular_axes,
768
+ )
769
+ return d2normal
770
+
771
+ def normal_derivatives_from_wall_value(
772
+ self,
773
+ field: jnp.ndarray,
774
+ wall_value: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray],
775
+ geometry: FciGeometry3D,
776
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
777
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
778
+ ) -> tuple[
779
+ tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray],
780
+ tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray],
781
+ ]:
782
+ values = _as_float64_array(field, "CoordinateNormalDerivativeConstructor3D.field")
783
+ if values.shape != geometry.shape:
784
+ raise ValueError(f"field must have shape {geometry.shape}, got {values.shape}")
785
+ periodic_axes = _normalize_axis_flags(periodic_axes, "periodic_axes")
786
+ axis_regular_axes = _normalize_axis_flags(axis_regular_axes, "axis_regular_axes")
787
+ if any(periodic and axis_regular for periodic, axis_regular in zip(periodic_axes, axis_regular_axes)):
788
+ raise ValueError(
789
+ "periodic_axes and axis_regular_axes cannot both be True on the same axis; "
790
+ f"got periodic_axes={periodic_axes}, axis_regular_axes={axis_regular_axes}"
791
+ )
792
+ if axis_regular_axes[1] or axis_regular_axes[2]:
793
+ raise ValueError(
794
+ "axis_regular_axes currently only supports the lower x axis; "
795
+ f"got axis_regular_axes={axis_regular_axes}"
796
+ )
797
+ fw_x, fw_y, fw_z = _as_coordinate_face_tuple(
798
+ wall_value,
799
+ geometry,
800
+ "CoordinateNormalDerivativeConstructor3D.wall_value",
801
+ )
802
+ if axis_regular_axes[0]:
803
+ fw_x = fw_x.at[0].set(_axis_regular_lower_x_face(values))
804
+
805
+ dfn_x = jnp.zeros_like(fw_x, dtype=jnp.float64)
806
+ dfn_y = jnp.zeros_like(fw_y, dtype=jnp.float64)
807
+ dfn_z = jnp.zeros_like(fw_z, dtype=jnp.float64)
808
+ d2fn_x = jnp.zeros_like(fw_x, dtype=jnp.float64)
809
+ d2fn_y = jnp.zeros_like(fw_y, dtype=jnp.float64)
810
+ d2fn_z = jnp.zeros_like(fw_z, dtype=jnp.float64)
811
+
812
+ def _apply_weights(weights, f_wall, f0, f1, f2):
813
+ return weights[0] * f_wall + weights[1] * f0 + weights[2] * f1 + weights[3] * f2
814
+
815
+ def _wall_derivatives(axis: int, side: int, f_wall, f0, f1, f2):
816
+ dnormal = _apply_weights(self.dnormal_weights[axis, side], f_wall, f0, f1, f2)
817
+ d2normal = _apply_weights(self.d2normal_weights[axis, side], f_wall, f0, f1, f2)
818
+ return dnormal, d2normal
819
+
820
+ if not periodic_axes[0]:
821
+ if values.shape[0] < 3:
822
+ raise ValueError("x-normal derivative reconstruction requires at least three x cells")
823
+ lower_dfn, lower_d2fn = _wall_derivatives(0, 0, fw_x[0], values[0], values[1], values[2])
824
+ upper_dfn, upper_d2fn = _wall_derivatives(0, 1, fw_x[-1], values[-1], values[-2], values[-3])
825
+ dfn_x = dfn_x.at[0].set(lower_dfn)
826
+ dfn_x = dfn_x.at[-1].set(upper_dfn)
827
+ d2fn_x = d2fn_x.at[0].set(lower_d2fn)
828
+ d2fn_x = d2fn_x.at[-1].set(upper_d2fn)
829
+
830
+ if not periodic_axes[1]:
831
+ if values.shape[1] < 3:
832
+ raise ValueError("y-normal derivative reconstruction requires at least three y cells")
833
+ lower_dfn, lower_d2fn = _wall_derivatives(1, 0, fw_y[:, 0, :], values[:, 0, :], values[:, 1, :], values[:, 2, :])
834
+ upper_dfn, upper_d2fn = _wall_derivatives(1, 1, fw_y[:, -1, :], values[:, -1, :], values[:, -2, :], values[:, -3, :])
835
+ dfn_y = dfn_y.at[:, 0, :].set(lower_dfn)
836
+ dfn_y = dfn_y.at[:, -1, :].set(upper_dfn)
837
+ d2fn_y = d2fn_y.at[:, 0, :].set(lower_d2fn)
838
+ d2fn_y = d2fn_y.at[:, -1, :].set(upper_d2fn)
839
+
840
+ if not periodic_axes[2]:
841
+ if values.shape[2] < 3:
842
+ raise ValueError("z-normal derivative reconstruction requires at least three z cells")
843
+ lower_dfn, lower_d2fn = _wall_derivatives(2, 0, fw_z[:, :, 0], values[:, :, 0], values[:, :, 1], values[:, :, 2])
844
+ upper_dfn, upper_d2fn = _wall_derivatives(2, 1, fw_z[:, :, -1], values[:, :, -1], values[:, :, -2], values[:, :, -3])
845
+ dfn_z = dfn_z.at[:, :, 0].set(lower_dfn)
846
+ dfn_z = dfn_z.at[:, :, -1].set(upper_dfn)
847
+ d2fn_z = d2fn_z.at[:, :, 0].set(lower_d2fn)
848
+ d2fn_z = d2fn_z.at[:, :, -1].set(upper_d2fn)
849
+
850
+ return (dfn_x, dfn_y, dfn_z), (d2fn_x, d2fn_y, d2fn_z)
851
+
852
+ def tree_flatten(self):
853
+ return ((self.dnormal_weights, self.d2normal_weights), None)
854
+
855
+ @classmethod
856
+ def tree_unflatten(cls, _aux_data, children):
857
+ return cls(*children)
858
+
859
+
860
+ @_pytree_base
861
+ @dataclass(frozen=True)
862
+ class CutWallValueReconstructor3D:
863
+ """Batched cut-wall value reconstructor for cell-centered fields."""
864
+
865
+ cut_wall_geometry: "CutWallGeometry3D" # geometry describing the cut-wall faces
866
+ neighbor_i: jnp.ndarray # (n_wall_faces, n_stencil)
867
+ neighbor_j: jnp.ndarray # (n_wall_faces, n_stencil)
868
+ neighbor_k: jnp.ndarray # (n_wall_faces, n_stencil)
869
+ weights: jnp.ndarray # (n_wall_faces, n_stencil)
870
+ bc_coeff: jnp.ndarray | None = None # (n_wall_faces,) or None for pure extrapolation
871
+
872
+ def __post_init__(self) -> None:
873
+ if not isinstance(self.cut_wall_geometry, CutWallGeometry3D):
874
+ raise TypeError("CutWallValueReconstructor3D.cut_wall_geometry must be a CutWallGeometry3D")
875
+ neighbor_i = _as_int_stencil_array(self.neighbor_i, "CutWallValueReconstructor3D.neighbor_i")
876
+ neighbor_j = _as_int_stencil_array(self.neighbor_j, "CutWallValueReconstructor3D.neighbor_j")
877
+ neighbor_k = _as_int_stencil_array(self.neighbor_k, "CutWallValueReconstructor3D.neighbor_k")
878
+ weights = _as_weight_stencil_array(self.weights, "CutWallValueReconstructor3D.weights")
879
+ if neighbor_i.shape != neighbor_j.shape or neighbor_i.shape != neighbor_k.shape or neighbor_i.shape != weights.shape:
880
+ raise ValueError("cut-wall neighbor and weight arrays must all have the same shape")
881
+ if neighbor_i.shape[0] != self.cut_wall_geometry.n_wall_faces:
882
+ raise ValueError(
883
+ "cut-wall neighbor arrays must have one row per cut-wall face; "
884
+ f"got {neighbor_i.shape[0]} rows and {self.cut_wall_geometry.n_wall_faces} faces"
885
+ )
886
+ object.__setattr__(self, "neighbor_i", neighbor_i)
887
+ object.__setattr__(self, "neighbor_j", neighbor_j)
888
+ object.__setattr__(self, "neighbor_k", neighbor_k)
889
+ object.__setattr__(self, "weights", weights)
890
+ if self.bc_coeff is not None:
891
+ bc_coeff = _as_wall_face_array(
892
+ self.bc_coeff,
893
+ self.cut_wall_geometry.n_wall_faces,
894
+ "CutWallValueReconstructor3D.bc_coeff",
895
+ )
896
+ object.__setattr__(self, "bc_coeff", bc_coeff)
897
+
898
+ @property
899
+ def n_wall_faces(self) -> int:
900
+ return self.cut_wall_geometry.n_wall_faces
901
+
902
+ def extrapolate(self, field: jnp.ndarray) -> jnp.ndarray:
903
+ values = _as_float64_array(field, "CutWallValueReconstructor3D.field")
904
+ gathered = values[self.neighbor_i, self.neighbor_j, self.neighbor_k]
905
+ return jnp.sum(self.weights * gathered, axis=1)
906
+
907
+ def extrapolate_neumann(
908
+ self,
909
+ field: jnp.ndarray,
910
+ normal_derivative: jnp.ndarray | float = 0.0,
911
+ ) -> jnp.ndarray:
912
+ if self.bc_coeff is None:
913
+ raise ValueError("CutWallValueReconstructor3D.extrapolate_neumann requires bc_coeff")
914
+ base_value = self.extrapolate(field)
915
+ bc_value = _as_wall_face_array(
916
+ normal_derivative,
917
+ self.cut_wall_geometry.n_wall_faces,
918
+ "CutWallValueReconstructor3D.normal_derivative",
919
+ )
920
+ return base_value + self.bc_coeff * bc_value
921
+
922
+ def tree_flatten(self):
923
+ children = [self.cut_wall_geometry, self.neighbor_i, self.neighbor_j, self.neighbor_k, self.weights]
924
+ aux = self.bc_coeff is None
925
+ if self.bc_coeff is not None:
926
+ children.append(self.bc_coeff)
927
+ return tuple(children), aux
928
+
929
+ @classmethod
930
+ def tree_unflatten(cls, aux_data, children):
931
+ bc_coeff_is_none = aux_data
932
+ if bc_coeff_is_none:
933
+ cut_wall_geometry, neighbor_i, neighbor_j, neighbor_k, weights = children
934
+ return cls(cut_wall_geometry, neighbor_i, neighbor_j, neighbor_k, weights, None)
935
+ cut_wall_geometry, neighbor_i, neighbor_j, neighbor_k, weights, bc_coeff = children
936
+ return cls(cut_wall_geometry, neighbor_i, neighbor_j, neighbor_k, weights, bc_coeff)
937
+
938
+
939
+ @_pytree_base
940
+ @dataclass(frozen=True)
941
+ class CutWallNormalDerivativeConstructor3D:
942
+ """Batched cut-wall normal derivative constructor for cell-centered fields.
943
+
944
+ Sign convention:
945
+ dnormal_from_wall_value(...) returns the outward normal derivative
946
+ d/dn, not the inward coordinate derivative d/ds. Any cut-wall
947
+ weight construction should therefore encode the outward normal sign
948
+ convention in the weights provided here.
949
+ """
950
+
951
+ cut_wall_geometry: "CutWallGeometry3D"
952
+ neighbor_i: jnp.ndarray # (n_wall_faces, n_stencil)
953
+ neighbor_j: jnp.ndarray # (n_wall_faces, n_stencil)
954
+ neighbor_k: jnp.ndarray # (n_wall_faces, n_stencil)
955
+ weights_dnormal: jnp.ndarray # (n_wall_faces, n_stencil)
956
+ weights_d2normal: jnp.ndarray # (n_wall_faces, n_stencil)
957
+ wall_coeff_dnormal: jnp.ndarray # (n_wall_faces,)
958
+ wall_coeff_d2normal: jnp.ndarray # (n_wall_faces,)
959
+
960
+ def __post_init__(self) -> None:
961
+ if not isinstance(self.cut_wall_geometry, CutWallGeometry3D):
962
+ raise TypeError("CutWallNormalDerivativeConstructor3D.cut_wall_geometry must be a CutWallGeometry3D")
963
+ neighbor_i = _as_int_stencil_array(self.neighbor_i, "CutWallNormalDerivativeConstructor3D.neighbor_i")
964
+ neighbor_j = _as_int_stencil_array(self.neighbor_j, "CutWallNormalDerivativeConstructor3D.neighbor_j")
965
+ neighbor_k = _as_int_stencil_array(self.neighbor_k, "CutWallNormalDerivativeConstructor3D.neighbor_k")
966
+ weights_dnormal = _as_weight_stencil_array(
967
+ self.weights_dnormal,
968
+ "CutWallNormalDerivativeConstructor3D.weights_dnormal",
969
+ )
970
+ weights_d2normal = _as_weight_stencil_array(
971
+ self.weights_d2normal,
972
+ "CutWallNormalDerivativeConstructor3D.weights_d2normal",
973
+ )
974
+ expected_shape = neighbor_i.shape
975
+ for name, value in (
976
+ ("neighbor_j", neighbor_j),
977
+ ("neighbor_k", neighbor_k),
978
+ ("weights_dnormal", weights_dnormal),
979
+ ("weights_d2normal", weights_d2normal),
980
+ ):
981
+ if value.shape != expected_shape:
982
+ raise ValueError(
983
+ "cut-wall normal derivative neighbor and weight arrays must all have "
984
+ f"shape {expected_shape}; {name} has shape {value.shape}"
985
+ )
986
+ if expected_shape[0] != self.cut_wall_geometry.n_wall_faces:
987
+ raise ValueError(
988
+ "cut-wall normal derivative arrays must have one row per cut-wall face; "
989
+ f"got {expected_shape[0]} rows and {self.cut_wall_geometry.n_wall_faces} faces"
990
+ )
991
+ wall_coeff_dnormal = _as_wall_face_array(
992
+ self.wall_coeff_dnormal,
993
+ self.cut_wall_geometry.n_wall_faces,
994
+ "CutWallNormalDerivativeConstructor3D.wall_coeff_dnormal",
995
+ )
996
+ wall_coeff_d2normal = _as_wall_face_array(
997
+ self.wall_coeff_d2normal,
998
+ self.cut_wall_geometry.n_wall_faces,
999
+ "CutWallNormalDerivativeConstructor3D.wall_coeff_d2normal",
1000
+ )
1001
+ object.__setattr__(self, "neighbor_i", neighbor_i)
1002
+ object.__setattr__(self, "neighbor_j", neighbor_j)
1003
+ object.__setattr__(self, "neighbor_k", neighbor_k)
1004
+ object.__setattr__(self, "weights_dnormal", weights_dnormal)
1005
+ object.__setattr__(self, "weights_d2normal", weights_d2normal)
1006
+ object.__setattr__(self, "wall_coeff_dnormal", wall_coeff_dnormal)
1007
+ object.__setattr__(self, "wall_coeff_d2normal", wall_coeff_d2normal)
1008
+
1009
+ @property
1010
+ def n_wall_faces(self) -> int:
1011
+ return self.cut_wall_geometry.n_wall_faces
1012
+
1013
+ def dnormal_from_wall_value(
1014
+ self,
1015
+ field: jnp.ndarray,
1016
+ wall_value: jnp.ndarray,
1017
+ ) -> jnp.ndarray:
1018
+ values = _as_float64_array(field, "CutWallNormalDerivativeConstructor3D.field")
1019
+ wall = _as_wall_face_array(
1020
+ wall_value,
1021
+ self.cut_wall_geometry.n_wall_faces,
1022
+ "CutWallNormalDerivativeConstructor3D.wall_value",
1023
+ )
1024
+ gathered = values[self.neighbor_i, self.neighbor_j, self.neighbor_k]
1025
+ return jnp.sum(self.weights_dnormal * gathered, axis=1) + self.wall_coeff_dnormal * wall
1026
+
1027
+ def d2normal_from_wall_value(
1028
+ self,
1029
+ field: jnp.ndarray,
1030
+ wall_value: jnp.ndarray,
1031
+ ) -> jnp.ndarray:
1032
+ values = _as_float64_array(field, "CutWallNormalDerivativeConstructor3D.field")
1033
+ wall = _as_wall_face_array(
1034
+ wall_value,
1035
+ self.cut_wall_geometry.n_wall_faces,
1036
+ "CutWallNormalDerivativeConstructor3D.wall_value",
1037
+ )
1038
+ gathered = values[self.neighbor_i, self.neighbor_j, self.neighbor_k]
1039
+ return jnp.sum(self.weights_d2normal * gathered, axis=1) + self.wall_coeff_d2normal * wall
1040
+
1041
+ def normal_derivatives_from_wall_value(
1042
+ self,
1043
+ field: jnp.ndarray,
1044
+ wall_value: jnp.ndarray,
1045
+ ) -> tuple[jnp.ndarray, jnp.ndarray]:
1046
+ return (
1047
+ self.dnormal_from_wall_value(field, wall_value),
1048
+ self.d2normal_from_wall_value(field, wall_value),
1049
+ )
1050
+
1051
+ def tree_flatten(self):
1052
+ return (
1053
+ (
1054
+ self.cut_wall_geometry,
1055
+ self.neighbor_i,
1056
+ self.neighbor_j,
1057
+ self.neighbor_k,
1058
+ self.weights_dnormal,
1059
+ self.weights_d2normal,
1060
+ self.wall_coeff_dnormal,
1061
+ self.wall_coeff_d2normal,
1062
+ ),
1063
+ None,
1064
+ )
1065
+
1066
+ @classmethod
1067
+ def tree_unflatten(cls, _aux_data, children):
1068
+ return cls(*children)
1069
+
1070
+
1071
+ @_pytree_base
1072
+ @dataclass(frozen=True)
1073
+ class LocalStencil1D:
1074
+ """Field-dependent 1D stencil values for one coordinate direction."""
1075
+
1076
+ center: jnp.ndarray
1077
+ minus: jnp.ndarray
1078
+ plus: jnp.ndarray
1079
+ dx_min: jnp.ndarray
1080
+ dx_plus: jnp.ndarray
1081
+ derivative_minus_weight: jnp.ndarray | None = None
1082
+ derivative_center_weight: jnp.ndarray | None = None
1083
+ derivative_plus_weight: jnp.ndarray | None = None
1084
+
1085
+ def __post_init__(self) -> None:
1086
+ center = _as_float64_array(self.center, "center")
1087
+ shape = center.shape
1088
+
1089
+ for name in ("minus", "plus", "dx_min", "dx_plus"):
1090
+ value = _as_float64_array(getattr(self, name), f"LocalStencil1D.{name}")
1091
+ if value.shape != shape:
1092
+ raise ValueError(
1093
+ f"LocalStencil1D.{name} must have shape {shape}, got {value.shape}"
1094
+ )
1095
+ object.__setattr__(self, name, value)
1096
+
1097
+ derivative_weight_names = (
1098
+ "derivative_minus_weight",
1099
+ "derivative_center_weight",
1100
+ "derivative_plus_weight",
1101
+ )
1102
+ supplied_weights = [getattr(self, name) is not None for name in derivative_weight_names]
1103
+ if any(supplied_weights) and not all(supplied_weights):
1104
+ raise ValueError(
1105
+ "LocalStencil1D derivative weights must either all be supplied or all be omitted"
1106
+ )
1107
+
1108
+ if all(supplied_weights):
1109
+ for name in derivative_weight_names:
1110
+ value = _as_float64_array(getattr(self, name), f"LocalStencil1D.{name}")
1111
+ if value.shape != shape:
1112
+ raise ValueError(
1113
+ f"LocalStencil1D.{name} must have shape {shape}, got {value.shape}"
1114
+ )
1115
+ object.__setattr__(self, name, value)
1116
+ else:
1117
+ dx_min = jnp.asarray(self.dx_min, dtype=jnp.float64)
1118
+ dx_plus = jnp.asarray(self.dx_plus, dtype=jnp.float64)
1119
+ denom = jnp.maximum(dx_min * dx_plus * (dx_min + dx_plus), 1.0e-30)
1120
+ object.__setattr__(self, "derivative_minus_weight", -dx_plus * dx_plus / denom)
1121
+ object.__setattr__(
1122
+ self,
1123
+ "derivative_center_weight",
1124
+ (dx_plus * dx_plus - dx_min * dx_min) / denom,
1125
+ )
1126
+ object.__setattr__(self, "derivative_plus_weight", dx_min * dx_min / denom)
1127
+
1128
+ object.__setattr__(self, "center", center)
1129
+
1130
+ @property
1131
+ def shape(self) -> tuple[int, int, int]:
1132
+ return tuple(int(v) for v in self.center.shape)
1133
+
1134
+ def replace(self, **updates: object) -> "LocalStencil1D":
1135
+ """Return a new stencil with one or more fields replaced."""
1136
+
1137
+ allowed = {
1138
+ "center",
1139
+ "minus",
1140
+ "plus",
1141
+ "dx_min",
1142
+ "dx_plus",
1143
+ "derivative_minus_weight",
1144
+ "derivative_center_weight",
1145
+ "derivative_plus_weight",
1146
+ }
1147
+ unknown = set(updates) - allowed
1148
+ if unknown:
1149
+ names = ", ".join(sorted(unknown))
1150
+ raise ValueError(f"Unknown LocalStencil1D field(s): {names}")
1151
+ return dataclass_replace_1d(self, **updates)
1152
+
1153
+ def tree_flatten(self):
1154
+ return (
1155
+ (
1156
+ self.center,
1157
+ self.minus,
1158
+ self.plus,
1159
+ self.dx_min,
1160
+ self.dx_plus,
1161
+ self.derivative_minus_weight,
1162
+ self.derivative_center_weight,
1163
+ self.derivative_plus_weight,
1164
+ ),
1165
+ None,
1166
+ )
1167
+
1168
+ @classmethod
1169
+ def tree_unflatten(cls, _aux_data, children):
1170
+ return cls(*children)
1171
+
1172
+
1173
+ @_pytree_base
1174
+ @dataclass(frozen=True)
1175
+ class LocalStencil3D:
1176
+ """Nested 3D stencil for local/pointwise operators.
1177
+
1178
+ This stencil is the reconstruction object for local derivatives and
1179
+ pointwise operators. It may be boundary-aware, including embedded-wall
1180
+ aware reconstruction logic in the caller that builds it.
1181
+ """
1182
+
1183
+ x: LocalStencil1D
1184
+ y: LocalStencil1D
1185
+ z: LocalStencil1D
1186
+
1187
+ def __post_init__(self) -> None:
1188
+ if not isinstance(self.x, LocalStencil1D):
1189
+ raise TypeError("LocalStencil3D.x must be a LocalStencil1D")
1190
+ if not isinstance(self.y, LocalStencil1D):
1191
+ raise TypeError("LocalStencil3D.y must be a LocalStencil1D")
1192
+ if not isinstance(self.z, LocalStencil1D):
1193
+ raise TypeError("LocalStencil3D.z must be a LocalStencil1D")
1194
+ if self.x.shape != self.y.shape or self.x.shape != self.z.shape:
1195
+ raise ValueError(
1196
+ "LocalStencil3D axis stencils must all have the same shape; "
1197
+ f"got x={self.x.shape}, y={self.y.shape}, z={self.z.shape}"
1198
+ )
1199
+
1200
+ @property
1201
+ def shape(self) -> tuple[int, int, int]:
1202
+ return self.x.shape
1203
+
1204
+ def replace(self, **updates: object) -> "LocalStencil3D":
1205
+ """Return a new stencil with one or more fields replaced."""
1206
+
1207
+ allowed = {"x", "y", "z"}
1208
+ unknown = set(updates) - allowed
1209
+ if unknown:
1210
+ names = ", ".join(sorted(unknown))
1211
+ raise ValueError(f"Unknown LocalStencil3D field(s): {names}")
1212
+ return dataclass_replace_3d(self, **updates)
1213
+
1214
+ def tree_flatten(self):
1215
+ return ((self.x, self.y, self.z), None)
1216
+
1217
+ @classmethod
1218
+ def tree_unflatten(cls, _aux_data, children):
1219
+ return cls(*children)
1220
+
1221
+
1222
+ @_pytree_base
1223
+ @dataclass(frozen=True)
1224
+ class FaceGradientStencil3D:
1225
+ """Face-centered coordinate gradients for a scalar field."""
1226
+
1227
+ x: jnp.ndarray
1228
+ y: jnp.ndarray
1229
+ z: jnp.ndarray
1230
+
1231
+ def __post_init__(self) -> None:
1232
+ x = jnp.asarray(self.x, dtype=jnp.float64)
1233
+ y = jnp.asarray(self.y, dtype=jnp.float64)
1234
+ z = jnp.asarray(self.z, dtype=jnp.float64)
1235
+
1236
+ for name, value in (("x", x), ("y", y), ("z", z)):
1237
+ if value.ndim != 4 or value.shape[-1] != 3:
1238
+ raise ValueError(
1239
+ f"FaceGradientStencil3D.{name} must have shape (nx, ny, nz, 3), got {value.shape}"
1240
+ )
1241
+
1242
+ object.__setattr__(self, "x", x)
1243
+ object.__setattr__(self, "y", y)
1244
+ object.__setattr__(self, "z", z)
1245
+
1246
+ @property
1247
+ def shape(self) -> tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]]:
1248
+ return tuple(int(v) for v in self.x.shape[:-1]), tuple(int(v) for v in self.y.shape[:-1]), tuple(int(v) for v in self.z.shape[:-1])
1249
+
1250
+ def tree_flatten(self):
1251
+ return ((self.x, self.y, self.z), None)
1252
+
1253
+ @classmethod
1254
+ def tree_unflatten(cls, _aux_data, children):
1255
+ return cls(*children)
1256
+
1257
+
1258
+ @_pytree_base
1259
+ @dataclass(frozen=True)
1260
+ class ConservativeStencil3D:
1261
+ """Nested 3D stencil for conservative operators.
1262
+
1263
+ This is intentionally separate from ``LocalStencil3D`` so conservative
1264
+ flux assembly cannot accidentally inherit embedded-wall reconstruction
1265
+ semantics. The payload is still cell-centered reconstruction data, but the
1266
+ meaning is restricted to regular control-volume flux construction.
1267
+ """
1268
+
1269
+ x: LocalStencil1D
1270
+ y: LocalStencil1D
1271
+ z: LocalStencil1D
1272
+ face_grad: FaceGradientStencil3D
1273
+
1274
+ def __post_init__(self) -> None:
1275
+ if not isinstance(self.x, LocalStencil1D):
1276
+ raise TypeError("ConservativeStencil3D.x must be a LocalStencil1D")
1277
+ if not isinstance(self.y, LocalStencil1D):
1278
+ raise TypeError("ConservativeStencil3D.y must be a LocalStencil1D")
1279
+ if not isinstance(self.z, LocalStencil1D):
1280
+ raise TypeError("ConservativeStencil3D.z must be a LocalStencil1D")
1281
+ if not isinstance(self.face_grad, FaceGradientStencil3D):
1282
+ raise TypeError("ConservativeStencil3D.face_grad must be a FaceGradientStencil3D")
1283
+ if self.x.shape != self.y.shape or self.x.shape != self.z.shape:
1284
+ raise ValueError(
1285
+ "ConservativeStencil3D axis stencils must all have the same shape; "
1286
+ f"got x={self.x.shape}, y={self.y.shape}, z={self.z.shape}"
1287
+ )
1288
+ expected_x = (self.x.shape[0] + 1, self.x.shape[1], self.x.shape[2])
1289
+ expected_y = (self.x.shape[0], self.x.shape[1] + 1, self.x.shape[2])
1290
+ expected_z = (self.x.shape[0], self.x.shape[1], self.x.shape[2] + 1)
1291
+ if self.face_grad.x.shape[:-1] != expected_x:
1292
+ raise ValueError(
1293
+ f"ConservativeStencil3D.face_grad.x must have shape {expected_x + (3,)}, got {self.face_grad.x.shape}"
1294
+ )
1295
+ if self.face_grad.y.shape[:-1] != expected_y:
1296
+ raise ValueError(
1297
+ f"ConservativeStencil3D.face_grad.y must have shape {expected_y + (3,)}, got {self.face_grad.y.shape}"
1298
+ )
1299
+ if self.face_grad.z.shape[:-1] != expected_z:
1300
+ raise ValueError(
1301
+ f"ConservativeStencil3D.face_grad.z must have shape {expected_z + (3,)}, got {self.face_grad.z.shape}"
1302
+ )
1303
+
1304
+ @property
1305
+ def shape(self) -> tuple[int, int, int]:
1306
+ return self.x.shape
1307
+
1308
+ def replace(self, **updates: object) -> "ConservativeStencil3D":
1309
+ """Return a new stencil with one or more fields replaced."""
1310
+
1311
+ allowed = {"x", "y", "z", "face_grad"}
1312
+ unknown = set(updates) - allowed
1313
+ if unknown:
1314
+ names = ", ".join(sorted(unknown))
1315
+ raise ValueError(f"Unknown ConservativeStencil3D field(s): {names}")
1316
+ return dataclass_replace_conservative(self, **updates)
1317
+
1318
+ def tree_flatten(self):
1319
+ return ((self.x, self.y, self.z, self.face_grad), None)
1320
+
1321
+ @classmethod
1322
+ def tree_unflatten(cls, _aux_data, children):
1323
+ return cls(*children)
1324
+
1325
+
1326
+ @_pytree_base
1327
+ @dataclass(frozen=True)
1328
+ class FaceFluxStencil3D:
1329
+ """Face-centered flux arrays for the three coordinate directions.
1330
+
1331
+ In the local domain-decomposed path, the inferred shape is the local
1332
+ owned-cell shape. These are the control faces needed to update owned
1333
+ cells, not necessarily uniquely owned global faces.
1334
+ """
1335
+
1336
+ x: jnp.ndarray
1337
+ y: jnp.ndarray
1338
+ z: jnp.ndarray
1339
+
1340
+ def __post_init__(self) -> None:
1341
+ x = _as_face_flux_array(self.x, "FaceFluxStencil3D.x")
1342
+ y = _as_face_flux_array(self.y, "FaceFluxStencil3D.y")
1343
+ z = _as_face_flux_array(self.z, "FaceFluxStencil3D.z")
1344
+
1345
+ cell_shape = (x.shape[0] - 1, y.shape[1] - 1, z.shape[2] - 1)
1346
+ expected_x = (cell_shape[0] + 1, cell_shape[1], cell_shape[2])
1347
+ expected_y = (cell_shape[0], cell_shape[1] + 1, cell_shape[2])
1348
+ expected_z = (cell_shape[0], cell_shape[1], cell_shape[2] + 1)
1349
+ if x.shape != expected_x or y.shape != expected_y or z.shape != expected_z:
1350
+ raise ValueError(
1351
+ "FaceFluxStencil3D axis shapes must match the face-grid layout; "
1352
+ f"expected x={expected_x}, y={expected_y}, z={expected_z}, got "
1353
+ f"x={x.shape}, y={y.shape}, z={z.shape}"
1354
+ )
1355
+
1356
+ object.__setattr__(self, "x", x)
1357
+ object.__setattr__(self, "y", y)
1358
+ object.__setattr__(self, "z", z)
1359
+
1360
+ @property
1361
+ def shape(self) -> tuple[int, int, int]:
1362
+ return (int(self.x.shape[0] - 1), int(self.y.shape[1] - 1), int(self.z.shape[2] - 1))
1363
+
1364
+ def tree_flatten(self):
1365
+ return ((self.x, self.y, self.z), None)
1366
+
1367
+ @classmethod
1368
+ def tree_unflatten(cls, _aux_data, children):
1369
+ return cls(*children)
1370
+
1371
+
1372
+ @_pytree_base
1373
+ @dataclass(frozen=True)
1374
+ class BoundaryFaceBC3D:
1375
+ """Dense face-grid boundary-condition data for regular coordinate faces."""
1376
+
1377
+ kind_x: jnp.ndarray # (nx + 1, ny, nz)
1378
+ kind_y: jnp.ndarray # (nx, ny + 1, nz)
1379
+ kind_z: jnp.ndarray # (nx, ny, nz + 1)
1380
+ value_x: jnp.ndarray # (nx + 1, ny, nz)
1381
+ value_y: jnp.ndarray # (nx, ny + 1, nz)
1382
+ value_z: jnp.ndarray # (nx, ny, nz + 1)
1383
+ mask_x: jnp.ndarray # (nx + 1, ny, nz)
1384
+ mask_y: jnp.ndarray # (nx, ny + 1, nz)
1385
+ mask_z: jnp.ndarray # (nx, ny, nz + 1)
1386
+
1387
+ def __post_init__(self) -> None:
1388
+ kind_x = _as_int_face_array(self.kind_x, "BoundaryFaceBC3D.kind_x")
1389
+ kind_y = _as_int_face_array(self.kind_y, "BoundaryFaceBC3D.kind_y")
1390
+ kind_z = _as_int_face_array(self.kind_z, "BoundaryFaceBC3D.kind_z")
1391
+ value_x = _as_face_flux_array(self.value_x, "BoundaryFaceBC3D.value_x")
1392
+ value_y = _as_face_flux_array(self.value_y, "BoundaryFaceBC3D.value_y")
1393
+ value_z = _as_face_flux_array(self.value_z, "BoundaryFaceBC3D.value_z")
1394
+ mask_x = _as_bool_face_array(self.mask_x, "BoundaryFaceBC3D.mask_x")
1395
+ mask_y = _as_bool_face_array(self.mask_y, "BoundaryFaceBC3D.mask_y")
1396
+ mask_z = _as_bool_face_array(self.mask_z, "BoundaryFaceBC3D.mask_z")
1397
+ if kind_x.shape != value_x.shape or kind_x.shape != mask_x.shape:
1398
+ raise ValueError("BoundaryFaceBC3D.x arrays must all have the same shape")
1399
+ if kind_y.shape != value_y.shape or kind_y.shape != mask_y.shape:
1400
+ raise ValueError("BoundaryFaceBC3D.y arrays must all have the same shape")
1401
+ if kind_z.shape != value_z.shape or kind_z.shape != mask_z.shape:
1402
+ raise ValueError("BoundaryFaceBC3D.z arrays must all have the same shape")
1403
+ object.__setattr__(self, "kind_x", kind_x)
1404
+ object.__setattr__(self, "kind_y", kind_y)
1405
+ object.__setattr__(self, "kind_z", kind_z)
1406
+ object.__setattr__(self, "value_x", value_x)
1407
+ object.__setattr__(self, "value_y", value_y)
1408
+ object.__setattr__(self, "value_z", value_z)
1409
+ object.__setattr__(self, "mask_x", mask_x)
1410
+ object.__setattr__(self, "mask_y", mask_y)
1411
+ object.__setattr__(self, "mask_z", mask_z)
1412
+
1413
+ @classmethod
1414
+ def empty(cls, geometry: RegularFaceGeometry3D) -> "BoundaryFaceBC3D":
1415
+ return cls(
1416
+ kind_x=jnp.zeros_like(geometry.x_area, dtype=jnp.int32),
1417
+ kind_y=jnp.zeros_like(geometry.y_area, dtype=jnp.int32),
1418
+ kind_z=jnp.zeros_like(geometry.z_area, dtype=jnp.int32),
1419
+ value_x=jnp.zeros_like(geometry.x_area, dtype=jnp.float64),
1420
+ value_y=jnp.zeros_like(geometry.y_area, dtype=jnp.float64),
1421
+ value_z=jnp.zeros_like(geometry.z_area, dtype=jnp.float64),
1422
+ mask_x=jnp.zeros_like(geometry.x_open_mask, dtype=bool),
1423
+ mask_y=jnp.zeros_like(geometry.y_open_mask, dtype=bool),
1424
+ mask_z=jnp.zeros_like(geometry.z_open_mask, dtype=bool),
1425
+ )
1426
+
1427
+ def tree_flatten(self):
1428
+ return (
1429
+ (
1430
+ self.kind_x,
1431
+ self.kind_y,
1432
+ self.kind_z,
1433
+ self.value_x,
1434
+ self.value_y,
1435
+ self.value_z,
1436
+ self.mask_x,
1437
+ self.mask_y,
1438
+ self.mask_z,
1439
+ ),
1440
+ None,
1441
+ )
1442
+
1443
+ @classmethod
1444
+ def tree_unflatten(cls, _aux_data, children):
1445
+ return cls(*children)
1446
+
1447
+ def replace(self, **updates: object) -> "BoundaryFaceBC3D":
1448
+ allowed = {
1449
+ "kind_x",
1450
+ "kind_y",
1451
+ "kind_z",
1452
+ "value_x",
1453
+ "value_y",
1454
+ "value_z",
1455
+ "mask_x",
1456
+ "mask_y",
1457
+ "mask_z",
1458
+ }
1459
+ unknown = set(updates) - allowed
1460
+ if unknown:
1461
+ names = ", ".join(sorted(unknown))
1462
+ raise ValueError(f"Unknown BoundaryFaceBC3D field(s): {names}")
1463
+ return BoundaryFaceBC3D(
1464
+ kind_x=updates.get("kind_x", self.kind_x),
1465
+ kind_y=updates.get("kind_y", self.kind_y),
1466
+ kind_z=updates.get("kind_z", self.kind_z),
1467
+ value_x=updates.get("value_x", self.value_x),
1468
+ value_y=updates.get("value_y", self.value_y),
1469
+ value_z=updates.get("value_z", self.value_z),
1470
+ mask_x=updates.get("mask_x", self.mask_x),
1471
+ mask_y=updates.get("mask_y", self.mask_y),
1472
+ mask_z=updates.get("mask_z", self.mask_z),
1473
+ )
1474
+
1475
+
1476
+ @_pytree_base
1477
+ @dataclass(frozen=True)
1478
+ class LocalBoundaryFaceBC3D(_DataclassPyTreeMixin):
1479
+ """
1480
+ Local physical regular-coordinate face boundary-condition payload.
1481
+
1482
+ Arrays are dense over local owned control faces, but masks are true only on
1483
+ true physical coordinate boundary faces touched by this local shard.
1484
+
1485
+ Internal shard interfaces, periodic interfaces, axis/topological fills, and
1486
+ ordinary interior faces must have mask=False.
1487
+
1488
+ This object is the single source of truth for physical coordinate-face BCs.
1489
+ It is consumed by conservative flux builders and, optionally, by ghost-cell
1490
+ fillers for operators that choose ghost-materialized BC enforcement.
1491
+ """
1492
+
1493
+ kind_x: jnp.ndarray
1494
+ kind_y: jnp.ndarray
1495
+ kind_z: jnp.ndarray
1496
+ value_x: jnp.ndarray
1497
+ value_y: jnp.ndarray
1498
+ value_z: jnp.ndarray
1499
+ mask_x: jnp.ndarray
1500
+ mask_y: jnp.ndarray
1501
+ mask_z: jnp.ndarray
1502
+ layout: HaloLayout3D
1503
+
1504
+ def __post_init__(self) -> None:
1505
+ if not isinstance(self.layout, HaloLayout3D):
1506
+ raise TypeError("layout must be a HaloLayout3D instance")
1507
+
1508
+ expected_x = self.layout.face_control_shape(axis=0)
1509
+ expected_y = self.layout.face_control_shape(axis=1)
1510
+ expected_z = self.layout.face_control_shape(axis=2)
1511
+
1512
+ kind_x = jnp.asarray(self.kind_x, dtype=jnp.int32)
1513
+ kind_y = jnp.asarray(self.kind_y, dtype=jnp.int32)
1514
+ kind_z = jnp.asarray(self.kind_z, dtype=jnp.int32)
1515
+ value_x = jnp.asarray(self.value_x, dtype=jnp.float64)
1516
+ value_y = jnp.asarray(self.value_y, dtype=jnp.float64)
1517
+ value_z = jnp.asarray(self.value_z, dtype=jnp.float64)
1518
+ mask_x = jnp.asarray(self.mask_x, dtype=bool)
1519
+ mask_y = jnp.asarray(self.mask_y, dtype=bool)
1520
+ mask_z = jnp.asarray(self.mask_z, dtype=bool)
1521
+
1522
+ for name, value, expected in (
1523
+ ("kind_x", kind_x, expected_x),
1524
+ ("kind_y", kind_y, expected_y),
1525
+ ("kind_z", kind_z, expected_z),
1526
+ ("value_x", value_x, expected_x),
1527
+ ("value_y", value_y, expected_y),
1528
+ ("value_z", value_z, expected_z),
1529
+ ("mask_x", mask_x, expected_x),
1530
+ ("mask_y", mask_y, expected_y),
1531
+ ("mask_z", mask_z, expected_z),
1532
+ ):
1533
+ if value.shape != expected:
1534
+ raise ValueError(
1535
+ f"LocalBoundaryFaceBC3D.{name} must have shape {expected}, got {value.shape}"
1536
+ )
1537
+
1538
+ kind_x = jnp.where(mask_x, kind_x, BC_NONE)
1539
+ kind_y = jnp.where(mask_y, kind_y, BC_NONE)
1540
+ kind_z = jnp.where(mask_z, kind_z, BC_NONE)
1541
+ value_x = jnp.where(mask_x, value_x, 0.0)
1542
+ value_y = jnp.where(mask_y, value_y, 0.0)
1543
+ value_z = jnp.where(mask_z, value_z, 0.0)
1544
+
1545
+ object.__setattr__(self, "kind_x", kind_x)
1546
+ object.__setattr__(self, "kind_y", kind_y)
1547
+ object.__setattr__(self, "kind_z", kind_z)
1548
+ object.__setattr__(self, "value_x", value_x)
1549
+ object.__setattr__(self, "value_y", value_y)
1550
+ object.__setattr__(self, "value_z", value_z)
1551
+ object.__setattr__(self, "mask_x", mask_x)
1552
+ object.__setattr__(self, "mask_y", mask_y)
1553
+ object.__setattr__(self, "mask_z", mask_z)
1554
+
1555
+ @property
1556
+ def shape(self) -> tuple[int, int, int]:
1557
+ return self.layout.owned_shape
1558
+
1559
+ @classmethod
1560
+ def empty(cls, layout: HaloLayout3D) -> "LocalBoundaryFaceBC3D":
1561
+ x_shape = layout.face_control_shape(axis=0)
1562
+ y_shape = layout.face_control_shape(axis=1)
1563
+ z_shape = layout.face_control_shape(axis=2)
1564
+ return cls(
1565
+ kind_x=jnp.zeros(x_shape, dtype=jnp.int32),
1566
+ kind_y=jnp.zeros(y_shape, dtype=jnp.int32),
1567
+ kind_z=jnp.zeros(z_shape, dtype=jnp.int32),
1568
+ value_x=jnp.zeros(x_shape, dtype=jnp.float64),
1569
+ value_y=jnp.zeros(y_shape, dtype=jnp.float64),
1570
+ value_z=jnp.zeros(z_shape, dtype=jnp.float64),
1571
+ mask_x=jnp.zeros(x_shape, dtype=bool),
1572
+ mask_y=jnp.zeros(y_shape, dtype=bool),
1573
+ mask_z=jnp.zeros(z_shape, dtype=bool),
1574
+ layout=layout,
1575
+ )
1576
+
1577
+ def tree_flatten(self):
1578
+ children = (
1579
+ self.kind_x,
1580
+ self.kind_y,
1581
+ self.kind_z,
1582
+ self.value_x,
1583
+ self.value_y,
1584
+ self.value_z,
1585
+ self.mask_x,
1586
+ self.mask_y,
1587
+ self.mask_z,
1588
+ )
1589
+ aux_data = self.layout
1590
+ return children, aux_data
1591
+
1592
+ @classmethod
1593
+ def tree_unflatten(cls, aux_data, children):
1594
+ layout = aux_data
1595
+ (
1596
+ kind_x,
1597
+ kind_y,
1598
+ kind_z,
1599
+ value_x,
1600
+ value_y,
1601
+ value_z,
1602
+ mask_x,
1603
+ mask_y,
1604
+ mask_z,
1605
+ ) = children
1606
+ return cls(
1607
+ kind_x=kind_x,
1608
+ kind_y=kind_y,
1609
+ kind_z=kind_z,
1610
+ value_x=value_x,
1611
+ value_y=value_y,
1612
+ value_z=value_z,
1613
+ mask_x=mask_x,
1614
+ mask_y=mask_y,
1615
+ mask_z=mask_z,
1616
+ layout=layout,
1617
+ )
1618
+
1619
+
1620
+ @_pytree_base
1621
+ @dataclass(frozen=True)
1622
+ class BoundaryConditionBuilder(Generic[BoundaryPayloadT]):
1623
+ """Callable adapter that delegates boundary-payload construction to an injected function."""
1624
+
1625
+ build_fn: Callable[
1626
+ [
1627
+ Any,
1628
+ "FciGeometry3D",
1629
+ tuple[bool | None, bool | None, bool | None] | None,
1630
+ "CutWallGeometry3D | None",
1631
+ "CutWallBC3D | None",
1632
+ ],
1633
+ BoundaryPayloadT,
1634
+ ]
1635
+
1636
+ def __call__(
1637
+ self,
1638
+ state: Any,
1639
+ geometry: "FciGeometry3D",
1640
+ periodic_axes: tuple[bool | None, bool | None, bool | None] | None,
1641
+ cut_wall_geometry: "CutWallGeometry3D | None",
1642
+ cut_wall_bc: "CutWallBC3D | None",
1643
+ ) -> BoundaryPayloadT:
1644
+ return self.build_fn(state, geometry, periodic_axes, cut_wall_geometry, cut_wall_bc)
1645
+
1646
+ def tree_flatten(self):
1647
+ return (), self.build_fn
1648
+
1649
+ @classmethod
1650
+ def tree_unflatten(cls, aux_data, children):
1651
+ return cls(aux_data)
1652
+
1653
+
1654
+ @_pytree_base
1655
+ @dataclass(frozen=True)
1656
+ class CutWallGeometry3D:
1657
+ """Geometry and metric data for true non-coordinate cut-wall faces."""
1658
+
1659
+ owner_i: jnp.ndarray
1660
+ owner_j: jnp.ndarray
1661
+ owner_k: jnp.ndarray
1662
+ center: jnp.ndarray # (n_wall_faces, 3)
1663
+ normal_contra: jnp.ndarray # (n_wall_faces, 3), outward normal from the computational domain
1664
+ area_covector: jnp.ndarray # (n_wall_faces, 3), outward-oriented area covector
1665
+ distance: jnp.ndarray # (n_wall_faces,)
1666
+ J: jnp.ndarray # (n_wall_faces,)
1667
+ g_contra: jnp.ndarray # (n_wall_faces, 3, 3)
1668
+ g_cov: jnp.ndarray # (n_wall_faces, 3, 3)
1669
+ B_contra: jnp.ndarray # (n_wall_faces, 3)
1670
+ Bmag: jnp.ndarray # (n_wall_faces,)
1671
+ sign: jnp.ndarray # (n_wall_faces,), orientation sign relative to the owner cell / outward domain normal
1672
+
1673
+ def __post_init__(self) -> None:
1674
+ owner_i = jnp.asarray(self.owner_i, dtype=jnp.int32)
1675
+ owner_j = jnp.asarray(self.owner_j, dtype=jnp.int32)
1676
+ owner_k = jnp.asarray(self.owner_k, dtype=jnp.int32)
1677
+ center = jnp.asarray(self.center, dtype=jnp.float64)
1678
+ normal_contra = jnp.asarray(self.normal_contra, dtype=jnp.float64)
1679
+ area_covector = jnp.asarray(self.area_covector, dtype=jnp.float64)
1680
+ distance = jnp.asarray(self.distance, dtype=jnp.float64)
1681
+ J = jnp.asarray(self.J, dtype=jnp.float64)
1682
+ g_contra = jnp.asarray(self.g_contra, dtype=jnp.float64)
1683
+ g_cov = jnp.asarray(self.g_cov, dtype=jnp.float64)
1684
+ B_contra = jnp.asarray(self.B_contra, dtype=jnp.float64)
1685
+ Bmag = jnp.asarray(self.Bmag, dtype=jnp.float64)
1686
+ sign = jnp.asarray(self.sign, dtype=jnp.float64)
1687
+ shape = owner_i.shape
1688
+ for name, value in (
1689
+ ("owner_j", owner_j),
1690
+ ("owner_k", owner_k),
1691
+ ("center", center),
1692
+ ("normal_contra", normal_contra),
1693
+ ("area_covector", area_covector),
1694
+ ("distance", distance),
1695
+ ("J", J),
1696
+ ("g_contra", g_contra),
1697
+ ("g_cov", g_cov),
1698
+ ("B_contra", B_contra),
1699
+ ("Bmag", Bmag),
1700
+ ("sign", sign),
1701
+ ):
1702
+ if name in {"center", "normal_contra", "area_covector", "B_contra"}:
1703
+ expected = shape + (3,)
1704
+ elif name in {"g_contra", "g_cov"}:
1705
+ expected = shape + (3, 3)
1706
+ else:
1707
+ expected = shape
1708
+ if value.shape != expected:
1709
+ raise ValueError(f"CutWallGeometry3D.{name} must have shape {expected}, got {value.shape}")
1710
+ object.__setattr__(self, "owner_i", owner_i)
1711
+ object.__setattr__(self, "owner_j", owner_j)
1712
+ object.__setattr__(self, "owner_k", owner_k)
1713
+ object.__setattr__(self, "center", center)
1714
+ object.__setattr__(self, "normal_contra", normal_contra)
1715
+ object.__setattr__(self, "area_covector", area_covector)
1716
+ object.__setattr__(self, "distance", distance)
1717
+ object.__setattr__(self, "J", J)
1718
+ object.__setattr__(self, "g_contra", g_contra)
1719
+ object.__setattr__(self, "g_cov", g_cov)
1720
+ object.__setattr__(self, "B_contra", B_contra)
1721
+ object.__setattr__(self, "Bmag", Bmag)
1722
+ object.__setattr__(self, "sign", sign)
1723
+
1724
+ @property
1725
+ def n_wall_faces(self) -> int:
1726
+ return int(self.owner_i.size)
1727
+
1728
+ @classmethod
1729
+ def empty(cls) -> "CutWallGeometry3D":
1730
+ return cls(
1731
+ owner_i=jnp.zeros((0,), dtype=jnp.int32),
1732
+ owner_j=jnp.zeros((0,), dtype=jnp.int32),
1733
+ owner_k=jnp.zeros((0,), dtype=jnp.int32),
1734
+ center=jnp.zeros((0, 3), dtype=jnp.float64),
1735
+ normal_contra=jnp.zeros((0, 3), dtype=jnp.float64),
1736
+ area_covector=jnp.zeros((0, 3), dtype=jnp.float64),
1737
+ distance=jnp.zeros((0,), dtype=jnp.float64),
1738
+ J=jnp.zeros((0,), dtype=jnp.float64),
1739
+ g_contra=jnp.zeros((0, 3, 3), dtype=jnp.float64),
1740
+ g_cov=jnp.zeros((0, 3, 3), dtype=jnp.float64),
1741
+ B_contra=jnp.zeros((0, 3), dtype=jnp.float64),
1742
+ Bmag=jnp.zeros((0,), dtype=jnp.float64),
1743
+ sign=jnp.zeros((0,), dtype=jnp.float64),
1744
+ )
1745
+
1746
+ def tree_flatten(self):
1747
+ return (
1748
+ (
1749
+ self.owner_i,
1750
+ self.owner_j,
1751
+ self.owner_k,
1752
+ self.center,
1753
+ self.normal_contra,
1754
+ self.area_covector,
1755
+ self.distance,
1756
+ self.J,
1757
+ self.g_contra,
1758
+ self.g_cov,
1759
+ self.B_contra,
1760
+ self.Bmag,
1761
+ self.sign,
1762
+ ),
1763
+ None,
1764
+ )
1765
+
1766
+ @classmethod
1767
+ def tree_unflatten(cls, _aux_data, children):
1768
+ return cls(*children)
1769
+
1770
+
1771
+ @_pytree_base
1772
+ @dataclass(frozen=True)
1773
+ class CutWallBC3D:
1774
+ """Future embedded-wall boundary data."""
1775
+
1776
+ kind: jnp.ndarray # (n_cut_faces,)
1777
+ value: jnp.ndarray # (n_cut_faces,)
1778
+
1779
+ def __post_init__(self) -> None:
1780
+ kind = jnp.asarray(self.kind, dtype=jnp.int32)
1781
+ value = jnp.asarray(self.value, dtype=jnp.float64)
1782
+ if kind.ndim != 1 or value.ndim != 1:
1783
+ raise ValueError("CutWallBC3D fields must be one-dimensional")
1784
+ if kind.shape != value.shape:
1785
+ raise ValueError(f"CutWallBC3D.kind and value must have the same shape, got {kind.shape} and {value.shape}")
1786
+ object.__setattr__(self, "kind", kind)
1787
+ object.__setattr__(self, "value", value)
1788
+
1789
+ @property
1790
+ def n_wall_faces(self) -> int:
1791
+ return int(self.kind.size)
1792
+
1793
+ @classmethod
1794
+ def empty(cls) -> "CutWallBC3D":
1795
+ return cls(
1796
+ kind=jnp.zeros((0,), dtype=jnp.int32),
1797
+ value=jnp.zeros((0,), dtype=jnp.float64),
1798
+ )
1799
+
1800
+ def tree_flatten(self):
1801
+ return ((self.kind, self.value), None)
1802
+
1803
+ @classmethod
1804
+ def tree_unflatten(cls, _aux_data, children):
1805
+ return cls(*children)
1806
+
1807
+
1808
+ @dataclass(frozen=True)
1809
+ class FourFieldBoundaryConditions:
1810
+ """Per-field face BCs (plus optional cut-wall data) for the four-field model."""
1811
+
1812
+ phi_face_bc: BoundaryFaceBC3D
1813
+ density_face_bc: BoundaryFaceBC3D
1814
+ omega_face_bc: BoundaryFaceBC3D
1815
+ v_ion_parallel_face_bc: BoundaryFaceBC3D
1816
+ v_electron_parallel_face_bc: BoundaryFaceBC3D
1817
+ phi_cut_wall_geometry: CutWallGeometry3D = field(default_factory=CutWallGeometry3D.empty)
1818
+ phi_cut_wall_bc: CutWallBC3D = field(default_factory=CutWallBC3D.empty)
1819
+ density_cut_wall_geometry: CutWallGeometry3D = field(default_factory=CutWallGeometry3D.empty)
1820
+ density_cut_wall_bc: CutWallBC3D = field(default_factory=CutWallBC3D.empty)
1821
+ omega_cut_wall_geometry: CutWallGeometry3D = field(default_factory=CutWallGeometry3D.empty)
1822
+ omega_cut_wall_bc: CutWallBC3D = field(default_factory=CutWallBC3D.empty)
1823
+ v_ion_parallel_cut_wall_geometry: CutWallGeometry3D = field(default_factory=CutWallGeometry3D.empty)
1824
+ v_ion_parallel_cut_wall_bc: CutWallBC3D = field(default_factory=CutWallBC3D.empty)
1825
+ v_electron_parallel_cut_wall_geometry: CutWallGeometry3D = field(default_factory=CutWallGeometry3D.empty)
1826
+ v_electron_parallel_cut_wall_bc: CutWallBC3D = field(default_factory=CutWallBC3D.empty)
1827
+
1828
+
1829
+ @_pytree_base
1830
+ @dataclass(frozen=True)
1831
+ class LocalCutWallGeometry3D(_DataclassPyTreeMixin):
1832
+ """Padded cut-wall geometry owned by one local shard."""
1833
+
1834
+ owner_i: jnp.ndarray
1835
+ owner_j: jnp.ndarray
1836
+ owner_k: jnp.ndarray
1837
+ center: jnp.ndarray
1838
+ normal_contra: jnp.ndarray
1839
+ area_covector: jnp.ndarray
1840
+ distance: jnp.ndarray
1841
+ J: jnp.ndarray
1842
+ g_contra: jnp.ndarray
1843
+ g_cov: jnp.ndarray
1844
+ B_contra: jnp.ndarray
1845
+ Bmag: jnp.ndarray
1846
+ sign: jnp.ndarray
1847
+ active: jnp.ndarray
1848
+ max_wall_faces: int
1849
+
1850
+ def __post_init__(self) -> None:
1851
+ max_wall_faces = int(self.max_wall_faces)
1852
+ if max_wall_faces < 0:
1853
+ raise ValueError(f"max_wall_faces must be non-negative, got {max_wall_faces}")
1854
+
1855
+ owner_i = _as_local_wall_int_array(self.owner_i, max_wall_faces, "LocalCutWallGeometry3D.owner_i")
1856
+ owner_j = _as_local_wall_int_array(self.owner_j, max_wall_faces, "LocalCutWallGeometry3D.owner_j")
1857
+ owner_k = _as_local_wall_int_array(self.owner_k, max_wall_faces, "LocalCutWallGeometry3D.owner_k")
1858
+ center = _as_local_wall_array(self.center, max_wall_faces, (3,), "LocalCutWallGeometry3D.center")
1859
+ normal_contra = _as_local_wall_array(self.normal_contra, max_wall_faces, (3,), "LocalCutWallGeometry3D.normal_contra")
1860
+ area_covector = _as_local_wall_array(self.area_covector, max_wall_faces, (3,), "LocalCutWallGeometry3D.area_covector")
1861
+ distance = _as_local_wall_array(self.distance, max_wall_faces, (), "LocalCutWallGeometry3D.distance")
1862
+ J = _as_local_wall_array(self.J, max_wall_faces, (), "LocalCutWallGeometry3D.J")
1863
+ g_contra = _as_local_wall_array(self.g_contra, max_wall_faces, (3, 3), "LocalCutWallGeometry3D.g_contra")
1864
+ g_cov = _as_local_wall_array(self.g_cov, max_wall_faces, (3, 3), "LocalCutWallGeometry3D.g_cov")
1865
+ B_contra = _as_local_wall_array(self.B_contra, max_wall_faces, (3,), "LocalCutWallGeometry3D.B_contra")
1866
+ Bmag = _as_local_wall_array(self.Bmag, max_wall_faces, (), "LocalCutWallGeometry3D.Bmag")
1867
+ sign = _as_local_wall_array(self.sign, max_wall_faces, (), "LocalCutWallGeometry3D.sign")
1868
+ active = _as_local_wall_bool_array(self.active, max_wall_faces, "LocalCutWallGeometry3D.active")
1869
+
1870
+ object.__setattr__(self, "owner_i", owner_i)
1871
+ object.__setattr__(self, "owner_j", owner_j)
1872
+ object.__setattr__(self, "owner_k", owner_k)
1873
+ object.__setattr__(self, "center", center)
1874
+ object.__setattr__(self, "normal_contra", normal_contra)
1875
+ object.__setattr__(self, "area_covector", area_covector)
1876
+ object.__setattr__(self, "distance", distance)
1877
+ object.__setattr__(self, "J", J)
1878
+ object.__setattr__(self, "g_contra", g_contra)
1879
+ object.__setattr__(self, "g_cov", g_cov)
1880
+ object.__setattr__(self, "B_contra", B_contra)
1881
+ object.__setattr__(self, "Bmag", Bmag)
1882
+ object.__setattr__(self, "sign", sign)
1883
+ object.__setattr__(self, "active", active)
1884
+ object.__setattr__(self, "max_wall_faces", max_wall_faces)
1885
+
1886
+ @property
1887
+ def n_wall_faces(self) -> int:
1888
+ return int(self.max_wall_faces)
1889
+
1890
+ @classmethod
1891
+ def empty(cls, max_wall_faces: int) -> "LocalCutWallGeometry3D":
1892
+ max_wall_faces = int(max_wall_faces)
1893
+ zeros3 = (max_wall_faces, 3)
1894
+ zeros33 = (max_wall_faces, 3, 3)
1895
+ return cls(
1896
+ owner_i=jnp.zeros((max_wall_faces,), dtype=jnp.int32),
1897
+ owner_j=jnp.zeros((max_wall_faces,), dtype=jnp.int32),
1898
+ owner_k=jnp.zeros((max_wall_faces,), dtype=jnp.int32),
1899
+ center=jnp.zeros(zeros3, dtype=jnp.float64),
1900
+ normal_contra=jnp.zeros(zeros3, dtype=jnp.float64),
1901
+ area_covector=jnp.zeros(zeros3, dtype=jnp.float64),
1902
+ distance=jnp.zeros((max_wall_faces,), dtype=jnp.float64),
1903
+ J=jnp.zeros((max_wall_faces,), dtype=jnp.float64),
1904
+ g_contra=jnp.zeros(zeros33, dtype=jnp.float64),
1905
+ g_cov=jnp.zeros(zeros33, dtype=jnp.float64),
1906
+ B_contra=jnp.zeros(zeros3, dtype=jnp.float64),
1907
+ Bmag=jnp.zeros((max_wall_faces,), dtype=jnp.float64),
1908
+ sign=jnp.zeros((max_wall_faces,), dtype=jnp.float64),
1909
+ active=jnp.zeros((max_wall_faces,), dtype=bool),
1910
+ max_wall_faces=max_wall_faces,
1911
+ )
1912
+
1913
+ def tree_flatten(self):
1914
+ return (
1915
+ (
1916
+ self.owner_i,
1917
+ self.owner_j,
1918
+ self.owner_k,
1919
+ self.center,
1920
+ self.normal_contra,
1921
+ self.area_covector,
1922
+ self.distance,
1923
+ self.J,
1924
+ self.g_contra,
1925
+ self.g_cov,
1926
+ self.B_contra,
1927
+ self.Bmag,
1928
+ self.sign,
1929
+ self.active,
1930
+ ),
1931
+ self.max_wall_faces,
1932
+ )
1933
+
1934
+ @classmethod
1935
+ def tree_unflatten(cls, max_wall_faces, children):
1936
+ (
1937
+ owner_i,
1938
+ owner_j,
1939
+ owner_k,
1940
+ center,
1941
+ normal_contra,
1942
+ area_covector,
1943
+ distance,
1944
+ J,
1945
+ g_contra,
1946
+ g_cov,
1947
+ B_contra,
1948
+ Bmag,
1949
+ sign,
1950
+ active,
1951
+ ) = children
1952
+ return cls(
1953
+ owner_i=owner_i,
1954
+ owner_j=owner_j,
1955
+ owner_k=owner_k,
1956
+ center=center,
1957
+ normal_contra=normal_contra,
1958
+ area_covector=area_covector,
1959
+ distance=distance,
1960
+ J=J,
1961
+ g_contra=g_contra,
1962
+ g_cov=g_cov,
1963
+ B_contra=B_contra,
1964
+ Bmag=Bmag,
1965
+ sign=sign,
1966
+ active=active,
1967
+ max_wall_faces=max_wall_faces,
1968
+ )
1969
+
1970
+
1971
+ @_pytree_base
1972
+ @dataclass(frozen=True)
1973
+ class LocalCutWallBC3D(_DataclassPyTreeMixin):
1974
+ """
1975
+ Local padded cut-wall boundary-condition payload for one shard.
1976
+
1977
+ Each active entry corresponds to one active entry in LocalCutWallGeometry3D.
1978
+
1979
+ The leading dimension is Wmax, where:
1980
+
1981
+ Wmax = maximum number of cut-wall pieces per shard/local domain
1982
+
1983
+ not the total global number of cut-wall pieces.
1984
+
1985
+ The BC arrays are padded so all shards have the same static shape.
1986
+ Inactive padded entries are masked by active=False.
1987
+ """
1988
+
1989
+ kind: jnp.ndarray
1990
+ value: jnp.ndarray
1991
+ active: jnp.ndarray
1992
+ max_wall_faces: int
1993
+
1994
+ def __post_init__(self) -> None:
1995
+ max_wall_faces = int(self.max_wall_faces)
1996
+ if max_wall_faces < 0:
1997
+ raise ValueError(f"max_wall_faces must be non-negative, got {max_wall_faces}")
1998
+
1999
+ kind = jnp.asarray(self.kind, dtype=jnp.int32)
2000
+ value = jnp.asarray(self.value, dtype=jnp.float64)
2001
+ active = jnp.asarray(self.active, dtype=bool)
2002
+ expected = (max_wall_faces,)
2003
+ if kind.shape != expected:
2004
+ raise ValueError(f"LocalCutWallBC3D.kind must have shape {expected}, got {kind.shape}")
2005
+ if value.shape != expected:
2006
+ raise ValueError(f"LocalCutWallBC3D.value must have shape {expected}, got {value.shape}")
2007
+ if active.shape != expected:
2008
+ raise ValueError(f"LocalCutWallBC3D.active must have shape {expected}, got {active.shape}")
2009
+
2010
+ kind = jnp.where(active, kind, BC_NONE)
2011
+ value = jnp.where(active, value, 0.0)
2012
+ object.__setattr__(self, "kind", kind)
2013
+ object.__setattr__(self, "value", value)
2014
+ object.__setattr__(self, "active", active)
2015
+ object.__setattr__(self, "max_wall_faces", max_wall_faces)
2016
+
2017
+ @property
2018
+ def shape(self) -> tuple[int]:
2019
+ return (int(self.max_wall_faces),)
2020
+
2021
+ @property
2022
+ def n_wall_faces(self) -> int:
2023
+ return int(self.max_wall_faces)
2024
+
2025
+ @property
2026
+ def n_active(self) -> jnp.ndarray:
2027
+ return jnp.sum(self.active)
2028
+
2029
+ @classmethod
2030
+ def empty(cls, max_wall_faces: int) -> "LocalCutWallBC3D":
2031
+ max_wall_faces = int(max_wall_faces)
2032
+ return cls(
2033
+ kind=jnp.zeros((max_wall_faces,), dtype=jnp.int32),
2034
+ value=jnp.zeros((max_wall_faces,), dtype=jnp.float64),
2035
+ active=jnp.zeros((max_wall_faces,), dtype=bool),
2036
+ max_wall_faces=max_wall_faces,
2037
+ )
2038
+
2039
+ def tree_flatten(self):
2040
+ return ((self.kind, self.value, self.active), self.max_wall_faces)
2041
+
2042
+ @classmethod
2043
+ def tree_unflatten(cls, max_wall_faces, children):
2044
+ kind, value, active = children
2045
+ return cls(kind=kind, value=value, active=active, max_wall_faces=max_wall_faces)
2046
+
2047
+
2048
+ @_pytree_base
2049
+ @dataclass(frozen=True)
2050
+ class LocalCutWallValueReconstructor3D(_DataclassPyTreeMixin):
2051
+ """Reconstruct field values at local cut-wall pieces from field_halo."""
2052
+
2053
+ cut_wall_geometry: LocalCutWallGeometry3D
2054
+ neighbor_i: jnp.ndarray
2055
+ neighbor_j: jnp.ndarray
2056
+ neighbor_k: jnp.ndarray
2057
+ weights: jnp.ndarray
2058
+ active: jnp.ndarray
2059
+ stencil_width: int
2060
+ max_wall_faces: int
2061
+
2062
+ def __post_init__(self) -> None:
2063
+ if not isinstance(self.cut_wall_geometry, LocalCutWallGeometry3D):
2064
+ raise TypeError("cut_wall_geometry must be a LocalCutWallGeometry3D")
2065
+
2066
+ max_wall_faces = int(self.max_wall_faces)
2067
+ stencil_width = int(self.stencil_width)
2068
+ if max_wall_faces < 0:
2069
+ raise ValueError(f"max_wall_faces must be non-negative, got {max_wall_faces}")
2070
+ if stencil_width < 1:
2071
+ raise ValueError(f"stencil_width must be positive, got {stencil_width}")
2072
+ if self.cut_wall_geometry.max_wall_faces != max_wall_faces:
2073
+ raise ValueError(
2074
+ "LocalCutWallValueReconstructor3D.cut_wall_geometry.max_wall_faces must match max_wall_faces; "
2075
+ f"got {self.cut_wall_geometry.max_wall_faces} and {max_wall_faces}"
2076
+ )
2077
+
2078
+ expected_stencil = (max_wall_faces, stencil_width)
2079
+ expected_wall = (max_wall_faces,)
2080
+ neighbor_i = _as_local_wall_stencil_index_array(
2081
+ self.neighbor_i, max_wall_faces, stencil_width, "LocalCutWallValueReconstructor3D.neighbor_i"
2082
+ )
2083
+ neighbor_j = _as_local_wall_stencil_index_array(
2084
+ self.neighbor_j, max_wall_faces, stencil_width, "LocalCutWallValueReconstructor3D.neighbor_j"
2085
+ )
2086
+ neighbor_k = _as_local_wall_stencil_index_array(
2087
+ self.neighbor_k, max_wall_faces, stencil_width, "LocalCutWallValueReconstructor3D.neighbor_k"
2088
+ )
2089
+ weights = _as_local_wall_stencil_weight_array(
2090
+ self.weights, max_wall_faces, stencil_width, "LocalCutWallValueReconstructor3D.weights"
2091
+ )
2092
+ active = _as_local_wall_bool_array(self.active, max_wall_faces, "LocalCutWallValueReconstructor3D.active")
2093
+
2094
+ if active.shape != expected_wall:
2095
+ raise ValueError(
2096
+ f"LocalCutWallValueReconstructor3D.active must have shape {expected_wall}, got {active.shape}"
2097
+ )
2098
+
2099
+ active2 = active[:, None]
2100
+ neighbor_i = jnp.where(active2, neighbor_i, 0)
2101
+ neighbor_j = jnp.where(active2, neighbor_j, 0)
2102
+ neighbor_k = jnp.where(active2, neighbor_k, 0)
2103
+ weights = jnp.where(active2, weights, 0.0)
2104
+
2105
+ object.__setattr__(self, "neighbor_i", neighbor_i)
2106
+ object.__setattr__(self, "neighbor_j", neighbor_j)
2107
+ object.__setattr__(self, "neighbor_k", neighbor_k)
2108
+ object.__setattr__(self, "weights", weights)
2109
+ object.__setattr__(self, "active", active)
2110
+ object.__setattr__(self, "stencil_width", stencil_width)
2111
+ object.__setattr__(self, "max_wall_faces", max_wall_faces)
2112
+
2113
+ def extrapolate(self, field_halo: jnp.ndarray) -> jnp.ndarray:
2114
+ values = jnp.asarray(field_halo, dtype=jnp.float64)
2115
+ gathered = values[self.neighbor_i, self.neighbor_j, self.neighbor_k]
2116
+ wall_value = jnp.sum(self.weights * gathered, axis=-1)
2117
+ return jnp.where(self.active, wall_value, 0.0)
2118
+
2119
+ def tree_flatten(self):
2120
+ return (
2121
+ (
2122
+ self.cut_wall_geometry,
2123
+ self.neighbor_i,
2124
+ self.neighbor_j,
2125
+ self.neighbor_k,
2126
+ self.weights,
2127
+ self.active,
2128
+ ),
2129
+ (self.stencil_width, self.max_wall_faces),
2130
+ )
2131
+
2132
+ @classmethod
2133
+ def tree_unflatten(cls, aux_data, children):
2134
+ stencil_width, max_wall_faces = aux_data
2135
+ (
2136
+ cut_wall_geometry,
2137
+ neighbor_i,
2138
+ neighbor_j,
2139
+ neighbor_k,
2140
+ weights,
2141
+ active,
2142
+ ) = children
2143
+ return cls(
2144
+ cut_wall_geometry=cut_wall_geometry,
2145
+ neighbor_i=neighbor_i,
2146
+ neighbor_j=neighbor_j,
2147
+ neighbor_k=neighbor_k,
2148
+ weights=weights,
2149
+ active=active,
2150
+ stencil_width=stencil_width,
2151
+ max_wall_faces=max_wall_faces,
2152
+ )
2153
+
2154
+
2155
+ @_pytree_base
2156
+ @dataclass(frozen=True)
2157
+ class LocalCutWallNormalDerivativeConstructor3D(_DataclassPyTreeMixin):
2158
+ """Construct wall-normal derivatives at local cut-wall pieces from field_halo."""
2159
+
2160
+ cut_wall_geometry: LocalCutWallGeometry3D
2161
+ neighbor_i: jnp.ndarray
2162
+ neighbor_j: jnp.ndarray
2163
+ neighbor_k: jnp.ndarray
2164
+ weights_dnormal: jnp.ndarray
2165
+ weights_d2normal: jnp.ndarray
2166
+ wall_coeff_dnormal: jnp.ndarray
2167
+ wall_coeff_d2normal: jnp.ndarray
2168
+ active: jnp.ndarray
2169
+ stencil_width: int
2170
+ max_wall_faces: int
2171
+
2172
+ def __post_init__(self) -> None:
2173
+ if not isinstance(self.cut_wall_geometry, LocalCutWallGeometry3D):
2174
+ raise TypeError("cut_wall_geometry must be a LocalCutWallGeometry3D")
2175
+
2176
+ max_wall_faces = int(self.max_wall_faces)
2177
+ stencil_width = int(self.stencil_width)
2178
+ if max_wall_faces < 0:
2179
+ raise ValueError(f"max_wall_faces must be non-negative, got {max_wall_faces}")
2180
+ if stencil_width < 1:
2181
+ raise ValueError(f"stencil_width must be positive, got {stencil_width}")
2182
+ if self.cut_wall_geometry.max_wall_faces != max_wall_faces:
2183
+ raise ValueError(
2184
+ "LocalCutWallNormalDerivativeConstructor3D.cut_wall_geometry.max_wall_faces must match max_wall_faces; "
2185
+ f"got {self.cut_wall_geometry.max_wall_faces} and {max_wall_faces}"
2186
+ )
2187
+
2188
+ expected_stencil = (max_wall_faces, stencil_width)
2189
+ expected_wall = (max_wall_faces,)
2190
+ neighbor_i = _as_local_wall_stencil_index_array(
2191
+ self.neighbor_i, max_wall_faces, stencil_width, "LocalCutWallNormalDerivativeConstructor3D.neighbor_i"
2192
+ )
2193
+ neighbor_j = _as_local_wall_stencil_index_array(
2194
+ self.neighbor_j, max_wall_faces, stencil_width, "LocalCutWallNormalDerivativeConstructor3D.neighbor_j"
2195
+ )
2196
+ neighbor_k = _as_local_wall_stencil_index_array(
2197
+ self.neighbor_k, max_wall_faces, stencil_width, "LocalCutWallNormalDerivativeConstructor3D.neighbor_k"
2198
+ )
2199
+ weights_dnormal = _as_local_wall_stencil_weight_array(
2200
+ self.weights_dnormal, max_wall_faces, stencil_width, "LocalCutWallNormalDerivativeConstructor3D.weights_dnormal"
2201
+ )
2202
+ weights_d2normal = _as_local_wall_stencil_weight_array(
2203
+ self.weights_d2normal, max_wall_faces, stencil_width, "LocalCutWallNormalDerivativeConstructor3D.weights_d2normal"
2204
+ )
2205
+ wall_coeff_dnormal = _as_local_wall_array(
2206
+ self.wall_coeff_dnormal, max_wall_faces, (), "LocalCutWallNormalDerivativeConstructor3D.wall_coeff_dnormal"
2207
+ )
2208
+ wall_coeff_d2normal = _as_local_wall_array(
2209
+ self.wall_coeff_d2normal, max_wall_faces, (), "LocalCutWallNormalDerivativeConstructor3D.wall_coeff_d2normal"
2210
+ )
2211
+ active = _as_local_wall_bool_array(self.active, max_wall_faces, "LocalCutWallNormalDerivativeConstructor3D.active")
2212
+
2213
+ if active.shape != expected_wall:
2214
+ raise ValueError(
2215
+ f"LocalCutWallNormalDerivativeConstructor3D.active must have shape {expected_wall}, got {active.shape}"
2216
+ )
2217
+
2218
+ active2 = active[:, None]
2219
+ neighbor_i = jnp.where(active2, neighbor_i, 0)
2220
+ neighbor_j = jnp.where(active2, neighbor_j, 0)
2221
+ neighbor_k = jnp.where(active2, neighbor_k, 0)
2222
+ weights_dnormal = jnp.where(active2, weights_dnormal, 0.0)
2223
+ weights_d2normal = jnp.where(active2, weights_d2normal, 0.0)
2224
+ wall_coeff_dnormal = jnp.where(active, wall_coeff_dnormal, 0.0)
2225
+ wall_coeff_d2normal = jnp.where(active, wall_coeff_d2normal, 0.0)
2226
+
2227
+ object.__setattr__(self, "neighbor_i", neighbor_i)
2228
+ object.__setattr__(self, "neighbor_j", neighbor_j)
2229
+ object.__setattr__(self, "neighbor_k", neighbor_k)
2230
+ object.__setattr__(self, "weights_dnormal", weights_dnormal)
2231
+ object.__setattr__(self, "weights_d2normal", weights_d2normal)
2232
+ object.__setattr__(self, "wall_coeff_dnormal", wall_coeff_dnormal)
2233
+ object.__setattr__(self, "wall_coeff_d2normal", wall_coeff_d2normal)
2234
+ object.__setattr__(self, "active", active)
2235
+ object.__setattr__(self, "stencil_width", stencil_width)
2236
+ object.__setattr__(self, "max_wall_faces", max_wall_faces)
2237
+
2238
+ def _gather(self, field_halo: jnp.ndarray) -> jnp.ndarray:
2239
+ values = jnp.asarray(field_halo, dtype=jnp.float64)
2240
+ return values[self.neighbor_i, self.neighbor_j, self.neighbor_k]
2241
+
2242
+ def dnormal_from_wall_value(self, field_halo: jnp.ndarray, wall_value: jnp.ndarray) -> jnp.ndarray:
2243
+ wall_value = jnp.asarray(wall_value, dtype=jnp.float64)
2244
+ expected_wall = (int(self.max_wall_faces),)
2245
+ if wall_value.shape != expected_wall:
2246
+ raise ValueError(f"wall_value must have shape {expected_wall}, got {wall_value.shape}")
2247
+ gathered = self._gather(field_halo)
2248
+ dnormal = jnp.sum(self.weights_dnormal * gathered, axis=-1) + self.wall_coeff_dnormal * wall_value
2249
+ return jnp.where(self.active, dnormal, 0.0)
2250
+
2251
+ def d2normal_from_wall_value(self, field_halo: jnp.ndarray, wall_value: jnp.ndarray) -> jnp.ndarray:
2252
+ wall_value = jnp.asarray(wall_value, dtype=jnp.float64)
2253
+ expected_wall = (int(self.max_wall_faces),)
2254
+ if wall_value.shape != expected_wall:
2255
+ raise ValueError(f"wall_value must have shape {expected_wall}, got {wall_value.shape}")
2256
+ gathered = self._gather(field_halo)
2257
+ d2normal = jnp.sum(self.weights_d2normal * gathered, axis=-1) + self.wall_coeff_d2normal * wall_value
2258
+ return jnp.where(self.active, d2normal, 0.0)
2259
+
2260
+ def normal_derivatives_from_wall_value(
2261
+ self, field_halo: jnp.ndarray, wall_value: jnp.ndarray
2262
+ ) -> tuple[jnp.ndarray, jnp.ndarray]:
2263
+ return (
2264
+ self.dnormal_from_wall_value(field_halo, wall_value),
2265
+ self.d2normal_from_wall_value(field_halo, wall_value),
2266
+ )
2267
+
2268
+ def tree_flatten(self):
2269
+ return (
2270
+ (
2271
+ self.cut_wall_geometry,
2272
+ self.neighbor_i,
2273
+ self.neighbor_j,
2274
+ self.neighbor_k,
2275
+ self.weights_dnormal,
2276
+ self.weights_d2normal,
2277
+ self.wall_coeff_dnormal,
2278
+ self.wall_coeff_d2normal,
2279
+ self.active,
2280
+ ),
2281
+ (self.stencil_width, self.max_wall_faces),
2282
+ )
2283
+
2284
+ @classmethod
2285
+ def tree_unflatten(cls, aux_data, children):
2286
+ stencil_width, max_wall_faces = aux_data
2287
+ (
2288
+ cut_wall_geometry,
2289
+ neighbor_i,
2290
+ neighbor_j,
2291
+ neighbor_k,
2292
+ weights_dnormal,
2293
+ weights_d2normal,
2294
+ wall_coeff_dnormal,
2295
+ wall_coeff_d2normal,
2296
+ active,
2297
+ ) = children
2298
+ return cls(
2299
+ cut_wall_geometry=cut_wall_geometry,
2300
+ neighbor_i=neighbor_i,
2301
+ neighbor_j=neighbor_j,
2302
+ neighbor_k=neighbor_k,
2303
+ weights_dnormal=weights_dnormal,
2304
+ weights_d2normal=weights_d2normal,
2305
+ wall_coeff_dnormal=wall_coeff_dnormal,
2306
+ wall_coeff_d2normal=wall_coeff_d2normal,
2307
+ active=active,
2308
+ stencil_width=stencil_width,
2309
+ max_wall_faces=max_wall_faces,
2310
+ )
2311
+
2312
+
2313
+ @_pytree_base
2314
+ @dataclass(frozen=True)
2315
+ class LocalControlVolumeFluxStencil3D:
2316
+ """Local control-volume flux payload consumed by conservative divergence."""
2317
+
2318
+ regular_flux: FaceFluxStencil3D
2319
+ regular_face_geometry: "LocalRegularFaceGeometry3D | RegularFaceGeometry3D"
2320
+ cell_volume: "LocalCellVolumeGeometry3D | CellVolumeGeometry3D"
2321
+ cut_wall_geometry: "LocalCutWallGeometry3D | CutWallGeometry3D | None" = None
2322
+ cut_wall_flux: jnp.ndarray | None = None
2323
+
2324
+ def __post_init__(self) -> None:
2325
+ if not isinstance(self.regular_flux, FaceFluxStencil3D):
2326
+ raise TypeError("LocalControlVolumeFluxStencil3D.regular_flux must be a FaceFluxStencil3D")
2327
+ if not isinstance(self.regular_face_geometry, (LocalRegularFaceGeometry3D, RegularFaceGeometry3D)):
2328
+ raise TypeError(
2329
+ "LocalControlVolumeFluxStencil3D.regular_face_geometry must be a "
2330
+ "LocalRegularFaceGeometry3D or RegularFaceGeometry3D"
2331
+ )
2332
+ if not isinstance(self.cell_volume, (LocalCellVolumeGeometry3D, CellVolumeGeometry3D)):
2333
+ raise TypeError(
2334
+ "LocalControlVolumeFluxStencil3D.cell_volume must be a "
2335
+ "LocalCellVolumeGeometry3D or CellVolumeGeometry3D"
2336
+ )
2337
+ cell_shape = self.cell_volume.shape
2338
+ if self.regular_flux.shape != cell_shape:
2339
+ raise ValueError(
2340
+ f"regular_flux.shape must match cell_volume.shape, got {self.regular_flux.shape} and {cell_shape}"
2341
+ )
2342
+ face_geometry_cell_shape = (
2343
+ self.regular_face_geometry.local_owned_shape
2344
+ if isinstance(self.regular_face_geometry, LocalRegularFaceGeometry3D)
2345
+ else self.regular_face_geometry.shape
2346
+ )
2347
+ if face_geometry_cell_shape != cell_shape:
2348
+ raise ValueError(
2349
+ "regular_face_geometry.local_owned_shape must match cell_volume.shape, "
2350
+ f"got {face_geometry_cell_shape} and {cell_shape}"
2351
+ )
2352
+ if (
2353
+ self.regular_face_geometry.x_area.shape != self.regular_flux.x.shape
2354
+ or self.regular_face_geometry.y_area.shape != self.regular_flux.y.shape
2355
+ or self.regular_face_geometry.z_area.shape != self.regular_flux.z.shape
2356
+ ):
2357
+ raise ValueError(
2358
+ "regular_face_geometry face arrays must match regular_flux face shapes"
2359
+ )
2360
+
2361
+ if self.cut_wall_geometry is None:
2362
+ if self.cut_wall_flux is not None:
2363
+ raise ValueError("cut_wall_flux must be None when cut_wall_geometry is None")
2364
+ object.__setattr__(self, "cut_wall_flux", None)
2365
+ return
2366
+
2367
+ if not isinstance(self.cut_wall_geometry, (LocalCutWallGeometry3D, CutWallGeometry3D)):
2368
+ raise TypeError(
2369
+ "LocalControlVolumeFluxStencil3D.cut_wall_geometry must be a "
2370
+ "LocalCutWallGeometry3D or CutWallGeometry3D"
2371
+ )
2372
+ if self.cut_wall_flux is None:
2373
+ Wmax = getattr(self.cut_wall_geometry, "max_wall_faces", self.cut_wall_geometry.n_wall_faces)
2374
+ cut_wall_flux = jnp.zeros((Wmax,), dtype=jnp.float64)
2375
+ else:
2376
+ cut_wall_flux = jnp.asarray(self.cut_wall_flux, dtype=jnp.float64)
2377
+ expected = (self.cut_wall_geometry.n_wall_faces,)
2378
+ if cut_wall_flux.shape != expected:
2379
+ raise ValueError(
2380
+ f"LocalControlVolumeFluxStencil3D.cut_wall_flux must have shape {expected}, got {cut_wall_flux.shape}"
2381
+ )
2382
+
2383
+ cut_wall_active = getattr(self.cut_wall_geometry, "active", None)
2384
+ if cut_wall_active is not None:
2385
+ cut_wall_active = jnp.asarray(cut_wall_active, dtype=bool)
2386
+ if cut_wall_active.shape != cut_wall_flux.shape:
2387
+ raise ValueError(
2388
+ "LocalControlVolumeFluxStencil3D.cut_wall_geometry.active must match cut_wall_flux shape"
2389
+ )
2390
+ cut_wall_flux = jnp.where(cut_wall_active, cut_wall_flux, 0.0)
2391
+ object.__setattr__(self, "cut_wall_flux", cut_wall_flux)
2392
+
2393
+ @property
2394
+ def shape(self) -> tuple[int, int, int]:
2395
+ return self.regular_flux.shape
2396
+
2397
+ def tree_flatten(self):
2398
+ return (
2399
+ (
2400
+ self.regular_flux,
2401
+ self.regular_face_geometry,
2402
+ self.cell_volume,
2403
+ self.cut_wall_geometry,
2404
+ self.cut_wall_flux,
2405
+ ),
2406
+ None,
2407
+ )
2408
+
2409
+ @classmethod
2410
+ def tree_unflatten(cls, _aux_data, children):
2411
+ return cls(*children)
2412
+
2413
+ def dataclass_replace_1d(instance: LocalStencil1D, **updates: object) -> LocalStencil1D:
2414
+ """Small ``dataclasses.replace`` helper that keeps this module self-contained."""
2415
+
2416
+ return LocalStencil1D(
2417
+ center=updates.get("center", instance.center),
2418
+ minus=updates.get("minus", instance.minus),
2419
+ plus=updates.get("plus", instance.plus),
2420
+ dx_min=updates.get("dx_min", instance.dx_min),
2421
+ dx_plus=updates.get("dx_plus", instance.dx_plus),
2422
+ derivative_minus_weight=updates.get(
2423
+ "derivative_minus_weight",
2424
+ instance.derivative_minus_weight,
2425
+ ),
2426
+ derivative_center_weight=updates.get(
2427
+ "derivative_center_weight",
2428
+ instance.derivative_center_weight,
2429
+ ),
2430
+ derivative_plus_weight=updates.get(
2431
+ "derivative_plus_weight",
2432
+ instance.derivative_plus_weight,
2433
+ ),
2434
+ )
2435
+
2436
+
2437
+ def dataclass_replace_3d(instance: LocalStencil3D, **updates: object) -> LocalStencil3D:
2438
+ """Small ``dataclasses.replace`` helper that keeps this module self-contained."""
2439
+
2440
+ return LocalStencil3D(
2441
+ x=updates.get("x", instance.x),
2442
+ y=updates.get("y", instance.y),
2443
+ z=updates.get("z", instance.z),
2444
+ )
2445
+
2446
+
2447
+ def dataclass_replace_conservative(
2448
+ instance: ConservativeStencil3D, **updates: object
2449
+ ) -> ConservativeStencil3D:
2450
+ """Small ``dataclasses.replace`` helper for conservative stencil payloads."""
2451
+
2452
+ return ConservativeStencil3D(
2453
+ x=updates.get("x", instance.x),
2454
+ y=updates.get("y", instance.y),
2455
+ z=updates.get("z", instance.z),
2456
+ face_grad=updates.get("face_grad", instance.face_grad),
2457
+ )
2458
+
2459
+
2460
+ __all__ = [
2461
+ "BC_DIRICHLET",
2462
+ "BC_NEUMANN",
2463
+ "BC_NONE",
2464
+ "BC_NORMALFLUX",
2465
+ "BC_NOFLUX",
2466
+ "BoundaryConditionBuilder",
2467
+ "BoundaryFaceBC3D",
2468
+ "CellVolumeGeometry3D",
2469
+ "CoordinateFaceValueReconstructor3D",
2470
+ "CoordinateNormalDerivativeConstructor3D",
2471
+ "FaceGradientStencil3D",
2472
+ "ConservativeStencil3D",
2473
+ "CutWallBC3D",
2474
+ "CutWallGeometry3D",
2475
+ "CutWallNormalDerivativeConstructor3D",
2476
+ "CutWallValueReconstructor3D",
2477
+ "FaceFluxStencil3D",
2478
+ "FourFieldBoundaryConditions",
2479
+ "LocalBoundaryConditionBuilder",
2480
+ "LocalBoundaryData3D",
2481
+ "LocalBoundaryFaceBC3D",
2482
+ "LocalCoordinateFaceValueReconstructor3D",
2483
+ "LocalCoordinateNormalDerivativeConstructor3D",
2484
+ "LocalCoordinateSideValues1D",
2485
+ "LocalCoordinateSideValues3D",
2486
+ "LocalControlVolumeFluxStencil3D",
2487
+ "LocalCutWallBC3D",
2488
+ "LocalCutWallGeometry3D",
2489
+ "LocalCutWallNormalDerivativeConstructor3D",
2490
+ "LocalCutWallValueReconstructor3D",
2491
+ "LocalStencil1D",
2492
+ "LocalStencil3D",
2493
+ "RegularFaceGeometry3D",
2494
+ ]