Skip to content

Commit 1efba12

Browse files
authored
Merge pull request #225 from underworldcode/feature/boundary-slip-surfaces
Boundary tangent-slip: BoundingSurface objects + mesh.boundary_slip API (step 1, with geometry/ownership hardening)
2 parents 2776ef6 + ac17a34 commit 1efba12

8 files changed

Lines changed: 1013 additions & 0 deletions

File tree

docs/developer/design/boundary-slip-strategy.md

Lines changed: 347 additions & 0 deletions
Large diffs are not rendered by default.

src/underworld3/discretisation/discretisation_mesh.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,11 @@ class replacement_boundaries(Enum):
442442

443443
self.filename = filename
444444
self.boundaries = boundaries
445+
# Bounding-surface objects (tangent-slip + restore), keyed by boundary
446+
# label. SEPARATE from self.boundaries (the persisted gmsh/DMPlex
447+
# labelling, untouched). Populated by analytic-geometry constructors;
448+
# see docs/developer/design/boundary-slip-strategy.md.
449+
self._bounding_surfaces = {}
445450
self.boundary_normals = boundary_normals
446451
self.regions = regions
447452
self.parent = None # Set by extract_region() for submeshes
@@ -2007,6 +2012,182 @@ def Gamma_P1(self):
20072012
self._update_projected_normals()
20082013
return self._projected_normals.sym
20092014

