From c2b0c180bbf3f95c6d8f9e7a9b97048839052045 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Tue, 16 Jun 2026 07:39:53 +0530 Subject: [PATCH 1/2] Fix spherical shell internal boundary labels Rework SphericalShellInternalBoundary mesh construction so the shell volume is retained, the internal spherical surface is embedded, and duplicate internal geometry is removed before meshing. The previous nested-sphere fragment path could leave the Lower boundary unusable for BdIntegral; the benchmark probe observed lower_area=0.0 even though the Stokes solve converged. Add a boundary-integral regression test that checks nonzero, close-to-analytic Lower, Internal, and Upper surface areas. Validation: ./uw build passed. py_compile passed for src/underworld3/meshing/spherical.py. pytest -q tests/test_0502_boundary_integrals.py::test_bd_integral_spherical_internal_boundary_areas passed. 005_internal_boundary_delta_probe.py passed with -uw_mesh_source uw3_builtin for serial Nitsche, serial constrained, 8-rank Nitsche, and 8-rank constrained runs. --- src/underworld3/meshing/spherical.py | 151 +++++++++++++------------- tests/test_0502_boundary_integrals.py | 46 ++++++++ 2 files changed, 122 insertions(+), 75 deletions(-) diff --git a/src/underworld3/meshing/spherical.py b/src/underworld3/meshing/spherical.py index fb03e1b8..4e7288d9 100644 --- a/src/underworld3/meshing/spherical.py +++ b/src/underworld3/meshing/spherical.py @@ -580,96 +580,97 @@ class regions(Enum): gmsh.option.setNumber("General.Verbosity", gmsh_verbosity) gmsh.model.add("SphereShell_with_Internal_Surface") - # Create three concentric spheres and use OCC fragment to split - # into two non-overlapping shell volumes sharing the internal surface - ball_outer = gmsh.model.occ.addSphere(0, 0, 0, radiusOuter) - ball_internal = gmsh.model.occ.addSphere(0, 0, 0, radiusInternal) - ball_inner = gmsh.model.occ.addSphere(0, 0, 0, radiusInner) - - # Fragment creates non-overlapping pieces from the boolean intersection - out_dimtags, out_map = gmsh.model.occ.fragment( - [(3, ball_outer)], - [(3, ball_internal), (3, ball_inner)], + # Create the spherical shell volume. + outer = gmsh.model.occ.addSphere(0.0, 0.0, 0.0, radiusOuter) + inner = gmsh.model.occ.addSphere(0.0, 0.0, 0.0, radiusInner) + gmsh.model.occ.cut( + [(3, outer)], + [(3, inner)], + removeObject=True, + removeTool=True, + ) + + # Create an internal shell only to obtain a clean spherical surface at + # radiusInternal. That surface is embedded into the shell volume below; + # the duplicate volume and duplicate lower surface are removed before + # meshing. + internal = gmsh.model.occ.addSphere(0.0, 0.0, 0.0, radiusInternal) + inner_copy = gmsh.model.occ.addSphere(0.0, 0.0, 0.0, radiusInner) + gmsh.model.occ.cut( + [(3, internal)], + [(3, inner_copy)], + removeObject=True, + removeTool=True, ) gmsh.model.occ.synchronize() gmsh.option.setNumber("Mesh.CharacteristicLengthMax", cellSize) - # Identify volumes and surfaces by bounding box - # For a sphere, bbox diagonal = sqrt(3) * radius - volumes = gmsh.model.getEntities(3) - surfaces = gmsh.model.getEntities(2) - def bbox_radius(dimtag): - """Estimate the sphere radius from a bounding box diagonal.""" + """Estimate a concentric sphere radius from the bounding box.""" bb = gmsh.model.get_bounding_box(dimtag[0], dimtag[1]) return np.sqrt(bb[3]**2 + bb[4]**2 + bb[5]**2) / np.sqrt(3.0) - inner_vols = [] - outer_vols = [] - solid_ball_vols = [] # r < radiusInner — to be removed - - for vol in volumes: - r_est = bbox_radius(vol) - if np.isclose(r_est, radiusInner, atol=cellSize): - solid_ball_vols.append(vol) - elif np.isclose(r_est, radiusInternal, atol=cellSize): - inner_vols.append(vol) - elif np.isclose(r_est, radiusOuter, atol=cellSize): - outer_vols.append(vol) - - # Remove the solid inner ball (r < radiusInner) - if solid_ball_vols: - gmsh.model.occ.remove(solid_ball_vols, recursive=True) - gmsh.model.occ.synchronize() - - # Re-query after removal volumes = gmsh.model.getEntities(3) - surfaces = gmsh.model.getEntities(2) + shell_vols = [vol for vol in volumes if np.isclose(bbox_radius(vol), radiusOuter, atol=cellSize * 0.5)] + duplicate_vols = [vol for vol in volumes if np.isclose(bbox_radius(vol), radiusInternal, atol=cellSize * 0.5)] + + if len(shell_vols) != 1: + raise RuntimeError( + "Could not identify the spherical-shell volume while building " + "SphericalShellInternalBoundary." + ) - # Classify surfaces by bounding box radius - for surface in surfaces: + shell_vol = shell_vols[0] + shell_boundary = { + dimtag[1] + for dimtag in gmsh.model.getBoundary([shell_vol], oriented=False, recursive=False) + if dimtag[0] == 2 + } + + lower_surface_tags = [] + internal_surface_tags = [] + upper_surface_tags = [] + duplicate_lower_tags = [] + + for surface in gmsh.model.getEntities(2): + surface_tag = surface[1] r_est = bbox_radius(surface) if np.isclose(r_est, radiusInner, atol=cellSize * 0.5): - gmsh.model.addPhysicalGroup( - surface[0], [surface[1]], - boundaries.Lower.value, name=boundaries.Lower.name, - ) + if surface_tag in shell_boundary: + lower_surface_tags.append(surface_tag) + else: + duplicate_lower_tags.append(surface_tag) elif np.isclose(r_est, radiusOuter, atol=cellSize * 0.5): - gmsh.model.addPhysicalGroup( - surface[0], [surface[1]], - boundaries.Upper.value, name=boundaries.Upper.name, - ) + upper_surface_tags.append(surface_tag) elif np.isclose(r_est, radiusInternal, atol=cellSize * 0.5): - gmsh.model.addPhysicalGroup( - surface[0], [surface[1]], - boundaries.Internal.value, name=boundaries.Internal.name, - ) - - # Classify remaining volumes into Inner and Outer - inner_vol_tags = [v[1] for v in inner_vols if v not in solid_ball_vols] - outer_vol_tags = [v[1] for v in outer_vols] - # Re-classify from current volumes in case tags changed after removal - inner_vol_tags = [] - outer_vol_tags = [] - for vol in volumes: - r_est = bbox_radius(vol) - if r_est < radiusInternal + cellSize * 0.5: - inner_vol_tags.append(vol[1]) - else: - outer_vol_tags.append(vol[1]) - - # Region physical groups - if inner_vol_tags: - gmsh.model.addPhysicalGroup(3, inner_vol_tags, - regions.Inner.value, name=regions.Inner.name) - if outer_vol_tags: - gmsh.model.addPhysicalGroup(3, outer_vol_tags, - regions.Outer.value, name=regions.Outer.name) - - # Combined elements group - all_vol_tags = inner_vol_tags + outer_vol_tags - gmsh.model.addPhysicalGroup(3, all_vol_tags, 99999, "Elements") + internal_surface_tags.append(surface_tag) + + if not lower_surface_tags or not upper_surface_tags or not internal_surface_tags: + raise RuntimeError( + "Could not identify Lower, Internal, and Upper spherical surfaces " + "while building SphericalShellInternalBoundary." + ) + + gmsh.model.mesh.embed(2, internal_surface_tags, shell_vol[0], shell_vol[1]) + + remove_dimtags = duplicate_vols + [(2, tag) for tag in duplicate_lower_tags] + if remove_dimtags: + gmsh.model.remove_entities(remove_dimtags, recursive=False) + gmsh.model.occ.remove(remove_dimtags, recursive=False) + gmsh.model.occ.synchronize() + + gmsh.model.addPhysicalGroup( + 2, lower_surface_tags, boundaries.Lower.value, name=boundaries.Lower.name + ) + gmsh.model.addPhysicalGroup( + 2, internal_surface_tags, boundaries.Internal.value, name=boundaries.Internal.name + ) + gmsh.model.addPhysicalGroup( + 2, upper_surface_tags, boundaries.Upper.value, name=boundaries.Upper.name + ) + + gmsh.model.addPhysicalGroup(shell_vol[0], [shell_vol[1]], 99999, "Elements") gmsh.model.mesh.generate(3) gmsh.write(uw_filename) diff --git a/tests/test_0502_boundary_integrals.py b/tests/test_0502_boundary_integrals.py index 0cd9e18d..1721db7d 100644 --- a/tests/test_0502_boundary_integrals.py +++ b/tests/test_0502_boundary_integrals.py @@ -293,6 +293,52 @@ def test_bd_integral_annulus_internal_normal_tangential(): assert abs(value) < 0.05, f"Expected ~0, got {value}" +# --- Spherical shell internal boundary tests --- + +from underworld3.meshing import SphericalShellInternalBoundary + +_R_SHELL_INNER = 0.55 +_R_SHELL_INTERNAL = 0.775 +_R_SHELL_OUTER = 1.0 +_mesh_spherical_internal = None + + +def _get_spherical_internal_mesh(): + global _mesh_spherical_internal + if _mesh_spherical_internal is None: + _mesh_spherical_internal = SphericalShellInternalBoundary( + radiusOuter=_R_SHELL_OUTER, + radiusInternal=_R_SHELL_INTERNAL, + radiusInner=_R_SHELL_INNER, + cellSize=0.25, + degree=1, + qdegree=2, + ) + uw.discretisation.MeshVariable( + "T_spherical_internal", _mesh_spherical_internal, 1, degree=1 + ) + return _mesh_spherical_internal + + +def test_bd_integral_spherical_internal_boundary_areas(): + """SphericalShellInternalBoundary preserves Lower/Internal/Upper labels.""" + + mesh_spherical = _get_spherical_internal_mesh() + expected_areas = { + "Lower": 4.0 * np.pi * _R_SHELL_INNER**2, + "Internal": 4.0 * np.pi * _R_SHELL_INTERNAL**2, + "Upper": 4.0 * np.pi * _R_SHELL_OUTER**2, + } + + for boundary, expected in expected_areas.items(): + value = uw.maths.BdIntegral(mesh_spherical, fn=1.0, boundary=boundary).evaluate() + relative_error = abs(value - expected) / expected + assert relative_error < 0.06, ( + f"{boundary} area should be close to {expected:.4f}; " + f"got {value:.4f} (relative error {relative_error:.3f})" + ) + + def _build_spherical_shell_for_integrals(): from underworld3.meshing import SphericalShell From 55566d980d13466b2c591fc8d3e0426f3294262e Mon Sep 17 00:00:00 2001 From: Tyagi Date: Tue, 16 Jun 2026 08:03:39 +0530 Subject: [PATCH 2/2] Polish spherical shell internal boundary validation Add explicit radius ordering validation for SphericalShellInternalBoundary and wrap the entity-selection comprehensions introduced by the internal-boundary label fix. Validation: py_compile passed for src/underworld3/meshing/spherical.py and test_bd_integral_spherical_internal_boundary_areas passed. --- src/underworld3/meshing/spherical.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/underworld3/meshing/spherical.py b/src/underworld3/meshing/spherical.py index 4e7288d9..2ea99fa9 100644 --- a/src/underworld3/meshing/spherical.py +++ b/src/underworld3/meshing/spherical.py @@ -571,9 +571,13 @@ class regions(Enum): else: uw_filename = filename - # Check if r_i is greater than 0 if radiusInner <= 0: raise ValueError("The inner radius must be greater than 0.") + if not radiusInner < radiusInternal < radiusOuter: + raise ValueError( + "SphericalShellInternalBoundary requires " + "radiusInner < radiusInternal < radiusOuter." + ) if uw.mpi.rank == 0: gmsh.initialize() @@ -612,8 +616,16 @@ def bbox_radius(dimtag): return np.sqrt(bb[3]**2 + bb[4]**2 + bb[5]**2) / np.sqrt(3.0) volumes = gmsh.model.getEntities(3) - shell_vols = [vol for vol in volumes if np.isclose(bbox_radius(vol), radiusOuter, atol=cellSize * 0.5)] - duplicate_vols = [vol for vol in volumes if np.isclose(bbox_radius(vol), radiusInternal, atol=cellSize * 0.5)] + shell_vols = [ + vol + for vol in volumes + if np.isclose(bbox_radius(vol), radiusOuter, atol=cellSize * 0.5) + ] + duplicate_vols = [ + vol + for vol in volumes + if np.isclose(bbox_radius(vol), radiusInternal, atol=cellSize * 0.5) + ] if len(shell_vols) != 1: raise RuntimeError(