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,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