geoswe 1.0.0__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.
geoswe/__init__.py ADDED
@@ -0,0 +1,102 @@
1
+ """GeoSWE: Geophysical Shallow-Water Engine.
2
+
3
+ A finite-volume solver for the 2D nonlinear shallow-water equations, built for
4
+ flood modeling from county to continental scale. It runs on NVIDIA and AMD GPUs
5
+ through CuPy, across GPUs with mpi4py, and on the CPU with NumPy.
6
+
7
+ Highlights
8
+ ----------
9
+ * HLLC and local Lax-Friedrichs numerical fluxes.
10
+ * Well-balanced schemes: the Xia (2017) surface-reconstruction method (SRM)
11
+ and Audusse hydrostatic reconstruction; exact lake-at-rest preservation.
12
+ * Implicit Manning friction, rainfall, wetting and drying, and prescribed
13
+ water levels (tide, surge).
14
+ * A compressed active-cell mesh (GPU) that stores and updates only the cells
15
+ of the modeled region, reaching continental scale on one node (Florida at
16
+ 10 m, the conterminous United States at 30 m).
17
+
18
+ Quick start
19
+ -----------
20
+ >>> import numpy as np
21
+ >>> from geoswe import Mesh2D, Config, Solver2D
22
+ >>> nx, ny = 200, 160
23
+ >>> mesh = Mesh2D(nx=nx, ny=ny, dx=1.0, dy=1.0)
24
+ >>> cfg = Config() # first-order HLLC + SRM, forward Euler, CFL 0.5
25
+ >>> q0 = np.zeros((3, nx, ny)); q0[0] = 1.0 # h, hu, hv
26
+ >>> q0[0, 80:120, 60:100] = 2.0 # a column of water
27
+ >>> s = Solver2D(mesh, cfg, q0, np.zeros((nx, ny)))
28
+ >>> _ = s.run(t_end=1.0)
29
+ >>> h = s.depth() # NumPy array (nx, ny)
30
+
31
+ GeoSWE uses the GPU when CuPy and a CUDA device are present and NumPy
32
+ otherwise. Set the ``GEOSWE_BACKEND`` environment variable to ``"numpy"`` or
33
+ ``"cupy"`` before importing to choose.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ # --- always importable: pure-NumPy-capable core ---------------------------
38
+ from .backend import (
39
+ xp,
40
+ set_backend,
41
+ get_backend,
42
+ gpu_platform,
43
+ to_host,
44
+ to_device,
45
+ sync,
46
+ USING_CUPY,
47
+ )
48
+ from .swe import G, H_MIN
49
+ from .mesh import Mesh1D, Mesh2D
50
+ from .solver import Config, Solver1D, Solver2D
51
+ from .forcing import RainfallForcing, StageBoundary
52
+
53
+ __version__ = "1.0.0"
54
+
55
+ __all__ = [
56
+ # backend control
57
+ "xp",
58
+ "set_backend",
59
+ "get_backend",
60
+ "gpu_platform",
61
+ "to_host",
62
+ "to_device",
63
+ "sync",
64
+ "USING_CUPY",
65
+ # physical constants / defaults
66
+ "G",
67
+ "H_MIN",
68
+ # meshes
69
+ "Mesh1D",
70
+ "Mesh2D",
71
+ # configuration + solvers
72
+ "Config",
73
+ "Solver1D",
74
+ "Solver2D",
75
+ # forcings
76
+ "RainfallForcing",
77
+ "StageBoundary",
78
+ # NOTE: the GPU-only CompressedSolver is exposed lazily via __getattr__ but
79
+ # deliberately NOT listed in __all__, so `from geoswe import *` works on
80
+ # CPU-only installs.
81
+ "__version__",
82
+ ]
83
+
84
+
85
+ def __getattr__(name):
86
+ """Lazily expose the GPU-only compressed solver.
87
+
88
+ ``geoswe.compressed_solver`` hard-imports CuPy at module load, so importing
89
+ it eagerly would break ``import geoswe`` on a CPU-only machine. Resolve it
90
+ only when the caller actually asks for ``geoswe.CompressedSolver``.
91
+ """
92
+ if name == "CompressedSolver":
93
+ try:
94
+ from .compressed_solver import CompressedSolver
95
+ except ImportError as e: # clear message on CPU-only installs
96
+ raise ImportError(
97
+ "geoswe.CompressedSolver needs CuPy and SciPy (the gpu extra): "
98
+ "pip install 'cupy-cuda12x[ctk]' scipy, or on an AMD GPU (the gpu-rocm "
99
+ "extra): pip install 'cupy-rocm-7-0' scipy"
100
+ ) from e
101
+ return CompressedSolver
102
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
geoswe/backend.py ADDED
@@ -0,0 +1,335 @@
1
+ """Backend abstraction: numpy on CPU, cupy on GPU.
2
+
3
+ Usage:
4
+ from .backend import xp, to_host, sync, USING_CUPY
5
+ a = xp.zeros((100, 100)) # numpy or cupy array depending on backend
6
+
7
+ The reliable way to choose the backend is the ``GEOSWE_BACKEND`` environment
8
+ variable ("cupy" or "numpy"), set before ``import geoswe``; several modules
9
+ specialise for the backend at import time. ``set_backend()`` works only while
10
+ no solver module has been imported yet; afterwards it raises.
11
+
12
+ Once set, all modules that import `xp` from this file will use the selected backend.
13
+
14
+ CuPy drives NVIDIA GPUs through CUDA and AMD GPUs through ROCm/HIP, and
15
+ ``gpu_platform()`` says which one is in use. The kernels are written in CUDA C;
16
+ build them with ``raw_kernel()`` / ``raw_module()`` / ``elementwise_kernel()`` rather
17
+ than the CuPy classes so the ROCm compiler receives a source and options it accepts.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import re
23
+ import warnings
24
+
25
+ USING_CUPY = False
26
+ GPU_PLATFORM = None # "cuda" (NVIDIA), "hip" (AMD ROCm) or None (NumPy backend)
27
+ _xp_module = None
28
+
29
+
30
+ # CuPy ships one wheel per CUDA major (cupy-cuda12x, cupy-cuda13x, ...) and every one
31
+ # installs the same `cupy` package, so two of them overwrite each other's files. The
32
+ # usual way in: install the default CUDA 12 build, then add the CUDA 13 one on top.
33
+ # The ROCm builds (cupy-rocm-7-0 on PyPI, amd-cupy on AMD's index) collide the same way.
34
+ _CUPY_BUILD = re.compile(r"(amd-)?cupy(-cuda\d+x|-rocm-[\d-]+)?")
35
+
36
+
37
+ def _installed_cupy_builds():
38
+ """Names of the installed CuPy distributions (normally zero or one)."""
39
+ from importlib import metadata
40
+ names = set()
41
+ for dist in metadata.distributions():
42
+ name = (dist.metadata.get("Name") or "").lower().replace("_", "-")
43
+ if _CUPY_BUILD.fullmatch(name):
44
+ names.add(name)
45
+ return sorted(names)
46
+
47
+
48
+ def _check_single_cupy():
49
+ """Refuse the GPU backend when more than one CuPy build is installed."""
50
+ if os.environ.get("GEOSWE_ALLOW_MULTIPLE_CUPY") == "1":
51
+ return
52
+ builds = _installed_cupy_builds()
53
+ if len(builds) > 1:
54
+ if any("rocm" in b or b.startswith("amd-") for b in builds):
55
+ keep = ("pip install 'cupy-cuda12x[ctk]' for an NVIDIA GPU or 'cupy-rocm-7-0' for an "
56
+ "AMD one (the builds the gpu and gpu-rocm extras install). ")
57
+ else:
58
+ keep = ("pip install 'cupy-cuda12x[ctk]'. That build, which the gpu extra installs, "
59
+ "runs on both CUDA 12 and CUDA 13 drivers. ")
60
+ raise RuntimeError(
61
+ f"several CuPy builds are installed ({', '.join(builds)}). They overwrite each "
62
+ f"other in the same `cupy` package, and kernels then fail in confusing ways. "
63
+ f"Keep one: pip uninstall -y {' '.join(builds)} && {keep}"
64
+ f"(GEOSWE_ALLOW_MULTIPLE_CUPY=1 skips this check.)")
65
+
66
+
67
+ def _platform_of(cp):
68
+ """Which GPU stack this CuPy build targets: "hip" for AMD ROCm, else "cuda"."""
69
+ try:
70
+ return "hip" if cp.cuda.runtime.is_hip else "cuda"
71
+ except AttributeError: # CuPy too old to have a ROCm build
72
+ return "cuda"
73
+
74
+
75
+ def _cuda_device_count(cp):
76
+ """Number of visible GPUs (CuPy counts AMD devices through the same call); 0 when the
77
+ driver or every device is missing."""
78
+ try:
79
+ return int(cp.cuda.runtime.getDeviceCount())
80
+ except Exception: # CUDARuntimeError: no driver, no device
81
+ return 0
82
+
83
+
84
+ def _no_device(cp):
85
+ """What to say when this CuPy build sees no device."""
86
+ if _platform_of(cp) == "hip":
87
+ return "no AMD GPU is visible (check rocm-smi and HIP_VISIBLE_DEVICES)"
88
+ return "no CUDA device is visible (check nvidia-smi and CUDA_VISIBLE_DEVICES)"
89
+
90
+
91
+ def _select_default():
92
+ global _xp_module, USING_CUPY, GPU_PLATFORM
93
+ # GEOSWE_BACKEND is the documented name. SWELL_BACKEND and SWE_IGR_BACKEND are the
94
+ # research tree's names, kept so that both trees respond to the same job scripts.
95
+ # First one set wins; with none set the GPU is used when it is there.
96
+ asked = (os.environ.get("GEOSWE_BACKEND")
97
+ or os.environ.get("SWELL_BACKEND")
98
+ or os.environ.get("SWE_IGR_BACKEND"))
99
+ backend = (asked or "cupy").lower()
100
+ if backend not in ("cupy", "numpy"):
101
+ raise ValueError(f"GEOSWE_BACKEND must be 'cupy' (GPU) or 'numpy' (CPU), got {backend!r}")
102
+ if backend == "cupy":
103
+ _check_single_cupy() # raise here, not inside the NumPy fallback below
104
+ try:
105
+ import cupy as cp # type: ignore
106
+ except ImportError:
107
+ cp = None
108
+ if asked: # the GPU was asked for by name: say why it is not used
109
+ warnings.warn("GEOSWE_BACKEND=cupy, but CuPy is not installed; using the NumPy "
110
+ "(CPU) backend. The gpu extra installs it: "
111
+ "pip install 'cupy-cuda12x[ctk]' (on an AMD GPU, the gpu-rocm "
112
+ "extra: pip install 'cupy-rocm-7-0')",
113
+ stacklevel=3)
114
+ if cp is not None:
115
+ if _cuda_device_count(cp) > 0:
116
+ _xp_module = cp
117
+ USING_CUPY = True
118
+ GPU_PLATFORM = _platform_of(cp)
119
+ return
120
+ if asked:
121
+ raise RuntimeError(
122
+ f"GEOSWE_BACKEND=cupy, but {_no_device(cp)}. "
123
+ f"Set GEOSWE_BACKEND=numpy to run on the CPU.")
124
+ warnings.warn(f"CuPy is installed but {_no_device(cp)}; using the NumPy "
125
+ f"(CPU) backend. Set GEOSWE_BACKEND=numpy to make this choice explicit.",
126
+ stacklevel=3)
127
+ import numpy as np
128
+ _xp_module = np
129
+ USING_CUPY = False
130
+ GPU_PLATFORM = None
131
+
132
+
133
+ _select_default()
134
+
135
+
136
+ def set_backend(name: str):
137
+ """Switch the global backend to 'numpy' or 'cupy'.
138
+
139
+ Must be called before any solver module is imported: ``geoswe.solver`` (and
140
+ the CUDA kernels behind it) freeze the backend choice at import time, so a
141
+ late switch would silently run the wrong code path. Prefer setting the
142
+ ``GEOSWE_BACKEND`` environment variable before ``import geoswe``.
143
+ """
144
+ global _xp_module, USING_CUPY, GPU_PLATFORM
145
+ name = str(name).lower() # normalize
146
+ if name not in ("cupy", "numpy"):
147
+ raise ValueError(f"backend must be 'cupy' or 'numpy', got {name!r}")
148
+ # Fail loud when the switch cannot take effect: solver.py freezes
149
+ # USING_CUPY/fused-kernel dispatch at its own import.
150
+ import sys
151
+ if "geoswe.solver" in sys.modules and name != get_backend():
152
+ raise RuntimeError(
153
+ f"set_backend({name!r}) called after geoswe.solver was imported with the "
154
+ f"{get_backend()!r} backend; the switch cannot take effect. Set the "
155
+ f"GEOSWE_BACKEND environment variable before importing geoswe instead."
156
+ )
157
+ if name == "cupy":
158
+ _check_single_cupy()
159
+ import cupy as cp # type: ignore
160
+ if _cuda_device_count(cp) < 1:
161
+ raise RuntimeError(f"set_backend('cupy'): {_no_device(cp)}")
162
+ _xp_module = cp
163
+ USING_CUPY = True
164
+ GPU_PLATFORM = _platform_of(cp)
165
+ else:
166
+ import numpy as np
167
+ _xp_module = np
168
+ USING_CUPY = False
169
+ GPU_PLATFORM = None
170
+
171
+
172
+ def get_backend() -> str:
173
+ """Return ``"cupy"`` when the GPU backend is active, else ``"numpy"``."""
174
+ return "cupy" if USING_CUPY else "numpy"
175
+
176
+
177
+ def gpu_platform():
178
+ """GPU stack behind the CuPy backend: ``"cuda"`` (NVIDIA), ``"hip"`` (AMD ROCm),
179
+ or ``None`` on the NumPy backend."""
180
+ return GPU_PLATFORM
181
+
182
+
183
+ def nvidia_compute_capability() -> str:
184
+ """Compute capability of the current NVIDIA device as CuPy reports it (``"90"`` on
185
+ an H100), or ``""`` when the device is not an NVIDIA one.
186
+
187
+ Use this to recognise an NVIDIA architecture, not ``cp.cuda.Device().compute_capability``:
188
+ under ROCm that attribute carries the AMD ISA number, so an MI200-series card
189
+ (gfx90a) reports ``"90"`` too.
190
+ """
191
+ if GPU_PLATFORM != "cuda":
192
+ return ""
193
+ try:
194
+ import cupy as cp # type: ignore
195
+ return str(cp.cuda.Device().compute_capability)
196
+ except Exception:
197
+ return ""
198
+
199
+
200
+ # Declared ahead of every kernel under ROCm. CUDA guarantees that __fmul_rn/__fadd_rn
201
+ # (and the double forms) are never contracted into a fused multiply-add; the kernels use
202
+ # them where two code paths must round identically. HIP declares them as plain `*` and
203
+ # `+`, which clang then fuses after inlining, so they are redefined here with contraction
204
+ # switched off.
205
+ _HIP_COMPAT = r"""
206
+ static __device__ __forceinline__ float _geoswe_fmul_rn(float a, float b) {
207
+ #pragma clang fp contract(off)
208
+ return a * b;
209
+ }
210
+ static __device__ __forceinline__ float _geoswe_fadd_rn(float a, float b) {
211
+ #pragma clang fp contract(off)
212
+ return a + b;
213
+ }
214
+ static __device__ __forceinline__ double _geoswe_dmul_rn(double a, double b) {
215
+ #pragma clang fp contract(off)
216
+ return a * b;
217
+ }
218
+ static __device__ __forceinline__ double _geoswe_dadd_rn(double a, double b) {
219
+ #pragma clang fp contract(off)
220
+ return a + b;
221
+ }
222
+ #define __fmul_rn(a, b) _geoswe_fmul_rn((a), (b))
223
+ #define __fadd_rn(a, b) _geoswe_fadd_rn((a), (b))
224
+ #define __dmul_rn(a, b) _geoswe_dmul_rn((a), (b))
225
+ #define __dadd_rn(a, b) _geoswe_dadd_rn((a), (b))
226
+ """
227
+ # `#line 1` keeps compiler diagnostics on the kernel's own line numbers.
228
+ _HIP_PREAMBLE = _HIP_COMPAT + "#line 1\n"
229
+
230
+ # Compiler options that only NVRTC understands; the ROCm compiler rejects them.
231
+ _NVRTC_ONLY_OPTIONS = ("-maxrregcount", "--maxrregcount")
232
+ _warned_options = set()
233
+
234
+
235
+ def _hip_options(options):
236
+ """`options` without the NVRTC-only ones, warning once for each one dropped."""
237
+ kept = []
238
+ for opt in options:
239
+ if str(opt).startswith(_NVRTC_ONLY_OPTIONS):
240
+ if opt not in _warned_options:
241
+ _warned_options.add(opt)
242
+ warnings.warn(f"kernel option {opt!r} is specific to NVIDIA's compiler and "
243
+ f"is ignored on AMD GPUs (ROCm)", RuntimeWarning, stacklevel=3)
244
+ continue
245
+ kept.append(opt)
246
+ return tuple(kept)
247
+
248
+
249
+ # How the ROCm compiler may contract `a*b + c` into a fused multiply-add, chosen by
250
+ # GEOSWE_HIP_FP_CONTRACT. clang's own default for HIP ("fast") fuses across statements at
251
+ # the optimizer's discretion, so the same expression can round differently in two kernels:
252
+ # the fused and the split time step then differ in the last bit (measured on an MI200,
253
+ # tests/test_gpu_storage_fused.py). "off" never fuses and "on" fuses only inside a single
254
+ # source expression, so both are fixed by the source text. "off" is the default: every
255
+ # operation rounds once, in source order, so two kernels agree whenever their statements
256
+ # do, and the dense and compressed solvers agree bit for bit. "on" is about 3 % faster
257
+ # (MI250X, 147 M cells) but leaves 1 ulp between those two (test_gpu_compressed_equiv.py).
258
+ _HIP_FP_CONTRACT = {"off": "-ffp-contract=off", "on": "-ffp-contract=on",
259
+ "fast": "-ffp-contract=fast-honor-pragmas"}
260
+ _HIP_FP_CONTRACT_DEFAULT = "off"
261
+
262
+
263
+ def _hip_fp_contract():
264
+ mode = os.environ.get("GEOSWE_HIP_FP_CONTRACT", _HIP_FP_CONTRACT_DEFAULT).lower()
265
+ if mode not in _HIP_FP_CONTRACT:
266
+ raise ValueError(f"GEOSWE_HIP_FP_CONTRACT must be one of {sorted(_HIP_FP_CONTRACT)}, "
267
+ f"got {mode!r}")
268
+ return (_HIP_FP_CONTRACT[mode],)
269
+
270
+
271
+ def raw_kernel(code, name, options=(), **kwargs):
272
+ """``cupy.RawKernel`` for a CUDA C kernel, on either GPU vendor.
273
+
274
+ On NVIDIA the source and the options reach CuPy unchanged. Under ROCm the source
275
+ gets the compatibility preamble, NVRTC-only options are dropped, and the
276
+ floating-point contraction mode is set (``GEOSWE_HIP_FP_CONTRACT``).
277
+ """
278
+ import cupy as cp # type: ignore
279
+ if GPU_PLATFORM == "hip":
280
+ code, options = _HIP_PREAMBLE + code, _hip_options(options) + _hip_fp_contract()
281
+ return cp.RawKernel(code, name, options=tuple(options), **kwargs)
282
+
283
+
284
+ def raw_module(code, options=(), **kwargs):
285
+ """``cupy.RawModule`` counterpart of :func:`raw_kernel`."""
286
+ import cupy as cp # type: ignore
287
+ if GPU_PLATFORM == "hip":
288
+ code, options = _HIP_PREAMBLE + code, _hip_options(options) + _hip_fp_contract()
289
+ return cp.RawModule(code=code, options=tuple(options), **kwargs)
290
+
291
+
292
+ def elementwise_kernel(in_params, out_params, operation, name="kernel", **kwargs):
293
+ """``cupy.ElementwiseKernel`` counterpart of :func:`raw_kernel`.
294
+
295
+ The split time step is made of these, so under ROCm they must be compiled with the
296
+ same contraction mode as the raw kernels they are compared against.
297
+ """
298
+ import cupy as cp # type: ignore
299
+ if GPU_PLATFORM == "hip":
300
+ kwargs["options"] = _hip_options(kwargs.get("options", ())) + _hip_fp_contract()
301
+ kwargs["preamble"] = _HIP_COMPAT + kwargs.get("preamble", "")
302
+ return cp.ElementwiseKernel(in_params, out_params, operation, name, **kwargs)
303
+
304
+
305
+ class _XPProxy:
306
+ """Proxy that forwards attribute access to the currently-selected backend module."""
307
+
308
+ def __getattr__(self, name):
309
+ return getattr(_xp_module, name)
310
+
311
+
312
+ xp = _XPProxy()
313
+
314
+
315
+ def to_host(arr):
316
+ """Return a NumPy array regardless of backend."""
317
+ if USING_CUPY and hasattr(arr, "get"):
318
+ return arr.get()
319
+ import numpy as np
320
+ return np.asarray(arr)
321
+
322
+
323
+ def to_device(arr):
324
+ """Move a host array to the current backend (GPU if CuPy)."""
325
+ if USING_CUPY:
326
+ import cupy as cp # type: ignore
327
+ return cp.asarray(arr)
328
+ return arr
329
+
330
+
331
+ def sync():
332
+ """Block until all GPU operations have completed (no-op on CPU)."""
333
+ if USING_CUPY:
334
+ import cupy as cp # type: ignore
335
+ cp.cuda.Stream.null.synchronize()
geoswe/bc.py ADDED
@@ -0,0 +1,235 @@
1
+ """Boundary conditions implemented via ghost cells.
2
+
3
+ For SWE state q in 1D shape (Nvar, Nx_pad) or 2D shape (Nvar, Nx_pad, Ny_pad).
4
+ Ghost cells are the first/last ngh cells along each spatial axis.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from .backend import xp as np # backend-agnostic
9
+
10
+
11
+ def apply_bc_1d(q, ngh: int, kind: str = "extrapolate", left=None, right=None):
12
+ """Apply boundary conditions to a 1D state.
13
+
14
+ kind:
15
+ 'extrapolate': zero-gradient (open)
16
+ 'periodic': wrap-around
17
+ 'wall': reflective (h mirrored, hu sign-flipped)
18
+ 'dirichlet': both ends fixed; provide `left` and `right` (each (Nvar,))
19
+ """
20
+ if kind == "periodic":
21
+ q[:, :ngh] = q[:, -2 * ngh : -ngh]
22
+ q[:, -ngh:] = q[:, ngh : 2 * ngh]
23
+ return
24
+ if kind == "extrapolate":
25
+ q[:, :ngh] = q[:, ngh : ngh + 1]
26
+ q[:, -ngh:] = q[:, -ngh - 1 : -ngh]
27
+ return
28
+ if kind == "wall":
29
+ # mirror with sign flip on the normal momentum
30
+ for j in range(ngh):
31
+ q[0, j] = q[0, 2 * ngh - 1 - j]
32
+ q[1, j] = -q[1, 2 * ngh - 1 - j]
33
+ q[0, -1 - j] = q[0, -2 * ngh + j]
34
+ q[1, -1 - j] = -q[1, -2 * ngh + j]
35
+ return
36
+ if kind == "dirichlet":
37
+ assert left is not None and right is not None
38
+ for j in range(ngh):
39
+ q[:, j] = left
40
+ q[:, -1 - j] = right
41
+ return
42
+ if kind == "fall":
43
+ # 'fall' = free outflow: zero depth and momentum in the ghost cells (same as apply_bc_2d).
44
+ q[:, :ngh] = 0.0
45
+ q[:, -ngh:] = 0.0
46
+ return
47
+ raise ValueError(f"unknown BC kind {kind}")
48
+
49
+
50
+ def apply_bc_2d(q, ngh: int, kind_x: str = "extrapolate", kind_y: str = "extrapolate"):
51
+ """Apply boundary conditions to a 2D state. Independent BCs along x and y."""
52
+ # x-direction ghost cells
53
+ if kind_x == "periodic":
54
+ q[:, :ngh, :] = q[:, -2 * ngh : -ngh, :]
55
+ q[:, -ngh:, :] = q[:, ngh : 2 * ngh, :]
56
+ elif kind_x == "extrapolate":
57
+ q[:, :ngh, :] = q[:, ngh : ngh + 1, :]
58
+ q[:, -ngh:, :] = q[:, -ngh - 1 : -ngh, :]
59
+ elif kind_x == "wall":
60
+ # Vectorized mirror-with-sign-flip avoids launching 6 kernels per
61
+ # ghost layer on CuPy. The
62
+ # ::-1 indexing on q[:, ngh:2*ngh, :] produces the mirror of the first
63
+ # ngh interior cells in reverse order, matching the per-j formula
64
+ # q[..., j] = q[..., 2*ngh - 1 - j].
65
+ q[0, :ngh, :] = q[0, ngh : 2 * ngh, :][::-1, :]
66
+ q[1, :ngh, :] = -q[1, ngh : 2 * ngh, :][::-1, :]
67
+ q[2, :ngh, :] = q[2, ngh : 2 * ngh, :][::-1, :]
68
+ q[0, -ngh:, :] = q[0, -2 * ngh : -ngh, :][::-1, :]
69
+ q[1, -ngh:, :] = -q[1, -2 * ngh : -ngh, :][::-1, :]
70
+ q[2, -ngh:, :] = q[2, -2 * ngh : -ngh, :][::-1, :]
71
+ elif kind_x == "fall":
72
+ # 'fall': h=0, hU=0 in the ghost cells -- forces free outflow by
73
+ # creating a maximum hydraulic gradient at the boundary
74
+ q[:, :ngh, :] = 0.0
75
+ q[:, -ngh:, :] = 0.0
76
+ else:
77
+ raise ValueError(f"unknown BC kind_x {kind_x}")
78
+
79
+ # y-direction ghost cells
80
+ if kind_y == "periodic":
81
+ q[:, :, :ngh] = q[:, :, -2 * ngh : -ngh]
82
+ q[:, :, -ngh:] = q[:, :, ngh : 2 * ngh]
83
+ elif kind_y == "extrapolate":
84
+ q[:, :, :ngh] = q[:, :, ngh : ngh + 1]
85
+ q[:, :, -ngh:] = q[:, :, -ngh - 1 : -ngh]
86
+ elif kind_y == "wall":
87
+ # Vectorized mirror-with-sign-flip on y-normal momentum.
88
+ q[0, :, :ngh] = q[0, :, ngh : 2 * ngh][:, ::-1]
89
+ q[1, :, :ngh] = q[1, :, ngh : 2 * ngh][:, ::-1]
90
+ q[2, :, :ngh] = -q[2, :, ngh : 2 * ngh][:, ::-1]
91
+ q[0, :, -ngh:] = q[0, :, -2 * ngh : -ngh][:, ::-1]
92
+ q[1, :, -ngh:] = q[1, :, -2 * ngh : -ngh][:, ::-1]
93
+ q[2, :, -ngh:] = -q[2, :, -2 * ngh : -ngh][:, ::-1]
94
+ elif kind_y == "fall":
95
+ q[:, :, :ngh] = 0.0
96
+ q[:, :, -ngh:] = 0.0
97
+ else:
98
+ raise ValueError(f"unknown BC kind_y {kind_y}")
99
+
100
+
101
+ def apply_bc_2d_face(q, ngh: int, face: str, kind: str):
102
+ """Apply a physical BC to a single ghost face of a 2D state.
103
+
104
+ face: one of 'x-', 'x+', 'y-', 'y+'
105
+ kind: 'extrapolate', 'wall', or 'periodic' (periodic only makes sense
106
+ for x- and x+ together / y- and y+ together; calling it on a
107
+ single face wraps to the local subgrid's opposite interior side
108
+ which is rarely useful in multi-rank settings; used here only
109
+ when the caller has already determined the face is physical-only).
110
+
111
+ Used by the multi-GPU dispatcher: halo.exchange fills MPI faces, then
112
+ this fills only the remaining physical faces (avoiding the clobber
113
+ that ``apply_bc_2d`` with kind='periodic' would cause on an MPI face).
114
+ """
115
+ if face == "x-":
116
+ if kind == "extrapolate":
117
+ q[:, :ngh, :] = q[:, ngh : ngh + 1, :]
118
+ elif kind == "wall":
119
+ for j in range(ngh):
120
+ q[0, j, :] = q[0, 2 * ngh - 1 - j, :]
121
+ q[1, j, :] = -q[1, 2 * ngh - 1 - j, :]
122
+ q[2, j, :] = q[2, 2 * ngh - 1 - j, :]
123
+ elif kind == "fall":
124
+ q[:, :ngh, :] = 0.0
125
+ elif kind == "periodic":
126
+ q[:, :ngh, :] = q[:, -2 * ngh : -ngh, :]
127
+ else:
128
+ raise ValueError(f"unknown BC kind {kind!r} on face {face}")
129
+ elif face == "x+":
130
+ if kind == "extrapolate":
131
+ q[:, -ngh:, :] = q[:, -ngh - 1 : -ngh, :]
132
+ elif kind == "wall":
133
+ for j in range(ngh):
134
+ q[0, -1 - j, :] = q[0, -2 * ngh + j, :]
135
+ q[1, -1 - j, :] = -q[1, -2 * ngh + j, :]
136
+ q[2, -1 - j, :] = q[2, -2 * ngh + j, :]
137
+ elif kind == "fall":
138
+ q[:, -ngh:, :] = 0.0
139
+ elif kind == "periodic":
140
+ q[:, -ngh:, :] = q[:, ngh : 2 * ngh, :]
141
+ else:
142
+ raise ValueError(f"unknown BC kind {kind!r} on face {face}")
143
+ elif face == "y-":
144
+ if kind == "extrapolate":
145
+ q[:, :, :ngh] = q[:, :, ngh : ngh + 1]
146
+ elif kind == "wall":
147
+ for j in range(ngh):
148
+ q[0, :, j] = q[0, :, 2 * ngh - 1 - j]
149
+ q[1, :, j] = q[1, :, 2 * ngh - 1 - j]
150
+ q[2, :, j] = -q[2, :, 2 * ngh - 1 - j]
151
+ elif kind == "fall":
152
+ q[:, :, :ngh] = 0.0
153
+ elif kind == "periodic":
154
+ q[:, :, :ngh] = q[:, :, -2 * ngh : -ngh]
155
+ else:
156
+ raise ValueError(f"unknown BC kind {kind!r} on face {face}")
157
+ elif face == "y+":
158
+ if kind == "extrapolate":
159
+ q[:, :, -ngh:] = q[:, :, -ngh - 1 : -ngh]
160
+ elif kind == "wall":
161
+ for j in range(ngh):
162
+ q[0, :, -1 - j] = q[0, :, -2 * ngh + j]
163
+ q[1, :, -1 - j] = q[1, :, -2 * ngh + j]
164
+ q[2, :, -1 - j] = -q[2, :, -2 * ngh + j]
165
+ elif kind == "fall":
166
+ q[:, :, -ngh:] = 0.0
167
+ elif kind == "periodic":
168
+ q[:, :, -ngh:] = q[:, :, ngh : 2 * ngh]
169
+ else:
170
+ raise ValueError(f"unknown BC kind {kind!r} on face {face}")
171
+ else:
172
+ raise ValueError(f"unknown face {face!r}")
173
+
174
+
175
+ def apply_inflow_discharge(q, bed, idx_i, idx_j, nx_n, ny_n, ds, Q, ngh,
176
+ hsum=None, dry_frac=0.10):
177
+ """Impose a discharge (hydrograph) inflow through the GHOST cells of an inlet.
178
+
179
+ The stage ring of \\S3.6 prescribes a free surface and zeroes momentum; a
180
+ river inlet is the opposite case -- the discharge is what is known and the
181
+ depth has to come from the flow. The split across the cross-section follows
182
+ the usual depth-weighted convention, so a single hydrograph drives the
183
+ whole inlet:
184
+
185
+ * total discharge ``Q`` (m^3/s) is split over the inlet cells in
186
+ proportion to their depth, ``w_k = h_k / sum(h)``, so the deeper part of
187
+ a cross-section carries more of the flow;
188
+ * the per-cell normal unit discharge is ``Q*w_k/ds`` (m^2/s);
189
+ * a DRY cross-section has no depth to weight by, so the surface is seeded
190
+ from the bed relief, ``z_min + dry_frac*(z_max - z_min)``; where that
191
+ section is FLAT this degenerates to zero depth and the hydrograph would
192
+ never start, so it falls back to the critical depth ``(q^2/g)^(1/3)`` --
193
+ the minimum-energy depth carrying q, which adds no free parameter.
194
+
195
+ The values are written into the ``ngh`` GHOST cells outward of each inlet
196
+ cell, NOT into the interior. Writing the interior instead makes the inlet
197
+ depend on whatever face BC surrounds it -- a wall would mirror the imposed
198
+ momentum straight back and deliver nothing -- and it would also clobber the
199
+ solution every step. Writing the ghost lets the scheme compute the boundary
200
+ flux itself, so the inlet works on a wall, an open face or a mask edge
201
+ alike. Depth is carried out to the ghost unchanged (zero-gradient), which is
202
+ the "impose one condition, take the other from the flow" a subcritical inlet
203
+ wants.
204
+
205
+ ``nx_n``, ``ny_n`` are the INWARD unit normal and must be axis-aligned
206
+ (one of them +-1, the other 0). ``hsum`` takes the MPI-reduced depth sum
207
+ when a cross-section spans ranks; otherwise each rank would weight against
208
+ its own share.
209
+ """
210
+ if len(idx_i) == 0:
211
+ return
212
+ h = q[0][idx_i, idx_j]
213
+ z = bed[idx_i, idx_j]
214
+ s = float(h.sum()) if hsum is None else float(hsum)
215
+ if s > 0.0:
216
+ w = h / s
217
+ h_face = h # zero-gradient depth into the ghost
218
+ else: # dry cross-section: see docstring
219
+ zmin = z.min(); zmax = z.max()
220
+ h_face = np.maximum(zmin + dry_frac * (zmax - zmin) - z, 0.0)
221
+ low = (z <= zmin) # put the flow in the thalweg
222
+ n_low = float(low.sum())
223
+ if float(h_face.max()) <= 0.0: # FLAT inlet -> critical depth
224
+ g = 9.80665
225
+ q_cell = abs(Q) / (max(n_low, 1.0) * ds)
226
+ h_face = h_face + (q_cell * q_cell / g) ** (1.0 / 3.0)
227
+ w = np.where(low, 1.0 / max(n_low, 1.0), 0.0)
228
+ qn = (Q / ds) * w # normal unit discharge, m^2/s
229
+ si, sj = int(round(nx_n)), int(round(ny_n))
230
+ for k in range(1, int(ngh) + 1): # ghost cells, outward of the inlet
231
+ gi = idx_i - k * si
232
+ gj = idx_j - k * sj
233
+ q[0][gi, gj] = h_face
234
+ q[1][gi, gj] = qn * nx_n
235
+ q[2][gi, gj] = qn * ny_n