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,4081 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from functools import partial
5
+ from typing import Literal
6
+
7
+ import jax
8
+ import jax.numpy as jnp
9
+
10
+ from solvax import gmres as solvax_gmres
11
+
12
+ _pytree_base = jax.tree_util.register_pytree_node_class
13
+
14
+ from ..geometry import (
15
+ CellVolumeGeometry3D,
16
+ CellCenteredGrid3D,
17
+ ConservativeStencilBuilder,
18
+ HaloLayout3D,
19
+ FaceBFieldGeometry,
20
+ FaceMetricGeometry,
21
+ FciGeometry3D,
22
+ FciMaps3D,
23
+ Grid1D,
24
+ LocalBFieldGeometry,
25
+ LocalCellCenteredGrid3D,
26
+ LocalCellVolumeGeometry3D,
27
+ LocalDomain3D,
28
+ LocalFciDirectionMap,
29
+ LocalFciGeometry3D,
30
+ LocalFciLocalDependencyTable,
31
+ LocalFciMaps3D,
32
+ LocalFciRemoteDependencyTable,
33
+ LocalFaceBFieldGeometry,
34
+ LocalFaceMetricGeometry,
35
+ LocalGrid1D,
36
+ LocalMetricGeometry,
37
+ LocalRegularFaceGeometry3D,
38
+ LocalSpacing3D,
39
+ LocalStencilBuilder,
40
+ LocalConservativeStencilBuilder,
41
+ NeighborMap3D,
42
+ ShardSpec3D,
43
+ Spacing3D,
44
+ RegularFaceGeometry3D,
45
+ build_conservative_stencil_from_field,
46
+ build_local_conservative_stencil_from_field,
47
+ build_local_direct_stencil_one_sided_physical_from_halo,
48
+ build_local_stencil_from_field,
49
+ )
50
+ from ..geometry.fci_geometry import (
51
+ StencilBuilderContext,
52
+ )
53
+ from .fci import _first_derivative_3d
54
+ from .fci_halo import HaloExchange3D, TopologyHaloFiller3D
55
+ from .fci_model import (
56
+ inject_owned_field_to_halo,
57
+ inject_owned_vector_field_to_halo,
58
+ )
59
+ from .fci_boundaries import (
60
+ BC_DIRICHLET,
61
+ BC_NEUMANN,
62
+ BC_NONE,
63
+ BC_NORMALFLUX,
64
+ BC_NOFLUX,
65
+ LocalBoundaryConditionBuilder,
66
+ LocalBoundaryData3D,
67
+ LocalBoundaryFaceBC3D,
68
+ LocalControlVolumeFluxStencil3D,
69
+ LocalCoordinateFaceValueReconstructor3D,
70
+ LocalCoordinateNormalDerivativeConstructor3D,
71
+ LocalCoordinateSideValues1D,
72
+ LocalCoordinateSideValues3D,
73
+ LocalCutWallBC3D,
74
+ LocalCutWallGeometry3D,
75
+ LocalCutWallNormalDerivativeConstructor3D,
76
+ LocalCutWallValueReconstructor3D,
77
+ CutWallBC3D,
78
+ CutWallGeometry3D,
79
+ BoundaryFaceBC3D,
80
+ FaceFluxStencil3D,
81
+ ConservativeStencil3D,
82
+ LocalStencil1D,
83
+ LocalStencil3D,
84
+ )
85
+
86
+
87
+ # =============================================================================
88
+ # Parallel-gradient operators
89
+ # =============================================================================
90
+
91
+ def _take_stencil_finite_difference(stencil: LocalStencil1D) -> jnp.ndarray:
92
+ """Apply a reconstructed 1D derivative stencil.
93
+
94
+ The stencil arrays may represent either global/reference cells or local
95
+ owned cells. The output has the same shape as the stencil.
96
+ """
97
+
98
+ if stencil.center.ndim != 3:
99
+ raise ValueError(
100
+ f"stencil center must be 3D, got shape {stencil.center.shape}"
101
+ )
102
+
103
+ minus = jnp.asarray(stencil.minus, dtype=jnp.float64)
104
+ center = jnp.asarray(stencil.center, dtype=jnp.float64)
105
+ plus = jnp.asarray(stencil.plus, dtype=jnp.float64)
106
+
107
+ c_minus = jnp.asarray(stencil.derivative_minus_weight, dtype=jnp.float64)
108
+ c_center = jnp.asarray(stencil.derivative_center_weight, dtype=jnp.float64)
109
+ c_plus = jnp.asarray(stencil.derivative_plus_weight, dtype=jnp.float64)
110
+
111
+ return c_minus * minus + c_center * center + c_plus * plus
112
+
113
+
114
+ def grad_parallel_op_fci(
115
+ stencil: LocalStencil1D,
116
+ geometry: FciGeometry3D,
117
+ ) -> jnp.ndarray:
118
+ """Global/reference centered FCI parallel gradient.
119
+
120
+ Computes ``grad_parallel(f)`` from a field-line stencil. This reference
121
+ path assumes ``stencil.shape == geometry.shape``.
122
+ """
123
+
124
+ if stencil.shape != geometry.shape:
125
+ raise ValueError(
126
+ f"stencil must have shape {geometry.shape}, got {stencil.shape}"
127
+ )
128
+
129
+ return _take_stencil_finite_difference(stencil)
130
+
131
+
132
+ def local_grad_parallel_op_fci(
133
+ stencil: LocalStencil1D,
134
+ geometry: LocalFciGeometry3D,
135
+ ) -> jnp.ndarray:
136
+ """Local/domain-decomposed centered FCI parallel gradient.
137
+
138
+ Computes ``grad_parallel(f)`` from a field-line stencil on owned cells.
139
+ The stencil builder is responsible for using the prepared halo field,
140
+ topology information, and cut-wall/boundary information to construct the
141
+ stencil.
142
+ """
143
+
144
+ if not isinstance(geometry, LocalFciGeometry3D):
145
+ raise TypeError(
146
+ "local_grad_parallel_op_fci requires LocalFciGeometry3D, "
147
+ f"got {type(geometry).__name__}"
148
+ )
149
+
150
+ if stencil.shape != geometry.owned_shape:
151
+ raise ValueError(
152
+ f"stencil must have shape {geometry.owned_shape}, "
153
+ f"got {stencil.shape}"
154
+ )
155
+
156
+ return _take_stencil_finite_difference(stencil)
157
+
158
+
159
+ def grad_parallel_op_direct(
160
+ stencil: LocalStencil3D,
161
+ geometry: FciGeometry3D,
162
+ *,
163
+ b_floor: float = 1.0e-30,
164
+ ) -> jnp.ndarray:
165
+ """Global/reference direct finite-difference parallel gradient.
166
+
167
+ Computes ``grad_parallel(f) = b^i partial_i f`` using coordinate-direction
168
+ derivative stencils. This reference path assumes
169
+ ``stencil.shape == geometry.shape`` and that the cell-centered magnetic
170
+ field arrays are shaped like the stencil.
171
+ """
172
+
173
+ if stencil.shape != geometry.shape:
174
+ raise ValueError(
175
+ f"stencil must have shape {geometry.shape}, got {stencil.shape}"
176
+ )
177
+
178
+ dfdx = _take_stencil_finite_difference(
179
+ stencil.x,
180
+ )
181
+
182
+ dfdy = _take_stencil_finite_difference(
183
+ stencil.y,
184
+ )
185
+
186
+ dfdz = _take_stencil_finite_difference(
187
+ stencil.z,
188
+ )
189
+
190
+ df = jnp.stack((dfdx, dfdy, dfdz), axis=-1)
191
+
192
+ # Prefer the explicit B_contra/Bmag representation. Fall back to the
193
+ # normalized field property for older global/reference geometry objects.
194
+ if hasattr(geometry.cell_bfield, "B_contra") and hasattr(
195
+ geometry.cell_bfield,
196
+ "Bmag",
197
+ ):
198
+ B_contra = jnp.asarray(geometry.cell_bfield.B_contra, dtype=jnp.float64)
199
+ Bmag = jnp.asarray(geometry.cell_bfield.Bmag, dtype=jnp.float64)
200
+ Bmag = jnp.maximum(Bmag, float(b_floor))
201
+ b_contra = B_contra / Bmag[..., None]
202
+ else:
203
+ b_contra = jnp.asarray(
204
+ geometry.cell_bfield.b_contra,
205
+ dtype=jnp.float64,
206
+ )
207
+
208
+ return jnp.einsum("...i,...i->...", b_contra, df)
209
+
210
+
211
+ def local_grad_parallel_op_direct(
212
+ stencil: LocalStencil3D,
213
+ geometry: LocalFciGeometry3D,
214
+ *,
215
+ b_floor: float = 1.0e-30,
216
+ ) -> jnp.ndarray:
217
+ """Local/domain-decomposed direct finite-difference parallel gradient.
218
+
219
+ Computes ``grad_parallel(f) = b^i partial_i f`` on owned cells. The
220
+ stencil must have been built from a fully prepared halo field, while this
221
+ operator contracts the owned-cell derivatives with the owned portion of
222
+ the halo-shaped local magnetic field.
223
+ """
224
+
225
+ if not isinstance(geometry, LocalFciGeometry3D):
226
+ raise TypeError(
227
+ "local_grad_parallel_op_direct requires LocalFciGeometry3D, "
228
+ f"got {type(geometry).__name__}"
229
+ )
230
+
231
+ if stencil.shape != geometry.owned_shape:
232
+ raise ValueError(
233
+ f"stencil must have shape {geometry.owned_shape}, "
234
+ f"got {stencil.shape}"
235
+ )
236
+
237
+ dfdx = _take_stencil_finite_difference(stencil.x)
238
+ dfdy = _take_stencil_finite_difference(stencil.y)
239
+ dfdz = _take_stencil_finite_difference(stencil.z)
240
+
241
+ df = jnp.stack((dfdx, dfdy, dfdz), axis=-1)
242
+
243
+ # Cell-centered local geometry is halo-shaped. Use the owned magnetic
244
+ # field properties so the contraction matches the owned derivative shape.
245
+ B_contra = jnp.asarray(
246
+ geometry.cell_bfield.B_contra_owned,
247
+ dtype=jnp.float64,
248
+ )
249
+ Bmag = jnp.asarray(
250
+ geometry.cell_bfield.Bmag_owned,
251
+ dtype=jnp.float64,
252
+ )
253
+
254
+ Bmag = jnp.maximum(Bmag, float(b_floor))
255
+ b_contra = B_contra / Bmag[..., None]
256
+
257
+ return jnp.einsum("...i,...i->...", b_contra, df)
258
+
259
+
260
+ def parallel_laplacian_direct_op(
261
+ field: jnp.ndarray,
262
+ geometry: FciGeometry3D,
263
+ *,
264
+ stencil_builder: LocalStencilBuilder | ConservativeStencilBuilder = build_local_stencil_from_field,
265
+ face_bc: BoundaryFaceBC3D | None = None,
266
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
267
+ ) -> jnp.ndarray:
268
+ """Return the chained direct parallel Laplacian ``grad_parallel(grad_parallel(f))``.
269
+
270
+ The operator is built by reconstructing a local stencil for ``field``,
271
+ applying ``grad_parallel_op_direct`` once, reconstructing a second stencil
272
+ for the intermediate field, and applying ``grad_parallel_op_direct`` again.
273
+ """
274
+
275
+ if not isinstance(stencil_builder, (LocalStencilBuilder, ConservativeStencilBuilder)):
276
+ raise TypeError(
277
+ "stencil_builder must be a LocalStencilBuilder or ConservativeStencilBuilder instance"
278
+ )
279
+
280
+ if face_bc is None:
281
+ face_bc = BoundaryFaceBC3D.empty(RegularFaceGeometry3D.unit(geometry))
282
+
283
+ first_stencil = stencil_builder(
284
+ field,
285
+ geometry,
286
+ periodic_axes=periodic_axes,
287
+ face_bc=face_bc,
288
+ )
289
+ first_grad = grad_parallel_op_direct(first_stencil, geometry)
290
+
291
+ second_stencil = stencil_builder(
292
+ first_grad,
293
+ geometry,
294
+ periodic_axes=periodic_axes,
295
+ face_bc=face_bc,
296
+ )
297
+ return grad_parallel_op_direct(second_stencil, geometry)
298
+
299
+
300
+ def local_parallel_laplacian_direct_op(
301
+ field_halo_full: jnp.ndarray,
302
+ geometry: LocalFciGeometry3D,
303
+ domain: LocalDomain3D,
304
+ *,
305
+ context: StencilBuilderContext,
306
+ first_stencil_builder: LocalStencilBuilder = build_local_stencil_from_field,
307
+ intermediate_stencil_builder: LocalStencilBuilder = LocalStencilBuilder(
308
+ build_local_direct_stencil_one_sided_physical_from_halo
309
+ ),
310
+ halo_exchange: HaloExchange3D,
311
+ topology_filler: TopologyHaloFiller3D,
312
+ b_floor: float = 1.0e-30,
313
+ ) -> jnp.ndarray:
314
+ """Compute a local chained parallel Laplacian with one-sided closure.
315
+
316
+ The first derivative is built from the fully prepared input halo field.
317
+ Its owned result is then injected into a fresh halo field, exchanged across
318
+ shard interfaces, and topology-filled. The second derivative uses centered
319
+ stencils away from true physical coordinate boundaries and nonuniform
320
+ three-point one-sided stencils on those physical boundary planes. No
321
+ physical ghost values are read for the intermediate derivative field.
322
+
323
+ ``intermediate_stencil_builder`` defaults to the built-in one-sided
324
+ physical-boundary builder and can be overridden with another correctly
325
+ constructed ``LocalStencilBuilder``.
326
+ """
327
+
328
+ if not isinstance(geometry, LocalFciGeometry3D):
329
+ raise TypeError(
330
+ "local_parallel_laplacian_direct_op requires "
331
+ f"LocalFciGeometry3D, got {type(geometry).__name__}"
332
+ )
333
+ if not isinstance(domain, LocalDomain3D):
334
+ raise TypeError(
335
+ "local_parallel_laplacian_direct_op requires "
336
+ f"LocalDomain3D, got {type(domain).__name__}"
337
+ )
338
+ if not isinstance(halo_exchange, HaloExchange3D):
339
+ raise TypeError(
340
+ "halo_exchange must be a HaloExchange3D, "
341
+ f"got {type(halo_exchange).__name__}"
342
+ )
343
+ if not isinstance(topology_filler, TopologyHaloFiller3D):
344
+ raise TypeError(
345
+ "topology_filler must be a TopologyHaloFiller3D, "
346
+ f"got {type(topology_filler).__name__}"
347
+ )
348
+ if not isinstance(context, StencilBuilderContext):
349
+ raise TypeError(
350
+ "context must be a StencilBuilderContext, "
351
+ f"got {type(context).__name__}"
352
+ )
353
+ if not isinstance(intermediate_stencil_builder, LocalStencilBuilder):
354
+ raise TypeError(
355
+ "intermediate_stencil_builder must be a LocalStencilBuilder, "
356
+ f"got {type(intermediate_stencil_builder).__name__}"
357
+ )
358
+ if domain.layout != geometry.layout:
359
+ raise ValueError("geometry and domain must share the same HaloLayout3D")
360
+ if context.layout != geometry.layout:
361
+ raise ValueError("geometry and context must share the same HaloLayout3D")
362
+ if context.domain is None:
363
+ raise ValueError("context.domain is required for the local stencil builders")
364
+
365
+ field_halo_full = jnp.asarray(field_halo_full, dtype=jnp.float64)
366
+ if field_halo_full.shape != geometry.halo_shape:
367
+ raise ValueError(
368
+ "field_halo_full must match geometry.halo_shape; "
369
+ f"got {field_halo_full.shape}, expected {geometry.halo_shape}"
370
+ )
371
+
372
+ # First derivative of the prepared input field.
373
+ first_stencil = first_stencil_builder(
374
+ field_halo_full,
375
+ geometry,
376
+ context,
377
+ )
378
+ q_owned = local_grad_parallel_op_direct(
379
+ first_stencil,
380
+ geometry,
381
+ b_floor=b_floor,
382
+ )
383
+
384
+ # The intermediate derivative is owned-shaped. Reconstruct its halo before
385
+ # taking the second derivative. These stages intentionally do not perform
386
+ # physical ghost filling; the one-sided stencil owns those side planes.
387
+ q_halo = inject_owned_field_to_halo(q_owned, domain.layout)
388
+ q_halo = halo_exchange(q_halo, domain)
389
+ q_halo = topology_filler(q_halo, domain)
390
+
391
+ second_stencil = intermediate_stencil_builder(
392
+ q_halo,
393
+ geometry,
394
+ context,
395
+ )
396
+ return local_grad_parallel_op_direct(
397
+ second_stencil,
398
+ geometry,
399
+ b_floor=b_floor,
400
+ )
401
+
402
+
403
+ def grad_perp_op(
404
+ stencil: LocalStencil3D,
405
+ geometry: FciGeometry3D,
406
+ *,
407
+ b_floor: float = 1.0e-30,
408
+ ) -> jnp.ndarray:
409
+ """Return the projected perpendicular gradient from a 3D local stencil."""
410
+
411
+ if stencil.shape != geometry.shape:
412
+ raise ValueError(
413
+ f"stencil must have shape {geometry.shape}, got {stencil.shape}"
414
+ )
415
+
416
+ dfdx = _take_stencil_finite_difference(stencil.x)
417
+ dfdy = _take_stencil_finite_difference(stencil.y)
418
+ dfdz = _take_stencil_finite_difference(stencil.z)
419
+ df = jnp.stack((dfdx, dfdy, dfdz), axis=-1)
420
+
421
+ cell_metric = geometry.cell_metric
422
+ cell_bfield = geometry.cell_bfield
423
+ g = jnp.stack(
424
+ [
425
+ jnp.stack([cell_metric.g11, cell_metric.g12, cell_metric.g13], axis=-1),
426
+ jnp.stack([cell_metric.g12, cell_metric.g22, cell_metric.g23], axis=-1),
427
+ jnp.stack([cell_metric.g13, cell_metric.g23, cell_metric.g33], axis=-1),
428
+ ],
429
+ axis=-2,
430
+ )
431
+ b = jnp.asarray(cell_bfield.B_contra, dtype=jnp.float64)
432
+ bmag = jnp.maximum(jnp.asarray(cell_bfield.Bmag, dtype=jnp.float64), float(b_floor))
433
+ b_unit = b / bmag[..., None]
434
+ projector = g - jnp.einsum("...i,...j->...ij", b_unit, b_unit)
435
+ return jnp.einsum("...ij,...j->...i", projector, df)
436
+
437
+
438
+ def local_grad_perp_op_direct(
439
+ stencil: LocalStencil3D,
440
+ geometry: LocalFciGeometry3D,
441
+ *,
442
+ b_floor: float = 1.0e-30,
443
+ ) -> jnp.ndarray:
444
+ """Local/domain-decomposed direct finite-difference perpendicular gradient.
445
+
446
+ Computes the contravariant components of the perpendicular gradient:
447
+
448
+ grad_perp(f)^i = P^{ij} partial_j f
449
+
450
+ where:
451
+
452
+ P^{ij} = g^{ij} - b^i b^j
453
+
454
+ and ``b^i = B^i / |B|``. The stencil and all geometry used in the
455
+ contraction are owned-shaped; halo exchange and boundary preparation are
456
+ expected to have happened before this operator is called.
457
+
458
+ Returns:
459
+ An owned-cell array with shape ``geometry.owned_shape + (3,)``.
460
+ """
461
+
462
+ if not isinstance(geometry, LocalFciGeometry3D):
463
+ raise TypeError(
464
+ "local_grad_perp_op_direct requires LocalFciGeometry3D, "
465
+ f"got {type(geometry).__name__}"
466
+ )
467
+
468
+ if stencil.shape != geometry.owned_shape:
469
+ raise ValueError(
470
+ f"stencil must have shape {geometry.owned_shape}, "
471
+ f"got {stencil.shape}"
472
+ )
473
+
474
+ # Coordinate partial derivatives on owned cells:
475
+ #
476
+ # df_j = partial_j f
477
+ #
478
+ dfdx = _take_stencil_finite_difference(stencil.x)
479
+ dfdy = _take_stencil_finite_difference(stencil.y)
480
+ dfdz = _take_stencil_finite_difference(stencil.z)
481
+ df = jnp.stack((dfdx, dfdy, dfdz), axis=-1)
482
+
483
+ # These properties explicitly select owned cells from the halo-padded
484
+ # local geometry and return the shapes required by the owned stencil.
485
+ g_contra = jnp.asarray(geometry.cell_metric.g_contra_owned, dtype=jnp.float64)
486
+ B_contra = jnp.asarray(
487
+ geometry.cell_bfield.B_contra_owned,
488
+ dtype=jnp.float64,
489
+ )
490
+ Bmag = jnp.asarray(geometry.cell_bfield.Bmag_owned, dtype=jnp.float64)
491
+
492
+ Bmag = jnp.maximum(Bmag, float(b_floor))
493
+ b_contra = B_contra / Bmag[..., None]
494
+
495
+ projector = g_contra - jnp.einsum(
496
+ "...i,...j->...ij",
497
+ b_contra,
498
+ b_contra,
499
+ )
500
+
501
+ return jnp.einsum("...ij,...j->...i", projector, df)
502
+
503
+
504
+ def local_perp_laplacian_local_op(
505
+ field_halo_full: jnp.ndarray,
506
+ geometry: LocalFciGeometry3D,
507
+ domain: LocalDomain3D,
508
+ *,
509
+ context: StencilBuilderContext,
510
+ field_stencil_builder: LocalStencilBuilder = build_local_stencil_from_field,
511
+ intermediate_stencil_builder: LocalStencilBuilder,
512
+ halo_exchange: HaloExchange3D,
513
+ topology_filler: TopologyHaloFiller3D,
514
+ b_floor: float = 1.0e-30,
515
+ jacobian_floor: float = 1.0e-30,
516
+ ) -> jnp.ndarray:
517
+ """Compute the domain-decomposed pointwise perpendicular Laplacian.
518
+
519
+ This evaluates
520
+
521
+ ``(1 / J) partial_i (J P^{ij} partial_j f)``
522
+
523
+ using owned-cell coordinate stencils. The intermediate contravariant flux
524
+ ``F^i = J P^{ij} partial_j f`` is injected as one vector-valued halo field,
525
+ so all three components pass through one halo exchange and one topology
526
+ filler call. The intermediate stencil builder is responsible for the
527
+ physical-boundary closure of each scalar component (for example, the
528
+ one-sided builder used by the chained parallel Laplacian).
529
+
530
+ This is a pointwise/local reconstruction operator, not the conservative
531
+ face-flux finite-volume perpendicular Laplacian.
532
+ """
533
+
534
+ if not isinstance(geometry, LocalFciGeometry3D):
535
+ raise TypeError(
536
+ "local_perp_laplacian_local_op requires LocalFciGeometry3D, "
537
+ f"got {type(geometry).__name__}"
538
+ )
539
+ if not isinstance(domain, LocalDomain3D):
540
+ raise TypeError(
541
+ "local_perp_laplacian_local_op requires LocalDomain3D, "
542
+ f"got {type(domain).__name__}"
543
+ )
544
+ if not isinstance(context, StencilBuilderContext):
545
+ raise TypeError(
546
+ "context must be a StencilBuilderContext, "
547
+ f"got {type(context).__name__}"
548
+ )
549
+ if not isinstance(field_stencil_builder, LocalStencilBuilder):
550
+ raise TypeError(
551
+ "field_stencil_builder must be a LocalStencilBuilder, "
552
+ f"got {type(field_stencil_builder).__name__}"
553
+ )
554
+ if not isinstance(intermediate_stencil_builder, LocalStencilBuilder):
555
+ raise TypeError(
556
+ "intermediate_stencil_builder must be a LocalStencilBuilder, "
557
+ f"got {type(intermediate_stencil_builder).__name__}"
558
+ )
559
+ if not isinstance(halo_exchange, HaloExchange3D):
560
+ raise TypeError(
561
+ "halo_exchange must be a HaloExchange3D, "
562
+ f"got {type(halo_exchange).__name__}"
563
+ )
564
+ if not isinstance(topology_filler, TopologyHaloFiller3D):
565
+ raise TypeError(
566
+ "topology_filler must be a TopologyHaloFiller3D, "
567
+ f"got {type(topology_filler).__name__}"
568
+ )
569
+ if geometry.layout != domain.layout:
570
+ raise ValueError("geometry and domain must share the same HaloLayout3D")
571
+ if context.layout != geometry.layout:
572
+ raise ValueError("geometry and context must share the same HaloLayout3D")
573
+ if context.domain is None:
574
+ raise ValueError("context.domain is required for local stencil builders")
575
+
576
+ field_halo_full = jnp.asarray(field_halo_full, dtype=jnp.float64)
577
+ if field_halo_full.shape != geometry.halo_shape:
578
+ raise ValueError(
579
+ "field_halo_full must match geometry.halo_shape; "
580
+ f"got {field_halo_full.shape}, expected {geometry.halo_shape}"
581
+ )
582
+
583
+ field_stencil = field_stencil_builder(
584
+ field_halo_full,
585
+ geometry,
586
+ context,
587
+ )
588
+ if field_stencil.shape != geometry.owned_shape:
589
+ raise ValueError(
590
+ "field_stencil must have owned-cell shape; "
591
+ f"got {field_stencil.shape}, expected {geometry.owned_shape}"
592
+ )
593
+
594
+ grad_f = local_grad_perp_op_direct(
595
+ field_stencil,
596
+ geometry,
597
+ b_floor=b_floor,
598
+ )
599
+ J_owned = jnp.asarray(geometry.cell_metric.J_owned, dtype=jnp.float64)
600
+ flux_owned = J_owned[..., None] * grad_f
601
+
602
+ # Keep the three components together through the communication stages.
603
+ # The scalar stencil builder is called only after the single vector halo
604
+ # exchange/topology pass, once for each component.
605
+ flux_halo = inject_owned_vector_field_to_halo(
606
+ flux_owned,
607
+ domain.layout,
608
+ )
609
+ flux_halo = halo_exchange(flux_halo, domain)
610
+ flux_halo = topology_filler(flux_halo, domain)
611
+
612
+ flux_stencils = tuple(
613
+ intermediate_stencil_builder(
614
+ flux_halo[..., component],
615
+ geometry,
616
+ context,
617
+ )
618
+ for component in range(3)
619
+ )
620
+ for component, flux_stencil in enumerate(flux_stencils):
621
+ if flux_stencil.shape != geometry.owned_shape:
622
+ raise ValueError(
623
+ f"flux_{component}_stencil must have owned-cell shape; "
624
+ f"got {flux_stencil.shape}, expected {geometry.owned_shape}"
625
+ )
626
+
627
+ div_flux = (
628
+ _take_stencil_finite_difference(flux_stencils[0].x)
629
+ + _take_stencil_finite_difference(flux_stencils[1].y)
630
+ + _take_stencil_finite_difference(flux_stencils[2].z)
631
+ )
632
+ return div_flux / jnp.maximum(J_owned, float(jacobian_floor))
633
+
634
+
635
+ def perp_laplacian_local_op(
636
+ stencil: LocalStencil3D,
637
+ geometry: FciGeometry3D,
638
+ *,
639
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
640
+ b_floor: float = 1.0e-30,
641
+ jacobian_floor: float = 1.0e-30,
642
+ ) -> jnp.ndarray:
643
+ """Return a pointwise local approximation of the perpendicular Laplacian.
644
+
645
+ This operator stays entirely on the cell-centered reconstruction layer:
646
+ it uses ``LocalStencil3D`` to recover the field gradient, projects that
647
+ gradient with the cell-centered perpendicular projector, forms a
648
+ cell-centered flux, and then takes a standard logical divergence.
649
+
650
+ Unlike ``perp_laplacian_conservative_op``, this path does not consume
651
+ face-flux payloads or any cut-wall geometry.
652
+ """
653
+
654
+ if stencil.shape != geometry.shape:
655
+ raise ValueError(f"stencil must have shape {geometry.shape}, got {stencil.shape}")
656
+
657
+ periodic_axes = tuple(bool(value) for value in periodic_axes)
658
+
659
+ dfdx = _take_stencil_finite_difference(stencil.x)
660
+ dfdy = _take_stencil_finite_difference(stencil.y)
661
+ dfdz = _take_stencil_finite_difference(stencil.z)
662
+ grad_f = jnp.stack((dfdx, dfdy, dfdz), axis=-1)
663
+
664
+ cell_metric = geometry.cell_metric
665
+ cell_bfield = geometry.cell_bfield
666
+ projector = cell_metric.g_contra
667
+ b = jnp.asarray(cell_bfield.B_contra, dtype=jnp.float64)
668
+ bmag = jnp.maximum(jnp.asarray(cell_bfield.Bmag, dtype=jnp.float64), float(b_floor))
669
+ b_unit = b / bmag[..., None]
670
+ projector = projector - jnp.einsum("...i,...j->...ij", b_unit, b_unit)
671
+
672
+ j = jnp.asarray(cell_metric.J, dtype=jnp.float64)
673
+ flux = j[..., None] * jnp.einsum("...ij,...j->...i", projector, grad_f)
674
+ div_flux = (
675
+ _first_derivative_3d(flux[..., 0], geometry.spacing.dx, axis=0, periodic=periodic_axes[0])
676
+ + _first_derivative_3d(flux[..., 1], geometry.spacing.dy, axis=1, periodic=periodic_axes[1])
677
+ + _first_derivative_3d(flux[..., 2], geometry.spacing.dz, axis=2, periodic=periodic_axes[2])
678
+ )
679
+ return div_flux / jnp.maximum(j, float(jacobian_floor))
680
+
681
+
682
+ def poisson_bracket_op(
683
+ f_stencil: LocalStencil3D,
684
+ g_stencil: LocalStencil3D,
685
+ geometry: FciGeometry3D,
686
+ *,
687
+ b_floor: float = 1.0e-30,
688
+ jacobian_floor: float = 1.0e-30,
689
+ ) -> jnp.ndarray:
690
+ """Return the logical Poisson bracket from local stencils."""
691
+
692
+ metric = geometry.cell_metric
693
+ cell_bfield = geometry.cell_bfield
694
+ if f_stencil.shape != geometry.shape:
695
+ raise ValueError(f"f_stencil must have shape {geometry.shape}, got {f_stencil.shape}")
696
+ if g_stencil.shape != geometry.shape:
697
+ raise ValueError(f"g_stencil must have shape {geometry.shape}, got {g_stencil.shape}")
698
+
699
+ df = jnp.stack(
700
+ (
701
+ _take_stencil_finite_difference(f_stencil.x),
702
+ _take_stencil_finite_difference(f_stencil.y),
703
+ _take_stencil_finite_difference(f_stencil.z),
704
+ ),
705
+ axis=-1,
706
+ )
707
+ dg = jnp.stack(
708
+ (
709
+ _take_stencil_finite_difference(g_stencil.x),
710
+ _take_stencil_finite_difference(g_stencil.y),
711
+ _take_stencil_finite_difference(g_stencil.z),
712
+ ),
713
+ axis=-1,
714
+ )
715
+
716
+ g_cov = metric.g_cov
717
+ b = jnp.asarray(cell_bfield.B_contra, dtype=jnp.float64)
718
+ bmag = jnp.maximum(jnp.asarray(cell_bfield.Bmag, dtype=jnp.float64), float(b_floor))
719
+ b_unit = b / bmag[..., None]
720
+ b_covariant = jnp.einsum("...ij,...j->...i", g_cov, b_unit)
721
+ cross = jnp.cross(df, dg)
722
+ return jnp.sum(b_covariant * cross, axis=-1) / jnp.maximum(
723
+ jnp.asarray(metric.J, dtype=jnp.float64),
724
+ float(jacobian_floor),
725
+ )
726
+
727
+
728
+ def local_poisson_bracket_op(
729
+ f_stencil: LocalStencil3D,
730
+ g_stencil: LocalStencil3D,
731
+ geometry: LocalFciGeometry3D,
732
+ *,
733
+ b_floor: float = 1.0e-30,
734
+ jacobian_floor: float = 1.0e-30,
735
+ ) -> jnp.ndarray:
736
+ """Compute the owned-cell logical Poisson bracket.
737
+
738
+ The input stencils are assumed to be complete local stencils: their
739
+ builders own halo exchange, topology filling, physical-boundary closure,
740
+ and any cut-wall treatment. This operator only evaluates the owned-cell
741
+ algebra using local geometry.
742
+
743
+ The bracket is
744
+
745
+ ``{f, g} = (1 / J) b_i epsilon^{ijk} partial_j f partial_k g``.
746
+ """
747
+
748
+ if not isinstance(geometry, LocalFciGeometry3D):
749
+ raise TypeError(
750
+ "local_poisson_bracket_op requires LocalFciGeometry3D, "
751
+ f"got {type(geometry).__name__}"
752
+ )
753
+ if not isinstance(f_stencil, LocalStencil3D):
754
+ raise TypeError(
755
+ "f_stencil must be a LocalStencil3D, "
756
+ f"got {type(f_stencil).__name__}"
757
+ )
758
+ if not isinstance(g_stencil, LocalStencil3D):
759
+ raise TypeError(
760
+ "g_stencil must be a LocalStencil3D, "
761
+ f"got {type(g_stencil).__name__}"
762
+ )
763
+ if f_stencil.shape != geometry.owned_shape:
764
+ raise ValueError(
765
+ f"f_stencil must have shape {geometry.owned_shape}, "
766
+ f"got {f_stencil.shape}"
767
+ )
768
+ if g_stencil.shape != geometry.owned_shape:
769
+ raise ValueError(
770
+ f"g_stencil must have shape {geometry.owned_shape}, "
771
+ f"got {g_stencil.shape}"
772
+ )
773
+
774
+ df = jnp.stack(
775
+ (
776
+ _take_stencil_finite_difference(f_stencil.x),
777
+ _take_stencil_finite_difference(f_stencil.y),
778
+ _take_stencil_finite_difference(f_stencil.z),
779
+ ),
780
+ axis=-1,
781
+ )
782
+ dg = jnp.stack(
783
+ (
784
+ _take_stencil_finite_difference(g_stencil.x),
785
+ _take_stencil_finite_difference(g_stencil.y),
786
+ _take_stencil_finite_difference(g_stencil.z),
787
+ ),
788
+ axis=-1,
789
+ )
790
+
791
+ g_cov = jnp.asarray(geometry.cell_metric.g_cov_owned, dtype=jnp.float64)
792
+ B_contra = jnp.asarray(
793
+ geometry.cell_bfield.B_contra_owned,
794
+ dtype=jnp.float64,
795
+ )
796
+ Bmag = jnp.asarray(geometry.cell_bfield.Bmag_owned, dtype=jnp.float64)
797
+ Bmag = jnp.maximum(Bmag, float(b_floor))
798
+
799
+ b_contra = B_contra / Bmag[..., None]
800
+ b_covariant = jnp.einsum(
801
+ "...ij,...j->...i",
802
+ g_cov,
803
+ b_contra,
804
+ )
805
+ cross = jnp.cross(df, dg, axis=-1)
806
+ J_owned = jnp.asarray(geometry.cell_metric.J_owned, dtype=jnp.float64)
807
+
808
+ return jnp.sum(b_covariant * cross, axis=-1) / jnp.maximum(
809
+ J_owned,
810
+ float(jacobian_floor),
811
+ )
812
+
813
+
814
+ def curvature_op(
815
+ stencil: LocalStencil3D,
816
+ geometry: FciGeometry3D,
817
+ *,
818
+ curvature_coefficients: jnp.ndarray,
819
+ ) -> jnp.ndarray:
820
+ """Return the curvature operator applied to a local scalar-field stencil."""
821
+
822
+ if stencil.shape != geometry.shape:
823
+ raise ValueError(f"stencil must have shape {geometry.shape}, got {stencil.shape}")
824
+ if curvature_coefficients.shape != geometry.shape + (3,):
825
+ raise ValueError(
826
+ f"curvature_coefficients must have shape {geometry.shape + (3,)}, got {curvature_coefficients.shape}"
827
+ )
828
+ dfdx = _take_stencil_finite_difference(stencil.x)
829
+ dfdy = _take_stencil_finite_difference(stencil.y)
830
+ dfdz = _take_stencil_finite_difference(stencil.z)
831
+ grad_f = jnp.stack((dfdx, dfdy, dfdz), axis=-1)
832
+ return jnp.einsum("...i,...i->...", jnp.asarray(curvature_coefficients, dtype=jnp.float64), grad_f)
833
+
834
+
835
+ def local_curvature_op(
836
+ stencil: LocalStencil3D,
837
+ geometry: LocalFciGeometry3D,
838
+ *,
839
+ curvature_coefficients: jnp.ndarray,
840
+ ) -> jnp.ndarray:
841
+ """Apply curvature coefficients to an owned local scalar-field stencil.
842
+
843
+ ``curvature_coefficients`` is an owned-cell vector field with shape
844
+ ``geometry.owned_shape + (3,)``. Halo-shaped coefficient fields must be
845
+ sliced to the owned region before calling this operator.
846
+ """
847
+
848
+ if not isinstance(geometry, LocalFciGeometry3D):
849
+ raise TypeError(
850
+ "local_curvature_op requires LocalFciGeometry3D, "
851
+ f"got {type(geometry).__name__}"
852
+ )
853
+ if not isinstance(stencil, LocalStencil3D):
854
+ raise TypeError(
855
+ "stencil must be a LocalStencil3D, "
856
+ f"got {type(stencil).__name__}"
857
+ )
858
+ if stencil.shape != geometry.owned_shape:
859
+ raise ValueError(
860
+ f"stencil must have shape {geometry.owned_shape}, "
861
+ f"got {stencil.shape}"
862
+ )
863
+
864
+ curvature_coefficients = jnp.asarray(
865
+ curvature_coefficients,
866
+ dtype=jnp.float64,
867
+ )
868
+ expected_coefficients_shape = geometry.owned_shape + (3,)
869
+ if curvature_coefficients.shape != expected_coefficients_shape:
870
+ raise ValueError(
871
+ "curvature_coefficients must have owned-cell shape "
872
+ f"{expected_coefficients_shape}, got {curvature_coefficients.shape}"
873
+ )
874
+
875
+ grad_f = jnp.stack(
876
+ (
877
+ _take_stencil_finite_difference(stencil.x),
878
+ _take_stencil_finite_difference(stencil.y),
879
+ _take_stencil_finite_difference(stencil.z),
880
+ ),
881
+ axis=-1,
882
+ )
883
+ return jnp.einsum(
884
+ "...i,...i->...",
885
+ curvature_coefficients,
886
+ grad_f,
887
+ )
888
+
889
+
890
+ def _build_laplacian_face_projectors(
891
+ geometry: FciGeometry3D,
892
+ *,
893
+ b_floor: float = 1.0e-30,
894
+ parallel: bool,
895
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
896
+ ) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
897
+ """Build the geometry-only face projectors for projected Laplacians."""
898
+
899
+ b_floor_value = float(b_floor)
900
+ axis_regular_axes = tuple(bool(value) for value in axis_regular_axes)
901
+ if axis_regular_axes[1] or axis_regular_axes[2]:
902
+ raise NotImplementedError(
903
+ "axis_regular_axes currently only supports the lower x axis for face projectors; "
904
+ f"got axis_regular_axes={axis_regular_axes}"
905
+ )
906
+
907
+ def _axis_regularize_lower_x_face(projector: jnp.ndarray) -> jnp.ndarray:
908
+ if not axis_regular_axes[0]:
909
+ return projector
910
+ if projector.shape[1] % 2 != 0:
911
+ raise ValueError("axis-regular lower-x face projectors require an even poloidal grid")
912
+ return projector.at[0].set(jnp.zeros_like(projector[0]))
913
+
914
+ def _face_projector(metric, bfield, *, family_axis: int) -> jnp.ndarray:
915
+ b_contra = jnp.asarray(bfield.B_contra, dtype=jnp.float64)
916
+ b_contra = jnp.where(jnp.isfinite(b_contra), b_contra, 0.0)
917
+ bmag = jnp.asarray(bfield.Bmag, dtype=jnp.float64)
918
+ bmag = jnp.where(jnp.isfinite(bmag), bmag, b_floor_value)
919
+ b = b_contra / jnp.maximum(bmag[..., None], b_floor_value)
920
+ projector = jnp.einsum("...i,...j->...ij", b, b)
921
+ if not parallel:
922
+ projector = jnp.asarray(metric.g_contra, dtype=jnp.float64) - projector
923
+ if family_axis == 0:
924
+ projector = _axis_regularize_lower_x_face(projector)
925
+ return jnp.where(jnp.isfinite(projector), projector, 0.0)
926
+
927
+ return (
928
+ _face_projector(geometry.face_metric.x, geometry.face_bfield.x, family_axis=0),
929
+ _face_projector(geometry.face_metric.y, geometry.face_bfield.y, family_axis=1),
930
+ _face_projector(geometry.face_metric.z, geometry.face_bfield.z, family_axis=2),
931
+ )
932
+
933
+
934
+ def build_perp_laplacian_face_projectors(
935
+ geometry: FciGeometry3D,
936
+ *,
937
+ b_floor: float = 1.0e-30,
938
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
939
+ ) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
940
+ """Build the geometry-only face projectors for the perpendicular Laplacian.
941
+
942
+ The returned tensors are the face-family analogs of ``P_face``:
943
+ ``g_contra - b_hat ⊗ b_hat`` evaluated on x-, y-, and z-face grids.
944
+ """
945
+
946
+ return _build_laplacian_face_projectors(
947
+ geometry,
948
+ b_floor=b_floor,
949
+ parallel=False,
950
+ axis_regular_axes=axis_regular_axes,
951
+ )
952
+
953
+
954
+ def _build_local_laplacian_face_projectors(
955
+ geometry: LocalFciGeometry3D,
956
+ domain: LocalDomain3D,
957
+ *,
958
+ b_floor: float = 1.0e-30,
959
+ parallel: bool,
960
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
961
+ ) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
962
+ """Build owned-face projectors for local projected Laplacians."""
963
+
964
+ if not isinstance(geometry, LocalFciGeometry3D):
965
+ raise TypeError(
966
+ "_build_local_laplacian_face_projectors requires "
967
+ f"LocalFciGeometry3D, got {type(geometry).__name__}"
968
+ )
969
+ if not isinstance(domain, LocalDomain3D):
970
+ raise TypeError(
971
+ "_build_local_laplacian_face_projectors requires "
972
+ f"LocalDomain3D, got {type(domain).__name__}"
973
+ )
974
+ if geometry.layout != domain.layout:
975
+ raise ValueError("geometry and domain must share the same HaloLayout3D")
976
+
977
+ axis_regular_axes = tuple(bool(value) for value in axis_regular_axes)
978
+ if len(axis_regular_axes) != 3:
979
+ raise ValueError("axis_regular_axes must have length 3")
980
+ if axis_regular_axes[1] or axis_regular_axes[2]:
981
+ raise NotImplementedError(
982
+ "axis_regular_axes currently only supports the lower x axis; "
983
+ f"got axis_regular_axes={axis_regular_axes}"
984
+ )
985
+
986
+ b_floor_value = float(b_floor)
987
+ if b_floor_value < 0.0:
988
+ raise ValueError(f"b_floor must be nonnegative, got {b_floor}")
989
+
990
+ face_locations = ("x_face", "y_face", "z_face")
991
+ expected_face_shapes = tuple(
992
+ domain.layout.location_owned_shape(location)
993
+ for location in face_locations
994
+ )
995
+
996
+ def _face_projector(metric, bfield, *, family_axis: int) -> jnp.ndarray:
997
+ g_contra = jnp.asarray(metric.g_contra_owned, dtype=jnp.float64)
998
+ B_contra = jnp.asarray(bfield.B_contra_owned, dtype=jnp.float64)
999
+ Bmag = jnp.asarray(bfield.Bmag_owned, dtype=jnp.float64)
1000
+
1001
+ B_contra = jnp.where(jnp.isfinite(B_contra), B_contra, 0.0)
1002
+ Bmag = jnp.where(jnp.isfinite(Bmag), Bmag, b_floor_value)
1003
+ b = B_contra / jnp.maximum(Bmag[..., None], b_floor_value)
1004
+
1005
+ projector = jnp.einsum("...i,...j->...ij", b, b)
1006
+ if not bool(parallel):
1007
+ projector = g_contra - projector
1008
+ projector = jnp.where(jnp.isfinite(projector), projector, 0.0)
1009
+
1010
+ # The x-face owned index 0 is the global lower-x face only on the
1011
+ # shard that touches that side. Other shards also have a local index 0,
1012
+ # but that face is an internal shard interface and must not be zeroed.
1013
+ if family_axis == 0 and axis_regular_axes[0]:
1014
+ do_axis_lower = domain.runtime_has_axis_regular_lower(0)
1015
+ lower = jnp.where(
1016
+ do_axis_lower,
1017
+ jnp.zeros_like(projector[0]),
1018
+ projector[0],
1019
+ )
1020
+ projector = projector.at[0].set(lower)
1021
+
1022
+ expected_shape = expected_face_shapes[family_axis] + (3, 3)
1023
+ if projector.shape != expected_shape:
1024
+ raise ValueError(
1025
+ f"local face projector for {face_locations[family_axis]} must "
1026
+ f"have shape {expected_shape}, got {projector.shape}"
1027
+ )
1028
+ return projector
1029
+
1030
+ return (
1031
+ _face_projector(
1032
+ geometry.face_metric.x,
1033
+ geometry.face_bfield.x,
1034
+ family_axis=0,
1035
+ ),
1036
+ _face_projector(
1037
+ geometry.face_metric.y,
1038
+ geometry.face_bfield.y,
1039
+ family_axis=1,
1040
+ ),
1041
+ _face_projector(
1042
+ geometry.face_metric.z,
1043
+ geometry.face_bfield.z,
1044
+ family_axis=2,
1045
+ ),
1046
+ )
1047
+
1048
+
1049
+ def build_local_perp_laplacian_face_projectors(
1050
+ geometry: LocalFciGeometry3D,
1051
+ domain: LocalDomain3D,
1052
+ *,
1053
+ b_floor: float = 1.0e-30,
1054
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
1055
+ ) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
1056
+ """Build owned-face projectors for the local perpendicular Laplacian."""
1057
+
1058
+ return _build_local_laplacian_face_projectors(
1059
+ geometry,
1060
+ domain,
1061
+ b_floor=b_floor,
1062
+ parallel=False,
1063
+ axis_regular_axes=axis_regular_axes,
1064
+ )
1065
+
1066
+
1067
+ def _build_projected_laplacian_stencil(
1068
+ local: ConservativeStencil3D,
1069
+ geometry: FciGeometry3D,
1070
+ *,
1071
+ face_projectors: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray] | None = None,
1072
+ face_bc: BoundaryFaceBC3D | None = None,
1073
+ regular_face_geometry: RegularFaceGeometry3D | None = None,
1074
+ cut_wall_geometry: CutWallGeometry3D | None = None,
1075
+ cut_wall_bc: CutWallBC3D | None = None,
1076
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
1077
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
1078
+ b_floor: float = 1.0e-30,
1079
+ ) -> LocalControlVolumeFluxStencil3D:
1080
+ """Build a boundary-complete control-volume flux stencil for a projected Laplacian.
1081
+
1082
+ ``ConservativeStencil3D`` is the conservative-operator input type.
1083
+ It carries the cell-based reconstruction data needed to build regular
1084
+ coordinate-face fluxes, but it is intentionally separate from
1085
+ ``LocalStencil3D`` so local and conservative semantics cannot be mixed
1086
+ accidentally.
1087
+
1088
+ Regular coordinate-face boundary conditions are applied while constructing
1089
+ the face fluxes. Cut-wall payloads are assembled here so the divergence
1090
+ kernel only performs flux balance.
1091
+ """
1092
+
1093
+ if local.shape != geometry.shape:
1094
+ raise ValueError(f"local stencil must have shape {geometry.shape}, got {local.shape}")
1095
+
1096
+ periodic_axes = tuple(bool(value) for value in periodic_axes)
1097
+ axis_regular_axes = tuple(bool(value) for value in axis_regular_axes)
1098
+ regular_face_geometry = regular_face_geometry or RegularFaceGeometry3D.unit(geometry)
1099
+ cell_volume = CellVolumeGeometry3D.unit(geometry)
1100
+ if face_bc is None:
1101
+ face_bc = BoundaryFaceBC3D.empty(regular_face_geometry)
1102
+ cut_wall_geometry = cut_wall_geometry or CutWallGeometry3D.empty()
1103
+ cut_wall_bc = cut_wall_bc or CutWallBC3D.empty()
1104
+ if face_projectors is None:
1105
+ face_projectors = build_perp_laplacian_face_projectors(geometry, b_floor=b_floor)
1106
+ x_face_projector, y_face_projector, z_face_projector = face_projectors
1107
+
1108
+ values = jnp.asarray(local.x.center, dtype=jnp.float64)
1109
+ x_face_grad = jnp.asarray(local.face_grad.x, dtype=jnp.float64)
1110
+ y_face_grad = jnp.asarray(local.face_grad.y, dtype=jnp.float64)
1111
+ z_face_grad = jnp.asarray(local.face_grad.z, dtype=jnp.float64)
1112
+
1113
+ def _require_face_shape(value: jnp.ndarray, expected_shape: tuple[int, ...], name: str) -> None:
1114
+ if value.shape != expected_shape:
1115
+ raise ValueError(f"{name} must have shape {expected_shape}, got {value.shape}")
1116
+
1117
+ _require_face_shape(x_face_grad, regular_face_geometry.x_area.shape + (3,), "x_face_grad")
1118
+ _require_face_shape(y_face_grad, regular_face_geometry.y_area.shape + (3,), "y_face_grad")
1119
+ _require_face_shape(z_face_grad, regular_face_geometry.z_area.shape + (3,), "z_face_grad")
1120
+
1121
+ def _patch_axis_face_gradients(
1122
+ face_grad: jnp.ndarray,
1123
+ *,
1124
+ axis: int,
1125
+ axis_kind: jnp.ndarray,
1126
+ axis_value: jnp.ndarray,
1127
+ axis_mask: jnp.ndarray,
1128
+ ) -> jnp.ndarray:
1129
+ if periodic_axes[axis]:
1130
+ return face_grad
1131
+
1132
+ face_grad = jnp.asarray(face_grad, dtype=jnp.float64)
1133
+ kind = jnp.asarray(axis_kind, dtype=jnp.int32)
1134
+ value = jnp.asarray(axis_value, dtype=jnp.float64)
1135
+ mask = jnp.asarray(axis_mask, dtype=bool)
1136
+
1137
+ if axis == 0:
1138
+ lower_value = value[0]
1139
+ upper_value = value[-1]
1140
+ lower_kind = kind[0]
1141
+ upper_kind = kind[-1]
1142
+ lower_mask = mask[0]
1143
+ upper_mask = mask[-1]
1144
+ lower_distance = jnp.asarray(geometry.grid.x.lower_center_to_face, dtype=jnp.float64)
1145
+ upper_distance = jnp.asarray(geometry.grid.x.upper_center_to_face, dtype=jnp.float64)
1146
+ lower_center = values[0]
1147
+ upper_center = values[-1]
1148
+ elif axis == 1:
1149
+ lower_value = value[:, 0, :]
1150
+ upper_value = value[:, -1, :]
1151
+ lower_kind = kind[:, 0, :]
1152
+ upper_kind = kind[:, -1, :]
1153
+ lower_mask = mask[:, 0, :]
1154
+ upper_mask = mask[:, -1, :]
1155
+ lower_distance = jnp.asarray(geometry.grid.y.lower_center_to_face, dtype=jnp.float64)
1156
+ upper_distance = jnp.asarray(geometry.grid.y.upper_center_to_face, dtype=jnp.float64)
1157
+ lower_center = values[:, 0, :]
1158
+ upper_center = values[:, -1, :]
1159
+ else:
1160
+ lower_value = value[:, :, 0]
1161
+ upper_value = value[:, :, -1]
1162
+ lower_kind = kind[:, :, 0]
1163
+ upper_kind = kind[:, :, -1]
1164
+ lower_mask = mask[:, :, 0]
1165
+ upper_mask = mask[:, :, -1]
1166
+ lower_distance = jnp.asarray(geometry.grid.z.lower_center_to_face, dtype=jnp.float64)
1167
+ upper_distance = jnp.asarray(geometry.grid.z.upper_center_to_face, dtype=jnp.float64)
1168
+ lower_center = values[:, :, 0]
1169
+ upper_center = values[:, :, -1]
1170
+
1171
+ lower_kind_mask = lower_mask & (lower_kind == BC_DIRICHLET)
1172
+ upper_kind_mask = upper_mask & (upper_kind == BC_DIRICHLET)
1173
+ lower_plane = face_grad[_axis_index_nd(axis, 0, face_grad.ndim)]
1174
+ lower_normal = lower_plane[..., axis]
1175
+ lower_coord = (lower_center - lower_value) / jnp.maximum(lower_distance, 1.0e-30)
1176
+ if not (axis == 0 and axis_regular_axes[0]):
1177
+ lower_plane = lower_plane.at[..., axis].set(jnp.where(lower_kind_mask, lower_coord, lower_normal))
1178
+ face_grad = face_grad.at[_axis_index_nd(axis, 0, face_grad.ndim)].set(lower_plane)
1179
+
1180
+ upper_plane = face_grad[_axis_index_nd(axis, -1, face_grad.ndim)]
1181
+ upper_normal = upper_plane[..., axis]
1182
+ upper_coord = (upper_value - upper_center) / jnp.maximum(upper_distance, 1.0e-30)
1183
+ upper_plane = upper_plane.at[..., axis].set(jnp.where(upper_kind_mask, upper_coord, upper_normal))
1184
+ face_grad = face_grad.at[_axis_index_nd(axis, -1, face_grad.ndim)].set(upper_plane)
1185
+
1186
+ lower_kind_mask = lower_mask & (lower_kind == BC_NEUMANN)
1187
+ upper_kind_mask = upper_mask & (upper_kind == BC_NEUMANN)
1188
+ if not (axis == 0 and axis_regular_axes[0]):
1189
+ lower_plane = face_grad[_axis_index_nd(axis, 0, face_grad.ndim)]
1190
+ lower_normal = lower_plane[..., axis]
1191
+ lower_plane = lower_plane.at[..., axis].set(jnp.where(lower_kind_mask, -lower_value, lower_normal))
1192
+ face_grad = face_grad.at[_axis_index_nd(axis, 0, face_grad.ndim)].set(lower_plane)
1193
+
1194
+ upper_plane = face_grad[_axis_index_nd(axis, -1, face_grad.ndim)]
1195
+ upper_normal = upper_plane[..., axis]
1196
+ upper_plane = upper_plane.at[..., axis].set(jnp.where(upper_kind_mask, upper_value, upper_normal))
1197
+ face_grad = face_grad.at[_axis_index_nd(axis, -1, face_grad.ndim)].set(upper_plane)
1198
+
1199
+ return face_grad
1200
+
1201
+ x_face_grad = _patch_axis_face_gradients(
1202
+ x_face_grad,
1203
+ axis=0,
1204
+ axis_kind=face_bc.kind_x,
1205
+ axis_value=face_bc.value_x,
1206
+ axis_mask=face_bc.mask_x,
1207
+ )
1208
+ y_face_grad = _patch_axis_face_gradients(
1209
+ y_face_grad,
1210
+ axis=1,
1211
+ axis_kind=face_bc.kind_y,
1212
+ axis_value=face_bc.value_y,
1213
+ axis_mask=face_bc.mask_y,
1214
+ )
1215
+ z_face_grad = _patch_axis_face_gradients(
1216
+ z_face_grad,
1217
+ axis=2,
1218
+ axis_kind=face_bc.kind_z,
1219
+ axis_value=face_bc.value_z,
1220
+ axis_mask=face_bc.mask_z,
1221
+ )
1222
+
1223
+ x_face_metric = geometry.face_metric.x
1224
+ y_face_metric = geometry.face_metric.y
1225
+ z_face_metric = geometry.face_metric.z
1226
+
1227
+ x_flux = jnp.asarray(x_face_metric.J, dtype=jnp.float64) * jnp.einsum(
1228
+ "...j,...j->...", x_face_projector[..., 0, :], x_face_grad
1229
+ )
1230
+ if axis_regular_axes[0]:
1231
+ x_flux = x_flux.at[0].set(jnp.zeros_like(x_flux[0]))
1232
+ y_flux = jnp.asarray(y_face_metric.J, dtype=jnp.float64) * jnp.einsum(
1233
+ "...j,...j->...", y_face_projector[..., 1, :], y_face_grad
1234
+ )
1235
+ z_flux = jnp.asarray(z_face_metric.J, dtype=jnp.float64) * jnp.einsum(
1236
+ "...j,...j->...", z_face_projector[..., 2, :], z_face_grad
1237
+ )
1238
+
1239
+ def _apply_face_flux_bc(flux: jnp.ndarray, *, axis: int, axis_kind: jnp.ndarray, axis_value: jnp.ndarray, axis_mask: jnp.ndarray) -> jnp.ndarray:
1240
+ if periodic_axes[axis]:
1241
+ return flux
1242
+ result = jnp.asarray(flux, dtype=jnp.float64)
1243
+ if axis == 0:
1244
+ lower_kind = axis_kind[0]
1245
+ upper_kind = axis_kind[-1]
1246
+ lower_value = axis_value[0]
1247
+ upper_value = axis_value[-1]
1248
+ lower_mask = axis_mask[0]
1249
+ upper_mask = axis_mask[-1]
1250
+ elif axis == 1:
1251
+ lower_kind = axis_kind[:, 0, :]
1252
+ upper_kind = axis_kind[:, -1, :]
1253
+ lower_value = axis_value[:, 0, :]
1254
+ upper_value = axis_value[:, -1, :]
1255
+ lower_mask = axis_mask[:, 0, :]
1256
+ upper_mask = axis_mask[:, -1, :]
1257
+ else:
1258
+ lower_kind = axis_kind[:, :, 0]
1259
+ upper_kind = axis_kind[:, :, -1]
1260
+ lower_value = axis_value[:, :, 0]
1261
+ upper_value = axis_value[:, :, -1]
1262
+ lower_mask = axis_mask[:, :, 0]
1263
+ upper_mask = axis_mask[:, :, -1]
1264
+
1265
+ if not (axis == 0 and axis_regular_axes[0]):
1266
+ lower_plane = result[_axis_index_nd(axis, 0, result.ndim)]
1267
+ lower_plane = jnp.where(lower_mask & (lower_kind == BC_NORMALFLUX), lower_value, lower_plane)
1268
+ lower_plane = jnp.where(lower_mask & (lower_kind == BC_NOFLUX), 0.0, lower_plane)
1269
+ result = result.at[_axis_index_nd(axis, 0, result.ndim)].set(lower_plane)
1270
+
1271
+ upper_plane = result[_axis_index_nd(axis, -1, result.ndim)]
1272
+ upper_plane = jnp.where(upper_mask & (upper_kind == BC_NORMALFLUX), upper_value, upper_plane)
1273
+ upper_plane = jnp.where(upper_mask & (upper_kind == BC_NOFLUX), 0.0, upper_plane)
1274
+ result = result.at[_axis_index_nd(axis, -1, result.ndim)].set(upper_plane)
1275
+ return result
1276
+
1277
+ x_flux = _apply_face_flux_bc(x_flux, axis=0, axis_kind=face_bc.kind_x, axis_value=face_bc.value_x, axis_mask=face_bc.mask_x)
1278
+ y_flux = _apply_face_flux_bc(y_flux, axis=1, axis_kind=face_bc.kind_y, axis_value=face_bc.value_y, axis_mask=face_bc.mask_y)
1279
+ z_flux = _apply_face_flux_bc(z_flux, axis=2, axis_kind=face_bc.kind_z, axis_value=face_bc.value_z, axis_mask=face_bc.mask_z)
1280
+
1281
+ cut_wall_flux = _build_cut_wall_flux_payload(
1282
+ local=local,
1283
+ cut_wall_geometry=cut_wall_geometry,
1284
+ cut_wall_bc=cut_wall_bc,
1285
+ b_floor=b_floor,
1286
+ )
1287
+
1288
+ return LocalControlVolumeFluxStencil3D(
1289
+ regular_flux=FaceFluxStencil3D(x=x_flux, y=y_flux, z=z_flux),
1290
+ regular_face_geometry=regular_face_geometry,
1291
+ cell_volume=cell_volume,
1292
+ cut_wall_geometry=cut_wall_geometry,
1293
+ cut_wall_flux=cut_wall_flux,
1294
+ )
1295
+
1296
+
1297
+ def build_perp_laplacian_stencil(
1298
+ local: ConservativeStencil3D,
1299
+ geometry: FciGeometry3D,
1300
+ *,
1301
+ face_projectors: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray] | None = None,
1302
+ face_bc: BoundaryFaceBC3D | None = None,
1303
+ regular_face_geometry: RegularFaceGeometry3D | None = None,
1304
+ cut_wall_geometry: CutWallGeometry3D | None = None,
1305
+ cut_wall_bc: CutWallBC3D | None = None,
1306
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
1307
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
1308
+ b_floor: float = 1.0e-30,
1309
+ ) -> LocalControlVolumeFluxStencil3D:
1310
+ """Build a boundary-complete control-volume flux stencil for ``-∇·(P⊥∇f)``."""
1311
+
1312
+ if face_projectors is None:
1313
+ face_projectors = build_perp_laplacian_face_projectors(
1314
+ geometry,
1315
+ b_floor=b_floor,
1316
+ axis_regular_axes=axis_regular_axes,
1317
+ )
1318
+ return _build_projected_laplacian_stencil(
1319
+ local,
1320
+ geometry,
1321
+ face_projectors=face_projectors,
1322
+ face_bc=face_bc,
1323
+ regular_face_geometry=regular_face_geometry,
1324
+ cut_wall_geometry=cut_wall_geometry,
1325
+ cut_wall_bc=cut_wall_bc,
1326
+ periodic_axes=periodic_axes,
1327
+ axis_regular_axes=axis_regular_axes,
1328
+ b_floor=b_floor,
1329
+ )
1330
+
1331
+
1332
+ def _build_cut_wall_flux_payload(
1333
+ *,
1334
+ local: ConservativeStencil3D,
1335
+ cut_wall_geometry: CutWallGeometry3D,
1336
+ cut_wall_bc: CutWallBC3D,
1337
+ b_floor: float = 1.0e-30,
1338
+ ) -> jnp.ndarray:
1339
+ """Build the embedded-wall flux payload for the conservative control volume.
1340
+
1341
+ The wall contribution is assembled here into an already-integrated flux-area
1342
+ term so the divergence kernel can remain a pure finite-volume balance.
1343
+ """
1344
+
1345
+ if not cut_wall_geometry.n_wall_faces:
1346
+ return jnp.zeros((0,), dtype=jnp.float64)
1347
+
1348
+ cut_wall_kind = jnp.asarray(cut_wall_bc.kind, dtype=jnp.int32)
1349
+ cut_wall_value = jnp.asarray(cut_wall_bc.value, dtype=jnp.float64)
1350
+ if cut_wall_kind.ndim != 1 or cut_wall_value.ndim != 1:
1351
+ raise ValueError("cut_wall_bc.kind and cut_wall_bc.value must be 1D")
1352
+ if cut_wall_kind.shape != cut_wall_value.shape:
1353
+ raise ValueError(
1354
+ f"cut_wall_bc.kind and cut_wall_bc.value must have the same shape, got {cut_wall_kind.shape} and {cut_wall_value.shape}"
1355
+ )
1356
+ if cut_wall_kind.size != cut_wall_geometry.n_wall_faces:
1357
+ raise ValueError(
1358
+ f"cut_wall_bc must have {cut_wall_geometry.n_wall_faces} entries, got {cut_wall_kind.size}"
1359
+ )
1360
+ supported = (
1361
+ (cut_wall_kind == BC_NONE)
1362
+ | (cut_wall_kind == BC_DIRICHLET)
1363
+ | (cut_wall_kind == BC_NEUMANN)
1364
+ | (cut_wall_kind == BC_NORMALFLUX)
1365
+ | (cut_wall_kind == BC_NOFLUX)
1366
+ )
1367
+ if not bool(jnp.all(supported)):
1368
+ raise NotImplementedError(
1369
+ "cut-wall BC kinds other than NONE, NOFLUX, and NORMALFLUX are not implemented"
1370
+ )
1371
+
1372
+ owner_i = jnp.asarray(cut_wall_geometry.owner_i, dtype=jnp.int32)
1373
+ owner_j = jnp.asarray(cut_wall_geometry.owner_j, dtype=jnp.int32)
1374
+ owner_k = jnp.asarray(cut_wall_geometry.owner_k, dtype=jnp.int32)
1375
+
1376
+ field = jnp.asarray(local.x.center, dtype=jnp.float64)
1377
+ dfdx_cell = _take_stencil_finite_difference(local.x)
1378
+ dfdy_cell = _take_stencil_finite_difference(local.y)
1379
+ dfdz_cell = _take_stencil_finite_difference(local.z)
1380
+
1381
+ grad_cell = jnp.stack(
1382
+ (
1383
+ dfdx_cell[owner_i, owner_j, owner_k],
1384
+ dfdy_cell[owner_i, owner_j, owner_k],
1385
+ dfdz_cell[owner_i, owner_j, owner_k],
1386
+ ),
1387
+ axis=-1,
1388
+ )
1389
+ f_cell = field[owner_i, owner_j, owner_k]
1390
+
1391
+ normal_contra = jnp.asarray(cut_wall_geometry.normal_contra, dtype=jnp.float64)
1392
+ normal_cov = jnp.einsum("...ij,...j->...i", jnp.asarray(cut_wall_geometry.g_cov, dtype=jnp.float64), normal_contra)
1393
+ g_cell = jnp.einsum("...i,...i->...", normal_contra, grad_cell)
1394
+ grad_tangent = grad_cell - g_cell[..., None] * normal_cov
1395
+
1396
+ distance = jnp.asarray(cut_wall_geometry.distance, dtype=jnp.float64)
1397
+ safe_distance = jnp.maximum(jnp.abs(distance), 1.0e-30)
1398
+ g_dirichlet = (cut_wall_value - f_cell) / safe_distance
1399
+ g_neumann = cut_wall_value
1400
+ g_wall = g_cell
1401
+ g_wall = jnp.where(cut_wall_kind == BC_DIRICHLET, g_dirichlet, g_wall)
1402
+ g_wall = jnp.where(cut_wall_kind == BC_NEUMANN, g_neumann, g_wall)
1403
+ grad_wall = grad_tangent + g_wall[..., None] * normal_cov
1404
+
1405
+ bmag = jnp.maximum(jnp.asarray(cut_wall_geometry.Bmag, dtype=jnp.float64), float(b_floor))
1406
+ b_wall = jnp.asarray(cut_wall_geometry.B_contra, dtype=jnp.float64) / bmag[..., None]
1407
+ projector = jnp.asarray(cut_wall_geometry.g_contra, dtype=jnp.float64) - jnp.einsum(
1408
+ "...i,...j->...ij",
1409
+ b_wall,
1410
+ b_wall,
1411
+ )
1412
+ wall_flux_area = jnp.asarray(cut_wall_geometry.J, dtype=jnp.float64) * jnp.einsum(
1413
+ "...i,...ij,...j->...",
1414
+ jnp.asarray(cut_wall_geometry.area_covector, dtype=jnp.float64),
1415
+ projector,
1416
+ grad_wall,
1417
+ )
1418
+ # For true cut walls, BC_NORMALFLUX is treated as an already integrated
1419
+ # wall flux-area contribution.
1420
+ wall_flux_area = jnp.where(cut_wall_kind == BC_NORMALFLUX, cut_wall_value, wall_flux_area)
1421
+ wall_flux_area = jnp.where(cut_wall_kind == BC_NOFLUX, 0.0, wall_flux_area)
1422
+
1423
+ sign = jnp.asarray(cut_wall_geometry.sign, dtype=jnp.float64)
1424
+ if sign.shape != wall_flux_area.shape:
1425
+ raise ValueError(
1426
+ f"cut_wall_geometry.sign must have shape {wall_flux_area.shape}, got {sign.shape}"
1427
+ )
1428
+ return sign * wall_flux_area
1429
+
1430
+
1431
+ def divergence_conservative_op(
1432
+ cv_flux: LocalControlVolumeFluxStencil3D,
1433
+ geometry: FciGeometry3D,
1434
+ *,
1435
+ jacobian_floor: float = 1.0e-30,
1436
+ ) -> jnp.ndarray:
1437
+ """Return the conservative divergence from a completed control-volume flux stencil."""
1438
+
1439
+ if cv_flux.shape != geometry.shape:
1440
+ raise ValueError(f"cv_flux must have shape {geometry.shape}, got {cv_flux.shape}")
1441
+
1442
+ def _divergence_from_face_flux(flux: jnp.ndarray, spacing: jnp.ndarray | float, *, axis: int, area: jnp.ndarray) -> jnp.ndarray:
1443
+ face_flux = jnp.asarray(flux, dtype=jnp.float64) * jnp.asarray(area, dtype=jnp.float64)
1444
+ h = jnp.asarray(spacing, dtype=jnp.float64)
1445
+ if h.ndim == 0:
1446
+ h = jnp.ones_like(face_flux[_axis_slice_nd(axis, 1, None, face_flux.ndim)]) * h
1447
+ h = jnp.maximum(h, 1.0e-30)
1448
+ return (
1449
+ face_flux[_axis_slice_nd(axis, 1, None, face_flux.ndim)]
1450
+ - face_flux[_axis_slice_nd(axis, None, -1, face_flux.ndim)]
1451
+ ) / h
1452
+
1453
+ div_flux = (
1454
+ _divergence_from_face_flux(
1455
+ cv_flux.regular_flux.x,
1456
+ geometry.spacing.dx,
1457
+ axis=0,
1458
+ area=
1459
+ cv_flux.regular_face_geometry.x_area
1460
+ * cv_flux.regular_face_geometry.x_area_fraction
1461
+ * cv_flux.regular_face_geometry.x_open_mask,
1462
+ )
1463
+ + _divergence_from_face_flux(
1464
+ cv_flux.regular_flux.y,
1465
+ geometry.spacing.dy,
1466
+ axis=1,
1467
+ area=
1468
+ cv_flux.regular_face_geometry.y_area
1469
+ * cv_flux.regular_face_geometry.y_area_fraction
1470
+ * cv_flux.regular_face_geometry.y_open_mask,
1471
+ )
1472
+ + _divergence_from_face_flux(
1473
+ cv_flux.regular_flux.z,
1474
+ geometry.spacing.dz,
1475
+ axis=2,
1476
+ area=
1477
+ cv_flux.regular_face_geometry.z_area
1478
+ * cv_flux.regular_face_geometry.z_area_fraction
1479
+ * cv_flux.regular_face_geometry.z_open_mask,
1480
+ )
1481
+ )
1482
+
1483
+ if cv_flux.cut_wall_geometry is not None and cv_flux.cut_wall_flux is not None and cv_flux.cut_wall_flux.size:
1484
+ cut_wall_contrib = jnp.zeros(geometry.shape, dtype=jnp.float64)
1485
+ cut_wall_contrib = cut_wall_contrib.at[
1486
+ jnp.asarray(cv_flux.cut_wall_geometry.owner_i, dtype=jnp.int32),
1487
+ jnp.asarray(cv_flux.cut_wall_geometry.owner_j, dtype=jnp.int32),
1488
+ jnp.asarray(cv_flux.cut_wall_geometry.owner_k, dtype=jnp.int32),
1489
+ ].add(jnp.asarray(cv_flux.cut_wall_flux, dtype=jnp.float64))
1490
+ div_flux = div_flux + cut_wall_contrib
1491
+
1492
+ effective_volume = jnp.asarray(cv_flux.cell_volume.volume, dtype=jnp.float64) * jnp.asarray(
1493
+ cv_flux.cell_volume.volume_fraction, dtype=jnp.float64
1494
+ )
1495
+ return div_flux / jnp.maximum(effective_volume, float(jacobian_floor))
1496
+
1497
+
1498
+ def perp_laplacian_conservative_op(
1499
+ local: ConservativeStencil3D,
1500
+ geometry: FciGeometry3D,
1501
+ *,
1502
+ face_projectors: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray] | None = None,
1503
+ face_bc: BoundaryFaceBC3D | None = None,
1504
+ regular_face_geometry: RegularFaceGeometry3D | None = None,
1505
+ cut_wall_geometry: CutWallGeometry3D | None = None,
1506
+ cut_wall_bc: CutWallBC3D | None = None,
1507
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
1508
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
1509
+ b_floor: float = 1.0e-30,
1510
+ jacobian_floor: float = 1.0e-30,
1511
+ ) -> jnp.ndarray:
1512
+ """Build the conservative flux stencil and immediately apply divergence."""
1513
+
1514
+ cv_flux = build_perp_laplacian_stencil(
1515
+ local,
1516
+ geometry,
1517
+ face_projectors=face_projectors,
1518
+ face_bc=face_bc,
1519
+ regular_face_geometry=regular_face_geometry,
1520
+ cut_wall_geometry=cut_wall_geometry,
1521
+ cut_wall_bc=cut_wall_bc,
1522
+ periodic_axes=periodic_axes,
1523
+ axis_regular_axes=axis_regular_axes,
1524
+ b_floor=b_floor,
1525
+ )
1526
+ return divergence_conservative_op(cv_flux, geometry, jacobian_floor=jacobian_floor)
1527
+
1528
+
1529
+ def parallel_laplacian_conservative_op(
1530
+ local: ConservativeStencil3D,
1531
+ geometry: FciGeometry3D,
1532
+ *,
1533
+ face_projectors: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray] | None = None,
1534
+ face_bc: BoundaryFaceBC3D | None = None,
1535
+ regular_face_geometry: RegularFaceGeometry3D | None = None,
1536
+ cut_wall_geometry: CutWallGeometry3D | None = None,
1537
+ cut_wall_bc: CutWallBC3D | None = None,
1538
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
1539
+ b_floor: float = 1.0e-30,
1540
+ jacobian_floor: float = 1.0e-30,
1541
+ ) -> jnp.ndarray:
1542
+ """Return the conservative parallel Laplacian ``∇·(b b·∇f)``."""
1543
+
1544
+ if face_projectors is None:
1545
+ face_projectors = _build_laplacian_face_projectors(geometry, b_floor=b_floor, parallel=True)
1546
+ cv_flux = _build_projected_laplacian_stencil(
1547
+ local,
1548
+ geometry,
1549
+ face_projectors=face_projectors,
1550
+ face_bc=face_bc,
1551
+ regular_face_geometry=regular_face_geometry,
1552
+ cut_wall_geometry=cut_wall_geometry,
1553
+ cut_wall_bc=cut_wall_bc,
1554
+ periodic_axes=periodic_axes,
1555
+ b_floor=b_floor,
1556
+ )
1557
+ return divergence_conservative_op(cv_flux, geometry, jacobian_floor=jacobian_floor)
1558
+
1559
+
1560
+ def _patch_local_axis_face_gradients(
1561
+ face_grad: jnp.ndarray,
1562
+ *,
1563
+ values_owned: jnp.ndarray,
1564
+ geometry: LocalFciGeometry3D,
1565
+ domain: LocalDomain3D,
1566
+ axis: int,
1567
+ axis_kind: jnp.ndarray,
1568
+ axis_value: jnp.ndarray,
1569
+ axis_mask: jnp.ndarray,
1570
+ axis_regular_axes: tuple[bool, bool, bool],
1571
+ ) -> jnp.ndarray:
1572
+ """Apply local physical face-gradient closures on the owned face grid."""
1573
+
1574
+ if axis_regular_axes[1] or axis_regular_axes[2]:
1575
+ raise NotImplementedError(
1576
+ "axis_regular_axes currently only supports the lower x axis; "
1577
+ f"got axis_regular_axes={axis_regular_axes}"
1578
+ )
1579
+
1580
+ if axis == 0 and axis_regular_axes[0]:
1581
+ # The global lower-x face is handled by the axis-regular topology path,
1582
+ # not by physical face closures.
1583
+ lower_patch_allowed = False
1584
+ else:
1585
+ lower_patch_allowed = True
1586
+
1587
+ face_grad = jnp.asarray(face_grad, dtype=jnp.float64)
1588
+ values_owned = jnp.asarray(values_owned, dtype=jnp.float64)
1589
+ kind = jnp.asarray(axis_kind, dtype=jnp.int32)
1590
+ value = jnp.asarray(axis_value, dtype=jnp.float64)
1591
+ mask = jnp.asarray(axis_mask, dtype=bool)
1592
+
1593
+ if axis == 0:
1594
+ lower_value = value[0]
1595
+ upper_value = value[-1]
1596
+ lower_kind = kind[0]
1597
+ upper_kind = kind[-1]
1598
+ lower_mask = mask[0]
1599
+ upper_mask = mask[-1]
1600
+ lower_distance = jnp.asarray(geometry.grid.x.lower_center_to_face, dtype=jnp.float64)
1601
+ upper_distance = jnp.asarray(geometry.grid.x.upper_center_to_face, dtype=jnp.float64)
1602
+ lower_center = values_owned[0]
1603
+ upper_center = values_owned[-1]
1604
+ elif axis == 1:
1605
+ lower_value = value[:, 0, :]
1606
+ upper_value = value[:, -1, :]
1607
+ lower_kind = kind[:, 0, :]
1608
+ upper_kind = kind[:, -1, :]
1609
+ lower_mask = mask[:, 0, :]
1610
+ upper_mask = mask[:, -1, :]
1611
+ lower_distance = jnp.asarray(geometry.grid.y.lower_center_to_face, dtype=jnp.float64)
1612
+ upper_distance = jnp.asarray(geometry.grid.y.upper_center_to_face, dtype=jnp.float64)
1613
+ lower_center = values_owned[:, 0, :]
1614
+ upper_center = values_owned[:, -1, :]
1615
+ else:
1616
+ lower_value = value[:, :, 0]
1617
+ upper_value = value[:, :, -1]
1618
+ lower_kind = kind[:, :, 0]
1619
+ upper_kind = kind[:, :, -1]
1620
+ lower_mask = mask[:, :, 0]
1621
+ upper_mask = mask[:, :, -1]
1622
+ lower_distance = jnp.asarray(geometry.grid.z.lower_center_to_face, dtype=jnp.float64)
1623
+ upper_distance = jnp.asarray(geometry.grid.z.upper_center_to_face, dtype=jnp.float64)
1624
+ lower_center = values_owned[:, :, 0]
1625
+ upper_center = values_owned[:, :, -1]
1626
+
1627
+ lower_plane = face_grad[_axis_index_nd(axis, 0, face_grad.ndim)]
1628
+ lower_normal = lower_plane[..., axis]
1629
+ lower_coord = (lower_center - lower_value) / jnp.maximum(lower_distance, 1.0e-30)
1630
+ if lower_patch_allowed:
1631
+ lower_plane = lower_plane.at[..., axis].set(
1632
+ jnp.where(lower_mask & (lower_kind == BC_DIRICHLET), lower_coord, lower_normal)
1633
+ )
1634
+ lower_plane = lower_plane.at[..., axis].set(
1635
+ jnp.where(lower_mask & (lower_kind == BC_NEUMANN), -lower_value, lower_plane[..., axis])
1636
+ )
1637
+ face_grad = face_grad.at[_axis_index_nd(axis, 0, face_grad.ndim)].set(lower_plane)
1638
+
1639
+ upper_plane = face_grad[_axis_index_nd(axis, -1, face_grad.ndim)]
1640
+ upper_normal = upper_plane[..., axis]
1641
+ upper_coord = (upper_value - upper_center) / jnp.maximum(upper_distance, 1.0e-30)
1642
+ upper_plane = upper_plane.at[..., axis].set(
1643
+ jnp.where(upper_mask & (upper_kind == BC_DIRICHLET), upper_coord, upper_normal)
1644
+ )
1645
+ upper_plane = upper_plane.at[..., axis].set(
1646
+ jnp.where(upper_mask & (upper_kind == BC_NEUMANN), upper_value, upper_plane[..., axis])
1647
+ )
1648
+ face_grad = face_grad.at[_axis_index_nd(axis, -1, face_grad.ndim)].set(upper_plane)
1649
+
1650
+ return face_grad
1651
+
1652
+
1653
+ def _apply_local_face_flux_bc(
1654
+ flux: jnp.ndarray,
1655
+ *,
1656
+ axis: int,
1657
+ axis_kind: jnp.ndarray,
1658
+ axis_value: jnp.ndarray,
1659
+ axis_mask: jnp.ndarray,
1660
+ axis_regular_axes: tuple[bool, bool, bool],
1661
+ ) -> jnp.ndarray:
1662
+ """Apply local physical face flux boundary conditions on owned faces."""
1663
+
1664
+ if axis_regular_axes[1] or axis_regular_axes[2]:
1665
+ raise NotImplementedError(
1666
+ "axis_regular_axes currently only supports the lower x axis; "
1667
+ f"got axis_regular_axes={axis_regular_axes}"
1668
+ )
1669
+
1670
+ result = jnp.asarray(flux, dtype=jnp.float64)
1671
+ kind = jnp.asarray(axis_kind, dtype=jnp.int32)
1672
+ value = jnp.asarray(axis_value, dtype=jnp.float64)
1673
+ mask = jnp.asarray(axis_mask, dtype=bool)
1674
+
1675
+ if axis == 0:
1676
+ lower_kind = kind[0]
1677
+ upper_kind = kind[-1]
1678
+ lower_value = value[0]
1679
+ upper_value = value[-1]
1680
+ lower_mask = mask[0]
1681
+ upper_mask = mask[-1]
1682
+ skip_lower = bool(axis_regular_axes[0])
1683
+ elif axis == 1:
1684
+ lower_kind = kind[:, 0, :]
1685
+ upper_kind = kind[:, -1, :]
1686
+ lower_value = value[:, 0, :]
1687
+ upper_value = value[:, -1, :]
1688
+ lower_mask = mask[:, 0, :]
1689
+ upper_mask = mask[:, -1, :]
1690
+ skip_lower = False
1691
+ else:
1692
+ lower_kind = kind[:, :, 0]
1693
+ upper_kind = kind[:, :, -1]
1694
+ lower_value = value[:, :, 0]
1695
+ upper_value = value[:, :, -1]
1696
+ lower_mask = mask[:, :, 0]
1697
+ upper_mask = mask[:, :, -1]
1698
+ skip_lower = False
1699
+
1700
+ if not skip_lower:
1701
+ lower_plane = result[_axis_index_nd(axis, 0, result.ndim)]
1702
+ lower_plane = jnp.where(lower_mask & (lower_kind == BC_NORMALFLUX), lower_value, lower_plane)
1703
+ lower_plane = jnp.where(lower_mask & (lower_kind == BC_NOFLUX), 0.0, lower_plane)
1704
+ result = result.at[_axis_index_nd(axis, 0, result.ndim)].set(lower_plane)
1705
+
1706
+ upper_plane = result[_axis_index_nd(axis, -1, result.ndim)]
1707
+ upper_plane = jnp.where(upper_mask & (upper_kind == BC_NORMALFLUX), upper_value, upper_plane)
1708
+ upper_plane = jnp.where(upper_mask & (upper_kind == BC_NOFLUX), 0.0, upper_plane)
1709
+ result = result.at[_axis_index_nd(axis, -1, result.ndim)].set(upper_plane)
1710
+ return result
1711
+
1712
+
1713
+ def _build_local_cut_wall_flux_payload(
1714
+ *,
1715
+ local: ConservativeStencil3D,
1716
+ cut_wall_geometry: LocalCutWallGeometry3D,
1717
+ cut_wall_bc: LocalCutWallBC3D,
1718
+ b_floor: float = 1.0e-30,
1719
+ ) -> jnp.ndarray:
1720
+ """Build the padded embedded-wall flux payload for the conservative control volume."""
1721
+
1722
+ if not isinstance(cut_wall_geometry, LocalCutWallGeometry3D):
1723
+ raise TypeError(
1724
+ "_build_local_cut_wall_flux_payload requires LocalCutWallGeometry3D, "
1725
+ f"got {type(cut_wall_geometry).__name__}"
1726
+ )
1727
+ if not isinstance(cut_wall_bc, LocalCutWallBC3D):
1728
+ raise TypeError(
1729
+ "_build_local_cut_wall_flux_payload requires LocalCutWallBC3D, "
1730
+ f"got {type(cut_wall_bc).__name__}"
1731
+ )
1732
+ if cut_wall_geometry.max_wall_faces != cut_wall_bc.max_wall_faces:
1733
+ raise ValueError(
1734
+ "cut_wall_geometry and cut_wall_bc must use the same padded wall-face length"
1735
+ )
1736
+
1737
+ max_wall_faces = int(cut_wall_geometry.max_wall_faces)
1738
+ if max_wall_faces == 0:
1739
+ return jnp.zeros((0,), dtype=jnp.float64)
1740
+
1741
+ active = jnp.asarray(cut_wall_geometry.active, dtype=bool) & jnp.asarray(cut_wall_bc.active, dtype=bool)
1742
+ cut_wall_kind = jnp.asarray(cut_wall_bc.kind, dtype=jnp.int32)
1743
+ cut_wall_value = jnp.asarray(cut_wall_bc.value, dtype=jnp.float64)
1744
+
1745
+ supported = (
1746
+ (cut_wall_kind == BC_NONE)
1747
+ | (cut_wall_kind == BC_DIRICHLET)
1748
+ | (cut_wall_kind == BC_NEUMANN)
1749
+ | (cut_wall_kind == BC_NORMALFLUX)
1750
+ | (cut_wall_kind == BC_NOFLUX)
1751
+ )
1752
+ active = active & supported
1753
+ cut_wall_kind = jnp.where(supported, cut_wall_kind, BC_NONE)
1754
+ cut_wall_value = jnp.where(supported, cut_wall_value, 0.0)
1755
+
1756
+ owner_i = jnp.asarray(cut_wall_geometry.owner_i, dtype=jnp.int32)
1757
+ owner_j = jnp.asarray(cut_wall_geometry.owner_j, dtype=jnp.int32)
1758
+ owner_k = jnp.asarray(cut_wall_geometry.owner_k, dtype=jnp.int32)
1759
+
1760
+ field = jnp.asarray(local.x.center, dtype=jnp.float64)
1761
+ dfdx_cell = _take_stencil_finite_difference(local.x)
1762
+ dfdy_cell = _take_stencil_finite_difference(local.y)
1763
+ dfdz_cell = _take_stencil_finite_difference(local.z)
1764
+
1765
+ grad_cell = jnp.stack(
1766
+ (
1767
+ dfdx_cell[owner_i, owner_j, owner_k],
1768
+ dfdy_cell[owner_i, owner_j, owner_k],
1769
+ dfdz_cell[owner_i, owner_j, owner_k],
1770
+ ),
1771
+ axis=-1,
1772
+ )
1773
+ f_cell = field[owner_i, owner_j, owner_k]
1774
+
1775
+ normal_contra = jnp.asarray(cut_wall_geometry.normal_contra, dtype=jnp.float64)
1776
+ normal_cov = jnp.einsum(
1777
+ "...ij,...j->...i",
1778
+ jnp.asarray(cut_wall_geometry.g_cov, dtype=jnp.float64),
1779
+ normal_contra,
1780
+ )
1781
+ g_cell = jnp.einsum("...i,...i->...", normal_contra, grad_cell)
1782
+ grad_tangent = grad_cell - g_cell[..., None] * normal_cov
1783
+
1784
+ distance = jnp.asarray(cut_wall_geometry.distance, dtype=jnp.float64)
1785
+ safe_distance = jnp.maximum(jnp.abs(distance), 1.0e-30)
1786
+ g_dirichlet = (cut_wall_value - f_cell) / safe_distance
1787
+ g_neumann = cut_wall_value
1788
+ g_wall = g_cell
1789
+ g_wall = jnp.where(cut_wall_kind == BC_DIRICHLET, g_dirichlet, g_wall)
1790
+ g_wall = jnp.where(cut_wall_kind == BC_NEUMANN, g_neumann, g_wall)
1791
+ grad_wall = grad_tangent + g_wall[..., None] * normal_cov
1792
+
1793
+ bmag = jnp.maximum(jnp.asarray(cut_wall_geometry.Bmag, dtype=jnp.float64), float(b_floor))
1794
+ b_wall = jnp.asarray(cut_wall_geometry.B_contra, dtype=jnp.float64) / bmag[..., None]
1795
+ projector = jnp.asarray(cut_wall_geometry.g_contra, dtype=jnp.float64) - jnp.einsum(
1796
+ "...i,...j->...ij",
1797
+ b_wall,
1798
+ b_wall,
1799
+ )
1800
+ wall_flux_area = jnp.asarray(cut_wall_geometry.J, dtype=jnp.float64) * jnp.einsum(
1801
+ "...i,...ij,...j->...",
1802
+ jnp.asarray(cut_wall_geometry.area_covector, dtype=jnp.float64),
1803
+ projector,
1804
+ grad_wall,
1805
+ )
1806
+ wall_flux_area = jnp.where(cut_wall_kind == BC_NORMALFLUX, cut_wall_value, wall_flux_area)
1807
+ wall_flux_area = jnp.where(cut_wall_kind == BC_NOFLUX, 0.0, wall_flux_area)
1808
+ wall_flux_area = jnp.where(active, wall_flux_area, 0.0)
1809
+
1810
+ sign = jnp.asarray(cut_wall_geometry.sign, dtype=jnp.float64)
1811
+ if sign.shape != wall_flux_area.shape:
1812
+ raise ValueError(
1813
+ f"cut_wall_geometry.sign must have shape {wall_flux_area.shape}, got {sign.shape}"
1814
+ )
1815
+ return sign * wall_flux_area
1816
+
1817
+
1818
+ def build_local_projected_laplacian_flux_stencil(
1819
+ local: ConservativeStencil3D,
1820
+ geometry: LocalFciGeometry3D,
1821
+ domain: LocalDomain3D,
1822
+ *,
1823
+ face_projectors: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray] | None = None,
1824
+ face_bc: LocalBoundaryFaceBC3D | None = None,
1825
+ regular_face_geometry: LocalRegularFaceGeometry3D | None = None,
1826
+ cell_volume: LocalCellVolumeGeometry3D | None = None,
1827
+ cut_wall_geometry: LocalCutWallGeometry3D | None = None,
1828
+ cut_wall_bc: LocalCutWallBC3D | None = None,
1829
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
1830
+ b_floor: float = 1.0e-30,
1831
+ ) -> LocalControlVolumeFluxStencil3D:
1832
+ """Build the local face-flux stencil for a projected Laplacian."""
1833
+
1834
+ if not isinstance(geometry, LocalFciGeometry3D):
1835
+ raise TypeError(
1836
+ "build_local_projected_laplacian_flux_stencil requires LocalFciGeometry3D, "
1837
+ f"got {type(geometry).__name__}"
1838
+ )
1839
+ if not isinstance(domain, LocalDomain3D):
1840
+ raise TypeError(
1841
+ "build_local_projected_laplacian_flux_stencil requires LocalDomain3D, "
1842
+ f"got {type(domain).__name__}"
1843
+ )
1844
+ if geometry.layout != domain.layout:
1845
+ raise ValueError("geometry and domain must share the same HaloLayout3D")
1846
+ if local.shape != geometry.owned_shape:
1847
+ raise ValueError(
1848
+ f"local stencil must have shape {geometry.owned_shape}, got {local.shape}"
1849
+ )
1850
+ if not hasattr(local, "face_grad"):
1851
+ raise TypeError("local must provide face_grad")
1852
+
1853
+ axis_regular_axes = tuple(bool(value) for value in axis_regular_axes)
1854
+ if len(axis_regular_axes) != 3:
1855
+ raise ValueError("axis_regular_axes must have length 3")
1856
+ if axis_regular_axes[1] or axis_regular_axes[2]:
1857
+ raise NotImplementedError(
1858
+ "axis_regular_axes currently only supports the lower x axis; "
1859
+ f"got axis_regular_axes={axis_regular_axes}"
1860
+ )
1861
+
1862
+ regular_face_geometry = regular_face_geometry or geometry.regular_face_geometry
1863
+ cell_volume = cell_volume or geometry.cell_volume_geometry
1864
+ face_bc = face_bc or LocalBoundaryFaceBC3D.empty(geometry.layout)
1865
+ if cut_wall_geometry is None and cut_wall_bc is None:
1866
+ cut_wall_geometry = LocalCutWallGeometry3D.empty(0)
1867
+ cut_wall_bc = LocalCutWallBC3D.empty(0)
1868
+ elif cut_wall_geometry is None:
1869
+ cut_wall_geometry = LocalCutWallGeometry3D.empty(cut_wall_bc.n_wall_faces)
1870
+ elif cut_wall_bc is None:
1871
+ cut_wall_bc = LocalCutWallBC3D.empty(cut_wall_geometry.max_wall_faces)
1872
+
1873
+ if face_projectors is None:
1874
+ face_projectors = build_local_perp_laplacian_face_projectors(
1875
+ geometry,
1876
+ domain,
1877
+ b_floor=b_floor,
1878
+ axis_regular_axes=axis_regular_axes,
1879
+ )
1880
+ x_face_projector, y_face_projector, z_face_projector = face_projectors
1881
+
1882
+ x_face_grad = jnp.asarray(local.face_grad.x, dtype=jnp.float64)
1883
+ y_face_grad = jnp.asarray(local.face_grad.y, dtype=jnp.float64)
1884
+ z_face_grad = jnp.asarray(local.face_grad.z, dtype=jnp.float64)
1885
+
1886
+ values_owned = jnp.asarray(local.x.center, dtype=jnp.float64)
1887
+ x_face_grad = _patch_local_axis_face_gradients(
1888
+ x_face_grad,
1889
+ values_owned=values_owned,
1890
+ geometry=geometry,
1891
+ domain=domain,
1892
+ axis=0,
1893
+ axis_kind=face_bc.kind_x,
1894
+ axis_value=face_bc.value_x,
1895
+ axis_mask=face_bc.mask_x,
1896
+ axis_regular_axes=axis_regular_axes,
1897
+ )
1898
+ y_face_grad = _patch_local_axis_face_gradients(
1899
+ y_face_grad,
1900
+ values_owned=values_owned,
1901
+ geometry=geometry,
1902
+ domain=domain,
1903
+ axis=1,
1904
+ axis_kind=face_bc.kind_y,
1905
+ axis_value=face_bc.value_y,
1906
+ axis_mask=face_bc.mask_y,
1907
+ axis_regular_axes=axis_regular_axes,
1908
+ )
1909
+ z_face_grad = _patch_local_axis_face_gradients(
1910
+ z_face_grad,
1911
+ values_owned=values_owned,
1912
+ geometry=geometry,
1913
+ domain=domain,
1914
+ axis=2,
1915
+ axis_kind=face_bc.kind_z,
1916
+ axis_value=face_bc.value_z,
1917
+ axis_mask=face_bc.mask_z,
1918
+ axis_regular_axes=axis_regular_axes,
1919
+ )
1920
+
1921
+ x_face_metric = geometry.face_metric.x
1922
+ y_face_metric = geometry.face_metric.y
1923
+ z_face_metric = geometry.face_metric.z
1924
+
1925
+ x_flux = jnp.asarray(x_face_metric.J_owned, dtype=jnp.float64) * jnp.einsum(
1926
+ "...j,...j->...", x_face_projector[..., 0, :], x_face_grad
1927
+ )
1928
+ y_flux = jnp.asarray(y_face_metric.J_owned, dtype=jnp.float64) * jnp.einsum(
1929
+ "...j,...j->...", y_face_projector[..., 1, :], y_face_grad
1930
+ )
1931
+ z_flux = jnp.asarray(z_face_metric.J_owned, dtype=jnp.float64) * jnp.einsum(
1932
+ "...j,...j->...", z_face_projector[..., 2, :], z_face_grad
1933
+ )
1934
+
1935
+ x_flux = _apply_local_face_flux_bc(
1936
+ x_flux,
1937
+ axis=0,
1938
+ axis_kind=face_bc.kind_x,
1939
+ axis_value=face_bc.value_x,
1940
+ axis_mask=face_bc.mask_x,
1941
+ axis_regular_axes=axis_regular_axes,
1942
+ )
1943
+ y_flux = _apply_local_face_flux_bc(
1944
+ y_flux,
1945
+ axis=1,
1946
+ axis_kind=face_bc.kind_y,
1947
+ axis_value=face_bc.value_y,
1948
+ axis_mask=face_bc.mask_y,
1949
+ axis_regular_axes=axis_regular_axes,
1950
+ )
1951
+ z_flux = _apply_local_face_flux_bc(
1952
+ z_flux,
1953
+ axis=2,
1954
+ axis_kind=face_bc.kind_z,
1955
+ axis_value=face_bc.value_z,
1956
+ axis_mask=face_bc.mask_z,
1957
+ axis_regular_axes=axis_regular_axes,
1958
+ )
1959
+
1960
+ cut_wall_flux = _build_local_cut_wall_flux_payload(
1961
+ local=local,
1962
+ cut_wall_geometry=cut_wall_geometry,
1963
+ cut_wall_bc=cut_wall_bc,
1964
+ b_floor=b_floor,
1965
+ )
1966
+
1967
+ return LocalControlVolumeFluxStencil3D(
1968
+ regular_flux=FaceFluxStencil3D(x=x_flux, y=y_flux, z=z_flux),
1969
+ regular_face_geometry=regular_face_geometry,
1970
+ cell_volume=cell_volume,
1971
+ cut_wall_geometry=cut_wall_geometry,
1972
+ cut_wall_flux=cut_wall_flux,
1973
+ )
1974
+
1975
+
1976
+ def local_divergence_conservative_op(
1977
+ cv_flux: LocalControlVolumeFluxStencil3D,
1978
+ geometry: LocalFciGeometry3D,
1979
+ *,
1980
+ jacobian_floor: float = 1.0e-30,
1981
+ ) -> jnp.ndarray:
1982
+ """Return the local conservative divergence from a completed face-flux stencil."""
1983
+
1984
+ if not isinstance(geometry, LocalFciGeometry3D):
1985
+ raise TypeError(
1986
+ "local_divergence_conservative_op requires LocalFciGeometry3D, "
1987
+ f"got {type(geometry).__name__}"
1988
+ )
1989
+ if cv_flux.shape != geometry.owned_shape:
1990
+ raise ValueError(
1991
+ f"cv_flux must have shape {geometry.owned_shape}, got {cv_flux.shape}"
1992
+ )
1993
+
1994
+ def _divergence_from_face_flux(
1995
+ flux: jnp.ndarray,
1996
+ spacing: jnp.ndarray,
1997
+ *,
1998
+ axis: int,
1999
+ area: jnp.ndarray,
2000
+ ) -> jnp.ndarray:
2001
+ face_flux = jnp.asarray(flux, dtype=jnp.float64) * jnp.asarray(area, dtype=jnp.float64)
2002
+ h = jnp.asarray(spacing, dtype=jnp.float64)
2003
+ if h.shape != face_flux[_axis_slice_nd(axis, 1, None, face_flux.ndim)].shape:
2004
+ raise ValueError(
2005
+ f"local spacing for axis {axis} must match the owned cell shape; got {h.shape}"
2006
+ )
2007
+ return (
2008
+ face_flux[_axis_slice_nd(axis, 1, None, face_flux.ndim)]
2009
+ - face_flux[_axis_slice_nd(axis, None, -1, face_flux.ndim)]
2010
+ ) / jnp.maximum(h, 1.0e-30)
2011
+
2012
+ div_flux = (
2013
+ _divergence_from_face_flux(
2014
+ cv_flux.regular_flux.x,
2015
+ geometry.spacing.dx_owned,
2016
+ axis=0,
2017
+ area=
2018
+ cv_flux.regular_face_geometry.x_area
2019
+ * cv_flux.regular_face_geometry.x_area_fraction
2020
+ * cv_flux.regular_face_geometry.x_open_mask,
2021
+ )
2022
+ + _divergence_from_face_flux(
2023
+ cv_flux.regular_flux.y,
2024
+ geometry.spacing.dy_owned,
2025
+ axis=1,
2026
+ area=
2027
+ cv_flux.regular_face_geometry.y_area
2028
+ * cv_flux.regular_face_geometry.y_area_fraction
2029
+ * cv_flux.regular_face_geometry.y_open_mask,
2030
+ )
2031
+ + _divergence_from_face_flux(
2032
+ cv_flux.regular_flux.z,
2033
+ geometry.spacing.dz_owned,
2034
+ axis=2,
2035
+ area=
2036
+ cv_flux.regular_face_geometry.z_area
2037
+ * cv_flux.regular_face_geometry.z_area_fraction
2038
+ * cv_flux.regular_face_geometry.z_open_mask,
2039
+ )
2040
+ )
2041
+
2042
+ if cv_flux.cut_wall_geometry is not None and cv_flux.cut_wall_flux is not None and cv_flux.cut_wall_flux.size:
2043
+ cut_wall_contrib = jnp.zeros(geometry.owned_shape, dtype=jnp.float64)
2044
+ cut_wall_contrib = cut_wall_contrib.at[
2045
+ jnp.asarray(cv_flux.cut_wall_geometry.owner_i, dtype=jnp.int32),
2046
+ jnp.asarray(cv_flux.cut_wall_geometry.owner_j, dtype=jnp.int32),
2047
+ jnp.asarray(cv_flux.cut_wall_geometry.owner_k, dtype=jnp.int32),
2048
+ ].add(jnp.asarray(cv_flux.cut_wall_flux, dtype=jnp.float64))
2049
+ div_flux = div_flux + cut_wall_contrib
2050
+
2051
+ effective_volume = jnp.asarray(cv_flux.cell_volume.volume, dtype=jnp.float64) * jnp.asarray(
2052
+ cv_flux.cell_volume.volume_fraction, dtype=jnp.float64
2053
+ )
2054
+ return div_flux / jnp.maximum(effective_volume, float(jacobian_floor))
2055
+
2056
+
2057
+ def build_local_perp_laplacian_stencil(
2058
+ local: ConservativeStencil3D,
2059
+ geometry: LocalFciGeometry3D,
2060
+ domain: LocalDomain3D,
2061
+ *,
2062
+ face_projectors: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray] | None = None,
2063
+ face_bc: LocalBoundaryFaceBC3D | None = None,
2064
+ regular_face_geometry: LocalRegularFaceGeometry3D | None = None,
2065
+ cell_volume: LocalCellVolumeGeometry3D | None = None,
2066
+ cut_wall_geometry: LocalCutWallGeometry3D | None = None,
2067
+ cut_wall_bc: LocalCutWallBC3D | None = None,
2068
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
2069
+ b_floor: float = 1.0e-30,
2070
+ ) -> LocalControlVolumeFluxStencil3D:
2071
+ """Build the local conservative flux stencil for ``-∇·(P⊥∇f)``."""
2072
+
2073
+ if face_projectors is None:
2074
+ face_projectors = build_local_perp_laplacian_face_projectors(
2075
+ geometry,
2076
+ domain,
2077
+ b_floor=b_floor,
2078
+ axis_regular_axes=axis_regular_axes,
2079
+ )
2080
+ return build_local_projected_laplacian_flux_stencil(
2081
+ local,
2082
+ geometry,
2083
+ domain,
2084
+ face_projectors=face_projectors,
2085
+ face_bc=face_bc,
2086
+ regular_face_geometry=regular_face_geometry,
2087
+ cell_volume=cell_volume,
2088
+ cut_wall_geometry=cut_wall_geometry,
2089
+ cut_wall_bc=cut_wall_bc,
2090
+ axis_regular_axes=axis_regular_axes,
2091
+ b_floor=b_floor,
2092
+ )
2093
+
2094
+
2095
+ def local_perp_laplacian_conservative_op(
2096
+ local: ConservativeStencil3D,
2097
+ geometry: LocalFciGeometry3D,
2098
+ domain: LocalDomain3D,
2099
+ *,
2100
+ face_projectors: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray] | None = None,
2101
+ face_bc: LocalBoundaryFaceBC3D | None = None,
2102
+ regular_face_geometry: LocalRegularFaceGeometry3D | None = None,
2103
+ cell_volume: LocalCellVolumeGeometry3D | None = None,
2104
+ cut_wall_geometry: LocalCutWallGeometry3D | None = None,
2105
+ cut_wall_bc: LocalCutWallBC3D | None = None,
2106
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
2107
+ b_floor: float = 1.0e-30,
2108
+ jacobian_floor: float = 1.0e-30,
2109
+ ) -> jnp.ndarray:
2110
+ """Return the domain-decomposed conservative perpendicular Laplacian."""
2111
+
2112
+ cv_flux = build_local_perp_laplacian_stencil(
2113
+ local,
2114
+ geometry,
2115
+ domain,
2116
+ face_projectors=face_projectors,
2117
+ face_bc=face_bc,
2118
+ regular_face_geometry=regular_face_geometry,
2119
+ cell_volume=cell_volume,
2120
+ cut_wall_geometry=cut_wall_geometry,
2121
+ cut_wall_bc=cut_wall_bc,
2122
+ axis_regular_axes=axis_regular_axes,
2123
+ b_floor=b_floor,
2124
+ )
2125
+ return local_divergence_conservative_op(cv_flux, geometry, jacobian_floor=jacobian_floor)
2126
+
2127
+
2128
+ def _has_dirichlet_regular_faces(face_bc: BoundaryFaceBC3D | None) -> bool:
2129
+ if face_bc is None:
2130
+ return False
2131
+ return bool(
2132
+ jnp.any((face_bc.kind_x == BC_DIRICHLET) & face_bc.mask_x)
2133
+ or jnp.any((face_bc.kind_y == BC_DIRICHLET) & face_bc.mask_y)
2134
+ or jnp.any((face_bc.kind_z == BC_DIRICHLET) & face_bc.mask_z)
2135
+ )
2136
+
2137
+
2138
+ def _has_dirichlet_cut_walls(cut_wall_bc: CutWallBC3D | None) -> bool:
2139
+ if cut_wall_bc is None or not cut_wall_bc.kind.size:
2140
+ return False
2141
+ return bool(jnp.any(jnp.asarray(cut_wall_bc.kind, dtype=jnp.int32) == BC_DIRICHLET))
2142
+
2143
+
2144
+ def _homogeneous_boundary_face_bc(face_bc: BoundaryFaceBC3D) -> BoundaryFaceBC3D:
2145
+ """Keep regular-face BC kinds/masks but remove affine boundary values."""
2146
+
2147
+ return face_bc.replace(
2148
+ value_x=jnp.zeros_like(face_bc.value_x, dtype=jnp.float64),
2149
+ value_y=jnp.zeros_like(face_bc.value_y, dtype=jnp.float64),
2150
+ value_z=jnp.zeros_like(face_bc.value_z, dtype=jnp.float64),
2151
+ )
2152
+
2153
+
2154
+ def _homogeneous_cut_wall_bc(cut_wall_bc: CutWallBC3D | None) -> CutWallBC3D:
2155
+ """Keep cut-wall BC kinds but remove affine wall values."""
2156
+
2157
+ cut_wall_bc = cut_wall_bc or CutWallBC3D.empty()
2158
+ return CutWallBC3D(
2159
+ kind=jnp.asarray(cut_wall_bc.kind, dtype=jnp.int32),
2160
+ value=jnp.zeros_like(jnp.asarray(cut_wall_bc.value, dtype=jnp.float64)),
2161
+ )
2162
+
2163
+
2164
+ def _homogeneous_boundary_payload(
2165
+ face_bc: BoundaryFaceBC3D,
2166
+ cut_wall_bc: CutWallBC3D | None,
2167
+ ) -> tuple[BoundaryFaceBC3D, CutWallBC3D]:
2168
+ """Return the homogeneous payload used by the linear inverse operator."""
2169
+
2170
+ return _homogeneous_boundary_face_bc(face_bc), _homogeneous_cut_wall_bc(cut_wall_bc)
2171
+
2172
+
2173
+ def _dirichlet_lift_correction_face_bc(face_bc: BoundaryFaceBC3D) -> BoundaryFaceBC3D:
2174
+ """Return correction BCs for ``phi = phi_lift + u`` on regular faces."""
2175
+
2176
+ return face_bc.replace(
2177
+ value_x=jnp.where(face_bc.kind_x == BC_DIRICHLET, 0.0, face_bc.value_x),
2178
+ value_y=jnp.where(face_bc.kind_y == BC_DIRICHLET, 0.0, face_bc.value_y),
2179
+ value_z=jnp.where(face_bc.kind_z == BC_DIRICHLET, 0.0, face_bc.value_z),
2180
+ )
2181
+
2182
+
2183
+ def _dirichlet_lift_correction_cut_wall_bc(cut_wall_bc: CutWallBC3D | None) -> CutWallBC3D:
2184
+ """Return correction BCs for ``phi = phi_lift + u`` on cut walls."""
2185
+
2186
+ cut_wall_bc = cut_wall_bc or CutWallBC3D.empty()
2187
+ return CutWallBC3D(
2188
+ kind=jnp.asarray(cut_wall_bc.kind, dtype=jnp.int32),
2189
+ value=jnp.where(
2190
+ jnp.asarray(cut_wall_bc.kind, dtype=jnp.int32) == BC_DIRICHLET,
2191
+ 0.0,
2192
+ jnp.asarray(cut_wall_bc.value, dtype=jnp.float64),
2193
+ ),
2194
+ )
2195
+
2196
+
2197
+ def _perp_laplacian_from_field_and_bc(
2198
+ field: jnp.ndarray,
2199
+ geometry: FciGeometry3D,
2200
+ *,
2201
+ bc: FciBoundaryCondition,
2202
+ b_floor: float = 1.0e-30,
2203
+ jacobian_floor: float = 1.0e-30,
2204
+ ) -> jnp.ndarray:
2205
+ raise NotImplementedError("legacy axis-level boundary conditions have been removed")
2206
+
2207
+
2208
+ def _axis_index_nd(axis: int, index: int, ndim: int) -> tuple[object, ...]:
2209
+ slices: list[object] = [slice(None)] * ndim
2210
+ slices[axis] = index
2211
+ return tuple(slices)
2212
+
2213
+
2214
+ def _axis_slice_nd(axis: int, start: int | None, stop: int | None, ndim: int) -> tuple[object, ...]:
2215
+ slices: list[object] = [slice(None)] * ndim
2216
+ slices[axis] = slice(start, stop)
2217
+ return tuple(slices)
2218
+
2219
+
2220
+ def _axis_name(axis: int) -> str:
2221
+ return ("x", "y", "z")[int(axis)]
2222
+
2223
+
2224
+ def _lift_cell_field_to_faces(field: jnp.ndarray, *, axis: int, periodic: bool) -> jnp.ndarray:
2225
+ """Map a cell-centered field onto the corresponding face grid along one axis."""
2226
+
2227
+ values_3d = jnp.asarray(field, dtype=jnp.float64)
2228
+ axis_n = values_3d.shape[axis]
2229
+ face_shape = list(values_3d.shape)
2230
+ face_shape[axis] += 1
2231
+
2232
+ if axis_n == 1:
2233
+ return jnp.broadcast_to(values_3d, tuple(face_shape))
2234
+
2235
+ first = jnp.take(values_3d, 0, axis=axis)
2236
+ second = jnp.take(values_3d, 1, axis=axis)
2237
+ last = jnp.take(values_3d, -1, axis=axis)
2238
+ penultimate = jnp.take(values_3d, -2, axis=axis)
2239
+
2240
+ if periodic:
2241
+ lower_ghost = last
2242
+ upper_ghost = first
2243
+ else:
2244
+ # Second-order ghost-cell extrapolation:
2245
+ # q_{-1} = 2 q_0 - q_1
2246
+ # q_{n} = 2 q_{n-1} - q_{n-2}
2247
+ # This lets the same face-average reconstruction be used at the
2248
+ # boundary without dropping to first order.
2249
+ lower_ghost = 2.0 * first - second
2250
+ upper_ghost = 2.0 * last - penultimate
2251
+
2252
+ ext = jnp.concatenate(
2253
+ (
2254
+ jnp.expand_dims(lower_ghost, axis=axis),
2255
+ values_3d,
2256
+ jnp.expand_dims(upper_ghost, axis=axis),
2257
+ ),
2258
+ axis=axis,
2259
+ )
2260
+ return 0.5 * (
2261
+ jnp.take(ext, jnp.arange(axis_n + 1), axis=axis)
2262
+ + jnp.take(ext, jnp.arange(1, axis_n + 2), axis=axis)
2263
+ )
2264
+
2265
+
2266
+ def _bc_periodic_axes(bc) -> tuple[bool, bool, bool]:
2267
+ raise NotImplementedError("legacy axis-level boundary conditions have been removed")
2268
+
2269
+
2270
+ def _bc_axis_spec(bc, axis: int, *, periodic_axes: tuple[bool, bool, bool]) -> object | None:
2271
+ raise NotImplementedError("legacy axis-level boundary conditions have been removed")
2272
+
2273
+
2274
+ def _homogeneous_axis_bc(axis_bc: object | None) -> object | None:
2275
+ raise NotImplementedError("legacy axis-level boundary conditions have been removed")
2276
+
2277
+
2278
+ def _homogeneous_bc(bc: FciBoundaryCondition) -> FciBoundaryCondition:
2279
+ raise NotImplementedError("legacy axis-level boundary conditions have been removed")
2280
+
2281
+
2282
+ def _bc_kind(bc) -> str:
2283
+ raise NotImplementedError("legacy axis-level boundary conditions have been removed")
2284
+
2285
+
2286
+ def _broadcast_boundary_value(value, target_shape: tuple[int, int]) -> jnp.ndarray:
2287
+ array = jnp.asarray(value, dtype=jnp.float64)
2288
+ return jnp.broadcast_to(array, target_shape)
2289
+
2290
+
2291
+ def _broadcast_axis_boundary_value(value, *, axis: int, field_shape: tuple[int, int, int]) -> jnp.ndarray:
2292
+ target_shape = tuple(field_shape[index] for index in range(3) if index != axis)
2293
+ return _broadcast_boundary_value(value, target_shape)
2294
+
2295
+
2296
+ def _set_axis_plane(field: jnp.ndarray, *, axis: int, index: int, value: jnp.ndarray) -> jnp.ndarray:
2297
+ return field.at[_axis_index_nd(axis, index, field.ndim)].set(value)
2298
+
2299
+
2300
+ def _apply_dirichlet_constraints(
2301
+ field: jnp.ndarray,
2302
+ *,
2303
+ axis_bcs: tuple[object | None, object | None, object | None],
2304
+ periodic_axes: tuple[bool, bool, bool],
2305
+ ) -> jnp.ndarray:
2306
+ constrained = jnp.asarray(field, dtype=jnp.float64)
2307
+ for axis, axis_bc in enumerate(axis_bcs):
2308
+ if periodic_axes[axis] or axis_bc is None:
2309
+ continue
2310
+ if _bc_kind(axis_bc) != "dirichlet":
2311
+ continue
2312
+ constrained = _set_axis_plane(
2313
+ constrained,
2314
+ axis=axis,
2315
+ index=0,
2316
+ value=_broadcast_axis_boundary_value(axis_bc.lower_value, axis=axis, field_shape=constrained.shape),
2317
+ )
2318
+ constrained = _set_axis_plane(
2319
+ constrained,
2320
+ axis=axis,
2321
+ index=-1,
2322
+ value=_broadcast_axis_boundary_value(axis_bc.upper_value, axis=axis, field_shape=constrained.shape),
2323
+ )
2324
+ return constrained
2325
+
2326
+
2327
+ def _zero_dirichlet_boundary_residual(
2328
+ field: jnp.ndarray,
2329
+ *,
2330
+ axis_bcs: tuple[object | None, object | None, object | None],
2331
+ periodic_axes: tuple[bool, bool, bool],
2332
+ ) -> jnp.ndarray:
2333
+ residual = jnp.asarray(field, dtype=jnp.float64)
2334
+ for axis, axis_bc in enumerate(axis_bcs):
2335
+ if periodic_axes[axis] or axis_bc is None or _bc_kind(axis_bc) != "dirichlet":
2336
+ continue
2337
+ residual = _set_axis_plane(residual, axis=axis, index=0, value=jnp.zeros_like(residual[_axis_index_nd(axis, 0, residual.ndim)]))
2338
+ residual = _set_axis_plane(residual, axis=axis, index=-1, value=jnp.zeros_like(residual[_axis_index_nd(axis, -1, residual.ndim)]))
2339
+ return residual
2340
+
2341
+
2342
+ def _dirichlet_boundary_flux(
2343
+ values: jnp.ndarray,
2344
+ geometry: FciGeometry3D,
2345
+ *,
2346
+ axis: int,
2347
+ side: str,
2348
+ periodic_axes: tuple[bool, bool, bool],
2349
+ b_floor: float = 1.0e-30,
2350
+ ) -> jnp.ndarray:
2351
+ field = jnp.asarray(values, dtype=jnp.float64)
2352
+ if axis == 0:
2353
+ metric = geometry.face_metric.x
2354
+ bfield = geometry.face_bfield.x
2355
+ elif axis == 1:
2356
+ metric = geometry.face_metric.y
2357
+ bfield = geometry.face_bfield.y
2358
+ else:
2359
+ metric = geometry.face_metric.z
2360
+ bfield = geometry.face_bfield.z
2361
+
2362
+ b_unit = jnp.asarray(bfield.b_contra, dtype=jnp.float64)
2363
+ projector = jnp.asarray(metric.g_contra, dtype=jnp.float64) - jnp.einsum("...i,...j->...ij", b_unit, b_unit)
2364
+
2365
+ dfdx = _first_derivative_3d(field, geometry.spacing.dx, axis=0, periodic=periodic_axes[0])
2366
+ dfdy = _first_derivative_3d(field, geometry.spacing.dy, axis=1, periodic=periodic_axes[1])
2367
+ dfdz = _first_derivative_3d(field, geometry.spacing.dz, axis=2, periodic=periodic_axes[2])
2368
+ grad_components = [dfdx, dfdy, dfdz]
2369
+
2370
+ if side == "lower":
2371
+ if field.shape[axis] < 3:
2372
+ raise ValueError("dirichlet boundary flux requires at least 3 points along the selected axis")
2373
+ normal_boundary = (
2374
+ -3.0 * field[_axis_index_nd(axis, 0, field.ndim)]
2375
+ + 4.0 * field[_axis_index_nd(axis, 1, field.ndim)]
2376
+ - field[_axis_index_nd(axis, 2, field.ndim)]
2377
+ ) / (2.0 * jnp.asarray(geometry.spacing.dx if axis == 0 else geometry.spacing.dy if axis == 1 else geometry.spacing.dz, dtype=jnp.float64)[_axis_index_nd(axis, 0, field.ndim)])
2378
+ boundary_components = [component[_axis_index_nd(axis, 0, field.ndim)] for component in grad_components]
2379
+ j_boundary = jnp.asarray(metric.J, dtype=jnp.float64)[_axis_index_nd(axis, 0, field.ndim)]
2380
+ projector_row = projector[_axis_index_nd(axis, 0, field.ndim)][..., axis, :]
2381
+ elif side == "upper":
2382
+ if field.shape[axis] < 3:
2383
+ raise ValueError("dirichlet boundary flux requires at least 3 points along the selected axis")
2384
+ normal_boundary = (
2385
+ 3.0 * field[_axis_index_nd(axis, -1, field.ndim)]
2386
+ - 4.0 * field[_axis_index_nd(axis, -2, field.ndim)]
2387
+ + field[_axis_index_nd(axis, -3, field.ndim)]
2388
+ ) / (2.0 * jnp.asarray(geometry.spacing.dx if axis == 0 else geometry.spacing.dy if axis == 1 else geometry.spacing.dz, dtype=jnp.float64)[_axis_index_nd(axis, -1, field.ndim)])
2389
+ boundary_components = [component[_axis_index_nd(axis, -1, field.ndim)] for component in grad_components]
2390
+ j_boundary = jnp.asarray(metric.J, dtype=jnp.float64)[_axis_index_nd(axis, -1, field.ndim)]
2391
+ projector_row = projector[_axis_index_nd(axis, -1, field.ndim)][..., axis, :]
2392
+ else:
2393
+ raise ValueError("side must be 'lower' or 'upper'")
2394
+
2395
+ boundary_components[axis] = normal_boundary
2396
+ grad_boundary = jnp.stack(boundary_components, axis=-1)
2397
+ return j_boundary * jnp.einsum("...j,...j->...", projector_row, grad_boundary)
2398
+
2399
+
2400
+ def _cell_volume_weights(geometry: FciGeometry3D) -> jnp.ndarray:
2401
+ return (
2402
+ jnp.asarray(geometry.cell_metric.J, dtype=jnp.float64)
2403
+ * jnp.asarray(geometry.spacing.dx, dtype=jnp.float64)
2404
+ * jnp.asarray(geometry.spacing.dy, dtype=jnp.float64)
2405
+ * jnp.asarray(geometry.spacing.dz, dtype=jnp.float64)
2406
+ )
2407
+
2408
+
2409
+ def _weighted_mean(field: jnp.ndarray, geometry: FciGeometry3D) -> jnp.ndarray:
2410
+ weights = _cell_volume_weights(geometry)
2411
+ return jnp.sum(weights * field) / jnp.maximum(jnp.sum(weights), 1.0e-30)
2412
+
2413
+
2414
+ def _weighted_l2(field: jnp.ndarray, geometry: FciGeometry3D) -> jnp.ndarray:
2415
+ weights = _cell_volume_weights(geometry)
2416
+ return jnp.sqrt(jnp.sum(weights * field * field) / jnp.maximum(jnp.sum(weights), 1.0e-30))
2417
+
2418
+
2419
+ def _remove_weighted_mean(field: jnp.ndarray, geometry: FciGeometry3D) -> jnp.ndarray:
2420
+ values = jnp.asarray(field, dtype=jnp.float64)
2421
+ return values - _weighted_mean(values, geometry)
2422
+
2423
+
2424
+ def _set_weighted_mean(field: jnp.ndarray, geometry: FciGeometry3D, target_mean: object) -> jnp.ndarray:
2425
+ values = jnp.asarray(field, dtype=jnp.float64)
2426
+ target = jnp.asarray(target_mean, dtype=jnp.float64)
2427
+ return values + (target - _weighted_mean(values, geometry))
2428
+
2429
+
2430
+ @_pytree_base
2431
+ @dataclass(frozen=True)
2432
+ class PerpLaplacianMgLevel:
2433
+ """One conservative perpendicular-Laplacian multigrid level."""
2434
+
2435
+ geometry: FciGeometry3D
2436
+ stencil_builder: ConservativeStencilBuilder
2437
+ face_bc: BoundaryFaceBC3D
2438
+ regular_face_geometry: RegularFaceGeometry3D
2439
+ cut_wall_geometry: CutWallGeometry3D
2440
+ cut_wall_bc: CutWallBC3D
2441
+ face_projectors: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]
2442
+ diag_inv: jnp.ndarray
2443
+ periodic_axes: tuple[bool, bool, bool]
2444
+ has_dirichlet: bool
2445
+ has_nullspace: bool
2446
+
2447
+ @property
2448
+ def shape(self) -> tuple[int, int, int]:
2449
+ return self.geometry.shape
2450
+
2451
+ def tree_flatten(self):
2452
+ children = (
2453
+ self.geometry,
2454
+ self.stencil_builder,
2455
+ self.face_bc,
2456
+ self.regular_face_geometry,
2457
+ self.cut_wall_geometry,
2458
+ self.cut_wall_bc,
2459
+ self.face_projectors,
2460
+ self.diag_inv,
2461
+ )
2462
+ aux = (self.periodic_axes, self.has_dirichlet, self.has_nullspace)
2463
+ return children, aux
2464
+
2465
+ @classmethod
2466
+ def tree_unflatten(cls, aux_data, children):
2467
+ periodic_axes, has_dirichlet, has_nullspace = aux_data
2468
+ (
2469
+ geometry,
2470
+ stencil_builder,
2471
+ face_bc,
2472
+ regular_face_geometry,
2473
+ cut_wall_geometry,
2474
+ cut_wall_bc,
2475
+ face_projectors,
2476
+ diag_inv,
2477
+ ) = children
2478
+ return cls(
2479
+ geometry=geometry,
2480
+ stencil_builder=stencil_builder,
2481
+ face_bc=face_bc,
2482
+ regular_face_geometry=regular_face_geometry,
2483
+ cut_wall_geometry=cut_wall_geometry,
2484
+ cut_wall_bc=cut_wall_bc,
2485
+ face_projectors=face_projectors,
2486
+ diag_inv=diag_inv,
2487
+ periodic_axes=periodic_axes,
2488
+ has_dirichlet=has_dirichlet,
2489
+ has_nullspace=has_nullspace,
2490
+ )
2491
+
2492
+
2493
+ @_pytree_base
2494
+ @dataclass(frozen=True)
2495
+ class PerpLaplacianMgHierarchy:
2496
+ """Reusable V-cycle preconditioner payload for the conservative inverse."""
2497
+
2498
+ levels: tuple[PerpLaplacianMgLevel, ...]
2499
+ pre_smooth: int = 2
2500
+ post_smooth: int = 2
2501
+ coarse_smooth: int = 16
2502
+ omega_jacobi: float = 0.65
2503
+ smoother: Literal["jacobi", "chebyshev"] = "chebyshev"
2504
+ chebyshev_order: int = 2
2505
+ spectral_radius_estimate: float | None = None
2506
+ direct_coarse_size: int = 512
2507
+ # LU factorization of the coarsest-level dense operator, computed once at
2508
+ # build time so each V-cycle does a triangular solve instead of assembling
2509
+ # and factorizing the coarse matrix from scratch.
2510
+ coarse_lu_and_piv: tuple[jnp.ndarray, jnp.ndarray] | None = None
2511
+
2512
+ def tree_flatten(self):
2513
+ return (self.levels, self.coarse_lu_and_piv), (
2514
+ self.pre_smooth,
2515
+ self.post_smooth,
2516
+ self.coarse_smooth,
2517
+ self.omega_jacobi,
2518
+ self.smoother,
2519
+ self.chebyshev_order,
2520
+ self.spectral_radius_estimate,
2521
+ self.direct_coarse_size,
2522
+ )
2523
+
2524
+ @classmethod
2525
+ def tree_unflatten(cls, aux_data, children):
2526
+ (
2527
+ pre_smooth,
2528
+ post_smooth,
2529
+ coarse_smooth,
2530
+ omega_jacobi,
2531
+ smoother,
2532
+ chebyshev_order,
2533
+ spectral_radius_estimate,
2534
+ direct_coarse_size,
2535
+ ) = aux_data
2536
+ levels, coarse_lu_and_piv = children
2537
+ return cls(
2538
+ levels=tuple(levels),
2539
+ coarse_lu_and_piv=coarse_lu_and_piv,
2540
+ pre_smooth=pre_smooth,
2541
+ post_smooth=post_smooth,
2542
+ coarse_smooth=coarse_smooth,
2543
+ omega_jacobi=omega_jacobi,
2544
+ smoother=smoother,
2545
+ chebyshev_order=chebyshev_order,
2546
+ spectral_radius_estimate=spectral_radius_estimate,
2547
+ direct_coarse_size=direct_coarse_size,
2548
+ )
2549
+
2550
+
2551
+ def _zero_dirichlet_face_adjacent_cells(field: jnp.ndarray, face_bc: BoundaryFaceBC3D) -> jnp.ndarray:
2552
+ values = jnp.asarray(field, dtype=jnp.float64)
2553
+ lower_x_mask = (face_bc.kind_x[0] == BC_DIRICHLET) & face_bc.mask_x[0]
2554
+ upper_x_mask = (face_bc.kind_x[-1] == BC_DIRICHLET) & face_bc.mask_x[-1]
2555
+ lower_y_mask = (face_bc.kind_y[:, 0, :] == BC_DIRICHLET) & face_bc.mask_y[:, 0, :]
2556
+ upper_y_mask = (face_bc.kind_y[:, -1, :] == BC_DIRICHLET) & face_bc.mask_y[:, -1, :]
2557
+ lower_z_mask = (face_bc.kind_z[:, :, 0] == BC_DIRICHLET) & face_bc.mask_z[:, :, 0]
2558
+ upper_z_mask = (face_bc.kind_z[:, :, -1] == BC_DIRICHLET) & face_bc.mask_z[:, :, -1]
2559
+
2560
+ values = values.at[0, :, :].set(jnp.where(lower_x_mask, 0.0, values[0, :, :]))
2561
+ values = values.at[-1, :, :].set(jnp.where(upper_x_mask, 0.0, values[-1, :, :]))
2562
+ values = values.at[:, 0, :].set(jnp.where(lower_y_mask, 0.0, values[:, 0, :]))
2563
+ values = values.at[:, -1, :].set(jnp.where(upper_y_mask, 0.0, values[:, -1, :]))
2564
+ values = values.at[:, :, 0].set(jnp.where(lower_z_mask, 0.0, values[:, :, 0]))
2565
+ values = values.at[:, :, -1].set(jnp.where(upper_z_mask, 0.0, values[:, :, -1]))
2566
+ return values
2567
+
2568
+
2569
+ def _project_homogeneous_correction(field: jnp.ndarray, *, level: PerpLaplacianMgLevel) -> jnp.ndarray:
2570
+ values = jnp.asarray(field, dtype=jnp.float64)
2571
+ if level.has_dirichlet:
2572
+ values = _zero_dirichlet_face_adjacent_cells(values, level.face_bc)
2573
+ if level.has_nullspace:
2574
+ values = _remove_weighted_mean(values, level.geometry)
2575
+ return values
2576
+
2577
+
2578
+ def _restrict_axis_cell_centered(values: jnp.ndarray, *, axis: int) -> jnp.ndarray:
2579
+ arr = jnp.moveaxis(jnp.asarray(values, dtype=jnp.float64), axis, 0)
2580
+ even = arr[0::2]
2581
+ odd = arr[1::2]
2582
+ if odd.shape[0] < even.shape[0]:
2583
+ odd = jnp.concatenate((odd, jnp.zeros_like(even[-1:])), axis=0)
2584
+ count = jnp.concatenate(
2585
+ (
2586
+ jnp.full((even.shape[0] - 1,) + (1,) * (even.ndim - 1), 2.0, dtype=jnp.float64),
2587
+ jnp.ones((1,) + (1,) * (even.ndim - 1), dtype=jnp.float64),
2588
+ ),
2589
+ axis=0,
2590
+ )
2591
+ else:
2592
+ count = jnp.full((even.shape[0],) + (1,) * (even.ndim - 1), 2.0, dtype=jnp.float64)
2593
+ return jnp.moveaxis((even + odd) / count, 0, axis)
2594
+
2595
+
2596
+ def _restrict_axis_cell_sum(values: jnp.ndarray, *, axis: int) -> jnp.ndarray:
2597
+ arr = jnp.moveaxis(jnp.asarray(values, dtype=jnp.float64), axis, 0)
2598
+ even = arr[0::2]
2599
+ odd = arr[1::2]
2600
+ if odd.shape[0] < even.shape[0]:
2601
+ odd = jnp.concatenate((odd, jnp.zeros_like(even[-1:])), axis=0)
2602
+ return jnp.moveaxis(even + odd, 0, axis)
2603
+
2604
+
2605
+ def _prolong_axis_cell_centered(values: jnp.ndarray, *, axis: int, target_size: int, periodic: bool) -> jnp.ndarray:
2606
+ arr = jnp.moveaxis(jnp.asarray(values, dtype=jnp.float64), axis, 0)
2607
+ nc = arr.shape[0]
2608
+ out = jnp.zeros((target_size,) + arr.shape[1:], dtype=arr.dtype)
2609
+ even_indices = jnp.arange(0, target_size, 2)
2610
+ out = out.at[even_indices].set(arr[: even_indices.shape[0]])
2611
+ odd_indices = jnp.arange(1, target_size, 2)
2612
+ if odd_indices.shape[0]:
2613
+ if periodic:
2614
+ right = jnp.roll(arr, -1, axis=0)
2615
+ else:
2616
+ right = jnp.concatenate((arr[1:], arr[-1:]), axis=0) if nc > 1 else arr
2617
+ odd_values = 0.5 * (arr[: odd_indices.shape[0]] + right[: odd_indices.shape[0]])
2618
+ out = out.at[odd_indices].set(odd_values)
2619
+ return jnp.moveaxis(out, 0, axis)
2620
+
2621
+
2622
+ def _restrict_field_simple(field: jnp.ndarray, *, periodic_axes: tuple[bool, bool, bool]) -> jnp.ndarray:
2623
+ del periodic_axes
2624
+ values = jnp.asarray(field, dtype=jnp.float64)
2625
+ for axis in range(3):
2626
+ values = _restrict_axis_cell_centered(values, axis=axis)
2627
+ return values
2628
+
2629
+
2630
+ def _restrict_field_sum(field: jnp.ndarray, *, periodic_axes: tuple[bool, bool, bool]) -> jnp.ndarray:
2631
+ del periodic_axes
2632
+ values = jnp.asarray(field, dtype=jnp.float64)
2633
+ for axis in range(3):
2634
+ values = _restrict_axis_cell_sum(values, axis=axis)
2635
+ return values
2636
+
2637
+
2638
+ def _prolong_field(
2639
+ field: jnp.ndarray,
2640
+ coarse_level: PerpLaplacianMgLevel,
2641
+ fine_level: PerpLaplacianMgLevel,
2642
+ ) -> jnp.ndarray:
2643
+ values = jnp.asarray(field, dtype=jnp.float64)
2644
+ for axis in range(3):
2645
+ values = _prolong_axis_cell_centered(
2646
+ values,
2647
+ axis=axis,
2648
+ target_size=fine_level.shape[axis],
2649
+ periodic=fine_level.periodic_axes[axis],
2650
+ )
2651
+ if values.shape != fine_level.shape:
2652
+ raise ValueError(f"prolongation produced shape {values.shape}, expected {fine_level.shape}")
2653
+ return values
2654
+
2655
+
2656
+ def _restrict_residual_jweighted(
2657
+ residual: jnp.ndarray,
2658
+ fine_level: PerpLaplacianMgLevel,
2659
+ coarse_level: PerpLaplacianMgLevel,
2660
+ ) -> jnp.ndarray:
2661
+ fine_weights = _cell_volume_weights(fine_level.geometry)
2662
+ weighted_residual = _restrict_field_sum(
2663
+ fine_weights * jnp.asarray(residual, dtype=jnp.float64),
2664
+ periodic_axes=fine_level.periodic_axes,
2665
+ )
2666
+ restricted_weights = _restrict_field_sum(fine_weights, periodic_axes=fine_level.periodic_axes)
2667
+ coarse_rhs = weighted_residual / jnp.maximum(restricted_weights, 1.0e-30)
2668
+ if coarse_rhs.shape != coarse_level.shape:
2669
+ raise ValueError(f"restricted residual shape {coarse_rhs.shape} does not match coarse level {coarse_level.shape}")
2670
+ if coarse_level.has_nullspace:
2671
+ coarse_rhs = _remove_weighted_mean(coarse_rhs, coarse_level.geometry)
2672
+ return coarse_rhs
2673
+
2674
+
2675
+ def _cell_indices_for_coarsening(size: int) -> jnp.ndarray:
2676
+ return jnp.arange(0, int(size), 2, dtype=jnp.int32)
2677
+
2678
+
2679
+ def _face_indices_for_coarsening(size: int) -> jnp.ndarray:
2680
+ indices = list(range(0, int(size), 2))
2681
+ if indices[-1] != int(size) - 1:
2682
+ indices.append(int(size) - 1)
2683
+ return jnp.asarray(indices, dtype=jnp.int32)
2684
+
2685
+
2686
+ def _take_axes(values: jnp.ndarray, indices: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]) -> jnp.ndarray:
2687
+ result = jnp.asarray(values)
2688
+ for axis, index in enumerate(indices):
2689
+ result = jnp.take(result, index, axis=axis)
2690
+ return result
2691
+
2692
+
2693
+ def _coarsen_cell_average(values: jnp.ndarray) -> jnp.ndarray:
2694
+ result = jnp.asarray(values, dtype=jnp.float64)
2695
+ for axis in range(3):
2696
+ result = _restrict_axis_cell_centered(result, axis=axis)
2697
+ return result
2698
+
2699
+
2700
+ def _coarsen_cell_sum(values: jnp.ndarray) -> jnp.ndarray:
2701
+ result = jnp.asarray(values, dtype=jnp.float64)
2702
+ for axis in range(3):
2703
+ result = _restrict_axis_cell_sum(result, axis=axis)
2704
+ return result
2705
+
2706
+
2707
+ def _coarsen_cell_weighted_average(values: jnp.ndarray, weights: jnp.ndarray) -> jnp.ndarray:
2708
+ weight_values = jnp.asarray(weights, dtype=jnp.float64)
2709
+ while weight_values.ndim < jnp.asarray(values).ndim:
2710
+ weight_values = weight_values[..., None]
2711
+ weighted_sum = _coarsen_cell_sum(jnp.asarray(values, dtype=jnp.float64) * weight_values)
2712
+ weight_sum = _coarsen_cell_sum(weight_values)
2713
+ return weighted_sum / jnp.maximum(weight_sum, 1.0e-30)
2714
+
2715
+
2716
+ def _coarsen_face_field(values: jnp.ndarray, *, axis: int, reduce: str = "mean") -> jnp.ndarray:
2717
+ result = jnp.asarray(values, dtype=jnp.float64)
2718
+ normal_indices = _face_indices_for_coarsening(result.shape[axis])
2719
+ result = jnp.take(result, normal_indices, axis=axis)
2720
+ for tangent_axis in range(3):
2721
+ if tangent_axis == axis:
2722
+ continue
2723
+ if reduce == "sum":
2724
+ result = _restrict_axis_cell_sum(result, axis=tangent_axis)
2725
+ else:
2726
+ result = _restrict_axis_cell_centered(result, axis=tangent_axis)
2727
+ return result
2728
+
2729
+
2730
+ def _coarsen_face_bool(values: jnp.ndarray, *, axis: int) -> jnp.ndarray:
2731
+ result = jnp.asarray(values).astype(jnp.float64)
2732
+ result = _coarsen_face_field(result, axis=axis, reduce="mean")
2733
+ return result > 0.5
2734
+
2735
+
2736
+ def _coarsen_metric(metric, indices: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray] | None = None, weights: jnp.ndarray | None = None):
2737
+ cls = metric.__class__
2738
+ if indices is not None:
2739
+ return cls(
2740
+ J=_take_axes(metric.J, indices),
2741
+ g11=_take_axes(metric.g11, indices),
2742
+ g22=_take_axes(metric.g22, indices),
2743
+ g33=_take_axes(metric.g33, indices),
2744
+ g12=_take_axes(metric.g12, indices),
2745
+ g13=_take_axes(metric.g13, indices),
2746
+ g23=_take_axes(metric.g23, indices),
2747
+ g_11=_take_axes(metric.g_11, indices),
2748
+ g_22=_take_axes(metric.g_22, indices),
2749
+ g_33=_take_axes(metric.g_33, indices),
2750
+ g_12=_take_axes(metric.g_12, indices),
2751
+ g_13=_take_axes(metric.g_13, indices),
2752
+ g_23=_take_axes(metric.g_23, indices),
2753
+ )
2754
+ metric_weights = jnp.asarray(metric.J if weights is None else weights, dtype=jnp.float64)
2755
+ return cls(
2756
+ J=_coarsen_cell_average(metric.J),
2757
+ g11=_coarsen_cell_weighted_average(metric.g11, metric_weights),
2758
+ g22=_coarsen_cell_weighted_average(metric.g22, metric_weights),
2759
+ g33=_coarsen_cell_weighted_average(metric.g33, metric_weights),
2760
+ g12=_coarsen_cell_weighted_average(metric.g12, metric_weights),
2761
+ g13=_coarsen_cell_weighted_average(metric.g13, metric_weights),
2762
+ g23=_coarsen_cell_weighted_average(metric.g23, metric_weights),
2763
+ g_11=_coarsen_cell_weighted_average(metric.g_11, metric_weights),
2764
+ g_22=_coarsen_cell_weighted_average(metric.g_22, metric_weights),
2765
+ g_33=_coarsen_cell_weighted_average(metric.g_33, metric_weights),
2766
+ g_12=_coarsen_cell_weighted_average(metric.g_12, metric_weights),
2767
+ g_13=_coarsen_cell_weighted_average(metric.g_13, metric_weights),
2768
+ g_23=_coarsen_cell_weighted_average(metric.g_23, metric_weights),
2769
+ )
2770
+
2771
+
2772
+ def _coarsen_bfield(bfield, indices: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray] | None = None, weights: jnp.ndarray | None = None):
2773
+ cls = bfield.__class__
2774
+ if indices is not None:
2775
+ return cls(
2776
+ B_contra=_take_axes(bfield.B_contra, indices),
2777
+ Bmag=_take_axes(bfield.Bmag, indices),
2778
+ )
2779
+ b_weights = jnp.ones_like(bfield.Bmag, dtype=jnp.float64) if weights is None else jnp.asarray(weights, dtype=jnp.float64)
2780
+ return cls(
2781
+ B_contra=_coarsen_cell_weighted_average(bfield.B_contra, b_weights),
2782
+ Bmag=_coarsen_cell_weighted_average(bfield.Bmag, b_weights),
2783
+ )
2784
+
2785
+
2786
+ def _coarsen_face_metric(metric, *, axis: int):
2787
+ cls = metric.__class__
2788
+ weights = jnp.asarray(metric.J, dtype=jnp.float64)
2789
+
2790
+ def weighted(values: jnp.ndarray) -> jnp.ndarray:
2791
+ weight_values = weights
2792
+ while weight_values.ndim < jnp.asarray(values).ndim:
2793
+ weight_values = weight_values[..., None]
2794
+ numerator = _coarsen_face_field(jnp.asarray(values, dtype=jnp.float64) * weight_values, axis=axis, reduce="sum")
2795
+ denominator = _coarsen_face_field(weight_values, axis=axis, reduce="sum")
2796
+ return numerator / jnp.maximum(denominator, 1.0e-30)
2797
+
2798
+ return cls(
2799
+ J=_coarsen_face_field(metric.J, axis=axis, reduce="mean"),
2800
+ g11=weighted(metric.g11),
2801
+ g22=weighted(metric.g22),
2802
+ g33=weighted(metric.g33),
2803
+ g12=weighted(metric.g12),
2804
+ g13=weighted(metric.g13),
2805
+ g23=weighted(metric.g23),
2806
+ g_11=weighted(metric.g_11),
2807
+ g_22=weighted(metric.g_22),
2808
+ g_33=weighted(metric.g_33),
2809
+ g_12=weighted(metric.g_12),
2810
+ g_13=weighted(metric.g_13),
2811
+ g_23=weighted(metric.g_23),
2812
+ )
2813
+
2814
+
2815
+ def _coarsen_face_bfield(bfield, *, axis: int, weights: jnp.ndarray):
2816
+ cls = bfield.__class__
2817
+ face_weights = jnp.asarray(weights, dtype=jnp.float64)
2818
+
2819
+ def weighted(values: jnp.ndarray) -> jnp.ndarray:
2820
+ weight_values = face_weights
2821
+ while weight_values.ndim < jnp.asarray(values).ndim:
2822
+ weight_values = weight_values[..., None]
2823
+ numerator = _coarsen_face_field(jnp.asarray(values, dtype=jnp.float64) * weight_values, axis=axis, reduce="sum")
2824
+ denominator = _coarsen_face_field(weight_values, axis=axis, reduce="sum")
2825
+ return numerator / jnp.maximum(denominator, 1.0e-30)
2826
+
2827
+ return cls(
2828
+ B_contra=weighted(bfield.B_contra),
2829
+ Bmag=weighted(bfield.Bmag),
2830
+ )
2831
+
2832
+
2833
+ def _coarsen_geometry(geometry: FciGeometry3D) -> FciGeometry3D:
2834
+ cell_indices = tuple(_cell_indices_for_coarsening(size) for size in geometry.shape)
2835
+ grid = CellCenteredGrid3D(
2836
+ x=Grid1D.from_centers(jnp.take(geometry.grid.x.centers, cell_indices[0])),
2837
+ y=Grid1D.from_centers(jnp.take(geometry.grid.y.centers, cell_indices[1])),
2838
+ z=Grid1D.from_centers(jnp.take(geometry.grid.z.centers, cell_indices[2])),
2839
+ )
2840
+ shape = grid.shape
2841
+ maps = FciMaps3D(
2842
+ forward_x=_take_axes(geometry.maps.forward_x, cell_indices),
2843
+ forward_y=_take_axes(geometry.maps.forward_y, cell_indices),
2844
+ backward_x=_take_axes(geometry.maps.backward_x, cell_indices),
2845
+ backward_y=_take_axes(geometry.maps.backward_y, cell_indices),
2846
+ forward_endpoint_x=_take_axes(geometry.maps.forward_endpoint_x, cell_indices),
2847
+ forward_endpoint_y=_take_axes(geometry.maps.forward_endpoint_y, cell_indices),
2848
+ forward_endpoint_z=_take_axes(geometry.maps.forward_endpoint_z, cell_indices),
2849
+ backward_endpoint_x=_take_axes(geometry.maps.backward_endpoint_x, cell_indices),
2850
+ backward_endpoint_y=_take_axes(geometry.maps.backward_endpoint_y, cell_indices),
2851
+ backward_endpoint_z=_take_axes(geometry.maps.backward_endpoint_z, cell_indices),
2852
+ forward_length=2.0 * _take_axes(geometry.maps.forward_length, cell_indices),
2853
+ backward_length=2.0 * _take_axes(geometry.maps.backward_length, cell_indices),
2854
+ forward_boundary=_take_axes(geometry.maps.forward_boundary, cell_indices).astype(bool),
2855
+ backward_boundary=_take_axes(geometry.maps.backward_boundary, cell_indices).astype(bool),
2856
+ )
2857
+ spacing = Spacing3D(
2858
+ dx=jnp.broadcast_to(grid.x.widths[:, None, None], shape),
2859
+ dy=jnp.broadcast_to(grid.y.widths[None, :, None], shape),
2860
+ dz=jnp.broadcast_to(grid.z.widths[None, None, :], shape),
2861
+ )
2862
+ return FciGeometry3D(
2863
+ grid=grid,
2864
+ maps=maps,
2865
+ spacing=spacing,
2866
+ cell_metric=_coarsen_metric(geometry.cell_metric),
2867
+ face_metric=FaceMetricGeometry(
2868
+ x=_coarsen_face_metric(geometry.face_metric.x, axis=0),
2869
+ y=_coarsen_face_metric(geometry.face_metric.y, axis=1),
2870
+ z=_coarsen_face_metric(geometry.face_metric.z, axis=2),
2871
+ ),
2872
+ cell_bfield=_coarsen_bfield(geometry.cell_bfield, weights=geometry.cell_metric.J),
2873
+ face_bfield=FaceBFieldGeometry(
2874
+ x=_coarsen_face_bfield(geometry.face_bfield.x, axis=0, weights=geometry.face_metric.x.J),
2875
+ y=_coarsen_face_bfield(geometry.face_bfield.y, axis=1, weights=geometry.face_metric.y.J),
2876
+ z=_coarsen_face_bfield(geometry.face_bfield.z, axis=2, weights=geometry.face_metric.z.J),
2877
+ ),
2878
+ )
2879
+
2880
+
2881
+ def _coarsen_regular_face_geometry(regular_face_geometry: RegularFaceGeometry3D) -> RegularFaceGeometry3D:
2882
+ return RegularFaceGeometry3D(
2883
+ x_area=_coarsen_face_field(regular_face_geometry.x_area, axis=0, reduce="mean"),
2884
+ y_area=_coarsen_face_field(regular_face_geometry.y_area, axis=1, reduce="mean"),
2885
+ z_area=_coarsen_face_field(regular_face_geometry.z_area, axis=2, reduce="mean"),
2886
+ x_area_fraction=_coarsen_face_field(regular_face_geometry.x_area_fraction, axis=0, reduce="mean"),
2887
+ y_area_fraction=_coarsen_face_field(regular_face_geometry.y_area_fraction, axis=1, reduce="mean"),
2888
+ z_area_fraction=_coarsen_face_field(regular_face_geometry.z_area_fraction, axis=2, reduce="mean"),
2889
+ x_open_mask=_coarsen_face_bool(regular_face_geometry.x_open_mask, axis=0),
2890
+ y_open_mask=_coarsen_face_bool(regular_face_geometry.y_open_mask, axis=1),
2891
+ z_open_mask=_coarsen_face_bool(regular_face_geometry.z_open_mask, axis=2),
2892
+ )
2893
+
2894
+
2895
+ def _coarsen_face_bc(face_bc: BoundaryFaceBC3D) -> BoundaryFaceBC3D:
2896
+ def kind(values: jnp.ndarray, *, axis: int) -> jnp.ndarray:
2897
+ return jnp.rint(_coarsen_face_field(values, axis=axis, reduce="mean")).astype(jnp.int32)
2898
+
2899
+ def mask(values: jnp.ndarray, *, axis: int) -> jnp.ndarray:
2900
+ return _coarsen_face_bool(values, axis=axis)
2901
+
2902
+ return BoundaryFaceBC3D(
2903
+ kind_x=kind(face_bc.kind_x, axis=0),
2904
+ kind_y=kind(face_bc.kind_y, axis=1),
2905
+ kind_z=kind(face_bc.kind_z, axis=2),
2906
+ value_x=_coarsen_face_field(face_bc.value_x, axis=0, reduce="mean"),
2907
+ value_y=_coarsen_face_field(face_bc.value_y, axis=1, reduce="mean"),
2908
+ value_z=_coarsen_face_field(face_bc.value_z, axis=2, reduce="mean"),
2909
+ mask_x=mask(face_bc.mask_x, axis=0),
2910
+ mask_y=mask(face_bc.mask_y, axis=1),
2911
+ mask_z=mask(face_bc.mask_z, axis=2),
2912
+ )
2913
+
2914
+
2915
+ def _build_approx_diag_inv(
2916
+ geometry: FciGeometry3D,
2917
+ face_bc: BoundaryFaceBC3D,
2918
+ regular_face_geometry: RegularFaceGeometry3D,
2919
+ face_projectors: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray],
2920
+ *,
2921
+ floor: float = 1.0e-12,
2922
+ ) -> jnp.ndarray:
2923
+ jac = jnp.maximum(jnp.asarray(geometry.cell_metric.J, dtype=jnp.float64), floor)
2924
+ dx2 = jnp.maximum(jnp.asarray(geometry.spacing.dx, dtype=jnp.float64) ** 2, floor)
2925
+ dy2 = jnp.maximum(jnp.asarray(geometry.spacing.dy, dtype=jnp.float64) ** 2, floor)
2926
+ dz2 = jnp.maximum(jnp.asarray(geometry.spacing.dz, dtype=jnp.float64) ** 2, floor)
2927
+
2928
+ x_face_coeff = (
2929
+ jnp.asarray(geometry.face_metric.x.J, dtype=jnp.float64)
2930
+ * jnp.asarray(regular_face_geometry.x_area, dtype=jnp.float64)
2931
+ * jnp.asarray(regular_face_geometry.x_area_fraction, dtype=jnp.float64)
2932
+ * jnp.asarray(regular_face_geometry.x_open_mask, dtype=jnp.float64)
2933
+ * jnp.asarray(face_projectors[0][..., 0, 0], dtype=jnp.float64)
2934
+ )
2935
+ y_face_coeff = (
2936
+ jnp.asarray(geometry.face_metric.y.J, dtype=jnp.float64)
2937
+ * jnp.asarray(regular_face_geometry.y_area, dtype=jnp.float64)
2938
+ * jnp.asarray(regular_face_geometry.y_area_fraction, dtype=jnp.float64)
2939
+ * jnp.asarray(regular_face_geometry.y_open_mask, dtype=jnp.float64)
2940
+ * jnp.asarray(face_projectors[1][..., 1, 1], dtype=jnp.float64)
2941
+ )
2942
+ z_face_coeff = (
2943
+ jnp.asarray(geometry.face_metric.z.J, dtype=jnp.float64)
2944
+ * jnp.asarray(regular_face_geometry.z_area, dtype=jnp.float64)
2945
+ * jnp.asarray(regular_face_geometry.z_area_fraction, dtype=jnp.float64)
2946
+ * jnp.asarray(regular_face_geometry.z_open_mask, dtype=jnp.float64)
2947
+ * jnp.asarray(face_projectors[2][..., 2, 2], dtype=jnp.float64)
2948
+ )
2949
+
2950
+ diag = jnp.abs(
2951
+ (
2952
+ x_face_coeff[:-1, :, :] + x_face_coeff[1:, :, :]
2953
+ )
2954
+ / dx2
2955
+ + (
2956
+ y_face_coeff[:, :-1, :] + y_face_coeff[:, 1:, :]
2957
+ )
2958
+ / dy2
2959
+ + (
2960
+ z_face_coeff[:, :, :-1] + z_face_coeff[:, :, 1:]
2961
+ )
2962
+ / dz2
2963
+ )
2964
+ diag = diag / jac
2965
+ lower_x_mask = (face_bc.kind_x[0] == BC_DIRICHLET) & face_bc.mask_x[0]
2966
+ upper_x_mask = (face_bc.kind_x[-1] == BC_DIRICHLET) & face_bc.mask_x[-1]
2967
+ lower_y_mask = (face_bc.kind_y[:, 0, :] == BC_DIRICHLET) & face_bc.mask_y[:, 0, :]
2968
+ upper_y_mask = (face_bc.kind_y[:, -1, :] == BC_DIRICHLET) & face_bc.mask_y[:, -1, :]
2969
+ lower_z_mask = (face_bc.kind_z[:, :, 0] == BC_DIRICHLET) & face_bc.mask_z[:, :, 0]
2970
+ upper_z_mask = (face_bc.kind_z[:, :, -1] == BC_DIRICHLET) & face_bc.mask_z[:, :, -1]
2971
+
2972
+ diag = diag.at[0, :, :].set(jnp.where(lower_x_mask, 1.0, diag[0, :, :]))
2973
+ diag = diag.at[-1, :, :].set(jnp.where(upper_x_mask, 1.0, diag[-1, :, :]))
2974
+ diag = diag.at[:, 0, :].set(jnp.where(lower_y_mask, 1.0, diag[:, 0, :]))
2975
+ diag = diag.at[:, -1, :].set(jnp.where(upper_y_mask, 1.0, diag[:, -1, :]))
2976
+ diag = diag.at[:, :, 0].set(jnp.where(lower_z_mask, 1.0, diag[:, :, 0]))
2977
+ diag = diag.at[:, :, -1].set(jnp.where(upper_z_mask, 1.0, diag[:, :, -1]))
2978
+ return 1.0 / jnp.maximum(diag, floor)
2979
+
2980
+
2981
+ def _can_coarsen_axis(size: int, *, periodic: bool) -> bool:
2982
+ del periodic
2983
+ return int(size) >= 4
2984
+
2985
+
2986
+ def _can_coarsen_shape(shape: tuple[int, int, int], *, periodic_axes: tuple[bool, bool, bool]) -> bool:
2987
+ return all(_can_coarsen_axis(shape[axis], periodic=periodic_axes[axis]) for axis in range(3))
2988
+
2989
+
2990
+ def _mg_apply_negative_perp_laplacian(field: jnp.ndarray, level: PerpLaplacianMgLevel) -> jnp.ndarray:
2991
+ values = jnp.asarray(field, dtype=jnp.float64)
2992
+ if values.shape != level.shape:
2993
+ raise ValueError(f"field must have shape {level.shape}, got {values.shape}")
2994
+ if level.has_nullspace:
2995
+ values = _remove_weighted_mean(values, level.geometry)
2996
+ local = level.stencil_builder(
2997
+ values,
2998
+ level.geometry,
2999
+ periodic_axes=level.periodic_axes,
3000
+ face_bc=level.face_bc,
3001
+ )
3002
+ result = -perp_laplacian_conservative_op(
3003
+ local,
3004
+ level.geometry,
3005
+ face_projectors=level.face_projectors,
3006
+ face_bc=level.face_bc,
3007
+ regular_face_geometry=level.regular_face_geometry,
3008
+ cut_wall_geometry=level.cut_wall_geometry,
3009
+ cut_wall_bc=level.cut_wall_bc,
3010
+ periodic_axes=level.periodic_axes,
3011
+ )
3012
+ if level.has_nullspace:
3013
+ result = _remove_weighted_mean(result, level.geometry)
3014
+ return result
3015
+
3016
+
3017
+ def _jacobi_smooth_once(
3018
+ x: jnp.ndarray,
3019
+ rhs: jnp.ndarray,
3020
+ *,
3021
+ level: PerpLaplacianMgLevel,
3022
+ omega_jacobi: float,
3023
+ ) -> jnp.ndarray:
3024
+ residual = rhs - _mg_apply_negative_perp_laplacian(x, level)
3025
+ x_new = x + float(omega_jacobi) * level.diag_inv * residual
3026
+ return _project_homogeneous_correction(x_new, level=level)
3027
+
3028
+
3029
+ def _jacobi_smooth(
3030
+ x: jnp.ndarray,
3031
+ rhs: jnp.ndarray,
3032
+ *,
3033
+ level: PerpLaplacianMgLevel,
3034
+ nsweeps: int,
3035
+ omega_jacobi: float,
3036
+ ) -> jnp.ndarray:
3037
+ def body(_, state):
3038
+ return _jacobi_smooth_once(state, rhs, level=level, omega_jacobi=omega_jacobi)
3039
+
3040
+ return jax.lax.fori_loop(0, int(nsweeps), body, jnp.asarray(x, dtype=jnp.float64))
3041
+
3042
+
3043
+ def _chebyshev_smooth(
3044
+ x: jnp.ndarray,
3045
+ rhs: jnp.ndarray,
3046
+ *,
3047
+ level: PerpLaplacianMgLevel,
3048
+ nsweeps: int,
3049
+ chebyshev_order: int,
3050
+ spectral_radius_estimate: float | None,
3051
+ ) -> jnp.ndarray:
3052
+ order = max(1, int(chebyshev_order))
3053
+ rho = float(spectral_radius_estimate) if spectral_radius_estimate is not None else 1.8
3054
+ lambda_max = max(rho, 1.0e-12)
3055
+ lambda_min = 0.1 * lambda_max
3056
+ center = 0.5 * (lambda_max + lambda_min)
3057
+ radius = 0.5 * (lambda_max - lambda_min)
3058
+
3059
+ def step(iteration, state):
3060
+ theta = jnp.pi * (2.0 * (iteration % order) + 1.0) / (2.0 * order)
3061
+ damping = 1.0 / jnp.maximum(center - radius * jnp.cos(theta), 1.0e-12)
3062
+ residual = rhs - _mg_apply_negative_perp_laplacian(state, level)
3063
+ state = state + damping * level.diag_inv * residual
3064
+ return _project_homogeneous_correction(state, level=level)
3065
+
3066
+ return jax.lax.fori_loop(0, max(0, int(nsweeps)) * order, step, jnp.asarray(x, dtype=jnp.float64))
3067
+
3068
+
3069
+ def _smooth(
3070
+ x: jnp.ndarray,
3071
+ rhs: jnp.ndarray,
3072
+ *,
3073
+ level: PerpLaplacianMgLevel,
3074
+ nsweeps: int,
3075
+ hierarchy: PerpLaplacianMgHierarchy,
3076
+ ) -> jnp.ndarray:
3077
+ if hierarchy.smoother == "jacobi":
3078
+ return _jacobi_smooth(
3079
+ x,
3080
+ rhs,
3081
+ level=level,
3082
+ nsweeps=nsweeps,
3083
+ omega_jacobi=hierarchy.omega_jacobi,
3084
+ )
3085
+ if hierarchy.smoother == "chebyshev":
3086
+ return _chebyshev_smooth(
3087
+ x,
3088
+ rhs,
3089
+ level=level,
3090
+ nsweeps=nsweeps,
3091
+ chebyshev_order=hierarchy.chebyshev_order,
3092
+ spectral_radius_estimate=hierarchy.spectral_radius_estimate,
3093
+ )
3094
+ raise ValueError(f"unknown multigrid smoother {hierarchy.smoother!r}")
3095
+
3096
+
3097
+ def _assemble_coarse_matrix(level: PerpLaplacianMgLevel) -> jnp.ndarray:
3098
+ """Dense coarse-level operator (with inactive rows/cols set to identity and
3099
+ the nullspace rank-one shift applied when present)."""
3100
+
3101
+ n_values = int(level.shape[0] * level.shape[1] * level.shape[2])
3102
+ identity = jnp.eye(n_values, dtype=jnp.float64)
3103
+
3104
+ def apply_basis(column: jnp.ndarray) -> jnp.ndarray:
3105
+ basis = jnp.reshape(column, level.shape)
3106
+ basis = _project_homogeneous_correction(basis, level=level)
3107
+ return jnp.ravel(_project_homogeneous_correction(_mg_apply_negative_perp_laplacian(basis, level), level=level))
3108
+
3109
+ matrix = jax.vmap(apply_basis, in_axes=1, out_axes=1)(identity)
3110
+ active_mask = _coarse_active_mask(level)
3111
+ active_matrix_mask = active_mask[:, None] & active_mask[None, :]
3112
+ matrix = jnp.where(active_matrix_mask, matrix, identity)
3113
+ if level.has_nullspace:
3114
+ weights = _coarse_nullspace_weights(level)
3115
+ matrix = matrix + jnp.outer(jnp.ones_like(weights), weights)
3116
+ return matrix
3117
+
3118
+
3119
+ def _coarse_active_mask(level: PerpLaplacianMgLevel) -> jnp.ndarray:
3120
+ return jnp.ravel(jnp.abs(_project_homogeneous_correction(jnp.ones(level.shape, dtype=jnp.float64), level=level)) > 0.0)
3121
+
3122
+
3123
+ def _coarse_nullspace_weights(level: PerpLaplacianMgLevel) -> jnp.ndarray:
3124
+ weights = jnp.ravel(_cell_volume_weights(level.geometry))
3125
+ return weights / jnp.maximum(jnp.sum(weights), 1.0e-30)
3126
+
3127
+
3128
+ def _direct_coarse_solve(
3129
+ rhs: jnp.ndarray,
3130
+ level: PerpLaplacianMgLevel,
3131
+ lu_and_piv: tuple[jnp.ndarray, jnp.ndarray] | None = None,
3132
+ ) -> jnp.ndarray:
3133
+ rhs_values = _project_homogeneous_correction(rhs, level=level)
3134
+ flat_rhs = jnp.ravel(rhs_values)
3135
+ flat_rhs = jnp.where(_coarse_active_mask(level), flat_rhs, 0.0)
3136
+ if level.has_nullspace:
3137
+ weights = _coarse_nullspace_weights(level)
3138
+ flat_rhs = flat_rhs - jnp.sum(weights * flat_rhs)
3139
+
3140
+ if lu_and_piv is not None:
3141
+ solution = jax.scipy.linalg.lu_solve(lu_and_piv, flat_rhs)
3142
+ else:
3143
+ solution = jnp.linalg.solve(_assemble_coarse_matrix(level), flat_rhs)
3144
+ solution = jnp.reshape(solution, level.shape)
3145
+ return _project_homogeneous_correction(solution, level=level)
3146
+
3147
+
3148
+ def _mg_vcycle(
3149
+ level_index: int,
3150
+ x: jnp.ndarray,
3151
+ rhs: jnp.ndarray,
3152
+ hierarchy: PerpLaplacianMgHierarchy,
3153
+ ) -> jnp.ndarray:
3154
+ level = hierarchy.levels[level_index]
3155
+ x = _project_homogeneous_correction(x, level=level)
3156
+ rhs = _project_homogeneous_correction(rhs, level=level)
3157
+
3158
+ if level_index == len(hierarchy.levels) - 1:
3159
+ if int(level.shape[0] * level.shape[1] * level.shape[2]) <= int(hierarchy.direct_coarse_size):
3160
+ return _direct_coarse_solve(rhs, level, hierarchy.coarse_lu_and_piv)
3161
+ return _smooth(x, rhs, level=level, nsweeps=hierarchy.coarse_smooth, hierarchy=hierarchy)
3162
+
3163
+ x = _smooth(x, rhs, level=level, nsweeps=hierarchy.pre_smooth, hierarchy=hierarchy)
3164
+ residual = rhs - _mg_apply_negative_perp_laplacian(x, level)
3165
+ residual = _project_homogeneous_correction(residual, level=level)
3166
+
3167
+ coarse_level = hierarchy.levels[level_index + 1]
3168
+ coarse_rhs = _restrict_residual_jweighted(residual, level, coarse_level)
3169
+ coarse_rhs = _project_homogeneous_correction(coarse_rhs, level=coarse_level)
3170
+ coarse_error = jnp.zeros_like(coarse_rhs)
3171
+ coarse_error = _mg_vcycle(level_index + 1, coarse_error, coarse_rhs, hierarchy)
3172
+ fine_correction = _prolong_field(coarse_error, coarse_level, level)
3173
+ fine_correction = _project_homogeneous_correction(fine_correction, level=level)
3174
+ x = x + fine_correction
3175
+ x = _smooth(x, rhs, level=level, nsweeps=hierarchy.post_smooth, hierarchy=hierarchy)
3176
+ return _project_homogeneous_correction(x, level=level)
3177
+
3178
+
3179
+ def _build_mg_level(
3180
+ geometry: FciGeometry3D,
3181
+ *,
3182
+ stencil_builder: ConservativeStencilBuilder,
3183
+ face_bc: BoundaryFaceBC3D,
3184
+ regular_face_geometry: RegularFaceGeometry3D,
3185
+ cut_wall_geometry: CutWallGeometry3D,
3186
+ cut_wall_bc: CutWallBC3D,
3187
+ periodic_axes: tuple[bool, bool, bool],
3188
+ axis_regular_axes: tuple[bool, bool, bool],
3189
+ b_floor: float,
3190
+ ) -> PerpLaplacianMgLevel:
3191
+ has_dirichlet = _has_dirichlet_regular_faces(face_bc) or _has_dirichlet_cut_walls(cut_wall_bc)
3192
+ has_nullspace = not has_dirichlet
3193
+ face_projectors = build_perp_laplacian_face_projectors(
3194
+ geometry,
3195
+ b_floor=b_floor,
3196
+ axis_regular_axes=axis_regular_axes,
3197
+ )
3198
+ return PerpLaplacianMgLevel(
3199
+ geometry=geometry,
3200
+ stencil_builder=stencil_builder,
3201
+ face_bc=face_bc,
3202
+ regular_face_geometry=regular_face_geometry,
3203
+ cut_wall_geometry=cut_wall_geometry,
3204
+ cut_wall_bc=cut_wall_bc,
3205
+ face_projectors=face_projectors,
3206
+ diag_inv=_build_approx_diag_inv(geometry, face_bc, regular_face_geometry, face_projectors),
3207
+ periodic_axes=periodic_axes,
3208
+ has_dirichlet=has_dirichlet,
3209
+ has_nullspace=has_nullspace,
3210
+ )
3211
+
3212
+
3213
+ def build_perp_laplacian_mg_hierarchy(
3214
+ geometry: FciGeometry3D,
3215
+ stencil_builder: ConservativeStencilBuilder = build_conservative_stencil_from_field,
3216
+ *,
3217
+ face_bc: BoundaryFaceBC3D | None = None,
3218
+ regular_face_geometry: RegularFaceGeometry3D | None = None,
3219
+ cut_wall_geometry: CutWallGeometry3D | None = None,
3220
+ cut_wall_bc: CutWallBC3D | None = None,
3221
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
3222
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
3223
+ max_levels: int | None = None,
3224
+ require_even_axis_sizes: tuple[bool, bool, bool] = (False, False, False),
3225
+ pre_smooth: int = 2,
3226
+ post_smooth: int = 2,
3227
+ coarse_smooth: int = 16,
3228
+ omega_jacobi: float = 0.65,
3229
+ smoother: Literal["jacobi", "chebyshev"] = "chebyshev",
3230
+ chebyshev_order: int = 2,
3231
+ spectral_radius_estimate: float | None = None,
3232
+ direct_coarse_size: int = 512,
3233
+ b_floor: float = 1.0e-30,
3234
+ ) -> PerpLaplacianMgHierarchy:
3235
+ """Build a reusable regular-face V-cycle hierarchy for ``-L_perp``."""
3236
+
3237
+ if not isinstance(stencil_builder, ConservativeStencilBuilder):
3238
+ raise TypeError("stencil_builder must be a ConservativeStencilBuilder instance")
3239
+ periodic_axes = tuple(bool(value) for value in periodic_axes)
3240
+ axis_regular_axes = tuple(bool(value) for value in axis_regular_axes)
3241
+ regular_face_geometry = regular_face_geometry or RegularFaceGeometry3D.unit(geometry)
3242
+ face_bc = face_bc or BoundaryFaceBC3D.empty(regular_face_geometry)
3243
+ cut_wall_geometry = cut_wall_geometry or CutWallGeometry3D.empty()
3244
+ cut_wall_bc = cut_wall_bc or CutWallBC3D.empty()
3245
+ if cut_wall_geometry.n_wall_faces or cut_wall_bc.n_wall_faces:
3246
+ raise NotImplementedError("multigrid coarsening for non-empty cut-wall payloads is not implemented")
3247
+ require_even_axis_sizes = tuple(bool(value) for value in require_even_axis_sizes)
3248
+ if len(require_even_axis_sizes) != 3:
3249
+ raise ValueError(f"require_even_axis_sizes must have length 3, got {require_even_axis_sizes}")
3250
+
3251
+ levels: list[PerpLaplacianMgLevel] = []
3252
+ current_geometry = geometry
3253
+ current_face_bc = face_bc
3254
+ current_regular_face_geometry = regular_face_geometry
3255
+ current_cut_wall_geometry = cut_wall_geometry
3256
+ current_cut_wall_bc = cut_wall_bc
3257
+ while True:
3258
+ levels.append(
3259
+ _build_mg_level(
3260
+ current_geometry,
3261
+ stencil_builder=stencil_builder,
3262
+ face_bc=current_face_bc,
3263
+ regular_face_geometry=current_regular_face_geometry,
3264
+ cut_wall_geometry=current_cut_wall_geometry,
3265
+ cut_wall_bc=current_cut_wall_bc,
3266
+ periodic_axes=periodic_axes,
3267
+ axis_regular_axes=axis_regular_axes,
3268
+ b_floor=b_floor,
3269
+ )
3270
+ )
3271
+ if max_levels is not None and len(levels) >= int(max_levels):
3272
+ break
3273
+ if not _can_coarsen_shape(current_geometry.shape, periodic_axes=periodic_axes):
3274
+ break
3275
+ next_shape = tuple(int(size // 2) for size in current_geometry.shape)
3276
+ if any(require_even_axis_sizes[axis] and (next_shape[axis] % 2) for axis in range(3)):
3277
+ break
3278
+ current_geometry = _coarsen_geometry(current_geometry)
3279
+ current_face_bc = _coarsen_face_bc(current_face_bc)
3280
+ current_regular_face_geometry = _coarsen_regular_face_geometry(current_regular_face_geometry)
3281
+ current_cut_wall_geometry = CutWallGeometry3D.empty()
3282
+ current_cut_wall_bc = CutWallBC3D.empty()
3283
+
3284
+ coarse_lu_and_piv = None
3285
+ coarsest = levels[-1]
3286
+ if int(coarsest.shape[0] * coarsest.shape[1] * coarsest.shape[2]) <= int(direct_coarse_size):
3287
+ lu, piv = jax.scipy.linalg.lu_factor(_assemble_coarse_matrix(coarsest))
3288
+ coarse_lu_and_piv = (lu, piv)
3289
+ return PerpLaplacianMgHierarchy(
3290
+ levels=tuple(levels),
3291
+ pre_smooth=int(pre_smooth),
3292
+ post_smooth=int(post_smooth),
3293
+ coarse_smooth=int(coarse_smooth),
3294
+ omega_jacobi=float(omega_jacobi),
3295
+ smoother=smoother,
3296
+ chebyshev_order=int(chebyshev_order),
3297
+ spectral_radius_estimate=spectral_radius_estimate,
3298
+ direct_coarse_size=int(direct_coarse_size),
3299
+ coarse_lu_and_piv=coarse_lu_and_piv,
3300
+ )
3301
+
3302
+
3303
+ def build_perp_laplacian_solver_mg_hierarchy(
3304
+ geometry: FciGeometry3D,
3305
+ stencil_builder: ConservativeStencilBuilder = build_conservative_stencil_from_field,
3306
+ *,
3307
+ face_bc: BoundaryFaceBC3D | None = None,
3308
+ cut_wall_bc: CutWallBC3D | None = None,
3309
+ lifted: bool = False,
3310
+ regular_face_geometry: RegularFaceGeometry3D | None = None,
3311
+ cut_wall_geometry: CutWallGeometry3D | None = None,
3312
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
3313
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
3314
+ max_levels: int | None = None,
3315
+ require_even_axis_sizes: tuple[bool, bool, bool] = (False, True, False),
3316
+ pre_smooth: int = 2,
3317
+ post_smooth: int = 2,
3318
+ coarse_smooth: int = 16,
3319
+ omega_jacobi: float = 0.65,
3320
+ smoother: Literal["jacobi", "chebyshev"] = "chebyshev",
3321
+ chebyshev_order: int = 2,
3322
+ spectral_radius_estimate: float | None = None,
3323
+ direct_coarse_size: int = 512,
3324
+ b_floor: float = 1.0e-30,
3325
+ ) -> PerpLaplacianMgHierarchy:
3326
+ """Build an MG hierarchy for the exact linear operator used by the solver."""
3327
+
3328
+ regular_face_geometry = regular_face_geometry or RegularFaceGeometry3D.unit(geometry)
3329
+ face_bc = face_bc or BoundaryFaceBC3D.empty(regular_face_geometry)
3330
+ cut_wall_bc = cut_wall_bc or CutWallBC3D.empty()
3331
+ if lifted:
3332
+ linear_face_bc = _dirichlet_lift_correction_face_bc(face_bc)
3333
+ linear_cut_wall_bc = _dirichlet_lift_correction_cut_wall_bc(cut_wall_bc)
3334
+ else:
3335
+ linear_face_bc, linear_cut_wall_bc = _homogeneous_boundary_payload(face_bc, cut_wall_bc)
3336
+ return build_perp_laplacian_mg_hierarchy(
3337
+ geometry,
3338
+ stencil_builder,
3339
+ face_bc=linear_face_bc,
3340
+ regular_face_geometry=regular_face_geometry,
3341
+ cut_wall_geometry=cut_wall_geometry,
3342
+ cut_wall_bc=linear_cut_wall_bc,
3343
+ periodic_axes=periodic_axes,
3344
+ axis_regular_axes=axis_regular_axes,
3345
+ max_levels=max_levels,
3346
+ require_even_axis_sizes=require_even_axis_sizes,
3347
+ pre_smooth=pre_smooth,
3348
+ post_smooth=post_smooth,
3349
+ coarse_smooth=coarse_smooth,
3350
+ omega_jacobi=omega_jacobi,
3351
+ smoother=smoother,
3352
+ chebyshev_order=chebyshev_order,
3353
+ spectral_radius_estimate=spectral_radius_estimate,
3354
+ direct_coarse_size=direct_coarse_size,
3355
+ b_floor=b_floor,
3356
+ )
3357
+
3358
+
3359
+ def _face_bc_equal(left: BoundaryFaceBC3D, right: BoundaryFaceBC3D) -> bool:
3360
+ return (
3361
+ bool(jnp.array_equal(left.kind_x, right.kind_x))
3362
+ and bool(jnp.array_equal(left.kind_y, right.kind_y))
3363
+ and bool(jnp.array_equal(left.kind_z, right.kind_z))
3364
+ and bool(jnp.array_equal(left.mask_x, right.mask_x))
3365
+ and bool(jnp.array_equal(left.mask_y, right.mask_y))
3366
+ and bool(jnp.array_equal(left.mask_z, right.mask_z))
3367
+ and bool(jnp.allclose(left.value_x, right.value_x, rtol=0.0, atol=0.0))
3368
+ and bool(jnp.allclose(left.value_y, right.value_y, rtol=0.0, atol=0.0))
3369
+ and bool(jnp.allclose(left.value_z, right.value_z, rtol=0.0, atol=0.0))
3370
+ )
3371
+
3372
+
3373
+ def _face_bc_values_are_zero(face_bc: BoundaryFaceBC3D) -> bool:
3374
+ return (
3375
+ bool(jnp.allclose(face_bc.value_x, 0.0, rtol=0.0, atol=0.0))
3376
+ and bool(jnp.allclose(face_bc.value_y, 0.0, rtol=0.0, atol=0.0))
3377
+ and bool(jnp.allclose(face_bc.value_z, 0.0, rtol=0.0, atol=0.0))
3378
+ )
3379
+
3380
+
3381
+ def _cut_wall_bc_values_are_zero(cut_wall_bc: CutWallBC3D) -> bool:
3382
+ return bool(jnp.allclose(jnp.asarray(cut_wall_bc.value, dtype=jnp.float64), 0.0, rtol=0.0, atol=0.0))
3383
+
3384
+
3385
+ def _validate_mg_hierarchy_for_linear_operator(
3386
+ hierarchy: PerpLaplacianMgHierarchy,
3387
+ *,
3388
+ geometry: FciGeometry3D,
3389
+ periodic_axes: tuple[bool, bool, bool],
3390
+ face_bc: BoundaryFaceBC3D,
3391
+ cut_wall_bc: CutWallBC3D,
3392
+ ) -> None:
3393
+ level0 = hierarchy.levels[0]
3394
+ if level0.shape != geometry.shape:
3395
+ raise ValueError(f"mg_hierarchy level-0 shape must be {geometry.shape}, got {level0.shape}")
3396
+ if tuple(level0.periodic_axes) != tuple(periodic_axes):
3397
+ raise ValueError("mg_hierarchy periodic_axes must match the inverse solver")
3398
+ if level0.cut_wall_geometry.n_wall_faces or level0.cut_wall_bc.n_wall_faces or cut_wall_bc.n_wall_faces:
3399
+ raise NotImplementedError("multigrid preconditioning is only supported for empty cut-wall payloads")
3400
+ if not _face_bc_values_are_zero(face_bc) or not _cut_wall_bc_values_are_zero(cut_wall_bc):
3401
+ raise ValueError("multigrid preconditioning requires zero-valued linear boundary payloads")
3402
+ if not _face_bc_equal(level0.face_bc, face_bc):
3403
+ raise ValueError("mg_hierarchy face_bc must match the inverse solver linear boundary payload")
3404
+
3405
+
3406
+ def mg_apply_preconditioner(rhs: jnp.ndarray, hierarchy: PerpLaplacianMgHierarchy) -> jnp.ndarray:
3407
+ """Apply one multigrid V-cycle as a Lineax-compatible preconditioner."""
3408
+
3409
+ if not isinstance(hierarchy, PerpLaplacianMgHierarchy):
3410
+ raise TypeError("hierarchy must be a PerpLaplacianMgHierarchy")
3411
+ level0 = hierarchy.levels[0]
3412
+ rhs_values = jnp.asarray(rhs, dtype=jnp.float64)
3413
+ if rhs_values.shape != level0.shape:
3414
+ raise ValueError(f"rhs must have shape {level0.shape}, got {rhs_values.shape}")
3415
+ rhs_values = _project_homogeneous_correction(rhs_values, level=level0)
3416
+ return _mg_vcycle(0, jnp.zeros_like(rhs_values), rhs_values, hierarchy)
3417
+
3418
+
3419
+ class PerpLaplacianInverseSolver:
3420
+ """Reusable Lineax solve adapter for repeated perpendicular-Laplacian inversions.
3421
+
3422
+ The object builds one stable jitted solve closure per geometry/operator
3423
+ payload. Stage-dependent RHS, initial guess, and boundary values remain
3424
+ dynamic inputs to the cached solve. Nonzero regular-face and cut-wall
3425
+ values are lifted out of the Lineax operator so the matvec remains linear.
3426
+ """
3427
+
3428
+ def __init__(
3429
+ self,
3430
+ geometry: FciGeometry3D,
3431
+ stencil_builder: ConservativeStencilBuilder,
3432
+ *,
3433
+ tol: float = 1.0e-6,
3434
+ maxiter: int = 50,
3435
+ restart: int = 50,
3436
+ face_projectors: tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray] | None = None,
3437
+ regular_face_geometry: RegularFaceGeometry3D | None = None,
3438
+ cut_wall_geometry: CutWallGeometry3D | None = None,
3439
+ cut_wall_bc: CutWallBC3D | None = None,
3440
+ periodic_axes: tuple[bool, bool, bool] = (False, True, True),
3441
+ axis_regular_axes: tuple[bool, bool, bool] = (False, False, False),
3442
+ b_floor: float = 1.0e-30,
3443
+ jacobian_floor: float = 1.0e-30,
3444
+ project_mean_zero: bool | None = None,
3445
+ target_mean_phi: object | None = None,
3446
+ pin_point: tuple[int, int, int] | None = None,
3447
+ pin_value: float = 0.0,
3448
+ regularization_epsilon: float = 0.0,
3449
+ mg_hierarchy: PerpLaplacianMgHierarchy | None = None,
3450
+ gmres_debug: bool = False,
3451
+ check_residual: bool = True,
3452
+ stagnation_iters: int = 20,
3453
+ ) -> None:
3454
+ if not isinstance(stencil_builder, ConservativeStencilBuilder):
3455
+ raise TypeError("stencil_builder must be a ConservativeStencilBuilder instance")
3456
+ self.geometry = geometry
3457
+ self.stencil_builder = stencil_builder
3458
+ self.tol = float(tol)
3459
+ self.maxiter = int(maxiter)
3460
+ self.restart = int(restart)
3461
+ self.face_projectors = face_projectors or build_perp_laplacian_face_projectors(
3462
+ geometry,
3463
+ b_floor=b_floor,
3464
+ axis_regular_axes=axis_regular_axes,
3465
+ )
3466
+ self.regular_face_geometry = regular_face_geometry or RegularFaceGeometry3D.unit(geometry)
3467
+ self.cut_wall_geometry = cut_wall_geometry or CutWallGeometry3D.empty()
3468
+ self.cut_wall_bc = cut_wall_bc or CutWallBC3D.empty()
3469
+ self.periodic_axes = tuple(bool(value) for value in periodic_axes)
3470
+ self.axis_regular_axes = tuple(bool(value) for value in axis_regular_axes)
3471
+ self.b_floor = float(b_floor)
3472
+ self.jacobian_floor = float(jacobian_floor)
3473
+ self.project_mean_zero = bool(project_mean_zero) if project_mean_zero is not None else False
3474
+ self.target_mean_phi = target_mean_phi
3475
+ self.pin_point = tuple(int(index) for index in pin_point) if pin_point is not None else None
3476
+ self.pin_value = float(pin_value)
3477
+ self.regularization_epsilon = float(regularization_epsilon)
3478
+ if self.regularization_epsilon < 0.0:
3479
+ raise ValueError("regularization_epsilon must be non-negative")
3480
+ if mg_hierarchy is not None and self.regularization_epsilon != 0.0:
3481
+ raise ValueError("mg_hierarchy does not include regularization_epsilon; use regularization_epsilon=0.0")
3482
+ if self.pin_point is not None and len(self.pin_point) != 3:
3483
+ raise ValueError("pin_point must be a 3-tuple of integer indices")
3484
+ if self.pin_point is not None:
3485
+ if mg_hierarchy is not None:
3486
+ raise ValueError("mg_hierarchy does not include pinned rows; use pin_point=None")
3487
+ for axis_index, axis_size in zip(self.pin_point, self.geometry.shape):
3488
+ if not 0 <= axis_index < axis_size:
3489
+ raise ValueError(
3490
+ "pin_point indices must lie inside the geometry shape; "
3491
+ f"got {self.pin_point} for shape {self.geometry.shape}"
3492
+ )
3493
+ self.mg_hierarchy = mg_hierarchy
3494
+ self.gmres_debug = bool(gmres_debug)
3495
+ self.check_residual = bool(check_residual)
3496
+ self.stagnation_iters = int(stagnation_iters)
3497
+ # `throw` is kept in the solve signatures for API stability; the solvax
3498
+ # backend never raises inside the solve (non-convergence is caught by the
3499
+ # host-side check_residual gate on the diagnostic path).
3500
+ self._solve_jit = jax.jit(self._solve_impl, static_argnums=(4,))
3501
+ self._solve_lifted_jit = jax.jit(self._solve_lifted_impl, static_argnums=(7,))
3502
+ self._solve_fast_jit = jax.jit(self._solve_fast_impl, static_argnums=(4,))
3503
+ # BC payloads are built once and reused every step, so validate each
3504
+ # (face_bc, cut_wall_bc) object pair against the MG hierarchy only the
3505
+ # first time it is seen: the check costs several host syncs.
3506
+ self._mg_validated_bc_ids: set[tuple[int, int]] = set()
3507
+
3508
+ def _apply_A(
3509
+ self,
3510
+ phi: jnp.ndarray,
3511
+ face_bc: BoundaryFaceBC3D,
3512
+ cut_wall_bc: CutWallBC3D,
3513
+ project_mean_zero: bool,
3514
+ ) -> jnp.ndarray:
3515
+ values = jnp.asarray(phi, dtype=jnp.float64)
3516
+ if project_mean_zero:
3517
+ values = _remove_weighted_mean(values, self.geometry)
3518
+ local = self.stencil_builder(
3519
+ values,
3520
+ self.geometry,
3521
+ periodic_axes=self.periodic_axes,
3522
+ face_bc=face_bc,
3523
+ )
3524
+ result = -perp_laplacian_conservative_op(
3525
+ local,
3526
+ self.geometry,
3527
+ face_projectors=self.face_projectors,
3528
+ face_bc=face_bc,
3529
+ regular_face_geometry=self.regular_face_geometry,
3530
+ cut_wall_geometry=self.cut_wall_geometry,
3531
+ cut_wall_bc=cut_wall_bc,
3532
+ periodic_axes=self.periodic_axes,
3533
+ axis_regular_axes=self.axis_regular_axes,
3534
+ b_floor=self.b_floor,
3535
+ jacobian_floor=self.jacobian_floor,
3536
+ )
3537
+ if self.regularization_epsilon != 0.0:
3538
+ result = result + self.regularization_epsilon * values
3539
+ if project_mean_zero:
3540
+ result = _remove_weighted_mean(result, self.geometry)
3541
+ return result
3542
+
3543
+
3544
+ def _gmres_flat(self, apply_A, rhs: jnp.ndarray, guess: jnp.ndarray) -> tuple[jnp.ndarray, jnp.ndarray]:
3545
+ """Restarted GMRES (solvax) on the flattened field.
3546
+
3547
+ Returns ``(solution_field, num_steps)``. solvax stops on the true
3548
+ residual ``||b - A x|| <= max(atol, rtol * ||b||)`` with ``atol = rtol
3549
+ = self.tol``; the multigrid V-cycle enters as a right preconditioner.
3550
+ The Krylov cycle length is capped at ``maxiter`` so the total iteration
3551
+ budget matches the previous backend.
3552
+ """
3553
+
3554
+ shape = rhs.shape
3555
+
3556
+ def matvec(flat: jnp.ndarray) -> jnp.ndarray:
3557
+ return jnp.ravel(apply_A(jnp.reshape(flat, shape)))
3558
+
3559
+ precond = None
3560
+ if self.mg_hierarchy is not None:
3561
+ def precond(flat: jnp.ndarray) -> jnp.ndarray:
3562
+ return jnp.ravel(mg_apply_preconditioner(jnp.reshape(flat, shape), self.mg_hierarchy))
3563
+
3564
+ # ``maxiter`` counts restart cycles (the semantics of the previous
3565
+ # backend, whose ``max_steps`` was outer cycles of length ``restart``),
3566
+ # so the total inner-iteration budget is ``maxiter * restart``. The
3567
+ # Krylov cycle is additionally capped at 64: solvax's fixed-shape
3568
+ # Arnoldi pays O(cycle) orthogonalization work per iteration whether or
3569
+ # not the slots are used, so very long requested cycles cost real time;
3570
+ # the cap preserves the total budget by adding restarts.
3571
+ budget = max(1, int(self.maxiter)) * max(1, int(self.restart))
3572
+ cycle = max(1, min(int(self.restart), 64))
3573
+ max_restarts = max(1, -(-budget // cycle))
3574
+ solution = solvax_gmres(
3575
+ matvec,
3576
+ jnp.ravel(rhs),
3577
+ x0=jnp.ravel(guess),
3578
+ precond=precond,
3579
+ restart=cycle,
3580
+ # Pure relative stopping on the true residual: the diagnostic
3581
+ # residual gate is relative, and an absolute atol=tol floor would
3582
+ # stop early whenever ||rhs|| < 1 (small-amplitude stages).
3583
+ rtol=self.tol,
3584
+ atol=0.0,
3585
+ max_restarts=max_restarts,
3586
+ )
3587
+ return jnp.reshape(solution.x, shape), jnp.asarray(solution.iterations, dtype=jnp.int32)
3588
+
3589
+ def _solve_fast_impl(
3590
+ self,
3591
+ omega: jnp.ndarray,
3592
+ phi_guess: jnp.ndarray,
3593
+ face_bc: BoundaryFaceBC3D,
3594
+ cut_wall_bc: CutWallBC3D,
3595
+ throw: bool,
3596
+ ) -> jnp.ndarray:
3597
+ """Solve for phi only: no diagnostic matvecs, no residual norms.
3598
+
3599
+ This is the hot-loop path (one boundary-source application plus the
3600
+ GMRES solve); `_solve_impl` adds the compatibility and final-residual
3601
+ diagnostics and is kept for validation and debugging.
3602
+ """
3603
+
3604
+ rhs = jnp.asarray(omega, dtype=jnp.float64)
3605
+ guess = jnp.asarray(phi_guess, dtype=jnp.float64)
3606
+ project_mean_zero = bool(self.project_mean_zero)
3607
+ if project_mean_zero:
3608
+ rhs = _remove_weighted_mean(rhs, self.geometry)
3609
+ guess = _remove_weighted_mean(guess, self.geometry)
3610
+ if self.pin_point is not None:
3611
+ guess = guess.at[self.pin_point].set(self.pin_value)
3612
+
3613
+ homogeneous_face_bc, homogeneous_cut_wall_bc = _homogeneous_boundary_payload(face_bc, cut_wall_bc)
3614
+ boundary_source = self._apply_A(jnp.zeros_like(rhs), face_bc, cut_wall_bc, project_mean_zero)
3615
+ linear_rhs = rhs - boundary_source
3616
+ if project_mean_zero:
3617
+ linear_rhs = _remove_weighted_mean(linear_rhs, self.geometry)
3618
+ if self.pin_point is not None:
3619
+ linear_rhs = linear_rhs.at[self.pin_point].set(self.pin_value)
3620
+
3621
+ def apply_A(phi: jnp.ndarray) -> jnp.ndarray:
3622
+ values = self._apply_A(phi, homogeneous_face_bc, homogeneous_cut_wall_bc, project_mean_zero)
3623
+ if self.pin_point is not None:
3624
+ values = values.at[self.pin_point].set(phi[self.pin_point])
3625
+ return values
3626
+
3627
+ phi, num_steps = self._gmres_flat(apply_A, linear_rhs, guess)
3628
+ if project_mean_zero:
3629
+ phi = _remove_weighted_mean(phi, self.geometry)
3630
+ if self.target_mean_phi is not None:
3631
+ phi = _set_weighted_mean(phi, self.geometry, self.target_mean_phi)
3632
+ if self.pin_point is not None:
3633
+ phi = phi.at[self.pin_point].set(self.pin_value)
3634
+ return phi
3635
+
3636
+ def _solve_impl(
3637
+ self,
3638
+ omega: jnp.ndarray,
3639
+ phi_guess: jnp.ndarray,
3640
+ face_bc: BoundaryFaceBC3D,
3641
+ cut_wall_bc: CutWallBC3D,
3642
+ throw: bool,
3643
+ ) -> tuple[
3644
+ jnp.ndarray,
3645
+ jnp.ndarray,
3646
+ jnp.ndarray,
3647
+ jnp.ndarray,
3648
+ jnp.ndarray,
3649
+ jnp.ndarray,
3650
+ jnp.ndarray,
3651
+ jnp.ndarray,
3652
+ jnp.ndarray,
3653
+ jnp.ndarray,
3654
+ jnp.ndarray,
3655
+ jnp.ndarray,
3656
+ jnp.ndarray,
3657
+ jnp.ndarray,
3658
+ jnp.ndarray,
3659
+ ]:
3660
+ rhs = jnp.asarray(omega, dtype=jnp.float64)
3661
+ guess = jnp.asarray(phi_guess, dtype=jnp.float64)
3662
+ rhs_is_finite = jnp.all(jnp.isfinite(rhs))
3663
+ guess_is_finite = jnp.all(jnp.isfinite(guess))
3664
+ compatibility_boundary_source = self._apply_A(jnp.zeros_like(rhs), face_bc, cut_wall_bc, False)
3665
+ compatibility_rhs = rhs - compatibility_boundary_source
3666
+ compatibility_rhs_mean = _weighted_mean(compatibility_rhs, self.geometry)
3667
+ compatibility_rhs_l2 = _weighted_l2(compatibility_rhs, self.geometry)
3668
+ compatibility_rhs_ratio = jnp.abs(compatibility_rhs_mean) / jnp.maximum(compatibility_rhs_l2, 1.0e-30)
3669
+ project_mean_zero = bool(self.project_mean_zero)
3670
+ if project_mean_zero:
3671
+ rhs = _remove_weighted_mean(rhs, self.geometry)
3672
+ guess = _remove_weighted_mean(guess, self.geometry)
3673
+ if self.pin_point is not None:
3674
+ guess = guess.at[self.pin_point].set(self.pin_value)
3675
+
3676
+ homogeneous_face_bc, homogeneous_cut_wall_bc = _homogeneous_boundary_payload(face_bc, cut_wall_bc)
3677
+ boundary_source = self._apply_A(jnp.zeros_like(rhs), face_bc, cut_wall_bc, project_mean_zero)
3678
+ linear_rhs = rhs - boundary_source
3679
+ if project_mean_zero:
3680
+ linear_rhs = _remove_weighted_mean(linear_rhs, self.geometry)
3681
+ projected_rhs_mean = _weighted_mean(linear_rhs, self.geometry)
3682
+ projected_rhs_l2 = _weighted_l2(linear_rhs, self.geometry)
3683
+ projected_rhs_ratio = jnp.abs(projected_rhs_mean) / jnp.maximum(projected_rhs_l2, 1.0e-30)
3684
+ if self.pin_point is not None:
3685
+ linear_rhs = linear_rhs.at[self.pin_point].set(self.pin_value)
3686
+
3687
+ def apply_A(phi: jnp.ndarray) -> jnp.ndarray:
3688
+ values = self._apply_A(phi, homogeneous_face_bc, homogeneous_cut_wall_bc, project_mean_zero)
3689
+ if self.pin_point is not None:
3690
+ values = values.at[self.pin_point].set(phi[self.pin_point])
3691
+ return values
3692
+
3693
+ phi, num_steps = self._gmres_flat(apply_A, linear_rhs, guess)
3694
+ if project_mean_zero:
3695
+ phi = _remove_weighted_mean(phi, self.geometry)
3696
+ if self.target_mean_phi is not None:
3697
+ phi = _set_weighted_mean(phi, self.geometry, self.target_mean_phi)
3698
+ if self.pin_point is not None:
3699
+ phi = phi.at[self.pin_point].set(self.pin_value)
3700
+ phi_is_finite = jnp.all(jnp.isfinite(phi))
3701
+ final_residual = self._apply_A(phi, face_bc, cut_wall_bc, project_mean_zero) - rhs
3702
+ if self.pin_point is not None:
3703
+ final_residual = final_residual.at[self.pin_point].set(phi[self.pin_point] - self.pin_value)
3704
+ final_residual_l2 = jnp.linalg.norm(final_residual)
3705
+ final_residual_linf = jnp.max(jnp.abs(final_residual))
3706
+ rhs_norm = jnp.linalg.norm(rhs)
3707
+ final_residual_rel_l2 = final_residual_l2 / (rhs_norm + 1.0e-30)
3708
+ return (
3709
+ phi,
3710
+ final_residual_l2,
3711
+ final_residual_linf,
3712
+ rhs_norm,
3713
+ final_residual_rel_l2,
3714
+ num_steps,
3715
+ rhs_is_finite,
3716
+ guess_is_finite,
3717
+ phi_is_finite,
3718
+ compatibility_rhs_mean,
3719
+ compatibility_rhs_l2,
3720
+ compatibility_rhs_ratio,
3721
+ projected_rhs_mean,
3722
+ projected_rhs_l2,
3723
+ projected_rhs_ratio,
3724
+ )
3725
+
3726
+ def _solve_lifted_impl(
3727
+ self,
3728
+ omega: jnp.ndarray,
3729
+ phi_guess: jnp.ndarray,
3730
+ face_bc: BoundaryFaceBC3D,
3731
+ cut_wall_bc: CutWallBC3D,
3732
+ phi_lift: jnp.ndarray,
3733
+ correction_face_bc: BoundaryFaceBC3D,
3734
+ correction_cut_wall_bc: CutWallBC3D,
3735
+ throw: bool,
3736
+ ) -> tuple[
3737
+ jnp.ndarray,
3738
+ jnp.ndarray,
3739
+ jnp.ndarray,
3740
+ jnp.ndarray,
3741
+ jnp.ndarray,
3742
+ jnp.ndarray,
3743
+ jnp.ndarray,
3744
+ jnp.ndarray,
3745
+ jnp.ndarray,
3746
+ jnp.ndarray,
3747
+ jnp.ndarray,
3748
+ jnp.ndarray,
3749
+ jnp.ndarray,
3750
+ jnp.ndarray,
3751
+ jnp.ndarray,
3752
+ jnp.ndarray,
3753
+ jnp.ndarray,
3754
+ ]:
3755
+ rhs = jnp.asarray(omega, dtype=jnp.float64)
3756
+ guess = jnp.asarray(phi_guess, dtype=jnp.float64)
3757
+ lift = jnp.asarray(phi_lift, dtype=jnp.float64)
3758
+ rhs_is_finite = jnp.all(jnp.isfinite(rhs))
3759
+ guess_is_finite = jnp.all(jnp.isfinite(guess))
3760
+ lift_is_finite = jnp.all(jnp.isfinite(lift))
3761
+ project_mean_zero = bool(self.project_mean_zero)
3762
+ if project_mean_zero:
3763
+ rhs = _remove_weighted_mean(rhs, self.geometry)
3764
+ correction_guess = guess - lift
3765
+ if project_mean_zero:
3766
+ correction_guess = _remove_weighted_mean(correction_guess, self.geometry)
3767
+
3768
+ lift_source = self._apply_A(lift, face_bc, cut_wall_bc, project_mean_zero)
3769
+ rhs_u = rhs - lift_source
3770
+ if project_mean_zero:
3771
+ rhs_u = _remove_weighted_mean(rhs_u, self.geometry)
3772
+ rhs_u_mean = _weighted_mean(rhs_u, self.geometry)
3773
+ rhs_u_l2_weighted = _weighted_l2(rhs_u, self.geometry)
3774
+ rhs_u_ratio = jnp.abs(rhs_u_mean) / jnp.maximum(rhs_u_l2_weighted, 1.0e-30)
3775
+ correction_pin_value = jnp.asarray(self.pin_value, dtype=jnp.float64)
3776
+ if self.pin_point is not None:
3777
+ correction_pin_value = correction_pin_value - lift[self.pin_point]
3778
+ correction_guess = correction_guess.at[self.pin_point].set(correction_pin_value)
3779
+ rhs_u = rhs_u.at[self.pin_point].set(correction_pin_value)
3780
+
3781
+ def apply_A(u: jnp.ndarray) -> jnp.ndarray:
3782
+ values = self._apply_A(u, correction_face_bc, correction_cut_wall_bc, project_mean_zero)
3783
+ if self.pin_point is not None:
3784
+ values = values.at[self.pin_point].set(u[self.pin_point])
3785
+ return values
3786
+
3787
+ correction, num_steps = self._gmres_flat(apply_A, rhs_u, correction_guess)
3788
+ if project_mean_zero:
3789
+ correction = _remove_weighted_mean(correction, self.geometry)
3790
+ phi = lift + correction
3791
+ if self.target_mean_phi is not None:
3792
+ phi = _set_weighted_mean(phi, self.geometry, self.target_mean_phi)
3793
+ correction = phi - lift
3794
+ if self.pin_point is not None:
3795
+ phi = phi.at[self.pin_point].set(self.pin_value)
3796
+ correction = correction.at[self.pin_point].set(correction_pin_value)
3797
+ phi_is_finite = jnp.all(jnp.isfinite(phi))
3798
+
3799
+ correction_residual = self._apply_A(correction, correction_face_bc, correction_cut_wall_bc, project_mean_zero) - rhs_u
3800
+ physical_residual = self._apply_A(phi, face_bc, cut_wall_bc, project_mean_zero) - rhs
3801
+ if self.pin_point is not None:
3802
+ correction_residual = correction_residual.at[self.pin_point].set(correction[self.pin_point] - correction_pin_value)
3803
+ physical_residual = physical_residual.at[self.pin_point].set(phi[self.pin_point] - self.pin_value)
3804
+ correction_residual_l2 = jnp.linalg.norm(correction_residual)
3805
+ correction_residual_linf = jnp.max(jnp.abs(correction_residual))
3806
+ rhs_u_norm = jnp.linalg.norm(rhs_u)
3807
+ correction_residual_rel_l2 = correction_residual_l2 / (rhs_u_norm + 1.0e-30)
3808
+ physical_residual_l2 = jnp.linalg.norm(physical_residual)
3809
+ physical_residual_linf = jnp.max(jnp.abs(physical_residual))
3810
+ physical_rhs_norm = jnp.linalg.norm(rhs)
3811
+ lift_source_norm = jnp.linalg.norm(lift_source)
3812
+ return (
3813
+ phi,
3814
+ correction_residual_l2,
3815
+ correction_residual_linf,
3816
+ rhs_u_norm,
3817
+ correction_residual_rel_l2,
3818
+ physical_residual_l2,
3819
+ physical_residual_linf,
3820
+ physical_rhs_norm,
3821
+ lift_source_norm,
3822
+ num_steps,
3823
+ rhs_is_finite,
3824
+ guess_is_finite,
3825
+ lift_is_finite,
3826
+ phi_is_finite,
3827
+ rhs_u_mean,
3828
+ rhs_u_l2_weighted,
3829
+ rhs_u_ratio,
3830
+ )
3831
+
3832
+ def __call__(
3833
+ self,
3834
+ omega: jnp.ndarray,
3835
+ *,
3836
+ phi_guess: jnp.ndarray | None = None,
3837
+ face_bc: BoundaryFaceBC3D | None = None,
3838
+ cut_wall_bc: CutWallBC3D | None = None,
3839
+ phi_lift: jnp.ndarray | None = None,
3840
+ correction_face_bc: BoundaryFaceBC3D | None = None,
3841
+ correction_cut_wall_bc: CutWallBC3D | None = None,
3842
+ throw: bool = False,
3843
+ return_diagnostics: bool = False,
3844
+ ) -> jnp.ndarray | tuple[jnp.ndarray, dict[str, object]]:
3845
+ rhs = jnp.asarray(omega, dtype=jnp.float64)
3846
+ if rhs.shape != self.geometry.shape:
3847
+ raise ValueError(f"omega must have shape {self.geometry.shape}, got {rhs.shape}")
3848
+ lift = None
3849
+ if phi_lift is not None:
3850
+ lift = jnp.asarray(phi_lift, dtype=jnp.float64)
3851
+ if lift.shape != self.geometry.shape:
3852
+ raise ValueError(f"phi_lift must have shape {self.geometry.shape}, got {lift.shape}")
3853
+ if phi_guess is None:
3854
+ phi_guess = lift
3855
+ else:
3856
+ phi_guess = jnp.asarray(phi_guess, dtype=jnp.float64)
3857
+ else:
3858
+ if phi_guess is None:
3859
+ phi_guess = jnp.zeros_like(rhs)
3860
+ else:
3861
+ phi_guess = jnp.asarray(phi_guess, dtype=jnp.float64)
3862
+ if phi_guess.shape != self.geometry.shape:
3863
+ raise ValueError(f"phi_guess must have shape {self.geometry.shape}, got {phi_guess.shape}")
3864
+ if face_bc is None:
3865
+ face_bc = BoundaryFaceBC3D.empty(self.regular_face_geometry)
3866
+ if cut_wall_bc is None:
3867
+ cut_wall_bc = self.cut_wall_bc
3868
+ elif not isinstance(cut_wall_bc, CutWallBC3D):
3869
+ raise TypeError("cut_wall_bc must be a CutWallBC3D instance")
3870
+ if phi_lift is not None:
3871
+ if correction_face_bc is None:
3872
+ correction_face_bc = _dirichlet_lift_correction_face_bc(face_bc)
3873
+ elif not isinstance(correction_face_bc, BoundaryFaceBC3D):
3874
+ raise TypeError("correction_face_bc must be a BoundaryFaceBC3D instance")
3875
+ if correction_cut_wall_bc is None:
3876
+ correction_cut_wall_bc = _dirichlet_lift_correction_cut_wall_bc(cut_wall_bc)
3877
+ elif not isinstance(correction_cut_wall_bc, CutWallBC3D):
3878
+ raise TypeError("correction_cut_wall_bc must be a CutWallBC3D instance")
3879
+ if self.mg_hierarchy is not None:
3880
+ bc_key = (id(face_bc), id(cut_wall_bc))
3881
+ if bc_key not in self._mg_validated_bc_ids:
3882
+ _validate_mg_hierarchy_for_linear_operator(
3883
+ self.mg_hierarchy,
3884
+ geometry=self.geometry,
3885
+ periodic_axes=self.periodic_axes,
3886
+ face_bc=correction_face_bc,
3887
+ cut_wall_bc=correction_cut_wall_bc,
3888
+ )
3889
+ self._mg_validated_bc_ids.add(bc_key)
3890
+ (
3891
+ phi,
3892
+ residual_l2,
3893
+ residual_linf,
3894
+ rhs_u_norm,
3895
+ residual_rel_l2,
3896
+ physical_residual_l2,
3897
+ physical_residual_linf,
3898
+ physical_rhs_norm,
3899
+ lift_source_norm,
3900
+ num_steps,
3901
+ rhs_is_finite,
3902
+ guess_is_finite,
3903
+ lift_is_finite,
3904
+ phi_is_finite,
3905
+ rhs_u_mean,
3906
+ rhs_u_l2_weighted,
3907
+ rhs_u_ratio,
3908
+ ) = self._solve_lifted_jit(
3909
+ rhs,
3910
+ phi_guess,
3911
+ face_bc,
3912
+ cut_wall_bc,
3913
+ lift,
3914
+ correction_face_bc,
3915
+ correction_cut_wall_bc,
3916
+ throw,
3917
+ )
3918
+ if self.gmres_debug:
3919
+ print("PerpLaplacianInverseSolver lifted GMRES num_steps:", int(num_steps))
3920
+ print("PerpLaplacianInverseSolver lifted correction residual l2:", float(residual_l2))
3921
+ print("PerpLaplacianInverseSolver lifted correction residual linf:", float(residual_linf))
3922
+ print("PerpLaplacianInverseSolver lifted rhs_u l2:", float(rhs_u_norm))
3923
+ print("PerpLaplacianInverseSolver lifted correction residual relative l2:", float(residual_rel_l2))
3924
+ print("PerpLaplacianInverseSolver lifted physical rhs l2:", float(physical_rhs_norm))
3925
+ print("PerpLaplacianInverseSolver lifted lift source l2:", float(lift_source_norm))
3926
+ print("PerpLaplacianInverseSolver lifted physical residual l2:", float(physical_residual_l2))
3927
+ print("PerpLaplacianInverseSolver lifted physical residual linf:", float(physical_residual_linf))
3928
+ print("PerpLaplacianInverseSolver lifted rhs_u mean J:", float(rhs_u_mean))
3929
+ print("PerpLaplacianInverseSolver lifted rhs_u l2 J:", float(rhs_u_l2_weighted))
3930
+ print("PerpLaplacianInverseSolver lifted rhs_u ratio:", float(rhs_u_ratio))
3931
+ print("PerpLaplacianInverseSolver lifted input finite flags:", {
3932
+ "rhs": bool(rhs_is_finite),
3933
+ "phi_guess": bool(guess_is_finite),
3934
+ "phi_lift": bool(lift_is_finite),
3935
+ "phi": bool(phi_is_finite),
3936
+ })
3937
+ if self.check_residual:
3938
+ if (
3939
+ not jnp.isfinite(residual_l2)
3940
+ or not jnp.isfinite(residual_linf)
3941
+ or not jnp.isfinite(rhs_u_norm)
3942
+ or not jnp.isfinite(physical_residual_l2)
3943
+ ):
3944
+ raise RuntimeError(
3945
+ "PerpLaplacianInverseSolver lifted solve produced a non-finite residual: "
3946
+ f"correction_l2={float(residual_l2):g}, correction_linf={float(residual_linf):g}, "
3947
+ f"rhs_u_l2={float(rhs_u_norm):g}, physical_l2={float(physical_residual_l2):g}, "
3948
+ f"num_steps={int(num_steps)}, rhs_finite={bool(rhs_is_finite)}, "
3949
+ f"phi_guess_finite={bool(guess_is_finite)}, phi_lift_finite={bool(lift_is_finite)}, "
3950
+ f"phi_finite={bool(phi_is_finite)}"
3951
+ )
3952
+ correction_atol = float(self.tol)
3953
+ correction_rtol = float(self.tol)
3954
+ correction_limit = max(correction_atol, correction_rtol * max(float(rhs_u_norm), 1.0))
3955
+ if float(residual_l2) > correction_limit:
3956
+ raise RuntimeError(
3957
+ "PerpLaplacianInverseSolver lifted GMRES residual too large: "
3958
+ f"correction_l2={float(residual_l2):g}, correction_rel_l2={float(residual_rel_l2):g}, "
3959
+ f"correction_linf={float(residual_linf):g}, rhs_u_l2={float(rhs_u_norm):g}, "
3960
+ f"physical_l2={float(physical_residual_l2):g}, physical_linf={float(physical_residual_linf):g}, "
3961
+ f"lift_source_l2={float(lift_source_norm):g}, rhsUratio={float(rhs_u_ratio):g}, "
3962
+ f"limit={correction_limit:g}"
3963
+ )
3964
+ if return_diagnostics:
3965
+ return phi, {
3966
+ "final_residual_l2": float(residual_l2),
3967
+ "final_residual_linf": float(residual_linf),
3968
+ "rhs_l2": float(rhs_u_norm),
3969
+ "final_residual_rel_l2": float(residual_rel_l2),
3970
+ "num_steps": int(num_steps),
3971
+ "physical_rhs_l2": float(physical_rhs_norm),
3972
+ "lift_source_l2": float(lift_source_norm),
3973
+ "rhs_u_l2": float(rhs_u_norm),
3974
+ "correction_residual_l2": float(residual_l2),
3975
+ "correction_residual_rel_l2": float(residual_rel_l2),
3976
+ "physical_residual_l2": float(physical_residual_l2),
3977
+ "physical_residual_linf": float(physical_residual_linf),
3978
+ "rhs_u_mean_J": float(rhs_u_mean),
3979
+ "rhs_u_l2_J": float(rhs_u_l2_weighted),
3980
+ "rhs_u_compatibility_ratio": float(rhs_u_ratio),
3981
+ "rhs_finite": bool(rhs_is_finite),
3982
+ "phi_guess_finite": bool(guess_is_finite),
3983
+ "phi_lift_finite": bool(lift_is_finite),
3984
+ "phi_finite": bool(phi_is_finite),
3985
+ "lifted": True,
3986
+ }
3987
+ return phi
3988
+ if self.mg_hierarchy is not None:
3989
+ bc_key = (id(face_bc), id(cut_wall_bc))
3990
+ if bc_key not in self._mg_validated_bc_ids:
3991
+ homogeneous_face_bc, homogeneous_cut_wall_bc = _homogeneous_boundary_payload(face_bc, cut_wall_bc)
3992
+ _validate_mg_hierarchy_for_linear_operator(
3993
+ self.mg_hierarchy,
3994
+ geometry=self.geometry,
3995
+ periodic_axes=self.periodic_axes,
3996
+ face_bc=homogeneous_face_bc,
3997
+ cut_wall_bc=homogeneous_cut_wall_bc,
3998
+ )
3999
+ self._mg_validated_bc_ids.add(bc_key)
4000
+ if not return_diagnostics and not self.check_residual and not self.gmres_debug:
4001
+ # Hot-loop path: phi only, no diagnostic matvecs, no host syncs.
4002
+ # Safe to call from inside jit-compiled code.
4003
+ return self._solve_fast_jit(rhs, phi_guess, face_bc, cut_wall_bc, throw)
4004
+ (
4005
+ phi,
4006
+ residual_l2,
4007
+ residual_linf,
4008
+ rhs_norm,
4009
+ residual_rel_l2,
4010
+ num_steps,
4011
+ rhs_is_finite,
4012
+ guess_is_finite,
4013
+ phi_is_finite,
4014
+ compatibility_rhs_mean,
4015
+ compatibility_rhs_l2,
4016
+ compatibility_rhs_ratio,
4017
+ projected_rhs_mean,
4018
+ projected_rhs_l2,
4019
+ projected_rhs_ratio,
4020
+ ) = self._solve_jit(
4021
+ rhs,
4022
+ phi_guess,
4023
+ face_bc,
4024
+ cut_wall_bc,
4025
+ throw,
4026
+ )
4027
+ if self.gmres_debug:
4028
+ print("PerpLaplacianInverseSolver GMRES num_steps:", int(num_steps))
4029
+ print("PerpLaplacianInverseSolver GMRES final residual l2:", float(residual_l2))
4030
+ print("PerpLaplacianInverseSolver GMRES final residual linf:", float(residual_linf))
4031
+ print("PerpLaplacianInverseSolver GMRES rhs l2:", float(rhs_norm))
4032
+ print("PerpLaplacianInverseSolver GMRES final residual relative l2:", float(residual_rel_l2))
4033
+ print("PerpLaplacianInverseSolver pre-projection rhs mean J:", float(compatibility_rhs_mean))
4034
+ print("PerpLaplacianInverseSolver pre-projection rhs l2 J:", float(compatibility_rhs_l2))
4035
+ print("PerpLaplacianInverseSolver pre-projection rhs ratio:", float(compatibility_rhs_ratio))
4036
+ print("PerpLaplacianInverseSolver post-projection rhs mean J:", float(projected_rhs_mean))
4037
+ print("PerpLaplacianInverseSolver post-projection rhs l2 J:", float(projected_rhs_l2))
4038
+ print("PerpLaplacianInverseSolver post-projection rhs ratio:", float(projected_rhs_ratio))
4039
+ print("PerpLaplacianInverseSolver input finite flags:", {
4040
+ "rhs": bool(rhs_is_finite),
4041
+ "phi_guess": bool(guess_is_finite),
4042
+ "phi": bool(phi_is_finite),
4043
+ })
4044
+ if self.check_residual:
4045
+ if (
4046
+ not jnp.isfinite(residual_l2)
4047
+ or not jnp.isfinite(residual_linf)
4048
+ or not jnp.isfinite(rhs_norm)
4049
+ ):
4050
+ raise RuntimeError(
4051
+ "PerpLaplacianInverseSolver produced a non-finite GMRES residual: "
4052
+ f"l2={float(residual_l2):g}, linf={float(residual_linf):g}, rhs_l2={float(rhs_norm):g}, "
4053
+ f"num_steps={int(num_steps)}, rhs_finite={bool(rhs_is_finite)}, "
4054
+ f"phi_guess_finite={bool(guess_is_finite)}, phi_finite={bool(phi_is_finite)}"
4055
+ )
4056
+ if float(residual_rel_l2) > max(10.0 * self.tol, 1.0e-12):
4057
+ raise RuntimeError(
4058
+ "PerpLaplacianInverseSolver GMRES residual too large: "
4059
+ f"l2={float(residual_l2):g}, rel_l2={float(residual_rel_l2):g}, "
4060
+ f"linf={float(residual_linf):g}, rhsCpre={float(compatibility_rhs_ratio):g}, "
4061
+ f"rhsCpost={float(projected_rhs_ratio):g}"
4062
+ )
4063
+ if return_diagnostics:
4064
+ return phi, {
4065
+ "final_residual_l2": float(residual_l2),
4066
+ "final_residual_linf": float(residual_linf),
4067
+ "rhs_l2": float(rhs_norm),
4068
+ "final_residual_rel_l2": float(residual_rel_l2),
4069
+ "num_steps": int(num_steps),
4070
+ "rhs_mean_J": float(compatibility_rhs_mean),
4071
+ "rhs_l2_J": float(compatibility_rhs_l2),
4072
+ "rhs_compatibility_ratio": float(compatibility_rhs_ratio),
4073
+ "projected_rhs_mean_J": float(projected_rhs_mean),
4074
+ "projected_rhs_l2_J": float(projected_rhs_l2),
4075
+ "projected_rhs_compatibility_ratio": float(projected_rhs_ratio),
4076
+ "rhs_finite": bool(rhs_is_finite),
4077
+ "phi_guess_finite": bool(guess_is_finite),
4078
+ "phi_finite": bool(phi_is_finite),
4079
+ }
4080
+ return phi
4081
+