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.
- drbx/__init__.py +20 -0
- drbx/__main__.py +5 -0
- drbx/cli.py +586 -0
- drbx/config/__init__.py +20 -0
- drbx/config/boutinp.py +556 -0
- drbx/config/model.py +34 -0
- drbx/config/normalization.py +66 -0
- drbx/data/atomic_rates/__init__.py +1 -0
- drbx/data/atomic_rates/iz_AMJUEL_H.x_2.1.5.json +213 -0
- drbx/data/atomic_rates/iz_AMJUEL_H.x_2.3.9a.json +213 -0
- drbx/data/atomic_rates/rec_AMJUEL_H.x_2.1.8.json +213 -0
- drbx/data/atomic_rates/rec_AMJUEL_H.x_2.3.13a.json +213 -0
- drbx/geometry/__init__.py +207 -0
- drbx/geometry/embedding.py +56 -0
- drbx/geometry/essos_import.py +1385 -0
- drbx/geometry/fci_geometry.py +4622 -0
- drbx/geometry/fci_maps.py +85 -0
- drbx/geometry/island_divertor.py +291 -0
- drbx/geometry/metric_tensor.py +99 -0
- drbx/geometry/open_slab.py +150 -0
- drbx/geometry/rotating_ellipse.py +253 -0
- drbx/geometry/shifted_torus.py +225 -0
- drbx/geometry/stellarator.py +287 -0
- drbx/geometry/vmec_extender_import.py +499 -0
- drbx/geometry/vmec_jax_import.py +306 -0
- drbx/linear/__init__.py +37 -0
- drbx/linear/dispersion.py +138 -0
- drbx/linear/eigen.py +91 -0
- drbx/native/__init__.py +224 -0
- drbx/native/array_backend.py +64 -0
- drbx/native/deck_runner.py +779 -0
- drbx/native/electromagnetic.py +250 -0
- drbx/native/expression.py +173 -0
- drbx/native/fci.py +295 -0
- drbx/native/fci_2_field_rhs.py +182 -0
- drbx/native/fci_4_field_rhs.py +1267 -0
- drbx/native/fci_boundaries.py +2494 -0
- drbx/native/fci_differentiable_case.py +304 -0
- drbx/native/fci_drb_EB_rhs.py +1243 -0
- drbx/native/fci_drb_rhs.py +190 -0
- drbx/native/fci_halo.py +1575 -0
- drbx/native/fci_helpers.py +350 -0
- drbx/native/fci_model.py +294 -0
- drbx/native/fci_neutral.py +139 -0
- drbx/native/fci_operators.py +4081 -0
- drbx/native/fci_sharding.py +597 -0
- drbx/native/fci_sheath_recycling.py +206 -0
- drbx/native/fci_time_integrator.py +96 -0
- drbx/native/fci_vorticity.py +198 -0
- drbx/native/fluid_1d.py +330 -0
- drbx/native/hasegawa_wakatani.py +196 -0
- drbx/native/limiters.py +57 -0
- drbx/native/mesh.py +238 -0
- drbx/native/metrics.py +234 -0
- drbx/native/neutrals/__init__.py +58 -0
- drbx/native/neutrals/atomic_rates.py +134 -0
- drbx/native/neutrals/detachment_sol_model.py +221 -0
- drbx/native/neutrals/reactions.py +164 -0
- drbx/native/neutrals/recycling_sol_model.py +197 -0
- drbx/native/sol_flux_tube.py +133 -0
- drbx/native/stellarator_turbulence.py +343 -0
- drbx/native/transport.py +134 -0
- drbx/native/units.py +32 -0
- drbx/native/vorticity.py +252 -0
- drbx/runtime/__init__.py +53 -0
- drbx/runtime/artifacts.py +161 -0
- drbx/runtime/memory.py +144 -0
- drbx/runtime/output.py +374 -0
- drbx/runtime/paths.py +9 -0
- drbx/runtime/performance.py +161 -0
- drbx/runtime/run_config.py +184 -0
- drbx/runtime/scheduler.py +99 -0
- drbx/runtime/state.py +40 -0
- drbx/validation/__init__.py +424 -0
- drbx/validation/autodiff_diffusion.py +329 -0
- drbx/validation/autodiff_diffusion_uncertainty.py +235 -0
- drbx/validation/diverted_tokamak_movie.py +558 -0
- drbx/validation/essos_fieldline_import_campaign.py +181 -0
- drbx/validation/essos_imported_artifact_audit.py +535 -0
- drbx/validation/essos_imported_drb_movie_campaign.py +2826 -0
- drbx/validation/essos_imported_fci_campaign.py +5241 -0
- drbx/validation/essos_imported_pytree_campaign.py +406 -0
- drbx/validation/essos_vmec_closed_field_campaign.py +314 -0
- drbx/validation/essos_vmec_closed_field_transient_campaign.py +629 -0
- drbx/validation/essos_vmec_fieldline_surface_campaign.py +620 -0
- drbx/validation/fluid_1d_mms_convergence.py +250 -0
- drbx/validation/geometry_lineouts.py +136 -0
- drbx/validation/geometry_slices.py +178 -0
- drbx/validation/publication_plotting.py +91 -0
- drbx/validation/stellarator_drb_pytree_campaign.py +621 -0
- drbx/validation/stellarator_fci_geometry_campaign.py +200 -0
- drbx/validation/stellarator_fci_operator_campaign.py +304 -0
- drbx/validation/stellarator_fci_suite_campaign.py +264 -0
- drbx/validation/stellarator_metric_mms_campaign.py +289 -0
- drbx/validation/stellarator_neutral_physics_campaign.py +255 -0
- drbx/validation/stellarator_sheath_recycling_campaign.py +331 -0
- drbx/validation/stellarator_sol_showcase.py +628 -0
- drbx/validation/stellarator_vorticity_campaign.py +304 -0
- drbx/validation/vmec_extender_edge_field_campaign.py +260 -0
- drbx/validation/vmec_extender_sol_smoke_campaign.py +365 -0
- drbx-2.0.0.dev0.dist-info/METADATA +380 -0
- drbx-2.0.0.dev0.dist-info/RECORD +106 -0
- drbx-2.0.0.dev0.dist-info/WHEEL +5 -0
- drbx-2.0.0.dev0.dist-info/entry_points.txt +2 -0
- drbx-2.0.0.dev0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
|