diff --git a/src/underworld3/meshing/spherical.py b/src/underworld3/meshing/spherical.py index fb03e1b8..2ea99fa9 100644 --- a/src/underworld3/meshing/spherical.py +++ b/src/underworld3/meshing/spherical.py @@ -571,105 +571,118 @@ 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() 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) + # 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, + ) - # 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 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