Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 88 additions & 75 deletions src/underworld3/meshing/spherical.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions tests/test_0502_boundary_integrals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading