bluemesh2d 0.1.1.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.
- bluemesh2d/_version.py +24 -0
- bluemesh2d/aabb_tree/findball.py +121 -0
- bluemesh2d/aabb_tree/findtria.py +299 -0
- bluemesh2d/aabb_tree/maketree.py +147 -0
- bluemesh2d/aabb_tree/mapvert.py +72 -0
- bluemesh2d/aabb_tree/queryset.py +83 -0
- bluemesh2d/aabb_tree/scantree.py +124 -0
- bluemesh2d/dependencies.py +83 -0
- bluemesh2d/feedback.py +142 -0
- bluemesh2d/geom_util/boundary_util.py +216 -0
- bluemesh2d/geom_util/getiso.py +194 -0
- bluemesh2d/geom_util/poly_util.py +795 -0
- bluemesh2d/geom_util/proj_util.py +250 -0
- bluemesh2d/geomesh_util/border_util.py +283 -0
- bluemesh2d/geomesh_util/depth_field.py +333 -0
- bluemesh2d/geomesh_util/grd_util.py +1000 -0
- bluemesh2d/geomesh_util/interpolation_mesh.py +318 -0
- bluemesh2d/geomesh_util/merge_circumcenters.py +268 -0
- bluemesh2d/geomesh_util/water_polygon.py +406 -0
- bluemesh2d/hfun_util/build_hfun.py +589 -0
- bluemesh2d/hfun_util/hfun_dispersion.py +96 -0
- bluemesh2d/hfun_util/lfshfn.py +110 -0
- bluemesh2d/hfun_util/limhfn.py +65 -0
- bluemesh2d/hfun_util/make_constant_hfun.py +32 -0
- bluemesh2d/hfun_util/make_depth_hfun.py +51 -0
- bluemesh2d/hfun_util/smooth_and_precomput.py +188 -0
- bluemesh2d/hfun_util/trihfn.py +84 -0
- bluemesh2d/hjac_util/limgrad.py +105 -0
- bluemesh2d/mesh_ball/cdtbal1.py +38 -0
- bluemesh2d/mesh_ball/cdtbal2.py +104 -0
- bluemesh2d/mesh_ball/inv_2x2.py +61 -0
- bluemesh2d/mesh_ball/inv_3x3.py +72 -0
- bluemesh2d/mesh_ball/pwrbal2.py +134 -0
- bluemesh2d/mesh_ball/tribal2.py +27 -0
- bluemesh2d/mesh_cost/relhfn.py +62 -0
- bluemesh2d/mesh_cost/triang.py +59 -0
- bluemesh2d/mesh_cost/triarea.py +51 -0
- bluemesh2d/mesh_cost/trideg.py +44 -0
- bluemesh2d/mesh_cost/triscr.py +43 -0
- bluemesh2d/mesh_file/bnd_util.py +664 -0
- bluemesh2d/mesh_file/loadmsh.py +165 -0
- bluemesh2d/mesh_file/ugrid.py +308 -0
- bluemesh2d/mesh_util/cfmtri.py +118 -0
- bluemesh2d/mesh_util/deltri.py +131 -0
- bluemesh2d/mesh_util/idxtri.py +53 -0
- bluemesh2d/mesh_util/isfeat.py +90 -0
- bluemesh2d/mesh_util/minlen.py +46 -0
- bluemesh2d/mesh_util/setset.py +49 -0
- bluemesh2d/mesh_util/tricon.py +90 -0
- bluemesh2d/mesh_util/tridiv.py +187 -0
- bluemesh2d/meshgen.py +202 -0
- bluemesh2d/ortho_merge/constants.py +27 -0
- bluemesh2d/ortho_merge/geometry.py +64 -0
- bluemesh2d/ortho_merge/ortho_merge_iter.py +812 -0
- bluemesh2d/ortho_merge/orthogonalize.py +2989 -0
- bluemesh2d/pipeline.py +277 -0
- bluemesh2d/poly_data/airfoil.msh +958 -0
- bluemesh2d/poly_data/channel.msh +212 -0
- bluemesh2d/poly_data/islands.msh +13819 -0
- bluemesh2d/poly_data/lake.msh +612 -0
- bluemesh2d/poly_data/river.msh +690 -0
- bluemesh2d/poly_test/inpoly.py +105 -0
- bluemesh2d/poly_test/inpoly_mat.py +104 -0
- bluemesh2d/refine.py +972 -0
- bluemesh2d/smood.py +623 -0
- bluemesh2d/smooth.py +522 -0
- bluemesh2d/tricost.py +426 -0
- bluemesh2d/tridemo.py +723 -0
- bluemesh2d/triread.py +53 -0
- bluemesh2d-0.1.1.dev0.dist-info/METADATA +139 -0
- bluemesh2d-0.1.1.dev0.dist-info/RECORD +74 -0
- bluemesh2d-0.1.1.dev0.dist-info/WHEEL +5 -0
- bluemesh2d-0.1.1.dev0.dist-info/licenses/LICENSE +674 -0
- bluemesh2d-0.1.1.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pyproj
|
|
3
|
+
import rasterio
|
|
4
|
+
from scipy.interpolate import LinearNDInterpolator
|
|
5
|
+
from scipy.ndimage import map_coordinates, distance_transform_edt
|
|
6
|
+
from scipy.spatial import cKDTree
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def interpolate_from_xyz(
|
|
10
|
+
x, y, z,
|
|
11
|
+
vert,
|
|
12
|
+
method="rbf",
|
|
13
|
+
rbf_function="cubic",
|
|
14
|
+
epsilon=None,
|
|
15
|
+
):
|
|
16
|
+
"""Interpolate scattered values at arbitrary target coordinates.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
x, y : ndarray of shape (N,)
|
|
21
|
+
Coordinates of scattered data points.
|
|
22
|
+
z : ndarray of shape (N,)
|
|
23
|
+
Scalar values at the scattered points.
|
|
24
|
+
vert : ndarray of shape (M, 2) or (M, 3)
|
|
25
|
+
Target coordinates where interpolation is evaluated.
|
|
26
|
+
method : {'linear', 'nearest', 'rbf'}, optional
|
|
27
|
+
Interpolation method. ``'linear'`` uses
|
|
28
|
+
:class:`scipy.interpolate.LinearNDInterpolator`; ``'nearest'`` uses a
|
|
29
|
+
KD-tree; ``'rbf'`` uses :class:`scipy.interpolate.RBFInterpolator``.
|
|
30
|
+
rbf_function : str, optional
|
|
31
|
+
RBF kernel (e.g. ``'cubic'``, ``'thin_plate_spline'``). Used only when
|
|
32
|
+
``method='rbf'``.
|
|
33
|
+
epsilon : float, optional
|
|
34
|
+
RBF shape parameter. Auto-estimated when ``None``.
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
values_interp : ndarray of shape (M,)
|
|
39
|
+
Interpolated values at ``vert`` (negated; NaN replaced by 0).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# Remove invalid points
|
|
43
|
+
mask = np.isfinite(x) & np.isfinite(y) & np.isfinite(z)
|
|
44
|
+
x, y, z = x[mask], y[mask], z[mask]
|
|
45
|
+
points = np.column_stack((x, y))
|
|
46
|
+
|
|
47
|
+
# Interpolation method
|
|
48
|
+
if method == "linear":
|
|
49
|
+
interp = LinearNDInterpolator(points, z, fill_value=np.nan)
|
|
50
|
+
values_interp = interp(vert[:, 0], vert[:, 1], vert[:, 2])
|
|
51
|
+
|
|
52
|
+
elif method == "nearest":
|
|
53
|
+
tree = cKDTree(points)
|
|
54
|
+
_, idx = tree.query(vert)
|
|
55
|
+
values_interp = z[idx]
|
|
56
|
+
|
|
57
|
+
elif method == "rbf":
|
|
58
|
+
# lazy import: RBFInterpolator needs scipy >= 1.7, and old QGIS
|
|
59
|
+
# bundles (e.g. macOS LTR with scipy 1.5) must still be able to use
|
|
60
|
+
# the other methods
|
|
61
|
+
try:
|
|
62
|
+
from scipy.interpolate import RBFInterpolator
|
|
63
|
+
except ImportError as exc:
|
|
64
|
+
raise ImportError(
|
|
65
|
+
"method='rbf' needs scipy >= 1.7 (RBFInterpolator); this "
|
|
66
|
+
"environment has an older scipy -- use method='linear' or "
|
|
67
|
+
"'nearest' instead.") from exc
|
|
68
|
+
interp = RBFInterpolator(points, z, kernel=rbf_function, epsilon=epsilon)
|
|
69
|
+
values_interp = interp(vert)
|
|
70
|
+
|
|
71
|
+
else:
|
|
72
|
+
raise ValueError("method must be 'linear', 'nearest', or 'rbf'")
|
|
73
|
+
|
|
74
|
+
# Handle NaNs
|
|
75
|
+
values_interp = - np.asarray(values_interp, dtype=float)
|
|
76
|
+
values_interp[np.isnan(values_interp)] = 0
|
|
77
|
+
|
|
78
|
+
return values_interp
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def interpolate_from_tiff(
|
|
82
|
+
tiff_path, vert, input_crs=None, order=3, mode="constant", cval=np.nan,
|
|
83
|
+
invert_z=False, nodata_value=None, max_cells=None
|
|
84
|
+
):
|
|
85
|
+
"""Interpolate GeoTIFF raster values at mesh node coordinates.
|
|
86
|
+
|
|
87
|
+
Only the window covering the mesh nodes is read (decimated when very
|
|
88
|
+
large), so an oversized bathymetry raster is sampled without being read
|
|
89
|
+
in full.
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
tiff_path : str
|
|
94
|
+
Path to the GeoTIFF file.
|
|
95
|
+
vert : ndarray of shape (N, 2)
|
|
96
|
+
Node coordinates ``(x, y)`` in ``input_crs``.
|
|
97
|
+
input_crs : str or pyproj.CRS, optional
|
|
98
|
+
CRS of ``vert``. If ``None``, assumes the raster CRS.
|
|
99
|
+
order : int, optional
|
|
100
|
+
Interpolation order for :func:`scipy.ndimage.map_coordinates` (0 =
|
|
101
|
+
nearest, 1 = bilinear, 3 = bicubic). Default is 3.
|
|
102
|
+
mode : str, optional
|
|
103
|
+
Boundary handling mode. Default is ``'constant'``.
|
|
104
|
+
cval : float, optional
|
|
105
|
+
Fill value outside the domain when ``mode='constant'``. Default is
|
|
106
|
+
``np.nan``.
|
|
107
|
+
invert_z : bool, optional
|
|
108
|
+
By default the raster stores elevation (positive up) and node values
|
|
109
|
+
are depth ``-value``. Set ``True`` for a depth-positive-down raster,
|
|
110
|
+
giving ``+value``. Default ``False``.
|
|
111
|
+
nodata_value : float or None, optional
|
|
112
|
+
Elevation (positive up) assigned to nodata / non-finite pixels.
|
|
113
|
+
``None`` (default) fills them from the nearest valid pixel.
|
|
114
|
+
max_cells : int or None, optional
|
|
115
|
+
Decimate the read window above this many cells. ``None`` sizes it to
|
|
116
|
+
available RAM.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
z : ndarray of shape (N,)
|
|
121
|
+
Node values: depth (``-elevation``), or ``+value`` if ``invert_z``.
|
|
122
|
+
"""
|
|
123
|
+
from bluemesh2d.feedback import _available_ram_bytes
|
|
124
|
+
from bluemesh2d.geom_util.proj_util import (
|
|
125
|
+
bundled_raster_data_env, require_georeferenced)
|
|
126
|
+
from bluemesh2d.geomesh_util.depth_field import (
|
|
127
|
+
_cell_center_offset, _read_window_decimated)
|
|
128
|
+
|
|
129
|
+
if max_cells is None:
|
|
130
|
+
avail = _available_ram_bytes() or 4_000_000_000
|
|
131
|
+
max_cells = int(min(120_000_000, max(4_000_000, 0.15 * avail / 16.0)))
|
|
132
|
+
sign = 1.0 if invert_z else -1.0
|
|
133
|
+
|
|
134
|
+
with bundled_raster_data_env(), rasterio.open(tiff_path) as src:
|
|
135
|
+
require_georeferenced(src)
|
|
136
|
+
raster_crs = src.crs
|
|
137
|
+
nodata = src.nodata
|
|
138
|
+
|
|
139
|
+
# node coordinates in the raster CRS, and the window that covers them
|
|
140
|
+
if (input_crs is not None
|
|
141
|
+
and pyproj.CRS.from_user_input(input_crs) != raster_crs):
|
|
142
|
+
transformer = pyproj.Transformer.from_crs(
|
|
143
|
+
input_crs, raster_crs, always_xy=True).transform
|
|
144
|
+
xs, ys = transformer(vert[:, 0], vert[:, 1])
|
|
145
|
+
else:
|
|
146
|
+
xs, ys = np.asarray(vert[:, 0]), np.asarray(vert[:, 1])
|
|
147
|
+
bbox = (float(np.min(xs)), float(np.min(ys)),
|
|
148
|
+
float(np.max(xs)), float(np.max(ys)))
|
|
149
|
+
center_off = _cell_center_offset(src) # 0.5 Area / 0.0 Point
|
|
150
|
+
band, transform, _ = _read_window_decimated(src, bbox, max_cells)
|
|
151
|
+
band = band.astype(np.float64)
|
|
152
|
+
|
|
153
|
+
if nodata is not None:
|
|
154
|
+
band = np.where(band == nodata, np.nan, band)
|
|
155
|
+
else:
|
|
156
|
+
band = np.where(~np.isfinite(band), np.nan, band)
|
|
157
|
+
|
|
158
|
+
if np.isnan(band).any():
|
|
159
|
+
if nodata_value is None:
|
|
160
|
+
mask = np.isnan(band)
|
|
161
|
+
if mask.all():
|
|
162
|
+
band = np.zeros_like(band) # nothing valid to fill from
|
|
163
|
+
else:
|
|
164
|
+
_, indices = distance_transform_edt(mask, return_indices=True)
|
|
165
|
+
band = band[tuple(indices)]
|
|
166
|
+
else:
|
|
167
|
+
band = np.where(np.isnan(band), float(nodata_value), band)
|
|
168
|
+
band_filled = band
|
|
169
|
+
|
|
170
|
+
inv_transform = ~transform
|
|
171
|
+
cols, rows = inv_transform * (xs, ys)
|
|
172
|
+
# the transform is corner-referenced (index k -> corner); for Area rasters
|
|
173
|
+
# band[i, j] is the cell CENTRE value, so shift by half a cell to sample
|
|
174
|
+
# centres (no half-cell shift). Point rasters use offset 0. Rim points
|
|
175
|
+
# falling outside the centre grid are handled by the nearest-fill branch.
|
|
176
|
+
cols = cols - center_off
|
|
177
|
+
rows = rows - center_off
|
|
178
|
+
|
|
179
|
+
mask_inside = (
|
|
180
|
+
(cols >= 0) & (cols < band.shape[1]) &
|
|
181
|
+
(rows >= 0) & (rows < band.shape[0])
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
z = np.full_like(np.asarray(xs, dtype=float), np.nan, dtype=float)
|
|
185
|
+
|
|
186
|
+
if np.any(mask_inside):
|
|
187
|
+
z[mask_inside] = sign * map_coordinates(
|
|
188
|
+
band_filled,
|
|
189
|
+
[rows[mask_inside], cols[mask_inside]],
|
|
190
|
+
order=order,
|
|
191
|
+
mode=mode,
|
|
192
|
+
cval=cval,
|
|
193
|
+
prefilter=False,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if np.any(~mask_inside):
|
|
197
|
+
rows_clip = np.clip(rows, 0, band.shape[0] - 1)
|
|
198
|
+
cols_clip = np.clip(cols, 0, band.shape[1] - 1)
|
|
199
|
+
z[~mask_inside] = sign * band_filled[
|
|
200
|
+
rows_clip[~mask_inside].astype(int),
|
|
201
|
+
cols_clip[~mask_inside].astype(int)]
|
|
202
|
+
|
|
203
|
+
return z
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def interpolate_from_xr(
|
|
207
|
+
ds,
|
|
208
|
+
vert,
|
|
209
|
+
order=3,
|
|
210
|
+
mode="constant",
|
|
211
|
+
cval=np.nan,
|
|
212
|
+
x_name='lon',
|
|
213
|
+
y_name='lat',
|
|
214
|
+
z_name="elevation",
|
|
215
|
+
fill_nan=True,
|
|
216
|
+
handle_out_of_bounds="nearest",
|
|
217
|
+
):
|
|
218
|
+
"""Interpolate bathymetry from an xarray dataset at mesh node coordinates.
|
|
219
|
+
|
|
220
|
+
Parameters
|
|
221
|
+
----------
|
|
222
|
+
ds : xarray.Dataset
|
|
223
|
+
Dataset with coordinate and elevation variables.
|
|
224
|
+
vert : ndarray of shape (N, 2)
|
|
225
|
+
Node coordinates ``(lon, lat)`` in degrees (EPSG:4326).
|
|
226
|
+
order : int, optional
|
|
227
|
+
Interpolation order (0 = nearest, 1 = bilinear, 3 = bicubic). Default is 3.
|
|
228
|
+
mode : str, optional
|
|
229
|
+
Boundary handling mode for :func:`scipy.ndimage.map_coordinates`.
|
|
230
|
+
Default is ``'constant'``.
|
|
231
|
+
cval : float, optional
|
|
232
|
+
Fill value outside the domain when ``mode='constant'``. Default is
|
|
233
|
+
``np.nan``.
|
|
234
|
+
x_name, y_name : str, optional
|
|
235
|
+
Names of the longitude and latitude coordinate variables in ``ds``.
|
|
236
|
+
z_name : str, optional
|
|
237
|
+
Name of the elevation variable in ``ds``. Default is ``'elevation'``.
|
|
238
|
+
fill_nan : bool, optional
|
|
239
|
+
If ``True``, fill NaN values in the dataset with the nearest valid
|
|
240
|
+
value. Default is ``True``.
|
|
241
|
+
handle_out_of_bounds : {'nearest', 'nan', 'clip'}, optional
|
|
242
|
+
Strategy for points outside the dataset extent. Default is ``'nearest'``.
|
|
243
|
+
|
|
244
|
+
Returns
|
|
245
|
+
-------
|
|
246
|
+
z : ndarray of shape (N,)
|
|
247
|
+
Interpolated depth values at mesh nodes (positive for ocean depth).
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
lon = ds[x_name].values
|
|
251
|
+
lat = ds[y_name].values
|
|
252
|
+
band = np.asarray(ds[z_name].values).astype(float)
|
|
253
|
+
|
|
254
|
+
# Handle NaN values in band (e.g., land mask)
|
|
255
|
+
if fill_nan and np.isnan(band).any():
|
|
256
|
+
mask = np.isnan(band)
|
|
257
|
+
_, indices = distance_transform_edt(mask, return_indices=True)
|
|
258
|
+
band = band[tuple(indices)]
|
|
259
|
+
|
|
260
|
+
# Extract coordinates (assumed to be lon/lat)
|
|
261
|
+
xs, ys = vert[:, 0], vert[:, 1]
|
|
262
|
+
|
|
263
|
+
# np.interp returns indices even for out-of-bounds values (extrapolates)
|
|
264
|
+
lon_idx = np.interp(xs, lon, np.arange(len(lon)))
|
|
265
|
+
lat_idx = np.interp(ys, lat, np.arange(len(lat)))
|
|
266
|
+
|
|
267
|
+
# Identify points inside/outside domain
|
|
268
|
+
lon_min, lon_max = lon.min(), lon.max()
|
|
269
|
+
lat_min, lat_max = lat.min(), lat.max()
|
|
270
|
+
|
|
271
|
+
mask_inside = (
|
|
272
|
+
(xs >= lon_min) & (xs <= lon_max) &
|
|
273
|
+
(ys >= lat_min) & (ys <= lat_max)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Initialize output array
|
|
277
|
+
z = np.full_like(xs, np.nan, dtype=float)
|
|
278
|
+
|
|
279
|
+
# Interpolate points inside domain
|
|
280
|
+
if np.any(mask_inside):
|
|
281
|
+
# Clip indices to valid range for map_coordinates
|
|
282
|
+
lat_idx_clip = np.clip(lat_idx[mask_inside], 0, len(lat) - 1)
|
|
283
|
+
lon_idx_clip = np.clip(lon_idx[mask_inside], 0, len(lon) - 1)
|
|
284
|
+
|
|
285
|
+
z[mask_inside] = -map_coordinates(
|
|
286
|
+
band,
|
|
287
|
+
[lat_idx_clip, lon_idx_clip],
|
|
288
|
+
order=order,
|
|
289
|
+
mode=mode,
|
|
290
|
+
cval=cval,
|
|
291
|
+
prefilter=(order > 1) # Prefilter only for order > 1 (bicubic)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if np.any(~mask_inside):
|
|
295
|
+
if handle_out_of_bounds == "nearest":
|
|
296
|
+
# Use nearest neighbor for out-of-bounds points
|
|
297
|
+
lat_idx_clip = np.clip(lat_idx[~mask_inside], 0, len(lat) - 1).astype(int)
|
|
298
|
+
lon_idx_clip = np.clip(lon_idx[~mask_inside], 0, len(lon) - 1).astype(int)
|
|
299
|
+
z[~mask_inside] = -band[lat_idx_clip, lon_idx_clip]
|
|
300
|
+
elif handle_out_of_bounds == "clip":
|
|
301
|
+
# Clip coordinates to domain and interpolate
|
|
302
|
+
xs_clip = np.clip(xs[~mask_inside], lon_min, lon_max)
|
|
303
|
+
ys_clip = np.clip(ys[~mask_inside], lat_min, lat_max)
|
|
304
|
+
lon_idx_clip = np.interp(xs_clip, lon, np.arange(len(lon)))
|
|
305
|
+
lat_idx_clip = np.interp(ys_clip, lat, np.arange(len(lat)))
|
|
306
|
+
lat_idx_clip = np.clip(lat_idx_clip, 0, len(lat) - 1)
|
|
307
|
+
lon_idx_clip = np.clip(lon_idx_clip, 0, len(lon) - 1)
|
|
308
|
+
z[~mask_inside] = -map_coordinates(
|
|
309
|
+
band,
|
|
310
|
+
[lat_idx_clip, lon_idx_clip],
|
|
311
|
+
order=order,
|
|
312
|
+
mode=mode,
|
|
313
|
+
cval=cval,
|
|
314
|
+
prefilter=(order > 1)
|
|
315
|
+
)
|
|
316
|
+
# else: "nan" - already initialized with NaN
|
|
317
|
+
|
|
318
|
+
return z
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Merge circumcenters along small dual links into quadrilateral faces.
|
|
2
|
+
|
|
3
|
+
Identifies short flow links, merges adjacent triangle pairs into quads, and
|
|
4
|
+
rebuilds a mixed tri/quad UGRID dataset compatible with Delft3D-FM export.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import xarray as xr
|
|
9
|
+
|
|
10
|
+
from .grd_util import build_ugrid_arrays_mixed, triangulate_mixed_face_row_to_tris
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _faces_list_from_ds(ds: xr.Dataset) -> list:
|
|
14
|
+
"""0-based polygon faces (len 3 or 4) from ``mesh2d_face_nodes``."""
|
|
15
|
+
face_nodes_raw = np.asarray(ds["mesh2d_face_nodes"].values, dtype=np.int64)
|
|
16
|
+
start = int(ds["mesh2d_face_nodes"].attrs.get("start_index", 1))
|
|
17
|
+
if start == 1:
|
|
18
|
+
face0b = np.full_like(face_nodes_raw, -1)
|
|
19
|
+
valid = face_nodes_raw > 0
|
|
20
|
+
face0b[valid] = face_nodes_raw[valid] - 1
|
|
21
|
+
else:
|
|
22
|
+
face0b = face_nodes_raw.copy()
|
|
23
|
+
faces: list = []
|
|
24
|
+
for row in face0b:
|
|
25
|
+
nodes = row[row >= 0]
|
|
26
|
+
if nodes.size >= 3:
|
|
27
|
+
faces.append(nodes.astype(np.int64, copy=True))
|
|
28
|
+
return faces
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _mixed_faces_to_triangles(vert_xy: np.ndarray, faces: list) -> np.ndarray:
|
|
32
|
+
"""Expand mixed faces to triangle rows using the merge diagonal ``(v1, v2)``."""
|
|
33
|
+
xy = np.asarray(vert_xy, dtype=np.float64)[:, :2]
|
|
34
|
+
tris: list = []
|
|
35
|
+
for f in faces:
|
|
36
|
+
fn = np.asarray(f, dtype=np.int64).reshape(-1)
|
|
37
|
+
if fn.size < 3:
|
|
38
|
+
continue
|
|
39
|
+
tris.extend(triangulate_mixed_face_row_to_tris(xy, fn))
|
|
40
|
+
if not tris:
|
|
41
|
+
return np.empty((0, 3), dtype=np.int64)
|
|
42
|
+
return np.asarray(tris, dtype=np.int64)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _signed_area_quad(vert, quad):
|
|
46
|
+
"""Signed area (doubled) of quadrilateral for orientation check."""
|
|
47
|
+
v = vert[quad]
|
|
48
|
+
return (
|
|
49
|
+
(v[1, 0] - v[0, 0]) * (v[2, 1] - v[0, 1])
|
|
50
|
+
- (v[2, 0] - v[0, 0]) * (v[1, 1] - v[0, 1])
|
|
51
|
+
+ (v[2, 0] - v[1, 0]) * (v[3, 1] - v[1, 1])
|
|
52
|
+
- (v[3, 0] - v[1, 0]) * (v[2, 1] - v[1, 1])
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _merge_small_links_into_faces(tria, edge_cc, small_link_indices, vert):
|
|
57
|
+
"""Merge triangle pairs along small links into quadrilateral faces."""
|
|
58
|
+
merged = set()
|
|
59
|
+
quads = []
|
|
60
|
+
for idx in small_link_indices:
|
|
61
|
+
e = edge_cc[idx]
|
|
62
|
+
v1, v2, t1, t2 = int(e[0]), int(e[1]), int(e[2]), int(e[3])
|
|
63
|
+
if t2 < 0 or t1 in merged or t2 in merged:
|
|
64
|
+
continue
|
|
65
|
+
tri1 = tria[t1]
|
|
66
|
+
tri2 = tria[t2]
|
|
67
|
+
mask1 = (tri1 != v1) & (tri1 != v2)
|
|
68
|
+
mask2 = (tri2 != v1) & (tri2 != v2)
|
|
69
|
+
if not np.any(mask1) or not np.any(mask2):
|
|
70
|
+
continue
|
|
71
|
+
a = int(tri1[mask1][0])
|
|
72
|
+
b = int(tri2[mask2][0])
|
|
73
|
+
quad = np.array([a, v1, b, v2], dtype=np.int32)
|
|
74
|
+
if _signed_area_quad(vert, quad) < 0:
|
|
75
|
+
quad = np.array([a, v2, b, v1], dtype=np.int32)
|
|
76
|
+
quads.append((t1, t2, quad))
|
|
77
|
+
merged.add(t1)
|
|
78
|
+
merged.add(t2)
|
|
79
|
+
|
|
80
|
+
faces_list = []
|
|
81
|
+
for t1, t2, q in quads:
|
|
82
|
+
faces_list.append(q)
|
|
83
|
+
for i in range(len(tria)):
|
|
84
|
+
if i not in merged:
|
|
85
|
+
faces_list.append(tria[i])
|
|
86
|
+
return faces_list
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _rebuild_ds_from_form(ds_ori, ugrid_arrays):
|
|
90
|
+
"""Rebuild an xarray dataset preserving the structure of ``ds_ori``."""
|
|
91
|
+
|
|
92
|
+
def _da(name, data, dims, attrs=None):
|
|
93
|
+
if name in ds_ori.variables and hasattr(ds_ori.variables[name], "attrs"):
|
|
94
|
+
base_attrs = dict(ds_ori.variables[name].attrs)
|
|
95
|
+
else:
|
|
96
|
+
base_attrs = {}
|
|
97
|
+
if attrs:
|
|
98
|
+
base_attrs.update(attrs)
|
|
99
|
+
return xr.DataArray(data=data, dims=dims, attrs=base_attrs)
|
|
100
|
+
|
|
101
|
+
coords = {
|
|
102
|
+
"mesh2d_node_x": _da(
|
|
103
|
+
"mesh2d_node_x",
|
|
104
|
+
ugrid_arrays["node_x"],
|
|
105
|
+
("mesh2d_nNodes",),
|
|
106
|
+
),
|
|
107
|
+
"mesh2d_node_y": _da(
|
|
108
|
+
"mesh2d_node_y",
|
|
109
|
+
ugrid_arrays["node_y"],
|
|
110
|
+
("mesh2d_nNodes",),
|
|
111
|
+
),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
data_vars = {
|
|
115
|
+
"mesh2d_node_z": _da(
|
|
116
|
+
"mesh2d_node_z",
|
|
117
|
+
ugrid_arrays["node_z"],
|
|
118
|
+
("mesh2d_nNodes",),
|
|
119
|
+
),
|
|
120
|
+
"mesh2d_edge_x": _da(
|
|
121
|
+
"mesh2d_edge_x",
|
|
122
|
+
ugrid_arrays["edge_x"],
|
|
123
|
+
("mesh2d_nEdges",),
|
|
124
|
+
),
|
|
125
|
+
"mesh2d_edge_y": _da(
|
|
126
|
+
"mesh2d_edge_y",
|
|
127
|
+
ugrid_arrays["edge_y"],
|
|
128
|
+
("mesh2d_nEdges",),
|
|
129
|
+
),
|
|
130
|
+
"mesh2d_edge_nodes": _da(
|
|
131
|
+
"mesh2d_edge_nodes",
|
|
132
|
+
ugrid_arrays["edge_nodes"],
|
|
133
|
+
("mesh2d_nEdges", "Two"),
|
|
134
|
+
),
|
|
135
|
+
"mesh2d_edge_faces": _da(
|
|
136
|
+
"mesh2d_edge_faces",
|
|
137
|
+
ugrid_arrays["edge_faces"],
|
|
138
|
+
("mesh2d_nEdges", "Two"),
|
|
139
|
+
),
|
|
140
|
+
"mesh2d_face_nodes": _da(
|
|
141
|
+
"mesh2d_face_nodes",
|
|
142
|
+
ugrid_arrays["face_nodes"],
|
|
143
|
+
("mesh2d_nFaces", "mesh2d_nMax_face_nodes"),
|
|
144
|
+
),
|
|
145
|
+
"mesh2d_face_x": _da(
|
|
146
|
+
"mesh2d_face_x",
|
|
147
|
+
ugrid_arrays["face_x"],
|
|
148
|
+
("mesh2d_nFaces",),
|
|
149
|
+
),
|
|
150
|
+
"mesh2d_face_y": _da(
|
|
151
|
+
"mesh2d_face_y",
|
|
152
|
+
ugrid_arrays["face_y"],
|
|
153
|
+
("mesh2d_nFaces",),
|
|
154
|
+
),
|
|
155
|
+
"mesh2d_face_x_bnd": _da(
|
|
156
|
+
"mesh2d_face_x_bnd",
|
|
157
|
+
ugrid_arrays["face_x_bnd"],
|
|
158
|
+
("mesh2d_nFaces", "mesh2d_nMax_face_nodes"),
|
|
159
|
+
),
|
|
160
|
+
"mesh2d_face_y_bnd": _da(
|
|
161
|
+
"mesh2d_face_y_bnd",
|
|
162
|
+
ugrid_arrays["face_y_bnd"],
|
|
163
|
+
("mesh2d_nFaces", "mesh2d_nMax_face_nodes"),
|
|
164
|
+
),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# Copy non-mesh variables from ds_ori (e.g. wgs84, mesh2d topology variable)
|
|
168
|
+
mesh_dims = {
|
|
169
|
+
"mesh2d_nNodes",
|
|
170
|
+
"mesh2d_nEdges",
|
|
171
|
+
"mesh2d_nFaces",
|
|
172
|
+
"mesh2d_nMax_face_nodes",
|
|
173
|
+
}
|
|
174
|
+
for k in ds_ori.variables:
|
|
175
|
+
if k in coords or k in data_vars:
|
|
176
|
+
continue
|
|
177
|
+
v = ds_ori.variables[k]
|
|
178
|
+
if set(v.dims) & mesh_dims:
|
|
179
|
+
continue
|
|
180
|
+
data_vars[k] = xr.DataArray(
|
|
181
|
+
data=ds_ori[k].values.copy(),
|
|
182
|
+
dims=v.dims,
|
|
183
|
+
attrs=dict(v.attrs),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
ds_final = xr.Dataset(data_vars=data_vars, coords=coords, attrs=dict(ds_ori.attrs))
|
|
187
|
+
return ds_final
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def merge_circumcenters(
|
|
191
|
+
ds_ori,
|
|
192
|
+
removesmalllinkstrsh=0.1,
|
|
193
|
+
jsferic=1,
|
|
194
|
+
):
|
|
195
|
+
"""Merge triangle pairs with small circumcenter links into quads.
|
|
196
|
+
|
|
197
|
+
Identifies short dual flow links, merges each adjacent triangle pair into
|
|
198
|
+
one quadrilateral, and rebuilds a Delft3D-FM UGRID dataset with the same
|
|
199
|
+
structure as ``ds_ori``.
|
|
200
|
+
|
|
201
|
+
Parameters
|
|
202
|
+
----------
|
|
203
|
+
ds_ori : xarray.Dataset
|
|
204
|
+
Input UGRID Delft3D-FM mesh.
|
|
205
|
+
removesmalllinkstrsh : float, optional
|
|
206
|
+
Threshold for small flow links. Default is 0.1.
|
|
207
|
+
jsferic : int, optional
|
|
208
|
+
Coordinate mode: ``1`` treats node coordinates as lon/lat degrees;
|
|
209
|
+
``0`` treats them as planar ``x/y``. Default is 1.
|
|
210
|
+
|
|
211
|
+
Returns
|
|
212
|
+
-------
|
|
213
|
+
ds_final : xarray.Dataset
|
|
214
|
+
Rebuilt mesh with small links replaced by quads.
|
|
215
|
+
``mesh2d_nMax_face_nodes`` is 4; triangles are padded in the fourth
|
|
216
|
+
node column.
|
|
217
|
+
"""
|
|
218
|
+
node_x = np.asarray(ds_ori["mesh2d_node_x"].values, dtype=np.float64)
|
|
219
|
+
node_y = np.asarray(ds_ori["mesh2d_node_y"].values, dtype=np.float64)
|
|
220
|
+
node_z = np.asarray(ds_ori["mesh2d_node_z"].values, dtype=np.float64)
|
|
221
|
+
vert = np.column_stack([node_x, node_y])
|
|
222
|
+
|
|
223
|
+
faces_in = _faces_list_from_ds(ds_ori)
|
|
224
|
+
|
|
225
|
+
# Triangulate mixed faces to a *triangle proxy* using the same diagonal
|
|
226
|
+
# (v1, v2) logic as the rest of the pipeline/export.
|
|
227
|
+
tria = _mixed_faces_to_triangles(vert, faces_in)
|
|
228
|
+
tria = np.asarray(tria, dtype=np.int32)
|
|
229
|
+
|
|
230
|
+
# IMPORTANT: use the same small-link metric as ortho_merge.orthogonalize,
|
|
231
|
+
# so our dual checks and merge decisions are consistent.
|
|
232
|
+
from ..ortho_merge import orthogonalize as ortho
|
|
233
|
+
from ..ortho_merge.geometry import build_edges_from_tria
|
|
234
|
+
|
|
235
|
+
edge_nodes, edge_faces = build_edges_from_tria(tria)
|
|
236
|
+
nlinktoosmall, small_edge_indices = ortho.compute_small_links_from_arrays(
|
|
237
|
+
node_x=node_x,
|
|
238
|
+
node_y=node_y,
|
|
239
|
+
face_nodes=tria,
|
|
240
|
+
edge_nodes=edge_nodes,
|
|
241
|
+
edge_faces=edge_faces,
|
|
242
|
+
removesmalllinkstrsh=float(removesmalllinkstrsh),
|
|
243
|
+
jsferic=jsferic,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if nlinktoosmall > 0:
|
|
247
|
+
# Build edge_cc compatible with `_merge_small_links_into_faces`:
|
|
248
|
+
# [v1, v2, t1, t2] where t1/t2 are adjacent face indices in `tria`.
|
|
249
|
+
edge_cc = np.column_stack(
|
|
250
|
+
[
|
|
251
|
+
edge_nodes[:, 0],
|
|
252
|
+
edge_nodes[:, 1],
|
|
253
|
+
edge_faces[:, 0],
|
|
254
|
+
edge_faces[:, 1],
|
|
255
|
+
]
|
|
256
|
+
).astype(np.int32, copy=False)
|
|
257
|
+
|
|
258
|
+
faces_list = _merge_small_links_into_faces(
|
|
259
|
+
tria, edge_cc, small_edge_indices, vert
|
|
260
|
+
)
|
|
261
|
+
else:
|
|
262
|
+
# No small links detected: keep the existing mixed topology.
|
|
263
|
+
faces_list = [np.asarray(f, dtype=np.int32).copy() for f in faces_in]
|
|
264
|
+
|
|
265
|
+
NODE = np.column_stack([vert[:, 0], vert[:, 1], node_z])
|
|
266
|
+
ugrid_arrays = build_ugrid_arrays_mixed(NODE, faces_list)
|
|
267
|
+
ds_final = _rebuild_ds_from_form(ds_ori, ugrid_arrays)
|
|
268
|
+
return ds_final
|