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.
Files changed (74) hide show
  1. bluemesh2d/_version.py +24 -0
  2. bluemesh2d/aabb_tree/findball.py +121 -0
  3. bluemesh2d/aabb_tree/findtria.py +299 -0
  4. bluemesh2d/aabb_tree/maketree.py +147 -0
  5. bluemesh2d/aabb_tree/mapvert.py +72 -0
  6. bluemesh2d/aabb_tree/queryset.py +83 -0
  7. bluemesh2d/aabb_tree/scantree.py +124 -0
  8. bluemesh2d/dependencies.py +83 -0
  9. bluemesh2d/feedback.py +142 -0
  10. bluemesh2d/geom_util/boundary_util.py +216 -0
  11. bluemesh2d/geom_util/getiso.py +194 -0
  12. bluemesh2d/geom_util/poly_util.py +795 -0
  13. bluemesh2d/geom_util/proj_util.py +250 -0
  14. bluemesh2d/geomesh_util/border_util.py +283 -0
  15. bluemesh2d/geomesh_util/depth_field.py +333 -0
  16. bluemesh2d/geomesh_util/grd_util.py +1000 -0
  17. bluemesh2d/geomesh_util/interpolation_mesh.py +318 -0
  18. bluemesh2d/geomesh_util/merge_circumcenters.py +268 -0
  19. bluemesh2d/geomesh_util/water_polygon.py +406 -0
  20. bluemesh2d/hfun_util/build_hfun.py +589 -0
  21. bluemesh2d/hfun_util/hfun_dispersion.py +96 -0
  22. bluemesh2d/hfun_util/lfshfn.py +110 -0
  23. bluemesh2d/hfun_util/limhfn.py +65 -0
  24. bluemesh2d/hfun_util/make_constant_hfun.py +32 -0
  25. bluemesh2d/hfun_util/make_depth_hfun.py +51 -0
  26. bluemesh2d/hfun_util/smooth_and_precomput.py +188 -0
  27. bluemesh2d/hfun_util/trihfn.py +84 -0
  28. bluemesh2d/hjac_util/limgrad.py +105 -0
  29. bluemesh2d/mesh_ball/cdtbal1.py +38 -0
  30. bluemesh2d/mesh_ball/cdtbal2.py +104 -0
  31. bluemesh2d/mesh_ball/inv_2x2.py +61 -0
  32. bluemesh2d/mesh_ball/inv_3x3.py +72 -0
  33. bluemesh2d/mesh_ball/pwrbal2.py +134 -0
  34. bluemesh2d/mesh_ball/tribal2.py +27 -0
  35. bluemesh2d/mesh_cost/relhfn.py +62 -0
  36. bluemesh2d/mesh_cost/triang.py +59 -0
  37. bluemesh2d/mesh_cost/triarea.py +51 -0
  38. bluemesh2d/mesh_cost/trideg.py +44 -0
  39. bluemesh2d/mesh_cost/triscr.py +43 -0
  40. bluemesh2d/mesh_file/bnd_util.py +664 -0
  41. bluemesh2d/mesh_file/loadmsh.py +165 -0
  42. bluemesh2d/mesh_file/ugrid.py +308 -0
  43. bluemesh2d/mesh_util/cfmtri.py +118 -0
  44. bluemesh2d/mesh_util/deltri.py +131 -0
  45. bluemesh2d/mesh_util/idxtri.py +53 -0
  46. bluemesh2d/mesh_util/isfeat.py +90 -0
  47. bluemesh2d/mesh_util/minlen.py +46 -0
  48. bluemesh2d/mesh_util/setset.py +49 -0
  49. bluemesh2d/mesh_util/tricon.py +90 -0
  50. bluemesh2d/mesh_util/tridiv.py +187 -0
  51. bluemesh2d/meshgen.py +202 -0
  52. bluemesh2d/ortho_merge/constants.py +27 -0
  53. bluemesh2d/ortho_merge/geometry.py +64 -0
  54. bluemesh2d/ortho_merge/ortho_merge_iter.py +812 -0
  55. bluemesh2d/ortho_merge/orthogonalize.py +2989 -0
  56. bluemesh2d/pipeline.py +277 -0
  57. bluemesh2d/poly_data/airfoil.msh +958 -0
  58. bluemesh2d/poly_data/channel.msh +212 -0
  59. bluemesh2d/poly_data/islands.msh +13819 -0
  60. bluemesh2d/poly_data/lake.msh +612 -0
  61. bluemesh2d/poly_data/river.msh +690 -0
  62. bluemesh2d/poly_test/inpoly.py +105 -0
  63. bluemesh2d/poly_test/inpoly_mat.py +104 -0
  64. bluemesh2d/refine.py +972 -0
  65. bluemesh2d/smood.py +623 -0
  66. bluemesh2d/smooth.py +522 -0
  67. bluemesh2d/tricost.py +426 -0
  68. bluemesh2d/tridemo.py +723 -0
  69. bluemesh2d/triread.py +53 -0
  70. bluemesh2d-0.1.1.dev0.dist-info/METADATA +139 -0
  71. bluemesh2d-0.1.1.dev0.dist-info/RECORD +74 -0
  72. bluemesh2d-0.1.1.dev0.dist-info/WHEEL +5 -0
  73. bluemesh2d-0.1.1.dev0.dist-info/licenses/LICENSE +674 -0
  74. 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