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,812 @@
1
+ """
2
+ Iterative pipeline: orthogonalize <-> merge_circumcenters (with optional recovery).
3
+
4
+ Used by :mod:`bluemesh2d.smood` as its orthogonalization/merge pipeline. The
5
+ underlying numeric orthogonality/small-link kernels live in
6
+ :mod:`bluemesh2d.ortho_merge.orthogonalize`.
7
+
8
+ Intended process (Delft3D-FM / dual mesh)
9
+ -----------------------------------------
10
+ 1. **Orthogonalize** on a triangulation of the current (mixed) faces **consistent with**
11
+ ``merge_circumcenters`` / UGRID export: quads ``[a,v1,b,v2]`` are split on diagonal
12
+ ``(v1,v2)`` (see ``geomesh_util.grd_util.triangulate_mixed_face_row_to_tris``), **not**
13
+ fan-from-``a`` (diagonal ``(a,b)``), which skews the dual w.r.t. Delft3D-FM.
14
+ 2. **Remove short flow links**: ``merge_circumcenters`` merges triangle pairs into quads
15
+ (skipped when ``merge_small_links=False``: the mesh then stays pure triangles).
16
+ 3. Repeat; optional **recovery** cycles if ``max|cosφ|`` or small-link count still fails
17
+ the dual criteria (see ``require_both_criteria``).
18
+ 4. With ``require_both_criteria=True``, **raise** if criteria are still not met after the
19
+ main and recovery cycles. Default **False** skips the global dual check and recovery.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from typing import Callable, List, Optional, Sequence, Tuple
26
+
27
+ import numpy as np
28
+
29
+
30
+ @dataclass
31
+ class OrthoMergeStats:
32
+ outer_iter: int
33
+ max_cosphi: float
34
+ n_small_flow_links: int
35
+ merged_this_iter: int
36
+ n_zones_orthogonalized: int
37
+ recovery: bool = False
38
+ recovery_iter: int = 0
39
+
40
+
41
+ def _faces_from_face_nodes(face_nodes: np.ndarray) -> List[np.ndarray]:
42
+ """
43
+ Convert internal UGRID `face_nodes` (0-based, invalid = -1) to a list of faces.
44
+
45
+ The returned faces contain only valid node indices (>=0) and keep original polygon
46
+ size (3 or 4+ depending on input).
47
+ """
48
+ face_nodes = np.asarray(face_nodes, dtype=np.int64)
49
+ faces: List[np.ndarray] = []
50
+ for f in range(face_nodes.shape[0]):
51
+ nodes = face_nodes[f, :]
52
+ nodes = nodes[nodes >= 0]
53
+ if nodes.size >= 3:
54
+ faces.append(nodes.astype(np.int64, copy=True))
55
+ return faces
56
+
57
+
58
+ def _triangulate_faces_for_ortho(
59
+ vert_xy: np.ndarray, faces: Sequence[np.ndarray]
60
+ ) -> Tuple[np.ndarray, np.ndarray]:
61
+ """
62
+ Triangle rows for ``orthogonalize_tria_mesh``, aligned with ``merge_circumcenters`` quads
63
+ and NetCDF export: use :func:`~bluemesh2d.geomesh_util.grd_util.triangulate_mixed_face_row_to_tris`
64
+ so 4-node faces use diagonal ``(v1,v2)``, not fan-from-``a``.
65
+
66
+ Returns
67
+ -------
68
+ (tria, tri_face_id)
69
+ - tria: (T,3) triangle rows.
70
+ - tri_face_id: (T,) index into ``faces`` of the origin face of each row.
71
+ """
72
+ from ..geomesh_util.grd_util import triangulate_mixed_face_row_to_tris
73
+
74
+ vert_xy = np.asarray(vert_xy, dtype=np.float64)
75
+ if vert_xy.ndim != 2 or vert_xy.shape[1] < 2:
76
+ raise ValueError("vert_xy must have shape (N, 2) or (N, >=2) for x,y")
77
+ xy = vert_xy[:, :2]
78
+ out: List[Tuple[int, int, int]] = []
79
+ face_ids: List[int] = []
80
+ for fid, nodes in enumerate(faces):
81
+ n = np.asarray(nodes, dtype=np.int64).reshape(-1)
82
+ if n.size < 3:
83
+ continue
84
+ tris = triangulate_mixed_face_row_to_tris(xy, n)
85
+ out.extend(tris)
86
+ face_ids.extend([fid] * len(tris))
87
+ if out:
88
+ return np.asarray(out, dtype=np.int64), np.asarray(face_ids, dtype=np.int64)
89
+ return np.empty((0, 3), dtype=np.int64), np.empty(0, dtype=np.int64)
90
+
91
+
92
+ def _faces_with_ortho_topology(
93
+ faces: Sequence[np.ndarray],
94
+ tria_in: np.ndarray,
95
+ tria_out: np.ndarray,
96
+ tri_face_id: np.ndarray,
97
+ ) -> Sequence[np.ndarray]:
98
+ """
99
+ Propagate topology changes (edge flips) made by ``orthogonalize_tria_mesh``
100
+ on the triangle proxy back onto the mixed face list.
101
+
102
+ ``orthogonalize_tria_mesh`` flips edges by rewriting the two triangle rows
103
+ in place, so rows of ``tria_out`` correspond 1:1 to rows of ``tria_in``.
104
+ Faces whose proxy rows are unchanged are kept as-is (including quads). A
105
+ quad whose proxy rows changed only by re-diagonalization (no external node
106
+ entered) is also kept — the 4-node face is the same, only the proxy
107
+ diagonal moved. Any other changed face is replaced by its flipped triangle
108
+ rows: without this, flips were silently discarded by the dataset rebuild
109
+ and the outer/recovery cycles could stall forever on an edge that only a
110
+ flip can fix, while the ortho stage kept reporting it as solved.
111
+ """
112
+ if tria_out.shape != tria_in.shape:
113
+ # Unexpected: fall back to the original faces (previous behaviour).
114
+ return faces
115
+ row_changed = np.any(np.sort(tria_in, axis=1) != np.sort(tria_out, axis=1), axis=1)
116
+ if not np.any(row_changed):
117
+ return faces
118
+
119
+ changed_faces = set(int(f) for f in np.unique(tri_face_id[row_changed]))
120
+ faces_out: List[np.ndarray] = []
121
+ for fid, f in enumerate(faces):
122
+ if fid not in changed_faces:
123
+ faces_out.append(f)
124
+ continue
125
+ rows = np.where(tri_face_id == fid)[0]
126
+ face_nodes = set(int(v) for v in np.asarray(f).reshape(-1))
127
+ out_nodes = set(int(v) for v in tria_out[rows].ravel())
128
+ if len(f) >= 4 and out_nodes <= face_nodes:
129
+ # Internal re-diagonalization of a quad: same 4-node face.
130
+ faces_out.append(f)
131
+ else:
132
+ for r in rows:
133
+ faces_out.append(np.asarray(tria_out[r], dtype=np.int64).copy())
134
+ return faces_out
135
+
136
+
137
+ def _face_nodes_raw_to_0b(face_nodes_raw: np.ndarray, start_index: int) -> np.ndarray:
138
+ """UGRID face_nodes array to 0-based with -1 fill."""
139
+ face_nodes_raw = np.asarray(face_nodes_raw, dtype=np.int64)
140
+ if int(start_index) == 1:
141
+ face_nodes = np.full_like(face_nodes_raw, -1)
142
+ valid = face_nodes_raw > 0
143
+ face_nodes[valid] = face_nodes_raw[valid] - 1
144
+ return face_nodes
145
+ return face_nodes_raw.copy()
146
+
147
+
148
+ def _fan_vert_tria_from_ds(
149
+ ds_cur,
150
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
151
+ """
152
+ Triangle proxy of the mixed UGRID dataset, aligned with quad splitting
153
+ used by the rest of this repository.
154
+
155
+ Returns
156
+ -------
157
+ vert_xy : (N,2) float64
158
+ Triangle-proxy vertex coordinates.
159
+ tria : (T,3) int64
160
+ Triangle connectivity of the proxy.
161
+ tri_origin_face_id : (T,) int64
162
+ Index of the original mixed-face row from which each triangle originates.
163
+ (Triangles coming from the same quad share the same origin id.)
164
+ quad_face_mask : (F,) bool
165
+ Whether each original mixed-face row is a quad (len==4).
166
+ """
167
+ face_nodes_raw = np.asarray(ds_cur["mesh2d_face_nodes"].values, dtype=np.int64)
168
+ start_index = int(ds_cur["mesh2d_face_nodes"].attrs.get("start_index", 1))
169
+ face_nodes = _face_nodes_raw_to_0b(face_nodes_raw, start_index)
170
+ faces = _faces_from_face_nodes(face_nodes)
171
+
172
+ node_x = np.asarray(ds_cur["mesh2d_node_x"].values, dtype=np.float64)
173
+ node_y = np.asarray(ds_cur["mesh2d_node_y"].values, dtype=np.float64)
174
+ vert_xy = np.column_stack([node_x, node_y])
175
+ xy = vert_xy[:, :2]
176
+
177
+ from ..geomesh_util.grd_util import triangulate_mixed_face_row_to_tris
178
+
179
+ quad_face_mask = np.asarray([(f.size == 4) for f in faces], dtype=bool)
180
+ tris: List[Tuple[int, int, int]] = []
181
+ tri_origin_face_id: List[int] = []
182
+
183
+ for fid, nodes in enumerate(faces):
184
+ tri_list = triangulate_mixed_face_row_to_tris(
185
+ xy, np.asarray(nodes, dtype=np.int64)
186
+ )
187
+ for tri in tri_list:
188
+ tris.append((int(tri[0]), int(tri[1]), int(tri[2])))
189
+ tri_origin_face_id.append(int(fid))
190
+
191
+ tria = np.asarray(tris, dtype=np.int64)
192
+ tri_origin_face_id = np.asarray(tri_origin_face_id, dtype=np.int64)
193
+
194
+ return vert_xy, tria, tri_origin_face_id, quad_face_mask
195
+
196
+
197
+ def dual_criteria_on_fan_mesh(
198
+ vert: np.ndarray,
199
+ tria: np.ndarray,
200
+ tri_origin_face_id: np.ndarray,
201
+ quad_face_mask: np.ndarray,
202
+ *,
203
+ cosphi_threshold: float,
204
+ removesmalllinkstrsh: float,
205
+ jsferic: int = 1,
206
+ ) -> Tuple[bool, float, int]:
207
+ """Check dual-mesh orthogonality and small-link criteria on a triangle proxy.
208
+
209
+ Parameters
210
+ ----------
211
+ vert : ndarray of shape (N, 2)
212
+ Node coordinates (lon/lat degrees or planar x/y per ``jsferic``).
213
+ tria : ndarray of shape (T, 3)
214
+ 0-based triangle connectivity.
215
+ tri_origin_face_id : ndarray of shape (T,)
216
+ Origin mixed-face index for each proxy triangle.
217
+ quad_face_mask : ndarray of shape (F,)
218
+ Whether each original mixed face is a quad.
219
+ cosphi_threshold : float
220
+ Maximum allowed ``|cos φ|`` on internal flow links.
221
+ removesmalllinkstrsh : float
222
+ Small flow-link threshold (Delft3D convention).
223
+ jsferic : int, optional
224
+ ``1`` for spherical lon/lat; ``0`` for planar coordinates.
225
+
226
+ Returns
227
+ -------
228
+ ok : bool
229
+ ``True`` when both criteria are satisfied.
230
+ max_c : float
231
+ Maximum ``|cos φ|`` over counted edges.
232
+ n_small : int
233
+ Number of small flow links detected.
234
+ """
235
+ from . import orthogonalize as ortho
236
+ from .geometry import build_edges_from_tria
237
+
238
+ vert = np.asarray(vert, dtype=np.float64)
239
+ tria = np.asarray(tria, dtype=np.int64)
240
+ if tria.size == 0:
241
+ return True, 0.0, 0
242
+
243
+ edge_nodes, edge_faces = build_edges_from_tria(tria)
244
+ _, _, cosphi_abs = ortho.compute_cosphi_abs_from_arrays(
245
+ vert[:, 0],
246
+ vert[:, 1],
247
+ tria,
248
+ edge_nodes,
249
+ edge_faces,
250
+ use_file_centers=False,
251
+ use_circumcenter_3d=True,
252
+ jsferic=jsferic,
253
+ )
254
+ # MeshKernel small-flow-links should ignore edges internal to a quad in the
255
+ # original mixed mesh. In the triangle-proxy, those correspond to the shared
256
+ # diagonal between the two triangles coming from the same quad-face row.
257
+ # Apply the same exclusion to max|cos φ|: internal diagonals are not flow
258
+ # links, so they must not force recovery when merge introduces quads.
259
+ n_edges = edge_faces.shape[0]
260
+ keep_edge_indices = np.arange(n_edges, dtype=np.int64)
261
+ exclude_mask = np.zeros(n_edges, dtype=bool)
262
+ for e in range(n_edges):
263
+ f1 = int(edge_faces[e, 0])
264
+ f2 = int(edge_faces[e, 1])
265
+ if f1 < 0 or f2 < 0 or f1 == f2:
266
+ continue
267
+ o1 = int(tri_origin_face_id[f1])
268
+ o2 = int(tri_origin_face_id[f2])
269
+ if o1 == o2 and bool(quad_face_mask[o1]):
270
+ exclude_mask[e] = True
271
+
272
+ mask = ~np.isnan(cosphi_abs) & ~exclude_mask
273
+ max_c = float(np.nanmax(cosphi_abs[mask])) if np.any(mask) else 0.0
274
+
275
+ keep_edge_indices = keep_edge_indices[~exclude_mask]
276
+ n_small, _ = ortho.compute_small_links_from_arrays(
277
+ vert[:, 0],
278
+ vert[:, 1],
279
+ tria,
280
+ edge_nodes,
281
+ edge_faces,
282
+ removesmalllinkstrsh=float(removesmalllinkstrsh),
283
+ edge_indices=keep_edge_indices,
284
+ jsferic=jsferic,
285
+ )
286
+
287
+ ok = (max_c <= float(cosphi_threshold) + 1.0e-9) and (int(n_small) == 0)
288
+ return ok, max_c, int(n_small)
289
+
290
+
291
+ def ortho_merge_iterate_dataset(
292
+ ds,
293
+ *,
294
+ outer_iter_max: int = 5,
295
+ cosphi_threshold: float = 0.49,
296
+ removesmalllinkstrsh: float = 0.11,
297
+ buffer_layers: int = 2,
298
+ max_global_iter: int = 6,
299
+ smooth_iter: int = 16,
300
+ enable_edge_flips: bool = True,
301
+ stop_if_no_merge: bool = True,
302
+ ortho_disable_smalllink_logic: bool = True,
303
+ require_both_criteria: bool = False,
304
+ max_recovery_iterations: int = 25,
305
+ recovery_stagnation_break: int = 3,
306
+ outer_stagnation_break: int = 2,
307
+ adaptive_recovery: bool = True,
308
+ recovery_buffer_growth: int = 1,
309
+ recovery_smooth_iter_growth: int = 6,
310
+ recovery_global_iter_growth: int = 1,
311
+ on_state: Optional[Callable[[OrthoMergeStats], None]] = None,
312
+ verbose: bool = True,
313
+ jsferic: int = 1,
314
+ merge_small_links: bool = True,
315
+ fixed: Optional[np.ndarray] = None,
316
+ ) -> tuple:
317
+ """Iteratively orthogonalize and merge circumcenters on a UGRID dataset.
318
+
319
+ Parameters
320
+ ----------
321
+ ds : xarray.Dataset
322
+ Delft3D-FM UGRID mesh dataset.
323
+ outer_iter_max : int, optional
324
+ Number of outer ortho+merge cycles.
325
+ cosphi_threshold : float, optional
326
+ Maximum allowed ``|cos φ|`` passed to :func:`orthogonalize_tria_mesh`.
327
+ removesmalllinkstrsh : float, optional
328
+ Small flow-link threshold.
329
+ buffer_layers : int, optional
330
+ Zone buffer depth for orthogonalization.
331
+ max_global_iter : int, optional
332
+ Global orthogonalization passes per cycle.
333
+ smooth_iter : int, optional
334
+ Smoothing iterations per zone pass.
335
+ enable_edge_flips : bool, optional
336
+ Allow quality-guarded edge flips during orthogonalization.
337
+ stop_if_no_merge : bool, optional
338
+ Stop when a merge cycle reduces no faces.
339
+ ortho_disable_smalllink_logic : bool, optional
340
+ If ``True``, delegate small-link removal to merge (not ortho).
341
+ require_both_criteria : bool, optional
342
+ If ``True``, require dual criteria after main and recovery cycles.
343
+ max_recovery_iterations : int, optional
344
+ Extra recovery cycles when criteria fail.
345
+ recovery_stagnation_break : int, optional
346
+ Stop recovery after this many stagnant cycles (0 = disabled).
347
+ outer_stagnation_break : int, optional
348
+ Stop main loop after this many stagnant outer cycles.
349
+ adaptive_recovery : bool, optional
350
+ Increase recovery effort when criteria remain unmet.
351
+ recovery_buffer_growth, recovery_smooth_iter_growth, recovery_global_iter_growth : int, optional
352
+ Per-recovery growth of buffer/smooth/global iteration counts.
353
+ on_state : callable, optional
354
+ Called with :class:`OrthoMergeStats` after each cycle.
355
+ verbose : bool, optional
356
+ Enable per-zone orthogonalization logs.
357
+ jsferic : int, optional
358
+ ``1`` for spherical lon/lat; ``0`` for planar coordinates.
359
+ merge_small_links : bool, optional
360
+ If ``False``, keep pure triangles (no quad merge step).
361
+ fixed : array_like of int, optional
362
+ Node indices to hold fixed (never displaced). Node numbering is
363
+ preserved by every pipeline step, so the indices stay valid.
364
+
365
+ Returns
366
+ -------
367
+ ds_final : xarray.Dataset
368
+ Updated mesh dataset.
369
+ stats : list of OrthoMergeStats
370
+ Per-cycle statistics.
371
+ """
372
+ import xarray as xr
373
+
374
+ from ..geomesh_util.merge_circumcenters import (
375
+ _rebuild_ds_from_form,
376
+ build_ugrid_arrays_mixed,
377
+ merge_circumcenters,
378
+ )
379
+
380
+ from .orthogonalize import orthogonalize_tria_mesh
381
+
382
+ if not isinstance(ds, xr.Dataset):
383
+ raise TypeError("ds must be an xarray.Dataset")
384
+
385
+ stats: List[OrthoMergeStats] = []
386
+
387
+ # Work on a copy to avoid mutating caller data
388
+ ds_cur = ds.copy(deep=True)
389
+
390
+ def _run_ortho_merge_cycle(
391
+ ds_in,
392
+ *,
393
+ buffer_layers_override: Optional[int] = None,
394
+ max_global_iter_override: Optional[int] = None,
395
+ smooth_iter_override: Optional[int] = None,
396
+ ):
397
+ """One ortho (dual-consistent tris of mixed faces) + merge_circumcenters. Returns updated ds."""
398
+ ds_before_outer = ds_in.copy(deep=True)
399
+ node_x = np.asarray(ds_in["mesh2d_node_x"].values, dtype=np.float64)
400
+ node_y = np.asarray(ds_in["mesh2d_node_y"].values, dtype=np.float64)
401
+ node_z = (
402
+ np.asarray(ds_in["mesh2d_node_z"].values, dtype=np.float64)
403
+ if "mesh2d_node_z" in ds_in
404
+ else np.zeros((node_x.shape[0],), dtype=np.float64)
405
+ )
406
+ vert = np.column_stack([node_x, node_y])
407
+ face_nodes_raw = np.asarray(ds_in["mesh2d_face_nodes"].values, dtype=np.int64)
408
+ start_index = int(ds_in["mesh2d_face_nodes"].attrs.get("start_index", 1))
409
+ face_nodes = _face_nodes_raw_to_0b(face_nodes_raw, start_index)
410
+ faces = _faces_from_face_nodes(face_nodes)
411
+ tria_for_ortho, tri_face_id = _triangulate_faces_for_ortho(vert, faces)
412
+
413
+ bl = int(
414
+ buffer_layers if buffer_layers_override is None else buffer_layers_override
415
+ )
416
+ mgi = int(
417
+ max_global_iter
418
+ if max_global_iter_override is None
419
+ else max_global_iter_override
420
+ )
421
+ si = int(smooth_iter if smooth_iter_override is None else smooth_iter_override)
422
+
423
+ if merge_small_links:
424
+ # Small links are removed by merge_circumcenters below; keep the
425
+ # orthogonalizer focused on |cosphi| only.
426
+ ortho_smalllink_trsh = (
427
+ 1.0e-12 if ortho_disable_smalllink_logic else removesmalllinkstrsh
428
+ )
429
+ else:
430
+ # Triangles-only mode: the orthogonalizer itself must clear small
431
+ # links (guarded flips + circumcenter-separation displacement).
432
+ ortho_smalllink_trsh = removesmalllinkstrsh
433
+ ortho_res = orthogonalize_tria_mesh(
434
+ vert,
435
+ tria_for_ortho,
436
+ cosphi_threshold=cosphi_threshold,
437
+ removesmalllinkstrsh=ortho_smalllink_trsh,
438
+ buffer_layers=bl,
439
+ max_global_iter=mgi,
440
+ smooth_iter=si,
441
+ enable_edge_flips=enable_edge_flips,
442
+ verbose=verbose,
443
+ jsferic=jsferic,
444
+ smalllink_priority=not merge_small_links,
445
+ fixed=fixed,
446
+ )
447
+
448
+ NODE = np.column_stack([ortho_res.vert[:, 0], ortho_res.vert[:, 1], node_z])
449
+ faces_after_ortho = _faces_with_ortho_topology(
450
+ faces,
451
+ tria_for_ortho,
452
+ np.asarray(ortho_res.tria, dtype=np.int64),
453
+ tri_face_id,
454
+ )
455
+ ugrid_arrays = build_ugrid_arrays_mixed(NODE, faces_after_ortho)
456
+ ds_after_ortho = _rebuild_ds_from_form(ds_in, ugrid_arrays)
457
+
458
+ if not merge_small_links:
459
+ return ds_after_ortho, ortho_res, 0, ds_before_outer
460
+
461
+ nfaces_before = int(
462
+ ds_after_ortho.sizes.get(
463
+ "mesh2d_nFaces", ds_after_ortho["mesh2d_face_nodes"].shape[0]
464
+ )
465
+ )
466
+ ds_merged = merge_circumcenters(
467
+ ds_after_ortho, removesmalllinkstrsh=removesmalllinkstrsh, jsferic=jsferic
468
+ )
469
+ nfaces_after = int(
470
+ ds_merged.sizes.get(
471
+ "mesh2d_nFaces", ds_merged["mesh2d_face_nodes"].shape[0]
472
+ )
473
+ )
474
+ merged_this_iter = max(0, nfaces_before - nfaces_after)
475
+
476
+ return ds_merged, ortho_res, merged_this_iter, ds_before_outer
477
+
478
+ outer_stall_limit = max(0, int(outer_stagnation_break))
479
+ outer_stall_count = 0
480
+ for outer in range(int(outer_iter_max)):
481
+ ds_cur, ortho_res, merged_this_iter, ds_before_outer = _run_ortho_merge_cycle(
482
+ ds_cur
483
+ )
484
+
485
+ # Early stop on stagnation: if the same outer-cycle metrics repeat,
486
+ # further global passes are unlikely to help and are expensive.
487
+ if len(stats) > 0:
488
+ prev = stats[-1]
489
+ same_metric = (
490
+ abs(float(ortho_res.max_cosphi) - float(prev.max_cosphi)) <= 1.0e-9
491
+ and int(ortho_res.n_small_flow_links) == int(prev.n_small_flow_links)
492
+ and int(merged_this_iter) == int(prev.merged_this_iter)
493
+ )
494
+ if same_metric:
495
+ outer_stall_count += 1
496
+ if outer_stall_limit > 0 and outer_stall_count >= outer_stall_limit:
497
+ stats.append(
498
+ OrthoMergeStats(
499
+ outer_iter=outer,
500
+ max_cosphi=float(ortho_res.max_cosphi),
501
+ n_small_flow_links=int(ortho_res.n_small_flow_links),
502
+ merged_this_iter=int(merged_this_iter),
503
+ n_zones_orthogonalized=int(
504
+ getattr(ortho_res, "n_zones_orthogonalized", 0)
505
+ ),
506
+ )
507
+ )
508
+ if on_state is not None:
509
+ on_state(stats[-1])
510
+ break
511
+ else:
512
+ outer_stall_count = 0
513
+
514
+ # Guardrail: if no merge happened and orthogonality got worse than previous outer-iter,
515
+ # revert this outer step and stop.
516
+ if (
517
+ merged_this_iter == 0
518
+ and len(stats) > 0
519
+ and float(ortho_res.max_cosphi) > (float(stats[-1].max_cosphi) + 1.0e-9)
520
+ ):
521
+ ds_cur = ds_before_outer
522
+ stats.append(
523
+ OrthoMergeStats(
524
+ outer_iter=outer,
525
+ max_cosphi=float(stats[-1].max_cosphi),
526
+ n_small_flow_links=int(stats[-1].n_small_flow_links),
527
+ merged_this_iter=0,
528
+ n_zones_orthogonalized=int(
529
+ getattr(ortho_res, "n_zones_orthogonalized", 0)
530
+ ),
531
+ )
532
+ )
533
+ if on_state is not None:
534
+ on_state(stats[-1])
535
+ break
536
+ stats.append(
537
+ OrthoMergeStats(
538
+ outer_iter=outer,
539
+ max_cosphi=float(ortho_res.max_cosphi),
540
+ n_small_flow_links=int(ortho_res.n_small_flow_links),
541
+ merged_this_iter=int(merged_this_iter),
542
+ n_zones_orthogonalized=int(
543
+ getattr(ortho_res, "n_zones_orthogonalized", 0)
544
+ ),
545
+ )
546
+ )
547
+ if on_state is not None:
548
+ on_state(stats[-1])
549
+
550
+ # Not meaningful in triangles-only mode (nothing is ever merged).
551
+ if merge_small_links and stop_if_no_merge and merged_this_iter == 0:
552
+ break
553
+
554
+ if bool(require_both_criteria):
555
+ v_chk, t_chk, tri_origin_face_id, quad_face_mask = _fan_vert_tria_from_ds(
556
+ ds_cur
557
+ )
558
+ ok, max_c, n_s = dual_criteria_on_fan_mesh(
559
+ v_chk,
560
+ t_chk,
561
+ tri_origin_face_id,
562
+ quad_face_mask,
563
+ cosphi_threshold=cosphi_threshold,
564
+ removesmalllinkstrsh=removesmalllinkstrsh,
565
+ jsferic=jsferic,
566
+ )
567
+ max_rec = max(0, int(max_recovery_iterations))
568
+ stall_need = int(recovery_stagnation_break)
569
+ prev_metric_key = (round(max_c, 9), int(n_s))
570
+ stall = 0
571
+ r = 0
572
+ while (not ok) and r < max_rec:
573
+ if bool(adaptive_recovery):
574
+ growth_steps = 1 + r + stall
575
+ bl_rec = int(
576
+ max(1, buffer_layers + int(recovery_buffer_growth) * growth_steps)
577
+ )
578
+ mgi_rec = int(
579
+ max(
580
+ 1,
581
+ max_global_iter
582
+ + int(recovery_global_iter_growth) * growth_steps,
583
+ )
584
+ )
585
+ si_rec = int(
586
+ max(
587
+ 1, smooth_iter + int(recovery_smooth_iter_growth) * growth_steps
588
+ )
589
+ )
590
+ else:
591
+ bl_rec = int(buffer_layers)
592
+ mgi_rec = int(max_global_iter)
593
+ si_rec = int(smooth_iter)
594
+
595
+ ds_cur, ortho_res, merged_this_iter, ds_before_outer = (
596
+ _run_ortho_merge_cycle(
597
+ ds_cur,
598
+ buffer_layers_override=bl_rec,
599
+ max_global_iter_override=mgi_rec,
600
+ smooth_iter_override=si_rec,
601
+ )
602
+ )
603
+ # Recovery: no guardrail revert (keep trying); always log cycle.
604
+ stats.append(
605
+ OrthoMergeStats(
606
+ outer_iter=r,
607
+ max_cosphi=float(ortho_res.max_cosphi),
608
+ n_small_flow_links=int(ortho_res.n_small_flow_links),
609
+ merged_this_iter=int(merged_this_iter),
610
+ n_zones_orthogonalized=int(
611
+ getattr(ortho_res, "n_zones_orthogonalized", 0)
612
+ ),
613
+ recovery=True,
614
+ recovery_iter=r,
615
+ )
616
+ )
617
+ if on_state is not None:
618
+ on_state(stats[-1])
619
+ v_chk, t_chk, tri_origin_face_id, quad_face_mask = _fan_vert_tria_from_ds(
620
+ ds_cur
621
+ )
622
+ ok, max_c, n_s = dual_criteria_on_fan_mesh(
623
+ v_chk,
624
+ t_chk,
625
+ tri_origin_face_id,
626
+ quad_face_mask,
627
+ cosphi_threshold=cosphi_threshold,
628
+ removesmalllinkstrsh=removesmalllinkstrsh,
629
+ jsferic=jsferic,
630
+ )
631
+ r += 1
632
+ if stall_need > 0:
633
+ key = (round(max_c, 9), int(n_s))
634
+ if key == prev_metric_key:
635
+ stall += 1
636
+ if stall >= stall_need:
637
+ break
638
+ else:
639
+ stall = 0
640
+ prev_metric_key = key
641
+
642
+ if not ok:
643
+ raise RuntimeError(
644
+ "ortho_merge_iterate_dataset: mesh still violates dual criteria after "
645
+ f"{int(outer_iter_max)} main cycle(s) and {r} recovery cycle(s). "
646
+ f"Required: max|cosφ| <= {cosphi_threshold} and n_small_flow_links == 0 "
647
+ f"(triangle proxy). Got max|cosφ|={max_c:.6f}, n_small={n_s}. "
648
+ "Increase max_recovery_iterations / outer_iter_max, relax thresholds, "
649
+ "or improve the initial mesh."
650
+ )
651
+
652
+ return ds_cur, stats
653
+
654
+
655
+ def print_stats(
656
+ stats: Sequence[OrthoMergeStats], *, print_header: bool = False
657
+ ) -> None:
658
+ """Print a compact ortho+merge progress table.
659
+
660
+ Parameters
661
+ ----------
662
+ stats : sequence of OrthoMergeStats
663
+ Statistics rows to display.
664
+ print_header : bool, optional
665
+ If ``True``, print the column header first.
666
+ """
667
+ if print_header:
668
+ print(" -------------------------------------------------------")
669
+ print(" |STATE.| |MAX|COS(PHI)| |N_SMALL| |N_ZONES|")
670
+ print(" -------------------------------------------------------")
671
+
672
+ for s in stats:
673
+ if getattr(s, "recovery", False):
674
+ head = f"recovery={s.recovery_iter}"
675
+ else:
676
+ # Special markers used by smood for start/end snapshots.
677
+ if int(s.outer_iter) == -1:
678
+ head = "initial"
679
+ elif int(s.outer_iter) == -2:
680
+ head = "final"
681
+ else:
682
+ head = f"outer={s.outer_iter}"
683
+
684
+ print(
685
+ f" {head:<11}{s.max_cosphi:>13.6f}{int(s.n_small_flow_links):>14d}{int(s.n_zones_orthogonalized):>11d}",
686
+ flush=True,
687
+ )
688
+
689
+
690
+ def ortho_merge_iterate_tria(
691
+ vert: np.ndarray,
692
+ tria: np.ndarray,
693
+ *,
694
+ node_z: Optional[np.ndarray] = None,
695
+ outer_iter_max: int = 5,
696
+ cosphi_threshold: float = 0.49,
697
+ removesmalllinkstrsh: float = 0.11,
698
+ buffer_layers: int = 2,
699
+ max_global_iter: int = 6,
700
+ smooth_iter: int = 16,
701
+ enable_edge_flips: bool = True,
702
+ stop_if_no_merge: bool = True,
703
+ ortho_disable_smalllink_logic: bool = True,
704
+ require_both_criteria: bool = False,
705
+ max_recovery_iterations: int = 25,
706
+ recovery_stagnation_break: int = 3,
707
+ outer_stagnation_break: int = 2,
708
+ adaptive_recovery: bool = True,
709
+ recovery_buffer_growth: int = 1,
710
+ recovery_smooth_iter_growth: int = 6,
711
+ recovery_global_iter_growth: int = 1,
712
+ on_state: Optional[Callable[[OrthoMergeStats], None]] = None,
713
+ verbose: bool = True,
714
+ jsferic: int = 1,
715
+ merge_small_links: bool = True,
716
+ fixed: Optional[np.ndarray] = None,
717
+ ) -> tuple:
718
+ """Run the ortho+merge pipeline starting from a pure triangle mesh.
719
+
720
+ Parameters
721
+ ----------
722
+ vert : ndarray of shape (N, 2)
723
+ Node coordinates (lon/lat degrees or planar x/y per ``jsferic``).
724
+ tria : ndarray of shape (T, 3)
725
+ 0-based triangle connectivity.
726
+ node_z : ndarray of shape (N,), optional
727
+ Node elevations; zeros used if ``None``.
728
+ outer_iter_max, cosphi_threshold, removesmalllinkstrsh, buffer_layers,
729
+ max_global_iter, smooth_iter, enable_edge_flips, stop_if_no_merge,
730
+ ortho_disable_smalllink_logic, require_both_criteria,
731
+ max_recovery_iterations, recovery_stagnation_break, outer_stagnation_break,
732
+ adaptive_recovery, recovery_buffer_growth, recovery_smooth_iter_growth,
733
+ recovery_global_iter_growth, on_state, verbose, jsferic, merge_small_links
734
+ Same meaning as in :func:`ortho_merge_iterate_dataset`.
735
+
736
+ Returns
737
+ -------
738
+ vert_out : ndarray of shape (N, 2)
739
+ Updated node coordinates.
740
+ face_nodes_0b : ndarray of shape (F, 4)
741
+ Mixed face-node rows (0-based, ``-1`` fill for triangles).
742
+ stats : list of OrthoMergeStats
743
+ Per-cycle statistics.
744
+ """
745
+ import xarray as xr
746
+
747
+ from ..geomesh_util.grd_util import adcirc2DFlowFM
748
+
749
+ vert = np.asarray(vert, dtype=np.float64)
750
+ tria = np.asarray(tria, dtype=np.int64)
751
+ if vert.ndim != 2 or vert.shape[1] != 2:
752
+ raise ValueError("vert must have shape (N,2)")
753
+ if tria.ndim != 2 or tria.shape[1] != 3:
754
+ raise ValueError("tria must have shape (T,3)")
755
+
756
+ if node_z is None:
757
+ node_z = np.zeros((vert.shape[0],), dtype=np.float64)
758
+ else:
759
+ node_z = np.asarray(node_z, dtype=np.float64).reshape(-1)
760
+ if node_z.shape[0] != vert.shape[0]:
761
+ raise ValueError("node_z must have length N (same as vert)")
762
+
763
+ NODE = np.column_stack([vert[:, 0], vert[:, 1], node_z])
764
+ ds0 = adcirc2DFlowFM(NODE=NODE, EDGE=tria)
765
+ if not isinstance(ds0, xr.Dataset):
766
+ raise TypeError(
767
+ "adcirc2DFlowFM must return an xarray.Dataset in this repository"
768
+ )
769
+
770
+ ds_final, stats = ortho_merge_iterate_dataset(
771
+ ds0,
772
+ outer_iter_max=outer_iter_max,
773
+ cosphi_threshold=cosphi_threshold,
774
+ removesmalllinkstrsh=removesmalllinkstrsh,
775
+ buffer_layers=buffer_layers,
776
+ max_global_iter=max_global_iter,
777
+ smooth_iter=smooth_iter,
778
+ enable_edge_flips=enable_edge_flips,
779
+ stop_if_no_merge=stop_if_no_merge,
780
+ ortho_disable_smalllink_logic=ortho_disable_smalllink_logic,
781
+ require_both_criteria=require_both_criteria,
782
+ max_recovery_iterations=max_recovery_iterations,
783
+ recovery_stagnation_break=recovery_stagnation_break,
784
+ outer_stagnation_break=outer_stagnation_break,
785
+ adaptive_recovery=adaptive_recovery,
786
+ recovery_buffer_growth=recovery_buffer_growth,
787
+ recovery_smooth_iter_growth=recovery_smooth_iter_growth,
788
+ recovery_global_iter_growth=recovery_global_iter_growth,
789
+ on_state=on_state,
790
+ verbose=verbose,
791
+ jsferic=jsferic,
792
+ merge_small_links=merge_small_links,
793
+ fixed=fixed,
794
+ )
795
+
796
+ vert_out = np.column_stack(
797
+ [
798
+ np.asarray(ds_final["mesh2d_node_x"].values, dtype=np.float64),
799
+ np.asarray(ds_final["mesh2d_node_y"].values, dtype=np.float64),
800
+ ]
801
+ )
802
+ face_nodes_1b = np.asarray(ds_final["mesh2d_face_nodes"].values, dtype=np.int64)
803
+ start_index = int(ds_final["mesh2d_face_nodes"].attrs.get("start_index", 1))
804
+ fill = -1
805
+ if start_index == 1:
806
+ # Common convention: invalid nodes are stored as 0 for 1-based meshes.
807
+ face_nodes_0b = np.where(face_nodes_1b > 0, face_nodes_1b - 1, fill)
808
+ else:
809
+ # Assume already 0-based with invalid encoded as -1.
810
+ face_nodes_0b = face_nodes_1b.copy()
811
+
812
+ return vert_out, face_nodes_0b, stats