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,1000 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Delft3D-FM UGRID (xarray) builders, mixed tri/quad connectivity, and ADCIRC helpers.
|
|
3
|
+
|
|
4
|
+
Used by :mod:`bluemesh2d.smood` and ``merge_circumcenters``.
|
|
5
|
+
|
|
6
|
+
``xarray`` is imported lazily so the array-level helpers (``build_ugrid_arrays``,
|
|
7
|
+
``calculate_edges``, ...) stay usable without it; only the ``xr.Dataset``
|
|
8
|
+
builders (``adcirc2DFlowFM*``) require it.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
import matplotlib.pyplot as plt
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import xarray as xr
|
|
19
|
+
except ImportError: # optional: only needed for the xr.Dataset builders
|
|
20
|
+
xr = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _signed_area_tri_xy(xy: np.ndarray, i: int, j: int, k: int) -> float:
|
|
24
|
+
"""Twice the signed triangle area in the x–y plane (CCW > 0)."""
|
|
25
|
+
p, q, r = xy[i], xy[j], xy[k]
|
|
26
|
+
return (q[0] - p[0]) * (r[1] - p[1]) - (r[0] - p[0]) * (q[1] - p[1])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def triangulate_mixed_face_row_to_tris(
|
|
30
|
+
node_xy: np.ndarray, nodes_valid: np.ndarray
|
|
31
|
+
) -> list[tuple[int, int, int]]:
|
|
32
|
+
"""Triangulate one mixed polygon face (3, 4, or more vertices).
|
|
33
|
+
|
|
34
|
+
Three-node faces yield one triangle. Four-node faces (quads stored as
|
|
35
|
+
``[a, v1, b, v2]`` from ``merge_circumcenters``) split along the merged
|
|
36
|
+
diagonal ``(v1, v2)``. Faces with five or more nodes are fanned from the
|
|
37
|
+
first vertex.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
node_xy : ndarray of shape (N, 2)
|
|
42
|
+
Mesh node coordinates.
|
|
43
|
+
nodes_valid : ndarray
|
|
44
|
+
0-based vertex indices for one face (length 3, 4, or more).
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
list of tuple of int
|
|
49
|
+
Triangle connectivity as ``(i, j, k)`` tuples.
|
|
50
|
+
"""
|
|
51
|
+
nodes = np.asarray(nodes_valid, dtype=np.int64).reshape(-1)
|
|
52
|
+
n = int(nodes.size)
|
|
53
|
+
if n < 3:
|
|
54
|
+
return []
|
|
55
|
+
xy = np.asarray(node_xy, dtype=np.float64)
|
|
56
|
+
if n == 3:
|
|
57
|
+
return [(int(nodes[0]), int(nodes[1]), int(nodes[2]))]
|
|
58
|
+
if n == 4:
|
|
59
|
+
a, v1, b, v2 = int(nodes[0]), int(nodes[1]), int(nodes[2]), int(nodes[3])
|
|
60
|
+
|
|
61
|
+
def sa(ii: int, jj: int, kk: int) -> float:
|
|
62
|
+
return _signed_area_tri_xy(xy, ii, jj, kk)
|
|
63
|
+
|
|
64
|
+
t1 = (a, v1, v2)
|
|
65
|
+
if sa(*t1) < 0:
|
|
66
|
+
t1 = (a, v2, v1)
|
|
67
|
+
o1 = sa(*t1)
|
|
68
|
+
t2 = None
|
|
69
|
+
for cand in ((b, v1, v2), (b, v2, v1)):
|
|
70
|
+
if o1 != 0.0 and sa(*cand) * o1 > 0:
|
|
71
|
+
t2 = cand
|
|
72
|
+
break
|
|
73
|
+
if t2 is None:
|
|
74
|
+
t2 = (b, v2, v1)
|
|
75
|
+
return [t1, t2]
|
|
76
|
+
tris: list[tuple[int, int, int]] = []
|
|
77
|
+
n0 = int(nodes[0])
|
|
78
|
+
for i in range(1, n - 1):
|
|
79
|
+
tris.append((n0, int(nodes[i]), int(nodes[i + 1])))
|
|
80
|
+
return tris
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def face_nodes_0b_to_faces_list(face_nodes_0b: np.ndarray) -> list:
|
|
84
|
+
"""Convert a padded face-node array to a list of variable-length faces.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
face_nodes_0b : ndarray of shape (F, 4)
|
|
89
|
+
Face-node connectivity (0-based, ``-1`` padding).
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
list of ndarray
|
|
94
|
+
One array of vertex indices per face (length 3 for triangles, 4 for quads).
|
|
95
|
+
"""
|
|
96
|
+
out: list = []
|
|
97
|
+
for row in np.asarray(face_nodes_0b, dtype=np.int64):
|
|
98
|
+
nodes = row[row >= 0]
|
|
99
|
+
if nodes.size >= 3:
|
|
100
|
+
out.append(nodes.copy())
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def validate_mixed_export_matches_smood_tria(
|
|
105
|
+
vert_xy: np.ndarray,
|
|
106
|
+
face_nodes_0b: np.ndarray,
|
|
107
|
+
tria_smood: np.ndarray,
|
|
108
|
+
) -> tuple[int, int]:
|
|
109
|
+
"""Verify that mixed-face export matches the ``smood`` triangle output.
|
|
110
|
+
|
|
111
|
+
Expands ``face_nodes_0b`` (final ortho+merge topology) and checks that the
|
|
112
|
+
resulting triangle set equals ``tria_smood`` from
|
|
113
|
+
:func:`bluemesh2d.smood.smood`.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
vert_xy : ndarray of shape (N, 2+)
|
|
118
|
+
Node coordinates.
|
|
119
|
+
face_nodes_0b : ndarray of shape (F, 4)
|
|
120
|
+
Mixed face-node connectivity (0-based, ``-1`` padding).
|
|
121
|
+
tria_smood : ndarray of shape (T, 3)
|
|
122
|
+
Triangle connectivity returned by ``smood``.
|
|
123
|
+
|
|
124
|
+
Returns
|
|
125
|
+
-------
|
|
126
|
+
n_tri_faces : int
|
|
127
|
+
Number of triangular faces in ``face_nodes_0b``.
|
|
128
|
+
n_quad_faces : int
|
|
129
|
+
Number of quadrilateral faces in ``face_nodes_0b``.
|
|
130
|
+
|
|
131
|
+
Raises
|
|
132
|
+
------
|
|
133
|
+
ValueError
|
|
134
|
+
If connectivity is out of range or the expanded triangles do not match
|
|
135
|
+
``tria_smood``.
|
|
136
|
+
"""
|
|
137
|
+
vert_xy = np.asarray(vert_xy, dtype=np.float64)
|
|
138
|
+
if vert_xy.ndim != 2 or vert_xy.shape[1] < 2:
|
|
139
|
+
raise ValueError("vert_xy must have shape (N, 2+)")
|
|
140
|
+
xy = vert_xy[:, :2]
|
|
141
|
+
fn = np.asarray(face_nodes_0b, dtype=np.int64)
|
|
142
|
+
tria_smood = np.asarray(tria_smood, dtype=np.int64)
|
|
143
|
+
if tria_smood.ndim != 2 or tria_smood.shape[1] != 3:
|
|
144
|
+
raise ValueError("tria_smood must have shape (T, 3)")
|
|
145
|
+
|
|
146
|
+
expanded: list[tuple[int, int, int]] = []
|
|
147
|
+
n_tri_f = n_quad_f = 0
|
|
148
|
+
for row in fn:
|
|
149
|
+
nodes = row[row >= 0]
|
|
150
|
+
if nodes.size < 3:
|
|
151
|
+
continue
|
|
152
|
+
if int(nodes.min()) < 0 or int(nodes.max()) >= vert_xy.shape[0]:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"face node index out of range [0, {vert_xy.shape[0]}): {nodes!r}"
|
|
155
|
+
)
|
|
156
|
+
if nodes.size == 4:
|
|
157
|
+
n_quad_f += 1
|
|
158
|
+
else:
|
|
159
|
+
n_tri_f += 1
|
|
160
|
+
expanded.extend(triangulate_mixed_face_row_to_tris(xy, nodes))
|
|
161
|
+
|
|
162
|
+
exp = np.asarray(expanded, dtype=np.int64)
|
|
163
|
+
if exp.shape != tria_smood.shape:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
"Export topology mismatch: mixed faces expand to "
|
|
166
|
+
f"{exp.shape[0]} triangles, but smood returned {tria_smood.shape[0]}. "
|
|
167
|
+
"You may be exporting triangle-only (quads re-split) or a stale ``face_nodes``."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _sort_rows(t: np.ndarray) -> np.ndarray:
|
|
171
|
+
return np.sort(np.asarray(t, dtype=np.int64), axis=1)
|
|
172
|
+
|
|
173
|
+
es = _sort_rows(exp)
|
|
174
|
+
ts = _sort_rows(tria_smood)
|
|
175
|
+
order_e = np.lexsort((es[:, 2], es[:, 1], es[:, 0]))
|
|
176
|
+
order_t = np.lexsort((ts[:, 2], ts[:, 1], ts[:, 0]))
|
|
177
|
+
if not np.array_equal(es[order_e], ts[order_t]):
|
|
178
|
+
raise ValueError(
|
|
179
|
+
"Triangle set from mixed faces ≠ smood triangle output (winding/diagonal mismatch)."
|
|
180
|
+
)
|
|
181
|
+
return int(n_tri_f), int(n_quad_f)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _xr_dataset_from_ugrid_dict(ugrid: dict) -> xr.Dataset:
|
|
185
|
+
"""Build the standard Delft3D-FM-style xarray Dataset from a ``build_ugrid_arrays*`` dict."""
|
|
186
|
+
if xr is None:
|
|
187
|
+
raise ImportError(
|
|
188
|
+
"xarray is required for the xr.Dataset builders (adcirc2DFlowFM*). "
|
|
189
|
+
"Either install xarray, or use build_ugrid_arrays() and write the "
|
|
190
|
+
"NetCDF yourself (e.g. with netCDF4)."
|
|
191
|
+
)
|
|
192
|
+
node_z_out = -ugrid["node_z"]
|
|
193
|
+
_WGS84_FILL = np.int32(-2147483647)
|
|
194
|
+
_UGRID_FILL = np.int32(-999)
|
|
195
|
+
|
|
196
|
+
coords = {
|
|
197
|
+
"mesh2d_node_x": xr.DataArray(
|
|
198
|
+
ugrid["node_x"],
|
|
199
|
+
dims=("mesh2d_nNodes",),
|
|
200
|
+
attrs={
|
|
201
|
+
"standard_name": "longitude",
|
|
202
|
+
"long_name": "x-coordinate of mesh nodes",
|
|
203
|
+
"units": "degrees_east",
|
|
204
|
+
},
|
|
205
|
+
),
|
|
206
|
+
"mesh2d_node_y": xr.DataArray(
|
|
207
|
+
ugrid["node_y"],
|
|
208
|
+
dims=("mesh2d_nNodes",),
|
|
209
|
+
attrs={
|
|
210
|
+
"standard_name": "latitude",
|
|
211
|
+
"long_name": "y-coordinate of mesh nodes",
|
|
212
|
+
"units": "degrees_north",
|
|
213
|
+
},
|
|
214
|
+
),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
data_vars = {
|
|
218
|
+
"mesh2d_node_z": xr.DataArray(
|
|
219
|
+
node_z_out,
|
|
220
|
+
dims=("mesh2d_nNodes",),
|
|
221
|
+
attrs={
|
|
222
|
+
"mesh": "mesh2d",
|
|
223
|
+
"location": "node",
|
|
224
|
+
"units": "m",
|
|
225
|
+
"standard_name": "altitude",
|
|
226
|
+
"long_name": "z-coordinate of mesh nodes",
|
|
227
|
+
"grid_mapping": "wgs84",
|
|
228
|
+
},
|
|
229
|
+
),
|
|
230
|
+
"mesh2d_edge_x": xr.DataArray(
|
|
231
|
+
ugrid["edge_x"],
|
|
232
|
+
dims=("mesh2d_nEdges",),
|
|
233
|
+
attrs={
|
|
234
|
+
"standard_name": "projection_x_coordinate",
|
|
235
|
+
"long_name": "characteristic x-coordinate of the mesh edge (e.g. midpoint)",
|
|
236
|
+
"units": "degrees_east",
|
|
237
|
+
# Keep standard_name as in GUI (longitude) instead of projection_x_coordinate.
|
|
238
|
+
"standard_name": "longitude",
|
|
239
|
+
},
|
|
240
|
+
),
|
|
241
|
+
"mesh2d_edge_y": xr.DataArray(
|
|
242
|
+
ugrid["edge_y"],
|
|
243
|
+
dims=("mesh2d_nEdges",),
|
|
244
|
+
attrs={
|
|
245
|
+
"standard_name": "projection_y_coordinate",
|
|
246
|
+
"long_name": "characteristic y-coordinate of the mesh edge (e.g. midpoint)",
|
|
247
|
+
"units": "degrees_north",
|
|
248
|
+
"standard_name": "latitude",
|
|
249
|
+
},
|
|
250
|
+
),
|
|
251
|
+
"mesh2d_edge_nodes": xr.DataArray(
|
|
252
|
+
ugrid["edge_nodes"],
|
|
253
|
+
dims=("mesh2d_nEdges", "Two"),
|
|
254
|
+
attrs={
|
|
255
|
+
"cf_role": "edge_node_connectivity",
|
|
256
|
+
"long_name": "Start and end nodes of mesh edges",
|
|
257
|
+
"start_index": 1,
|
|
258
|
+
},
|
|
259
|
+
),
|
|
260
|
+
"mesh2d_edge_faces": xr.DataArray(
|
|
261
|
+
ugrid["edge_faces"],
|
|
262
|
+
dims=("mesh2d_nEdges", "Two"),
|
|
263
|
+
attrs={
|
|
264
|
+
"cf_role": "edge_face_connectivity",
|
|
265
|
+
"long_name": "Neighboring faces of mesh edges",
|
|
266
|
+
"start_index": np.int32(1),
|
|
267
|
+
},
|
|
268
|
+
).assign_attrs(_FillValue=_UGRID_FILL),
|
|
269
|
+
"mesh2d_face_nodes": xr.DataArray(
|
|
270
|
+
ugrid["face_nodes"],
|
|
271
|
+
dims=("mesh2d_nFaces", "mesh2d_nMax_face_nodes"),
|
|
272
|
+
attrs={
|
|
273
|
+
"cf_role": "face_node_connectivity",
|
|
274
|
+
"long_name": "Vertex nodes of mesh faces (counterclockwise)",
|
|
275
|
+
"start_index": np.int32(1),
|
|
276
|
+
"coordinates": "mesh2d_node_x mesh2d_node_y",
|
|
277
|
+
},
|
|
278
|
+
).assign_attrs(_FillValue=_UGRID_FILL),
|
|
279
|
+
"mesh2d_face_x": xr.DataArray(
|
|
280
|
+
ugrid["face_x"],
|
|
281
|
+
dims=("mesh2d_nFaces",),
|
|
282
|
+
attrs={
|
|
283
|
+
"units": "degrees_east",
|
|
284
|
+
"standard_name": "longitude",
|
|
285
|
+
"long_name": "Characteristic x-coordinate of mesh face",
|
|
286
|
+
"bounds": "mesh2d_face_x_bnd",
|
|
287
|
+
},
|
|
288
|
+
),
|
|
289
|
+
"mesh2d_face_y": xr.DataArray(
|
|
290
|
+
ugrid["face_y"],
|
|
291
|
+
dims=("mesh2d_nFaces",),
|
|
292
|
+
attrs={
|
|
293
|
+
"units": "degrees_north",
|
|
294
|
+
"standard_name": "latitude",
|
|
295
|
+
"long_name": "Characteristic y-coordinate of mesh face",
|
|
296
|
+
"bounds": "mesh2d_face_y_bnd",
|
|
297
|
+
},
|
|
298
|
+
),
|
|
299
|
+
"mesh2d_face_x_bnd": xr.DataArray(
|
|
300
|
+
ugrid["face_x_bnd"],
|
|
301
|
+
dims=("mesh2d_nFaces", "mesh2d_nMax_face_nodes"),
|
|
302
|
+
attrs={
|
|
303
|
+
"long_name": "x-coordinate bounds of mesh faces (i.e. corner coordinates)",
|
|
304
|
+
"units": "degrees_east",
|
|
305
|
+
"standard_name": "longitude",
|
|
306
|
+
},
|
|
307
|
+
),
|
|
308
|
+
"mesh2d_face_y_bnd": xr.DataArray(
|
|
309
|
+
ugrid["face_y_bnd"],
|
|
310
|
+
dims=("mesh2d_nFaces", "mesh2d_nMax_face_nodes"),
|
|
311
|
+
attrs={
|
|
312
|
+
"long_name": "y-coordinate bounds of mesh faces (i.e. corner coordinates)",
|
|
313
|
+
"units": "degrees_north",
|
|
314
|
+
"standard_name": "latitude",
|
|
315
|
+
},
|
|
316
|
+
),
|
|
317
|
+
"wgs84": xr.DataArray(
|
|
318
|
+
np.int32(4326),
|
|
319
|
+
dims=(),
|
|
320
|
+
attrs={
|
|
321
|
+
"name": "WGS 84",
|
|
322
|
+
"epsg": np.int32(4326),
|
|
323
|
+
"grid_mapping_name": "latitude_longitude",
|
|
324
|
+
"longitude_of_prime_meridian": 0.0,
|
|
325
|
+
"semi_major_axis": 6378137.0,
|
|
326
|
+
"semi_minor_axis": 6356752.314245,
|
|
327
|
+
"inverse_flattening": 298.257223563,
|
|
328
|
+
"EPSG_code": "",
|
|
329
|
+
"value": "value is equal to EPSG code",
|
|
330
|
+
"proj_string": "+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs",
|
|
331
|
+
},
|
|
332
|
+
).assign_attrs(_FillValue=_WGS84_FILL),
|
|
333
|
+
"mesh2d": xr.DataArray(
|
|
334
|
+
_WGS84_FILL,
|
|
335
|
+
dims=(),
|
|
336
|
+
attrs={
|
|
337
|
+
"cf_role": "mesh_topology",
|
|
338
|
+
"long_name": "Topology data of 2D mesh",
|
|
339
|
+
"topology_dimension": 2,
|
|
340
|
+
"node_coordinates": "mesh2d_node_x mesh2d_node_y",
|
|
341
|
+
"node_dimension": "mesh2d_nNodes",
|
|
342
|
+
"edge_node_connectivity": "mesh2d_edge_nodes",
|
|
343
|
+
"edge_dimension": "mesh2d_nEdges",
|
|
344
|
+
"edge_coordinates": "mesh2d_edge_x mesh2d_edge_y",
|
|
345
|
+
"face_node_connectivity": "mesh2d_face_nodes",
|
|
346
|
+
"face_dimension": "mesh2d_nFaces",
|
|
347
|
+
"face_coordinates": "mesh2d_face_x mesh2d_face_y",
|
|
348
|
+
"max_face_nodes_dimension": "mesh2d_nMax_face_nodes",
|
|
349
|
+
"edge_face_connectivity": "mesh2d_edge_faces",
|
|
350
|
+
},
|
|
351
|
+
),
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
attrs = {
|
|
355
|
+
"institution": "GeoOcean",
|
|
356
|
+
"references": "https://github.com/GeoOcean/BlueMath_tk",
|
|
357
|
+
"source": f"BlueMath tk {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
358
|
+
"history": "Created with OCSmesh",
|
|
359
|
+
"Conventions": "CF-1.8 UGRID-1.0 Deltares-0.10",
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return xr.Dataset(data_vars=data_vars, coords=coords, attrs=attrs)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def adcirc2DFlowFM_mixed(NODE: np.ndarray, face_nodes_0b: np.ndarray) -> xr.Dataset:
|
|
366
|
+
"""Build a UGRID dataset preserving mixed triangles and quads.
|
|
367
|
+
|
|
368
|
+
Use this after ``merge_circumcenters`` so short dual links removed by quad
|
|
369
|
+
merging are not reintroduced by re-splitting quads into triangles.
|
|
370
|
+
|
|
371
|
+
Parameters
|
|
372
|
+
----------
|
|
373
|
+
NODE : ndarray of shape (n_nodes, 3)
|
|
374
|
+
Node coordinates ``(x, y, z)``.
|
|
375
|
+
face_nodes_0b : ndarray of shape (n_faces, 4)
|
|
376
|
+
Mixed face-node connectivity (0-based, ``-1`` padding).
|
|
377
|
+
|
|
378
|
+
Returns
|
|
379
|
+
-------
|
|
380
|
+
xarray.Dataset
|
|
381
|
+
Delft3D-FM UGRID mesh dataset with triangles and quads.
|
|
382
|
+
"""
|
|
383
|
+
NODE = np.asarray(NODE, dtype=np.float64)
|
|
384
|
+
if NODE.ndim != 2 or NODE.shape[1] < 3:
|
|
385
|
+
raise ValueError("NODE must have shape (n_nodes, 3) with x, y, z")
|
|
386
|
+
faces_list = face_nodes_0b_to_faces_list(face_nodes_0b)
|
|
387
|
+
ugrid = build_ugrid_arrays_mixed(NODE, faces_list)
|
|
388
|
+
ds = _xr_dataset_from_ugrid_dict(ugrid)
|
|
389
|
+
# Defensive re-encoding:
|
|
390
|
+
# Delft3D-FM / GUI is sensitive to the raw NetCDF encoding of UGRID connectivity
|
|
391
|
+
# variables. In particular, `mesh2d_face_nodes` / `mesh2d_edge_faces` must be
|
|
392
|
+
# int32 with `_FillValue=-999`.
|
|
393
|
+
#
|
|
394
|
+
# Note: `_xr_dataset_from_ugrid_dict` already sets `_FillValue` in the variable *attrs*.
|
|
395
|
+
# Setting `_FillValue` again via `encoding` makes xarray error out with:
|
|
396
|
+
# "failed to prevent overwriting existing key _FillValue in attrs".
|
|
397
|
+
_UGRID_FILL = np.int32(-999)
|
|
398
|
+
|
|
399
|
+
if "mesh2d_face_nodes" in ds:
|
|
400
|
+
da = ds["mesh2d_face_nodes"]
|
|
401
|
+
vals = da.values
|
|
402
|
+
if np.issubdtype(vals.dtype, np.floating):
|
|
403
|
+
# Replace NaN padding with GUI-like -999 fill, then cast to int32.
|
|
404
|
+
vals = np.where(np.isnan(vals), _UGRID_FILL, vals)
|
|
405
|
+
ds["mesh2d_face_nodes"] = da.copy(data=np.asarray(vals, dtype=np.int32))
|
|
406
|
+
# Ensure dtype is int32 (but do not touch encoding _FillValue).
|
|
407
|
+
if ds["mesh2d_face_nodes"].dtype != np.int32:
|
|
408
|
+
ds["mesh2d_face_nodes"] = ds["mesh2d_face_nodes"].astype(np.int32)
|
|
409
|
+
|
|
410
|
+
if "mesh2d_edge_faces" in ds:
|
|
411
|
+
da = ds["mesh2d_edge_faces"]
|
|
412
|
+
vals = da.values
|
|
413
|
+
if np.issubdtype(vals.dtype, np.floating):
|
|
414
|
+
vals = np.where(np.isnan(vals), _UGRID_FILL, vals)
|
|
415
|
+
ds["mesh2d_edge_faces"] = da.copy(data=np.asarray(vals, dtype=np.int32))
|
|
416
|
+
if ds["mesh2d_edge_faces"].dtype != np.int32:
|
|
417
|
+
ds["mesh2d_edge_faces"] = ds["mesh2d_edge_faces"].astype(np.int32)
|
|
418
|
+
|
|
419
|
+
# Match GUI type for the WGS84 grid mapping scalar (dtype only).
|
|
420
|
+
if "wgs84" in ds and ds["wgs84"].dtype != np.int32:
|
|
421
|
+
ds["wgs84"] = ds["wgs84"].astype(np.int32)
|
|
422
|
+
|
|
423
|
+
n_quad = sum(1 for f in faces_list if len(f) == 4)
|
|
424
|
+
n_tri = sum(1 for f in faces_list if len(f) == 3)
|
|
425
|
+
ds.attrs["bluemesh2d_export"] = "adcirc2DFlowFM_mixed"
|
|
426
|
+
ds.attrs["bluemesh2d_n_faces"] = str(len(faces_list))
|
|
427
|
+
ds.attrs["bluemesh2d_n_triangle_faces"] = str(n_tri)
|
|
428
|
+
ds.attrs["bluemesh2d_n_quad_faces"] = str(n_quad)
|
|
429
|
+
return ds
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def adcirc2DFlowFM(NODE: np.ndarray, EDGE: np.ndarray) -> xr.Dataset:
|
|
433
|
+
"""Build a Delft3D-FM UGRID mesh dataset from ADCIRC-style data.
|
|
434
|
+
|
|
435
|
+
Parameters
|
|
436
|
+
----------
|
|
437
|
+
NODE : ndarray of shape (n_nodes, 3)
|
|
438
|
+
Node coordinates ``(x, y, z)``.
|
|
439
|
+
EDGE : ndarray of shape (n_faces, 3) or (n_faces, 4)
|
|
440
|
+
Triangle connectivity (0-based), or padded face-node array with ``-1``
|
|
441
|
+
fill for mixed tri/quad export.
|
|
442
|
+
|
|
443
|
+
Returns
|
|
444
|
+
-------
|
|
445
|
+
xarray.Dataset
|
|
446
|
+
UGRID mesh dataset (``mesh2d_node_x/y/z``, ``mesh2d_face_nodes``, etc.).
|
|
447
|
+
"""
|
|
448
|
+
# Shape-based export selection:
|
|
449
|
+
# - (T,3) int : triangle connectivity => export triangle-only mesh.
|
|
450
|
+
# - (F,4) int with negative padding (typically -1) => face_nodes_0b => export mixed
|
|
451
|
+
# tri+quad mesh using adcirc2DFlowFM_mixed.
|
|
452
|
+
# - (F,4) int without negative padding => assume legacy "face nodes" encoding and
|
|
453
|
+
# triangulate them (triangle-only export) for backward compatibility.
|
|
454
|
+
EDGE = np.asarray(EDGE)
|
|
455
|
+
if EDGE.ndim == 2 and EDGE.shape[1] == 4 and np.any(EDGE < 0):
|
|
456
|
+
# face_nodes_0b with -1 padding => keep mixed topology.
|
|
457
|
+
return adcirc2DFlowFM_mixed(NODE, EDGE)
|
|
458
|
+
|
|
459
|
+
if EDGE.ndim == 2 and EDGE.shape[1] == 4:
|
|
460
|
+
# Legacy: triangulate face rows to triangle-only.
|
|
461
|
+
node_xy = np.asarray(NODE[:, :2], dtype=np.float64)
|
|
462
|
+
tri: list[tuple[int, int, int]] = []
|
|
463
|
+
for row in EDGE:
|
|
464
|
+
nodes = row[row >= 0]
|
|
465
|
+
if nodes.size >= 3:
|
|
466
|
+
tri.extend(triangulate_mixed_face_row_to_tris(node_xy, nodes))
|
|
467
|
+
EDGE = np.asarray(tri, dtype=np.int64)
|
|
468
|
+
|
|
469
|
+
ugrid = build_ugrid_arrays(NODE, EDGE)
|
|
470
|
+
return _xr_dataset_from_ugrid_dict(ugrid)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def calculate_edges(Elmts: np.ndarray) -> np.ndarray:
|
|
474
|
+
"""Extract unique edges from triangle connectivity.
|
|
475
|
+
|
|
476
|
+
Parameters
|
|
477
|
+
----------
|
|
478
|
+
Elmts : ndarray of shape (n_elmts, 3)
|
|
479
|
+
Triangle element connectivity (0-based node indices).
|
|
480
|
+
|
|
481
|
+
Returns
|
|
482
|
+
-------
|
|
483
|
+
ndarray of shape (n_edges, 2)
|
|
484
|
+
Unique undirected edges as vertex-index pairs.
|
|
485
|
+
"""
|
|
486
|
+
|
|
487
|
+
Links = np.zeros((len(Elmts) * 3, 2), dtype=int)
|
|
488
|
+
tel = 0
|
|
489
|
+
for elmt in Elmts:
|
|
490
|
+
Links[tel] = [elmt[0], elmt[1]]
|
|
491
|
+
tel += 1
|
|
492
|
+
Links[tel] = [elmt[1], elmt[2]]
|
|
493
|
+
tel += 1
|
|
494
|
+
Links[tel] = [elmt[2], elmt[0]]
|
|
495
|
+
tel += 1
|
|
496
|
+
|
|
497
|
+
Links_sorted = np.sort(Links, axis=1)
|
|
498
|
+
Links_unique = np.unique(Links_sorted, axis=0)
|
|
499
|
+
|
|
500
|
+
return Links_unique
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def build_ugrid_arrays(NODE: np.ndarray, EDGE: np.ndarray) -> dict:
|
|
504
|
+
"""Build UGRID mesh arrays from node coordinates and triangle connectivity.
|
|
505
|
+
|
|
506
|
+
Same logic as :func:`adcirc2DFlowFM` but returns arrays instead of an
|
|
507
|
+
``xarray.Dataset``. Used to rebuild connectivity after edge flips.
|
|
508
|
+
|
|
509
|
+
Parameters
|
|
510
|
+
----------
|
|
511
|
+
NODE : ndarray of shape (n_nodes, 3)
|
|
512
|
+
Node coordinates ``(x, y, z)``.
|
|
513
|
+
EDGE : ndarray of shape (n_faces, 3)
|
|
514
|
+
Triangle connectivity (0-based node indices).
|
|
515
|
+
|
|
516
|
+
Returns
|
|
517
|
+
-------
|
|
518
|
+
dict
|
|
519
|
+
UGRID arrays with keys ``node_x``, ``node_y``, ``node_z``,
|
|
520
|
+
``face_nodes``, ``edge_nodes``, ``edge_faces``, ``face_x``, ``face_y``,
|
|
521
|
+
``edge_x``, ``edge_y``, ``face_x_bnd``, ``face_y_bnd``, plus
|
|
522
|
+
``num_nodes``, ``num_faces``, ``num_edges``. Connectivity arrays are
|
|
523
|
+
1-based.
|
|
524
|
+
"""
|
|
525
|
+
edges = calculate_edges(EDGE) + 1
|
|
526
|
+
EDGE_S = np.sort(EDGE, axis=1)
|
|
527
|
+
EDGE_S = EDGE_S[EDGE_S[:, 2].argsort()]
|
|
528
|
+
EDGE_S = EDGE_S[EDGE_S[:, 1].argsort()]
|
|
529
|
+
face_node = np.array(EDGE_S[EDGE_S[:, 0].argsort()], dtype=np.int32)
|
|
530
|
+
edge_node = np.zeros([len(edges), 2], dtype="i4")
|
|
531
|
+
edge_face = np.zeros([len(edges), 2], dtype=np.double)
|
|
532
|
+
edge_x = np.zeros(len(edges))
|
|
533
|
+
edge_y = np.zeros(len(edges))
|
|
534
|
+
|
|
535
|
+
face_x = (
|
|
536
|
+
NODE[EDGE[:, 0].astype(int), 0]
|
|
537
|
+
+ NODE[EDGE[:, 1].astype(int), 0]
|
|
538
|
+
+ NODE[EDGE[:, 2].astype(int), 0]
|
|
539
|
+
) / 3
|
|
540
|
+
face_y = (
|
|
541
|
+
NODE[EDGE[:, 0].astype(int), 1]
|
|
542
|
+
+ NODE[EDGE[:, 1].astype(int), 1]
|
|
543
|
+
+ NODE[EDGE[:, 2].astype(int), 1]
|
|
544
|
+
) / 3
|
|
545
|
+
|
|
546
|
+
edge_x = (NODE[edges[:, 0] - 1, 0] + NODE[edges[:, 1] - 1, 0]) / 2
|
|
547
|
+
edge_y = (NODE[edges[:, 0] - 1, 1] + NODE[edges[:, 1] - 1, 1]) / 2
|
|
548
|
+
|
|
549
|
+
face_node_dict = {}
|
|
550
|
+
for idx, face in enumerate(face_node):
|
|
551
|
+
for node in face:
|
|
552
|
+
if node not in face_node_dict:
|
|
553
|
+
face_node_dict[node] = []
|
|
554
|
+
face_node_dict[node].append(idx)
|
|
555
|
+
|
|
556
|
+
for i, edge in enumerate(edges):
|
|
557
|
+
node1, node2 = map(int, edge)
|
|
558
|
+
edge_node[i, 0] = node1
|
|
559
|
+
edge_node[i, 1] = node2
|
|
560
|
+
faces_node1 = face_node_dict.get(node1 - 1, [])
|
|
561
|
+
faces_node2 = face_node_dict.get(node2 - 1, [])
|
|
562
|
+
faces = list(set(faces_node1) & set(faces_node2))
|
|
563
|
+
if len(faces) < 2:
|
|
564
|
+
edge_face[i, 0] = faces[0] + 1 if faces else 0
|
|
565
|
+
edge_face[i, 1] = 0
|
|
566
|
+
else:
|
|
567
|
+
edge_face[i, 0] = faces[0] + 1
|
|
568
|
+
edge_face[i, 1] = faces[1] + 1
|
|
569
|
+
|
|
570
|
+
face_x = np.asarray(face_x, dtype=np.float64)
|
|
571
|
+
face_y = np.asarray(face_y, dtype=np.float64)
|
|
572
|
+
node_x = np.asarray(NODE[:, 0], dtype=np.float64)
|
|
573
|
+
node_y = np.asarray(NODE[:, 1], dtype=np.float64)
|
|
574
|
+
node_z = np.asarray(NODE[:, 2], dtype=np.float64)
|
|
575
|
+
face_x_bnd = np.asarray(node_x[face_node], dtype=np.float64)
|
|
576
|
+
face_y_bnd = np.asarray(node_y[face_node], dtype=np.float64)
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
"node_x": node_x,
|
|
580
|
+
"node_y": node_y,
|
|
581
|
+
"node_z": node_z,
|
|
582
|
+
"face_nodes": face_node + 1,
|
|
583
|
+
"edge_nodes": edge_node,
|
|
584
|
+
"edge_faces": edge_face,
|
|
585
|
+
"face_x": face_x,
|
|
586
|
+
"face_y": face_y,
|
|
587
|
+
"edge_x": edge_x,
|
|
588
|
+
"edge_y": edge_y,
|
|
589
|
+
"face_x_bnd": face_x_bnd,
|
|
590
|
+
"face_y_bnd": face_y_bnd,
|
|
591
|
+
"num_nodes": NODE.shape[0],
|
|
592
|
+
"num_faces": EDGE.shape[0],
|
|
593
|
+
"num_edges": edges.shape[0],
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def build_ugrid_arrays_mixed(NODE: np.ndarray, faces_list: list) -> dict:
|
|
598
|
+
"""Build UGRID mesh arrays from mixed triangle and quadrilateral faces.
|
|
599
|
+
|
|
600
|
+
Parameters
|
|
601
|
+
----------
|
|
602
|
+
NODE : ndarray of shape (n_nodes, 3)
|
|
603
|
+
Node coordinates ``(x, y, z)``.
|
|
604
|
+
faces_list : list of ndarray
|
|
605
|
+
One array of 3 or 4 node indices (0-based) per face.
|
|
606
|
+
|
|
607
|
+
Returns
|
|
608
|
+
-------
|
|
609
|
+
dict
|
|
610
|
+
Same keys as :func:`build_ugrid_arrays`. ``face_nodes`` has shape
|
|
611
|
+
``(n_faces, 4)`` with triangles padded using fill value ``-999``.
|
|
612
|
+
"""
|
|
613
|
+
n_nodes = NODE.shape[0]
|
|
614
|
+
n_faces = len(faces_list)
|
|
615
|
+
node_x = np.asarray(NODE[:, 0], dtype=np.float64)
|
|
616
|
+
node_y = np.asarray(NODE[:, 1], dtype=np.float64)
|
|
617
|
+
node_z = np.asarray(NODE[:, 2], dtype=np.float64)
|
|
618
|
+
# GUI-like connectivity encoding (raw NetCDF):
|
|
619
|
+
# - face_nodes / edge_faces are int32 with `_FillValue=-999`.
|
|
620
|
+
# Xarray will typically decode -999 -> NaN depending on how the file is read.
|
|
621
|
+
FILL = np.int32(-999)
|
|
622
|
+
|
|
623
|
+
face_nodes = np.full((n_faces, 4), FILL, dtype=np.int32)
|
|
624
|
+
face_x = np.zeros(n_faces, dtype=np.float64)
|
|
625
|
+
face_y = np.zeros(n_faces, dtype=np.float64)
|
|
626
|
+
face_x_bnd = np.zeros((n_faces, 4), dtype=np.float64)
|
|
627
|
+
face_y_bnd = np.zeros((n_faces, 4), dtype=np.float64)
|
|
628
|
+
|
|
629
|
+
faces_norm = [np.asarray(f, dtype=np.int32).reshape(-1) for f in faces_list]
|
|
630
|
+
nv_arr = np.fromiter((f.size for f in faces_norm), dtype=np.int64, count=n_faces)
|
|
631
|
+
|
|
632
|
+
# Half-edges of all faces (built CCW), in face-then-edge traversal order.
|
|
633
|
+
he_v1_parts: list[np.ndarray] = []
|
|
634
|
+
he_v2_parts: list[np.ndarray] = []
|
|
635
|
+
he_fi_parts: list[np.ndarray] = []
|
|
636
|
+
he_k_parts: list[np.ndarray] = []
|
|
637
|
+
|
|
638
|
+
# Vectorized per group of equal vertex count (3 or 4).
|
|
639
|
+
for nv in np.unique(nv_arr):
|
|
640
|
+
nv = int(nv)
|
|
641
|
+
gidx = np.where(nv_arr == nv)[0]
|
|
642
|
+
fv = np.vstack([faces_norm[i] for i in gidx])
|
|
643
|
+
x = node_x[fv]
|
|
644
|
+
y = node_y[fv]
|
|
645
|
+
kp1 = (np.arange(nv) + 1) % nv
|
|
646
|
+
if nv >= 3:
|
|
647
|
+
# Signed area in current vertex order; build faces in CCW order to
|
|
648
|
+
# match UGRID expectations and Delft3D-FM polygon orientation
|
|
649
|
+
# conventions. For convex polygons this reliably detects CW/CCW.
|
|
650
|
+
d1 = np.zeros(gidx.size, dtype=np.float64)
|
|
651
|
+
d2 = np.zeros(gidx.size, dtype=np.float64)
|
|
652
|
+
for k in range(nv):
|
|
653
|
+
d1 += x[:, k] * y[:, kp1[k]]
|
|
654
|
+
d2 += y[:, k] * x[:, kp1[k]]
|
|
655
|
+
flip = 0.5 * (d1 - d2) < 0.0
|
|
656
|
+
if np.any(flip):
|
|
657
|
+
fv[flip] = fv[flip, ::-1]
|
|
658
|
+
x = node_x[fv]
|
|
659
|
+
y = node_y[fv]
|
|
660
|
+
|
|
661
|
+
face_nodes[gidx[:, None], np.arange(nv)] = fv + 1
|
|
662
|
+
# Area-weighted polygon centroid (shoelace formula), with a fallback
|
|
663
|
+
# to the vertex mean for near-degenerate polygons.
|
|
664
|
+
cross = np.empty_like(x)
|
|
665
|
+
for k in range(nv):
|
|
666
|
+
cross[:, k] = x[:, k] * y[:, kp1[k]] - x[:, kp1[k]] * y[:, k]
|
|
667
|
+
area2 = np.zeros(gidx.size, dtype=np.float64)
|
|
668
|
+
sx = np.zeros(gidx.size, dtype=np.float64)
|
|
669
|
+
sy = np.zeros(gidx.size, dtype=np.float64)
|
|
670
|
+
for k in range(nv):
|
|
671
|
+
area2 += cross[:, k]
|
|
672
|
+
sx += (x[:, k] + x[:, kp1[k]]) * cross[:, k]
|
|
673
|
+
sy += (y[:, k] + y[:, kp1[k]]) * cross[:, k]
|
|
674
|
+
degenerate = np.abs(area2) < 1e-30
|
|
675
|
+
den = np.where(degenerate, 1.0, 3.0 * area2)
|
|
676
|
+
face_x[gidx] = np.where(degenerate, np.mean(x, axis=1), sx / den)
|
|
677
|
+
face_y[gidx] = np.where(degenerate, np.mean(y, axis=1), sy / den)
|
|
678
|
+
face_x_bnd[gidx[:, None], np.arange(nv)] = x
|
|
679
|
+
face_y_bnd[gidx[:, None], np.arange(nv)] = y
|
|
680
|
+
if nv == 3:
|
|
681
|
+
face_x_bnd[gidx, 3] = np.nan
|
|
682
|
+
face_y_bnd[gidx, 3] = np.nan
|
|
683
|
+
|
|
684
|
+
he_v1_parts.append(fv.ravel())
|
|
685
|
+
he_v2_parts.append(fv[:, kp1].ravel())
|
|
686
|
+
he_fi_parts.append(np.repeat(gidx, nv))
|
|
687
|
+
he_k_parts.append(np.tile(np.arange(nv), gidx.size))
|
|
688
|
+
|
|
689
|
+
# Build unique edges and edge->face mapping. Edges are numbered by first
|
|
690
|
+
# occurrence in face-then-edge traversal order, and each edge's faces are
|
|
691
|
+
# kept in traversal order (same as the historical dict-based loop).
|
|
692
|
+
if not he_v1_parts:
|
|
693
|
+
he_v1_parts = [np.zeros(0, dtype=np.int64)]
|
|
694
|
+
he_v2_parts = [np.zeros(0, dtype=np.int64)]
|
|
695
|
+
he_fi_parts = [np.zeros(0, dtype=np.int64)]
|
|
696
|
+
he_k_parts = [np.zeros(0, dtype=np.int64)]
|
|
697
|
+
he_v1 = np.concatenate(he_v1_parts).astype(np.int64)
|
|
698
|
+
he_v2 = np.concatenate(he_v2_parts).astype(np.int64)
|
|
699
|
+
he_fi = np.concatenate(he_fi_parts)
|
|
700
|
+
he_k = np.concatenate(he_k_parts)
|
|
701
|
+
order = np.lexsort((he_k, he_fi))
|
|
702
|
+
he_v1, he_v2, he_fi = he_v1[order], he_v2[order], he_fi[order]
|
|
703
|
+
lo = np.minimum(he_v1, he_v2)
|
|
704
|
+
hi = np.maximum(he_v1, he_v2)
|
|
705
|
+
key = lo * np.int64(n_nodes + 1) + hi
|
|
706
|
+
_, first_pos, inverse = np.unique(key, return_index=True, return_inverse=True)
|
|
707
|
+
insertion = np.argsort(first_pos, kind="stable")
|
|
708
|
+
rank = np.empty(insertion.size, dtype=np.int64)
|
|
709
|
+
rank[insertion] = np.arange(insertion.size)
|
|
710
|
+
edge_id = rank[inverse]
|
|
711
|
+
|
|
712
|
+
edges = np.column_stack(
|
|
713
|
+
[lo[first_pos][insertion], hi[first_pos][insertion]]
|
|
714
|
+
).astype(np.int32)
|
|
715
|
+
edge_node = edges + 1
|
|
716
|
+
# GUI padding for boundary edges:
|
|
717
|
+
# In the working Delft3D-FM GUI export, missing neighbor faces in
|
|
718
|
+
# `mesh2d_edge_faces(:, 1)` are encoded as 0 (NOT as `_FillValue`).
|
|
719
|
+
# Using 0 instead of `_FillValue` makes UGRID import validation pass.
|
|
720
|
+
edge_face = np.zeros((len(edges), 2), dtype=np.int32)
|
|
721
|
+
pos = np.argsort(edge_id, kind="stable")
|
|
722
|
+
sid = edge_id[pos]
|
|
723
|
+
is_first = np.r_[True, sid[1:] != sid[:-1]]
|
|
724
|
+
starts = np.flatnonzero(is_first)
|
|
725
|
+
edge_face[sid[starts], 0] = he_fi[pos[starts]] + 1
|
|
726
|
+
seconds = starts + 1
|
|
727
|
+
seconds = seconds[seconds < sid.size]
|
|
728
|
+
seconds = seconds[~is_first[seconds]]
|
|
729
|
+
edge_face[sid[seconds], 1] = he_fi[pos[seconds]] + 1
|
|
730
|
+
|
|
731
|
+
edge_x = (node_x[edges[:, 0]] + node_x[edges[:, 1]]) / 2
|
|
732
|
+
edge_y = (node_y[edges[:, 0]] + node_y[edges[:, 1]]) / 2
|
|
733
|
+
|
|
734
|
+
return {
|
|
735
|
+
"node_x": node_x,
|
|
736
|
+
"node_y": node_y,
|
|
737
|
+
"node_z": node_z,
|
|
738
|
+
"face_nodes": face_nodes,
|
|
739
|
+
"edge_nodes": edge_node,
|
|
740
|
+
"edge_faces": edge_face,
|
|
741
|
+
"face_x": face_x,
|
|
742
|
+
"face_y": face_y,
|
|
743
|
+
"edge_x": edge_x,
|
|
744
|
+
"edge_y": edge_y,
|
|
745
|
+
"face_x_bnd": face_x_bnd,
|
|
746
|
+
"face_y_bnd": face_y_bnd,
|
|
747
|
+
"num_nodes": n_nodes,
|
|
748
|
+
"num_faces": n_faces,
|
|
749
|
+
"num_edges": edges.shape[0],
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def build_loops(edges):
|
|
754
|
+
"""Assemble closed node loops from an edge list.
|
|
755
|
+
|
|
756
|
+
Parameters
|
|
757
|
+
----------
|
|
758
|
+
edges : ndarray of shape (N, 2)
|
|
759
|
+
Edge list as pairs of node indices.
|
|
760
|
+
|
|
761
|
+
Returns
|
|
762
|
+
-------
|
|
763
|
+
loops : list of list of int
|
|
764
|
+
Closed node-index loops.
|
|
765
|
+
"""
|
|
766
|
+
edges = edges.tolist()
|
|
767
|
+
loops = []
|
|
768
|
+
while edges:
|
|
769
|
+
start, end = edges.pop(0)
|
|
770
|
+
loop = [start, end]
|
|
771
|
+
closed = False
|
|
772
|
+
while not closed:
|
|
773
|
+
found = False
|
|
774
|
+
for i, (a, b) in enumerate(edges):
|
|
775
|
+
if a == loop[-1]:
|
|
776
|
+
loop.append(b)
|
|
777
|
+
edges.pop(i)
|
|
778
|
+
found = True
|
|
779
|
+
break
|
|
780
|
+
elif b == loop[-1]:
|
|
781
|
+
loop.append(a)
|
|
782
|
+
edges.pop(i)
|
|
783
|
+
found = True
|
|
784
|
+
break
|
|
785
|
+
if not found:
|
|
786
|
+
break
|
|
787
|
+
if loop[-1] == loop[0]:
|
|
788
|
+
closed = True
|
|
789
|
+
loops.append(loop)
|
|
790
|
+
return loops
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def export_to_grd(
|
|
794
|
+
filename, vert, tria, z, crs, edge_tag, edge_open=None, edge_land=None,
|
|
795
|
+
open_contours=None, land_contours=None,
|
|
796
|
+
):
|
|
797
|
+
"""Export a mesh to ADCIRC ``.grd`` format with boundary contours.
|
|
798
|
+
|
|
799
|
+
Prefer ``open_contours`` / ``land_contours`` from
|
|
800
|
+
:func:`bluemesh2d.geomesh_util.border_util.identify_boundary` (one ordered
|
|
801
|
+
node-index array per contour) to preserve discontinuities between contours.
|
|
802
|
+
|
|
803
|
+
Parameters
|
|
804
|
+
----------
|
|
805
|
+
filename : str
|
|
806
|
+
Output ``.grd`` file path.
|
|
807
|
+
vert : ndarray of shape (N, 2)
|
|
808
|
+
Node coordinates ``(x, y)``.
|
|
809
|
+
tria : ndarray of shape (M, 3)
|
|
810
|
+
Triangle connectivity (0-based node indices).
|
|
811
|
+
z : ndarray of shape (N,)
|
|
812
|
+
Node depth or elevation values.
|
|
813
|
+
crs : str
|
|
814
|
+
Coordinate reference system string written to the file header.
|
|
815
|
+
edge_tag : ndarray of shape (K, 3)
|
|
816
|
+
Tagged boundary edges ``(node1, node2, tag)``; tag 1 = open, 2 = land.
|
|
817
|
+
edge_open : ndarray of shape (L, 2), optional
|
|
818
|
+
Flat open-boundary edges. Used when ``open_contours`` is ``None``.
|
|
819
|
+
edge_land : ndarray of shape (P, 2), optional
|
|
820
|
+
Flat land-boundary edges. Used when ``land_contours`` is ``None``.
|
|
821
|
+
open_contours : list of ndarray, optional
|
|
822
|
+
Ordered open-boundary contours (node indices per contour).
|
|
823
|
+
land_contours : list of ndarray, optional
|
|
824
|
+
Ordered land-boundary contours (node indices per contour).
|
|
825
|
+
"""
|
|
826
|
+
if open_contours is not None:
|
|
827
|
+
open_loops = [np.asarray(c, dtype=int).tolist() if np.ndim(c) > 0 else [int(c)] for c in open_contours]
|
|
828
|
+
else:
|
|
829
|
+
if edge_open is None:
|
|
830
|
+
edge_open = edge_tag[edge_tag[:, 2] == 1, :2].astype(int)
|
|
831
|
+
open_loops = build_loops(edge_open) if edge_open.size > 0 else []
|
|
832
|
+
|
|
833
|
+
if land_contours is not None:
|
|
834
|
+
land_loops = [np.asarray(c, dtype=int).tolist() if np.ndim(c) > 0 else [int(c)] for c in land_contours]
|
|
835
|
+
else:
|
|
836
|
+
if edge_land is None:
|
|
837
|
+
edge_land = edge_tag[edge_tag[:, 2] == 2, :2].astype(int)
|
|
838
|
+
land_loops = build_loops(edge_land) if edge_land.size > 0 else []
|
|
839
|
+
|
|
840
|
+
# 3. Write to file
|
|
841
|
+
with open(filename, "w") as f:
|
|
842
|
+
# Header
|
|
843
|
+
f.write(f"{crs}\n")
|
|
844
|
+
f.write(f"{tria.shape[0]} {vert.shape[0]}\n")
|
|
845
|
+
|
|
846
|
+
# Nodes
|
|
847
|
+
for i, (x, y, zi) in enumerate(zip(vert[:, 0], vert[:, 1], z), start=1):
|
|
848
|
+
if np.isnan(zi):
|
|
849
|
+
f.write(f"{i} {x:.15f} {y:.15f} NAN\n")
|
|
850
|
+
else:
|
|
851
|
+
f.write(f"{i} {x:.15f} {y:.15f} {zi:.15f}\n")
|
|
852
|
+
# Triangles
|
|
853
|
+
for i, tri in enumerate(tria, start=1):
|
|
854
|
+
f.write(f"{i} 3 {tri[0] + 1} {tri[1] + 1} {tri[2] + 1}\n")
|
|
855
|
+
|
|
856
|
+
# Open boundaries
|
|
857
|
+
total_open_nodes = sum(len(loop) for loop in open_loops)
|
|
858
|
+
f.write(f"{len(open_loops)} ! total number of open boundaries\n")
|
|
859
|
+
f.write(f"{total_open_nodes} ! total number of open boundary nodes\n")
|
|
860
|
+
|
|
861
|
+
for ib, loop in enumerate(open_loops):
|
|
862
|
+
f.write(f"{len(loop)} ! number of nodes for open_boundary_{ib}\n")
|
|
863
|
+
for nid in loop:
|
|
864
|
+
f.write(f"{nid + 1}\n")
|
|
865
|
+
|
|
866
|
+
# Land boundaries
|
|
867
|
+
total_land_nodes = sum(len(loop) for loop in land_loops)
|
|
868
|
+
f.write(f"{len(land_loops)} ! total number of land boundaries\n")
|
|
869
|
+
f.write(f"{total_land_nodes} ! Total number of land boundary nodes\n")
|
|
870
|
+
|
|
871
|
+
for i, loop in enumerate(land_loops):
|
|
872
|
+
f.write(f"{len(loop)} 1 ! boundary 1:{i}\n")
|
|
873
|
+
for nid in loop:
|
|
874
|
+
f.write(f"{nid + 1}\n")
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def plot_grd(filename, ax=None, show_boundaries=True):
|
|
878
|
+
"""Plot a preview of an ADCIRC ``.grd`` mesh file.
|
|
879
|
+
|
|
880
|
+
Parameters
|
|
881
|
+
----------
|
|
882
|
+
filename : str
|
|
883
|
+
Path to the ``.grd`` file.
|
|
884
|
+
ax : matplotlib.axes.Axes, optional
|
|
885
|
+
Axes to plot on. If ``None``, a new figure and axes are created.
|
|
886
|
+
show_boundaries : bool, optional
|
|
887
|
+
Whether to draw open and land boundary polylines. Default is ``True``.
|
|
888
|
+
"""
|
|
889
|
+
|
|
890
|
+
if ax is None:
|
|
891
|
+
fig, ax = plt.subplots()
|
|
892
|
+
|
|
893
|
+
with open(filename, "r") as f:
|
|
894
|
+
lines = f.readlines()
|
|
895
|
+
|
|
896
|
+
# search header
|
|
897
|
+
for i, line in enumerate(lines):
|
|
898
|
+
if (
|
|
899
|
+
len(line.split()) == 2
|
|
900
|
+
and line.strip().replace(".", "", 1).replace("-", "", 1).isdigit() is False
|
|
901
|
+
):
|
|
902
|
+
try:
|
|
903
|
+
nelem, nnode = map(int, line.split())
|
|
904
|
+
header_idx = i
|
|
905
|
+
break
|
|
906
|
+
except Exception:
|
|
907
|
+
continue
|
|
908
|
+
|
|
909
|
+
# Reading nodes
|
|
910
|
+
node_lines = lines[header_idx + 1 : header_idx + 1 + nnode]
|
|
911
|
+
vert = np.zeros((nnode, 3))
|
|
912
|
+
for i, ln in enumerate(node_lines):
|
|
913
|
+
parts = ln.split()
|
|
914
|
+
vert[i, 0] = float(parts[1]) # lon
|
|
915
|
+
vert[i, 1] = float(parts[2]) # lat
|
|
916
|
+
vert[i, 2] = float(parts[3]) # z
|
|
917
|
+
|
|
918
|
+
# Reading elements
|
|
919
|
+
elem_lines = lines[header_idx + 1 + nnode : header_idx + 1 + nnode + nelem]
|
|
920
|
+
tria = np.zeros((nelem, 3), dtype=int)
|
|
921
|
+
for i, ln in enumerate(elem_lines):
|
|
922
|
+
parts = ln.split()
|
|
923
|
+
tria[i, :] = np.array(parts[2:5], dtype=int) - 1 # indices 0-based
|
|
924
|
+
|
|
925
|
+
# Reading boundaries
|
|
926
|
+
open_boundaries = []
|
|
927
|
+
land_boundaries = []
|
|
928
|
+
|
|
929
|
+
if show_boundaries:
|
|
930
|
+
idx = header_idx + 1 + nnode + nelem
|
|
931
|
+
for j in range(idx, len(lines)):
|
|
932
|
+
line = lines[j].strip()
|
|
933
|
+
if "! total number of open boundaries" in line:
|
|
934
|
+
n_open = int(line.split()[0])
|
|
935
|
+
j += 1
|
|
936
|
+
n_open_nodes = int(lines[j].split()[0])
|
|
937
|
+
j += 1
|
|
938
|
+
for _ in range(n_open):
|
|
939
|
+
n_nodes = int(lines[j].split()[0])
|
|
940
|
+
j += 1
|
|
941
|
+
ids = []
|
|
942
|
+
for _ in range(n_nodes):
|
|
943
|
+
ids.append(int(lines[j].strip()) - 1)
|
|
944
|
+
j += 1
|
|
945
|
+
open_boundaries.append(ids)
|
|
946
|
+
if "! total number of land boundaries" in line:
|
|
947
|
+
n_land = int(line.split()[0])
|
|
948
|
+
j += 1
|
|
949
|
+
n_land_nodes = int(lines[j].split()[0])
|
|
950
|
+
j += 1
|
|
951
|
+
for _ in range(n_land):
|
|
952
|
+
parts = lines[j].split()
|
|
953
|
+
n_nodes = int(parts[0])
|
|
954
|
+
j += 1
|
|
955
|
+
ids = []
|
|
956
|
+
for _ in range(n_nodes):
|
|
957
|
+
ids.append(int(lines[j].strip()) - 1)
|
|
958
|
+
j += 1
|
|
959
|
+
land_boundaries.append(ids)
|
|
960
|
+
# break outer loop
|
|
961
|
+
if j >= len(lines):
|
|
962
|
+
break
|
|
963
|
+
|
|
964
|
+
if ax is None:
|
|
965
|
+
fig, ax = plt.subplots(figsize=(9, 8))
|
|
966
|
+
|
|
967
|
+
facecolors = np.mean(vert[:, 2][tria], axis=1)
|
|
968
|
+
pm = ax.tripcolor(
|
|
969
|
+
vert[:, 0],
|
|
970
|
+
vert[:, 1],
|
|
971
|
+
tria,
|
|
972
|
+
facecolors=facecolors,
|
|
973
|
+
edgecolors="k",
|
|
974
|
+
lw=0.2,
|
|
975
|
+
cmap="summer",
|
|
976
|
+
)
|
|
977
|
+
plt.colorbar(pm, ax=ax, label="Depth/Elevation")
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
for i, b in enumerate(open_boundaries):
|
|
981
|
+
ax.plot(
|
|
982
|
+
vert[b, 0],
|
|
983
|
+
vert[b, 1],
|
|
984
|
+
"r-",
|
|
985
|
+
lw=1.2,
|
|
986
|
+
label="Open boundary" if i == 0 else None,
|
|
987
|
+
)
|
|
988
|
+
for i, b in enumerate(land_boundaries):
|
|
989
|
+
ax.plot(
|
|
990
|
+
vert[b, 0],
|
|
991
|
+
vert[b, 1],
|
|
992
|
+
"k-",
|
|
993
|
+
lw=1.0,
|
|
994
|
+
label="Land boundary" if i == 0 else None,
|
|
995
|
+
)
|
|
996
|
+
ax.set_aspect("equal")
|
|
997
|
+
ax.set_xlabel("Longitude [°]")
|
|
998
|
+
ax.set_ylabel("Latitude [°]")
|
|
999
|
+
ax.set_title(f"Mesh preview: {filename}")
|
|
1000
|
+
ax.legend(loc="best", frameon=True)
|