Repository: Deltares/HYDROLIB-core
Pull request #776: Extended list of meshkernel arguments to be passed through hydrolib-core from hydrolib-dhydamo
Author: login: RuudHurkmans
Labels: name: type: feature
description: Brand new functionality
name: domain: mesh
Created: 2025-03-14T09:09:56Z | Merged: 2025-05-13T11:56:20Z

The latest commits do not pass to the original PR (#723 ), hence a new one. Perhaps it is to far behind the main by now.
 Also the unit tests are updated - the number of grid cells in a refined mesh are changed because the meshernel defaults are implemented now. 

#Issues
- Fixes #723

Linked issue #723: Implement ability to pass meshkernel refinement parameters
In net/models.py a function is defined to pass refinement parameter to Meshkernel:
 

 def refine(self, polygon: mk.GeometryList, level: int, min_edge_size: float = 10.0):
        """Refine the mesh within a polygon, by a number of steps (level)   
       
     parameters = mk.MeshRefinementParameters(
            refine_intersected=True,
            use_mass_center_when_refining=False,
            min_edge_size=min_edge_size,
            refinement_type=1,
            connect_hanging_nodes=True,
            account_for_samples_outside_face=False,
            max_refinement_iterations=level,
        )

However, it's not possible to pass most of the parameters from an external function. Is it possible to add 'refine_intersected', "use_mass_center_when_refining", "refinement_type","connect_hanging_nodes" and "account_for_samples_outside_face" as arguments  to two functions:

Mesh2d.refine() and Network.mesh2d_refine_mesh()? This enables setting the parameters from hydrolib. Especially "use_mass_center_when_refining" gives much better results. 
Linked issue comments: user: login: rhutten
created_at: 2025-05-09T07:04:01Z
body: Issue and PR can be closed after wrapping-up #776. See #776 for more information.
author_association: CONTRIBUTOR

Diff:
diff --git a/hydrolib/core/dflowfm/net/models.py b/hydrolib/core/dflowfm/net/models.py
index e8387ff47..7df9d90b6 100644
--- a/hydrolib/core/dflowfm/net/models.py
+++ b/hydrolib/core/dflowfm/net/models.py
@@ -234,7 +234,8 @@ def clip(
         deletemeshoption: mk.DeleteMeshOption = mk.DeleteMeshOption.INSIDE_NOT_INTERSECTED,
         inside=False,
     ) -> None:
-        """Clip the 2D mesh by a polygon. Both outside the exterior and inside the interiors is clipped
+        """Clip the 2D mesh by a polygon. Both outside the exterior and inside the interiors is clipped. It is also possible to clip inside a polygon with holes.
+
 
         Args:
             geometrylist (GeometryList): Polygon stored as GeometryList
@@ -255,30 +256,23 @@ def clip(
             exteriors = [parts[0]]
             interiors = parts[1:]
 
+            # Check if parts are closed
+            for part in exteriors + interiors:
+                if (part.x_coordinates[0], part.y_coordinates[0]) != (
+                    part.x_coordinates[-1],
+                    part.y_coordinates[-1],
+                ):
+                    raise ValueError(
+                        "First and last coordinate of each GeometryList part should match."
+                    )
+
         # Inside
         else:
-            # Check if any polygon contains holes, when clipping inside
-            if geometrylist.inner_outer_separator in geometrylist.x_coordinates:
-                raise NotImplementedError(
-                    "Deleting inside a (Multi)Polygon with holes is not implemented."
-                )
-
             # Get exterior and interiors
             parts = split_by(geometrylist, geometrylist.geometry_separator)
-
             exteriors = parts[:]
             interiors = []
 
-        # Check if parts are closed
-        for part in exteriors + interiors:
-            if (part.x_coordinates[0], part.y_coordinates[0]) != (
-                part.x_coordinates[-1],
-                part.y_coordinates[-1],
-            ):
-                raise ValueError(
-                    "First and last coordinate of each GeometryList part should match."
-                )
-
         # Delete everything outside the (Multi)Polygon
         for exterior in exteriors:
             self.meshkernel.mesh2d_delete(
@@ -295,30 +289,33 @@ def clip(
                 invert_deletion=inside,
             )
 
-    def refine(self, polygon: mk.GeometryList, level: int, min_edge_size: float = 10.0):
+    def refine(
+        self,
+        polygon: mk.GeometryList,
+        level: int,
+        parameters: mk.MeshRefinementParameters = None,
+    ):
         """Refine the mesh within a polygon, by a number of steps (level)
 
         Args:
             polygon (GeometryList): Polygon in which to refine
             level (int): Number of refinement steps
+            parameters (MeshRefinementParameters): object containing the Meshkernel refinement parameters. Default values are taken from meskernel documentation.
         """
-
-        # Check if parts are closed
-        # if not (polygon.x_coordinates[0], polygon.y_coordinates[0]) == (
-        #     polygon.x_coordinates[-1],
-        #     polygon.y_coordinates[-1],
-        # ):
-        #     raise ValueError("First and last coordinate of each GeometryList part should match.")
-
-        parameters = mk.MeshRefinementParameters(
-            refine_intersected=True,
-            use_mass_center_when_refining=False,
-            min_edge_size=min_edge_size,
-            refinement_type=1,
-            connect_hanging_nodes=True,
-            account_for_samples_outside_face=False,
-            max_refinement_iterations=level,
-        )
+        if parameters is None:
+            parameters = mk.MeshRefinementParameters(
+                refine_intersected=True,
+                use_mass_center_when_refining=True,
+                min_edge_size=1.0,
+                refinement_type=2,
+                connect_hanging_nodes=True,
+                account_for_samples_outside_face=True,
+                max_refinement_iterations=1,
+                smoothing_iterations=5,
+                max_courant_time=120.0,
+                directional_refinement=False,
+            )
+        parameters.max_refinement_iterations = level
         self.meshkernel.mesh2d_refine_based_on_polygon(polygon, parameters)
 
 
@@ -667,10 +664,12 @@ def _link_from_1d_to_2d(
         # a bounding polygon or the end points of the 1d mesh.
 
     def _link_from_2d_to_1d_embedded(
-        self, node_mask: np.ndarray, points: mk.GeometryList
+        self, node_mask: np.ndarray, polygons: mk.GeometryList
     ):
         """"""
-        self.meshkernel.contacts_compute_with_points(node_mask=node_mask, points=points)
+        self.meshkernel.contacts_compute_with_points(
+            node_mask=node_mask, polygons=polygons
+        )
 
     def _link_from_2d_to_1d_lateral(
         self,
@@ -1192,9 +1191,12 @@ def mesh2d_clip_mesh(
         )
 
     def mesh2d_refine_mesh(
-        self, polygon: mk.GeometryList, level: int = 1, min_edge_size: float = 10.0
-    ) -> None:
-        self._mesh2d.refine(polygon=polygon, level=level, min_edge_size=min_edge_size)
+        self,
+        polygon: mk.GeometryList,
+        level: int = None,
+        parameters: mk.MeshRefineParameters = None,
+    ):
+        self._mesh2d.refine(polygon=polygon, level=level, parameters=parameters)
 
     def mesh1d_add_branch(
         self,
diff --git a/tests/dflowfm/test_net.py b/tests/dflowfm/test_net.py
index be70ea813..532cecfd4 100644
--- a/tests/dflowfm/test_net.py
+++ b/tests/dflowfm/test_net.py
@@ -6,7 +6,12 @@
 import numpy as np
 import pytest
 import xarray as xr  # dev dependency only
-from meshkernel import DeleteMeshOption, GeometryList, MeshKernel
+from meshkernel import (
+    DeleteMeshOption,
+    GeometryList,
+    MeshKernel,
+    MeshRefinementParameters,
+)
 
 from hydrolib.core.basemodel import BaseModel
 from hydrolib.core.dflowfm.mdu.models import FMModel
@@ -257,6 +262,41 @@ def test_create_clip_2d(deletemeshoption, inside, nnodes, nedgenodes, nfaces):
     assert mesh2d_output.face_x.size == nfaces
 
 
+@pytest.mark.parametrize(
+    "deletemeshoption,inside,nnodes,nedgenodes,nfaces",
+    [
+        (DeleteMeshOption.INSIDE_NOT_INTERSECTED, True, 289, 1032, 228),
+        (DeleteMeshOption.INSIDE_AND_INTERSECTED, True, 177, 584, 116),
+        (DeleteMeshOption.FACES_WITH_INCLUDED_CIRCUMCENTERS, True, 233, 808, 172),
+    ],
+)
+def test_create_clip_inside_with_hole(
+    deletemeshoption, inside, nnodes, nedgenodes, nfaces
+):
+
+    clipgeom = GeometryList(
+        x_coordinates=np.array(
+            [-5.0, -5.0, 5.0, 5.0, -5.0, -998.0, 2.0, -2.0, -2.0, 2.0, 2.0]
+        ),
+        y_coordinates=np.array(
+            [-5.0, 5.0, 5.0, -5.0, -5.0, -998.0, 2.0, 2.0, -2.0, -2.0, 2.0]
+        ),
+        inner_outer_separator=-998.0,
+        geometry_separator=-999.0,
+    )
+
+    # Define polygon
+    bbox = (-8.0, -8.0, 8.0, 8.0)
+    mesh2d = Mesh2d(meshkernel=MeshKernel())
+    mesh2d.create_rectilinear(extent=bbox, dx=1.0, dy=1.0)
+
+    mesh2d.clip(clipgeom, deletemeshoption=deletemeshoption, inside=inside)
+    mesh2d_output = mesh2d.get_mesh2d()
+    assert mesh2d_output.node_x.size == nnodes
+    assert mesh2d_output.edge_nodes.size == nedgenodes  # 2x nedges
+    assert mesh2d_output.face_x.size == nfaces
+
+
 def test_create_refine_2d():
 
     polygon = GeometryList(
@@ -270,19 +310,23 @@ def test_create_refine_2d():
     mesh2d = Mesh2d(meshkernel=MeshKernel())
     # Create within bounding box
     mesh2d.create_rectilinear(extent=bbox, dx=0.5, dy=0.75)
+
+    parameters = MeshRefinementParameters(
+        min_edge_size=0.1,
+    )
     # Refine
-    mesh2d.refine(polygon, level=1, min_edge_size=0.1)
+    mesh2d.refine(polygon, parameters=parameters, level=1)
 
     mesh2d_output = mesh2d.get_mesh2d()
 
-    assert mesh2d_output.node_x.size == 114
-    assert mesh2d_output.edge_nodes.size == 426
+    assert mesh2d_output.node_x.size == 95
+    assert mesh2d_output.edge_nodes.size == 362
 
     # check fnc
     fnc = mesh2d.mesh2d_face_nodes
-    assert fnc.shape == (100, 4)
+    assert fnc.shape == (87, 4)
     fnc_fill_value = np.iinfo(np.int32).min
-    assert (fnc == fnc_fill_value).sum() == 12  # amount of triangles
+    assert (fnc == fnc_fill_value).sum() == 19  # amount of triangles
 
 
 cases = [


Reviews:
user: login: MAfarrag
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-11T08:53:49Z
user: login: RuudHurkmans
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T08:58:50Z
user: login: RuudHurkmans
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T09:03:05Z
user: login: RuudHurkmans
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T09:04:53Z

Review comments:
diff_hunk: @@ -295,29 +279,49 @@ def clip(
                 invert_deletion=inside,
             )
 
-    def refine(self, polygon: mk.GeometryList, level: int, min_edge_size: float = 10.0):
+    def refine(
+        self,
path: hydrolib/core/dflowfm/net/models.py
user: login: MAfarrag
body: These are quite a lot of passed parameters to the internal called function `MeshRefinementParameters` passing that much new parameters changes the signature of the `refine` function and is likely to be extended more in the future, so explicitly adding new parameters to the function is not the best way.
```
refine_intersected: bool=False,
use_mass_center_when_refining: bool=True,
refinement_type: int=2,
connect_hanging_nodes: bool=True,
account_for_samples_outside_face: bool=False,
min_edge_size: float = 0.5,
smoothing_iterations=5,
max_courant_time=120.0,
directional_refinement=False,
```

created_at: 2025-04-11T08:34:49Z
author_association: MEMBER
diff_hunk: @@ -295,29 +279,49 @@ def clip(
                 invert_deletion=inside,
             )
 
-    def refine(self, polygon: mk.GeometryList, level: int, min_edge_size: float = 10.0):
+    def refine(
+        self,
path: hydrolib/core/dflowfm/net/models.py
user: login: MAfarrag
body: I suggest creating a `dataclass` called `MeshRefinementOptions` (or even a pydantic model) that groups all of these parameters.

```python
from dataclasses import dataclass

@dataclass
class MeshRefinementOptions:
    refine_intersected: bool = False
    use_mass_center_when_refining: bool = True
    refinement_type: int = 2
    connect_hanging_nodes: bool = True
    account_for_samples_outside_face: bool = False
    min_edge_size: float = 0.5
    smoothing_iterations: int = 5
    max_courant_time: float = 120.0
    directional_refinement: bool = False
```
then update the `refine` method call to be 

```
def refine(..., refinement_options: MeshRefinementOptions = MeshRefinementOptions()):
    ...
```
- so any future improvements to the `refine` method can be added to the `MeshRefinementOptions ` class and the API does not change.

created_at: 2025-04-11T08:39:01Z
author_association: MEMBER
diff_hunk: @@ -1192,9 +1198,32 @@ def mesh2d_clip_mesh(
         )
 
     def mesh2d_refine_mesh(
-        self, polygon: mk.GeometryList, level: int = 1, min_edge_size: float = 10.0
-    ) -> None:
-        self._mesh2d.refine(polygon=polygon, level=level, min_edge_size=min_edge_size)
+        self,
+        polygon: mk.GeometryList,
+        level: int = None,
+        refine_intersected: bool = False,
path: hydrolib/core/dflowfm/net/models.py
user: login: MAfarrag
body: adjust this based on the changes suggested above
created_at: 2025-04-11T08:48:21Z
author_association: MEMBER
diff_hunk: @@ -257,28 +257,12 @@ def clip(
 
         # Inside
         else:
-            # Check if any polygon contains holes, when clipping inside
-            if geometrylist.inner_outer_separator in geometrylist.x_coordinates:
-                raise NotImplementedError(
-                    "Deleting inside a (Multi)Polygon with holes is not implemented."
-                )
-
path: hydrolib/core/dflowfm/net/models.py
user: login: MAfarrag
body: Why the above check is removed ? the check was there to inform the user that ```Deleting inside a (Multi)Polygon with holes is not implemented."```   it there any update to this? if so please add it to the docstring, or document it somewhere.  
created_at: 2025-04-11T08:52:09Z
author_association: MEMBER
diff_hunk: @@ -257,28 +257,12 @@ def clip(
 
         # Inside
         else:
-            # Check if any polygon contains holes, when clipping inside
-            if geometrylist.inner_outer_separator in geometrylist.x_coordinates:
-                raise NotImplementedError(
-                    "Deleting inside a (Multi)Polygon with holes is not implemented."
-                )
-
             # Get exterior and interiors
             parts = split_by(geometrylist, geometrylist.geometry_separator)
 
             exteriors = parts[:]
             interiors = []
 
-        # Check if parts are closed
-        for part in exteriors + interiors:
-            if (part.x_coordinates[0], part.y_coordinates[0]) != (
-                part.x_coordinates[-1],
-                part.y_coordinates[-1],
-            ):
-                raise ValueError(
-                    "First and last coordinate of each GeometryList part should match."
-                )
-
         # Delete everything outside the (Multi)Polygon
path: hydrolib/core/dflowfm/net/models.py
user: login: MAfarrag
body: again here about the check, if the check is not valid anymore, document the new behaviour of the function in the docstring, and provide a test for the new behaviour.
created_at: 2025-04-11T08:53:32Z
author_association: MEMBER
diff_hunk: @@ -295,29 +279,49 @@ def clip(
                 invert_deletion=inside,
             )
 
-    def refine(self, polygon: mk.GeometryList, level: int, min_edge_size: float = 10.0):
+    def refine(
+        self,
path: hydrolib/core/dflowfm/net/models.py
user: login: RuudHurkmans
body: Meshkernel already contains a class with MeshRefineParameters, that contains exactly these changes. I use this class and pass it from hydrolib-dhydamo. The call now looks like

def refine(...,  level: int,   parameters: mk.MeshRefinementParameters=None):

if not passed, meshkernel defaults are adopted. I kept one separate argument (level) which is not optional and overwrites  the corresponding MeshRefineMentParameter.
created_at: 2025-05-08T08:58:50Z
author_association: COLLABORATOR
diff_hunk: @@ -257,28 +257,12 @@ def clip(
 
         # Inside
         else:
-            # Check if any polygon contains holes, when clipping inside
-            if geometrylist.inner_outer_separator in geometrylist.x_coordinates:
-                raise NotImplementedError(
-                    "Deleting inside a (Multi)Polygon with holes is not implemented."
-                )
-
path: hydrolib/core/dflowfm/net/models.py
user: login: RuudHurkmans
body: Clipping inside polygon with holes is a very common operation in our model building and it works fine apart from the check. I modified the function and the docstring and added a test for 'clipping_inside_polygon_with holes'.
created_at: 2025-05-08T09:03:05Z
author_association: COLLABORATOR
diff_hunk: @@ -257,28 +257,12 @@ def clip(
 
         # Inside
         else:
-            # Check if any polygon contains holes, when clipping inside
-            if geometrylist.inner_outer_separator in geometrylist.x_coordinates:
-                raise NotImplementedError(
-                    "Deleting inside a (Multi)Polygon with holes is not implemented."
-                )
-
             # Get exterior and interiors
             parts = split_by(geometrylist, geometrylist.geometry_separator)
 
             exteriors = parts[:]
             interiors = []
 
-        # Check if parts are closed
-        for part in exteriors + interiors:
-            if (part.x_coordinates[0], part.y_coordinates[0]) != (
-                part.x_coordinates[-1],
-                part.y_coordinates[-1],
-            ):
-                raise ValueError(
-                    "First and last coordinate of each GeometryList part should match."
-                )
-
         # Delete everything outside the (Multi)Polygon
path: hydrolib/core/dflowfm/net/models.py
user: login: RuudHurkmans
body: I put the check back but only in case of clipping outside. If inside=True, the polygon might contain a hole and contains an exterior and an interior. This is clipped in one operation leading to the correct result. I also added a test, as noted before.
created_at: 2025-05-08T09:04:52Z
author_association: COLLABORATOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-03-14T09:10:59Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Deltares_HYDROLIB-core&pullRequest=776) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=Deltares_HYDROLIB-core&pullRequest=776&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Deltares_HYDROLIB-core&pullRequest=776&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Deltares_HYDROLIB-core&pullRequest=776&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=Deltares_HYDROLIB-core&pullRequest=776&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Deltares_HYDROLIB-core&pullRequest=776&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Deltares_HYDROLIB-core&pullRequest=776)


author_association: NONE
user: login: rhutten
created_at: 2025-05-09T06:59:59Z
body: @MAfarrag, Could you check the changes of Ruud?  He implemented your review comments.
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-05-13T11:48:10Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Deltares_HYDROLIB-core&pullRequest=776) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=Deltares_HYDROLIB-core&pullRequest=776&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Deltares_HYDROLIB-core&pullRequest=776&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Deltares_HYDROLIB-core&pullRequest=776&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=Deltares_HYDROLIB-core&pullRequest=776&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Deltares_HYDROLIB-core&pullRequest=776&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Deltares_HYDROLIB-core&pullRequest=776)


author_association: NONE