2015+
# ===================================================================
2016+
# Bounding surfaces — per-surface tangent-slip + restore.
2017+
# See docs/developer/design/boundary-slip-strategy.md. SEPARATE from
2018+
# self.boundaries (the persisted gmsh/DMPlex labelling, untouched).
2019+
# ===================================================================
2020+
@property
2021+
def bounding_surfaces(self):
2022+
"""Mapping ``{label: BoundingSurface}`` of this mesh's registered
2023+
bounding-surface objects (tangent-slip + restore).
2024+
2025+
This is a NEW collection, separate from and additional to
2026+
:attr:`boundaries` (the persisted gmsh/DMPlex label ``Enum``, left
2027+
untouched). Populated by analytic-geometry constructors (Annulus,
2028+
SphericalShell, CubedSphere, box meshes); user-extendable via
2029+
:meth:`register_tangent_slip_provider`.
2030+
"""
2031+
if not hasattr(self, "_bounding_surfaces") or self._bounding_surfaces is None:
2032+
self._bounding_surfaces = {}
2033+
return self._bounding_surfaces
2034+
2035+
def register_tangent_slip_provider(self, label, surface):
2036+
"""Install a :class:`BoundingSurface` object for a boundary ``label``
2037+
(separate from the persisted ``mesh.boundaries`` labelling).
2038+
2039+
Lets a user declare a custom analytic surface (e.g. an ellipsoid) the
2040+
constructors don't know about, or replace one (free-surface release).
2041+
"""
2042+
from underworld3.meshing.bounding_surface import BoundingSurface
2043+
if not isinstance(surface, BoundingSurface):
2044+
raise TypeError(
2045+
"surface must be a BoundingSurface; got "
2046+
f"{type(surface).__name__}")
2047+
self.bounding_surfaces[str(label)] = surface
2048+
return surface
2049+
2050+
def _resolve_slip_spec(self, slip_spec):
2051+
"""Resolve a ``slip_spec`` to ``(slip_labels tuple, free_labels set)``.
2052+
2053+
Back-compatible forms: ``True``/``"all"``/``"ring"``/``"box"`` → all
2054+
geometric boundary labels; ``False``/``None`` → none; a label name; a
2055+
list of labels; a ``dict {label: snap_bool}`` (``False`` = free
2056+
surface, slip but do not restore).
2057+
"""
2058+
from underworld3.meshing.smoothing import _auto_pinned_labels
2059+
geo = _auto_pinned_labels(self)
2060+
if slip_spec is None or slip_spec is False:
2061+
return (), set()
2062+
if slip_spec is True:
2063+
return tuple(geo), set()
2064+
if isinstance(slip_spec, dict):
2065+
return tuple(slip_spec.keys()), {k for k, v in slip_spec.items() if not v}
2066+
if isinstance(slip_spec, str):
2067+
s = slip_spec.strip().lower()
2068+
if s in ("true", "on", "1", "all", "ring", "box", "axes", "axis"):
2069+
return tuple(geo), set()
2070+
if s in ("false", "off", "0", "none", ""):
2071+
return (), set()
2072+
return (slip_spec,), set()
2073+
return tuple(slip_spec), set()
2074+
2075+
def restore_to_surface(self, coords, label):
2076+
"""Snap ``coords`` onto the named bounding surface (delegates to the
2077+
surface object's ``restore``)."""
2078+
return self.bounding_surfaces[str(label)].restore(
2079+
numpy.asarray(coords, dtype=float))
2080+
2081+
def tangent_project(self, coords, label, reference):
2082+
"""Tangent-slide ``coords`` (displacement measured from ``reference``)
2083+
on the named bounding surface (delegates to the surface object)."""
2084+
return self.bounding_surfaces[str(label)].tangent_project(
2085+
numpy.asarray(coords, dtype=float),
2086+
numpy.asarray(reference, dtype=float))
2087+
2088+
def boundary_slip(self, slip_spec=True, reference_coords=None,
2089+
boundary_labels=None):
2090+
"""Build ``(is_pinned, project)`` for tangent slip on this mesh's
2091+
bounding surfaces — the orchestrator the metric movers call.
2092+
2093+
See ``docs/developer/design/boundary-slip-strategy.md``. The mesh
2094+
classifies which vertices slip vs pin (the cross-surface concern); each
2095+
surface object owns its tangent-project + restore.
2096+
2097+
A vertex **slips** iff it lies on **exactly one** slip surface that has a
2098+
registered analytic :class:`BoundingSurface`; non-boundary, junction
2099+
(≥2 surfaces), unregistered-surface, or degenerate-normal vertices are
2100+
**pinned** (the step-1 safe default — ``facet`` restore is a follow-up).
2101+
2102+
Parameters
2103+
----------
2104+
slip_spec :
2105+
See :meth:`_resolve_slip_spec`. Default ``True`` (all surfaces).
2106+
reference_coords : ndarray, optional
2107+
Fixed reference vertex positions (local-chart vertex order) that
2108+
displacements are measured from. Defaults to ``mesh.X.coords``.
2109+
boundary_labels : iterable of str, optional
2110+
Boundary labels defining the boundary (``is_bnd``). Defaults to all
2111+
geometric boundary labels; pass a mover's pinned set for parity.
2112+
2113+
Returns
2114+
-------
2115+
(is_pinned, project) : (ndarray[bool], callable)
2116+
``is_pinned`` is the per-vertex pinned mask (local-chart order);
2117+
``project(Y)`` slides+restores the slip vertices of ``Y`` in place
2118+
and returns it.
2119+
"""
2120+
from underworld3.meshing.smoothing import (
2121+
_pinned_mask, _auto_pinned_labels, _owned_vertex_mask)
2122+
2123+
dm = self.dm
2124+
pStart, pEnd = dm.getDepthStratum(0)
2125+
n_verts = pEnd - pStart
2126+
if reference_coords is None:
2127+
reference_coords = numpy.asarray(self.X.coords, dtype=float)
2128+
ref = numpy.asarray(reference_coords, dtype=float)
2129+
2130+
all_labels = (tuple(boundary_labels) if boundary_labels is not None
2131+
else _auto_pinned_labels(self))
2132+
# TODO(follow-up): _pinned_mask expands labels through vertices/edges
2133+
# only, so a 3D boundary label that tags FACES alone (a mesh loaded with
2134+
# markVertices=False) leaves its boundary vertices unmarked. This is a
2135+
# pre-existing limitation of the shared helper used by every mover; the
2136+
# fix (close faces→edges→vertices) belongs with _pinned_mask itself.
2137+
is_bnd = _pinned_mask(dm, all_labels)
2138+
2139+
slip_labels, free_labels = self._resolve_slip_spec(slip_spec)
2140+
surf = self.bounding_surfaces
2141+
# Only labels with a registered analytic surface can slip (step 1).
2142+
usable = [lab for lab in slip_labels if lab in surf]
2143+
masks = {lab: _pinned_mask(dm, (lab,)) for lab in usable}
2144+
count = numpy.zeros(n_verts, dtype=int)
2145+
for m in masks.values():
2146+
count += m.astype(int)
2147+
slip_mask = is_bnd & (count == 1)
2148+
is_pinned = is_bnd & ~slip_mask
2149+
vert_label = numpy.empty(n_verts, dtype=object)
2150+
for lab, m in masks.items():
2151+
vert_label[m & slip_mask] = lab
2152+
2153+
# Project only OWNED slip vertices: the movers halo-sync owned→ghost
2154+
# after calling project(), so a leaf/ghost receives its owner's
2155+
# projected value — modifying non-owned coordinates here is both
2156+
# wasteful and a parallel-safety hazard. (Serial: every vertex is
2157+
# owned, so this is a no-op.) is_pinned stays the full geometric
2158+
# classification, which is rank-consistent for shared vertices.
2159+
slip_b = numpy.nonzero(slip_mask & _owned_vertex_mask(dm))[0]
2160+
if slip_b.size == 0:
2161+
return is_pinned, (lambda Y: Y)
2162+
old_slip = ref[slip_b]
2163+
labels_b = vert_label[slip_b]
2164+
2165+
def project(Y):
2166+
Y = numpy.asarray(Y, dtype=float)
2167+
for lab in usable:
2168+
sel = labels_b == lab
2169+
if not sel.any():
2170+
continue
2171+
bs = surf[lab]
2172+
idx = slip_b[sel]
2173+
slid = bs.tangent_project(Y[idx], old_slip[sel])
2174+
# FREE surfaces (dict spec False) slide but do not restore.
2175+
Y[idx] = slid if lab in free_labels else bs.restore(slid)
2176+
return Y
2177+
2178+
return is_pinned, project
2179+
2180+
def project_to_slip_surface(self, coords, slip_spec=True,
2181+
reference_coords=None, boundary_labels=None):
2182+
"""In-place convenience over :meth:`boundary_slip`: slide+restore the
2183+
slip vertices of ``coords`` (a full local-chart vertex array) and return
2184+
it. For callers that just want coordinates snapped back (a checkpoint
2185+
reload, a diagnostic, the free-surface module)."""
2186+
_is_pinned, project = self.boundary_slip(
2187+
slip_spec, reference_coords=reference_coords,
2188+
boundary_labels=boundary_labels)
2189+
return project(numpy.asarray(coords, dtype=float))
2190+
20102191
@timing.routine_timer_decorator
20112192
def update_lvec(self):
20122193
"""

src/underworld3/meshing/annulus.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,13 @@ class boundary_normals(Enum):
542542
x, y = new_mesh.X
543543
new_mesh._nullspace_rotations = [sympy.Matrix([-y, x])]
544544

545+
# Bounding-surface objects for tangent slip: Upper/Lower are radial about the
546+
# origin at the known radii (see docs/developer/design/boundary-slip-strategy.md).
547+
from underworld3.meshing.bounding_surface import register_radial_surfaces
548+
register_radial_surfaces(
549+
new_mesh, centre=(0.0, 0.0),
550+
label_radius={"Upper": radiusOuter, "Lower": radiusInner})
551+
545552
return new_mesh
546553

547554

0 commit comments

Comments
 (0)