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,664 @@
|
|
|
1
|
+
"""Boundary classification and boundary-condition / .grd / .pli exports."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from ..feedback import _NullFeedback
|
|
7
|
+
from .ugrid import read_ugrid_mesh
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def export_boundary_conditions(nc_path, out_dir, zlim=20.0,
|
|
11
|
+
pli_name="Boundary01", bc_name="Riemann",
|
|
12
|
+
ext_name="FlowFM_bnd", feedback=None):
|
|
13
|
+
"""Write Delft3D-FM open-boundary files (.pli, .bc, .ext) for a mesh.
|
|
14
|
+
|
|
15
|
+
Reproduces the notebook: boundary edges deeper than `zlim` are open; the
|
|
16
|
+
**longest** open contour becomes the boundary polyline, with a Riemann
|
|
17
|
+
time-series stanza per point.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
nc_path : str
|
|
22
|
+
Path to a UGRID NetCDF written by :func:`export_ugrid`.
|
|
23
|
+
out_dir : str
|
|
24
|
+
Output directory for the three files.
|
|
25
|
+
zlim : float, optional
|
|
26
|
+
Depth threshold (m); boundary edges deeper than `zlim` are
|
|
27
|
+
classified open. Default is 20.0.
|
|
28
|
+
pli_name, bc_name, ext_name : str, optional
|
|
29
|
+
Base names (without extension) for the ``.pli``, ``.bc`` and ``.ext``
|
|
30
|
+
files. Defaults are ``'Boundary01'``, ``'Riemann'`` and
|
|
31
|
+
``'FlowFM_bnd'``.
|
|
32
|
+
feedback : object or None, optional
|
|
33
|
+
Feedback sink, see :func:`extract_water_polygon`.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
pli_path, bc_path, ext_path : str
|
|
38
|
+
Paths to the three written files.
|
|
39
|
+
|
|
40
|
+
Raises
|
|
41
|
+
------
|
|
42
|
+
RuntimeError
|
|
43
|
+
If no open boundary is found at the given threshold.
|
|
44
|
+
"""
|
|
45
|
+
feedback = feedback or _NullFeedback()
|
|
46
|
+
import os
|
|
47
|
+
|
|
48
|
+
from bluemesh2d.geomesh_util.border_util import identify_boundary
|
|
49
|
+
|
|
50
|
+
vert, tria, z_depth = read_ugrid_mesh(nc_path)
|
|
51
|
+
feedback.pushInfo(f"Identifying boundaries (open where depth > {zlim} m) ...")
|
|
52
|
+
boundary = identify_boundary(vert, tria, z_depth, zlim=zlim)
|
|
53
|
+
open_contours = boundary["open_contours"]
|
|
54
|
+
if not open_contours:
|
|
55
|
+
raise RuntimeError(
|
|
56
|
+
f"No open boundary found with threshold {zlim} m; "
|
|
57
|
+
"lower the threshold.")
|
|
58
|
+
contour = max(open_contours, key=len)
|
|
59
|
+
feedback.pushInfo(
|
|
60
|
+
f"Open contours: {len(open_contours)}; using the longest "
|
|
61
|
+
f"({len(contour)} points).")
|
|
62
|
+
|
|
63
|
+
xb = vert[contour, 0]
|
|
64
|
+
yb = vert[contour, 1]
|
|
65
|
+
|
|
66
|
+
pli_path = os.path.join(out_dir, f"{pli_name}.pli")
|
|
67
|
+
bc_path = os.path.join(out_dir, f"{bc_name}.bc")
|
|
68
|
+
ext_path = os.path.join(out_dir, f"{ext_name}.ext")
|
|
69
|
+
|
|
70
|
+
with open(pli_path, "w") as f_pli, open(bc_path, "w") as f_bc:
|
|
71
|
+
f_pli.write(f"{pli_name}\n")
|
|
72
|
+
f_pli.write(f" {len(xb)} 2\n")
|
|
73
|
+
for i, (xi, yi) in enumerate(zip(xb, yb)):
|
|
74
|
+
boundary_id = f"{pli_name}_{i:04d}"
|
|
75
|
+
f_pli.write(f"{xi:.15E} {yi:.15E} {boundary_id}\n")
|
|
76
|
+
f_bc.write("[forcing]\n")
|
|
77
|
+
f_bc.write(f"Name = {boundary_id}\n")
|
|
78
|
+
f_bc.write("Function = timeseries\n")
|
|
79
|
+
f_bc.write("Time-interpolation = linear\n")
|
|
80
|
+
f_bc.write("Quantity = time\n")
|
|
81
|
+
f_bc.write("Unit = seconds since 2000-01-01 00:00:00\n")
|
|
82
|
+
f_bc.write("Quantity = riemannbnd\n")
|
|
83
|
+
f_bc.write("Unit = m\n")
|
|
84
|
+
f_bc.write("0 0\n")
|
|
85
|
+
f_bc.write("9999999999 0\n\n")
|
|
86
|
+
|
|
87
|
+
with open(ext_path, "w") as f:
|
|
88
|
+
f.write("[general]\n")
|
|
89
|
+
f.write("fileVersion=2.01\n")
|
|
90
|
+
f.write("fileType=extForce\n\n")
|
|
91
|
+
f.write("[boundary]\n")
|
|
92
|
+
f.write("quantity=riemannbnd\n")
|
|
93
|
+
f.write(f"locationFile={pli_name}.pli\n")
|
|
94
|
+
f.write(f"forcingFile={bc_name}.bc\n")
|
|
95
|
+
|
|
96
|
+
return pli_path, bc_path, ext_path
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def export_grd(nc_path, out_grd, zlim=20.0, crs="EPSG:4326", feedback=None):
|
|
100
|
+
"""Write an ADCIRC-style .grd with open/land boundary loops.
|
|
101
|
+
|
|
102
|
+
Boundary edges deeper than `zlim` are tagged open, the rest land
|
|
103
|
+
(``identify_boundary`` + ``export_to_grd``, as in the notebook).
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
nc_path : str
|
|
108
|
+
Path to a UGRID NetCDF written by :func:`export_ugrid`.
|
|
109
|
+
out_grd : str
|
|
110
|
+
Output ``.grd`` path.
|
|
111
|
+
zlim : float, optional
|
|
112
|
+
Depth threshold (m); boundary edges deeper than `zlim` are
|
|
113
|
+
classified open. Default is 20.0.
|
|
114
|
+
crs : str, optional
|
|
115
|
+
CRS string written to the ``.grd`` header. Default is
|
|
116
|
+
``'EPSG:4326'``.
|
|
117
|
+
feedback : object or None, optional
|
|
118
|
+
Feedback sink, see :func:`extract_water_polygon`.
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
out_grd : str
|
|
123
|
+
Path to the written file (same as the input `out_grd`).
|
|
124
|
+
"""
|
|
125
|
+
feedback = feedback or _NullFeedback()
|
|
126
|
+
from bluemesh2d.geomesh_util.border_util import identify_boundary
|
|
127
|
+
from bluemesh2d.geomesh_util.grd_util import export_to_grd
|
|
128
|
+
|
|
129
|
+
vert, tria, z_depth = read_ugrid_mesh(nc_path)
|
|
130
|
+
feedback.pushInfo(f"Identifying boundaries (open where depth > {zlim} m) ...")
|
|
131
|
+
boundary = identify_boundary(vert, tria, z_depth, zlim=zlim)
|
|
132
|
+
feedback.pushInfo(f"Writing .grd -> {out_grd}")
|
|
133
|
+
export_to_grd(
|
|
134
|
+
out_grd, vert=vert, tria=tria, z=z_depth, crs=crs,
|
|
135
|
+
edge_tag=boundary["edge_tag"],
|
|
136
|
+
edge_open=boundary["edge_open"],
|
|
137
|
+
edge_land=boundary["edge_land"],
|
|
138
|
+
)
|
|
139
|
+
return out_grd
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ===========================================================================
|
|
143
|
+
# Boundary condition generation (editable open / closed / island lines)
|
|
144
|
+
# ===========================================================================
|
|
145
|
+
|
|
146
|
+
def _boundary_loops(vert, tria):
|
|
147
|
+
"""Assemble the mesh boundary (free) edges into ordered node loops.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
vert : ndarray of shape (N, 2)
|
|
152
|
+
Node coordinates.
|
|
153
|
+
tria : ndarray of shape (T, 3)
|
|
154
|
+
Triangle connectivity (0-based).
|
|
155
|
+
|
|
156
|
+
Returns
|
|
157
|
+
-------
|
|
158
|
+
loops : list of list of int
|
|
159
|
+
One list of node indices per closed boundary loop (the first node is
|
|
160
|
+
not repeated at the end).
|
|
161
|
+
"""
|
|
162
|
+
import numpy as np
|
|
163
|
+
from collections import defaultdict
|
|
164
|
+
|
|
165
|
+
edges = np.vstack([tria[:, [0, 1]], tria[:, [1, 2]], tria[:, [2, 0]]])
|
|
166
|
+
es = np.sort(edges, axis=1)
|
|
167
|
+
uniq, counts = np.unique(es, axis=0, return_counts=True)
|
|
168
|
+
free = uniq[counts == 1]
|
|
169
|
+
|
|
170
|
+
adj = defaultdict(list)
|
|
171
|
+
for a, b in free:
|
|
172
|
+
adj[int(a)].append(int(b))
|
|
173
|
+
adj[int(b)].append(int(a))
|
|
174
|
+
|
|
175
|
+
def key(a, b):
|
|
176
|
+
return (a, b) if a < b else (b, a)
|
|
177
|
+
|
|
178
|
+
used = set()
|
|
179
|
+
loops = []
|
|
180
|
+
for a0, b0 in free:
|
|
181
|
+
a0, b0 = int(a0), int(b0)
|
|
182
|
+
if key(a0, b0) in used:
|
|
183
|
+
continue
|
|
184
|
+
used.add(key(a0, b0))
|
|
185
|
+
loop = [a0]
|
|
186
|
+
prev, cur = a0, b0
|
|
187
|
+
while cur != a0:
|
|
188
|
+
loop.append(cur)
|
|
189
|
+
nbrs = [n for n in adj[cur] if key(cur, n) not in used]
|
|
190
|
+
pref = [n for n in nbrs if n != prev]
|
|
191
|
+
step = pref or nbrs
|
|
192
|
+
if not step:
|
|
193
|
+
break
|
|
194
|
+
nxt = step[0]
|
|
195
|
+
used.add(key(cur, nxt))
|
|
196
|
+
prev, cur = cur, nxt
|
|
197
|
+
loops.append(loop)
|
|
198
|
+
return loops
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def classify_boundary_lines(vert, tria, z_depth, zlim=20.0):
|
|
202
|
+
"""Split the mesh boundary into open / closed / island polylines.
|
|
203
|
+
|
|
204
|
+
The boundary free edges are assembled into loops. Loops contained inside
|
|
205
|
+
another loop are *islands* (all their edges are coastline). On each outer
|
|
206
|
+
loop, an edge is *open* where the mean node depth exceeds `zlim`, otherwise
|
|
207
|
+
*closed* (land); consecutive edges of the same class form one continuous
|
|
208
|
+
polyline.
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
vert : ndarray of shape (N, 2)
|
|
213
|
+
Node coordinates (mesh CRS).
|
|
214
|
+
tria : ndarray of shape (T, 3)
|
|
215
|
+
Triangle connectivity (0-based).
|
|
216
|
+
z_depth : ndarray of shape (N,)
|
|
217
|
+
Node depth (positive down), as returned by :func:`read_ugrid_mesh`.
|
|
218
|
+
zlim : float, optional
|
|
219
|
+
Depth threshold (m); outer-boundary edges deeper than `zlim` are open.
|
|
220
|
+
Default is 20.0.
|
|
221
|
+
|
|
222
|
+
Returns
|
|
223
|
+
-------
|
|
224
|
+
lines : dict of {str: list of ndarray}
|
|
225
|
+
Keys ``'open'``, ``'closed'`` and ``'island'``; each value is a list
|
|
226
|
+
of ``(M, 2)`` coordinate arrays (polylines in the mesh CRS).
|
|
227
|
+
"""
|
|
228
|
+
import numpy as np
|
|
229
|
+
from shapely.geometry import Polygon
|
|
230
|
+
|
|
231
|
+
loops = _boundary_loops(vert, tria)
|
|
232
|
+
polys = [Polygon(vert[lp]) if len(lp) >= 3 else None for lp in loops]
|
|
233
|
+
|
|
234
|
+
# a loop is an island if it lies inside another (larger) loop
|
|
235
|
+
is_island = [False] * len(loops)
|
|
236
|
+
for i, pi in enumerate(polys):
|
|
237
|
+
if pi is None or not pi.is_valid:
|
|
238
|
+
continue
|
|
239
|
+
for j, pj in enumerate(polys):
|
|
240
|
+
if i == j or pj is None or not pj.is_valid:
|
|
241
|
+
continue
|
|
242
|
+
if pj.area > pi.area and pj.contains(pi.representative_point()):
|
|
243
|
+
is_island[i] = True
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
out = {"open": [], "closed": [], "island": []}
|
|
247
|
+
for lp, island in zip(loops, is_island):
|
|
248
|
+
coords = vert[lp]
|
|
249
|
+
ring = np.vstack([coords, coords[0]]) # close the ring for display
|
|
250
|
+
if island:
|
|
251
|
+
out["island"].append(ring)
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
n = len(lp)
|
|
255
|
+
tags = [0.5 * (z_depth[lp[k]] + z_depth[lp[(k + 1) % n]]) > zlim
|
|
256
|
+
for k in range(n)]
|
|
257
|
+
if all(tags):
|
|
258
|
+
out["open"].append(ring)
|
|
259
|
+
continue
|
|
260
|
+
if not any(tags):
|
|
261
|
+
out["closed"].append(ring)
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
# rotate so the walk starts at a class transition (avoids wrap-around)
|
|
265
|
+
start = next(k for k in range(n) if tags[k] != tags[k - 1])
|
|
266
|
+
eord = [(start + k) % n for k in range(n)]
|
|
267
|
+
runs = [[eord[0]]]
|
|
268
|
+
for e in eord[1:]:
|
|
269
|
+
if tags[e] == tags[runs[-1][-1]]:
|
|
270
|
+
runs[-1].append(e)
|
|
271
|
+
else:
|
|
272
|
+
runs.append([e])
|
|
273
|
+
for run in runs:
|
|
274
|
+
node_seq = [lp[run[0]]] + [lp[(e + 1) % n] for e in run]
|
|
275
|
+
line = vert[node_seq]
|
|
276
|
+
out["open" if tags[run[0]] else "closed"].append(line)
|
|
277
|
+
return out
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def classify_boundary_points(vert, tria, z_depth, zlim=20.0):
|
|
281
|
+
"""Classify each mesh boundary node as open / closed / island.
|
|
282
|
+
|
|
283
|
+
The boundary free edges are assembled into loops (see
|
|
284
|
+
:func:`_boundary_loops`). Loops contained inside another loop are
|
|
285
|
+
*islands* (every node tagged ``'island'``). On outer loops, a node is
|
|
286
|
+
``'open'`` where its depth exceeds `zlim`, otherwise ``'closed'``.
|
|
287
|
+
|
|
288
|
+
Parameters
|
|
289
|
+
----------
|
|
290
|
+
vert : ndarray of shape (N, 2)
|
|
291
|
+
Node coordinates (mesh CRS).
|
|
292
|
+
tria : ndarray of shape (T, 3)
|
|
293
|
+
Triangle connectivity (0-based).
|
|
294
|
+
z_depth : ndarray of shape (N,)
|
|
295
|
+
Node depth (positive down), as returned by :func:`read_ugrid_mesh`.
|
|
296
|
+
zlim : float, optional
|
|
297
|
+
Depth threshold (m); outer-boundary nodes deeper than `zlim` are
|
|
298
|
+
open. Default is 20.0.
|
|
299
|
+
|
|
300
|
+
Returns
|
|
301
|
+
-------
|
|
302
|
+
loops : list of dict
|
|
303
|
+
One dict per boundary loop, with keys ``'coords'`` (``(n, 2)``
|
|
304
|
+
node coordinates, in walk order, first node not repeated),
|
|
305
|
+
``'btype'`` (list of ``n`` strings), ``'depth'`` (list of ``n``
|
|
306
|
+
floats) and ``'island'`` (bool).
|
|
307
|
+
"""
|
|
308
|
+
import numpy as np
|
|
309
|
+
from shapely.geometry import Polygon
|
|
310
|
+
|
|
311
|
+
loops = _boundary_loops(vert, tria)
|
|
312
|
+
polys = [Polygon(vert[lp]) if len(lp) >= 3 else None for lp in loops]
|
|
313
|
+
|
|
314
|
+
# a loop is an island if it lies inside another (larger) loop
|
|
315
|
+
is_island = [False] * len(loops)
|
|
316
|
+
for i, pi in enumerate(polys):
|
|
317
|
+
if pi is None or not pi.is_valid:
|
|
318
|
+
continue
|
|
319
|
+
for j, pj in enumerate(polys):
|
|
320
|
+
if i == j or pj is None or not pj.is_valid:
|
|
321
|
+
continue
|
|
322
|
+
if pj.area > pi.area and pj.contains(pi.representative_point()):
|
|
323
|
+
is_island[i] = True
|
|
324
|
+
break
|
|
325
|
+
|
|
326
|
+
out = []
|
|
327
|
+
for lp, island in zip(loops, is_island):
|
|
328
|
+
depths = [float(z_depth[k]) for k in lp]
|
|
329
|
+
if island:
|
|
330
|
+
btype = ["island"] * len(lp)
|
|
331
|
+
else:
|
|
332
|
+
btype = ["open" if d > zlim else "closed" for d in depths]
|
|
333
|
+
out.append({"coords": np.asarray(vert[lp], dtype=float),
|
|
334
|
+
"btype": btype, "depth": depths, "island": island})
|
|
335
|
+
return out
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def boundary_lines_from_points(loops):
|
|
339
|
+
"""Rebuild open / closed / island polylines from per-node classifications.
|
|
340
|
+
|
|
341
|
+
Inverse companion of :func:`classify_boundary_points`, applied after the
|
|
342
|
+
user has edited node types: consecutive edges of the same class form one
|
|
343
|
+
polyline. An edge takes the type of its two nodes when they agree; at an
|
|
344
|
+
open/other transition the edge is not open (the ``.pli`` open boundary
|
|
345
|
+
only spans fully-open stretches), otherwise it takes its first node's
|
|
346
|
+
type.
|
|
347
|
+
|
|
348
|
+
Parameters
|
|
349
|
+
----------
|
|
350
|
+
loops : list of (ndarray of shape (n, 2), list of str)
|
|
351
|
+
Per loop: node coordinates in walk order (first node not repeated)
|
|
352
|
+
and one type string per node.
|
|
353
|
+
|
|
354
|
+
Returns
|
|
355
|
+
-------
|
|
356
|
+
lines : dict of {str: list of ndarray}
|
|
357
|
+
Type -> list of ``(M, 2)`` coordinate polylines, as in
|
|
358
|
+
:func:`classify_boundary_lines`.
|
|
359
|
+
"""
|
|
360
|
+
import numpy as np
|
|
361
|
+
|
|
362
|
+
out = {}
|
|
363
|
+
for coords, btype in loops:
|
|
364
|
+
coords = np.asarray(coords, dtype=float)
|
|
365
|
+
n = len(coords)
|
|
366
|
+
if n < 2:
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
def edge_type(k):
|
|
370
|
+
a, b = btype[k], btype[(k + 1) % n]
|
|
371
|
+
if a == b:
|
|
372
|
+
return a
|
|
373
|
+
if a == "open":
|
|
374
|
+
return b
|
|
375
|
+
if b == "open":
|
|
376
|
+
return a
|
|
377
|
+
return a
|
|
378
|
+
|
|
379
|
+
tags = [edge_type(k) for k in range(n)]
|
|
380
|
+
if all(t == tags[0] for t in tags):
|
|
381
|
+
ring = np.vstack([coords, coords[:1]])
|
|
382
|
+
out.setdefault(tags[0], []).append(ring)
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
# rotate so the walk starts at a class transition (avoids wrap-around)
|
|
386
|
+
start = next(k for k in range(n) if tags[k] != tags[k - 1])
|
|
387
|
+
eord = [(start + k) % n for k in range(n)]
|
|
388
|
+
runs = [[eord[0]]]
|
|
389
|
+
for e in eord[1:]:
|
|
390
|
+
if tags[e] == tags[runs[-1][-1]]:
|
|
391
|
+
runs[-1].append(e)
|
|
392
|
+
else:
|
|
393
|
+
runs.append([e])
|
|
394
|
+
for run in runs:
|
|
395
|
+
node_seq = [run[0]] + [(e + 1) % n for e in run]
|
|
396
|
+
out.setdefault(tags[run[0]], []).append(coords[node_seq])
|
|
397
|
+
return out
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def generate_boundary_condition_points(nc_path, zlim=20.0, feedback=None):
|
|
401
|
+
"""Classify each mesh boundary node as open / closed / island.
|
|
402
|
+
|
|
403
|
+
Parameters
|
|
404
|
+
----------
|
|
405
|
+
nc_path : str
|
|
406
|
+
Path to a UGRID NetCDF written by :func:`export_ugrid`.
|
|
407
|
+
zlim : float, optional
|
|
408
|
+
Depth threshold (m) for the initial open/closed split. Default 20.0.
|
|
409
|
+
feedback : object or None, optional
|
|
410
|
+
Feedback sink, see :func:`extract_water_polygon`.
|
|
411
|
+
|
|
412
|
+
Returns
|
|
413
|
+
-------
|
|
414
|
+
loops : list of dict
|
|
415
|
+
See :func:`classify_boundary_points`.
|
|
416
|
+
"""
|
|
417
|
+
feedback = feedback or _NullFeedback()
|
|
418
|
+
vert, tria, z_depth = read_ugrid_mesh(nc_path)
|
|
419
|
+
feedback.pushInfo(f"Classifying boundary (open where depth > {zlim} m) ...")
|
|
420
|
+
loops = classify_boundary_points(vert, tria, z_depth, zlim=zlim)
|
|
421
|
+
n_island = sum(1 for lp in loops if lp["island"])
|
|
422
|
+
n_pts = sum(len(lp["btype"]) for lp in loops)
|
|
423
|
+
feedback.pushInfo(
|
|
424
|
+
f"Boundary points: {n_pts} on {len(loops)} loop(s) "
|
|
425
|
+
f"({n_island} island).")
|
|
426
|
+
return loops
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def generate_boundary_conditions(nc_path, zlim=20.0, feedback=None):
|
|
430
|
+
"""Classify a mesh's boundary into open / closed / island polylines.
|
|
431
|
+
|
|
432
|
+
Parameters
|
|
433
|
+
----------
|
|
434
|
+
nc_path : str
|
|
435
|
+
Path to a UGRID NetCDF written by :func:`export_ugrid`.
|
|
436
|
+
zlim : float, optional
|
|
437
|
+
Depth threshold (m) for the initial open/closed split. Default 20.0.
|
|
438
|
+
feedback : object or None, optional
|
|
439
|
+
Feedback sink, see :func:`extract_water_polygon`.
|
|
440
|
+
|
|
441
|
+
Returns
|
|
442
|
+
-------
|
|
443
|
+
lines : dict of {str: list of ndarray}
|
|
444
|
+
See :func:`classify_boundary_lines`.
|
|
445
|
+
"""
|
|
446
|
+
feedback = feedback or _NullFeedback()
|
|
447
|
+
vert, tria, z_depth = read_ugrid_mesh(nc_path)
|
|
448
|
+
feedback.pushInfo(f"Classifying boundary (open where depth > {zlim} m) ...")
|
|
449
|
+
lines = classify_boundary_lines(vert, tria, z_depth, zlim=zlim)
|
|
450
|
+
feedback.pushInfo(
|
|
451
|
+
f"Boundary lines: {len(lines['open'])} open, "
|
|
452
|
+
f"{len(lines['closed'])} closed, {len(lines['island'])} island.")
|
|
453
|
+
return lines
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def write_open_boundary_pli(out_dir, open_lines, pli_name="Boundary01",
|
|
457
|
+
feedback=None):
|
|
458
|
+
"""Write a Delft3D-FM ``.pli`` polyline file from open boundary polylines.
|
|
459
|
+
|
|
460
|
+
Parameters
|
|
461
|
+
----------
|
|
462
|
+
out_dir : str
|
|
463
|
+
Output directory.
|
|
464
|
+
open_lines : list of array_like of shape (M, 2)
|
|
465
|
+
Open-boundary polylines (coordinates), e.g. the ``'open'`` features of
|
|
466
|
+
the stage-5 boundary-condition layer.
|
|
467
|
+
pli_name : str, optional
|
|
468
|
+
Base name for the ``.pli`` file. Default ``'Boundary01'``.
|
|
469
|
+
feedback : object or None, optional
|
|
470
|
+
Feedback sink, see :func:`extract_water_polygon`.
|
|
471
|
+
|
|
472
|
+
Returns
|
|
473
|
+
-------
|
|
474
|
+
pli_path : str
|
|
475
|
+
Path to the written file.
|
|
476
|
+
boundary_ids : list of list of str
|
|
477
|
+
Per-line lists of the boundary point ids written to the ``.pli``
|
|
478
|
+
file, in the same order/nesting as `open_lines` -- for use e.g. when
|
|
479
|
+
writing matching ``.bc`` forcing blocks.
|
|
480
|
+
|
|
481
|
+
Raises
|
|
482
|
+
------
|
|
483
|
+
RuntimeError
|
|
484
|
+
If `open_lines` is empty.
|
|
485
|
+
"""
|
|
486
|
+
import os
|
|
487
|
+
import numpy as np
|
|
488
|
+
|
|
489
|
+
feedback = feedback or _NullFeedback()
|
|
490
|
+
lines = [np.atleast_2d(np.asarray(ln, dtype=float)) for ln in open_lines
|
|
491
|
+
if len(ln) >= 2]
|
|
492
|
+
if not lines:
|
|
493
|
+
raise RuntimeError(
|
|
494
|
+
"No open boundary polyline provided; classify one in "
|
|
495
|
+
"'5 - Generate boundary conditions' (or lower the depth threshold).")
|
|
496
|
+
|
|
497
|
+
pli_path = os.path.join(out_dir, f"{pli_name}.pli")
|
|
498
|
+
idx = 0
|
|
499
|
+
boundary_ids = []
|
|
500
|
+
with open(pli_path, "w") as f_pli:
|
|
501
|
+
for li, line in enumerate(lines):
|
|
502
|
+
block = pli_name if len(lines) == 1 else f"{pli_name}_{li:03d}"
|
|
503
|
+
f_pli.write(f"{block}\n")
|
|
504
|
+
f_pli.write(f" {len(line)} 2\n")
|
|
505
|
+
ids = []
|
|
506
|
+
for xi, yi in line:
|
|
507
|
+
boundary_id = f"{pli_name}_{idx:04d}"
|
|
508
|
+
idx += 1
|
|
509
|
+
ids.append(boundary_id)
|
|
510
|
+
f_pli.write(f"{xi:.15E} {yi:.15E} {boundary_id}\n")
|
|
511
|
+
boundary_ids.append(ids)
|
|
512
|
+
|
|
513
|
+
feedback.pushInfo(f"Open boundary file: {pli_path}")
|
|
514
|
+
return pli_path, boundary_ids
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def write_open_boundary_files(out_dir, open_lines, pli_name="Boundary01",
|
|
518
|
+
bc_name="Riemann", ext_name="FlowFM_bnd",
|
|
519
|
+
feedback=None):
|
|
520
|
+
"""Write Delft3D-FM open-boundary files from open boundary polylines.
|
|
521
|
+
|
|
522
|
+
Parameters
|
|
523
|
+
----------
|
|
524
|
+
out_dir : str
|
|
525
|
+
Output directory.
|
|
526
|
+
open_lines : list of array_like of shape (M, 2)
|
|
527
|
+
Open-boundary polylines (coordinates), e.g. the ``'open'`` features of
|
|
528
|
+
the stage-5 boundary-condition layer.
|
|
529
|
+
pli_name, bc_name, ext_name : str, optional
|
|
530
|
+
Base names for the ``.pli``, ``.bc`` and ``.ext`` files. Defaults
|
|
531
|
+
``'Boundary01'``, ``'Riemann'``, ``'FlowFM_bnd'``.
|
|
532
|
+
feedback : object or None, optional
|
|
533
|
+
Feedback sink, see :func:`extract_water_polygon`.
|
|
534
|
+
|
|
535
|
+
Returns
|
|
536
|
+
-------
|
|
537
|
+
pli_path, bc_path, ext_path : str
|
|
538
|
+
Paths to the three written files.
|
|
539
|
+
|
|
540
|
+
Raises
|
|
541
|
+
------
|
|
542
|
+
RuntimeError
|
|
543
|
+
If `open_lines` is empty.
|
|
544
|
+
"""
|
|
545
|
+
import os
|
|
546
|
+
|
|
547
|
+
feedback = feedback or _NullFeedback()
|
|
548
|
+
pli_path, boundary_ids = write_open_boundary_pli(
|
|
549
|
+
out_dir, open_lines, pli_name=pli_name, feedback=feedback)
|
|
550
|
+
|
|
551
|
+
bc_path = os.path.join(out_dir, f"{bc_name}.bc")
|
|
552
|
+
with open(bc_path, "w") as f_bc:
|
|
553
|
+
for ids in boundary_ids:
|
|
554
|
+
for boundary_id in ids:
|
|
555
|
+
f_bc.write("[forcing]\n")
|
|
556
|
+
f_bc.write(f"Name = {boundary_id}\n")
|
|
557
|
+
f_bc.write("Function = timeseries\n")
|
|
558
|
+
f_bc.write("Time-interpolation = linear\n")
|
|
559
|
+
f_bc.write("Quantity = time\n")
|
|
560
|
+
f_bc.write("Unit = seconds since 2000-01-01 00:00:00\n")
|
|
561
|
+
f_bc.write("Quantity = riemannbnd\n")
|
|
562
|
+
f_bc.write("Unit = m\n")
|
|
563
|
+
f_bc.write("0 0\n")
|
|
564
|
+
f_bc.write("9999999999 0\n\n")
|
|
565
|
+
|
|
566
|
+
ext_path = os.path.join(out_dir, f"{ext_name}.ext")
|
|
567
|
+
with open(ext_path, "w") as f:
|
|
568
|
+
f.write("[general]\n")
|
|
569
|
+
f.write("fileVersion=2.01\n")
|
|
570
|
+
f.write("fileType=extForce\n\n")
|
|
571
|
+
f.write("[boundary]\n")
|
|
572
|
+
f.write("quantity=riemannbnd\n")
|
|
573
|
+
f.write(f"locationFile={pli_name}.pli\n")
|
|
574
|
+
f.write(f"forcingFile={bc_name}.bc\n")
|
|
575
|
+
|
|
576
|
+
feedback.pushInfo(f"Open boundary files: {pli_path}, {bc_path}, {ext_path}")
|
|
577
|
+
return pli_path, bc_path, ext_path
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def export_grd_from_lines(nc_path, out_grd, open_lines, land_lines,
|
|
581
|
+
crs="EPSG:4326", snap_tol=None, feedback=None):
|
|
582
|
+
"""Write an ADCIRC ``.grd`` using an edited open/land boundary classification.
|
|
583
|
+
|
|
584
|
+
Each polyline vertex is snapped to the nearest mesh node, so the (possibly
|
|
585
|
+
edited) stage-5 lines are mapped back to mesh boundary edges and contours.
|
|
586
|
+
|
|
587
|
+
Parameters
|
|
588
|
+
----------
|
|
589
|
+
nc_path : str
|
|
590
|
+
Path to a UGRID NetCDF written by :func:`export_ugrid`.
|
|
591
|
+
out_grd : str
|
|
592
|
+
Output ``.grd`` path.
|
|
593
|
+
open_lines : list of array_like of shape (M, 2)
|
|
594
|
+
Open-boundary polylines (the ``'open'`` features from stage 5).
|
|
595
|
+
land_lines : list of array_like of shape (M, 2)
|
|
596
|
+
Land-boundary polylines (the ``'closed'`` and ``'island'`` features).
|
|
597
|
+
crs : str, optional
|
|
598
|
+
CRS string written to the ``.grd`` header. Default ``'EPSG:4326'``.
|
|
599
|
+
snap_tol : float or None, optional
|
|
600
|
+
Maximum distance for snapping a vertex to a mesh node; auto-derived
|
|
601
|
+
from the median boundary edge length when ``None``.
|
|
602
|
+
feedback : object or None, optional
|
|
603
|
+
Feedback sink, see :func:`extract_water_polygon`.
|
|
604
|
+
|
|
605
|
+
Returns
|
|
606
|
+
-------
|
|
607
|
+
out_grd : str
|
|
608
|
+
Path to the written file.
|
|
609
|
+
"""
|
|
610
|
+
import numpy as np
|
|
611
|
+
from scipy.spatial import cKDTree
|
|
612
|
+
|
|
613
|
+
from bluemesh2d.geomesh_util.grd_util import export_to_grd
|
|
614
|
+
|
|
615
|
+
feedback = feedback or _NullFeedback()
|
|
616
|
+
vert, tria, z_depth = read_ugrid_mesh(nc_path)
|
|
617
|
+
tree = cKDTree(vert)
|
|
618
|
+
if snap_tol is None:
|
|
619
|
+
# median boundary edge length as a lenient default tolerance
|
|
620
|
+
loops = _boundary_loops(vert, tria)
|
|
621
|
+
d = [np.linalg.norm(vert[lp[k]] - vert[lp[(k + 1) % len(lp)]])
|
|
622
|
+
for lp in loops for k in range(len(lp))]
|
|
623
|
+
snap_tol = (np.median(d) if d else 1.0) * 0.75
|
|
624
|
+
|
|
625
|
+
def lines_to_edges_contours(lines):
|
|
626
|
+
edges, contours = [], []
|
|
627
|
+
for ln in lines:
|
|
628
|
+
ln = np.atleast_2d(np.asarray(ln, dtype=float))
|
|
629
|
+
dist, idx = tree.query(ln)
|
|
630
|
+
if np.any(dist > snap_tol):
|
|
631
|
+
feedback.pushWarning(
|
|
632
|
+
"Some boundary vertices are far from any mesh node; "
|
|
633
|
+
"the classification may be imprecise (avoid moving "
|
|
634
|
+
"vertices when editing).")
|
|
635
|
+
seq = [int(i) for i, _ in zip(idx, range(len(idx)))]
|
|
636
|
+
# drop consecutive duplicates from snapping
|
|
637
|
+
seq = [seq[0]] + [b for a, b in zip(seq[:-1], seq[1:]) if a != b]
|
|
638
|
+
if len(seq) >= 2:
|
|
639
|
+
contours.append(np.asarray(seq, dtype=int))
|
|
640
|
+
edges.extend([seq[k], seq[k + 1]] for k in range(len(seq) - 1))
|
|
641
|
+
return (np.asarray(edges, dtype=int) if edges
|
|
642
|
+
else np.empty((0, 2), dtype=int)), contours
|
|
643
|
+
|
|
644
|
+
edge_open, open_contours = lines_to_edges_contours(open_lines)
|
|
645
|
+
edge_land, land_contours = lines_to_edges_contours(land_lines)
|
|
646
|
+
|
|
647
|
+
tag_o = np.ones((edge_open.shape[0], 1), dtype=int)
|
|
648
|
+
tag_l = np.full((edge_land.shape[0], 1), 2, dtype=int)
|
|
649
|
+
parts = []
|
|
650
|
+
if edge_open.shape[0]:
|
|
651
|
+
parts.append(np.hstack([edge_open, tag_o]))
|
|
652
|
+
if edge_land.shape[0]:
|
|
653
|
+
parts.append(np.hstack([edge_land, tag_l]))
|
|
654
|
+
edge_tag = np.vstack(parts) if parts else np.empty((0, 3), dtype=int)
|
|
655
|
+
|
|
656
|
+
feedback.pushInfo(
|
|
657
|
+
f"Writing .grd -> {out_grd} ({edge_open.shape[0]} open, "
|
|
658
|
+
f"{edge_land.shape[0]} land edges)")
|
|
659
|
+
export_to_grd(
|
|
660
|
+
out_grd, vert=vert, tria=tria, z=z_depth, crs=crs,
|
|
661
|
+
edge_tag=edge_tag, edge_open=edge_open, edge_land=edge_land,
|
|
662
|
+
open_contours=open_contours, land_contours=land_contours,
|
|
663
|
+
)
|
|
664
|
+
return out_grd
|