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 +102 -0
- geoswe/backend.py +335 -0
- geoswe/bc.py +235 -0
- geoswe/compressed_mesh.py +191 -0
- geoswe/compressed_rhs.py +1082 -0
- geoswe/compressed_solver.py +3085 -0
- geoswe/data_prep.py +399 -0
- geoswe/elliptic.py +154 -0
- geoswe/elliptic_cuda.py +258 -0
- geoswe/flux.py +151 -0
- geoswe/forcing.py +329 -0
- geoswe/friction.py +108 -0
- geoswe/gauges.py +109 -0
- geoswe/io_geotiff.py +220 -0
- geoswe/mesh.py +87 -0
- geoswe/mpi_halo.py +495 -0
- geoswe/reconstruction.py +195 -0
- geoswe/rhs_cuda.py +2487 -0
- geoswe/runlib/__init__.py +27 -0
- geoswe/runlib/case.py +117 -0
- geoswe/runlib/cli.py +126 -0
- geoswe/runlib/driver.py +1546 -0
- geoswe/runlib/replay.py +162 -0
- geoswe/solver.py +2721 -0
- geoswe/swe.py +74 -0
- geoswe/well_balanced.py +415 -0
- geoswe-1.0.0.dist-info/METADATA +217 -0
- geoswe-1.0.0.dist-info/RECORD +31 -0
- geoswe-1.0.0.dist-info/WHEEL +5 -0
- geoswe-1.0.0.dist-info/licenses/LICENSE +29 -0
- geoswe-1.0.0.dist-info/top_level.txt +1 -0
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
|