From 7f83f933333293946266e4a1ef9aafcf1b23f944 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 15 Oct 2026 07:34:44 +1100 Subject: [PATCH 01/59] Thin sheet: a prescribed normal velocity, and the cold-start lift of a datum-only solve ThinSheet.normal_velocity sets the datum of the sheet-tangency constraint, so a sheet on a manifold can move along its normal (the radial motion of a tidally flexed shell over an ocean) and its in-plane strain rate carries the curvature term through the nodal normals. In the rotated nonlinear loop a cold start whose only forcing is the prescribed datum had a zero residual at u = 0 and returned converged in zero iterations before the lift was applied; the convergence test now waits for the lift and takes its reference scale from the lifted iterate. tests/test_1092: breathing (u = W rhat exactly, stress 6 eta W P) and the degree-2 membrane closed form (tangential velocity 3/11 of the radial pattern's surface gradient; 7.2, 3.4, 2.5 percent at h 0.3, 0.2, 0.15). scripts/sessions/tidal_shell/tidal_shell.py: the eccentricity-tide driver (degree 2, viscous or Maxwell sheet, collective diagnostics against the closed form, Mollweide maps). Underworld development team with AI support from Claude Code --- scripts/sessions/tidal_shell/tidal_shell.py | 185 ++++++++++++++++++ scripts/test.sh | 2 +- src/underworld3/systems/thin_sheet.py | 27 +++ src/underworld3/utilities/rotated_bc.py | 9 +- .../test_1092_thin_sheet_prescribed_normal.py | 85 ++++++++ 5 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 scripts/sessions/tidal_shell/tidal_shell.py create mode 100644 tests/test_1092_thin_sheet_prescribed_normal.py diff --git a/scripts/sessions/tidal_shell/tidal_shell.py b/scripts/sessions/tidal_shell/tidal_shell.py new file mode 100644 index 000000000..3d530a879 --- /dev/null +++ b/scripts/sessions/tidal_shell/tidal_shell.py @@ -0,0 +1,185 @@ +"""Global tidal response of a thin (visco)elastic shell on a sphere. + +The shell floats on an ocean: no basal drag, and its radial motion is the tide's, +w = d/dt [h2 V / g], prescribed as the normal velocity of the sheet (the datum of +the tangency constraint). The sheet solves for the tangential velocity under the +plane-stress membrane closure; the strain rate carries the curvature term w/R P. + +Non-dimensional: R = 1, orbital period 1, tidal radial amplitude A (the h2 V/g +scale), shear modulus 1, Maxwell time De (= eta / mu) in periods. Eccentricity +tide, synchronous rotation, degree 2 (Wahr et al. 2009 pattern): + + W(x, t) = A [ -3/2 P20 cos(nt) + 3/4 ( 3 (x^2 - y^2) cos(nt) + 8 x y sin(nt) ) ], + P20 = (3 z^2 - 1) / 2, n = 2 pi, + +with the sub-planet point on +x and the spin axis +z. Closed form for the viscous +limit on a uniform membrane: a degree-2 radial pattern W Y2 gives the tangential +velocity (3/11) W grad_s Y2, which is checked every step (--check). + +Parallel-safe: every diagnostic is a collective reduction; the maps are a +collective evaluation on a longitude-latitude grid drawn on rank 0. +""" +import argparse +import json +import os +import time + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3.utilities.rotated_bc import SHEET_TANGENCY + +ap = argparse.ArgumentParser() +ap.add_argument("--out", required=True) +ap.add_argument("--h", type=float, default=0.15, help="cell size on the unit sphere") +ap.add_argument("--A", type=float, default=0.01, help="radial tidal amplitude (h2 V/g scale)") +ap.add_argument("--De", type=float, default=0.0, help="Maxwell time in periods; 0: viscous sheet") +ap.add_argument("--eta", type=float, default=1.0, help="viscosity of the viscous sheet (De = 0)") +ap.add_argument("--L", type=float, default=0.08, help="shell thickness / R (Enceladus ~ 20/252)") +ap.add_argument("--periods", type=float, default=2.0) +ap.add_argument("--steps_per_period", type=int, default=32) +ap.add_argument("--tol", type=float, default=1e-8) +ap.add_argument("--check", type=int, default=1, help="compare with the membrane closed form each step") +ap.add_argument("--fig_every", type=int, default=8) +ap.add_argument("--verbose", action="store_true") +args = ap.parse_args() + +comm = uw.mpi.comm +rank0 = uw.mpi.rank == 0 +os.makedirs(args.out, exist_ok=True) + + +def log(*a): + if rank0: + print(*a, flush=True) + + +# ----------------------------------------------------------------------------- mesh and sheet +sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=args.h, degree=1, qdegree=5) +x, y, z = sphere.X +sheet = uw.systems.ThinSheet(sphere) +if args.De > 0: + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd", objective_rate="jaumann") + sheet.constitutive_model.Parameters.shear_modulus = 1.0 + sheet.constitutive_model.Parameters.shear_viscosity_0 = args.De # Maxwell time De periods +else: + sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = args.eta +sheet.thickness = args.L +sheet.tolerance = args.tol + +n_orb = 2 * sympy.pi +P20 = (3 * z ** 2 - 1) / 2 +pattern_c = -sympy.Rational(3, 2) * P20 + sympy.Rational(9, 4) * (x ** 2 - y ** 2) # multiplies cos(nt) +pattern_s = 6 * x * y # multiplies sin(nt) + + +def radial_velocity(t): + """w = dW/dt at time t (a sympy expression of position).""" + c, s = float(np.cos(2 * np.pi * t)), float(np.sin(2 * np.pi * t)) + return args.A * 2 * np.pi * (-pattern_c * s + pattern_s * c) + + +# closed form of the tangential velocity for the viscous membrane: (3/11) grad_s of the +# degree-2 radial pattern (both patterns are degree-2 harmonics on the unit sphere) +X = sympy.Matrix([x, y, z]) +rhat = X / sympy.sqrt(x ** 2 + y ** 2 + z ** 2) +P = sympy.eye(3) - rhat * rhat.T + + +def grad_s(f): + return P * sympy.Matrix([f.diff(v) for v in (x, y, z)]) + + +v_c = sympy.Rational(3, 11) * grad_s(pattern_c) +v_s = sympy.Rational(3, 11) * grad_s(pattern_s) + +# collective diagnostics: node arrays reduced over ranks +coords = np.asarray(sheet.u.coords) +rh = coords / np.linalg.norm(coords, axis=1)[:, None] +from mpi4py import MPI # reductions only; the solver handles the rest + +# the map grid (collective evaluation, drawn on rank 0) +lon = np.linspace(-np.pi, np.pi, 181) +lat = np.linspace(-np.pi / 2, np.pi / 2, 91) +LON, LAT = np.meshgrid(lon, lat) +grid = np.column_stack([(np.cos(LAT) * np.cos(LON)).ravel(), (np.cos(LAT) * np.sin(LON)).ravel(), np.sin(LAT).ravel()]) + + +def diagnostics(t): + u = np.asarray(sheet.u.array[:, 0, :]) + un = (u * rh).sum(axis=1) + ut = u - un[:, None] * rh + w_ex = np.asarray(uw.function.evaluate(radial_velocity(t), coords)).ravel() if coords.shape[0] else np.zeros(0) + c, s = np.cos(2 * np.pi * t), np.sin(2 * np.pi * t) + v_ex = np.asarray(uw.function.evaluate(args.A * 2 * np.pi * (-v_c * s + v_s * c), coords)).reshape(-1, 3) if coords.shape[0] else np.zeros((0, 3)) + loc = np.array([float((ut ** 2).sum()), float(((ut - v_ex) ** 2).sum()), float((v_ex ** 2).sum()), + float(np.abs(un - w_ex).max()) if un.size else 0.0, float(coords.shape[0])]) + tot = np.zeros_like(loc) + comm.Allreduce(loc, tot, op=MPI.SUM) + nrm = comm.allreduce(loc[3], op=MPI.MAX) + n = max(tot[4], 1.0) + return {"vt_rms": float(np.sqrt(tot[0] / n)), "vex_rms": float(np.sqrt(tot[2] / n)), + "rel_err_membrane": float(np.sqrt(tot[1] / max(tot[2], 1e-300))), "normal_datum_err": float(nrm)} + + +def figure(step, t): + stress = sheet.stress + # in-plane principal stresses from the 3x3 sheet stress on the grid (collective evaluation) + S = np.asarray(uw.function.evaluate(stress, grid)).reshape(-1, 3, 3) + U = np.asarray(uw.function.evaluate(sheet.u.sym, grid)).reshape(-1, 3) + if not rank0: + return + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + ev = np.linalg.eigvalsh(0.5 * (S + np.swapaxes(S, 1, 2))) # one eigenvalue ~ 0 (normal) + ev_sorted = np.sort(ev, axis=1) + s_max = ev_sorted[:, 2].reshape(LAT.shape) + s_min = ev_sorted[:, 0].reshape(LAT.shape) + un = (U * grid).sum(axis=1) + ut = np.linalg.norm(U - un[:, None] * grid, axis=1).reshape(LAT.shape) + fig, axes = plt.subplots(1, 3, figsize=(19, 4.6), subplot_kw={"projection": "mollweide"}) + for ax, fld, ttl, cmap in ((axes[0], un.reshape(LAT.shape), "radial velocity w (prescribed)", "RdBu_r"), + (axes[1], ut, "tangential speed", "viridis"), + (axes[2], s_max, "largest in-plane principal stress", "RdBu_r")): + lim = np.abs(fld).max() + 1e-30 + pc = ax.pcolormesh(LON, LAT, fld, cmap=cmap, shading="auto", + **({"vmin": -lim, "vmax": lim} if cmap == "RdBu_r" else {})) + ax.set_title(ttl, fontsize=10, loc="left"); ax.grid(alpha=0.3) + fig.colorbar(pc, ax=ax, shrink=0.6, orientation="horizontal", pad=0.05) + fig.suptitle(f"tidal shell: step {step}, t = {t:.3f} periods, De = {args.De}, A = {args.A}", fontsize=10) + fig.savefig(os.path.join(args.out, f"tide_{step:04d}.png"), dpi=110, bbox_inches="tight") + plt.close(fig) + + +# ----------------------------------------------------------------------------- time loop +dt = 1.0 / args.steps_per_period +nsteps = int(round(args.periods * args.steps_per_period)) +records = [] +t = 0.0 +log(f"sphere h {args.h}, {comm.allreduce(int(sphere.dm.getHeightStratum(0)[1]), op=MPI.SUM)} cells; " + f"De {args.De}, A {args.A}, dt {dt}, {nsteps} steps") +for k in range(nsteps): + t0 = time.perf_counter() + t_mid = t + dt # implicit: the datum at the end of the step + sheet.normal_velocity = radial_velocity(t_mid) + if args.De > 0: + sheet.solve(timestep=dt, zero_init_guess=(k == 0), verbose=args.verbose) + else: + sheet.solve(zero_init_guess=(k == 0), verbose=args.verbose) + t = t_mid + info = sheet._rotated_freeslip_info or {} + rec = {"step": k, "t": t, "dt": dt, "converged": bool(info.get("converged")), + "nonlinear": info.get("nonlinear_iterations"), "t_step": round(time.perf_counter() - t0, 1)} + if args.check: + rec.update(diagnostics(t)) + records.append(rec) + log("STEP", json.dumps(rec)) + if k % args.fig_every == 0 or k == nsteps - 1: + figure(k, t) + if rank0: + json.dump(records, open(os.path.join(args.out, "records.json"), "w"), indent=1) +log("DONE") diff --git a/scripts/test.sh b/scripts/test.sh index 61091a0a7..39c4458d7 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -195,7 +195,7 @@ if [ $PARALLEL_ONLY -eq 0 ]; then "${PYTEST[@]}" tests/test_106*py tests/test_107*py || status=1 # test_108*: thin sheet (objective step, the Newton tangent's closure, thin vs # thick, the quadratic benchmark); the slow ones are level_3 and marked so. - "${PYTEST[@]}" tests/test_108*py || status=1 + "${PYTEST[@]}" tests/test_108*py tests/test_109*py || status=1 # # test_1072 used to be pulled forward out of test_107* by name, because that # band was not batched and it is the ONLY guard on the 3-D free-surface sign diff --git a/src/underworld3/systems/thin_sheet.py b/src/underworld3/systems/thin_sheet.py index 0cacbfa4a..62ce86777 100644 --- a/src/underworld3/systems/thin_sheet.py +++ b/src/underworld3/systems/thin_sheet.py @@ -429,6 +429,33 @@ def basal_drag(self, value): self._needs_function_rewire = True self._basal_drag.sym = value + @property + def normal_velocity(self): + r"""Prescribed velocity of the sheet along its own normal, :math:`w` + (a number or an expression of position; time enters through a + constant the caller updates). On a manifold it is the datum of the + tangency constraint, :math:`\mathbf{u}\cdot\hat{\mathbf{n}} = w` at + every node, so the embedded velocity carries the normal motion and the + in-plane strain rate picks up its curvature term + :math:`w\,\nabla_s\hat{\mathbf{n}}` (:math:`(w/R)\,\mathbf{P}` on a + sphere): the breathing of a tidally flexed shell, say. ``None`` keeps + the sheet in its tangent plane. Re-evaluated at every solve.""" + from underworld3.utilities.rotated_bc import SHEET_TANGENCY + return self._rotated_freeslip_datum.get(SHEET_TANGENCY) + + @normal_velocity.setter + def normal_velocity(self, w): + from underworld3.utilities.rotated_bc import SHEET_TANGENCY + if self.mesh.dim == self.mesh.cdim: + raise NotImplementedError( + "a flat sheet has no normal direction to move along; the normal " + "velocity is a manifold sheet's datum (ThinSheet on a SphericalManifold).") + if w is None: + self._rotated_freeslip_datum.pop(SHEET_TANGENCY, None) + else: + self._rotated_freeslip_datum[SHEET_TANGENCY] = ( + w if isinstance(w, (int, float)) else sympy.sympify(w)) + def add_basal_drag(self, coefficient, boundary=None, reference_velocity=None): r"""Couple the sheet to its base by a drag :math:`-c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref})` per unit area. diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index fd2dbc788..03b4dca8b 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -1420,7 +1420,10 @@ def rotated_residual(uvec, keep_cartesian=False): for iters in range(max_it): Fhat = rotated_residual(u, keep_cartesian=True) rnorm = Fhat.norm() - if r0 is None: + if r0 is None or (lift_datum and iters == 1 and r0 == 0.0): + # A cold start whose only forcing is the prescribed datum (a sheet + # breathing with the tide, say) has a zero residual at u = 0, before + # the lift; the reference scale is then the lifted iterate's. r0 = rnorm ref = max(r0, fnorm_rest) if fnorm_rest is not None else r0 if verbose: @@ -1428,7 +1431,9 @@ def rotated_residual(uvec, keep_cartesian=False): f"rel={rnorm/(ref+1e-300):.3e} [{phase}]") # residual convergence (relative to the reference scale, plus an absolute # floor so an already-converged warm start does not chase machine noise). - if rnorm <= rtol * ref + atol: + if rnorm <= rtol * ref + atol and not (lift_datum and iters == 0): + # (the lift of a cold-start datum rides the first increment, so a + # zero residual at u = 0 is not convergence until it has been applied) converged = True exit_reason = _SNES_CONVERGED_FNORM_RELATIVE Fhat.destroy() diff --git a/tests/test_1092_thin_sheet_prescribed_normal.py b/tests/test_1092_thin_sheet_prescribed_normal.py new file mode 100644 index 000000000..0ebd95afc --- /dev/null +++ b/tests/test_1092_thin_sheet_prescribed_normal.py @@ -0,0 +1,85 @@ +"""A sheet on the sphere moving along its own normal: the prescribed normal +velocity is the datum of the tangency constraint, and the in-plane strain rate +carries its curvature term. Closed forms on the unit sphere for a uniform +membrane with no basal drag (the tidally flexed shell over an ocean): + +- breathing, w = W: no tangential motion, strain rate W P, sheet stress + sigma = 2 eta (eps + tr eps P) = 6 eta W P; +- a degree-l spheroidal pattern, w = W Y_l: tangential velocity + v = 3 W / (2 l (l + 1) - 1) grad_s Y_l (l = 2: 3W/11), from div_s sigma = 0 + with div_s Hess_s Y = grad_s(Delta_s Y) + grad_s Y on the unit sphere. +""" +import numpy as np +import pytest +import sympy +import underworld3 as uw + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + + +def _sphere(cell_size, degree=2): + return uw.meshing.SphericalManifold(radius=1.0, cellSize=cell_size, degree=1, + qdegree=2 * degree + 1) + + +def _sheet(sphere, eta=1.0): + sheet = uw.systems.ThinSheet(sphere) + sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = eta + sheet.tolerance = 1.0e-9 + return sheet + + +def _split(sheet): + """Velocity at the nodes split into its normal and tangential parts.""" + c = np.asarray(sheet.u.coords) + u = np.asarray(sheet.u.array[:, 0, :]) + rhat = c / np.linalg.norm(c, axis=1)[:, None] + un = (u * rhat).sum(axis=1) + return c, rhat, un, u - un[:, None] * rhat + + +def test_breathing_sphere(): + """w = W everywhere: the velocity is W r^ at every node to solver + precision, no tangential flow, and the sheet stress is 6 eta W P.""" + W, eta = 0.3, 2.0 + sphere = _sphere(0.25) + sheet = _sheet(sphere, eta) + sheet.normal_velocity = W + sheet.solve() + assert sheet._rotated_freeslip_info["converged"] + c, rhat, un, ut = _split(sheet) + assert np.abs(un - W).max() < 1.0e-9 + # the curvature term is discretised on flat facets through the nodal normal + # field, so a small tangential flow of order h^2 remains (0.25% at h = 0.25) + assert np.abs(ut).max() < 1.0e-2 * W + # the stress: evaluate the sheet's in-plane stress at a few cell interiors + pts = np.asarray(sphere.X.coords)[::97] + pts = pts / np.linalg.norm(pts, axis=1)[:, None] + sig = np.asarray(uw.function.evaluate(sheet.stress, pts)).reshape(len(pts), 3, 3) + P = np.eye(3)[None] - pts[:, :, None] * pts[:, None, :] + assert np.abs(sig - 6 * eta * W * P).max() < 0.1 * 6 * eta * W + + +def test_degree_two_membrane(): + """w = W Y20, Y20 = (3 z^2 - 1)/2: the tangential velocity is + (3W/11) grad_s Y20 = (9W/11) z (z^ - z r^), second order in h.""" + W = 0.5 + errs = [] + for h in (0.3, 0.15): + sphere = _sphere(h) + x, y, z = sphere.X + sheet = _sheet(sphere) + sheet.normal_velocity = W * (3 * z ** 2 - 1) / 2 + sheet.solve() + assert sheet._rotated_freeslip_info["converged"] + c, rhat, un, ut = _split(sheet) + zz = c[:, 2] + assert np.abs(un - W * (3 * zz ** 2 - 1) / 2).max() < 1.0e-8 + exact = (9 * W / 11) * zz[:, None] * (np.array([0.0, 0.0, 1.0])[None] - zz[:, None] * rhat) + errs.append(np.abs(ut - exact).max() / np.abs(exact).max()) + # measured: 7.2% (h 0.3), 3.4% (0.2), 2.5% (0.15) in the max norm; the + # curvature term is first order on flat facets, so the order is between 1 and 2 + assert errs[0] < 0.10 + assert errs[1] < 0.04 + assert errs[1] < 0.6 * errs[0] From c6df30c6f413cf0c89cb2076fe1457aeee360cb7 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 15 Oct 2026 07:41:40 +1100 Subject: [PATCH 02/59] Tidal shell driver: collective map evaluation just inside the faceted sphere, in-plane principal stresses The maps were drawn from a rank-local evaluation of the whole grid (garbage outside rank 0's cells) and the largest principal stress picked up the zero normal eigenvalue where both in-plane stresses were compressive. Underworld development team with AI support from Claude Code --- scripts/sessions/tidal_shell/tidal_shell.py | 23 ++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/scripts/sessions/tidal_shell/tidal_shell.py b/scripts/sessions/tidal_shell/tidal_shell.py index 3d530a879..1176f33c0 100644 --- a/scripts/sessions/tidal_shell/tidal_shell.py +++ b/scripts/sessions/tidal_shell/tidal_shell.py @@ -106,6 +106,11 @@ def grad_s(f): lat = np.linspace(-np.pi / 2, np.pi / 2, 91) LON, LAT = np.meshgrid(lon, lat) grid = np.column_stack([(np.cos(LAT) * np.cos(LON)).ravel(), (np.cos(LAT) * np.sin(LON)).ravel(), np.sin(LAT).ravel()]) +# local tangent basis on the grid (east, north) for the in-plane 2x2 stress +_e_east = np.column_stack([-np.sin(LON).ravel(), np.cos(LON).ravel(), np.zeros(LON.size)]) +_e_north = np.column_stack([(-np.sin(LAT) * np.cos(LON)).ravel(), (-np.sin(LAT) * np.sin(LON)).ravel(), np.cos(LAT).ravel()]) +# the faceted mesh lies inside the sphere (sagitta ~ h^2/8); evaluate just inside it +_grid_in = grid * (1.0 - 0.3 * args.h ** 2) def diagnostics(t): @@ -127,18 +132,22 @@ def diagnostics(t): def figure(step, t): stress = sheet.stress - # in-plane principal stresses from the 3x3 sheet stress on the grid (collective evaluation) - S = np.asarray(uw.function.evaluate(stress, grid)).reshape(-1, 3, 3) - U = np.asarray(uw.function.evaluate(sheet.u.sym, grid)).reshape(-1, 3) + # collective evaluations on the grid (every rank; drawn on rank 0): a rank-local + # evaluate() would only know the rank's own cells + S = np.asarray(uw.function.global_evaluate(stress, _grid_in)).reshape(-1, 3, 3) + U = np.asarray(uw.function.global_evaluate(sheet.u.sym, _grid_in)).reshape(-1, 3) if not rank0: return import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt - ev = np.linalg.eigvalsh(0.5 * (S + np.swapaxes(S, 1, 2))) # one eigenvalue ~ 0 (normal) - ev_sorted = np.sort(ev, axis=1) - s_max = ev_sorted[:, 2].reshape(LAT.shape) - s_min = ev_sorted[:, 0].reshape(LAT.shape) + # the in-plane 2x2 stress in the (east, north) basis and its principal values + s_ee = np.einsum("ni,nij,nj->n", _e_east, S, _e_east) + s_nn = np.einsum("ni,nij,nj->n", _e_north, S, _e_north) + s_en = 0.5 * (np.einsum("ni,nij,nj->n", _e_east, S, _e_north) + np.einsum("ni,nij,nj->n", _e_north, S, _e_east)) + mean, rad = 0.5 * (s_ee + s_nn), np.sqrt((0.5 * (s_ee - s_nn)) ** 2 + s_en ** 2) + s_max = (mean + rad).reshape(LAT.shape) + s_min = (mean - rad).reshape(LAT.shape) un = (U * grid).sum(axis=1) ut = np.linalg.norm(U - un[:, None] * grid, axis=1).reshape(LAT.shape) fig, axes = plt.subplots(1, 3, figsize=(19, 4.6), subplot_kw={"projection": "mollweide"}) From d445aa4a43ea15e0f9fe4b708514f17d41efbe22 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 15 Oct 2026 10:19:32 +1100 Subject: [PATCH 03/59] Rotated solve: the rotation gauge in the residual norm, and the sheet datum on fault pairs When rigid rotations are a genuine null space (a drag-free sheet on a sphere, a free-slip shell) the inner solve cannot reduce the residual's component along them, so the outer nonlinear residual is now measured in that quotient space, as it already was for the pressure gauge. A viscoelastic stress history on the faceted surface leaves such a component (measured: a tidally flexed Maxwell sheet floored at a relative residual of 1e-4 with every linear solve converged; one iteration per step after the change). The projected-out magnitude is kept on the solver as _rotated_rotation_gauge_residual. A split-fault pair on a manifold takes its tangency through the pair block, so a prescribed sheet normal velocity now rides the pair's mean-normal row (sqrt2 w, signed against the analytic normal); before, the fault nodes stood still while the sheet breathed (datum error 0.018 against an amplitude of 0.1). Tidal driver: tiger-stripe faults (count, length, spacing, latitude, interface law), per-step stress diagnostics on a collective grid through nodal stress components (the stress expression's velocity gradients cannot cross ranks), the objective-rate option, and the sweep plot script. Underworld development team with AI support from Claude Code --- scripts/sessions/tidal_shell/tidal_shell.py | 115 +++++++++++++++++- .../sessions/tidal_shell/tidal_sweep_plot.py | 32 +++++ src/underworld3/utilities/rotated_bc.py | 68 +++++++++++ 3 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 scripts/sessions/tidal_shell/tidal_sweep_plot.py diff --git a/scripts/sessions/tidal_shell/tidal_shell.py b/scripts/sessions/tidal_shell/tidal_shell.py index 1176f33c0..678d9747f 100644 --- a/scripts/sessions/tidal_shell/tidal_shell.py +++ b/scripts/sessions/tidal_shell/tidal_shell.py @@ -40,8 +40,14 @@ ap.add_argument("--periods", type=float, default=2.0) ap.add_argument("--steps_per_period", type=int, default=32) ap.add_argument("--tol", type=float, default=1e-8) +ap.add_argument("--objective", default="none", help="objective rate of the Maxwell history: none (tides rotate negligibly per step) or jaumann") ap.add_argument("--check", type=int, default=1, help="compare with the membrane closed form each step") ap.add_argument("--fig_every", type=int, default=8) +ap.add_argument("--faults", type=int, default=0, help="number of parallel tiger-stripe faults near the south pole") +ap.add_argument("--fault_len", type=float, default=0.52, help="fault length (rad; Enceladus ~130 km on R 252 km)") +ap.add_argument("--fault_spacing", type=float, default=0.14, help="spacing between the stripes (rad; ~35 km)") +ap.add_argument("--fault_eta", type=float, default=0.0, help="viscous interface law coefficient (0: frictionless)") +ap.add_argument("--fault_lat", type=float, default=-90.0, help="latitude of the stripe set's centre (deg)") ap.add_argument("--verbose", action="store_true") args = ap.parse_args() @@ -57,11 +63,41 @@ def log(*a): # ----------------------------------------------------------------------------- mesh and sheet sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=args.h, degree=1, qdegree=5) + + +def stripe_points(offset, half_len, n=9, lat0=-90.0): + """A stripe as a polyline on the unit sphere: the exponential map at the set's + centre (latitude lat0 on the x-z meridian), the stripe along the local e1 + direction at a distance `offset` along e2.""" + th0 = np.radians(lat0) + c = np.array([np.cos(th0), 0.0, np.sin(th0)]) # centre + e1 = np.array([0.0, 1.0, 0.0]) # east at the centre + e2 = np.cross(c, e1) # north-ish + a = np.linspace(-half_len, half_len, n) + pts = [] + for ai in a: + rho = np.hypot(ai, offset) + d = (ai * e1 + offset * e2) / max(rho, 1e-30) + pts.append(np.cos(rho) * c + np.sin(rho) * d) + return np.array(pts) + + +fault_names = [] +if args.faults > 0: + offsets = (np.arange(args.faults) - 0.5 * (args.faults - 1)) * args.fault_spacing + faults = [] + for i, off in enumerate(offsets): + name = f"Stripe{i}" + faults.append((name, stripe_points(off, 0.5 * args.fault_len, lat0=args.fault_lat))) + fault_names.append(name) + sphere = sphere.add_fault(faults) x, y, z = sphere.X sheet = uw.systems.ThinSheet(sphere) +for name in fault_names: + sheet.add_fault_bc(args.fault_eta, name) if args.De > 0: sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( - sheet.Unknowns, order=1, integrator="etd", objective_rate="jaumann") + sheet.Unknowns, order=1, integrator="etd", objective_rate=args.objective) sheet.constitutive_model.Parameters.shear_modulus = 1.0 sheet.constitutive_model.Parameters.shear_viscosity_0 = args.De # Maxwell time De periods else: @@ -113,6 +149,69 @@ def grad_s(f): _grid_in = grid * (1.0 - 0.3 * args.h ** 2) +# The sheet stress holds velocity gradients, which the collective cross-rank +# evaluator cannot take (PETSc error 75 at four ranks on the faulted sphere; the +# same class as the add-on yield indicator): evaluate it rank-locally at the +# sheet's own P1 nodes into six scalar variables and evaluate THOSE collectively. +_S_names = ("xx", "yy", "zz", "xy", "xz", "yz") +_S_idx = ((0, 0), (1, 1), (2, 2), (0, 1), (0, 2), (1, 2)) +_S_vars = [uw.discretisation.MeshVariable(f"S{n}", sphere, 1, degree=1) for n in _S_names] +_S_coords = np.asarray(_S_vars[0].coords) +_S_coords_in = _S_coords * (1.0 - 1e-9) + + +def stress_to_nodes(): + """Fill the six stress-component variables from the sheet stress (every rank + evaluates its own nodes; the writes are collective).""" + if _S_coords.shape[0]: + S = np.asarray(uw.function.evaluate(sheet.stress, _S_coords_in)).reshape(-1, 3, 3) + else: + S = np.zeros((0, 3, 3)) + for var, (i, j) in zip(_S_vars, _S_idx): + var.array[:, 0, 0] = S[:, i, j] + + +def stress_on(points): + """The 3x3 sheet stress at arbitrary points, from the nodal components (collective).""" + comps = [np.asarray(uw.function.global_evaluate(var.sym[0], points)).ravel() for var in _S_vars] + S = np.zeros((len(points), 3, 3)) + for c, (i, j) in zip(comps, _S_idx): + S[:, i, j] = c + S[:, j, i] = c + return S + + +# a coarser grid for the per-step stress diagnostics (collective evaluation) +_lon_d = np.linspace(-np.pi, np.pi, 73)[:-1] +_lat_d = np.linspace(-np.pi / 2, np.pi / 2, 37)[1:-1] +_LON_d, _LAT_d = np.meshgrid(_lon_d, _lat_d) +_grid_d = np.column_stack([(np.cos(_LAT_d) * np.cos(_LON_d)).ravel(), (np.cos(_LAT_d) * np.sin(_LON_d)).ravel(), + np.sin(_LAT_d).ravel()]) * (1.0 - 0.3 * args.h ** 2) +_w_d = np.cos(_LAT_d).ravel() # area weights +_ee_d = np.column_stack([-np.sin(_LON_d).ravel(), np.cos(_LON_d).ravel(), np.zeros(_LON_d.size)]) +_en_d = np.column_stack([(-np.sin(_LAT_d) * np.cos(_LON_d)).ravel(), (-np.sin(_LAT_d) * np.sin(_LON_d)).ravel(), + np.cos(_LAT_d).ravel()]) +_pat_c = np.asarray(sympy.lambdify((x, y, z), pattern_c, "numpy")(*_grid_d.T)).ravel() +_pat_s = np.asarray(sympy.lambdify((x, y, z), pattern_s, "numpy")(*_grid_d.T)).ravel() + + +def stress_diagnostics(t): + """Area-weighted rms of the in-plane stress invariant and the stress trace, + and the trace's projection on the two forcing patterns (its phase).""" + S = stress_on(_grid_d) + s_ee = np.einsum("ni,nij,nj->n", _ee_d, S, _ee_d) + s_nn = np.einsum("ni,nij,nj->n", _en_d, S, _en_d) + s_en = 0.5 * (np.einsum("ni,nij,nj->n", _ee_d, S, _en_d) + np.einsum("ni,nij,nj->n", _en_d, S, _ee_d)) + tr = s_ee + s_nn + inv2 = np.sqrt(0.5 * ((s_ee - 0.5 * tr) ** 2 + (s_nn - 0.5 * tr) ** 2) + s_en ** 2) + W = _w_d.sum() + cc, ss = (_w_d * _pat_c ** 2).sum(), (_w_d * _pat_s ** 2).sum() + return {"stress_inv_rms": float(np.sqrt((_w_d * inv2 ** 2).sum() / W)), + "stress_tr_rms": float(np.sqrt((_w_d * tr ** 2).sum() / W)), + "tr_on_cos": float((_w_d * tr * _pat_c).sum() / cc), + "tr_on_sin": float((_w_d * tr * _pat_s).sum() / ss)} + + def diagnostics(t): u = np.asarray(sheet.u.array[:, 0, :]) un = (u * rh).sum(axis=1) @@ -131,10 +230,9 @@ def diagnostics(t): def figure(step, t): - stress = sheet.stress # collective evaluations on the grid (every rank; drawn on rank 0): a rank-local # evaluate() would only know the rank's own cells - S = np.asarray(uw.function.global_evaluate(stress, _grid_in)).reshape(-1, 3, 3) + S = stress_on(_grid_in) U = np.asarray(uw.function.global_evaluate(sheet.u.sym, _grid_in)).reshape(-1, 3) if not rank0: return @@ -183,8 +281,19 @@ def figure(step, t): info = sheet._rotated_freeslip_info or {} rec = {"step": k, "t": t, "dt": dt, "converged": bool(info.get("converged")), "nonlinear": info.get("nonlinear_iterations"), "t_step": round(time.perf_counter() - t0, 1)} + stress_to_nodes() if args.check: rec.update(diagnostics(t)) + rec.update(stress_diagnostics(t)) + if fault_names: + from underworld3.utilities.fault_contact import fault_pair_jumps + slips = {} + for name in fault_names: + _c, jumps, normals = fault_pair_jumps(sheet, name, info, gather=True) # every rank holds the fault + v = np.linalg.norm(jumps, axis=1) if len(jumps) else np.zeros(1) + slips[name] = {"max": float(v.max()), "rms": float(np.sqrt((v ** 2).mean())), + "leak": float(np.abs(np.einsum("ij,ij->i", jumps, normals)).max()) if len(jumps) else 0.0} + rec["slip"] = slips records.append(rec) log("STEP", json.dumps(rec)) if k % args.fig_every == 0 or k == nsteps - 1: diff --git a/scripts/sessions/tidal_shell/tidal_sweep_plot.py b/scripts/sessions/tidal_shell/tidal_sweep_plot.py new file mode 100644 index 000000000..57f9e9192 --- /dev/null +++ b/scripts/sessions/tidal_shell/tidal_sweep_plot.py @@ -0,0 +1,32 @@ +"""Stress amplitude and phase of the tidally flexed shell against the Maxwell +time: reads records.json of each run, fits the trace projections on the two +forcing patterns to a sinusoid over the last period.""" +import json, os, sys +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +sim = sys.argv[1] +runs = sys.argv[2:] +fig, axes = plt.subplots(1, 3, figsize=(17, 4.6)) +summary = [] +for run in runs: + rs = json.load(open(os.path.join(sim, run, "records.json"))) + t = np.array([r["t"] for r in rs]); inv = np.array([r["stress_inv_rms"] for r in rs]) + tc = np.array([r["tr_on_cos"] for r in rs]); ts = np.array([r["tr_on_sin"] for r in rs]) + vt = np.array([r["vt_rms"] for r in rs]) + axes[0].plot(t, inv, label=run); axes[1].plot(t, tc, label=f"{run} cos"); axes[1].plot(t, ts, "--", label=f"{run} sin") + axes[2].plot(t, vt, label=run) + last = t > t.max() - 1.0 + 1e-9 + # the forcing's cos-pattern coefficient is A cos(2 pi t): fit tc = a cos + b sin + M = np.column_stack([np.cos(2 * np.pi * t[last]), np.sin(2 * np.pi * t[last])]) + a, b = np.linalg.lstsq(M, tc[last], rcond=None)[0] + amp, phase = np.hypot(a, b), np.degrees(np.arctan2(b, a)) + summary.append((run, inv[last].max(), amp, phase)) +axes[0].set_title("rms in-plane stress invariant"); axes[1].set_title("stress trace projected on the forcing patterns") +axes[2].set_title("rms tangential speed"); +for ax in axes: ax.set_xlabel("t (periods)"); ax.grid(alpha=0.3); ax.legend(fontsize=7) +fig.tight_layout(); fig.savefig(os.path.join(sim, "sweep.png"), dpi=120) +print("run max|inv| trace amplitude on cos pattern phase (deg, lag of the stress behind the radial displacement rate)") +for s in summary: print(f"{s[0]:8s} {s[1]:.4f} {s[2]:.4f} {s[3]:7.1f}") diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index 03b4dca8b..a10567651 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -569,6 +569,23 @@ def build_rotation(solver, boundaries, datum_specs=None): if fault_names: from underworld3.utilities import fault_contact s2 = 1.0 / np.sqrt(2.0) + # A sheet moving along its normal (the datum of SHEET_TANGENCY): a pair + # node takes the tangency through its pair block, so the MEAN row along + # the surface normal carries the datum there, (v+ + v-)/sqrt2 . e = sqrt2 w + # with e the frame's surface-normal vector (sign against the analytic + # normal); the jump row stays at 0. Without this the fault nodes stood + # still while the sheet breathed (measured: a datum error of 0.018 against + # an amplitude of 0.1, and spurious stress along the trace). + sheet_dspec = None if datum_specs is None else datum_specs.get(SHEET_TANGENCY) + sheet_normal_fn = None + if manifold and sheet_dspec is not None: + for spec in boundaries: + name_s, normal_s = _boundary_spec(spec) + if name_s == SHEET_TANGENCY: + sheet_normal_fn, sheet_normal_const = _normal_source(solver, normal_s, SHEET_TANGENCY) + pcsec = dm.getCoordinateSection() + pcvec = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, dim) + pv0, pv1 = dm.getDepthStratum(0) # Rank-local failures in this block (a pairing/label mismatch or a # vanishing analytic normal inside _fault_pair_nodes, a fault node # shared with a wall boundary, a pair straddling ranks) must not @@ -627,6 +644,16 @@ def build_rotation(solver, boundaries, datum_specs=None): # vector) are pinned; the slip row stays free normal_rows.append(gm[2]) normal_rows.append(gp[2]) + if sheet_dspec is not None: + xq = _point_coord(dm, dim, pcvec, pcsec, pv0, pv1, q_plus) + if sheet_normal_fn is not None: + ne = np.asarray(sheet_normal_fn(*xq), dtype=float).ravel() + else: + ne = np.asarray(sheet_normal_const, dtype=float).ravel() + ne = ne / (np.linalg.norm(ne) + 1e-30) + sgn_e = 1.0 if float(np.dot(np.asarray(frame[2], dtype=float).ravel(), ne)) >= 0 else -1.0 + # row gp[2] = s2 (v+ + v-) . e = s2 * 2 w * sgn_e + datum_nodes.append((gp[2], q_plus, sheet_dspec, 2.0 * s2 * sgn_e)) except (RuntimeError, ValueError) as err: problem = str(err) fault_contact._collective_raise(problem) @@ -1351,6 +1378,38 @@ def solve_rotated_freeslip(solver, boundaries, remove_rotation_gauge=True, # pressure rows) — kept observable so an INCOMPATIBLE datum cannot hide # behind the projection (see the guard after the Newton loop). pnull_gauge = [0.0] + # The rigid-rotation gauge likewise: when rotation modes are a genuine null + # space of the operator (a drag-free sheet on a sphere, a free-slip shell), + # the inner KSP cannot reduce the residual's component along them, so the + # outer norm is measured in that quotient space too. A viscous forcing has no + # such component by construction; a viscoelastic stress history on the + # faceted surface does (measured: the Newton loop floored at rel 1e-4 on a + # tidally flexed Maxwell sheet, with the linear solves converged). + rot_modes = [] # orthonormal, velocity-only, rotated frame + rnull_gauge = [0.0] + + def _collect_rotation_modes(nullspace): + rot_modes.clear() + if nullspace is None: + return + for v in nullspace.getVecs(): + if has_pressure: + sp = v.getSubVector(pres_is) + is_pressure = sp.norm() > 0.0 + v.restoreSubVector(pres_is, sp) + if is_pressure: + continue + m = v.copy() + for q in rot_modes: # Gram-Schmidt + m.axpy(-m.dot(q), q) + nm = m.norm() + if nm > 0.0: + m.scale(1.0 / nm) + rot_modes.append(m) + else: + m.destroy() + + _collect_rotation_modes(nsp) def rotated_residual(uvec, keep_cartesian=False): snes.computeFunction(uvec, Fc) @@ -1385,6 +1444,13 @@ def rotated_residual(uvec, keep_cartesian=False): sp.shift(-mean) # project out the pressure-gauge mode Fh.restoreSubVector(pres_is, sp) pnull_gauge[0] = abs(mean) * (max(n_p, 1) ** 0.5) + if rot_modes: + removed = 0.0 + for m in rot_modes: # project out the rotation-gauge modes + c = Fh.dot(m) + Fh.axpy(-c, m) + removed += c * c + rnull_gauge[0] = removed ** 0.5 return Fh # Convergence reference: max(initial residual, REST-STATE residual ‖F̂(0)‖). @@ -1489,6 +1555,7 @@ def rotated_residual(uvec, keep_cartesian=False): if ctx is None: Mp = _pressure_mass_schur_pmat(solver) if has_pressure else None nsp = _rotated_nullspace(solver, Q, normal_rows) # J assembled above + _collect_rotation_modes(nsp) elif assemble and Mp is not None: Jp.createSubMatrix(pres_is, pres_is, submat=Mp) # viscosity may be u-dependent if diag_scale is None: @@ -1619,6 +1686,7 @@ def rotated_residual(uvec, keep_cartesian=False): # incompressible domain) parks an O(forcing) constant there — and the loop # above would report convergence while mass conservation is violated. # Surface the component: readable state always, loud when it dominates. + solver._rotated_rotation_gauge_residual = rnull_gauge[0] if rot_modes else 0.0 if use_pnull: solver._rotated_pressure_gauge_residual = pnull_gauge[0] if converged and pnull_gauge[0] > 10.0 * max(rnorm, atol): From b971bd0ef6961bd7c1035a4719382d668468a79e Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 15 Oct 2026 10:32:26 +1100 Subject: [PATCH 04/59] Tidal shell driver: south polar view of the stripes (principal stress, shear invariant, speed) and the faulted-versus-unfaulted comparison script Underworld development team with AI support from Claude Code --- .../tidal_shell/tidal_fault_compare.py | 32 +++++++++ scripts/sessions/tidal_shell/tidal_shell.py | 65 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 scripts/sessions/tidal_shell/tidal_fault_compare.py diff --git a/scripts/sessions/tidal_shell/tidal_fault_compare.py b/scripts/sessions/tidal_shell/tidal_fault_compare.py new file mode 100644 index 000000000..fdf771b80 --- /dev/null +++ b/scripts/sessions/tidal_shell/tidal_fault_compare.py @@ -0,0 +1,32 @@ +"""Faulted against unfaulted shell: the global velocity and stress diagnostics +over the cycle, and the slip on each stripe. Usage: """ +import json, os, sys +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +sim, fr, rr = sys.argv[1:4] +F = json.load(open(os.path.join(sim, fr, "records.json"))) +R = json.load(open(os.path.join(sim, rr, "records.json"))) +n = min(len(F), len(R)); F, R = F[:n], R[:n] +t = np.array([r["t"] for r in F]) +fig, axes = plt.subplots(1, 3, figsize=(17, 4.6)) +for key, ax, ttl in (("vt_rms", axes[0], "rms tangential speed"), ("stress_inv_rms", axes[1], "rms in-plane stress invariant")): + ax.plot(t, [r[key] for r in R], "k", label="unfaulted") + ax.plot(t, [r[key] for r in F], "r", label="4 stripes") + ax.set_title(ttl); ax.set_xlabel("t (periods)"); ax.grid(alpha=0.3); ax.legend(fontsize=8) +names = sorted(F[0]["slip"].keys()) +for nm in names: + axes[2].plot(t, [r["slip"][nm]["max"] for r in F], label=f"{nm} max") +axes[2].set_title("slip on the stripes (max over the trace)"); axes[2].set_xlabel("t (periods)"); axes[2].grid(alpha=0.3); axes[2].legend(fontsize=7) +fig.tight_layout(); fig.savefig(os.path.join(sim, f"{fr}_vs_{rr}.png"), dpi=120) +last = t > t.max() - 1.0 + 1e-9 +def rms(key, rs): return float(np.sqrt(np.mean([r[key] ** 2 for r, m in zip(rs, last) if m]))) +print(f"{fr} vs {rr} over the last period:") +print(f" rms tangential speed faulted {rms('vt_rms', F):.5f} unfaulted {rms('vt_rms', R):.5f} ratio {rms('vt_rms', F)/rms('vt_rms', R):.4f}") +print(f" rms stress invariant faulted {rms('stress_inv_rms', F):.5f} unfaulted {rms('stress_inv_rms', R):.5f} ratio {rms('stress_inv_rms', F)/rms('stress_inv_rms', R):.4f}") +print(f" membrane rel err faulted {rms('rel_err_membrane', F):.4f} unfaulted {rms('rel_err_membrane', R):.4f}") +for nm in names: + v = [r["slip"][nm]["max"] for r, m in zip(F, last) if m] + print(f" {nm}: max slip over the cycle {max(v):.4f}, rms {np.sqrt(np.mean(np.square(v))):.4f}") diff --git a/scripts/sessions/tidal_shell/tidal_shell.py b/scripts/sessions/tidal_shell/tidal_shell.py index 678d9747f..48e160367 100644 --- a/scripts/sessions/tidal_shell/tidal_shell.py +++ b/scripts/sessions/tidal_shell/tidal_shell.py @@ -262,6 +262,70 @@ def figure(step, t): plt.close(fig) +# polar view: azimuthal (exponential-map) grid around the stripe set's centre +_pole_lat = args.fault_lat if args.faults > 0 else -90.0 +_th0 = np.radians(_pole_lat) +_pc = np.array([np.cos(_th0), 0.0, np.sin(_th0)]) +_pe1 = np.array([0.0, 1.0, 0.0]); _pe2 = np.cross(_pc, _pe1) +_prho = np.linspace(0.0, 0.9, 61)[1:] +_ppsi = np.linspace(-np.pi, np.pi, 145) +_PR, _PP = np.meshgrid(_prho, _ppsi) +_pa, _pb = (_PR * np.cos(_PP)).ravel(), (_PR * np.sin(_PP)).ravel() +_pd = (_pa[:, None] * _pe1[None] + _pb[:, None] * _pe2[None]) / np.maximum(_PR.ravel(), 1e-30)[:, None] +_pgrid = np.cos(_PR.ravel())[:, None] * _pc[None] + np.sin(_PR.ravel())[:, None] * _pd +_pgrid_in = _pgrid * (1.0 - 0.3 * args.h ** 2) +_pee = np.cross(_pgrid, _pd) # azimuthal direction +_pee /= np.maximum(np.linalg.norm(_pee, axis=1), 1e-30)[:, None] +_pen = np.cross(_pee, _pgrid) # radial-in-plane direction + + +def to_polar(points): + """Points on the sphere -> (a, b) in the azimuthal projection about the centre.""" + pts = np.asarray(points) / np.linalg.norm(points, axis=1)[:, None] + cosr = np.clip(pts @ _pc, -1, 1) + rho = np.arccos(cosr) + d = pts - cosr[:, None] * _pc[None] + d /= np.maximum(np.linalg.norm(d, axis=1), 1e-30)[:, None] + return rho * (d @ _pe1), rho * (d @ _pe2) + + +_fault_polylines = [to_polar(pts) for _n, pts in (faults if args.faults > 0 else [])] + + +def polar_figure(step, t): + S = stress_on(_pgrid_in) + U = np.asarray(uw.function.global_evaluate(sheet.u.sym, _pgrid_in)).reshape(-1, 3) + if not rank0: + return + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + s_ee = np.einsum("ni,nij,nj->n", _pee, S, _pee); s_nn = np.einsum("ni,nij,nj->n", _pen, S, _pen) + s_en = 0.5 * (np.einsum("ni,nij,nj->n", _pee, S, _pen) + np.einsum("ni,nij,nj->n", _pen, S, _pee)) + mean, rad = 0.5 * (s_ee + s_nn), np.sqrt((0.5 * (s_ee - s_nn)) ** 2 + s_en ** 2) + s_max = (mean + rad).reshape(_PR.shape); inv = rad.reshape(_PR.shape) + un = (U * _pgrid).sum(axis=1) + ut = np.linalg.norm(U - un[:, None] * _pgrid, axis=1).reshape(_PR.shape) + A_, B_ = (_PR * np.cos(_PP)), (_PR * np.sin(_PP)) + fig, axes = plt.subplots(1, 3, figsize=(17, 5.4)) + for ax, fld, ttl, cmap, sym in ((axes[0], s_max, "largest in-plane principal stress", "RdBu_r", True), + (axes[1], inv, "in-plane stress invariant (max shear)", "magma", False), + (axes[2], ut, "tangential speed", "viridis", False)): + lim = np.abs(fld).max() + 1e-30 + pc = ax.pcolormesh(A_, B_, fld, cmap=cmap, shading="auto", **({"vmin": -lim, "vmax": lim} if sym else {})) + for pa, pb in _fault_polylines: + ax.plot(pa, pb, color="w" if cmap != "RdBu_r" else "k", lw=2.0) + for r in (0.25, 0.5, 0.75): + ax.add_patch(plt.Circle((0, 0), r, fill=False, color="gray", lw=0.5, alpha=0.6)) + ax.set_aspect("equal"); ax.set_xlim(-0.9, 0.9); ax.set_ylim(-0.9, 0.9) + ax.set_title(ttl, fontsize=10, loc="left"); ax.set_xticks([]); ax.set_yticks([]) + fig.colorbar(pc, ax=ax, shrink=0.8) + fig.suptitle(f"south polar view (centre lat {_pole_lat:.0f}, radius 0.9 rad): step {step}, t = {t:.3f} periods, De = {args.De}", + fontsize=10) + fig.savefig(os.path.join(args.out, f"polar_{step:04d}.png"), dpi=110, bbox_inches="tight") + plt.close(fig) + + # ----------------------------------------------------------------------------- time loop dt = 1.0 / args.steps_per_period nsteps = int(round(args.periods * args.steps_per_period)) @@ -298,6 +362,7 @@ def figure(step, t): log("STEP", json.dumps(rec)) if k % args.fig_every == 0 or k == nsteps - 1: figure(k, t) + polar_figure(k, t) if rank0: json.dump(records, open(os.path.join(args.out, "records.json"), "w"), indent=1) log("DONE") From 9920e934fa47ccd9f869e720111b9398ed881e89 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 15 Oct 2026 11:07:59 +1100 Subject: [PATCH 05/59] Tidal shell: polar stress fields saved at figure steps, Coulomb failure stress against a reference run Underworld development team with AI support from Claude Code --- scripts/sessions/tidal_shell/tidal_cff.py | 75 +++++++++++++++++++++ scripts/sessions/tidal_shell/tidal_shell.py | 3 + 2 files changed, 78 insertions(+) create mode 100644 scripts/sessions/tidal_shell/tidal_cff.py diff --git a/scripts/sessions/tidal_shell/tidal_cff.py b/scripts/sessions/tidal_shell/tidal_cff.py new file mode 100644 index 000000000..1d9307b91 --- /dev/null +++ b/scripts/sessions/tidal_shell/tidal_cff.py @@ -0,0 +1,75 @@ +"""Coulomb failure stress on receiver planes parallel to the tiger stripes, in the +south polar view, for a run against a reference run at the same step: + + CFF = |tau_s| + mu sigma_n (sigma_n positive in tension: unclamping promotes slip) + dCFF = (tau_s - tau_s_ref) sgn(tau_s_ref) + mu (sigma_n - sigma_n_ref) + +Usage: tidal_cff.py [mu] -> /cff__vs__.png +The receiver plane at a grid point is parallel to the stripes (the local direction +of the stripe family in the azimuthal projection) with its in-plane normal.""" +import glob, os, sys +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +sim, run, ref = sys.argv[1:4] +mu = float(sys.argv[4]) if len(sys.argv) > 4 else 0.4 + + +def receiver_frame(d): + """Stripe-parallel tangent and in-plane normal at the polar grid points.""" + rho, psi = d["rho"].ravel(), d["psi"].ravel() + c, e1, e2 = d["centre"], d["e1"], d["e2"] + a, b = rho * np.cos(psi), rho * np.sin(psi) + eps = 1e-5 + def pmap(a, b): + r = np.maximum(np.hypot(a, b), 1e-30) + return np.cos(r)[:, None] * c[None] + (np.sin(r) / r)[:, None] * (a[:, None] * e1[None] + b[:, None] * e2[None]) + p = pmap(a, b) + t = (pmap(a + eps, b) - pmap(a - eps, b)) / (2 * eps) # along the stripes + t -= (t * p).sum(1)[:, None] * p + t /= np.maximum(np.linalg.norm(t, axis=1), 1e-30)[:, None] + n = np.cross(p, t) # in-plane normal + return p, t, n + + +def resolve(S, t, n): + tau = np.einsum("ni,nij,nj->n", t, S, n) + sn = np.einsum("ni,nij,nj->n", n, S, n) + return tau, sn + + +files = sorted(glob.glob(os.path.join(sim, run, "polar_*.npz"))) +for f in files: + step = os.path.basename(f)[6:10] + g = os.path.join(sim, ref, f"polar_{step}.npz") + if not os.path.exists(g): + continue + A, B = np.load(f), np.load(g) + p, t, n = receiver_frame(A) + tau_a, sn_a = resolve(A["S"], t, n) + tau_b, sn_b = resolve(B["S"], t, n) + cff_a = np.abs(tau_a) + mu * sn_a + cff_b = np.abs(tau_b) + mu * sn_b + dcff = (tau_a - tau_b) * np.sign(tau_b + 1e-30) + mu * (sn_a - sn_b) + shape = A["rho"].shape + X, Y = A["rho"] * np.cos(A["psi"]), A["rho"] * np.sin(A["psi"]) + faults = A["faults"] if A["faults"].size else B["faults"] + fig, axes = plt.subplots(1, 3, figsize=(17, 5.4)) + lim = max(np.abs(cff_a).max(), np.abs(cff_b).max()); dlim = np.abs(dcff).max() + 1e-30 + for ax, fld, ttl, vmin, vmax in ((axes[0], cff_a.reshape(shape), f"CFF {run}", -lim, lim), + (axes[1], cff_b.reshape(shape), f"CFF {ref} (reference)", -lim, lim), + (axes[2], dcff.reshape(shape), f"dCFF {run} - {ref}", -dlim, dlim)): + pc = ax.pcolormesh(X, Y, fld, cmap="RdBu_r", vmin=vmin, vmax=vmax, shading="auto") + for fp in faults: + ax.plot(fp[:, 0], fp[:, 1], "k", lw=2) + for r in (0.25, 0.5, 0.75): + ax.add_patch(plt.Circle((0, 0), r, fill=False, color="gray", lw=0.5, alpha=0.6)) + ax.set_aspect("equal"); ax.set_xlim(-0.9, 0.9); ax.set_ylim(-0.9, 0.9); ax.set_xticks([]); ax.set_yticks([]) + ax.set_title(ttl, fontsize=10, loc="left"); fig.colorbar(pc, ax=ax, shrink=0.8) + fig.suptitle(f"Coulomb failure stress on stripe-parallel receiver planes, mu = {mu}: step {int(step)}, t = {float(A['t']):.3f} periods", + fontsize=10) + out = os.path.join(sim, f"cff_{run}_vs_{ref}_{step}.png") + fig.savefig(out, dpi=110, bbox_inches="tight"); plt.close(fig) + print("wrote", out, "dCFF range", f"{dcff.min():.4f} {dcff.max():.4f}") diff --git a/scripts/sessions/tidal_shell/tidal_shell.py b/scripts/sessions/tidal_shell/tidal_shell.py index 48e160367..ec37fba76 100644 --- a/scripts/sessions/tidal_shell/tidal_shell.py +++ b/scripts/sessions/tidal_shell/tidal_shell.py @@ -297,6 +297,9 @@ def polar_figure(step, t): U = np.asarray(uw.function.global_evaluate(sheet.u.sym, _pgrid_in)).reshape(-1, 3) if not rank0: return + # the polar fields, for post-processing against a reference run (Coulomb stress changes) + np.savez(os.path.join(args.out, f"polar_{step:04d}.npz"), t=t, S=S, U=U, grid=_pgrid, rho=_PR, psi=_PP, + centre=_pc, e1=_pe1, e2=_pe2, faults=np.array([np.column_stack(fp) for fp in _fault_polylines]) if _fault_polylines else np.zeros((0, 0, 2))) import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt From e50cbd4ea74c55d3d296e34a58fabd32d900bf89 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 15 Oct 2026 11:26:27 +1100 Subject: [PATCH 06/59] Tidal shell: Coulomb stress on the weakest plane at each point (Mohr-circle optimum) beside the stripe-parallel receivers Underworld development team with AI support from Claude Code --- scripts/sessions/tidal_shell/tidal_cff.py | 55 +++++++++++++++++------ 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/scripts/sessions/tidal_shell/tidal_cff.py b/scripts/sessions/tidal_shell/tidal_cff.py index 1d9307b91..333ac2a96 100644 --- a/scripts/sessions/tidal_shell/tidal_cff.py +++ b/scripts/sessions/tidal_shell/tidal_cff.py @@ -15,6 +15,7 @@ sim, run, ref = sys.argv[1:4] mu = float(sys.argv[4]) if len(sys.argv) > 4 else 0.4 +planes = sys.argv[5] if len(sys.argv) > 5 else "stripes" # "stripes" or "optimal" def receiver_frame(d): @@ -34,6 +35,24 @@ def pmap(a, b): return p, t, n +def optimal(S, t, n): + """Coulomb stress maximised over all in-plane orientations (Mohr circle): + CFF_max = R sqrt(1 + mu^2) + mu s_mean, with R = (s1 - s3)/2, on the planes at + angle arctan(1/mu)/2 from the s1 direction. Returns CFF_max and the angle of the + weakest plane's normal from the stripe-normal direction (degrees).""" + s_tt = np.einsum("ni,nij,nj->n", t, S, t) + s_nn = np.einsum("ni,nij,nj->n", n, S, n) + s_tn = 0.5 * (np.einsum("ni,nij,nj->n", t, S, n) + np.einsum("ni,nij,nj->n", n, S, t)) + mean = 0.5 * (s_tt + s_nn) + R = np.sqrt((0.5 * (s_tt - s_nn)) ** 2 + s_tn ** 2) + cff = R * np.sqrt(1 + mu ** 2) + mu * mean + theta1 = 0.5 * np.arctan2(2 * s_tn, s_tt - s_nn) # s1 direction from t + beta = 0.5 * np.arctan(1.0 / mu) # weakest planes at +-beta from s1 + ang = np.degrees(theta1 + beta) + ang = (ang + 90) % 180 - 90 + return cff, ang + + def resolve(S, t, n): tau = np.einsum("ni,nij,nj->n", t, S, n) sn = np.einsum("ni,nij,nj->n", n, S, n) @@ -48,28 +67,38 @@ def resolve(S, t, n): continue A, B = np.load(f), np.load(g) p, t, n = receiver_frame(A) - tau_a, sn_a = resolve(A["S"], t, n) - tau_b, sn_b = resolve(B["S"], t, n) - cff_a = np.abs(tau_a) + mu * sn_a - cff_b = np.abs(tau_b) + mu * sn_b - dcff = (tau_a - tau_b) * np.sign(tau_b + 1e-30) + mu * (sn_a - sn_b) + if planes == "optimal": + cff_a, ang_a = optimal(A["S"], t, n) + cff_b, ang_b = optimal(B["S"], t, n) + dcff = cff_a - cff_b + else: + tau_a, sn_a = resolve(A["S"], t, n) + tau_b, sn_b = resolve(B["S"], t, n) + cff_a = np.abs(tau_a) + mu * sn_a + cff_b = np.abs(tau_b) + mu * sn_b + dcff = (tau_a - tau_b) * np.sign(tau_b + 1e-30) + mu * (sn_a - sn_b) shape = A["rho"].shape X, Y = A["rho"] * np.cos(A["psi"]), A["rho"] * np.sin(A["psi"]) faults = A["faults"] if A["faults"].size else B["faults"] - fig, axes = plt.subplots(1, 3, figsize=(17, 5.4)) + ncol = 4 if planes == "optimal" else 3 + fig, axes = plt.subplots(1, ncol, figsize=(5.6 * ncol, 5.4)) lim = max(np.abs(cff_a).max(), np.abs(cff_b).max()); dlim = np.abs(dcff).max() + 1e-30 - for ax, fld, ttl, vmin, vmax in ((axes[0], cff_a.reshape(shape), f"CFF {run}", -lim, lim), - (axes[1], cff_b.reshape(shape), f"CFF {ref} (reference)", -lim, lim), - (axes[2], dcff.reshape(shape), f"dCFF {run} - {ref}", -dlim, dlim)): - pc = ax.pcolormesh(X, Y, fld, cmap="RdBu_r", vmin=vmin, vmax=vmax, shading="auto") + label = "CFF on the weakest plane" if planes == "optimal" else "CFF on stripe-parallel planes" + panels = [(axes[0], cff_a.reshape(shape), f"{label}: {run}", -lim, lim, "RdBu_r"), + (axes[1], cff_b.reshape(shape), f"{label}: {ref} (reference)", -lim, lim, "RdBu_r"), + (axes[2], dcff.reshape(shape), f"dCFF {run} - {ref}", -dlim, dlim, "RdBu_r")] + if planes == "optimal": + panels.append((axes[3], ang_a.reshape(shape), f"weakest-plane normal from the stripe normal (deg): {run}", -90, 90, "twilight")) + for ax, fld, ttl, vmin, vmax, cmap in panels: + pc = ax.pcolormesh(X, Y, fld, cmap=cmap, vmin=vmin, vmax=vmax, shading="auto") for fp in faults: ax.plot(fp[:, 0], fp[:, 1], "k", lw=2) for r in (0.25, 0.5, 0.75): ax.add_patch(plt.Circle((0, 0), r, fill=False, color="gray", lw=0.5, alpha=0.6)) ax.set_aspect("equal"); ax.set_xlim(-0.9, 0.9); ax.set_ylim(-0.9, 0.9); ax.set_xticks([]); ax.set_yticks([]) ax.set_title(ttl, fontsize=10, loc="left"); fig.colorbar(pc, ax=ax, shrink=0.8) - fig.suptitle(f"Coulomb failure stress on stripe-parallel receiver planes, mu = {mu}: step {int(step)}, t = {float(A['t']):.3f} periods", - fontsize=10) - out = os.path.join(sim, f"cff_{run}_vs_{ref}_{step}.png") + fig.suptitle(f"Coulomb failure stress ({'weakest plane at each point' if planes == 'optimal' else 'stripe-parallel receiver planes'}), " + f"mu = {mu}: step {int(step)}, t = {float(A['t']):.3f} periods", fontsize=10) + out = os.path.join(sim, f"cff{'opt' if planes == 'optimal' else ''}_{run}_vs_{ref}_{step}.png") fig.savefig(out, dpi=110, bbox_inches="tight"); plt.close(fig) print("wrote", out, "dCFF range", f"{dcff.min():.4f} {dcff.max():.4f}") From c8cff0d81fbed6545ccc6da8eec15c1e46c1a0d3 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 15 Oct 2026 11:44:19 +1100 Subject: [PATCH 07/59] Tidal shell: Coulomb friction on the stripes with a background overburden, driving-traction probes on virtual stripes, stripe azimuth, cycle plots and the Berne et al. figure analogue CoulombFaultLaw takes a constant effective normal stress added to the reaction-fed one (compression positive), the clamped-but-modulated fault of Berne et al. 2024. Underworld development team with AI support from Claude Code --- .../sessions/tidal_shell/tidal_berne_fig.py | 56 +++++++++++++++ .../sessions/tidal_shell/tidal_cycle_plot.py | 41 +++++++++++ scripts/sessions/tidal_shell/tidal_shell.py | 71 +++++++++++++++++-- src/underworld3/utilities/fault_contact.py | 20 ++++-- 4 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 scripts/sessions/tidal_shell/tidal_berne_fig.py create mode 100644 scripts/sessions/tidal_shell/tidal_cycle_plot.py diff --git a/scripts/sessions/tidal_shell/tidal_berne_fig.py b/scripts/sessions/tidal_shell/tidal_berne_fig.py new file mode 100644 index 000000000..4a37b1883 --- /dev/null +++ b/scripts/sessions/tidal_shell/tidal_berne_fig.py @@ -0,0 +1,56 @@ +"""Berne et al. (2024) Fig. 2 analogue: the locked shell's driving tractions on +the stripe planes (shear, normal), the Coulomb traction tau_c = |tau| - mu (T_b - sigma_n) +(sigma_n tension positive; positive tau_c = failure), and the frictional shell's +accumulated lateral slip, all against mean anomaly, for the last cycle; plus the +spin-up parameter Xi(t) = (S(t) - S(t + T)) / S(t) on the mean slip (their Eq. 21). +Usage: """ +import json, os, sys +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +sim, locked, fric = sys.argv[1:4] +mu, Tb = float(sys.argv[4]), float(sys.argv[5]) +L = json.load(open(os.path.join(sim, locked, "records.json"))) +F = json.load(open(os.path.join(sim, fric, "records.json"))) +tL = np.array([r["t"] for r in L]); tF = np.array([r["t"] for r in F]) +names = sorted(L[0]["drive"].keys()) +last_L = tL > tL.max() - 1.0 + 1e-9 +last_F = tF > tF.max() - 1.0 + 1e-9 +phL = (tL[last_L] - np.floor(tL[last_L] - 1e-9)) * 360.0 +phF = (tF[last_F] - np.floor(tF[last_F] - 1e-9)) * 360.0 +fig, axes = plt.subplots(2, 2, figsize=(13, 8.5)) +tau_all, sn_all, tc_all, s_all = [], [], [], [] +for nm in names: + tau = np.array([r["drive"][nm]["tau"] for r in L])[last_L] + sn = np.array([r["drive"][nm]["sigma_n"] for r in L])[last_L] + tc = np.abs(tau) - mu * (Tb - sn) + axes[0, 0].plot(phL, tau, label=nm); axes[0, 1].plot(phL, sn, label=nm); axes[1, 0].plot(phL, tc, label=nm) + tau_all.append(tau); sn_all.append(sn); tc_all.append(tc) + if nm in F[0]["slip"]: + cum = np.array([r["slip"][nm]["cumulative"] for r in F])[last_F] + s = cum - cum[0] + axes[1, 1].plot(phF, s, label=nm); s_all.append(s) +for ax, arrs in ((axes[0, 0], tau_all), (axes[0, 1], sn_all), (axes[1, 0], tc_all), (axes[1, 1], s_all)): + if arrs: + ax.plot(phL if ax is not axes[1, 1] else phF, np.mean(arrs, axis=0), "k", lw=2.2, label="combined") + ax.axhline(0, color="k", lw=0.6); ax.grid(alpha=0.3); ax.set_xlim(0, 360); ax.set_xlabel("mean anomaly (deg)") +axes[0, 0].set_title("driving shear traction on the stripe planes (locked shell)") +axes[0, 1].set_title("driving normal traction (tension positive)") +axes[1, 0].set_title(f"Coulomb traction |tau| - mu (T_b - sigma_n), mu = {mu}, T_b = {Tb} (> 0: failure)") +axes[1, 1].set_title(f"accumulated lateral slip, frictional shell ({fric})") +axes[0, 0].legend(fontsize=7) +fig.suptitle(f"Berne et al. Fig. 2 analogue: last cycle of {locked} / {fric}", fontsize=11) +fig.tight_layout(); fig.savefig(os.path.join(sim, f"berne_fig2_{fric}.png"), dpi=120) + +# spin-up parameter on the mean slip rate magnitude over the stripes +fig2, ax = plt.subplots(figsize=(7, 4)) +S = np.array([np.mean([abs(r["slip"][nm]["signed_mean"]) for nm in r["slip"]]) for r in F]) +dt = tF[1] - tF[0]; nper = int(round(1.0 / dt)) +xi = (S[:-nper] - S[nper:]) / np.maximum(S[:-nper], 1e-12) +ax.plot(tF[:-nper], xi); ax.set_xscale("log"); ax.axhline(0, color="k", lw=0.6); ax.grid(alpha=0.3) +ax.set_xlabel("time (periods)"); ax.set_ylabel("spin-up parameter Xi"); ax.set_title(f"cycle-to-cycle mismatch of the mean slip rate, {fric}") +fig2.tight_layout(); fig2.savefig(os.path.join(sim, f"berne_spinup_{fric}.png"), dpi=120) +print("net slip over the last cycle per stripe:", {nm: round(float(s[-1]), 5) for nm, s in zip(names, s_all)}) +print("Xi at the last available cycle:", round(float(xi[-1]), 4) if len(xi) else None) diff --git a/scripts/sessions/tidal_shell/tidal_cycle_plot.py b/scripts/sessions/tidal_shell/tidal_cycle_plot.py new file mode 100644 index 000000000..061758e20 --- /dev/null +++ b/scripts/sessions/tidal_shell/tidal_cycle_plot.py @@ -0,0 +1,41 @@ +"""Slip cycle of each stripe against orbital phase, cycle by cycle (the +asymmetry that develops under cyclic loading): signed slip rate along the +stripe, cumulative slip, and the normal traction (clamping) on the stripe. +Usage: [ ...] -> /cycle_.png and a per-cycle table.""" +import json, os, sys +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +sim = sys.argv[1] +for run in sys.argv[2:]: + rs = json.load(open(os.path.join(sim, run, "records.json"))) + t = np.array([r["t"] for r in rs]) + names = sorted(rs[0]["slip"].keys()) + ncyc = int(np.ceil(t.max() - 1e-9)) + fig, axes = plt.subplots(len(names), 3, figsize=(16, 3.0 * len(names)), squeeze=False) + print(f"{run}: net slip per cycle (signed, along the stripe) and clamping range") + for i, nm in enumerate(names): + rate = np.array([r["slip"][nm]["signed_mean"] for r in rs]) + cum = np.array([r["slip"][nm]["cumulative"] for r in rs]) + sn = np.array([r["slip"][nm].get("sigma_n_mean", np.nan) for r in rs]) + for c in range(ncyc): + m = (t > c) & (t <= c + 1 + 1e-9) + ph = (t[m] - c) * 360.0 + axes[i, 0].plot(ph, rate[m], label=f"cycle {c + 1}") + axes[i, 1].plot(ph, cum[m] - (cum[m][0] - rate[m][0] * (t[m][0] - c)), label=f"cycle {c + 1}") + axes[i, 2].plot(ph, sn[m], label=f"cycle {c + 1}") + net = cum[m][-1] - (cum[m][0] - rate[m][0] * (t[1] - t[0])) + print(f" {nm} cycle {c + 1}: net slip {net:+.5f}, |rate| max {np.abs(rate[m]).max():.4f}, " + f"sigma_n mean range {np.nanmin(sn[m]):+.4f} .. {np.nanmax(sn[m]):+.4f}") + axes[i, 0].set_ylabel(nm); axes[i, 0].axhline(0, color="k", lw=0.5) + axes[i, 1].axhline(0, color="k", lw=0.5); axes[i, 2].axhline(0, color="k", lw=0.5) + for ax in axes[i]: + ax.grid(alpha=0.3); ax.set_xlim(0, 360) + axes[0, 0].set_title("signed slip rate along the stripe"); axes[0, 1].set_title("slip within the cycle (from the cycle start)") + axes[0, 2].set_title("normal traction on the stripe (negative = clamped)") + for ax in axes[-1]: ax.set_xlabel("orbital phase (deg)") + axes[0, 0].legend(fontsize=7) + fig.suptitle(run, fontsize=11); fig.tight_layout() + fig.savefig(os.path.join(sim, f"cycle_{run}.png"), dpi=110); plt.close(fig) diff --git a/scripts/sessions/tidal_shell/tidal_shell.py b/scripts/sessions/tidal_shell/tidal_shell.py index ec37fba76..68e2a8ba3 100644 --- a/scripts/sessions/tidal_shell/tidal_shell.py +++ b/scripts/sessions/tidal_shell/tidal_shell.py @@ -47,7 +47,14 @@ ap.add_argument("--fault_len", type=float, default=0.52, help="fault length (rad; Enceladus ~130 km on R 252 km)") ap.add_argument("--fault_spacing", type=float, default=0.14, help="spacing between the stripes (rad; ~35 km)") ap.add_argument("--fault_eta", type=float, default=0.0, help="viscous interface law coefficient (0: frictionless)") +ap.add_argument("--fault_law", default="frictionless", choices=["frictionless", "viscous", "coulomb"], + help="coulomb: regularised Coulomb friction with the strength fed by the no-opening reaction") +ap.add_argument("--fault_mu", type=float, default=0.4, help="Coulomb friction coefficient") +ap.add_argument("--fault_V0", type=float, default=1e-3, help="Coulomb regularisation slip rate (well below the tidal slip rates)") +ap.add_argument("--fault_Tb", type=float, default=0.0, help="Coulomb: constant effective overburden (compression positive) added to the reaction") +ap.add_argument("--probe_stripes", type=int, default=0, help="unfaulted run: record the driving tractions on N virtual stripe traces (Berne et al. Fig 2a,b)") ap.add_argument("--fault_lat", type=float, default=-90.0, help="latitude of the stripe set's centre (deg)") +ap.add_argument("--fault_azimuth", type=float, default=-90.0, help="stripe direction: angle from the sub-planet direction at the set's centre, counter-clockwise viewed from outside (deg); -90 = east-west") ap.add_argument("--verbose", action="store_true") args = ap.parse_args() @@ -71,8 +78,11 @@ def stripe_points(offset, half_len, n=9, lat0=-90.0): direction at a distance `offset` along e2.""" th0 = np.radians(lat0) c = np.array([np.cos(th0), 0.0, np.sin(th0)]) # centre - e1 = np.array([0.0, 1.0, 0.0]) # east at the centre - e2 = np.cross(c, e1) # north-ish + east = np.array([0.0, 1.0, 0.0]) + sub = np.cross(c, east) # toward the sub-planet point (+x) along the surface + az = np.radians(args.fault_azimuth) + e1 = np.cos(az) * sub + np.sin(az) * np.cross(c, sub) # stripe direction + e2 = np.cross(c, e1) # across the stripes a = np.linspace(-half_len, half_len, n) pts = [] for ai in a: @@ -94,7 +104,12 @@ def stripe_points(offset, half_len, n=9, lat0=-90.0): x, y, z = sphere.X sheet = uw.systems.ThinSheet(sphere) for name in fault_names: - sheet.add_fault_bc(args.fault_eta, name) + if args.fault_law == "coulomb": + from underworld3.utilities.fault_contact import add_coulomb_fault_bc + add_coulomb_fault_bc(sheet, args.fault_mu, name, sigma_n="reaction", V0=args.fault_V0, background=args.fault_Tb) + else: + sheet.add_fault_bc(args.fault_eta if args.fault_law == "viscous" else 0.0, name) +cum_slip = {name: 0.0 for name in fault_names} # signed, along the stripe direction if args.De > 0: sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( sheet.Unknowns, order=1, integrator="etd", objective_rate=args.objective) @@ -266,7 +281,9 @@ def figure(step, t): _pole_lat = args.fault_lat if args.faults > 0 else -90.0 _th0 = np.radians(_pole_lat) _pc = np.array([np.cos(_th0), 0.0, np.sin(_th0)]) -_pe1 = np.array([0.0, 1.0, 0.0]); _pe2 = np.cross(_pc, _pe1) +_sub = np.cross(_pc, np.array([0.0, 1.0, 0.0])) +_az = np.radians(args.fault_azimuth) +_pe1 = np.cos(_az) * _sub + np.sin(_az) * np.cross(_pc, _sub); _pe2 = np.cross(_pc, _pe1) _prho = np.linspace(0.0, 0.9, 61)[1:] _ppsi = np.linspace(-np.pi, np.pi, 145) _PR, _PP = np.meshgrid(_prho, _ppsi) @@ -291,6 +308,30 @@ def to_polar(points): _fault_polylines = [to_polar(pts) for _n, pts in (faults if args.faults > 0 else [])] +# virtual stripe traces for the driving tractions of an unfaulted (locked) shell +_probe = [] +if args.probe_stripes > 0 and args.faults == 0: + offsets = (np.arange(args.probe_stripes) - 0.5 * (args.probe_stripes - 1)) * args.fault_spacing + for i, off in enumerate(offsets): + pts = stripe_points(off, 0.5 * args.fault_len, n=33, lat0=args.fault_lat) + tt = np.gradient(pts, axis=0); tt -= (tt * pts).sum(1)[:, None] * pts + tt /= np.maximum(np.linalg.norm(tt, axis=1), 1e-30)[:, None] + nn = np.cross(pts, tt) + _probe.append((f"Stripe{i}", pts * (1.0 - 0.3 * args.h ** 2), tt, nn)) + _fault_polylines = [to_polar(pts) for _n, pts, _t, _nn in _probe] + + +def driving_tractions(): + """Shear (along the stripe, signed) and normal (tension positive) traction on + each virtual stripe, averaged along it: the locked shell's driving tractions.""" + out = {} + for name, pts, tt, nn in _probe: + S = stress_on(pts) + tau = np.einsum("ni,nij,nj->n", tt, S, nn) + sn = np.einsum("ni,nij,nj->n", nn, S, nn) + out[name] = {"tau": float(tau.mean()), "sigma_n": float(sn.mean()), "tau_max": float(np.abs(tau).max())} + return out + def polar_figure(step, t): S = stress_on(_pgrid_in) @@ -352,13 +393,35 @@ def polar_figure(step, t): if args.check: rec.update(diagnostics(t)) rec.update(stress_diagnostics(t)) + if _probe: + rec["drive"] = driving_tractions() if fault_names: from underworld3.utilities.fault_contact import fault_pair_jumps slips = {} for name in fault_names: _c, jumps, normals = fault_pair_jumps(sheet, name, info, gather=True) # every rank holds the fault v = np.linalg.norm(jumps, axis=1) if len(jumps) else np.zeros(1) + # signed slip along the stripe direction (the stripes run along e1 at the + # set's centre; its tangent projection at each pair) + if len(jumps): + pc_ = np.asarray(_c) / np.linalg.norm(_c, axis=1)[:, None] + tdir = _pe1[None] - (pc_ @ _pe1)[:, None] * pc_ + tdir /= np.maximum(np.linalg.norm(tdir, axis=1), 1e-30)[:, None] + signed = float(np.mean(np.einsum("ij,ij->i", jumps, tdir))) + else: + signed = 0.0 + cum_slip[name] += signed * dt + # clamping: the normal traction on the stripe from the no-opening reaction + # (negative in compression); rank-local per pair, reduced collectively + from underworld3.utilities.fault_contact import fault_normal_traction + _cs, sig = fault_normal_traction(sheet, name, info) + loc = np.array([float(np.sum(sig)), float(len(sig)), float(np.min(sig)) if len(sig) else np.inf, + float(np.max(sig)) if len(sig) else -np.inf]) + tot = comm.allreduce(loc[0], op=MPI.SUM), comm.allreduce(loc[1], op=MPI.SUM) + sn_min, sn_max = comm.allreduce(loc[2], op=MPI.MIN), comm.allreduce(loc[3], op=MPI.MAX) slips[name] = {"max": float(v.max()), "rms": float(np.sqrt((v ** 2).mean())), + "signed_mean": signed, "cumulative": cum_slip[name], + "sigma_n_mean": float(tot[0] / max(tot[1], 1.0)), "sigma_n_min": float(sn_min), "sigma_n_max": float(sn_max), "leak": float(np.abs(np.einsum("ij,ij->i", jumps, normals)).max()) if len(jumps) else 0.0} rec["slip"] = slips records.append(rec) diff --git a/src/underworld3/utilities/fault_contact.py b/src/underworld3/utilities/fault_contact.py index e1239a8c6..2786fb3d6 100644 --- a/src/underworld3/utilities/fault_contact.py +++ b/src/underworld3/utilities/fault_contact.py @@ -358,10 +358,16 @@ def ViscousFaultLaw(eta_f): return SymbolicFaultLaw(float(eta_f) * slip_rate) -def CoulombFaultLaw(mu, sigma_n, V0): +def CoulombFaultLaw(mu, sigma_n, V0, background=0.0): r"""Regularised Coulomb friction, :math:`\tau = \mu\sigma_n\,\tfrac{2}{\pi}\arctan(V/V_0)`. + ``background`` adds a constant effective normal stress (compression + positive) to the reaction-fed one: the overburden less the pore pressure + of a fault that stays clamped while a tide modulates its strength + (Berne et al. 2024 on the tiger stripes: an overburden of about 50 kPa + against tidal normal tractions of about 25 kPa). + Smooth, bounded by the strength :math:`\mu\sigma_n`, monotone. Below the regularisation velocity :math:`V_0` the fault sticks (residual creep :math:`\sim V_0`); above it the shear traction saturates and the @@ -372,16 +378,16 @@ def CoulombFaultLaw(mu, sigma_n, V0): """ if sigma_n == "reaction": # the strength clamp is the law's own: bare friction loses all - # strength the moment the normal stress turns tensile - strength = float(mu) * sympy.Max(normal_stress, 0) + # strength the moment the (effective) normal stress turns tensile + strength = float(mu) * sympy.Max(normal_stress + float(background), 0) else: - strength = float(mu) * float(sigma_n) + strength = float(mu) * (float(sigma_n) + float(background)) return SymbolicFaultLaw( strength * (2 / sympy.pi) * sympy.atan(slip_rate / float(V0))) def add_coulomb_fault_bc(solver, conds, boundary, sigma_n=None, V0=1.0e-5, - normal=None): + normal=None, background=0.0): """Regularised Coulomb friction on the split fault ``boundary`` (value-first: ``conds`` is the friction coefficient mu). @@ -401,8 +407,10 @@ def add_coulomb_fault_bc(solver, conds, boundary, sigma_n=None, V0=1.0e-5, if mu <= 0.0 or float(V0) <= 0.0 or ( sigma_n != "reaction" and float(sigma_n) <= 0.0): raise ValueError("mu, sigma_n and V0 must all be positive.") + if float(background) < 0.0: + raise ValueError("background (the constant effective normal stress, compression positive) must be >= 0.") add_frictionless_fault_bc(solver, boundary, normal=normal) - _register_law(solver, boundary, CoulombFaultLaw(mu, sigma_n, V0)) + _register_law(solver, boundary, CoulombFaultLaw(mu, sigma_n, V0, background)) def RateStateFaultLaw(a, b, V0, f0, Dc, sigma_n="reaction"): From a791bcd0c1362949cc49fab4982843110a6f33d2 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 15 Oct 2026 12:57:52 +1100 Subject: [PATCH 08/59] Tidal shell: normalised Coulomb comparison for the rheology reference Underworld development team with AI support from Claude Code --- scripts/sessions/tidal_shell/tidal_cff.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/scripts/sessions/tidal_shell/tidal_cff.py b/scripts/sessions/tidal_shell/tidal_cff.py index 333ac2a96..00b114c7a 100644 --- a/scripts/sessions/tidal_shell/tidal_cff.py +++ b/scripts/sessions/tidal_shell/tidal_cff.py @@ -16,6 +16,7 @@ sim, run, ref = sys.argv[1:4] mu = float(sys.argv[4]) if len(sys.argv) > 4 else 0.4 planes = sys.argv[5] if len(sys.argv) > 5 else "stripes" # "stripes" or "optimal" +normalised = len(sys.argv) > 6 and sys.argv[6] == "normalised" # each run scaled by its own rms shear def receiver_frame(d): @@ -67,13 +68,19 @@ def resolve(S, t, n): continue A, B = np.load(f), np.load(g) p, t, n = receiver_frame(A) + SA, SB = A["S"], B["S"] + if normalised: + # the rheology comparison: scale each run's stress by its own rms shear on the + # receiver planes so the maps compare pattern and phase, not amplitude + _ta, _ = resolve(SA, t, n); _tb, _ = resolve(SB, t, n) + SA = SA / (np.sqrt(np.mean(_ta ** 2)) + 1e-30); SB = SB / (np.sqrt(np.mean(_tb ** 2)) + 1e-30) if planes == "optimal": - cff_a, ang_a = optimal(A["S"], t, n) - cff_b, ang_b = optimal(B["S"], t, n) + cff_a, ang_a = optimal(SA, t, n) + cff_b, ang_b = optimal(SB, t, n) dcff = cff_a - cff_b else: - tau_a, sn_a = resolve(A["S"], t, n) - tau_b, sn_b = resolve(B["S"], t, n) + tau_a, sn_a = resolve(SA, t, n) + tau_b, sn_b = resolve(SB, t, n) cff_a = np.abs(tau_a) + mu * sn_a cff_b = np.abs(tau_b) + mu * sn_b dcff = (tau_a - tau_b) * np.sign(tau_b + 1e-30) + mu * (sn_a - sn_b) @@ -98,7 +105,7 @@ def resolve(S, t, n): ax.set_aspect("equal"); ax.set_xlim(-0.9, 0.9); ax.set_ylim(-0.9, 0.9); ax.set_xticks([]); ax.set_yticks([]) ax.set_title(ttl, fontsize=10, loc="left"); fig.colorbar(pc, ax=ax, shrink=0.8) fig.suptitle(f"Coulomb failure stress ({'weakest plane at each point' if planes == 'optimal' else 'stripe-parallel receiver planes'}), " - f"mu = {mu}: step {int(step)}, t = {float(A['t']):.3f} periods", fontsize=10) - out = os.path.join(sim, f"cff{'opt' if planes == 'optimal' else ''}_{run}_vs_{ref}_{step}.png") + f"mu = {mu}{', each run normalised by its rms shear' if normalised else ''}: step {int(step)}, t = {float(A['t']):.3f} periods", fontsize=10) + out = os.path.join(sim, f"cff{'opt' if planes == 'optimal' else ''}{'norm' if normalised else ''}_{run}_vs_{ref}_{step}.png") fig.savefig(out, dpi=110, bbox_inches="tight"); plt.close(fig) print("wrote", out, "dCFF range", f"{dcff.min():.4f} {dcff.max():.4f}") From dd669146897b4bc40d74ae6e1635a79f3d9ef1ed Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 15 Oct 2026 13:35:47 +1100 Subject: [PATCH 09/59] Tidal shell: the Coulomb overburden is a stress, converted by the thickness to the sheet's resultant units; clamping reported as a stress Underworld development team with AI support from Claude Code --- scripts/sessions/tidal_shell/tidal_shell.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/sessions/tidal_shell/tidal_shell.py b/scripts/sessions/tidal_shell/tidal_shell.py index 68e2a8ba3..3eecff67b 100644 --- a/scripts/sessions/tidal_shell/tidal_shell.py +++ b/scripts/sessions/tidal_shell/tidal_shell.py @@ -51,7 +51,7 @@ help="coulomb: regularised Coulomb friction with the strength fed by the no-opening reaction") ap.add_argument("--fault_mu", type=float, default=0.4, help="Coulomb friction coefficient") ap.add_argument("--fault_V0", type=float, default=1e-3, help="Coulomb regularisation slip rate (well below the tidal slip rates)") -ap.add_argument("--fault_Tb", type=float, default=0.0, help="Coulomb: constant effective overburden (compression positive) added to the reaction") +ap.add_argument("--fault_Tb", type=float, default=0.0, help="Coulomb: constant effective overburden as a STRESS (compression positive); enters the law as L * T_b") ap.add_argument("--probe_stripes", type=int, default=0, help="unfaulted run: record the driving tractions on N virtual stripe traces (Berne et al. Fig 2a,b)") ap.add_argument("--fault_lat", type=float, default=-90.0, help="latitude of the stripe set's centre (deg)") ap.add_argument("--fault_azimuth", type=float, default=-90.0, help="stripe direction: angle from the sub-planet direction at the set's centre, counter-clockwise viewed from outside (deg); -90 = east-west") @@ -106,7 +106,10 @@ def stripe_points(offset, half_len, n=9, lat0=-90.0): for name in fault_names: if args.fault_law == "coulomb": from underworld3.utilities.fault_contact import add_coulomb_fault_bc - add_coulomb_fault_bc(sheet, args.fault_mu, name, sigma_n="reaction", V0=args.fault_V0, background=args.fault_Tb) + # the sheet's fault reaction is thickness-integrated (a force per unit length, + # L times the stress), so the overburden given as a stress enters as L * T_b + add_coulomb_fault_bc(sheet, args.fault_mu, name, sigma_n="reaction", V0=args.fault_V0, + background=args.fault_Tb * args.L) else: sheet.add_fault_bc(args.fault_eta if args.fault_law == "viscous" else 0.0, name) cum_slip = {name: 0.0 for name in fault_names} # signed, along the stripe direction @@ -415,6 +418,7 @@ def polar_figure(step, t): # (negative in compression); rank-local per pair, reduced collectively from underworld3.utilities.fault_contact import fault_normal_traction _cs, sig = fault_normal_traction(sheet, name, info) + sig = np.asarray(sig) / args.L # resultant -> stress (negative in compression) loc = np.array([float(np.sum(sig)), float(len(sig)), float(np.min(sig)) if len(sig) else np.inf, float(np.max(sig)) if len(sig) else -np.inf]) tot = comm.allreduce(loc[0], op=MPI.SUM), comm.allreduce(loc[1], op=MPI.SUM) From 52e27ecbf46463f20e284e579d1492e829ebe3a0 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 15 Oct 2026 14:13:24 +1100 Subject: [PATCH 10/59] Fault contact: the trace mass of a surface-mesh fault follows the facet topology On a sheet on a sphere the fault is an edge carried by a 3-vector; the de-smearing mass picked the triangle's weights by the component count and gave the mid-edge nodes an eighth of their mass, so the effective normal stress the interface laws read (and the friction they produced) was off by node-dependent factors of 5 to 8. The reaction is the membrane resultant, L sigma_nn, now to 0.4 percent on every pair of the breathing sphere (test_1092). Underworld development team with AI support from Claude Code --- src/underworld3/utilities/fault_contact.py | 12 +++++++++-- .../test_1092_thin_sheet_prescribed_normal.py | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/underworld3/utilities/fault_contact.py b/src/underworld3/utilities/fault_contact.py index 2786fb3d6..70a07d185 100644 --- a/src/underworld3/utilities/fault_contact.py +++ b/src/underworld3/utilities/fault_contact.py @@ -930,8 +930,13 @@ def node_index(q): # redistribution that precedes every parallel split the fault is # rank-interior and the exchange is a no-op on the fault rows. mass = np.zeros(len(rows)) + # the branch follows the facet TOPOLOGY (mesh.dim), as the quadrature + # above does, not the component count: a fault on a surface mesh is an + # edge carried by a 3-vector, and the triangle weights gave its mid-edge + # nodes an eighth of their mass (measured: the breathing sphere's fault + # read 5 to 8 times its membrane resultant) for idx, measure, _law_id in facets: - if dim == 2: + if solver.mesh.dim == 2: weights = (measure / 6.0, 2.0 * measure / 3.0, measure / 6.0) else: @@ -1042,7 +1047,10 @@ def nodal_normal_traction(self, solver, reaction): reaction (negative in compression): the jump-normal constraint's nodal load l_i = (n.r+ - n.r-)/2, de-smeared by the lumped trace mass. The reaction must be the PURE bulk residual — the loop - stashes it before the interface force is added.""" + stashes it before the interface force is added. On a thin sheet the + residual carries the thickness, so this is the membrane RESULTANT + (L sigma_nn): the interface laws' effective normal stress and any + background they add are in the same units.""" dm = solver.dm dim = self._nrm.shape[1] lvec = dm.getLocalVec() diff --git a/tests/test_1092_thin_sheet_prescribed_normal.py b/tests/test_1092_thin_sheet_prescribed_normal.py index 0ebd95afc..3c973e086 100644 --- a/tests/test_1092_thin_sheet_prescribed_normal.py +++ b/tests/test_1092_thin_sheet_prescribed_normal.py @@ -83,3 +83,24 @@ def test_degree_two_membrane(): assert errs[0] < 0.10 assert errs[1] < 0.04 assert errs[1] < 0.6 * errs[0] + + +def test_fault_reaction_is_the_membrane_resultant(): + """A split fault in the breathing sheet carries the membrane stress across + it: the no-opening reaction, de-smeared by the trace mass, is the resultant + L * 6 eta W on every pair (the trace mass once took the triangle's weights + on a surface mesh and read 5 to 8 times that).""" + from underworld3.utilities.fault_contact import fault_normal_traction + W, eta, L = 0.1, 1.0, 0.08 + sphere = _sphere(0.15) + phi = np.linspace(-0.3, 0.3, 9) + split = sphere.add_fault(("Flt", np.column_stack([np.cos(phi), np.sin(phi), np.zeros_like(phi)]))) + sheet = _sheet(split, eta) + sheet.thickness = L + sheet.add_fault_bc(0, "Flt") + sheet.normal_velocity = W + sheet.solve() + _c, sig = fault_normal_traction(sheet, "Flt", sheet._rotated_freeslip_info) + expected = L * 6 * eta * W + assert len(sig) > 5 + assert np.abs(sig - expected).max() < 0.02 * expected From 887a4efc4ffcb05720e206ca100c96e96b410224 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 15 Oct 2026 18:00:17 +1100 Subject: [PATCH 11/59] Thin sheet tide: the Maxwell transfer-function benchmark as a test, the prediction in the sweep plot Amplitude (24/11) mu A wt/sqrt(1+wt^2) and lag atan(1/wt): measured at 128 steps per period 0.0195 against 0.0193 and 32 against 28 degrees at De 0.3; the lag excess is first order in the step (15 degrees at 32 steps per period). Underworld development team with AI support from Claude Code --- .../sessions/tidal_shell/tidal_sweep_plot.py | 18 ++++++- .../test_1092_thin_sheet_prescribed_normal.py | 52 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/scripts/sessions/tidal_shell/tidal_sweep_plot.py b/scripts/sessions/tidal_shell/tidal_sweep_plot.py index 57f9e9192..e758287ff 100644 --- a/scripts/sessions/tidal_shell/tidal_sweep_plot.py +++ b/scripts/sessions/tidal_shell/tidal_sweep_plot.py @@ -28,5 +28,19 @@ axes[2].set_title("rms tangential speed"); for ax in axes: ax.set_xlabel("t (periods)"); ax.grid(alpha=0.3); ax.legend(fontsize=7) fig.tight_layout(); fig.savefig(os.path.join(sim, "sweep.png"), dpi=120) -print("run max|inv| trace amplitude on cos pattern phase (deg, lag of the stress behind the radial displacement rate)") -for s in summary: print(f"{s[0]:8s} {s[1]:.4f} {s[2]:.4f} {s[3]:7.1f}") +# Maxwell transfer function for the membrane (strain in phase with the displacement): +# trace amplitude on the cos pattern = (24/11) mu A * wt/sqrt(1+wt^2), lag behind the +# displacement = atan(1/wt), wt = 2 pi De; the viscous sheet: 6 eta (4/11) w A at -90 deg +import re +A_amp, mu_mod, eta_v = 0.01, 1.0, 1.0 +print("run max|inv| trace amp (meas) phase meas (deg, vs cos(nt) = the displacement) | Maxwell prediction amp, lag") +for r_, inv_, amp_, ph_ in summary: + m = re.search(r"De([0-9.]+)", r_) + De = float(m.group(1)) if m else 0.0 + if De > 0: + wt = 2 * np.pi * De + amp_th = (24 / 11) * mu_mod * A_amp * wt / np.sqrt(1 + wt ** 2) + lag_th = -np.degrees(np.arctan(1 / wt)) + else: + amp_th, lag_th = 6 * eta_v * (4 / 11) * 2 * np.pi * A_amp, -90.0 + print(f"{r_:9s} {inv_:.4f} {amp_:.4f} {ph_:7.1f} | {amp_th:.4f} {lag_th:7.1f}") diff --git a/tests/test_1092_thin_sheet_prescribed_normal.py b/tests/test_1092_thin_sheet_prescribed_normal.py index 3c973e086..7814a2c8e 100644 --- a/tests/test_1092_thin_sheet_prescribed_normal.py +++ b/tests/test_1092_thin_sheet_prescribed_normal.py @@ -104,3 +104,55 @@ def test_fault_reaction_is_the_membrane_resultant(): expected = L * 6 * eta * W assert len(sig) > 5 assert np.abs(sig - expected).max() < 0.02 * expected + + +@pytest.mark.level_3 +def test_maxwell_transfer_function_under_a_tide(): + """A Maxwell membrane under the degree-2 eccentricity tide: the stress trace + on the cos pattern has amplitude (24/11) mu A * wt / sqrt(1 + wt^2) and lags + the displacement by atan(1/wt), wt = 2 pi De (the velocity is instantaneous: + 3/11 of the pattern's gradient whatever the rheology). Measured at 128 steps + per period: amplitude within 1.5%, lag 32 deg against 28 at De 0.3; at the + 64 steps per period used here the lag is about 4 deg larger still (first + order in the step).""" + De, A, mu = 0.3, 0.01, 1.0 + steps, periods = 64, 3 + sphere = _sphere(0.3) + x, y, z = sphere.X + sheet = uw.systems.ThinSheet(sphere) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd", objective_rate="none") + sheet.constitutive_model.Parameters.shear_modulus = mu + sheet.constitutive_model.Parameters.shear_viscosity_0 = De + sheet.thickness = 0.08 + sheet.tolerance = 1.0e-8 + pat_c = -sympy.Rational(3, 2) * (3 * z ** 2 - 1) / 2 + sympy.Rational(9, 4) * (x ** 2 - y ** 2) + pat_s = 6 * x * y + # projection of the stress trace on the cos pattern, area-weighted on a lat-lon grid + lon = np.linspace(-np.pi, np.pi, 73)[:-1]; lat = np.linspace(-np.pi / 2, np.pi / 2, 37)[1:-1] + LON, LAT = np.meshgrid(lon, lat) + g = np.column_stack([(np.cos(LAT) * np.cos(LON)).ravel(), (np.cos(LAT) * np.sin(LON)).ravel(), np.sin(LAT).ravel()]) + wts = np.cos(LAT).ravel() + pc = np.asarray(sympy.lambdify((x, y, z), pat_c, "numpy")(*g.T)).ravel() + rhat = g / np.linalg.norm(g, axis=1)[:, None] + P = np.eye(3)[None] - rhat[:, :, None] * rhat[:, None, :] + dt = 1.0 / steps + t, tc, ts = 0.0, [], [] + for k in range(steps * periods): + t += dt + c, s = float(np.cos(2 * np.pi * t)), float(np.sin(2 * np.pi * t)) + sheet.normal_velocity = A * 2 * np.pi * (-pat_c * s + pat_s * c) + sheet.solve(timestep=dt, zero_init_guess=(k == 0)) + assert sheet._rotated_freeslip_info["converged"] + if t > periods - 1 - 1e-9: + S = np.asarray(uw.function.evaluate(sheet.stress, g * (1 - 0.3 * 0.3 ** 2))).reshape(-1, 3, 3) + tr = np.einsum("nij,nij->n", P, S) + tc.append((wts * tr * pc).sum() / (wts * pc ** 2).sum()); ts.append(t) + ts, tc = np.array(ts), np.array(tc) + M = np.column_stack([np.cos(2 * np.pi * ts), np.sin(2 * np.pi * ts)]) + a, b = np.linalg.lstsq(M, tc, rcond=None)[0] + amp, lag = np.hypot(a, b), -np.degrees(np.arctan2(b, a)) + wt = 2 * np.pi * De + amp_th, lag_th = (24 / 11) * mu * A * wt / np.sqrt(1 + wt ** 2), np.degrees(np.arctan(1 / wt)) + assert abs(amp - amp_th) < 0.05 * amp_th, (amp, amp_th) + assert lag_th - 2 < lag < lag_th + 12, (lag, lag_th) From a076bd476a2c8ef6d4bc531ce4175e4b82099f5c Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 15 Oct 2026 18:31:48 +1100 Subject: [PATCH 12/59] Tidal shell: accumulated slip and net slip per orbit against the friction coefficient Underworld development team with AI support from Claude Code --- .../sessions/tidal_shell/tidal_mu_overlay.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 scripts/sessions/tidal_shell/tidal_mu_overlay.py diff --git a/scripts/sessions/tidal_shell/tidal_mu_overlay.py b/scripts/sessions/tidal_shell/tidal_mu_overlay.py new file mode 100644 index 000000000..b3a0a31fe --- /dev/null +++ b/scripts/sessions/tidal_shell/tidal_mu_overlay.py @@ -0,0 +1,31 @@ +"""Accumulated lateral slip (mean over the stripes) through the last cycle for +several friction coefficients, and the net slip per orbit against mu. +Usage: [ ...]""" +import json, os, sys +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +sim = sys.argv[1] +fig, axes = plt.subplots(1, 3, figsize=(17, 4.8)) +mus, nets, peaks = [], [], [] +for arg in sys.argv[2:]: + run, mu = arg.split(":"); mu = float(mu) + rs = json.load(open(os.path.join(sim, run, "records.json"))) + t = np.array([r["t"] for r in rs]); last = t > t.max() - 1.0 + 1e-9 + names = sorted(rs[0]["slip"].keys()) + cum = np.mean([[r["slip"][nm]["cumulative"] for r in rs] for nm in names], axis=0)[last] + rate = np.mean([[r["slip"][nm]["signed_mean"] for r in rs] for nm in names], axis=0)[last] + ph = (t[last] - np.floor(t[last] - 1e-9)) * 360.0 + axes[0].plot(ph, cum - cum[0], label=f"mu = {mu}") + axes[1].plot(ph, rate, label=f"mu = {mu}") + mus.append(mu); nets.append(cum[-1] - cum[0]); peaks.append(np.abs(rate).max()) +axes[0].set_title("accumulated lateral slip through the last cycle (mean over the stripes)") +axes[1].set_title("signed slip rate (mean over the stripes)") +for ax in axes[:2]: + ax.axhline(0, color="k", lw=0.6); ax.grid(alpha=0.3); ax.set_xlim(0, 360); ax.set_xlabel("mean anomaly (deg)"); ax.legend(fontsize=8) +axes[2].plot(mus, nets, "o-", label="net slip per orbit"); axes[2].plot(mus, peaks, "s--", label="peak slip rate") +axes[2].set_xlabel("friction coefficient mu"); axes[2].grid(alpha=0.3); axes[2].legend(fontsize=8); axes[2].set_title("asymmetry (net slip per orbit) against friction") +fig.tight_layout(); fig.savefig(os.path.join(sim, "berne_mu_overlay.png"), dpi=120) +print({m: (round(n, 5), round(p, 4)) for m, n, p in zip(mus, nets, peaks)}) From 93829e30cceebd819a4401f010d8a5b9d44f569b Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 16 Oct 2026 03:57:01 +1100 Subject: [PATCH 13/59] Tidal shell driver: stripe set centre longitude, global stress fields saved per step for the site analysis Underworld development team with AI support from Claude Code --- scripts/sessions/tidal_shell/tidal_shell.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/sessions/tidal_shell/tidal_shell.py b/scripts/sessions/tidal_shell/tidal_shell.py index 3eecff67b..03877872e 100644 --- a/scripts/sessions/tidal_shell/tidal_shell.py +++ b/scripts/sessions/tidal_shell/tidal_shell.py @@ -54,6 +54,8 @@ ap.add_argument("--fault_Tb", type=float, default=0.0, help="Coulomb: constant effective overburden as a STRESS (compression positive); enters the law as L * T_b") ap.add_argument("--probe_stripes", type=int, default=0, help="unfaulted run: record the driving tractions on N virtual stripe traces (Berne et al. Fig 2a,b)") ap.add_argument("--fault_lat", type=float, default=-90.0, help="latitude of the stripe set's centre (deg)") +ap.add_argument("--fault_lon", type=float, default=0.0, help="longitude of the stripe set's centre (deg; 0 = sub-planet meridian)") +ap.add_argument("--save_global_stress", type=int, default=0, help="save the 3x3 stress on the lat-lon grid every step (npz), for the site/orientation analysis") ap.add_argument("--fault_azimuth", type=float, default=-90.0, help="stripe direction: angle from the sub-planet direction at the set's centre, counter-clockwise viewed from outside (deg); -90 = east-west") ap.add_argument("--verbose", action="store_true") args = ap.parse_args() @@ -76,9 +78,9 @@ def stripe_points(offset, half_len, n=9, lat0=-90.0): """A stripe as a polyline on the unit sphere: the exponential map at the set's centre (latitude lat0 on the x-z meridian), the stripe along the local e1 direction at a distance `offset` along e2.""" - th0 = np.radians(lat0) - c = np.array([np.cos(th0), 0.0, np.sin(th0)]) # centre - east = np.array([0.0, 1.0, 0.0]) + th0, ph0 = np.radians(lat0), np.radians(args.fault_lon) + c = np.array([np.cos(th0) * np.cos(ph0), np.cos(th0) * np.sin(ph0), np.sin(th0)]) # centre + east = np.array([-np.sin(ph0), np.cos(ph0), 0.0]) sub = np.cross(c, east) # toward the sub-planet point (+x) along the surface az = np.radians(args.fault_azimuth) e1 = np.cos(az) * sub + np.sin(az) * np.cross(c, sub) # stripe direction @@ -282,9 +284,9 @@ def figure(step, t): # polar view: azimuthal (exponential-map) grid around the stripe set's centre _pole_lat = args.fault_lat if args.faults > 0 else -90.0 -_th0 = np.radians(_pole_lat) -_pc = np.array([np.cos(_th0), 0.0, np.sin(_th0)]) -_sub = np.cross(_pc, np.array([0.0, 1.0, 0.0])) +_th0, _ph0 = np.radians(_pole_lat), np.radians(args.fault_lon) +_pc = np.array([np.cos(_th0) * np.cos(_ph0), np.cos(_th0) * np.sin(_ph0), np.sin(_th0)]) +_sub = np.cross(_pc, np.array([-np.sin(_ph0), np.cos(_ph0), 0.0])) _az = np.radians(args.fault_azimuth) _pe1 = np.cos(_az) * _sub + np.sin(_az) * np.cross(_pc, _sub); _pe2 = np.cross(_pc, _pe1) _prho = np.linspace(0.0, 0.9, 61)[1:] @@ -393,6 +395,10 @@ def polar_figure(step, t): rec = {"step": k, "t": t, "dt": dt, "converged": bool(info.get("converged")), "nonlinear": info.get("nonlinear_iterations"), "t_step": round(time.perf_counter() - t0, 1)} stress_to_nodes() + if args.save_global_stress: + Sg = stress_on(_grid_in) # collective + if rank0: + np.savez(os.path.join(args.out, f"gstress_{k:04d}.npz"), t=t, S=Sg, lon=LON, lat=LAT) if args.check: rec.update(diagnostics(t)) rec.update(stress_diagnostics(t)) From ba9b86ab1a547004e0b7d526b4753c1ef15d30c7 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 16 Oct 2026 03:58:11 +1100 Subject: [PATCH 14/59] Tidal shell: site and orientation analysis of the locked shell's stripe-plane tractions (phase of the clamping, Coulomb asymmetry index); the overlay takes labelled runs Underworld development team with AI support from Claude Code --- .../sessions/tidal_shell/tidal_mu_overlay.py | 17 ++- .../tidal_shell/tidal_site_analysis.py | 104 ++++++++++++++++++ 2 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 scripts/sessions/tidal_shell/tidal_site_analysis.py diff --git a/scripts/sessions/tidal_shell/tidal_mu_overlay.py b/scripts/sessions/tidal_shell/tidal_mu_overlay.py index b3a0a31fe..7249421a0 100644 --- a/scripts/sessions/tidal_shell/tidal_mu_overlay.py +++ b/scripts/sessions/tidal_shell/tidal_mu_overlay.py @@ -11,21 +11,28 @@ fig, axes = plt.subplots(1, 3, figsize=(17, 4.8)) mus, nets, peaks = [], [], [] for arg in sys.argv[2:]: - run, mu = arg.split(":"); mu = float(mu) + run, mu = arg.split(":") + try: + mu = float(mu) + except ValueError: + pass rs = json.load(open(os.path.join(sim, run, "records.json"))) t = np.array([r["t"] for r in rs]); last = t > t.max() - 1.0 + 1e-9 names = sorted(rs[0]["slip"].keys()) cum = np.mean([[r["slip"][nm]["cumulative"] for r in rs] for nm in names], axis=0)[last] rate = np.mean([[r["slip"][nm]["signed_mean"] for r in rs] for nm in names], axis=0)[last] ph = (t[last] - np.floor(t[last] - 1e-9)) * 360.0 - axes[0].plot(ph, cum - cum[0], label=f"mu = {mu}") - axes[1].plot(ph, rate, label=f"mu = {mu}") + axes[0].plot(ph, cum - cum[0], label=f"{mu}" if isinstance(mu, str) else f"mu = {mu}") + axes[1].plot(ph, rate, label=f"{mu}" if isinstance(mu, str) else f"mu = {mu}") mus.append(mu); nets.append(cum[-1] - cum[0]); peaks.append(np.abs(rate).max()) axes[0].set_title("accumulated lateral slip through the last cycle (mean over the stripes)") axes[1].set_title("signed slip rate (mean over the stripes)") for ax in axes[:2]: ax.axhline(0, color="k", lw=0.6); ax.grid(alpha=0.3); ax.set_xlim(0, 360); ax.set_xlabel("mean anomaly (deg)"); ax.legend(fontsize=8) -axes[2].plot(mus, nets, "o-", label="net slip per orbit"); axes[2].plot(mus, peaks, "s--", label="peak slip rate") +xs = mus if all(not isinstance(m, str) for m in mus) else np.arange(len(mus)) +axes[2].plot(xs, nets, "o-", label="net slip per orbit"); axes[2].plot(xs, peaks, "s--", label="peak slip rate") +if xs is not mus: axes[2].set_xticks(xs); axes[2].set_xticklabels(mus, rotation=20, fontsize=8) axes[2].set_xlabel("friction coefficient mu"); axes[2].grid(alpha=0.3); axes[2].legend(fontsize=8); axes[2].set_title("asymmetry (net slip per orbit) against friction") -fig.tight_layout(); fig.savefig(os.path.join(sim, "berne_mu_overlay.png"), dpi=120) +out = sys.argv[1 + len(sys.argv[2:]) + 1] if False else os.environ.get("OVERLAY_OUT", "berne_mu_overlay.png") +fig.tight_layout(); fig.savefig(os.path.join(sim, out), dpi=120) print({m: (round(n, 5), round(p, 4)) for m, n, p in zip(mus, nets, peaks)}) diff --git a/scripts/sessions/tidal_shell/tidal_site_analysis.py b/scripts/sessions/tidal_shell/tidal_site_analysis.py new file mode 100644 index 000000000..b02fb372d --- /dev/null +++ b/scripts/sessions/tidal_shell/tidal_site_analysis.py @@ -0,0 +1,104 @@ +"""Where and how a frictional stripe would slip asymmetrically: from the locked +shell's global stress (gstress_*.npz, last period), the shear and normal traction +on a plane of given azimuth at every grid point, their amplitudes, the phase of +the clamping relative to the shear, and an asymmetry index from the paper's 1-D +Coulomb picture (ED Fig. 4): slip rate ~ max(0, |tau| - mu (T_b - sigma_n)) with +the sign of tau, so + + asym = (I_right - I_left) / (I_right + I_left), I = integral of the positive + Coulomb traction over the right- / left-lateral windows. + +Azimuth: the plane's strike measured counter-clockwise from local east (viewed +from outside). Usage: """ +import glob, os, sys +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +sim, run = sys.argv[1:3] +mu, Tb = float(sys.argv[3]), float(sys.argv[4]) +az_arg = sys.argv[5] if len(sys.argv) > 5 else "30" +files = sorted(glob.glob(os.path.join(sim, run, "gstress_*.npz"))) +d0 = np.load(files[-1]); LON, LAT = d0["lon"], d0["lat"] +t_all = np.array([float(np.load(f)["t"]) for f in files]) +last = t_all > t_all.max() - 1.0 + 1e-9 +S = np.array([np.load(f)["S"] for f, m in zip(files, last) if m]) # (nt, npts, 3, 3) +t = t_all[last] +lon, lat = LON.ravel(), LAT.ravel() +r = np.column_stack([np.cos(lat) * np.cos(lon), np.cos(lat) * np.sin(lon), np.sin(lat)]) +east = np.column_stack([-np.sin(lon), np.cos(lon), np.zeros_like(lon)]) +north = np.cross(r, east) + + +def tractions(az_deg): + a = np.radians(az_deg) + strike = np.cos(a) * east + np.sin(a) * north + nrm = np.cross(r, strike) + tau = np.einsum("ni,tnij,nj->tn", strike, S, nrm) + sn = np.einsum("ni,tnij,nj->tn", nrm, S, nrm) + return tau, sn + + +def analyse(tau, sn): + M = np.column_stack([np.cos(2 * np.pi * t), np.sin(2 * np.pi * t)]) + ct = np.linalg.lstsq(M, tau, rcond=None)[0]; cn = np.linalg.lstsq(M, sn, rcond=None)[0] + amp_t, amp_n = np.hypot(*ct), np.hypot(*cn) + ph_t, ph_n = np.arctan2(ct[1], ct[0]), np.arctan2(cn[1], cn[0]) + dphi = np.degrees((ph_n - ph_t + np.pi) % (2 * np.pi) - np.pi) # clamping phase relative to shear + coul = np.abs(tau) - mu * (Tb - sn) + pos = np.maximum(coul, 0.0) + I_r = (pos * (tau > 0)).sum(axis=0); I_l = (pos * (tau < 0)).sum(axis=0) + asym = (I_r - I_l) / np.maximum(I_r + I_l, 1e-30) + frac = (pos > 0).mean(axis=0) # fraction of the cycle failing + return amp_t, amp_n, dphi, asym, frac + + +if az_arg == "best": + azs = np.arange(0, 180, 10) + best = None + for az in azs: + amp_t, amp_n, dphi, asym, frac = analyse(*tractions(az)) + if best is None: + best = [amp_t, amp_n, dphi, asym, frac, np.full_like(asym, az)] + else: + m = np.abs(asym) > np.abs(best[3]) + for i, v in enumerate((amp_t, amp_n, dphi, asym, frac)): + best[i] = np.where(m, v, best[i]) + best[5] = np.where(m, az, best[5]) + amp_t, amp_n, dphi, asym, frac, azmap = best + title = "plane of largest |asymmetry| at each point" +else: + amp_t, amp_n, dphi, asym, frac = analyse(*tractions(float(az_arg))) + azmap = None + title = f"planes at azimuth {az_arg} deg from local east" + +fields = [(amp_t, "shear traction amplitude", "viridis", None), (amp_n, "normal traction amplitude", "viridis", None), + (dphi, "phase of the clamping relative to the shear (deg)", "twilight", (-180, 180)), + (asym, "asymmetry index (I_right - I_left)/(I_right + I_left)", "RdBu_r", (-1, 1)), + (frac, "fraction of the cycle at failure", "magma", (0, 1))] +if azmap is not None: + fields.append((azmap, "azimuth of that plane (deg from east)", "twilight", (0, 180))) +n = len(fields) +fig, axes = plt.subplots(2, 3, figsize=(19, 8), subplot_kw={"projection": "mollweide"}) +for ax, (fld, ttl, cmap, lim) in zip(axes.ravel(), fields): + kw = {} if lim is None else {"vmin": lim[0], "vmax": lim[1]} + pc = ax.pcolormesh(LON, LAT, fld.reshape(LAT.shape), cmap=cmap, shading="auto", **kw) + ax.set_title(ttl, fontsize=9, loc="left"); ax.grid(alpha=0.3) + fig.colorbar(pc, ax=ax, shrink=0.6, orientation="horizontal", pad=0.05) +for ax in axes.ravel()[n:]: + ax.set_visible(False) +fig.suptitle(f"locked shell {run}, mu = {mu}, T_b = {Tb}: {title}", fontsize=11) +out = os.path.join(sim, f"sites_{run}_az{az_arg}.png") +fig.savefig(out, dpi=110, bbox_inches="tight"); print("wrote", out) +# the sites run frictionally +for name, la, lo, az in (("pole az30", -90, 0, 30), ("pole az90", -90, 0, 90), ("lat-45 lon0 az30", -45, 0, 30), + ("lat-45 lon90 az30", -45, 90, 30), ("equator lon0 az45", 0, 0, 45)): + i = np.argmin((np.degrees(lat) - la) ** 2 + (np.cos(np.radians(la)) * (np.degrees(lon) - lo)) ** 2) + # local azimuth from east: the driver's azimuth is from the sub-planet direction; at the + # south pole that direction is +x, which is "north" in the local (east, north) frame + # of the pole's lat-lon grid at lon 0 -> azimuth from east = az + 90 there; at other + # sites the sub-planet direction is local west-ish (lon 0) or south-ish (lon 90) + for az_e in (az + 90, az + 180, az, az + 270): + a_t, a_n, dp, asy, fr = analyse(*tractions(az_e % 180)) + print(f" {name:20s} plane az_east {az_e % 180:5.0f}: |tau| {a_t[i]:.4f} |sn| {a_n[i]:.4f} clamp phase {dp[i]:+6.0f} asym {asy[i]:+.2f} failing {fr[i]:.2f}") From 3b4aaf1cd36ed9a26ebb46a6554d52006dffdabf Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 16 Oct 2026 04:29:26 +1100 Subject: [PATCH 15/59] Tidal shell site analysis: size and timing asymmetry indices at fixed azimuth Underworld development team with AI support from Claude Code --- .../tidal_shell/tidal_site_analysis.py | 47 ++++++++----------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/scripts/sessions/tidal_shell/tidal_site_analysis.py b/scripts/sessions/tidal_shell/tidal_site_analysis.py index b02fb372d..758636c23 100644 --- a/scripts/sessions/tidal_shell/tidal_site_analysis.py +++ b/scripts/sessions/tidal_shell/tidal_site_analysis.py @@ -18,7 +18,7 @@ sim, run = sys.argv[1:3] mu, Tb = float(sys.argv[3]), float(sys.argv[4]) -az_arg = sys.argv[5] if len(sys.argv) > 5 else "30" +az_arg = sys.argv[5] if len(sys.argv) > 5 else "120" files = sorted(glob.glob(os.path.join(sim, run, "gstress_*.npz"))) d0 = np.load(files[-1]); LON, LAT = d0["lon"], d0["lat"] t_all = np.array([float(np.load(f)["t"]) for f in files]) @@ -51,34 +51,26 @@ def analyse(tau, sn): I_r = (pos * (tau > 0)).sum(axis=0); I_l = (pos * (tau < 0)).sum(axis=0) asym = (I_r - I_l) / np.maximum(I_r + I_l, 1e-30) frac = (pos > 0).mean(axis=0) # fraction of the cycle failing - return amp_t, amp_n, dphi, asym, frac + # timing: the centroid of each failure window relative to the shear extreme of + # its sign (degrees, positive = late); the difference between the two windows + ph = 2 * np.pi * t + def centroid_offset(sign): + w = pos * (np.sign(tau) == sign) + ex = ph_t if sign > 0 else ph_t + np.pi # phase of the shear extreme + rel = (ph[:, None] - ex[None, :] + np.pi) % (2 * np.pi) - np.pi + return np.degrees((w * rel).sum(axis=0) / np.maximum(w.sum(axis=0), 1e-30)) + timing = centroid_offset(+1) - centroid_offset(-1) + timing = np.where((I_r > 0) & (I_l > 0), timing, np.nan) + return amp_t, amp_n, dphi, asym, frac, timing -if az_arg == "best": - azs = np.arange(0, 180, 10) - best = None - for az in azs: - amp_t, amp_n, dphi, asym, frac = analyse(*tractions(az)) - if best is None: - best = [amp_t, amp_n, dphi, asym, frac, np.full_like(asym, az)] - else: - m = np.abs(asym) > np.abs(best[3]) - for i, v in enumerate((amp_t, amp_n, dphi, asym, frac)): - best[i] = np.where(m, v, best[i]) - best[5] = np.where(m, az, best[5]) - amp_t, amp_n, dphi, asym, frac, azmap = best - title = "plane of largest |asymmetry| at each point" -else: - amp_t, amp_n, dphi, asym, frac = analyse(*tractions(float(az_arg))) - azmap = None - title = f"planes at azimuth {az_arg} deg from local east" - +amp_t, amp_n, dphi, asym, frac, timing = analyse(*tractions(float(az_arg))) +title = f"planes at azimuth {az_arg} deg from local east" fields = [(amp_t, "shear traction amplitude", "viridis", None), (amp_n, "normal traction amplitude", "viridis", None), (dphi, "phase of the clamping relative to the shear (deg)", "twilight", (-180, 180)), - (asym, "asymmetry index (I_right - I_left)/(I_right + I_left)", "RdBu_r", (-1, 1)), + (asym, "size asymmetry (I_right - I_left)/(I_right + I_left)", "RdBu_r", (-1, 1)), + (timing, "timing asymmetry: onset offset, right minus left window (deg)", "RdBu_r", (-90, 90)), (frac, "fraction of the cycle at failure", "magma", (0, 1))] -if azmap is not None: - fields.append((azmap, "azimuth of that plane (deg from east)", "twilight", (0, 180))) n = len(fields) fig, axes = plt.subplots(2, 3, figsize=(19, 8), subplot_kw={"projection": "mollweide"}) for ax, (fld, ttl, cmap, lim) in zip(axes.ravel(), fields): @@ -99,6 +91,7 @@ def analyse(tau, sn): # south pole that direction is +x, which is "north" in the local (east, north) frame # of the pole's lat-lon grid at lon 0 -> azimuth from east = az + 90 there; at other # sites the sub-planet direction is local west-ish (lon 0) or south-ish (lon 90) - for az_e in (az + 90, az + 180, az, az + 270): - a_t, a_n, dp, asy, fr = analyse(*tractions(az_e % 180)) - print(f" {name:20s} plane az_east {az_e % 180:5.0f}: |tau| {a_t[i]:.4f} |sn| {a_n[i]:.4f} clamp phase {dp[i]:+6.0f} asym {asy[i]:+.2f} failing {fr[i]:.2f}") + for az_e in (az + 90, az): + a_t, a_n, dp, asy, fr, tm = analyse(*tractions(az_e % 180)) + print(f" {name:20s} plane az_east {az_e % 180:5.0f}: |tau| {a_t[i]:.4f} |sn| {a_n[i]:.4f} clamp phase {dp[i]:+6.0f} " + f"size asym {asy[i]:+.2f} timing asym {tm[i]:+6.0f} failing {fr[i]:.2f}") From 5f1d2defbe0b2ac8ba33496e5372bf63db9c0952 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 16 Oct 2026 05:05:05 +1100 Subject: [PATCH 16/59] Tidal shell: driving shear against the Coulomb strength through the cycle, the excess and the slip it drives Underworld development team with AI support from Claude Code --- .../sessions/tidal_shell/tidal_strength.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 scripts/sessions/tidal_shell/tidal_strength.py diff --git a/scripts/sessions/tidal_shell/tidal_strength.py b/scripts/sessions/tidal_shell/tidal_strength.py new file mode 100644 index 000000000..cf3c4fdd5 --- /dev/null +++ b/scripts/sessions/tidal_shell/tidal_strength.py @@ -0,0 +1,48 @@ +"""How far the tidal shear overcomes the fault strength: the driving shear on the +stripes (locked shell) against the Coulomb strength mu (T_b - sigma_n) read on the +frictional shell, the slip rate, and the build-up released at each onset. +Usage: """ +import json, os, sys +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +sim, locked, fric = sys.argv[1:4] +mu, Tb = float(sys.argv[4]), float(sys.argv[5]) +L = json.load(open(os.path.join(sim, locked, "records.json"))) +F = json.load(open(os.path.join(sim, fric, "records.json"))) +tL = np.array([r["t"] for r in L]); tF = np.array([r["t"] for r in F]) +lastL = tL > tL.max() - 1 + 1e-9; lastF = tF > tF.max() - 1 + 1e-9 +names = sorted(F[0]["slip"].keys()) +tau = np.mean([[r["drive"][n]["tau"] for r in L] for n in names], axis=0)[lastL] +ph = (tL[lastL] - np.floor(tL[lastL] - 1e-9)) * 360 +sn = np.mean([[r["slip"][n]["sigma_n_mean"] for r in F] for n in names], axis=0)[lastF] +rate = np.mean([[r["slip"][n]["signed_mean"] for r in F] for n in names], axis=0)[lastF] +phF = (tF[lastF] - np.floor(tF[lastF] - 1e-9)) * 360 +strength = mu * np.maximum(Tb - sn, 0) +tau_i = np.interp(phF, ph, tau) +excess = np.abs(tau_i) - strength +fig, axes = plt.subplots(1, 2, figsize=(14, 4.8)) +ax = axes[0] +ax.plot(phF, np.abs(tau_i), "k", lw=2, label="|driving shear| (locked shell)") +ax.plot(phF, strength, "r", lw=2, label="strength mu (T_b - sigma_n) (frictional shell)") +ax.fill_between(phF, strength, np.abs(tau_i), where=np.abs(tau_i) > strength, color="orange", alpha=0.35, label="excess: shear above strength") +ax.set_xlabel("mean anomaly (deg)"); ax.set_xlim(0, 360); ax.grid(alpha=0.3); ax.legend(fontsize=8) +ax.set_title("driving shear against fault strength (stripe mean, last cycle)") +ax2 = axes[1] +ax2.plot(phF, excess, "orange", lw=2, label="excess |tau| - strength") +ax2.plot(phF, rate / np.abs(rate).max() * np.abs(excess).max(), "b--", lw=1.5, label="slip rate (scaled)") +ax2.axhline(0, color="k", lw=0.6); ax2.set_xlim(0, 360); ax2.grid(alpha=0.3); ax2.legend(fontsize=8); ax2.set_xlabel("mean anomaly (deg)") +ax2.set_title("Coulomb excess and the slip it drives") +fig.tight_layout(); fig.savefig(os.path.join(sim, f"strength_{fric}.png"), dpi=120) +# numbers: onsets (excess crossing zero upward), the strength and shear there, peak excess per window +sgn = np.sign(excess); on = np.where((sgn[1:] > 0) & (sgn[:-1] <= 0))[0] + 1 +print(f"{fric}: peak |driving shear| {np.abs(tau_i).max():.4f}, strength range {strength.min():.4f} .. {strength.max():.4f} " + f"(fully unclamped at sigma_n = T_b: {mu*Tb:.4f} at sigma_n 0)") +print(f" fraction of the cycle with |tau| > strength: {(excess > 0).mean():.2f}") +for k in on: + w = np.arange(k, len(excess)); w = w[: np.argmax(excess[w] <= 0) or len(w)] + print(f" onset at {phF[k]:5.0f} deg: |tau| {abs(tau_i[k]):.4f}, strength {strength[k]:.4f}, strength falling at " + f"{(strength[k]-strength[k-1])/(phF[k]-phF[k-1])*360:+.4f}/period vs |tau| changing at {(abs(tau_i[k])-abs(tau_i[k-1]))/(phF[k]-phF[k-1])*360:+.4f}/period; " + f"window peak excess {excess[w].max():.4f} ({excess[w].max()/max(strength[w].mean(),1e-12):.0%} of the strength), slip in window {np.trapezoid(rate[w], phF[w]/360):+.4f}") From 50c152e932352a56ab86c833ab8c3646008b7443 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 16 Oct 2026 08:20:31 +1100 Subject: [PATCH 17/59] Tidal shell driver: cumulative tangential displacement on the global and polar grids, saved at cycle boundaries Underworld development team with AI support from Claude Code --- scripts/sessions/tidal_shell/tidal_shell.py | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/scripts/sessions/tidal_shell/tidal_shell.py b/scripts/sessions/tidal_shell/tidal_shell.py index 03877872e..bfffb5c65 100644 --- a/scripts/sessions/tidal_shell/tidal_shell.py +++ b/scripts/sessions/tidal_shell/tidal_shell.py @@ -55,6 +55,7 @@ ap.add_argument("--probe_stripes", type=int, default=0, help="unfaulted run: record the driving tractions on N virtual stripe traces (Berne et al. Fig 2a,b)") ap.add_argument("--fault_lat", type=float, default=-90.0, help="latitude of the stripe set's centre (deg)") ap.add_argument("--fault_lon", type=float, default=0.0, help="longitude of the stripe set's centre (deg; 0 = sub-planet meridian)") +ap.add_argument("--accumulate_displacement", type=int, default=0, help="integrate the tangential velocity on the global and polar grids every step; save the cumulative displacement at figure steps (far-field net deformation per orbit)") ap.add_argument("--save_global_stress", type=int, default=0, help="save the 3x3 stress on the lat-lon grid every step (npz), for the site/orientation analysis") ap.add_argument("--fault_azimuth", type=float, default=-90.0, help="stripe direction: angle from the sub-planet direction at the set's centre, counter-clockwise viewed from outside (deg); -90 = east-west") ap.add_argument("--verbose", action="store_true") @@ -375,6 +376,26 @@ def polar_figure(step, t): plt.close(fig) +# cumulative tangential displacement on the grids (rank 0 holds it; the evaluations are collective) +_disp_g = np.zeros((grid.shape[0], 3)); _disp_p = np.zeros((_pgrid.shape[0], 3)) + + +def accumulate_displacement(dt_): + global _disp_g, _disp_p + Ug = np.asarray(uw.function.global_evaluate(sheet.u.sym, _grid_in)).reshape(-1, 3) + Up = np.asarray(uw.function.global_evaluate(sheet.u.sym, _pgrid_in)).reshape(-1, 3) + if rank0: + Ug -= (Ug * grid).sum(1)[:, None] * grid; Up -= (Up * _pgrid).sum(1)[:, None] * _pgrid + _disp_g += dt_ * Ug; _disp_p += dt_ * Up + + +def save_displacement(step, t): + if rank0: + np.savez(os.path.join(args.out, f"disp_{step:04d}.npz"), t=t, disp_global=_disp_g, grid=grid, lon=LON, lat=LAT, + disp_polar=_disp_p, pgrid=_pgrid, rho=_PR, psi=_PP, centre=_pc, e1=_pe1, e2=_pe2, + faults=np.array([np.column_stack(fp) for fp in _fault_polylines]) if _fault_polylines else np.zeros((0, 0, 2))) + + # ----------------------------------------------------------------------------- time loop dt = 1.0 / args.steps_per_period nsteps = int(round(args.periods * args.steps_per_period)) @@ -395,6 +416,10 @@ def polar_figure(step, t): rec = {"step": k, "t": t, "dt": dt, "converged": bool(info.get("converged")), "nonlinear": info.get("nonlinear_iterations"), "t_step": round(time.perf_counter() - t0, 1)} stress_to_nodes() + if args.accumulate_displacement: + accumulate_displacement(dt) + if (k + 1) % args.steps_per_period == 0 or k == nsteps - 1: + save_displacement(k, t) # at every cycle boundary if args.save_global_stress: Sg = stress_on(_grid_in) # collective if rank0: From 16352fc725cdf3acc34a72eb0d34f1defc75a395 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 16 Oct 2026 08:21:10 +1100 Subject: [PATCH 18/59] Tidal shell: far-field net displacement per orbit, faulted minus unfaulted Underworld development team with AI support from Claude Code --- .../sessions/tidal_shell/tidal_farfield.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 scripts/sessions/tidal_shell/tidal_farfield.py diff --git a/scripts/sessions/tidal_shell/tidal_farfield.py b/scripts/sessions/tidal_shell/tidal_farfield.py new file mode 100644 index 000000000..ddeaa80dd --- /dev/null +++ b/scripts/sessions/tidal_shell/tidal_farfield.py @@ -0,0 +1,72 @@ +"""Net tangential displacement of the shell over one orbit: the far-field +permanent deformation the stripes leave. Reads disp_*.npz (cumulative +displacement at cycle boundaries) of a faulted and an unfaulted run, takes +the last orbit's increment of each, and maps faulted minus unfaulted on the +Mollweide and in the polar view, with the stripes' own slip for scale. +Usage: """ +import glob, json, os, sys +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +sim, fr, ur = sys.argv[1:4] + + +def last_orbit(run): + files = sorted(glob.glob(os.path.join(sim, run, "disp_*.npz"))) + a, b = np.load(files[-2]), np.load(files[-1]) + return b, b["disp_global"] - a["disp_global"], b["disp_polar"] - a["disp_polar"], float(b["t"]) - float(a["t"]) + + +F, dgF, dpF, TF = last_orbit(fr) +U, dgU, dpU, TU = last_orbit(ur) +LON, LAT = F["lon"], F["lat"]; grid = F["grid"] +net_g = dgF - dgU; net_p = dpF - dpU +mag_g = np.linalg.norm(net_g, axis=1).reshape(LAT.shape) +mag_p = np.linalg.norm(net_p, axis=1).reshape(F["rho"].shape) +# the stripes' own net slip for scale +rs = json.load(open(os.path.join(sim, fr, "records.json"))) +t = np.array([r["t"] for r in rs]); last = t > t.max() - 1 + 1e-9 +names = sorted(rs[0]["slip"].keys()) +net_slip = np.mean([rs[-1]["slip"][n]["cumulative"] - rs[int(np.argmax(last))]["slip"][n]["cumulative"] for n in names]) +# the unfaulted shell's own residual (numerical): the noise floor +floor = np.linalg.norm(dgU, axis=1) +print(f"orbit length {TF:.3f}; stripe-mean net slip over the orbit {net_slip:+.5f}") +print(f"far-field net displacement |faulted - unfaulted|: max {mag_g.max():.5f}, median {np.median(mag_g):.5f}; " + f"unfaulted residual max {floor.max():.6f} (noise floor)") +# where it lives: fraction of the global rms within 0.5 rad of the stripe set's centre +c = F["centre"]; ang = np.degrees(np.arccos(np.clip(grid @ c, -1, 1))) +w = np.cos(LAT).ravel() +for rad in (15, 30, 60, 90): + m = ang < rad + print(f" within {rad:3d} deg of the stripes: rms {np.sqrt((w[m]*np.linalg.norm(net_g[m],axis=1)**2).sum()/w[m].sum()):.5f}; " + f"beyond: rms {np.sqrt((w[~m]*np.linalg.norm(net_g[~m],axis=1)**2).sum()/max(w[~m].sum(),1e-30)):.5f}") + +fig = plt.figure(figsize=(18, 5.2)) +ax = fig.add_subplot(1, 2, 1, projection="mollweide") +pc = ax.pcolormesh(LON, LAT, mag_g, cmap="magma", shading="auto") +ax.set_title(f"net tangential displacement over one orbit, faulted minus unfaulted (stripe net slip {net_slip:+.4f})", fontsize=9, loc="left") +ax.grid(alpha=0.3); fig.colorbar(pc, ax=ax, shrink=0.6, orientation="horizontal", pad=0.05) +ax = fig.add_subplot(1, 2, 2) +X, Y = F["rho"] * np.cos(F["psi"]), F["rho"] * np.sin(F["psi"]) +pc = ax.pcolormesh(X, Y, mag_p, cmap="magma", shading="auto") +# arrows in the polar projection: the displacement's components along e1, e2 +e1, e2 = F["e1"], F["e2"] +pg = F["pgrid"] +rho = F["rho"].ravel(); psi = F["psi"].ravel() +d_ = pg - (pg @ c)[:, None] * c; d_ /= np.maximum(np.linalg.norm(d_, axis=1), 1e-30)[:, None] +# local radial (away from the centre) and azimuthal unit vectors on the sphere +e_r = np.cos(rho)[:, None] * d_ - np.sin(rho)[:, None] * c[None] +e_a = np.cross(pg, e_r) +ur_, ua_ = (net_p * e_r).sum(1), (net_p * e_a).sum(1) +ex = ur_ * np.cos(psi) - ua_ * np.sin(psi); ey = ur_ * np.sin(psi) + ua_ * np.cos(psi) +sk = (slice(None, None, 6), slice(None, None, 4)) +ax.quiver(X[sk], Y[sk], ex.reshape(X.shape)[sk], ey.reshape(X.shape)[sk], color="w", scale=mag_p.max() * 25, width=0.003) +for fp in F["faults"]: + ax.plot(fp[:, 0], fp[:, 1], color="cyan", lw=2) +ax.set_aspect("equal"); ax.set_xlim(-0.9, 0.9); ax.set_ylim(-0.9, 0.9); ax.set_xticks([]); ax.set_yticks([]) +ax.set_title("polar view: magnitude and direction of the net displacement", fontsize=9, loc="left") +fig.colorbar(pc, ax=ax, shrink=0.8) +out = os.path.join(sim, f"farfield_{fr}_vs_{ur}.png") +fig.savefig(out, dpi=120, bbox_inches="tight"); print("wrote", out) From c110795769b9a8138a73a59fbcfdf4ad0b98c738 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Thu, 15 Oct 2026 14:36:32 -0700 Subject: [PATCH 19/59] Faults in convection: the split-ladder multigrid tail and the driver rules A fault placed as a ladder on a copy of the base's finest level gets only the welded tail (A); in a yielding, convecting box its velocity sub-solve caps at 200 once the lid mobilises. Building C by hand, the same ladder at 4S and 2S on the coarser base levels, each split along its own rungs, keeps the Newton-phase velocity iterations at 29-38 with no caps (A: 71-200, 2 caps) at A's cost per cycle. New capability guide guides/faults-in-convection.md (skill link included): the lean tail, the rotated loop's warm-start rules (first solve from rest; newton_switch_rtol measured from each solve's own residual, so warm solves need it at 1), rebuilding for a moving trench (shifted warm start, Picard first, rebuild every few rungs), and the measured traps. The design note records the A / C comparison and lists the ribbon replay as not yet done. Underworld development team with AI support from Claude Code Claude-Session: https://claude.ai/code/session_019aromUkeWWPzcL2Kb8365E --- .claude/skills/faults-in-convection/SKILL.md | 1 + .../design/FAULT_MULTIGRID_HIERARCHIES.md | 25 +++- docs/developer/guides/faults-in-convection.md | 119 ++++++++++++++++++ docs/developer/index.md | 1 + 4 files changed, 145 insertions(+), 1 deletion(-) create mode 120000 .claude/skills/faults-in-convection/SKILL.md create mode 100644 docs/developer/guides/faults-in-convection.md diff --git a/.claude/skills/faults-in-convection/SKILL.md b/.claude/skills/faults-in-convection/SKILL.md new file mode 120000 index 000000000..a5f701634 --- /dev/null +++ b/.claude/skills/faults-in-convection/SKILL.md @@ -0,0 +1 @@ +../../../docs/developer/guides/faults-in-convection.md \ No newline at end of file diff --git a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md index 24ac76f5b..f81b2453f 100644 --- a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md +++ b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md @@ -172,6 +172,27 @@ every problem, for no gain. B2 is worse than A, and badly so for the viscous fault. A smeared band on a coarse level is a continuous shear that the split fine operator rejects, so the coarse space has to contain the discontinuity itself. +## C on the ladder, in a convecting lid (2026-10-14) + +A fault placed as a ladder (`place_fault_ribbon_2d`, `cut=False` spines) on a copy of the +base's finest level has no add-on levels, and its spines are not replayed onto the +levels, so the tail it can be given is A. In a yielding, convecting box (4 x 1, base +cellSize 0.125 with two refinements, rung spacing S = 0.004, frictionless split spine +with a weak bridge to the surface) the welded tail capped the velocity sub-solve at 200 +once the lid mobilised. C was built by hand: the same ladder at 4S on the base's middle +level and at 2S on its finest, each split along its own rungs, as the mid levels, with +the coarsest base level welded. One solve from rest on a mobilising state, 4 ranks: + +| Tail | Levels (cells) | Newton increments | Velocity its, total | Newton phase, per increment | Caps | +|---|---|---|---|---|---| +| A, welded | 642, 2568, 10272, 11604 | 39 | 1849 | 71-200 | 2 | +| C, every ladder on the finest base level | 642, 2568, 10272, 10573, 10966, 11612 | 42 | 636 | 26-29 | 0 | +| C, lean (ladders on the coarser base levels) | 642, 2924, 10950, 11612 | 42 | 695 | 29-38 | 0 | + +The lean tail keeps C's counts at A's cost per cycle. A constant band width on every level +and a band two of each level's own rungs wide gave the same counts. The recipe and the +driver rules that go with it are in `guides/faults-in-convection.md`. + ## State (library, 2026-10-13) In the library: the add-on levels of one `adapt` call, each split by `add_fault`; the @@ -181,4 +202,6 @@ layer with every level split, extruded in parallel; 3-D sheet faults on add-on m Not yet: fault networks through the levels (a network is replayed on every level, but a one-cell junction ligament closes at the coarser spacing and the replay then stops there, leaving that level and those beneath welded), faults near a boundary, -co-locating the levels with the finest as an optimisation. +co-locating the levels with the finest as an optimisation, and the ribbon replay that +would give a placed ladder its split levels automatically (a driver builds them by hand: +`guides/faults-in-convection.md`). diff --git a/docs/developer/guides/faults-in-convection.md b/docs/developer/guides/faults-in-convection.md new file mode 100644 index 000000000..4dd8df77a --- /dev/null +++ b/docs/developer/guides/faults-in-convection.md @@ -0,0 +1,119 @@ +--- +name: faults-in-convection +description: Recipe for a split-node fault in a TIME-DEPENDENT convection model (yielding lid, fault placed as the structured ladder by place_fault_ribbon_2d on a copy of the base's finest level, Stokes on the faulted mesh, temperature on the unfaulted base). Covers the multigrid hierarchy that keeps the faulted velocity solve bounded (the lean split-ladder chain, "C on the ladder"), the warm-start rules of the rotated nonlinear loop, rebuilding the faulted mesh for a moving trench, and the traps measured on the 4 x 1 box. Reach for THIS before writing or tuning a fault-in-convection driver; for the fault mechanics on an adapted mesh see `adapt-on-top-faults`, for the yield-law solver see `nonlinear-solver`. +families: [Stokes, AdvDiffusion] +kind: recipe +--- + +# Faults in convection + +A split-node fault in a convecting, yielding lid. The pattern measured on a 4 x 1 box +(2026-10, branch `feature/box-faults`), with the fault-multigrid library of +`feature/fault-split-multigrid`: + +- the base mesh carries the temperature and its transport and is never cut; +- the faulted mesh is a copy of the base's finest level with the fault placed as the + structured ladder, the spine split and in frictionless contact from three rung + spacings below the surface, the last rungs to the surface a weak isotropic bridge; +- Stokes is solved on the faulted mesh, and the velocity is evaluated back onto the + base for the transport step. + +## The multigrid hierarchy: split the coarse levels + +`set_custom_fmg(stokes, base_levels, field_id=0)` with the base's own levels gives the +WELDED hierarchy (A in `design/FAULT_MULTIGRID_HIERARCHIES.md`): only the finest level +carries the fault. Its coarse spaces cannot represent the slip, the weak bridge or the +ladder cells, and the velocity sub-solve caps at 200 iterations once Newton starts on a +mobilising lid. A placed ladder is not replayed onto the levels by `add_fault`, so the +split levels have to be built by hand: the same ladder at twice and four times the rung +spacing, placed on the base levels one and two steps below the finest and split along +their own rungs, as the mid levels. + +```python +from underworld3.utilities.custom_mg import set_custom_fmg +from underworld3.utilities.place_surface import place_fault_ribbon_2d + +base_levels = base._coarse_level_meshes()[:-1] + [base] # coarsest .. finest + +def placed_ladder(host, coarsening): + # the spine and trace sampled at coarsening x the rung spacing S; the finest + # sample count is a multiple of 2**chain, so every coarser sampling is a subset + spine, trace = fault_trace(coarsening) + width = 2 * S * coarsening # two of the level's own rungs + mesh, info = place_fault_ribbon_2d(host, [("Fault", trace)], width, + spines=[("Fault", spine)], margin_rings=1, + clearance=0.7, split=True, mesher="ladder") + return mesh, info + +chain = 2 +mids = [placed_ladder(base_levels[len(base_levels) - k], 2 ** k)[0] + for k in range(chain, 0, -1)] +faulted, info = placed_ladder(base, 1) +stokes = uw.systems.Stokes(faulted, ...) +set_custom_fmg(stokes, base_levels[:len(base_levels) - chain] + mids, field_id=0) +``` + +Measured on one hard state (a mobilising lid, slip ~270, one solve from rest, 4 ranks; +velocity iterations as the rotated loop reports them per Newton increment): + +| Hierarchy | Levels (cells) | Newton increments | Velocity its, total | Velocity its per increment, Newton phase | Caps (200) | +|---|---|---|---|---|---| +| A, welded | 642, 2568, 10272, 11604 | 39 | 1849 | 71-200 | 2 | +| ladder chain all on the finest | 642, 2568, 10272, 10573, 10966, 11612 | 42 | 636 | 26-29 | 0 | +| **lean chain** (above) | 642, 2924, 10950, 11612 | 42 | 695 | 29-38 | 0 | + +A cycle of the chain placed entirely on the finest level visits three levels of ~11k +cells, about twice the work of a lean or welded cycle, so the lean chain is the cheapest +per solve. In the time-stepping run every velocity sub-solve capped under A once the lid +mobilised; under the lean chain none did, at 4-5 Newton increments per step and the same +slip. A band of constant width (two finest rungs) on every level gave the same counts as +the widened bands. The band patch smoother (`fac_zone=band`) did not help the welded +hierarchy: 114-150 velocity iterations per increment against 91-162 without. + +## The rotated nonlinear loop + +`add_fault_bc` sends the solve through the rotated strong-constraint loop +(`rotated_bc.solve_rotated_freeslip`). Walls with plain essential BCs compose with it; +block-constraint multipliers do not. A closed box needs +`petsc_use_pressure_nullspace = True`. + +- Picard steps on this path need `stokes.consistent_jacobian = "continuation"`; + `solve(picard=N)` with the default tangent raises. +- The first solve on a faulted mesh starts from rest. A warm start from a velocity + computed without the fault stalls the Picard line search (measured 8e-2 of the + reference residual after 2 iterations); from rest the solve converges in 18-34 Picard + and 5-8 Newton iterations on the states measured. +- The loop switches from Picard to Newton when the residual has fallen by + `newton_switch_rtol` from THIS solve's starting residual, while its convergence test + is relative to the cold reference. A warm start already near convergence therefore + stays in Picard to the end (measured 13-27 iterations at ~0.85 per iteration). After + the first solve set `stokes.newton_switch_rtol = 1.0`: warm steps then take 2 Newton + iterations. + +## A moving trench + +A fault fixed in space loses its slab once the convergence zone migrates; the slab +stays on the fault when the trench moves with the overriding plate (the median surface +velocity over a window beyond the fault, not the sliver next to it). The faulted mesh is +then rebuilt as the trench moves: + +- carry the solution across a rebuild by evaluating the old velocity and pressure at + each new node shifted back by the trench displacement, so the old slip lands on the + new fault; +- start the rebuilt solve with Picard to a residual reduction of 1e-2 of its own start. + Newton from the transferred field stalls (measured 40 iterations at 0.1 of the + reference residual), and so does Newton after one order of Picard reduction; +- rebuild every few rungs (four, half a base cell here), not every rung: a rebuild step + costs about twice an ordinary one. + +## Cost and diagnostics + +- Through an overturn the nonlinear solve, not the multigrid, sets the cost: 15-25 + Newton increments per step on the moving-trench run while the velocity sub-solve + stayed bounded. +- Compare solver cost by iteration counts (Newton increments, velocity cycles), not + seconds: on a laptop the wall time moves with power state and load. +- A refined mesh read back from its checkpoint has its cells joined to the wrong + vertices (open roadmap item). Renderers rebuild the mesh from the run's parameters + and remap the fields by coordinate; draw P1 fields on the mesh's own cells, not on a + Delaunay of their DOFs. diff --git a/docs/developer/index.md b/docs/developer/index.md index b9a5aedc5..d4c32de5b 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -65,6 +65,7 @@ pages, never copies. A change to a family is reviewed against its guides. | [Plasticity solvers](guides/plasticity-solvers.md) | viscoplastic and VEP Stokes | | [Adaptive meshing](guides/adaptive-meshing.md) | moving-mesh convection | | [Adapt-on-top faults](guides/adapt-on-top-faults.md) | fault models on adapted meshes | +| [Faults in convection](guides/faults-in-convection.md) | split-node faults in time-dependent Stokes | | [Free-surface convection](guides/free-surface-convection.md) | free-surface Stokes | | [Visualisation](guides/uw-visualisation.md), [CeTZ figures](guides/cetz-figures.md) | figures | From c0171e660f70d4dd0918866c45c84254bf6bdf86 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 16 Oct 2026 08:51:06 +1100 Subject: [PATCH 20/59] Tidal shell driver: per-pair slip accumulation along each stripe (cycle-integrated activity and net offset), saved at orbit boundaries Underworld development team with AI support from Claude Code --- scripts/sessions/tidal_shell/tidal_shell.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scripts/sessions/tidal_shell/tidal_shell.py b/scripts/sessions/tidal_shell/tidal_shell.py index bfffb5c65..8109bb9c7 100644 --- a/scripts/sessions/tidal_shell/tidal_shell.py +++ b/scripts/sessions/tidal_shell/tidal_shell.py @@ -116,6 +116,9 @@ def stripe_points(offset, half_len, n=9, lat0=-90.0): else: sheet.add_fault_bc(args.fault_eta if args.fault_law == "viscous" else 0.0, name) cum_slip = {name: 0.0 for name in fault_names} # signed, along the stripe direction +# per-pair accumulation along each trace (Berne et al. Fig. 4a: the cycle-integrated +# lateral slip along the stripes): keyed by the pair coordinates seen on the first step +_prof = {} # name -> dict(coords, act, net, act_cycle, net_cycle) if args.De > 0: sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( sheet.Unknowns, order=1, integrator="etd", objective_rate=args.objective) @@ -420,6 +423,12 @@ def save_displacement(step, t): accumulate_displacement(dt) if (k + 1) % args.steps_per_period == 0 or k == nsteps - 1: save_displacement(k, t) # at every cycle boundary + if _prof and ((k + 1) % args.steps_per_period == 0 or k == nsteps - 1): + if rank0: + np.savez(os.path.join(args.out, f"slipprof_{k:04d}.npz"), t=t, + **{f"{n}_{key}": v for n, pr in _prof.items() for key, v in pr.items()}) + for pr in _prof.values(): + pr["act_cycle"][:] = 0.0; pr["net_cycle"][:] = 0.0 if args.save_global_stress: Sg = stress_on(_grid_in) # collective if rank0: @@ -445,6 +454,17 @@ def save_displacement(step, t): else: signed = 0.0 cum_slip[name] += signed * dt + if len(jumps): + vs = np.einsum("ij,ij->i", jumps, tdir) # signed slip rate per pair + if name not in _prof: + _prof[name] = {"coords": np.asarray(_c).copy(), "act": np.zeros(len(vs)), "net": np.zeros(len(vs)), + "act_cycle": np.zeros(len(vs)), "net_cycle": np.zeros(len(vs))} + pr = _prof[name] + if len(vs) == len(pr["act"]): + from scipy.spatial import cKDTree + _d, idx = cKDTree(np.asarray(_c)).query(pr["coords"]) # same pairs, any order + pr["act"] += np.abs(vs[idx]) * dt; pr["net"] += vs[idx] * dt + pr["act_cycle"] += np.abs(vs[idx]) * dt; pr["net_cycle"] += vs[idx] * dt # clamping: the normal traction on the stripe from the no-opening reaction # (negative in compression); rank-local per pair, reduced collectively from underworld3.utilities.fault_contact import fault_normal_traction From 49866d366411493fc31f30d9599f1dc198871f12 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 16 Oct 2026 08:51:30 +1100 Subject: [PATCH 21/59] Tidal shell: slip along the stripes over an orbit, polar view and 1-D profiles (Berne et al. Fig. 4 style) Underworld development team with AI support from Claude Code --- .../tidal_shell/tidal_slip_profile.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 scripts/sessions/tidal_shell/tidal_slip_profile.py diff --git a/scripts/sessions/tidal_shell/tidal_slip_profile.py b/scripts/sessions/tidal_shell/tidal_slip_profile.py new file mode 100644 index 000000000..6d1157186 --- /dev/null +++ b/scripts/sessions/tidal_shell/tidal_slip_profile.py @@ -0,0 +1,55 @@ +"""Slip along the stripes over the last orbit, Berne et al. Fig. 4 style: the +cycle-integrated lateral slip |ds| (activity) coloured along each trace in the +polar view and as 1-D profiles against distance along the stripe, relative to the +maximum over all stripes; and the same for the signed net offset of the orbit. +Usage: """ +import glob, os, sys +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +sim, run = sys.argv[1:3] +f = sorted(glob.glob(os.path.join(sim, run, "slipprof_*.npz")))[-1] +d = np.load(f) +names = sorted({k.rsplit("_", 1)[0] for k in d.files if k.endswith("_coords")}) +pol = np.load(sorted(glob.glob(os.path.join(sim, run, "polar_*.npz")))[-1]) +c, e1, e2 = pol["centre"], pol["e1"], pol["e2"] + + +def to_polar(pts): + pts = pts / np.linalg.norm(pts, axis=1)[:, None] + cosr = np.clip(pts @ c, -1, 1); rho = np.arccos(cosr) + dd = pts - cosr[:, None] * c[None]; dd /= np.maximum(np.linalg.norm(dd, axis=1), 1e-30)[:, None] + return rho * (dd @ e1), rho * (dd @ e2) + + +act_max = max(d[f"{n}_act_cycle"].max() for n in names) +net_max = max(np.abs(d[f"{n}_net_cycle"]).max() for n in names) +fig, axes = plt.subplots(1, 3, figsize=(18, 5.4)) +for ax, key, vmax, cmap, ttl in ((axes[0], "act_cycle", act_max, "inferno", "cycle-integrated lateral slip along the stripes (relative to the maximum)"), + (axes[1], "net_cycle", net_max, "RdBu_r", "net offset over the orbit (right-lateral positive)")): + for n in names: + X = d[f"{n}_coords"]; a, b = to_polar(X) + s = X @ e1; o = np.argsort(s) + v = d[f"{n}_{key}"] + sc = ax.scatter(a[o], b[o], c=(v[o] / vmax if key == "act_cycle" else v[o]), cmap=cmap, + vmin=(0 if key == "act_cycle" else -net_max), vmax=(1 if key == "act_cycle" else net_max), s=60, zorder=3) + ax.plot(a[o], b[o], color="gray", lw=4, zorder=2, alpha=0.4) + for r in (0.1, 0.2, 0.3): + ax.add_patch(plt.Circle((0, 0), r, fill=False, color="gray", lw=0.5, alpha=0.6)) + ax.set_aspect("equal"); ax.set_xlim(-0.4, 0.4); ax.set_ylim(-0.4, 0.4); ax.set_xticks([]); ax.set_yticks([]) + ax.set_title(ttl, fontsize=9, loc="left"); fig.colorbar(sc, ax=ax, shrink=0.8) +ax = axes[2] +for n in names: + X = d[f"{n}_coords"]; s = X @ e1; o = np.argsort(s); s = s[o] - s[o][0] + ax.plot(s, d[f"{n}_act_cycle"][o] / act_max, "-", label=f"{n} activity") + ax.plot(s, d[f"{n}_net_cycle"][o] / net_max, "--", label=f"{n} net (scaled)") +ax.set_xlabel("distance along the stripe"); ax.set_ylabel("relative to the maximum"); ax.grid(alpha=0.3); ax.legend(fontsize=7, ncol=2) +ax.set_title("1-D profiles along each stripe", fontsize=9, loc="left") +fig.suptitle(f"{run}: slip along the tiger stripes over the last orbit (t = {float(d['t']):.2f}); max activity {act_max:.4f}, max net {net_max:.4f}", fontsize=10) +out = os.path.join(sim, f"slipprofile_{run}.png"); fig.savefig(out, dpi=120, bbox_inches="tight"); print("wrote", out) +for n in names: + X = d[f"{n}_coords"]; s = X @ e1; o = np.argsort(s); v = d[f"{n}_act_cycle"][o]; w = d[f"{n}_net_cycle"][o] + print(f" {n}: {len(v)} pairs, activity max {v.max():.4f} at {np.argmax(v)/(len(v)-1):.2f} of the length, " + f"ends {v[0]/v.max():.2f} / {v[-1]/v.max():.2f} of max; net offset max {w.max():+.4f} min {w.min():+.4f}") From 613d3f87d3d3a68f688fa5bd8a0e42c4a5a4ba6a Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 16 Oct 2026 11:29:34 +1100 Subject: [PATCH 22/59] Tidal shell driver: each tiger stripe is a single great-circle arc (a kinked chain of arcs lost part of its cut silently) Underworld development team with AI support from Claude Code --- scripts/sessions/tidal_shell/tidal_shell.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/scripts/sessions/tidal_shell/tidal_shell.py b/scripts/sessions/tidal_shell/tidal_shell.py index 8109bb9c7..0da586ff9 100644 --- a/scripts/sessions/tidal_shell/tidal_shell.py +++ b/scripts/sessions/tidal_shell/tidal_shell.py @@ -86,13 +86,15 @@ def stripe_points(offset, half_len, n=9, lat0=-90.0): az = np.radians(args.fault_azimuth) e1 = np.cos(az) * sub + np.sin(az) * np.cross(c, sub) # stripe direction e2 = np.cross(c, e1) # across the stripes + # each stripe is ONE great-circle arc: through the point at `offset` along e2 + # from the centre, in the direction e1 (which is tangent there too). A chain of + # arcs between points of the exponential-map locus has a kink at every vertex, + # and the cut lost part of such a stripe silently (measured: the outer stripe + # of the south-polar set kept 11 of 16 facets at 9 points, 8 at a 0.005 shift + # of the offset, and fragmented at 13 points). + cp = np.cos(offset) * c + np.sin(offset) * e2 a = np.linspace(-half_len, half_len, n) - pts = [] - for ai in a: - rho = np.hypot(ai, offset) - d = (ai * e1 + offset * e2) / max(rho, 1e-30) - pts.append(np.cos(rho) * c + np.sin(rho) * d) - return np.array(pts) + return np.array([np.cos(ai) * cp + np.sin(ai) * e1 for ai in a]) fault_names = [] From 5535550046ac6eb4d3d0c4ae6ab0a8b8605e6275 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 16 Oct 2026 15:06:42 +1100 Subject: [PATCH 23/59] Tidal shell: surface strain of the net displacement per orbit in the polar view Underworld development team with AI support from Claude Code --- .../tidal_shell/tidal_farfield_strain.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 scripts/sessions/tidal_shell/tidal_farfield_strain.py diff --git a/scripts/sessions/tidal_shell/tidal_farfield_strain.py b/scripts/sessions/tidal_shell/tidal_farfield_strain.py new file mode 100644 index 000000000..ad169215b --- /dev/null +++ b/scripts/sessions/tidal_shell/tidal_farfield_strain.py @@ -0,0 +1,60 @@ +"""Surface strain of the net displacement per orbit (faulted minus unfaulted): +dilatation and maximum shear on the polar azimuthal grid (geodesic polar +coordinates about the stripe set's centre, unit sphere), masked near the centre. +Reports the rms strain by distance from the set against the tidal strain scale A. +Usage: [A]""" +import glob, os, sys +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +sim, fr, ur = sys.argv[1:4] +A = float(sys.argv[4]) if len(sys.argv) > 4 else 0.01 + + +def last_orbit(run): + f = sorted(glob.glob(os.path.join(sim, run, "disp_*.npz"))) + a, b = np.load(f[-2]), np.load(f[-1]) + return b, b["disp_polar"] - a["disp_polar"] + + +F, dF = last_orbit(fr); U, dU = last_orbit(ur) +net = dF - dU +rho, psi = F["rho"], F["psi"] # (npsi, nrho) grids +pg = F["pgrid"]; c = F["centre"] +r_ = rho.ravel() +d_ = pg - (pg @ c)[:, None] * c; d_ /= np.maximum(np.linalg.norm(d_, axis=1), 1e-30)[:, None] +e_r = np.cos(r_)[:, None] * d_ - np.sin(r_)[:, None] * c[None] # geodesic radial direction +e_p = np.cross(pg, e_r) # azimuthal +u_r = (net * e_r).sum(1).reshape(rho.shape); u_p = (net * e_p).sum(1).reshape(rho.shape) +r1 = rho[0]; p1 = psi[:, 0] +du_r_dr = np.gradient(u_r, r1, axis=1); du_p_dr = np.gradient(u_p, r1, axis=1) +du_r_dp = np.gradient(u_r, p1, axis=0); du_p_dp = np.gradient(u_p, p1, axis=0) +sr, cr = np.sin(rho), np.cos(rho) +e_rr = du_r_dr +e_pp = du_p_dp / sr + u_r * cr / sr +e_rp = 0.5 * (du_r_dp / sr + du_p_dr - u_p * cr / sr) +dil = e_rr + e_pp +shear = np.sqrt((0.5 * (e_rr - e_pp)) ** 2 + e_rp ** 2) +mask = rho < 0.03 +dil = np.where(mask, np.nan, dil); shear = np.where(mask, np.nan, shear) +X, Y = rho * np.cos(psi), rho * np.sin(psi) +fig, axes = plt.subplots(1, 2, figsize=(13, 5.6)) +for ax, fld, ttl, cmap, sym in ((axes[0], dil, "dilatation of the net displacement per orbit", "RdBu_r", True), + (axes[1], shear, "maximum shear strain of the net displacement per orbit", "magma", False)): + lim = np.nanmax(np.abs(fld)); kw = {"vmin": -lim, "vmax": lim} if sym else {"vmin": 0, "vmax": lim} + pc = ax.pcolormesh(X, Y, fld, cmap=cmap, shading="auto", **kw) + for fp in F["faults"]: + ax.plot(fp[:, 0], fp[:, 1], color="cyan" if not sym else "k", lw=2) + for rr in (0.25, 0.5, 0.75): + ax.add_patch(plt.Circle((0, 0), rr, fill=False, color="gray", lw=0.5, alpha=0.6)) + ax.set_aspect("equal"); ax.set_xlim(-0.9, 0.9); ax.set_ylim(-0.9, 0.9); ax.set_xticks([]); ax.set_yticks([]) + ax.set_title(ttl, fontsize=9, loc="left"); fig.colorbar(pc, ax=ax, shrink=0.8) +fig.suptitle(f"{fr} minus {ur}: far-field strain per orbit (polar view, tidal strain scale A = {A})", fontsize=10) +out = os.path.join(sim, f"farfield_strain_{fr}_vs_{ur}.png"); fig.savefig(out, dpi=120, bbox_inches="tight"); print("wrote", out) +w = np.sin(rho) +for lo, hi in ((0.03, 0.25), (0.25, 0.5), (0.5, 0.9)): + m = (rho >= lo) & (rho < hi) + print(f" {np.degrees(lo):3.0f}-{np.degrees(hi):3.0f} deg from the set: rms shear strain {np.sqrt((w[m]*shear[m]**2).sum()/w[m].sum()):.2e} " + f"({np.sqrt((w[m]*shear[m]**2).sum()/w[m].sum())/A:.3f} A), rms dilatation {np.sqrt((w[m]*dil[m]**2).sum()/w[m].sum()):.2e}") From e1c25cc0ddf24ebb344eef496e2216430ec16c03 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 16 Oct 2026 15:07:15 +1100 Subject: [PATCH 24/59] Tidal shell: far-field strain colour scale and bands exclude the stripe block Underworld development team with AI support from Claude Code --- scripts/sessions/tidal_shell/tidal_farfield_strain.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/sessions/tidal_shell/tidal_farfield_strain.py b/scripts/sessions/tidal_shell/tidal_farfield_strain.py index ad169215b..81fed53d6 100644 --- a/scripts/sessions/tidal_shell/tidal_farfield_strain.py +++ b/scripts/sessions/tidal_shell/tidal_farfield_strain.py @@ -39,11 +39,16 @@ def last_orbit(run): shear = np.sqrt((0.5 * (e_rr - e_pp)) ** 2 + e_rp ** 2) mask = rho < 0.03 dil = np.where(mask, np.nan, dil); shear = np.where(mask, np.nan, shear) +# the stripes' own offsets are displacement discontinuities, so the finite-difference +# strain inside the stripe block (corners at ~0.33 rad) is slip over grid spacing, not +# a far-field quantity: the colour scale is set from the field beyond 0.35 rad +far = rho > 0.35 +lim_d = np.nanpercentile(np.abs(dil[far]), 99); lim_s = np.nanpercentile(shear[far], 99) X, Y = rho * np.cos(psi), rho * np.sin(psi) fig, axes = plt.subplots(1, 2, figsize=(13, 5.6)) for ax, fld, ttl, cmap, sym in ((axes[0], dil, "dilatation of the net displacement per orbit", "RdBu_r", True), (axes[1], shear, "maximum shear strain of the net displacement per orbit", "magma", False)): - lim = np.nanmax(np.abs(fld)); kw = {"vmin": -lim, "vmax": lim} if sym else {"vmin": 0, "vmax": lim} + lim = lim_d if sym else lim_s; kw = {"vmin": -lim, "vmax": lim} if sym else {"vmin": 0, "vmax": lim} pc = ax.pcolormesh(X, Y, fld, cmap=cmap, shading="auto", **kw) for fp in F["faults"]: ax.plot(fp[:, 0], fp[:, 1], color="cyan" if not sym else "k", lw=2) @@ -54,7 +59,8 @@ def last_orbit(run): fig.suptitle(f"{fr} minus {ur}: far-field strain per orbit (polar view, tidal strain scale A = {A})", fontsize=10) out = os.path.join(sim, f"farfield_strain_{fr}_vs_{ur}.png"); fig.savefig(out, dpi=120, bbox_inches="tight"); print("wrote", out) w = np.sin(rho) -for lo, hi in ((0.03, 0.25), (0.25, 0.5), (0.5, 0.9)): +print(" (inside 0.35 rad the strain is the stripes' own offsets over the grid spacing; far-field bands only)") +for lo, hi in ((0.35, 0.5), (0.5, 0.7), (0.7, 0.9)): m = (rho >= lo) & (rho < hi) print(f" {np.degrees(lo):3.0f}-{np.degrees(hi):3.0f} deg from the set: rms shear strain {np.sqrt((w[m]*shear[m]**2).sum()/w[m].sum()):.2e} " f"({np.sqrt((w[m]*shear[m]**2).sum()/w[m].sum())/A:.3f} A), rms dilatation {np.sqrt((w[m]*dil[m]**2).sum()/w[m].sum()):.2e}") From d0971abfb038f41487cb8f140873c301e94adfaf Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 16 Oct 2026 17:22:29 +1100 Subject: [PATCH 25/59] Tidal shell: far-field strain reported against its finite-difference noise floor (the unfaulted shell's residual) Underworld development team with AI support from Claude Code --- .../tidal_shell/tidal_farfield_strain.py | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/scripts/sessions/tidal_shell/tidal_farfield_strain.py b/scripts/sessions/tidal_shell/tidal_farfield_strain.py index 81fed53d6..bd4b7be34 100644 --- a/scripts/sessions/tidal_shell/tidal_farfield_strain.py +++ b/scripts/sessions/tidal_shell/tidal_farfield_strain.py @@ -27,16 +27,25 @@ def last_orbit(run): d_ = pg - (pg @ c)[:, None] * c; d_ /= np.maximum(np.linalg.norm(d_, axis=1), 1e-30)[:, None] e_r = np.cos(r_)[:, None] * d_ - np.sin(r_)[:, None] * c[None] # geodesic radial direction e_p = np.cross(pg, e_r) # azimuthal -u_r = (net * e_r).sum(1).reshape(rho.shape); u_p = (net * e_p).sum(1).reshape(rho.shape) -r1 = rho[0]; p1 = psi[:, 0] -du_r_dr = np.gradient(u_r, r1, axis=1); du_p_dr = np.gradient(u_p, r1, axis=1) -du_r_dp = np.gradient(u_r, p1, axis=0); du_p_dp = np.gradient(u_p, p1, axis=0) -sr, cr = np.sin(rho), np.cos(rho) -e_rr = du_r_dr -e_pp = du_p_dp / sr + u_r * cr / sr -e_rp = 0.5 * (du_r_dp / sr + du_p_dr - u_p * cr / sr) -dil = e_rr + e_pp -shear = np.sqrt((0.5 * (e_rr - e_pp)) ** 2 + e_rp ** 2) + + +def strain_of(disp): + u_r = (disp * e_r).sum(1).reshape(rho.shape); u_p = (disp * e_p).sum(1).reshape(rho.shape) + r1 = rho[0]; p1 = psi[:, 0] + du_r_dr = np.gradient(u_r, r1, axis=1); du_p_dr = np.gradient(u_p, r1, axis=1) + du_r_dp = np.gradient(u_r, p1, axis=0); du_p_dp = np.gradient(u_p, p1, axis=0) + sr, cr = np.sin(rho), np.cos(rho) + e_rr = du_r_dr + e_pp = du_p_dp / sr + u_r * cr / sr + e_rp = 0.5 * (du_r_dp / sr + du_p_dr - u_p * cr / sr) + return e_rr + e_pp, np.sqrt((0.5 * (e_rr - e_pp)) ** 2 + e_rp ** 2) + + +dil, shear = strain_of(net) +# the noise floor of this finite-difference measure: the same strain of the UNFAULTED +# shell's own residual over the orbit (a periodic shell has none; what remains is the +# grid interpolation of a P2 field, differenced) +dil_n, shear_n = strain_of(dU) mask = rho < 0.03 dil = np.where(mask, np.nan, dil); shear = np.where(mask, np.nan, shear) # the stripes' own offsets are displacement discontinuities, so the finite-difference @@ -62,5 +71,6 @@ def last_orbit(run): print(" (inside 0.35 rad the strain is the stripes' own offsets over the grid spacing; far-field bands only)") for lo, hi in ((0.35, 0.5), (0.5, 0.7), (0.7, 0.9)): m = (rho >= lo) & (rho < hi) - print(f" {np.degrees(lo):3.0f}-{np.degrees(hi):3.0f} deg from the set: rms shear strain {np.sqrt((w[m]*shear[m]**2).sum()/w[m].sum()):.2e} " - f"({np.sqrt((w[m]*shear[m]**2).sum()/w[m].sum())/A:.3f} A), rms dilatation {np.sqrt((w[m]*dil[m]**2).sum()/w[m].sum()):.2e}") + rs = lambda f: np.sqrt((w[m] * f[m] ** 2).sum() / w[m].sum()) + print(f" {np.degrees(lo):3.0f}-{np.degrees(hi):3.0f} deg from the set: rms shear strain {rs(shear):.2e} ({rs(shear)/A:.3f} A), " + f"rms dilatation {rs(dil):.2e}; NOISE FLOOR (unfaulted residual) shear {rs(shear_n):.2e}, dilatation {rs(dil_n):.2e}") From c8c342b908651405fd1e16375509e21a54eccfbd Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 16 Oct 2026 17:23:16 +1100 Subject: [PATCH 26/59] Tidal shell: the orbit's stripe net slip is measured from the record before the cycle's first step (it missed one step) Underworld development team with AI support from Claude Code --- scripts/sessions/tidal_shell/tidal_farfield.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/sessions/tidal_shell/tidal_farfield.py b/scripts/sessions/tidal_shell/tidal_farfield.py index ddeaa80dd..979c6deba 100644 --- a/scripts/sessions/tidal_shell/tidal_farfield.py +++ b/scripts/sessions/tidal_shell/tidal_farfield.py @@ -29,7 +29,10 @@ def last_orbit(run): rs = json.load(open(os.path.join(sim, fr, "records.json"))) t = np.array([r["t"] for r in rs]); last = t > t.max() - 1 + 1e-9 names = sorted(rs[0]["slip"].keys()) -net_slip = np.mean([rs[-1]["slip"][n]["cumulative"] - rs[int(np.argmax(last))]["slip"][n]["cumulative"] for n in names]) +# the orbit's net slip is the cumulative at its end minus the cumulative at the end +# of the previous orbit (the record BEFORE the first step of the last cycle) +i0 = int(np.argmax(last)) +net_slip = np.mean([rs[-1]["slip"][n]["cumulative"] - rs[i0 - 1]["slip"][n]["cumulative"] for n in names]) # the unfaulted shell's own residual (numerical): the noise floor floor = np.linalg.norm(dgU, axis=1) print(f"orbit length {TF:.3f}; stripe-mean net slip over the orbit {net_slip:+.5f}") From adfdc6dd71782dd5558251c4b34625199d90e346 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 07:05:41 +1100 Subject: [PATCH 27/59] Tidal shell: render the split sphere mesh with the realised stripe cuts Underworld development team with AI support from Claude Code --- .../sessions/tidal_shell/tidal_mesh_fig.py | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 scripts/sessions/tidal_shell/tidal_mesh_fig.py diff --git a/scripts/sessions/tidal_shell/tidal_mesh_fig.py b/scripts/sessions/tidal_shell/tidal_mesh_fig.py new file mode 100644 index 000000000..b39a544fd --- /dev/null +++ b/scripts/sessions/tidal_shell/tidal_mesh_fig.py @@ -0,0 +1,112 @@ +"""Render the spherical sheet mesh used in the tidal study, with the four tiger stripes. + +Usage: python tidal_mesh_fig.py [h] (h defaults to 0.08, the production cell size) + +Two panels: the whole sphere viewed from the south-polar side with the sub-planet +point (+x) marked, and a close-up of the stripe set. The mesh is the split mesh the +driver uses (SphericalManifold + add_fault with the driver's default geometry); +the stripes are drawn as the great-circle arcs passed to the cut. +""" +import os +import sys + +import numpy as np +import pyvista as pv + +pv.OFF_SCREEN = True +import underworld3 as uw +import underworld3.visualisation as vis + +sim = sys.argv[1] +h = float(sys.argv[2]) if len(sys.argv) > 2 else 0.08 +os.makedirs(sim, exist_ok=True) + +# the driver's default stripe geometry (tidal_shell.py: --faults 4 --fault_len 0.52 +# --fault_spacing 0.14 --fault_lat -90 --fault_lon 0 --fault_azimuth -90) +n_faults, fault_len, spacing, lat0, lon0, azim = 4, 0.52, 0.14, -90.0, 0.0, -90.0 + + +def stripe_points(offset, half_len, n=9): + th0, ph0 = np.radians(lat0), np.radians(lon0) + c = np.array([np.cos(th0) * np.cos(ph0), np.cos(th0) * np.sin(ph0), np.sin(th0)]) + east = np.array([-np.sin(ph0), np.cos(ph0), 0.0]) + sub = np.cross(c, east) + az = np.radians(azim) + e1 = np.cos(az) * sub + np.sin(az) * np.cross(c, sub) + e2 = np.cross(c, e1) + cp = np.cos(offset) * c + np.sin(offset) * e2 + a = np.linspace(-half_len, half_len, n) + return np.array([np.cos(ai) * cp + np.sin(ai) * e1 for ai in a]) + + +sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=h, degree=1, qdegree=5) +offsets = (np.arange(n_faults) - 0.5 * (n_faults - 1)) * spacing +faults = [(f"Stripe{i}", stripe_points(off, 0.5 * fault_len)) for i, off in enumerate(offsets)] +split = sphere.add_fault(faults) + +pv_mesh = vis.mesh_to_pv_mesh(split) +edges = pv_mesh.extract_all_edges() +n_cells = pv_mesh.n_cells +n_pts = pv_mesh.n_points + +# the realised cut: the facets carrying each stripe's label (the cut snaps existing +# mesh nodes onto the requested arc, so these are mesh edges; no refinement) +dm = split.dm +fS, fE = dm.getHeightStratum(1) +pS, pE = dm.getDepthStratum(0) +X = split.X.coords +lines = [] +n_pairs = {} +for name, _ in faults: + lab = dm.getLabel(name) + segs = [] + for v in lab.getValueIS().getIndices(): + iss = dm.getStratumIS(name, v) + if iss is None: + continue + for p in iss.getIndices(): + if fS <= p < fE: + segs.append(dm.getCone(p) - pS) + segs = np.array(segs) + pts = X[segs.reshape(-1)] * 1.002 + cells = np.column_stack([np.full(len(segs), 2), np.arange(0, 2 * len(segs), 2), np.arange(1, 2 * len(segs), 2)]) + lines.append(pv.PolyData(pts, lines=cells.reshape(-1))) + n_pairs[name] = len(split._fault_point_pairs[name]) +subplanet = pv.PolyData(np.array([[1.0, 0.0, 0.0]])) +south = pv.PolyData(np.array([[0.0, 0.0, -1.0]])) + +pl = pv.Plotter(off_screen=True, shape=(1, 2), window_size=(2000, 1000), border=False) +for k in range(2): + pl.subplot(0, k) + pl.set_background("white") + pl.add_mesh(pv_mesh, color="#f2f2f2", show_edges=False, lighting=False) + pl.add_mesh(edges, color="#404040", line_width=0.8 if k == 0 else 1.4, lighting=False) + for blk in lines: + pl.add_mesh(blk, color="#c0392b", line_width=4 if k == 0 else 6, lighting=False, render_lines_as_tubes=True) + pl.add_mesh(subplanet, color="#1f77b4", point_size=18, render_points_as_spheres=True, lighting=False) + pl.add_mesh(south, color="#c0392b", point_size=10, render_points_as_spheres=True, lighting=False) + pl.camera.parallel_projection = True + if k == 0: + # oblique: sub-planet point at the right, south pole toward the viewer + pl.camera.position = (2.0, -1.5, -3.0) + pl.camera.focal_point = (0.0, 0.0, 0.0) + pl.camera.up = (1.0, 0.0, 0.0) + pl.camera.parallel_scale = 1.15 + pl.add_point_labels(subplanet, ["sub-planet point (+x)"], font_size=22, text_color="#1f77b4", + point_color="#1f77b4", point_size=1, shape=None, always_visible=True) + pl.add_text(f"SphericalManifold, h = {h}: {n_cells} triangles, {n_pts} nodes (split)", + position="upper_left", font_size=14, color="black") + else: + # polar close-up: looking up at the south pole, +x (sub-planet direction) to the top + pl.camera.position = (0.0, 0.0, -4.0) + pl.camera.focal_point = (0.0, 0.0, -1.0) + pl.camera.up = (1.0, 0.0, 0.0) + pl.camera.parallel_scale = 0.45 + pl.add_text("South polar region: four stripes, length 0.52, spacing 0.14 rad\n" + "(mesh nodes snapped onto the arcs and duplicated, no local refinement; +x toward the top)", + position="upper_left", font_size=14, color="black") + +out = os.path.join(sim, f"mesh_h{h:g}.png") +pl.screenshot(out) +pl.close() +print("wrote", out, "cells", n_cells, "points", n_pts, "unsplit points", sphere.X.coords.shape[0], "pairs", n_pairs) From 479569f413caf650e644d0e86c48a0f8d103a2ff Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 08:39:30 +1100 Subject: [PATCH 28/59] Box fault cyclic loading: model set-up figure (Typst/cetz) Underworld development team with AI support from Claude Code --- .../box_fault_cyclic/figures/setup.png | Bin 0 -> 118784 bytes .../box_fault_cyclic/figures/setup.typ | 155 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 scripts/sessions/box_fault_cyclic/figures/setup.png create mode 100644 scripts/sessions/box_fault_cyclic/figures/setup.typ diff --git a/scripts/sessions/box_fault_cyclic/figures/setup.png b/scripts/sessions/box_fault_cyclic/figures/setup.png new file mode 100644 index 0000000000000000000000000000000000000000..866f2bff2f2275ef63887c119891d3124298b810 GIT binary patch literal 118784 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 N<<;Mn%6|IxzW{Mbiz@&C literal 0 HcmV?d00001 diff --git a/scripts/sessions/box_fault_cyclic/figures/setup.typ b/scripts/sessions/box_fault_cyclic/figures/setup.typ new file mode 100644 index 000000000..5de223e8c --- /dev/null +++ b/scripts/sessions/box_fault_cyclic/figures/setup.typ @@ -0,0 +1,155 @@ +// Model set-up: a surface-breaking strike-slip fault in a 3-D viscoelastic +// block under a harmonic end load (the box toy model of the tidal cycle). +// House style: cetz-figures skill. Compile: typst compile --format png --ppi 220 setup.typ setup.png +#import "@preview/cetz:0.3.4" + +#set page(width: auto, height: auto, margin: 10pt) +#set text(size: 9pt) + +// ── Colours (hex, named by role) ────────────────────────────────────── +#let edge-colour = rgb("#1f3a6b") // navy — block edges +#let hidden-colour = rgb("#9aa5b8") // grey-blue — hidden edges +#let face-fill = rgb(31, 58, 107, 14) // faint navy wash +#let fixed-fill = rgb(31, 58, 107, 70) // darker wash — the fixed face +#let fault-colour = rgb("#c2410c") // rust — the fault +#let fault-fill = rgb(194, 65, 12, 60) +#let later-colour = rgb("#7c3aed") // violet — faults added later +#let later-fill = rgb(124, 58, 237, 28) +#let load-colour = rgb("#059669") // emerald — applied tractions +#let slip-colour = rgb("#1f3a6b") +#let shear-colour = rgb("#059669") +#let normal-colour = rgb("#c2410c") +#let grey = rgb("#6b7280") + +// ── Oblique (cabinet) projection: x right, y receding, z up ─────────── +#let proj(p) = (p.at(0) + 0.42 * p.at(1), p.at(2) + 0.26 * p.at(1)) +#let P(x, y, z) = proj((x, y, z)) + +// block (model units) and fault: vertical, normal y, strike x, breaking the surface +#let Lx = 3.2 +#let Ly = 1.8 +#let H = 1.0 +#let yf = 0.85 +#let xa = 1.05 +#let xb = 2.35 +#let w = 0.6 +// a later fault, drawn faintly +#let yf2 = 1.35 +#let xa2 = 1.6 +#let xb2 = 2.7 + +#let seg(a, b, col, th: 0.8pt, dash: none) = { + import cetz.draw: * + line(a, b, stroke: (paint: col, thickness: th, dash: dash)) +} +#let arrow(a, b, col, th: 1.0pt) = { + import cetz.draw: * + line(a, b, stroke: (paint: col, thickness: th), mark: (end: ">", fill: col, scale: 0.6)) +} + +#let panel-a = cetz.canvas(length: 1.9cm, { + import cetz.draw: * + + // 1. hidden edges + seg(P(0, Ly, 0), P(Lx, Ly, 0), hidden-colour, dash: "dashed") + seg(P(0, Ly, 0), P(0, Ly, H), hidden-colour, dash: "dashed") + seg(P(0, 0, 0), P(0, Ly, 0), hidden-colour, dash: "dashed") + + // 2. the later fault (faint), then the fault — both inside the block + line(P(xa2, yf2, H), P(xb2, yf2, H), P(xb2, yf2, H - w), P(xa2, yf2, H - w), close: true, + fill: later-fill, stroke: (paint: later-colour, thickness: 0.7pt, dash: "dotted")) + line(P(xa, yf, H), P(xb, yf, H), P(xb, yf, H - w), P(xa, yf, H - w), close: true, + fill: fault-fill, stroke: (paint: fault-colour, thickness: 0.9pt, dash: "dashed")) + + // 3. visible faces: top, front (y = 0), right (x = Lx); fixed face at x = 0 shows through + line(P(0, 0, H), P(Lx, 0, H), P(Lx, Ly, H), P(0, Ly, H), close: true, + fill: face-fill, stroke: (paint: edge-colour, thickness: 0.9pt)) + line(P(0, 0, 0), P(Lx, 0, 0), P(Lx, 0, H), P(0, 0, H), close: true, + fill: face-fill, stroke: (paint: edge-colour, thickness: 0.9pt)) + line(P(Lx, 0, 0), P(Lx, Ly, 0), P(Lx, Ly, H), P(Lx, 0, H), close: true, + fill: face-fill, stroke: (paint: edge-colour, thickness: 0.9pt)) + // the fixed face x = 0, hatched by short strokes along its visible edges + for k in range(0, 7) { + let z = H * k / 6 + seg(P(0, 0, z), P(0, 0.22, z + 0.05), edge-colour, th: 0.6pt) + } + content(P(0, 0.05, 0.5 * H), text(fill: edge-colour, size: 8pt)[fixed\ $bold(u) = 0$], anchor: "east", padding: 4pt) + + // surface traces (solid) over the top wash: later fault faint, the fault strong + seg(P(xa2, yf2, H), P(xb2, yf2, H), later-colour, th: 1.0pt, dash: "dotted") + seg(P(xa, yf, H), P(xb, yf, H), fault-colour, th: 1.8pt) + + // 4. slip sense on the surface (right-lateral in the first half cycle) + let xm = 0.5 * (xa + xb) + arrow(P(xm - 0.25, yf - 0.17, H), P(xm + 0.25, yf - 0.17, H), slip-colour) + arrow(P(xm + 0.25, yf + 0.17, H), P(xm - 0.25, yf + 0.17, H), slip-colour) + + // 5. fault dimensions and label + seg(P(xa, yf, H - w - 0.08), P(xb, yf, H - w - 0.08), grey, th: 0.5pt) + content(P(xm, yf, H - w - 0.08), text(fill: grey, $ell$), anchor: "north", padding: 2pt) + seg(P(xb + 0.08, yf, H), P(xb + 0.08, yf, H - w), grey, th: 0.5pt) + content(P(xb + 0.08, yf, H - 0.5 * w), text(fill: grey, $w$), anchor: "west", padding: 2pt) + content(P(xm, yf, H - 0.55 * w), + text(fill: fault-colour, size: 8pt)[strike-slip fault\ Coulomb $mu$, $T_b$], + anchor: "center") + content(P(xb2 + 0.05, yf2, H - 0.3 * w), text(fill: later-colour, size: 7.5pt)[further faults\ (later)], anchor: "west", padding: 2pt) + + // 6. the harmonic end load on x = Lx: normal and horizontal shear tractions + arrow(P(Lx, 0.5 * Ly, 0.55 * H), P(Lx + 0.5, 0.5 * Ly, 0.55 * H), load-colour, th: 1.2pt) + content(P(Lx + 0.55, 0.5 * Ly, 0.55 * H), text(fill: load-colour, $sigma_(x x)(t)$), anchor: "west") + arrow(P(Lx, 0.25 * Ly, 0.18 * H), P(Lx, 0.70 * Ly, 0.18 * H), load-colour, th: 1.2pt) + content(P(Lx, 0.72 * Ly, 0.18 * H), text(fill: load-colour, $sigma_(x y)(t)$), anchor: "north-west", padding: 1pt) + + // 7. axes + let o = P(-0.55, 0, -0.35) + arrow(o, proj((-0.55 + 0.45, 0, -0.35)), grey) + content(proj((-0.55 + 0.5, 0, -0.35)), text(fill: grey, $hat(x)$), anchor: "west") + arrow(o, proj((-0.55, 0.6, -0.35)), grey) + content(proj((-0.55, 0.7, -0.35)), text(fill: grey, $hat(y)$), anchor: "south-west") + arrow(o, proj((-0.55, 0, -0.35 + 0.45)), grey) + content(proj((-0.55, 0, -0.35 + 0.5)), text(fill: grey, $hat(z)$), anchor: "south") + + // 8. boundary annotations + content(P(0.5 * Lx, Ly, H), text(fill: edge-colour, size: 8pt)[free surface], anchor: "south", padding: 2pt) + content(P(0.5 * Lx, 0, 0), text(fill: edge-colour, size: 8pt)[base: free-slip], anchor: "north", padding: 3pt) + content(P(0.30 * Lx, 0, 0.32 * H), text(fill: edge-colour, size: 8pt)[$y$ faces: periodic\ (or in/outflow)], anchor: "center") + content(P(0.74 * Lx, 0, 0), text(fill: edge-colour, size: 8pt)[Maxwell block, Wallace number $"Wa" = T_"orbit" \/ T_M$], anchor: "north-west", padding: 3pt) +}) + +// ── Panel (b): the load cycle on the driven face ────────────────────── +#let panel-b = cetz.canvas(length: 1.0cm, { + import cetz.draw: * + let W = 5.0 + let A = 1.1 + let phase = 80.0 // deg: the normal traction leads the shear (placeholder: stripe value) + let n = 120 + seg((0, -A - 0.3), (0, A + 0.3), grey, th: 0.6pt) + arrow((0, 0), (W + 0.3, 0), grey, th: 0.6pt) + content((W + 0.35, 0), text(fill: grey, $t \/ T_"orbit"$), anchor: "west") + for k in range(0, 5) { + let x = W * k / 4 + seg((x, -0.06), (x, 0.06), grey, th: 0.6pt) + } + content((W, -0.1), text(fill: grey, size: 8pt)[1], anchor: "north") + let pts-s = range(0, n + 1).map(i => (W * i / n, A * calc.sin(2 * calc.pi * i / n))) + let pts-n = range(0, n + 1).map(i => (W * i / n, A * calc.sin(2 * calc.pi * i / n + phase * calc.pi / 180))) + line(..pts-s, stroke: (paint: shear-colour, thickness: 1.2pt)) + line(..pts-n, stroke: (paint: normal-colour, thickness: 1.2pt, dash: "dashed")) + seg((W + 0.5, A + 0.05), (W + 1.0, A + 0.05), shear-colour, th: 1.2pt) + content((W + 1.1, A + 0.05), text(fill: shear-colour, size: 8pt)[$sigma_(x y)$ shear, $S sin(omega t)$], anchor: "west") + seg((W + 0.5, A - 0.35), (W + 1.0, A - 0.35), normal-colour, th: 1.2pt, dash: "dashed") + content((W + 1.1, A - 0.35), text(fill: normal-colour, size: 8pt)[$sigma_(x x)$ normal, $N sin(omega t + phi.alt)$], anchor: "west") + let xs = W * 0.25 + let xn = W * (0.25 - phase / 360) + seg((xn, -A - 0.15), (xs, -A - 0.15), grey, th: 0.6pt) + seg((xn, -A - 0.22), (xn, -A - 0.08), grey, th: 0.6pt) + seg((xs, -A - 0.22), (xs, -A - 0.08), grey, th: 0.6pt) + content((0.5 * (xn + xs), -A - 0.2), text(fill: grey, size: 8pt)[$phi.alt$], anchor: "north", padding: 2pt) + content((W * 0.25, -A * 0.45), text(fill: grey, size: 7.5pt)[right-lateral\ half cycle], anchor: "center") + content((W * 0.75, A * 0.5), text(fill: grey, size: 7.5pt)[left-lateral\ half cycle], anchor: "center") +}) + +#grid(columns: 2, column-gutter: 14pt, align: bottom, + [#panel-a #v(2pt) #text(size: 8pt)[(a) block, boundary conditions and the fault]], + [#panel-b #v(2pt) #text(size: 8pt)[(b) the harmonic end load: normal and shear tractions with a phase lag $phi.alt$]], +) From 20cf7121cf88915feded640114fdd717f5ea1e41 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 08:43:52 +1100 Subject: [PATCH 29/59] Box fault figure: side walls traction-free Underworld development team with AI support from Claude Code --- .../box_fault_cyclic/figures/setup.png | Bin 118784 -> 118780 bytes .../box_fault_cyclic/figures/setup.typ | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sessions/box_fault_cyclic/figures/setup.png b/scripts/sessions/box_fault_cyclic/figures/setup.png index 866f2bff2f2275ef63887c119891d3124298b810..ed2c9cef00acfaba50f9b1b81cc58e8997090e74 100644 GIT binary patch delta 78471 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 BT{!>% diff --git a/scripts/sessions/box_fault_cyclic/figures/setup.typ b/scripts/sessions/box_fault_cyclic/figures/setup.typ index 5de223e8c..406e7dbcf 100644 --- a/scripts/sessions/box_fault_cyclic/figures/setup.typ +++ b/scripts/sessions/box_fault_cyclic/figures/setup.typ @@ -112,7 +112,7 @@ // 8. boundary annotations content(P(0.5 * Lx, Ly, H), text(fill: edge-colour, size: 8pt)[free surface], anchor: "south", padding: 2pt) content(P(0.5 * Lx, 0, 0), text(fill: edge-colour, size: 8pt)[base: free-slip], anchor: "north", padding: 3pt) - content(P(0.30 * Lx, 0, 0.32 * H), text(fill: edge-colour, size: 8pt)[$y$ faces: periodic\ (or in/outflow)], anchor: "center") + content(P(0.30 * Lx, 0, 0.32 * H), text(fill: edge-colour, size: 8pt)[$y$ faces: traction-free\ (or $u_x = u_z = 0$)], anchor: "center") content(P(0.74 * Lx, 0, 0), text(fill: edge-colour, size: 8pt)[Maxwell block, Wallace number $"Wa" = T_"orbit" \/ T_M$], anchor: "north-west", padding: 3pt) }) From 186ac060a244e947c986632688e152616e48887e Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 09:09:18 +1100 Subject: [PATCH 30/59] Port the 3-D fault fixes from feature/mohr-triaxial Three fixes found by the tumbling-fault Mohr sweep, brought onto this branch for the box fault study: - fault_contact: the 3-D fault normal traction is recovered pointwise (a linear fit of the P2-triangle consistent load on the facet vertices) rather than by dividing by P1 sub-lumped masses, which read zero at every vertex and 4/3 of the value at every midpoint. The branch is keyed on the facet topology (mesh.dim), not the coordinate dimension, so the edge fault on a surface mesh keeps its lumped path. - place_surface: the sheet cavity is closed in the three shapes that defeated it on adapted meshes (an enclosed kept cell, a pinched shell vertex, a flat fill tet), with a bounded refill loop. - fault_split: a single split_fault keeps the mesh's multigrid tail, as the network path already did. Tests: the pointwise 3-D traction and the multigrid-tail checks in test_0848; the adapted-mesh placement cases as test_0867 (0864 was taken on this branch). Underworld development team with AI support from Claude Code --- src/underworld3/utilities/fault_contact.py | 140 ++++++-- src/underworld3/utilities/fault_split.py | 12 + src/underworld3/utilities/place_surface.py | 372 +++++++++++++++------ tests/test_0848_fault_split_3d.py | 99 ++++++ tests/test_0867_place_sheet_adapted_3d.py | 88 +++++ 5 files changed, 591 insertions(+), 120 deletions(-) create mode 100644 tests/test_0867_place_sheet_adapted_3d.py diff --git a/src/underworld3/utilities/fault_contact.py b/src/underworld3/utilities/fault_contact.py index 70a07d185..014893c34 100644 --- a/src/underworld3/utilities/fault_contact.py +++ b/src/underworld3/utilities/fault_contact.py @@ -775,6 +775,91 @@ def _tangent_frame(nrm): for i, j in ((0, 1), (1, 2), (2, 0))]) +def _linear_trace_fit(facets, facet_verts, n_rows): + r"""Operators that recover a traction on a P2-triangle fault trace as a + linear field on the facet vertices. + + For a traction :math:`\sigma = \sum_v \sigma_v \lambda_v` linear on a + facet of area :math:`A`, the consistent P2 load is exact and closed-form: + + .. math:: + + \int \phi_{v_i} \sigma = \tfrac{A}{30}\sigma_i + - \tfrac{A}{60}(\sigma_j + \sigma_k), \qquad + \int \phi_{e_{ij}} \sigma = \tfrac{A}{15}(2\sigma_i + 2\sigma_j + + \sigma_k). + + Assembled over the facets this gives ``G``, pair-node load from vertex + values: about three times as many rows as columns, so the vertex values + are a least-squares fit. Rim vertices are ordinary columns — they have + no pair row of their own, but the midpoints beside them pin them. + + Returns ``(G, ends)``: ``G`` a rank-local AIJ Mat ``(n_rows, n_vertices)`` + and ``ends`` an ``(n_rows, 2)`` array of the vertex columns whose mean + is each pair node's value (a vertex node lists its own column twice). + """ + column = {} + entries = {} + ends = np.full((n_rows, 2), -1, dtype=np.int64) + for (idx, area, _law_id), verts in zip(facets, facet_verts): + cols = [column.setdefault(v, len(column)) for v in verts] + for i in range(3): + k = idx[i] + if k < 0: + continue + ends[k] = (cols[i], cols[i]) + for j in range(3): + w = area / 30.0 if i == j else -area / 60.0 + entries[(k, cols[j])] = entries.get((k, cols[j]), 0.0) + w + for e, (i, j) in enumerate(((0, 1), (1, 2), (2, 0))): + k = idx[3 + e] + if k < 0: + continue + ends[k] = (cols[i], cols[j]) + for m in range(3): + w = (2.0 if m in (i, j) else 1.0) * area / 15.0 + entries[(k, cols[m])] = entries.get((k, cols[m]), 0.0) + w + + G = PETSc.Mat().createAIJ(size=(n_rows, max(len(column), 1)), + comm=PETSc.COMM_SELF) + G.setPreallocationNNZ(9) + for (k, c), w in entries.items(): + G.setValue(int(k), int(c), w, addv=True) + G.assemble() + return G, ends + + +def _fitted_normal_traction(fit, load): + """Pair-node traction from the nodal load through the linear trace fit. + + Solved by LSQR on this rank: the fault is rank-interior after the + redistribution every parallel split performs. A failed fit is reported + to every rank, since the callers run collectively. + """ + G, ends = fit + if not len(ends): + _collective_raise(None) + return np.zeros(0) + n_rows, n_cols = G.getSize() + rhs = PETSc.Vec().createSeq(n_rows) + rhs.setArray(np.asarray(load, dtype=float)) + vertex_values = PETSc.Vec().createSeq(n_cols) + ksp = PETSc.KSP().create(comm=PETSc.COMM_SELF) + ksp.setOperators(G) + ksp.setType("lsqr") + ksp.getPC().setType("none") + ksp.setTolerances(rtol=1e-12, atol=0.0, max_it=20 * n_cols) + ksp.solve(rhs, vertex_values) + reason = ksp.getConvergedReason() + values = vertex_values.getArray().copy() + for obj in (ksp, rhs, vertex_values): + obj.destroy() + _collective_raise(None if reason > 0 else + f"the linear trace fit of the fault normal traction " + f"did not converge (KSP reason {reason}).") + return 0.5 * (values[ends[:, 0]] + values[ends[:, 1]]) + + class _InterfaceAssembler: r"""Residual and consistent tangent of the interface laws, per iterate. @@ -809,6 +894,9 @@ def __init__(self, solver, include=()): rows, lo_p, lo_m, tans, nrms, laws_of, facets = \ [], [], [], [], [], [], [] + # 3-D only: each facet's vertex points (rim vertices included), + # for the linear fit that recovers the normal traction + facet_verts = [] index_of = {} fault_of = [] current = [None] # the fault being walked @@ -898,6 +986,7 @@ def node_index(q): (verts[i], verts[j]))]) for i, j in ((0, 1), (1, 2), (2, 0))) facets.append((idx, area, law_id)) + facet_verts.append(verts) _collective_raise(problem) # petsc4py refuses to narrow index arrays — Vec/Mat setValues need @@ -918,32 +1007,38 @@ def node_index(q): self._dim = dim self._points = {q: k for q, k in index_of.items()} - # Positive trace mass per pair node, for de-smearing the constraint - # reaction into a pointwise normal traction. On the P2 LINE the - # lumped (row-sum) mass is positive (L/6, 2L/3, L/6) and is used - # directly. On the P2 TRIANGLE the lumped VERTEX rows vanish (the - # known trap: row sums are 0, 0, 0, A/3, A/3, A/3), so the triangle - # uses the P1 SUB-LUMPING instead — each straight P2 triangle is - # four P1 sub-triangles of area A/4, whose lumped masses give every - # node a positive weight (A/12 per vertex, A/4 per midpoint). + # Recovering a pointwise normal traction from the constraint + # reaction. On the P2 LINE the lumped (row-sum) trace mass is + # positive (L/6, 2L/3, L/6) and dividing the nodal load by it is + # exact for a uniform traction. On the P2 TRIANGLE the consistent + # load of ANY traction vanishes at the vertex nodes (row sums 0, 0, + # 0, A/3, A/3, A/3), so no nodal division can be pointwise right — + # the former P1 sub-lumping read 0 at every vertex and 4/3 of the + # traction at every midpoint. A consistent-mass solve does not + # rescue it either: the unsplit rim nodes carry no load, and leaving + # them out polluted the whole patch (measured error ~50%). The + # triangle instead fits the traction as a LINEAR field on the facet + # vertices, rim vertices included, whose consistent load matches the + # nodal load in least squares — exact for uniform and linear + # traction (see _linear_trace_fit). # The star-forest completion below is defensive: after the # redistribution that precedes every parallel split the fault is # rank-interior and the exchange is a no-op on the fault rows. + self._traction_fit = (_linear_trace_fit(facets, facet_verts, len(rows)) + if solver.mesh.dim == 3 else None) mass = np.zeros(len(rows)) # the branch follows the facet TOPOLOGY (mesh.dim), as the quadrature # above does, not the component count: a fault on a surface mesh is an # edge carried by a 3-vector, and the triangle weights gave its mid-edge # nodes an eighth of their mass (measured: the breathing sphere's fault - # read 5 to 8 times its membrane resultant) - for idx, measure, _law_id in facets: - if solver.mesh.dim == 2: - weights = (measure / 6.0, 2.0 * measure / 3.0, - measure / 6.0) - else: - weights = (measure / 12.0,) * 3 + (measure / 4.0,) * 3 - for k, w in zip(idx, weights): - if k >= 0: - mass[k] += w + # read 5 to 8 times its membrane resultant). The triangle (mesh.dim 3) + # takes no lumped mass at all: its recovery is the linear fit above. + if solver.mesh.dim == 2: + for idx, measure, _law_id in facets: + weights = (measure / 6.0, 2.0 * measure / 3.0, measure / 6.0) + for k, w in zip(idx, weights): + if k >= 0: + mass[k] += w if mpi.size > 1: pStart, pEnd = dm.getChart() chart_mass = np.zeros(pEnd - pStart, dtype=np.float64) @@ -1056,14 +1151,15 @@ def nodal_normal_traction(self, solver, reaction): lvec = dm.getLocalVec() dm.globalToLocal(reaction, lvec) a = np.asarray(lvec.getArray()) - sig = np.zeros(len(self._rows)) + load = np.zeros(len(self._rows)) for k in range(len(self._rows)): rp = a[self._lo_p[k]:self._lo_p[k] + dim] rm = a[self._lo_m[k]:self._lo_m[k] + dim] - load = 0.5 * float(self._nrm[k] @ (rp - rm)) - sig[k] = load / max(self._mass[k], 1e-300) + load[k] = 0.5 * float(self._nrm[k] @ (rp - rm)) dm.restoreLocalVec(lvec) - return sig + if self._traction_fit is not None: + return _fitted_normal_traction(self._traction_fit, load) + return load / np.maximum(self._mass, 1e-300) def update_normal_stress(self, solver, reaction): """Picard-lag the SIGNED effective normal stress into the laws: diff --git a/src/underworld3/utilities/fault_split.py b/src/underworld3/utilities/fault_split.py index 736d9ba74..0cd11243e 100644 --- a/src/underworld3/utilities/fault_split.py +++ b/src/underworld3/utilities/fault_split.py @@ -2420,5 +2420,17 @@ def carry_keys(d, what): if prior_normals or seam_normals: child._fault_seam_normals = dict(prior_normals) child._fault_seam_normals[name] = seam_normals + # The split re-represents the same grid (only the fault vertices are + # duplicated), so a mesh-owned geometric-MG tail serves it unchanged — + # the rule add_fault and split_faults already applied to their results. + # Here too, or the direct 3-D pipeline (adapt -> add_conforming_sheet -> + # split_fault) silently solved on GAMG. The FAC zone is not inherited: + # a split fault needs no patch. + own_tail = getattr(mesh, "_custom_mg_coarse_meshes", None) + if own_tail is not None: + child._custom_mg_coarse_meshes = list(own_tail) + child._custom_mg_builder = getattr(mesh, "_custom_mg_builder", + "barycentric") + child._custom_mg_fac_zone = None mesh._registered_children.add(child) return child diff --git a/src/underworld3/utilities/place_surface.py b/src/underworld3/utilities/place_surface.py index becd3a07e..acb6afa06 100644 --- a/src/underworld3/utilities/place_surface.py +++ b/src/underworld3/utilities/place_surface.py @@ -67,6 +67,30 @@ refused with the reason; interior surfaces, the fault case, run at any rank count. +TODO(BUG): the sheet's cavity shell fails to close at fine resolution in an +orientation- and grading-dependent way, and raising ``clearance`` does not +help. Measured 2026-09-17 (3-D disc, base 0.2, h_near 0.0125, three adapt +levels): with the refinement band reaching 0.4 from the fault, a 60-orientation +sweep placed 60/60; with the band tightened to 0.1, 20 of 24 shards died on +their first sample with "the sheet's cavity shell did not close in 20 growth +rounds; raise `clearance`". On one fixed orientation, band 0.1 SUCCEEDS at the +default clearance 0.6 (and at 0.9 and 1.2) while band 0.2 FAILS at 0.6 — so it +is not monotonic in either tightness or clearance, and the growth-round cap is +being hit for reasons that depend on the particular adapted mesh. A wide band +is the working configuration at this resolution. + +TODO(BUG): the gather is never undone, so placement leaves the mesh badly +IMBALANCED and every solve on it is effectively serial. Measured 2026-09-17 +(3-D sheet on a box, base 0.2 / h_near 0.05 / one adapt level, np=4): cells +per rank are [5177, 6137, 5191, 5254] (max/mean 1.13) after ``adapt``, and +[1399, 17348, 1132, 1284] (max/mean 3.28) after ``add_conforming_sheet`` — +82% of the mesh on the surgery rank. ``split_fault`` leaves that untouched +(its own redistribute is bounded by the fault star, as documented), so the +cost is here and not in the contact stream. Consequence: an MPI fault solve +is SLOWER than serial — a 48k-cell 3-D sample solves in 202 s at np=1 and +281 s at np=2. A rebalancing redistribute after the collective rebuild is +what is missing. + Both dimensions, one fill. The 2-D curve (:func:`place_along_lines`) and the 3-D sheet (:func:`place_sheet`) both delegate their cavity fill to gmsh — constrained triangulation with Steiner insertion is exactly what a mesh @@ -1795,7 +1819,7 @@ def find(a): def _carve_cavity_3d(dm, X, cells, sheet_pts, sheet_tris, clearance, held_cells, h_vertex, on_wall, shared_chart, - open_deletable=None, open_near=None): + open_deletable=None, open_near=None, extra_victims=None): """Victims, dropped tets and the closed cavity shell around the sheet. The same two-part rule as 2-D — vertices within the clearance go, and any @@ -1829,6 +1853,9 @@ def _carve_cavity_3d(dm, X, cells, sheet_pts, sheet_tris, clearance, held_vertex[cells[c]] = True victim = ((d_sheet < clearance * h_vertex) & (~on_wall | on_open) & ~held_vertex) + if extra_victims is not None: + # vertices a previous fill of this cavity left flat cells on + victim |= extra_victims & ~on_wall & ~held_vertex drop = victim[cells].any(axis=1) cen = X[cells].mean(axis=1) @@ -2468,6 +2495,15 @@ def _closed_shell_3d(dm, X, cells, drop, victim, held_cells, shared_chart, original mesh did not). Growing the drop at every non-manifold edge merges the wedges; dropping more cells only enlarges the fill. + An edge-manifold shell can still be one of two shapes gmsh fills wrongly, + both measured on newest-vertex-bisection children at the default + clearance: a KEPT cell wholly enclosed by the cavity, whose faces close a + separate shell component that gmsh fills too (the fill overlaps the kept + cell and the domain volume grows by exactly its volume); and a PINCHED + vertex, where the shell faces around the vertex form more than one fan + (gmsh returns zero-volume tets there). Both are grown out the same way: + the enclosed cells join the drop, and so does every pinched vertex's star. + ``open_vertex`` (a vertex mask) lets the cavity OPEN onto the boundary near an outcrop — the bowl. A dropped cell's wall face whose vertices all carry the mask becomes a CAP face (returned separately; the caller @@ -2529,16 +2565,22 @@ def _closed_shell_3d(dm, X, cells, drop, victim, held_cells, shared_chart, for e in ((a, b), (a, c), (b, c)): edge_count[e] += 1 bad = [e for e, k in edge_count.items() if k != 2] - if not bad: - break - for a, b in bad: - grow = (cells == a).any(axis=1) & (cells == b).any(axis=1) - if (grow & held).any(): - raise RuntimeError( - f"closing the {noun}'s cavity shell needs a cell held " - "for a surface already embedded; move the object away " - "or raise `clearance`.") - drop |= grow + grow = np.zeros(len(cells), dtype=bool) + if bad: + for a, b in bad: + grow |= (cells == a).any(axis=1) & (cells == b).any(axis=1) + else: + grow |= _enclosed_kept_cells(face_support, drop) + for v in _pinched_shell_vertices(shell + cap_faces): + grow |= (cells == v).any(axis=1) + if not grow.any(): + break + if (grow & held).any(): + raise RuntimeError( + f"closing the {noun}'s cavity shell needs a cell held " + "for a surface already embedded; move the object away " + "or raise `clearance`.") + drop |= grow else: raise RuntimeError( f"the {noun}'s cavity shell did not close in 20 growth rounds; " @@ -2550,6 +2592,72 @@ def _closed_shell_3d(dm, X, cells, drop, victim, held_cells, shared_chart, return shell, cap_faces, drop +def _enclosed_kept_cells(face_support, drop): + """Kept cells that cannot reach an open face without crossing the cavity. + + An open face has one cell on this rank: the domain wall, or a partition + seam on a distributed mesh. Kept cells are flooded from those faces + through faces shared with other kept cells; whatever the flood misses is + wholly enclosed by dropped cells. Returns a cell mask. + """ + neighbours = [[] for _ in range(len(drop))] + reached = np.zeros(len(drop), dtype=bool) + stack = [] + for support in face_support.values(): + if len(support) == 1: + c = support[0] + if not drop[c] and not reached[c]: + reached[c] = True + stack.append(c) + elif not drop[support[0]] and not drop[support[1]]: + neighbours[support[0]].append(support[1]) + neighbours[support[1]].append(support[0]) + while stack: + c = stack.pop() + for j in neighbours[c]: + if not reached[j]: + reached[j] = True + stack.append(j) + return ~drop & ~reached + + +def _pinched_shell_vertices(faces): + """Shell vertices whose incident faces form more than one fan. + + ``faces`` is a list of ``(face, vertices)``. Around a manifold vertex the + incident faces are connected through the edges they share at that + vertex; a vertex where two parts of the shell merely touch has two or + more such fans. + """ + from collections import defaultdict + + around = defaultdict(list) + for k, (_f, verts) in enumerate(faces): + for v in verts: + around[v].append(k) + pinched = [] + for v, incident in around.items(): + through = defaultdict(list) + for k in incident: + for w in faces[k][1]: + if w != v: + through[w].append(k) + fan = {incident[0]} + frontier = [incident[0]] + while frontier: + k = frontier.pop() + for w in faces[k][1]: + if w == v: + continue + for j in through[w]: + if j not in fan: + fan.add(j) + frontier.append(j) + if len(fan) != len(incident): + pinched.append(v) + return pinched + + def _gmsh_fill_3d(shell_xyz, shell_tris, sheet_pts, sheet_tris, h, cap=None): """Tetrahedralise inside the shell with the sheet embedded, via gmsh. @@ -2661,6 +2769,40 @@ def cap_tag_of(k): gmsh.finalize() +# Refills place_sheet will try before refusing. One is measured to clear +# every flat cell seen; the rest are margin, each deleting a few more shell +# vertices. +_SHEET_FILL_ATTEMPTS = 5 + + +def _flat_fill_shell_nodes(fill_pts, fill_tets, n_shell, h, rel_tol=1e-8): + """Shell nodes of the fill's flat tets, as fill-local indices. + + A tet is flat when its volume is below ``rel_tol * h**3``: the genuine + cells of a fill sit near ``1e-3 h**3`` at worst, the flat ones near + ``1e-17 h**3``, so the threshold separates them by orders of magnitude on + either side. Nodes ``< n_shell`` are shell vertices, which the carve can + delete; a flat tet with no shell vertex is built from sheet points alone + and no refill of the cavity can remove it, so that refuses. + """ + tets = np.asarray(fill_tets) + P = fill_pts[tets] + volume = np.abs(np.einsum("ij,ij->i", + np.cross(P[:, 1] - P[:, 0], P[:, 2] - P[:, 0]), + P[:, 3] - P[:, 0])) / 6.0 + flat = tets[volume < rel_tol * h**3] + if not len(flat): + return np.zeros(0, dtype=np.int64) + nodes = np.unique(flat.ravel()) + nodes = nodes[nodes < n_shell] + if not len(nodes): + raise RuntimeError( + "the cavity fill made a flat cell from sheet points alone; the " + "sheet is finer than the mesh can take — re-triangulate it with " + "`size=`.") + return nodes + + def _attach_uninterp_vertex_sf(new, dm, v_old_to_compact, nc_new, nroots, extra_leaves=()): """Give the uninterpolated plex its vertex star-forest, BEFORE interpolate. @@ -3196,95 +3338,122 @@ def place_sheet(dm, points, triangles, label=CUT_LABEL, label_value=1, deletable, near, _regions = _outcrop_frame_3d( X, on_wall, dom_verts, dom_tris, dom_region, sheet_pts, chain_edges) - victims, drop_ids, shell, cap_faces = _carve_cavity_3d( - dm_work, X, cells, sheet_pts, sheet_tris, clearance, - held_cells, h_vertex, on_wall, shared, - open_deletable=deletable, open_near=near) - # The gather's contract, asserted: nothing the surgery touches is - # shared. A violation is a marking defect and must be loud. - touched = set() - for c in drop_ids: - for q in dm_work.getTransitiveClosure(int(c) + cS)[0]: - touched.add(int(q)) - if any(shared[q - pStart] for q in touched): - raise RuntimeError( - "place_sheet internal: the gathered region touches a " - "shared point; the gather mask under-reached.") - - shell_vert_ids = sorted( - {v for _f, verts in shell for v in verts} - | {v for _f, verts in cap_faces for v in verts}) - local = {v: i for i, v in enumerate(shell_vert_ids)} - shell_xyz = X[shell_vert_ids] - shell_tris = np.array([[local[v] for v in verts] - for _f, verts in shell], dtype=np.int64) - - cap_payload = None - removed_wall = [] - if cap_faces: - # Labels each removed wall face carried, read PER FACE - # before the rebuild forgets them — the bowl can span - # several walls (a curved boundary's facets), so each new - # wall triangle takes the labels of the old face it lies - # on, not a set common to the whole bowl (the volume - # path's rule). - names = [dm_work.getLabelName(i) - for i in range(dm_work.getNumLabels()) - if dm_work.getLabelName(i) - not in reconnect._TOPOLOGY_LABELS] - for f, verts in cap_faces: - pairs_f = [] - for name in names: - val = dm_work.getLabel(name).getValue(int(f)) - if val >= 0: - pairs_f.append((name, int(val))) - removed_wall.append((X[np.asarray(verts)].copy(), - pairs_f)) - - cap_tris_mesh = np.array([verts for _f, verts in cap_faces], - dtype=np.int64) - d_cap, at_cap = _nearest_facet( - X[cap_tris_mesh].mean(axis=1), dom_verts, dom_tris) - if (d_cap > 1e-9).any(): + extra_victims = np.zeros(len(X), dtype=bool) + for _attempt in range(_SHEET_FILL_ATTEMPTS): + victims, drop_ids, shell, cap_faces = _carve_cavity_3d( + dm_work, X, cells, sheet_pts, sheet_tris, clearance, + held_cells, h_vertex, on_wall, shared, + open_deletable=deletable, open_near=near, + extra_victims=extra_victims) + # The gather's contract, asserted: nothing the surgery + # touches is shared. A violation is a marking defect and + # must be loud. + touched = set() + for c in drop_ids: + for q in dm_work.getTransitiveClosure(int(c) + cS)[0]: + touched.add(int(q)) + if any(shared[q - pStart] for q in touched): raise RuntimeError( - "an outcrop bowl wall face lies off the gathered " - "boundary complex; the wall mask and the complex " - "disagree") - alive = np.ones(len(X), dtype=bool) - alive[np.asarray(victims, dtype=np.int64)] = False - cap_nodes, chain_nodes, cap_extra, cap_tris = \ - _outcrop_collar_3d( - X, alive, cap_tris_mesh, dom_region[at_cap], - dom_planes, sheet_pts, - np.zeros((0, 3), dtype=np.int64), - np.zeros(0, dtype=np.int64), None, chain=chain) - cap_payload = { - "rim_shell_local": [local[v] for v in cap_nodes], - "chain_sheet_local": list(chain_nodes), - "tris": cap_tris, - "extra_xyz": cap_extra, - } - - fill = _gmsh_fill_3d(shell_xyz, shell_tris, sheet_pts, - sheet_tris, h, cap=cap_payload) - (fill_pts, fill_tets, sheet_out, moved_nodes, n_shell, - cap_out) = fill - if moved_nodes: - raise RuntimeError( - f"the fill moved {moved_nodes} constrained node(s); the " - "cavity cannot be sewn back. A defect, not a tolerance.") - if sheet_out is None or len(sheet_out) != len(sheet_tris): - raise RuntimeError( - "the fill remeshed the sheet " - f"({0 if sheet_out is None else len(sheet_out)} " - f"triangles for {len(sheet_tris)} given).") - if cap_payload is not None and ( - cap_out is None - or len(cap_out) != len(cap_payload["tris"])): + "place_sheet internal: the gathered region touches a " + "shared point; the gather mask under-reached.") + + shell_vert_ids = sorted( + {v for _f, verts in shell for v in verts} + | {v for _f, verts in cap_faces for v in verts}) + local = {v: i for i, v in enumerate(shell_vert_ids)} + shell_xyz = X[shell_vert_ids] + shell_tris = np.array([[local[v] for v in verts] + for _f, verts in shell], + dtype=np.int64) + + cap_payload = None + removed_wall = [] + if cap_faces: + # Labels each removed wall face carried, read PER FACE + # before the rebuild forgets them — the bowl can span + # several walls (a curved boundary's facets), so each + # new wall triangle takes the labels of the old face it + # lies on, not a set common to the whole bowl (the + # volume path's rule). + names = [dm_work.getLabelName(i) + for i in range(dm_work.getNumLabels()) + if dm_work.getLabelName(i) + not in reconnect._TOPOLOGY_LABELS] + for f, verts in cap_faces: + pairs_f = [] + for name in names: + val = dm_work.getLabel(name).getValue(int(f)) + if val >= 0: + pairs_f.append((name, int(val))) + removed_wall.append((X[np.asarray(verts)].copy(), + pairs_f)) + + cap_tris_mesh = np.array( + [verts for _f, verts in cap_faces], dtype=np.int64) + d_cap, at_cap = _nearest_facet( + X[cap_tris_mesh].mean(axis=1), dom_verts, dom_tris) + if (d_cap > 1e-9).any(): + raise RuntimeError( + "an outcrop bowl wall face lies off the gathered " + "boundary complex; the wall mask and the complex " + "disagree") + alive = np.ones(len(X), dtype=bool) + alive[np.asarray(victims, dtype=np.int64)] = False + cap_nodes, chain_nodes, cap_extra, cap_tris = \ + _outcrop_collar_3d( + X, alive, cap_tris_mesh, dom_region[at_cap], + dom_planes, sheet_pts, + np.zeros((0, 3), dtype=np.int64), + np.zeros(0, dtype=np.int64), None, chain=chain) + cap_payload = { + "rim_shell_local": [local[v] for v in cap_nodes], + "chain_sheet_local": list(chain_nodes), + "tris": cap_tris, + "extra_xyz": cap_extra, + } + + fill = _gmsh_fill_3d(shell_xyz, shell_tris, sheet_pts, + sheet_tris, h, cap=cap_payload) + (fill_pts, fill_tets, sheet_out, moved_nodes, n_shell, + cap_out) = fill + if moved_nodes: + raise RuntimeError( + f"the fill moved {moved_nodes} constrained node(s); " + "the cavity cannot be sewn back. A defect, not a " + "tolerance.") + if sheet_out is None or len(sheet_out) != len(sheet_tris): + raise RuntimeError( + "the fill remeshed the sheet " + f"({0 if sheet_out is None else len(sheet_out)} " + f"triangles for {len(sheet_tris)} given).") + if cap_payload is not None and ( + cap_out is None + or len(cap_out) != len(cap_payload["tris"])): + raise RuntimeError( + "the fill remeshed the outcrop cap " + f"({0 if cap_out is None else len(cap_out)} " + f"triangles for {len(cap_payload['tris'])} given).") + + # A fill can span nearly coplanar shell nodes with a flat + # cell (measured ~1e-20 on bisection children, from gmsh's + # Delaunay and HXT algorithms alike) that none of the gates + # sees. Deleting those shell vertices and refilling the + # larger cavity removes it, measured in one round. + flat_nodes = _flat_fill_shell_nodes(fill_pts, fill_tets, + n_shell, h) + if not len(flat_nodes): + break + grow = np.asarray(shell_vert_ids, dtype=np.int64)[flat_nodes] + if on_wall[grow].any(): + raise RuntimeError( + "the cavity fill left a flat cell against the domain " + "wall; move the sheet further from the wall or raise " + "`clearance`.") + extra_victims[grow] = True + else: raise RuntimeError( - "the fill remeshed the outcrop cap " - f"({0 if cap_out is None else len(cap_out)} triangles " - f"for {len(cap_payload['tris'])} given).") + f"the cavity fill still held flat cells after " + f"{_SHEET_FILL_ATTEMPTS} refills; raise `clearance`.") # Exception, not just RuntimeError/ValueError: a raw gmsh error # (e.g. a PLC intersection) is a plain Exception, and an # uncaught raise on the surgery rank is a HANG for its peers — @@ -8408,6 +8577,13 @@ def place_thin_volume(dm, patches, width, label=ZONE_LABEL, label_value=1, "extra_xyz": cap_extra, } + # TODO(BUG): no flat-cell gate on this fill. place_sheet's fill + # was measured leaving ~1e-20 cells across nearly coplanar shell + # nodes on bisection children (both gmsh Delaunay and HXT); this + # annulus fill shares the shell and gmsh, so it is likely exposed + # too. place_sheet deletes those shell vertices and refills + # (_flat_fill_shell_nodes); the same loop belongs here. + # Found 2026-09-15 building the tumbling-fault Mohr sweep. fill = _gmsh_fill_annulus_3d(shell_xyz, shell_tris, skin_xyz_m, skin_tris_fill_m, size_out=h, size_in=size, cap=cap_payload) diff --git a/tests/test_0848_fault_split_3d.py b/tests/test_0848_fault_split_3d.py index fa3c98d43..f4dd74e4a 100644 --- a/tests/test_0848_fault_split_3d.py +++ b/tests/test_0848_fault_split_3d.py @@ -268,6 +268,42 @@ def test_coulomb_slide_stick_3d(split_box): assert results[0.4] < 5e-3 # stuck at ~V0 creep +@pytest.mark.level_2 +@pytest.mark.tier_b +@pytest.mark.parametrize("state", ["uniform", "linear"]) +def test_normal_traction_is_pointwise_3d(split_box, state): + """The recovered normal traction matches an exact Stokes state at EVERY + pair node, vertices and edge midpoints alike. + + On the P2 triangle the consistent load vanishes at the vertex nodes, so + dividing the reaction by a lumped nodal area read 0 at vertices and 4/3 + of the traction at midpoints — right as an integral, wrong pointwise, + and hidden by a median over the patch. The x-normal patch sees + uniform: v = (-(x-c), (y-c), 0) sigma_nn = -2 + linear: v = (-(x-c)(1+(y-c)), (y-c)+(y-c)^2/2, 0) sigma_nn = -2 - 3(y-c) + both exact Stokes solutions with a zero-mean pressure. + """ + from underworld3.utilities import fault_contact + + x, y, z = split_box.X + if state == "uniform": + drive = (-(x - 0.5), (y - 0.5), 0.0) + exact = lambda P: np.full(len(P), -2.0) + tolerance = 1e-3 + else: + drive = (-(x - 0.5) * (1 + (y - 0.5)), (y - 0.5) + (y - 0.5) ** 2 / 2, 0.0) + exact = lambda P: -2.0 - 3.0 * (P[:, 1] - 0.5) + tolerance = 0.06 + + stokes = _stokes_on(split_box, drive, f"nt_{state}") + stokes.petsc_use_pressure_nullspace = True + stokes.add_fault_bc(500.0, boundary="FltA") + fault_contact.solve_with_fault(stokes) + crds, sig = fault_contact.fault_normal_traction( + stokes, "FltA", stokes._rotated_freeslip_info) + assert np.abs(sig - exact(crds)).max() < tolerance + + @pytest.mark.level_1 @pytest.mark.tier_b def test_daylighting_patch_refused(): @@ -314,3 +350,66 @@ def test_fault_surface_route(): fs2.triangulate() with pytest.raises(NotImplementedError, match="planar"): fs2.rim_polygon() + + +@pytest.mark.level_2 +@pytest.mark.tier_b +def test_split_sheet_keeps_the_multigrid_tail(): + """Splitting a sheet placed on an adapt child keeps geometric multigrid. + + The adapt-on-top 3-D fault pipeline is ``adapt`` -> ``add_conforming_sheet`` + -> ``split_fault``. The split re-represents the same grid (only the fault + vertices are duplicated), so the coarse tail the placed mesh owns serves + the split mesh unchanged. Without it the fault solve silently fell back + to GAMG. + """ + from underworld3.utilities import fault_contact + + centre, radius, h = np.array([0.5, 0.5, 0.5]), 0.3, 0.1 + normal = np.array([0.3, 0.5, 0.81]) + normal /= np.linalg.norm(normal) + + def metric(pts): + rel = pts - centre + dn = rel @ normal + r = np.linalg.norm(rel - dn[:, None] * normal, axis=1) + d = np.hypot(dn, np.maximum(r - radius, 0.0)) + return 1.0 / np.minimum(h + d, 0.2) ** 2 + + base = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0,) * 3, maxCoords=(1.0,) * 3, cellSize=0.2, + regular=False, qdegree=2, refinement=1) + child = base.adapt(metric, max_levels=1) + e1 = np.cross(normal, [1.0, 0.0, 0.0]) + e1 /= np.linalg.norm(e1) + e2 = np.cross(normal, e1) + n_rim = int(np.ceil(2.0 * np.pi * radius / h)) + a = np.linspace(0.0, 2.0 * np.pi, n_rim, endpoint=False) + pts = np.vstack([centre, centre + radius * (np.outer(np.cos(a), e1) + + np.outer(np.sin(a), e2))]) + tris = np.array([(0, 1 + i, 1 + (i + 1) % n_rim) for i in range(n_rim)]) + placed = child.add_conforming_sheet(pts, tris, "Fault", size=h) + split = split_fault(placed, "Fault") + + assert split._custom_mg_coarse_meshes is not None, ( + "split_fault dropped the placed mesh's multigrid tail") + assert split._custom_mg_coarse_meshes == placed._custom_mg_coarse_meshes + assert split._custom_mg_builder == placed._custom_mg_builder + + v = uw.discretisation.MeshVariable("v_tail", split, 3, degree=2) + p = uw.discretisation.MeshVariable("p_tail", split, 1, degree=0, + continuous=False) + stokes = uw.systems.Stokes(split, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + stokes.bodyforce = [0.0, 0.0, 0.0] + drive = (-(split.X[0] - 0.5), split.X[1] - 0.5, 0.0) + for wall in ("Bottom", "Top", "Right", "Left", "Front", "Back"): + stokes.add_dirichlet_bc(drive, wall) + stokes.petsc_use_pressure_nullspace = True + stokes.tolerance = 1e-6 + stokes.add_fault_bc(200.0, boundary="Fault") + fault_contact.solve_with_fault(stokes) + info = stokes._rotated_freeslip_info + assert info["velocity_pc"] == "custom-FMG", info["velocity_pc"] + assert info["converged"] diff --git a/tests/test_0867_place_sheet_adapted_3d.py b/tests/test_0867_place_sheet_adapted_3d.py new file mode 100644 index 000000000..2fbb0671b --- /dev/null +++ b/tests/test_0867_place_sheet_adapted_3d.py @@ -0,0 +1,88 @@ +"""Placing a sheet into an ``adapt()`` child in 3-D. + +The fault workflow is base mesh -> ``adapt`` toward the fault -> +``add_conforming_sheet`` -> ``split_fault``. On a newest-vertex-bisection +child the cavity the carve clears, and the fill gmsh returns for it, can be +wrong in three ways that the earlier gates did not see, all at the default +clearance and none on the gmsh base: + +* a KEPT cell (or cluster) wholly enclosed by dropped cells — its faces + form a separate closed component of the shell, gmsh fills it as part of + the cavity, and the fill overlaps the kept cell (measured: the domain + volume grew by exactly that cell's volume, refused by the volume gate); +* a PINCHED vertex — every shell edge has two faces, but the faces around + the vertex form two or more separate fans — where gmsh returns + zero-volume tets (refused by PETSc's geometry check, ``|J| = 0``); +* a FLAT tet spanning four nearly coplanar shell nodes — volume ~1e-20, + which no gate refused, from either of gmsh's Delaunay or HXT algorithms + depending on the orientation. +""" +import numpy as np +import pytest + +import underworld3 as uw +from underworld3.utilities.place_surface import _cell_volumes_signed6 + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + +CENTRE = np.array([0.5, 0.5, 0.5]) +RADIUS = 0.2 +H_NEAR = 0.05 +TILTED = np.array([0.3, -0.5, 0.8]) / np.linalg.norm([0.3, -0.5, 0.8]) +DIAGONAL = np.array([1.0, 1.0, 1.0]) / np.sqrt(3.0) + + +def _disc_sheet(normal, n_rim=32): + """A planar disc as a centre fan; ``size=`` re-triangulates it.""" + helper = np.eye(3)[np.argmin(np.abs(normal))] + e1 = np.cross(normal, helper) + e1 /= np.linalg.norm(e1) + e2 = np.cross(normal, e1) + a = np.linspace(0.0, 2.0 * np.pi, n_rim, endpoint=False) + rim = CENTRE + RADIUS * (np.outer(np.cos(a), e1) + np.outer(np.sin(a), e2)) + pts = np.vstack([CENTRE, rim]) + tris = np.array([(0, 1 + i, 1 + (i + 1) % n_rim) for i in range(n_rim)], + dtype=np.int64) + return pts, tris + + +def _graded_metric(normal): + """M = 1/h^2 from the exact distance to the disc, h_near -> 2 h_near.""" + def metric(X): + rel = np.asarray(X)[:, :3] - CENTRE + zn = rel @ normal + r = np.linalg.norm(rel - zn[:, None] * normal, axis=1) + d = np.sqrt(np.maximum(r - RADIUS, 0.0) ** 2 + zn ** 2) + h = np.clip(H_NEAR + d * H_NEAR / 0.4, H_NEAR, 2.0 * H_NEAR) + return 1.0 / h**2 + return metric + + +def _uniform_metric(normal): + return lambda X: np.full(len(X), 1.0 / H_NEAR**2) + + +@pytest.fixture(scope="module") +def base(): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0, 0.0), maxCoords=(1.0, 1.0, 1.0), + cellSize=0.2, regular=False, qdegree=2, refinement=1) + + +@pytest.mark.parametrize( + "metric, normal", + [(_graded_metric, TILTED), (_uniform_metric, TILTED), + (_graded_metric, DIAGONAL)], + ids=["graded-enclosed-cell", "uniform-pinch-and-flat", "graded-flat"]) +def test_sheet_places_into_an_nvb_child(base, metric, normal): + """The placed mesh keeps the domain volume, one orientation, and no + degenerate cell, and every sheet triangle is a labelled face.""" + child = base.adapt(metric(normal), max_levels=1) + pts, tris = _disc_sheet(normal) + placed = child.add_conforming_sheet(pts, tris, "Fault", size=H_NEAR) + + v6 = _cell_volumes_signed6(placed.dm) + assert abs(np.abs(v6).sum() / 6.0 - 1.0) < 1e-9 + assert (np.sign(v6) == np.sign(v6[0])).all() + assert np.abs(v6).min() / 6.0 > 1e-12 + assert placed._surface_info["n_surface_facets"] > 0 From c913f9d45432376a35bdd365547fa4d594098fe9 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 09:09:18 +1100 Subject: [PATCH 31/59] Box fault cyclic loading: driver and figures A 3-D Maxwell block with a blind vertical strike-slip fault under a damage band, one end fixed, the other under a harmonic normal and shear traction with a phase lag; traction-free sides, free-slip base. Two mesh routes (a gmsh graded conforming patch, or a placed sheet with setback), Coulomb, viscous or frictionless fault, all diagnostics collective. The plotting script draws the load and slip-rate history, the per-cycle and cumulative slip on the fault plane and the surface net displacement. Underworld development team with AI support from Claude Code --- .../box_fault_cyclic/box_fault_cyclic.py | 268 ++++++++++++++++++ .../box_fault_cyclic/box_fault_plot.py | 98 +++++++ 2 files changed, 366 insertions(+) create mode 100644 scripts/sessions/box_fault_cyclic/box_fault_cyclic.py create mode 100644 scripts/sessions/box_fault_cyclic/box_fault_plot.py diff --git a/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py b/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py new file mode 100644 index 000000000..9c2b1cd1f --- /dev/null +++ b/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py @@ -0,0 +1,268 @@ +"""A strike-slip fault under cyclic loading: the 3-D box toy model. + +Wiki: Box-fault-cyclic-loading. A Maxwell (or viscous) block, x in [0, Lx], +y in [0, Ly], z in [0, H]. The x = 0 face is fixed; the x = Lx face carries a +harmonic traction with a normal component N sin(wt + phi) and a horizontal +shear component S sin(wt); the y faces are traction-free (or held at +u_x = u_z = 0 with --side_bc uxuz0); the base is free-slip; the top is free. + +The fault is a vertical plane y = Ly/2 of length l along x, reaching from +depth w below the surface to a setback of a few cells under the top face +(the standing 3-D ruling: a fault reaches daylight blind, under a damage +region, never split through the surface). The sheet is placed with +add_conforming_sheet(setback=...) and split into coincident node pairs; the +damage band above it to the surface is a low-viscosity region located by +the placement's surface trace. + +Non-dimensional: shear modulus 1, loading period 1, Maxwell time De periods +(Wallace number Wa = 1/De), tractions as fractions of the shear modulus. + +Parallel-safe: every diagnostic is a collective reduction or an all-gathered +pair table; the surface displacement is a collective evaluation on a fixed +grid; files are written from rank 0 only. +""" +import argparse +import json +import os +import time + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3.utilities.fault_split import split_fault +from underworld3.utilities import fault_contact + +ap = argparse.ArgumentParser() +ap.add_argument("--out", required=True) +ap.add_argument("--h", type=float, default=0.15, help="cell size away from the fault") +ap.add_argument("--h_fault", type=float, default=0.05, help="cell size on the fault (gmsh mesher; 0: uniform)") +ap.add_argument("--grading", type=float, default=0.0, help="distance over which the size grades back to --h (0: 8 h_fault)") +ap.add_argument("--mesher", default="gmsh", choices=["gmsh", "place"]) +ap.add_argument("--Lx", type=float, default=3.0) +ap.add_argument("--Ly", type=float, default=2.0) +ap.add_argument("--H", type=float, default=1.0) +ap.add_argument("--fault_len", type=float, default=1.0, help="fault length along x") +ap.add_argument("--fault_depth", type=float, default=0.5, help="depth of the fault bottom below the surface") +ap.add_argument("--setback", type=float, default=2.0, help="setback of the fault top below the surface, in fault cells (gmsh) or cells (place)") +ap.add_argument("--damage", type=float, default=0.01, + help="viscosity ratio in the damage band above the fault top (1: no damage band)") +ap.add_argument("--damage_width", type=float, default=0.0, help="half-width of the damage band (0: one cell)") +ap.add_argument("--De", type=float, default=1.0, help="Maxwell time in periods (0: viscous block with --eta)") +ap.add_argument("--eta", type=float, default=1.0) +ap.add_argument("--S", type=float, default=0.01, help="shear traction amplitude on the loaded face") +ap.add_argument("--N", type=float, default=0.01, help="normal traction amplitude on the loaded face") +ap.add_argument("--phi", type=float, default=80.0, help="phase of the normal traction ahead of the shear (deg)") +ap.add_argument("--fault_law", default="coulomb", choices=["frictionless", "viscous", "coulomb"]) +ap.add_argument("--fault_eta", type=float, default=0.0) +ap.add_argument("--fault_mu", type=float, default=0.4) +ap.add_argument("--fault_V0", type=float, default=1e-3) +ap.add_argument("--fault_Tb", type=float, default=0.0, help="constant effective overburden on the fault (compression positive)") +ap.add_argument("--side_bc", default="free", choices=["free", "uxuz0"]) +ap.add_argument("--periods", type=float, default=2.0) +ap.add_argument("--steps_per_period", type=int, default=32) +ap.add_argument("--tol", type=float, default=1e-6) +ap.add_argument("--picard", type=int, default=0) +ap.add_argument("--surface_grid", type=int, default=40, help="points along x of the surface displacement grid") +ap.add_argument("--verbose", action="store_true") +args = ap.parse_args() + +from mpi4py import MPI # noqa: E402 (collective bookkeeping only) +comm = MPI.COMM_WORLD +rank0 = comm.rank == 0 +if rank0: + os.makedirs(args.out, exist_ok=True) + with open(os.path.join(args.out, "args.json"), "w") as f: + json.dump(vars(args), f, indent=1) + + +def log(*a): + if rank0: + print(*a, flush=True) + + +# ----------------------------------------------------------------------------- mesh, fault, split +# Two routes to a blind vertical fault whose rim is strictly inside the box: +# gmsh — BoxInternalPatch: the rectangle is a conforming patch of the gmsh mesh, +# graded from --h_fault on the patch to --h far away. No cavity, so the +# setback below the surface can be a couple of FAULT cells. +# place — a uniform box, the sheet placed by carving a cavity (add_conforming_sheet) +# with setback; the cavity must not touch the top face, which needs a +# setback of three to four cells (measured: refused at two). +h = args.h +hf = args.h_fault if args.h_fault > 0 else h +yf = 0.5 * args.Ly +xa, xb = 0.5 * (args.Lx - args.fault_len), 0.5 * (args.Lx + args.fault_len) +zb = args.H - args.fault_depth +setback = args.setback * (hf if args.mesher == "gmsh" else h) +ztop_fault = args.H - setback +trace = [[xa, yf, args.H], [xb, yf, args.H]] # the would-be surface trace (a straight fault) +t0 = time.perf_counter() +if args.mesher == "gmsh": + patch = np.array([[xa, yf, zb], [xb, yf, zb], [xb, yf, ztop_fault], [xa, yf, ztop_fault]]) + cut = uw.meshing.BoxInternalPatch(cellSize=h, minCoords=(0.0, 0.0, 0.0), maxCoords=(args.Lx, args.Ly, args.H), + patch_points=patch, patch_name="Fault", + patch_cellSize=hf if hf < h else None, + grading_distance=args.grading if args.grading > 0 else None, + qdegree=3) +else: + base = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0, 0.0), maxCoords=(args.Lx, args.Ly, args.H), + cellSize=h, regular=False, qdegree=3) + nx = max(2, int(round(args.fault_len / h)) + 1) + nz = max(2, int(round((args.H + 0.3 * h - zb) / h)) + 1) + xs = np.linspace(xa, xb, nx) + zs = np.linspace(zb, args.H + 0.3 * h, nz) + XX, ZZ = np.meshgrid(xs, zs, indexing="ij") + sheet_pts = np.column_stack([XX.ravel(), np.full(XX.size, yf), ZZ.ravel()]) + tris = [] + for i in range(nx - 1): + for j in range(nz - 1): + a, b, c, d = i * nz + j, (i + 1) * nz + j, (i + 1) * nz + j + 1, i * nz + j + 1 + tris += [[a, b, c], [a, c, d]] + cut = base.add_conforming_sheet(sheet_pts, np.array(tris, dtype=np.int64), "Fault", setback=setback, + verbose=args.verbose) + tr = cut._surface_info.get("surface_trace") if hasattr(cut, "_surface_info") else None + if tr: + trace = tr +mesh = split_fault(cut, "Fault") +ncell = comm.allreduce(int(mesh.dm.getHeightStratum(0)[1] - mesh.dm.getHeightStratum(0)[0]), op=MPI.SUM) +log(f"mesh ({args.mesher}) h {h} h_fault {hf}: {ncell} cells after fault + split ({time.perf_counter() - t0:.1f}s); " + f"fault x [{xa:.2f}, {xb:.2f}], z [{zb:.2f}, {ztop_fault:.3f}], setback {setback:.3f}") +x, y, z = mesh.X + +# ----------------------------------------------------------------------------- solver +v = uw.discretisation.MeshVariable("v", mesh, 3, degree=2) +p = uw.discretisation.MeshVariable("p", mesh, 1, degree=1) +stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) +stokes.bodyforce = sympy.Matrix([0.0, 0.0, 0.0]) +stokes.tolerance = args.tol + +# the damage band: a low-viscosity region from the fault top to the surface, +# the width of a cell (or --damage_width) either side of the trace +wd = args.damage_width if args.damage_width > 0 else hf +ztop = ztop_fault - 0.5 * wd +g_y = sympy.exp(-((y - yf) / wd) ** 2) +g_z = 0.5 * (1 + sympy.tanh((z - ztop) / (0.5 * wd))) +g_x = 0.25 * (1 + sympy.tanh((x - xa) / (0.5 * wd))) * (1 + sympy.tanh((xb - x) / (0.5 * wd))) +damage = g_x * g_y * g_z # 1 in the band, 0 away from it +eta_factor = 1.0 - (1.0 - args.damage) * damage if args.damage < 1.0 else 1.0 + +if args.De > 0: + stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=1, integrator="etd", objective_rate="none") + stokes.constitutive_model.Parameters.shear_modulus = 1.0 + stokes.constitutive_model.Parameters.shear_viscosity_0 = args.De * eta_factor +else: + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = args.eta * eta_factor + +# the harmonic end load: two uniform P1 fields hold the instantaneous amplitudes so the +# traction changes each step without recompiling the residual +N_t = uw.discretisation.MeshVariable("N_t", mesh, 1, degree=1) +S_t = uw.discretisation.MeshVariable("S_t", mesh, 1, degree=1) +stokes.add_dirichlet_bc((0.0, 0.0, 0.0), "Left") # x = 0 fixed +stokes.add_natural_bc(sympy.Matrix([[N_t.sym[0], S_t.sym[0], 0.0]]), "Right") # x = Lx: (sigma_xx, sigma_xy, 0) +stokes.add_dirichlet_bc((None, None, 0.0), "Bottom") # free-slip base +if args.side_bc == "uxuz0": + stokes.add_dirichlet_bc((0.0, None, 0.0), "Front") + stokes.add_dirichlet_bc((0.0, None, 0.0), "Back") + +if args.fault_law == "coulomb": + fault_contact.add_coulomb_fault_bc(stokes, args.fault_mu, "Fault", sigma_n="reaction", + V0=args.fault_V0, background=args.fault_Tb) +else: + stokes.add_fault_bc(args.fault_eta if args.fault_law == "viscous" else 0.0, "Fault") + + +def set_load(t): + w = 2 * np.pi + N_t.array[...] = args.N * np.sin(w * t + np.radians(args.phi)) + S_t.array[...] = args.S * np.sin(w * t) + + +# ----------------------------------------------------------------------------- diagnostics +# surface displacement on a fixed grid (collective evaluation), accumulated over the run +gx = np.linspace(0.02 * args.Lx, 0.98 * args.Lx, args.surface_grid) +gy = np.linspace(0.02 * args.Ly, 0.98 * args.Ly, max(4, int(round(args.surface_grid * args.Ly / args.Lx)))) +GX, GY = np.meshgrid(gx, gy, indexing="ij") +surf_pts = np.column_stack([GX.ravel(), GY.ravel(), np.full(GX.size, args.H - 1e-6)]) +disp = np.zeros((surf_pts.shape[0], 3)) +disp_cycle = np.zeros_like(disp) + +pair_tab = None # coords, cumulative signed slip, per-cycle slip, keyed by the pair order + + +def fault_diagnostics(info, dt): + """Signed strike slip (v+ - v-).x̂ at every pair, all-gathered; cumulative and per cycle.""" + global pair_tab + coords, jumps, normals = fault_contact.fault_pair_jumps(stokes, "Fault", info, gather=True) + if pair_tab is None: + pair_tab = {"coords": coords, "cum": np.zeros(len(coords)), "cycle": np.zeros(len(coords)), + "act": np.zeros(len(coords))} + leak = np.einsum("ij,ij->i", jumps, normals) + rate = jumps[:, 0] # strike direction is x + pair_tab["cum"] += rate * dt + pair_tab["cycle"] += rate * dt + pair_tab["act"] += np.abs(rate) * dt + _c, sig = fault_contact.fault_normal_traction(stokes, "Fault", info) + sig = np.asarray(sig) + sig_all = np.concatenate(comm.allgather(sig.ravel())) if comm.size > 1 else sig.ravel() + return {"slip_rate_mean": float(rate.mean()) if len(rate) else 0.0, + "slip_rate_absmax": float(np.abs(rate).max()) if len(rate) else 0.0, + "leak_max": float(np.abs(leak).max()) if len(leak) else 0.0, + "sigma_n_mean": float(sig_all.mean()) if len(sig_all) else 0.0, + "sigma_n_min": float(sig_all.min()) if len(sig_all) else 0.0, + "sigma_n_max": float(sig_all.max()) if len(sig_all) else 0.0, + "n_pairs": int(len(rate))} + + +def save_cycle(step, t): + if rank0: + np.savez(os.path.join(args.out, f"cycle_{step:04d}.npz"), t=t, + pair_coords=pair_tab["coords"], slip_cum=pair_tab["cum"], slip_cycle=pair_tab["cycle"], + activity=pair_tab["act"], surf_pts=surf_pts, disp=disp, disp_cycle=disp_cycle, + trace=np.array(trace, dtype=float)) + pair_tab["cycle"][:] = 0.0 + disp_cycle[:] = 0.0 + + +# ----------------------------------------------------------------------------- time loop +dt = 1.0 / args.steps_per_period +nsteps = int(round(args.periods * args.steps_per_period)) +records = [] +t = 0.0 +log(f"De {args.De}, S {args.S}, N {args.N}, phi {args.phi}, law {args.fault_law} mu {args.fault_mu} Tb {args.fault_Tb}; " + f"dt {dt}, {nsteps} steps") +for k in range(nsteps): + tk = time.perf_counter() + t_new = t + dt + set_load(t_new) # implicit: the load at the end of the step + if args.De > 0: + stokes.solve(timestep=dt, zero_init_guess=(k == 0), verbose=args.verbose, picard=args.picard) + else: + stokes.solve(zero_init_guess=(k == 0), verbose=args.verbose, picard=args.picard) + t = t_new + info = getattr(stokes, "_rotated_freeslip_info", None) or {} + rec = {"step": k, "t": t, "converged": bool(info.get("converged", True)), + "nonlinear": info.get("nonlinear_iterations"), "t_step": round(time.perf_counter() - tk, 1)} + rec.update(fault_diagnostics(info, dt)) + U = np.asarray(uw.function.global_evaluate(v.sym, surf_pts)).reshape(-1, 3) + disp += U * dt + disp_cycle += U * dt + rec["surf_ux_absmax"] = float(np.abs(U[:, 0]).max()) + right = surf_pts[:, 0] > 0.9 * args.Lx + rec["surf_ux_right"] = float(U[right, 0].mean()) # signed: +N on the loaded face must pull it out (+x) + rec["load_N"], rec["load_S"] = float(N_t.array.ravel()[0]) if N_t.array.size else 0.0, float(S_t.array.ravel()[0]) if S_t.array.size else 0.0 + records.append(rec) + log(f"step {k:4d} t {t:.4f} its {rec['nonlinear']} {rec['t_step']}s | slip rate mean {rec['slip_rate_mean']:+.2e} " + f"max {rec['slip_rate_absmax']:.2e} leak {rec['leak_max']:.1e} | sigma_n [{rec['sigma_n_min']:+.3f}, {rec['sigma_n_max']:+.3f}]" + f" | load N {rec['load_N']:+.4f} S {rec['load_S']:+.4f} | surface u_x(right) {rec['surf_ux_right']:+.2e}") + if (k + 1) % args.steps_per_period == 0 or k == nsteps - 1: + cyc = pair_tab["cycle"] + log(f" cycle {(k + 1) // args.steps_per_period}: net slip mean {cyc.mean():+.3e}, max {cyc.max():+.3e} min {cyc.min():+.3e}; " + f"cumulative mean {pair_tab['cum'].mean():+.3e}; surface net |u| max {np.linalg.norm(disp_cycle, axis=1).max():.2e}") + save_cycle(k, t) + if rank0: + with open(os.path.join(args.out, "records.json"), "w") as f: + json.dump(records, f) +log("done") diff --git a/scripts/sessions/box_fault_cyclic/box_fault_plot.py b/scripts/sessions/box_fault_cyclic/box_fault_plot.py new file mode 100644 index 000000000..e90cb35d6 --- /dev/null +++ b/scripts/sessions/box_fault_cyclic/box_fault_plot.py @@ -0,0 +1,98 @@ +"""Figures for a box_fault_cyclic run. + +Usage: python box_fault_plot.py + +Reads //records.json and cycle_*.npz; writes +/boxfault_.png with + (a) the load components and the mean slip rate over time, + (b) the net slip per cycle on the fault plane (x along strike, z depth), + (c) the cumulative slip on the fault plane, + (d) the surface net displacement over the last cycle, with the fault trace. +""" +import glob +import json +import os +import sys + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +sim, run = sys.argv[1], sys.argv[2] +d = os.path.join(sim, run) +with open(os.path.join(d, "records.json")) as f: + rec = json.load(f) +with open(os.path.join(d, "args.json")) as f: + args = json.load(f) +cycles = sorted(glob.glob(os.path.join(d, "cycle_*.npz"))) +if not cycles: + sys.exit("no cycle files yet") +last = np.load(cycles[-1]) + +t = np.array([r["t"] for r in rec]) +rate = np.array([r["slip_rate_mean"] for r in rec]) +N = np.array([r.get("load_N", np.nan) for r in rec]) +S = np.array([r.get("load_S", np.nan) for r in rec]) +sig_min = np.array([r["sigma_n_min"] for r in rec]) +sig_max = np.array([r["sigma_n_max"] for r in rec]) + +fig, axes = plt.subplots(2, 2, figsize=(13, 8.5)) +ax = axes[0, 0] +ax.plot(t, S, color="#059669", label=r"$\sigma_{xy}$ shear load") +ax.plot(t, N, color="#c2410c", ls="--", label=r"$\sigma_{xx}$ normal load") +ax.set_ylabel("traction on the loaded face") +ax.axhline(0, color="0.7", lw=0.6) +ax2 = ax.twinx() +ax2.plot(t, rate, color="#1f3a6b", label="mean slip rate (x)") +ax2.fill_between(t, sig_min, sig_max, color="0.85", label=r"$\sigma_n$ range on the fault") +ax2.set_ylabel("slip rate / normal traction", color="#1f3a6b") +h1, l1 = ax.get_legend_handles_labels() +h2, l2 = ax2.get_legend_handles_labels() +ax.legend(h1 + h2, l1 + l2, fontsize=8, loc="upper right") +ax.set_xlabel("t / period") +ax.set_title(f"{run}: De {args['De']}, mu {args['fault_mu']}, Tb {args['fault_Tb']}, phi {args['phi']}", fontsize=10) + +pc = last["pair_coords"] +for ax, key, title in ((axes[0, 1], "slip_cycle", "net slip over the last cycle"), + (axes[1, 0], "slip_cum", "cumulative slip")): + val = last[key] + vmax = np.abs(val).max() if len(val) else 1.0 + sc = ax.scatter(pc[:, 0], pc[:, 2], c=val, cmap="RdBu_r", vmin=-vmax, vmax=vmax, s=18) + ax.set_aspect("equal") + ax.set_xlabel("x (along strike)") + ax.set_ylabel("z") + ax.set_title(title, fontsize=10) + plt.colorbar(sc, ax=ax, shrink=0.8, label="signed slip, right-lateral +") + +ax = axes[1, 1] +sp = last["surf_pts"] +dc = last["disp_cycle"] +nx = len(np.unique(sp[:, 0])) +ny = len(np.unique(sp[:, 1])) +X = sp[:, 0].reshape(nx, ny) +Y = sp[:, 1].reshape(nx, ny) +mag = np.linalg.norm(dc[:, :2], axis=1).reshape(nx, ny) +pcm = ax.pcolormesh(X, Y, mag, cmap="magma", shading="auto") +stride = max(1, nx // 16) +ax.quiver(X[::stride, ::stride], Y[::stride, ::stride], + dc[:, 0].reshape(nx, ny)[::stride, ::stride], dc[:, 1].reshape(nx, ny)[::stride, ::stride], + color="white", scale=None, width=0.004) +tr = last["trace"] +if len(tr): + ax.plot(tr[:, 0], tr[:, 1], color="#c2410c", lw=2) +ax.set_aspect("equal") +ax.set_xlabel("x") +ax.set_ylabel("y") +ax.set_title("surface net displacement over the last cycle", fontsize=10) +plt.colorbar(pcm, ax=ax, shrink=0.8, label="|u| net per cycle") + +fig.tight_layout() +out = os.path.join(sim, f"boxfault_{run}.png") +fig.savefig(out, dpi=130) +print("wrote", out) +for c in cycles: + z = np.load(c) + sc_ = z["slip_cycle"] + print(f" t {float(z['t']):.2f}: net slip per cycle mean {sc_.mean():+.3e} (max {sc_.max():+.3e}, min {sc_.min():+.3e}); " + f"cumulative mean {z['slip_cum'].mean():+.3e}; surface net |u| max {np.linalg.norm(z['disp_cycle'][:, :2], axis=1).max():.2e}") From bccad29e675dfdc634ddc8f6a09c067fadd472ed Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 09:26:55 +1100 Subject: [PATCH 32/59] Rotated solve: say so when the velocity block falls back to GAMG A GAMG velocity block on a fault or free-slip mesh is the symptom of a mesh built without its multigrid tail, not a configuration anyone chooses, and it crawls against the sub-solve cap on 3-D fault meshes. The fallback used to be silent; it now warns once per solver and names the two ways to get the tail. Underworld development team with AI support from Claude Code --- src/underworld3/utilities/rotated_bc.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index a10567651..622c5a786 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -1778,6 +1778,18 @@ def _build_rotated_custom_Pl(solver, Q, normal_rows): resolved = custom_mg.build_transfers(solver, field_id=0) h, Ps = resolved if resolved is not None else (None, None) if h is None or Ps is None: + # Loud, not silent: a GAMG velocity block on a fault or free-slip mesh is + # a symptom of a mesh built without its multigrid tail (a gmsh mesh with a + # conforming patch, a split that dropped the tail), not a configuration + # anyone chooses — it crawls against the sub-solve cap on 3-D fault + # meshes. Ruling 2026-10-17: flag the fallback every time it happens. + if not getattr(solver, "_rotated_gamg_fallback_warned", False): + mpi.pprint("[rotated_bc] WARNING: no multigrid hierarchy on this mesh " + "(no adapt tail, no set_custom_fmg registration); the " + "velocity block falls back to GAMG. Build the mesh through " + "adapt() so the tail rides through, or register a coarse " + "tail with set_custom_fmg.") + solver._rotated_gamg_fallback_warned = True return None walls = bool(getattr(solver, "_rotated_freeslip_bcs", None)) if getattr(h, "has_split_levels", False) or \ From 84212228e29ddee86e26e36683c176265cd4e7ee Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 09:26:55 +1100 Subject: [PATCH 33/59] Box fault driver: adapt-on-top route with the multigrid tail, GAMG refused The default mesh route is the designed pipeline: a box with one refinement level, adapt-on-top toward the fault column (exact-distance callable metric, refined through to the free surface), the sheet placed with a four-cell setback at clearance 0.9, the patch split. The tail rides through and the rotated solve takes the custom-P FMG velocity block; after the first solve the driver refuses a GAMG velocity block unless --allow_gamg. The gmsh patch route (no tail) and the uniform placed route stay as options. Load readout fixed. Underworld development team with AI support from Claude Code --- .../box_fault_cyclic/box_fault_cyclic.py | 118 +++++++++++++----- 1 file changed, 88 insertions(+), 30 deletions(-) diff --git a/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py b/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py index 9c2b1cd1f..b17efc6cc 100644 --- a/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py +++ b/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py @@ -38,13 +38,16 @@ ap.add_argument("--h", type=float, default=0.15, help="cell size away from the fault") ap.add_argument("--h_fault", type=float, default=0.05, help="cell size on the fault (gmsh mesher; 0: uniform)") ap.add_argument("--grading", type=float, default=0.0, help="distance over which the size grades back to --h (0: 8 h_fault)") -ap.add_argument("--mesher", default="gmsh", choices=["gmsh", "place"]) +ap.add_argument("--mesher", default="adapt", choices=["adapt", "gmsh", "place"]) +ap.add_argument("--adapt_levels", type=int, default=2, help="adapt-on-top levels toward the fault") +ap.add_argument("--clearance", type=float, default=0.9, help="sheet placement clearance (0.9 on a bisection child)") +ap.add_argument("--allow_gamg", action="store_true", help="accept a GAMG velocity block (no multigrid tail); otherwise refused") ap.add_argument("--Lx", type=float, default=3.0) ap.add_argument("--Ly", type=float, default=2.0) ap.add_argument("--H", type=float, default=1.0) ap.add_argument("--fault_len", type=float, default=1.0, help="fault length along x") ap.add_argument("--fault_depth", type=float, default=0.5, help="depth of the fault bottom below the surface") -ap.add_argument("--setback", type=float, default=2.0, help="setback of the fault top below the surface, in fault cells (gmsh) or cells (place)") +ap.add_argument("--setback", type=float, default=4.0, help="setback of the fault top below the surface, in fault cells (adapt, gmsh) or cells (place)") ap.add_argument("--damage", type=float, default=0.01, help="viscosity ratio in the damage band above the fault top (1: no damage band)") ap.add_argument("--damage_width", type=float, default=0.0, help="half-width of the damage band (0: one cell)") @@ -82,21 +85,65 @@ def log(*a): # ----------------------------------------------------------------------------- mesh, fault, split -# Two routes to a blind vertical fault whose rim is strictly inside the box: -# gmsh — BoxInternalPatch: the rectangle is a conforming patch of the gmsh mesh, -# graded from --h_fault on the patch to --h far away. No cavity, so the -# setback below the surface can be a couple of FAULT cells. -# place — a uniform box, the sheet placed by carving a cavity (add_conforming_sheet) -# with setback; the cavity must not touch the top face, which needs a -# setback of three to four cells (measured: refused at two). +# Three routes to a blind vertical fault whose rim is strictly inside the box: +# adapt — the designed pipeline, and the only one with geometric multigrid: a box built +# with one uniform refinement level (the coarse tail), adapt-on-top toward the +# fault plane (exact-distance callable metric), the sheet placed by carving a +# cavity (add_conforming_sheet with setback, clearance 0.9 on a bisection child), +# the patch split. The tail rides through every step and the rotated solve +# takes the custom-P FMG velocity block. +# gmsh — BoxInternalPatch: the rectangle is a conforming patch of a gmsh mesh graded +# to the fault. No cavity, so a two-cell setback works, but NO multigrid tail: +# the velocity block falls back to GAMG (refused below unless --allow_gamg). +# place — a uniform box, cavity placement with setback (needs three to four cells). h = args.h hf = args.h_fault if args.h_fault > 0 else h yf = 0.5 * args.Ly xa, xb = 0.5 * (args.Lx - args.fault_len), 0.5 * (args.Lx + args.fault_len) zb = args.H - args.fault_depth -setback = args.setback * (hf if args.mesher == "gmsh" else h) +setback = args.setback * (h if args.mesher == "place" else hf) ztop_fault = args.H - setback trace = [[xa, yf, args.H], [xb, yf, args.H]] # the would-be surface trace (a straight fault) + + +def fault_distance(pts): + """Exact distance from points to the fault plane's column (axis-aligned rectangle in + the plane y = yf from the fault bottom up THROUGH the free surface): the refinement + must reach the surface above the fault, both for the damage band and so the + placement's cavity (clearance x local h, plus the dropped cells) stays clear of + the top face — a coarse cell above the fault top reached it (measured).""" + pts = np.asarray(pts, dtype=float).reshape(-1, 3) + dx = np.maximum(np.maximum(xa - pts[:, 0], pts[:, 0] - xb), 0.0) + dz = np.maximum(zb - pts[:, 2], 0.0) + dy = np.abs(pts[:, 1] - yf) + return np.sqrt(dx ** 2 + dy ** 2 + dz ** 2) + + +def fault_metric(pts): + """M = 1/h^2: h_fault within one fault cell of the plane, grading linearly to h.""" + d = fault_distance(pts) + width = args.grading if args.grading > 0 else 6.0 * hf + hh = np.where(d < hf, hf, np.minimum(hf + (h - hf) * (d - hf) / width, h)) + return 1.0 / hh ** 2 + + +def sheet_triangulation(cell): + """The fault rectangle as a triangulated sheet of size `cell`, run past the top so the + placement clips it and stops it `setback` below the surface.""" + nx = max(2, int(round(args.fault_len / cell)) + 1) + nz = max(2, int(round((args.H + 0.3 * cell - zb) / cell)) + 1) + xs = np.linspace(xa, xb, nx) + zs = np.linspace(zb, args.H + 0.3 * cell, nz) + XX, ZZ = np.meshgrid(xs, zs, indexing="ij") + pts = np.column_stack([XX.ravel(), np.full(XX.size, yf), ZZ.ravel()]) + tris = [] + for i in range(nx - 1): + for j in range(nz - 1): + a, b, c, d = i * nz + j, (i + 1) * nz + j, (i + 1) * nz + j + 1, i * nz + j + 1 + tris += [[a, b, c], [a, c, d]] + return pts, np.array(tris, dtype=np.int64) + + t0 = time.perf_counter() if args.mesher == "gmsh": patch = np.array([[xa, yf, zb], [xb, yf, zb], [xb, yf, ztop_fault], [xa, yf, ztop_fault]]) @@ -105,29 +152,29 @@ def log(*a): patch_cellSize=hf if hf < h else None, grading_distance=args.grading if args.grading > 0 else None, qdegree=3) +elif args.mesher == "adapt": + base = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0, 0.0), maxCoords=(args.Lx, args.Ly, args.H), + cellSize=2.0 * h, regular=False, qdegree=3, refinement=1) + child = base.adapt(fault_metric, max_levels=args.adapt_levels, verbose=args.verbose) + n_tail = len(getattr(child, "_custom_mg_coarse_meshes", None) or []) + log(f"adapt: {comm.allreduce(int(child.dm.getHeightStratum(0)[1] - child.dm.getHeightStratum(0)[0]), op=MPI.SUM)} cells, " + f"multigrid tail of {n_tail} coarse levels") + pts, tris = sheet_triangulation(hf) + cut = child.add_conforming_sheet(pts, tris, "Fault", setback=setback, clearance=args.clearance, + verbose=args.verbose) else: base = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0, 0.0), maxCoords=(args.Lx, args.Ly, args.H), cellSize=h, regular=False, qdegree=3) - nx = max(2, int(round(args.fault_len / h)) + 1) - nz = max(2, int(round((args.H + 0.3 * h - zb) / h)) + 1) - xs = np.linspace(xa, xb, nx) - zs = np.linspace(zb, args.H + 0.3 * h, nz) - XX, ZZ = np.meshgrid(xs, zs, indexing="ij") - sheet_pts = np.column_stack([XX.ravel(), np.full(XX.size, yf), ZZ.ravel()]) - tris = [] - for i in range(nx - 1): - for j in range(nz - 1): - a, b, c, d = i * nz + j, (i + 1) * nz + j, (i + 1) * nz + j + 1, i * nz + j + 1 - tris += [[a, b, c], [a, c, d]] - cut = base.add_conforming_sheet(sheet_pts, np.array(tris, dtype=np.int64), "Fault", setback=setback, - verbose=args.verbose) - tr = cut._surface_info.get("surface_trace") if hasattr(cut, "_surface_info") else None - if tr: - trace = tr + pts, tris = sheet_triangulation(h) + cut = base.add_conforming_sheet(pts, tris, "Fault", setback=setback, verbose=args.verbose) +if hasattr(cut, "_surface_info") and cut._surface_info.get("surface_trace"): + trace = cut._surface_info["surface_trace"] mesh = split_fault(cut, "Fault") +n_tail = len(getattr(mesh, "_custom_mg_coarse_meshes", None) or []) ncell = comm.allreduce(int(mesh.dm.getHeightStratum(0)[1] - mesh.dm.getHeightStratum(0)[0]), op=MPI.SUM) log(f"mesh ({args.mesher}) h {h} h_fault {hf}: {ncell} cells after fault + split ({time.perf_counter() - t0:.1f}s); " - f"fault x [{xa:.2f}, {xb:.2f}], z [{zb:.2f}, {ztop_fault:.3f}], setback {setback:.3f}") + f"fault x [{xa:.2f}, {xb:.2f}], z [{zb:.2f}, {ztop_fault:.3f}], setback {setback:.3f}; " + f"multigrid tail {n_tail} levels") x, y, z = mesh.X # ----------------------------------------------------------------------------- solver @@ -174,10 +221,15 @@ def log(*a): stokes.add_fault_bc(args.fault_eta if args.fault_law == "viscous" else 0.0, "Fault") +load_now = {"N": 0.0, "S": 0.0} + + def set_load(t): w = 2 * np.pi - N_t.array[...] = args.N * np.sin(w * t + np.radians(args.phi)) - S_t.array[...] = args.S * np.sin(w * t) + load_now["N"] = args.N * np.sin(w * t + np.radians(args.phi)) + load_now["S"] = args.S * np.sin(w * t) + N_t.array[...] = load_now["N"] + S_t.array[...] = load_now["S"] # ----------------------------------------------------------------------------- diagnostics @@ -243,6 +295,12 @@ def save_cycle(step, t): stokes.solve(zero_init_guess=(k == 0), verbose=args.verbose, picard=args.picard) t = t_new info = getattr(stokes, "_rotated_freeslip_info", None) or {} + if k == 0: + vpc = info.get("velocity_pc") + log(f"velocity block: {vpc}; velocity its (last) {info.get('vel_its_last')}, pressure its (last) {info.get('pres_its_last')}") + if vpc == "GAMG" and not args.allow_gamg: + raise SystemExit("the velocity block fell back to GAMG: the mesh carries no multigrid tail. " + "Use --mesher adapt (or pass --allow_gamg to accept it).") rec = {"step": k, "t": t, "converged": bool(info.get("converged", True)), "nonlinear": info.get("nonlinear_iterations"), "t_step": round(time.perf_counter() - tk, 1)} rec.update(fault_diagnostics(info, dt)) @@ -252,7 +310,7 @@ def save_cycle(step, t): rec["surf_ux_absmax"] = float(np.abs(U[:, 0]).max()) right = surf_pts[:, 0] > 0.9 * args.Lx rec["surf_ux_right"] = float(U[right, 0].mean()) # signed: +N on the loaded face must pull it out (+x) - rec["load_N"], rec["load_S"] = float(N_t.array.ravel()[0]) if N_t.array.size else 0.0, float(S_t.array.ravel()[0]) if S_t.array.size else 0.0 + rec["load_N"], rec["load_S"] = float(load_now["N"]), float(load_now["S"]) records.append(rec) log(f"step {k:4d} t {t:.4f} its {rec['nonlinear']} {rec['t_step']}s | slip rate mean {rec['slip_rate_mean']:+.2e} " f"max {rec['slip_rate_absmax']:.2e} leak {rec['leak_max']:.1e} | sigma_n [{rec['sigma_n_min']:+.3f}, {rec['sigma_n_max']:+.3f}]" From 0ea0b743eea9479df067beef23445a46e54b6f85 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 09:34:04 +1100 Subject: [PATCH 34/59] place_sheet: mark the parallel gather under-reach on a grown cavity On a partitioned adapt child the carve's growth rounds take vertex stars one diameter beyond the gather mask, and the surgery rank refuses with 'the gather mask under-reached' where the same placement passes serially (measured on the box fault geometry, 8 ranks). TODO(BUG) at the mask with the intended fix: re-gather wider on that refusal. Underworld development team with AI support from Claude Code --- src/underworld3/utilities/place_surface.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/underworld3/utilities/place_surface.py b/src/underworld3/utilities/place_surface.py index acb6afa06..5523f016e 100644 --- a/src/underworld3/utilities/place_surface.py +++ b/src/underworld3/utilities/place_surface.py @@ -3281,6 +3281,15 @@ def place_sheet(dm, points, triangles, label=CUT_LABEL, label_value=1, # so a wider mask here only moves cells the surgery never touches (#670: # a +2 margin gathered 91% of a fixture whose cavity was 6%). The carve # asserts nothing shared afterwards, so an under-reach is loud. + # TODO(BUG): the mask does not cover the cavity GROWTH (_carve_cavity_3d's + # enclosed-cell and pinched-vertex rounds take whole vertex stars at the + # shell, one more diameter out), so on a partitioned adapt child the grown + # cavity touched a shared point and the surgery rank raised "the gather + # mask under-reached" (measured 2026-10-17: box 3x2x1, adapt to h 0.05 on + # the fault column, blind sheet with setback 0.25, 8 ranks; the same + # placement passes serially). Fix: on that refusal re-gather from the + # original dm with a wider margin and retry, rather than widening every + # gather (#670's cost note). See planning file: underworld.md. mark = np.zeros(pEnd - pStart, dtype=np.int32) mark[np.flatnonzero(d_sheet < (clearance + 1.0) * h_vertex) + vS - pStart] = 1 From e714d2eae60c165a8206ca3b3ea618fb15557f79 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 09:35:10 +1100 Subject: [PATCH 35/59] Stokes: the fieldsplit health check also skips a fault-contact solve The fault contact rides the manual rotated loop with no free-slip wall registered, so the native snes is never set up and asking it for sub-KSPs printed a PETSc error banner on every step of a faulted box. Underworld development team with AI support from Claude Code --- src/underworld3/systems/solvers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index 01d1a7df2..2c6c98bcb 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -2782,7 +2782,11 @@ def _check_velocity_preconditioner(self): # makes a healthy solve look broken. Skip it by the path rather than by # catching. (Suppressing with pushErrorHandler was tried: it is global # state and broke 23 unrelated tests.) - if getattr(self, "_rotated_freeslip_bcs", None): + # A fault contact (split-node pairs) takes the same manual rotated loop + # with no free-slip wall registered, so it is skipped by its own marker + # (measured: the banner on every step of a faulted box with no walls). + if (getattr(self, "_rotated_freeslip_bcs", None) + or getattr(self, "_fault_contact_faults", None)): return pc = self.snes.getKSP().getPC() if pc.getType() != "fieldsplit": From 427a7f57b263510a4792328132aece2a7c29ab02 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 09:38:20 +1100 Subject: [PATCH 36/59] Box fault driver: gmsh conforming patch with a registered coarse tail; traction sign The default mesh is the gmsh box with the fault as a conforming interior patch graded to the fault, with two plain coarse boxes registered as the multigrid tail (set_custom_fmg): no cavity, a two-cell setback, and the rotated solve takes the custom-P FMG velocity block (measured on 8 ranks: 32 velocity, 10 pressure iterations, zero leak). The adapt route stays as an option with its measured setback rule and the parallel placement defect noted. The natural traction enters negated: a positive value in add_natural_bc pushes into the domain (measured), so N > 0 is tension and the loaded end moves outward. Underworld development team with AI support from Claude Code --- .../box_fault_cyclic/box_fault_cyclic.py | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py b/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py index b17efc6cc..2b67f0865 100644 --- a/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py +++ b/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py @@ -38,7 +38,7 @@ ap.add_argument("--h", type=float, default=0.15, help="cell size away from the fault") ap.add_argument("--h_fault", type=float, default=0.05, help="cell size on the fault (gmsh mesher; 0: uniform)") ap.add_argument("--grading", type=float, default=0.0, help="distance over which the size grades back to --h (0: 8 h_fault)") -ap.add_argument("--mesher", default="adapt", choices=["adapt", "gmsh", "place"]) +ap.add_argument("--mesher", default="gmsh", choices=["gmsh", "adapt", "place"]) ap.add_argument("--adapt_levels", type=int, default=2, help="adapt-on-top levels toward the fault") ap.add_argument("--clearance", type=float, default=0.9, help="sheet placement clearance (0.9 on a bisection child)") ap.add_argument("--allow_gamg", action="store_true", help="accept a GAMG velocity block (no multigrid tail); otherwise refused") @@ -47,7 +47,7 @@ ap.add_argument("--H", type=float, default=1.0) ap.add_argument("--fault_len", type=float, default=1.0, help="fault length along x") ap.add_argument("--fault_depth", type=float, default=0.5, help="depth of the fault bottom below the surface") -ap.add_argument("--setback", type=float, default=4.0, help="setback of the fault top below the surface, in fault cells (adapt, gmsh) or cells (place)") +ap.add_argument("--setback", type=float, default=2.0, help="setback of the fault top below the surface, in fault cells (adapt, gmsh) or cells (place)") ap.add_argument("--damage", type=float, default=0.01, help="viscosity ratio in the damage band above the fault top (1: no damage band)") ap.add_argument("--damage_width", type=float, default=0.0, help="half-width of the damage band (0: one cell)") @@ -86,15 +86,18 @@ def log(*a): # ----------------------------------------------------------------------------- mesh, fault, split # Three routes to a blind vertical fault whose rim is strictly inside the box: -# adapt — the designed pipeline, and the only one with geometric multigrid: a box built -# with one uniform refinement level (the coarse tail), adapt-on-top toward the -# fault plane (exact-distance callable metric), the sheet placed by carving a -# cavity (add_conforming_sheet with setback, clearance 0.9 on a bisection child), -# the patch split. The tail rides through every step and the rotated solve -# takes the custom-P FMG velocity block. # gmsh — BoxInternalPatch: the rectangle is a conforming patch of a gmsh mesh graded -# to the fault. No cavity, so a two-cell setback works, but NO multigrid tail: -# the velocity block falls back to GAMG (refused below unless --allow_gamg). +# to the fault. No cavity, so a two-cell setback works. The mesh has no +# multigrid tail of its own, so two plain boxes at 2h and 4h are REGISTERED +# as the coarse tail (set_custom_fmg, non-nested, barycentric transfers) and +# the rotated solve takes the custom-P FMG velocity block (measured serially: +# 3 velocity, 12 pressure iterations, converged). Default. +# adapt — a box with one refinement level, adapt-on-top toward the fault column, the +# sheet placed by carving a cavity, the patch split; the tail rides through +# by itself. The cavity reaches ~2 cell diameters above the sheet top, so +# the setback must be >= 5 fine cells (0.20 refused, 0.25 passed), and the +# parallel placement currently under-reaches its gather on this geometry +# (TODO(BUG) in place_surface.py; serial passes). # place — a uniform box, cavity placement with setback (needs three to four cells). h = args.h hf = args.h_fault if args.h_fault > 0 else h @@ -208,12 +211,22 @@ def sheet_triangulation(cell): N_t = uw.discretisation.MeshVariable("N_t", mesh, 1, degree=1) S_t = uw.discretisation.MeshVariable("S_t", mesh, 1, degree=1) stokes.add_dirichlet_bc((0.0, 0.0, 0.0), "Left") # x = 0 fixed -stokes.add_natural_bc(sympy.Matrix([[N_t.sym[0], S_t.sym[0], 0.0]]), "Right") # x = Lx: (sigma_xx, sigma_xy, 0) +# sign convention (measured): a positive value in add_natural_bc pushes INTO the domain, so +# the traction sigma.n = (N, S, 0) on the outward normal +x enters negated: N > 0 is tension +# (the loaded end moves +x), S > 0 is shear traction in +y on the x = Lx face. +stokes.add_natural_bc(sympy.Matrix([[-N_t.sym[0], -S_t.sym[0], 0.0]]), "Right") stokes.add_dirichlet_bc((None, None, 0.0), "Bottom") # free-slip base if args.side_bc == "uxuz0": stokes.add_dirichlet_bc((0.0, None, 0.0), "Front") stokes.add_dirichlet_bc((0.0, None, 0.0), "Back") +if args.mesher == "gmsh": + from underworld3.utilities.custom_mg import set_custom_fmg + coarse_tail = [uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0, 0.0), maxCoords=(args.Lx, args.Ly, args.H), + cellSize=c, regular=False, qdegree=3) for c in (4.0 * h, 2.0 * h)] + set_custom_fmg(stokes, coarse_tail, field_id=0) + log(f"coarse tail registered: {[comm.allreduce(int(m.dm.getHeightStratum(0)[1] - m.dm.getHeightStratum(0)[0]), op=MPI.SUM) for m in coarse_tail]} cells") + if args.fault_law == "coulomb": fault_contact.add_coulomb_fault_bc(stokes, args.fault_mu, "Fault", sigma_n="reaction", V0=args.fault_V0, background=args.fault_Tb) From d980cabfd615fa39d14f3e0218909ef5940fc002 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 09:40:23 +1100 Subject: [PATCH 37/59] Box fault figures: load curves drawn over the normal-traction band, legend below the panel Underworld development team with AI support from Claude Code --- scripts/sessions/box_fault_cyclic/box_fault_plot.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/sessions/box_fault_cyclic/box_fault_plot.py b/scripts/sessions/box_fault_cyclic/box_fault_plot.py index e90cb35d6..6f435c339 100644 --- a/scripts/sessions/box_fault_cyclic/box_fault_plot.py +++ b/scripts/sessions/box_fault_cyclic/box_fault_plot.py @@ -44,12 +44,14 @@ ax.set_ylabel("traction on the loaded face") ax.axhline(0, color="0.7", lw=0.6) ax2 = ax.twinx() -ax2.plot(t, rate, color="#1f3a6b", label="mean slip rate (x)") -ax2.fill_between(t, sig_min, sig_max, color="0.85", label=r"$\sigma_n$ range on the fault") +ax2.fill_between(t, sig_min, sig_max, color="0.88", label=r"$\sigma_n$ range on the fault", zorder=0) +ax2.plot(t, rate, color="#1f3a6b", label="mean slip rate (x)", zorder=3) ax2.set_ylabel("slip rate / normal traction", color="#1f3a6b") +ax.set_zorder(ax2.get_zorder() + 1) # the load curves draw over the band +ax.patch.set_visible(False) h1, l1 = ax.get_legend_handles_labels() h2, l2 = ax2.get_legend_handles_labels() -ax.legend(h1 + h2, l1 + l2, fontsize=8, loc="upper right") +ax.legend(h1 + h2, l1 + l2, fontsize=8, loc="upper left", bbox_to_anchor=(0.0, -0.18), ncol=2, frameon=False) ax.set_xlabel("t / period") ax.set_title(f"{run}: De {args['De']}, mu {args['fault_mu']}, Tb {args['fault_Tb']}, phi {args['phi']}", fontsize=10) From 9e97670b8e9aa14c4c9a0e889e082fe937447b08 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 10:25:33 +1100 Subject: [PATCH 38/59] Maxwell ETD integrator: a viscosity that varies in space keeps its elastic response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exponential coefficients alpha = exp(-dt/tau) and phi were formed from float(viscosity)/float(modulus). A viscosity written as an expression of position (a damage band, a temperature-dependent law) raised there and the Maxwell time fell silently to None, which the coefficient update reads as the viscous limit: the model lost its elastic response wherever the viscosity was not a plain number (measured on the box fault model: a first-step end velocity of 0.007, the viscous value, against 0.061 with a constant). The coefficients are now formed symbolically when the Maxwell time is an expression, in the shared helper and at both constitutive-model callers; a constant viscosity takes the numeric path as before. Test: tests/test_1093 — a uniform-valued expression must match the constant it equals, and a weak band must not make the block viscous. Both shown failing before the fix. Underworld development team with AI support from Claude Code --- src/underworld3/constitutive_models.py | 9 ++- src/underworld3/systems/ddt.py | 21 ++++++- .../test_1093_ve_etd_expression_viscosity.py | 62 +++++++++++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 tests/test_1093_ve_etd_expression_viscosity.py diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index b8d5f7ffe..deb5219bf 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -2437,7 +2437,11 @@ def _update_history_coefficients(self): mu_val = float(params.shear_modulus.sym) tau_eff = eta_val / mu_val if mu_val > 0 else sympy.oo except (TypeError, ValueError): - tau_eff = None + # a viscosity (or modulus) that varies in space: the Maxwell + # time is a field and the ETD coefficients are formed + # symbolically (ddt._update_exp_values); None here meant the + # viscous limit and lost the elastic response silently + tau_eff = sympy.sympify(params.shear_viscosity_0.sym) / sympy.sympify(params.shear_modulus.sym) try: dt_val = ( float(params.dt_elastic.sym) @@ -4169,7 +4173,8 @@ def _update_etd_coefficients(self): mu_val = float(params.shear_modulus.sym) tau_eff = eta_val / mu_val if mu_val > 0 else sympy.oo except (TypeError, ValueError): - tau_eff = None + # spatially varying: symbolic coefficients (see the isotropic model) + tau_eff = sympy.sympify(params.shear_viscosity_1.sym) / sympy.sympify(params.shear_modulus.sym) try: dt_val = ( float(params.dt_elastic.sym) diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index cb94d5c58..19a460ed3 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -544,11 +544,28 @@ def _update_exp_values(coeffs, dt, tau_eff): Two-element list ``[α, φ]`` to update. dt : float or None Current timestep. - tau_eff : float or None + tau_eff : float, sympy expression or None Maxwell relaxation time (η_eff/μ). When None or non-positive, - defaults to the viscous limit. + defaults to the viscous limit. A sympy expression (a viscosity that + varies in space) gives symbolic coefficients, evaluated pointwise. """ dt_f = _as_float(dt) + if (isinstance(tau_eff, sympy.Basic) and not tau_eff.is_number + and dt_f is not None and dt_f > 0.0): + # A spatially varying Maxwell time (a viscosity written as an expression + # of position or of a field): the coefficients are FIELDS, formed + # symbolically and evaluated by the kernels where they are used. Taking + # the float here failed and fell to the viscous limit, which silently + # removed the elastic response wherever the viscosity was not a plain + # number (measured: a damage band made the whole block viscous). + # tau <= 0 anywhere is read as the viscous limit there (x -> inf). + tau_pos = sympy.Max(tau_eff, sympy.Float(1e-300)) + x = sympy.Float(dt_f) / tau_pos + alpha = sympy.exp(-x) + phi = (1 - alpha) / x + coeffs[0].sym = alpha + coeffs[1].sym = phi + return tau_f = _as_float(tau_eff) if dt_f is None or tau_f is None or tau_f <= 0.0 or dt_f <= 0.0: alpha, phi = 0.0, 0.0 # viscous limit diff --git a/tests/test_1093_ve_etd_expression_viscosity.py b/tests/test_1093_ve_etd_expression_viscosity.py new file mode 100644 index 000000000..08da7ce76 --- /dev/null +++ b/tests/test_1093_ve_etd_expression_viscosity.py @@ -0,0 +1,62 @@ +"""The ETD Maxwell integrator with a spatially varying viscosity. + +The exponential (ETD) coefficients alpha = exp(-dt/tau) and phi are formed +from the Maxwell time tau = eta/mu. When the viscosity is a sympy EXPRESSION +of position (a damage band, a temperature-dependent viscosity) rather than a +number, the coefficient update could not take its float, fell back to the +viscous limit (alpha = phi = 0) and the model silently lost its elastic +response: a block under a step traction moved at the steady viscous rate +instead of taking the elastic jump (measured on the box fault study, +2026-10-17: first-step end velocity 7e-3 against 6e-2). + +The check: a viscosity written as an expression whose VALUE is uniform must +give the same first step as the constant it equals; and a genuinely varying +viscosity must not collapse onto the viscous limit. +""" +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + + +def _first_step_end_velocity(eta_expr, dt=0.125, tag="a"): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(3.0, 1.0), + cellSize=0.25, regular=False, qdegree=3) + x, y = mesh.X + v = uw.discretisation.MeshVariable(f"v_{tag}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"p_{tag}", mesh, 1, degree=1) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=1, integrator="etd", objective_rate="none") + stokes.constitutive_model.Parameters.shear_modulus = 1.0 + stokes.constitutive_model.Parameters.shear_viscosity_0 = eta_expr(x, y) + stokes.bodyforce = sympy.Matrix([0.0, 0.0]) + stokes.tolerance = 1e-8 + stokes.add_dirichlet_bc((0.0, 0.0), "Left") + stokes.add_dirichlet_bc((None, 0.0), "Bottom") + stokes.add_natural_bc(sympy.Matrix([[-0.01, 0.0]]), "Right") # tension on the free end + stokes.solve(timestep=dt, zero_init_guess=True) + pts = np.array([[2.95, 0.5]]) + return float(np.asarray(uw.function.evaluate(v.sym, pts)).reshape(-1, 2)[0, 0]) + + +def test_uniform_expression_matches_the_constant(): + u_const = _first_step_end_velocity(lambda x, y: 1.0, tag="c") + # numerically uniform, but a sympy expression of position: float() of it fails + u_expr = _first_step_end_velocity(lambda x, y: 1.0 + 1.0e-12 * x, tag="e") + assert abs(u_expr - u_const) < 1e-6 * abs(u_const), (u_const, u_expr) + + +def test_varying_viscosity_keeps_the_elastic_response(): + # a weak band in the middle third: the response must stay near the elastic + # jump of the constant case (the band is small), not fall to the viscous rate, + # which is an order of magnitude smaller at this dt (tau = 1 period, dt = 1/8) + u_const = _first_step_end_velocity(lambda x, y: 1.0, tag="c2") + u_visc = _first_step_end_velocity(lambda x, y: 1.0, dt=1.0e6, tag="v") # dt >> tau: viscous limit + u_band = _first_step_end_velocity( + lambda x, y: 1.0 - 0.99 * sympy.exp(-((x - 1.5) / 0.1) ** 2) * sympy.exp(-((y - 0.5) / 0.1) ** 2), tag="b") + assert u_visc < 0.3 * u_const # the two limits are distinct here + assert u_band > 0.7 * u_const, (u_const, u_visc, u_band) # the band did not make it viscous From 13dfc1fbea9646f82e1a92b929c6270ae288bd76 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 10:25:33 +1100 Subject: [PATCH 39/59] Stokes: stress_transport='none', the untransported grid history; the Maxwell creep benchmark per flavour When the displacement per step is far below a cell (a tidal or seismic cycle) every transport of the stress history is a numerical cost with no physics in it, and the semi-Lagrangian trace-back across a split-node fault queries a field that is double-valued at the slit and reads one side only (measured on the box fault model: a quarter of the slip rate on the second step between the semi-Lagrangian and Eulerian flavours). 'none' keeps the Eulerian grid store with its transport switched off. tests/test_1094: a Maxwell block under a step traction against the exact creep solution for every mesh-based flavour (ETD-1's first step is the creep rate over 1 - e^-x), and the flavours against each other. The subsystem page gains the flavour. The box driver defaults to it. Underworld development team with AI support from Claude Code --- docs/developer/subsystems/stress-transport.md | 5 +- .../box_fault_cyclic/box_fault_cyclic.py | 3 + src/underworld3/systems/solvers.py | 16 +++-- ...1094_maxwell_step_load_history_flavours.py | 67 +++++++++++++++++++ 4 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 tests/test_1094_maxwell_step_load_history_flavours.py diff --git a/docs/developer/subsystems/stress-transport.md b/docs/developer/subsystems/stress-transport.md index a1def6f95..d5230f4cd 100644 --- a/docs/developer/subsystems/stress-transport.md +++ b/docs/developer/subsystems/stress-transport.md @@ -8,7 +8,7 @@ what limits it, and how to keep a run inside those limits. ```python stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) -stokes.stress_transport = "integration_point" # or "semi_lagrangian" (the default), "forward", "eulerian" +stokes.stress_transport = "integration_point" # or "semi_lagrangian" (the default), "forward", "eulerian", "lagrangian", "none" stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( stokes.Unknowns, order=1, integrator="bdf", objective_rate="upper_convected") stokes.constitutive_model.Parameters.shear_viscosity_0 = eta_p @@ -17,7 +17,7 @@ stokes.constitutive_model.Parameters.solvent_viscosity = eta_s # Oldroyd-B; o stokes.constitutive_model.Parameters.dt_elastic = dt ``` -## The five histories +## The six histories | `stress_transport` | storage | carried by | stable at | fails by | |---|---|---|---|---| @@ -26,6 +26,7 @@ stokes.constitutive_model.Parameters.dt_elastic = dt | `forward` | discontinuous P1 per cell, fitted from the arrivals | fixed launch set of interior points (the integration points), one forward trajectory a step; the flux is read back at the launch points through a continuous P1 projection; an inflow cell's uncovered share is filled with the inflow value | the cylinder walls at dt 0.04; below Courant one with `flux_smoothing` at c = 0.023 (Waters-King 1/16, dt 0.0125: 0.9543 at t 1 and 0.5185 at t 6.5, against nodal 0.9622 and 0.5171) | the same cell-scale mode as the integration-point history without that smoothing (diverges at t 2.4 there); first order only; does not cross a periodic seam or follow a moving mesh | | `lagrangian` (particles) | a swarm the solver owns and advects, one value per particle, read through a discontinuous cells proxy | the material points themselves: the constitutive flux is evaluated at the particles each step and never projected back to the mesh; a particle that entered through an inflow takes the inflow value | any Courant number; no numerical diffusion of the history | the cost and bookkeeping of a swarm, and a proxy that needs its cells kept populated (population control refills them); the conformation check does not read a per-point tensor from it | | `eulerian` (SUPG grid) | continuous P1 | assembled transport equation with streamline upwinding | with DEVSS | without DEVSS the velocity block loses its preconditioner as the stress grows | +| `none` (grid, untransported) | continuous P1 | nothing: the history stays at its node | any step whose displacement is far below a cell (a tidal or seismic cycle: the box fault model moves 1e-3 of a cell a step) | it is not a transport at all, so it is wrong as soon as material moves by a cell; a traced-back history on a split-node fault reads the other side of the slit (measured: a quarter of the slip rate on the second step), which this flavour avoids by not tracing | The nodal and integration-point flavours store the stress after every solve by the same global L2 projection onto the continuous space. The integration-point diff --git a/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py b/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py index 2b67f0865..2d5c94b18 100644 --- a/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py +++ b/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py @@ -66,6 +66,8 @@ ap.add_argument("--steps_per_period", type=int, default=32) ap.add_argument("--tol", type=float, default=1e-6) ap.add_argument("--picard", type=int, default=0) +ap.add_argument("--stress_transport", default="none", choices=["none", "eulerian", "semi_lagrangian", "integration_point"], + help="flavour of the Maxwell stress history. Default none: the displacement per step is far below a cell, so transport is a cost not physics, and a traced-back query across the split fault reads the other side") ap.add_argument("--surface_grid", type=int, default=40, help="points along x of the surface displacement grid") ap.add_argument("--verbose", action="store_true") args = ap.parse_args() @@ -198,6 +200,7 @@ def sheet_triangulation(cell): eta_factor = 1.0 - (1.0 - args.damage) * damage if args.damage < 1.0 else 1.0 if args.De > 0: + stokes.stress_transport = args.stress_transport # before the model: it creates the history stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( stokes.Unknowns, order=1, integrator="etd", objective_rate="none") stokes.constitutive_model.Parameters.shear_modulus = 1.0 diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index 2c6c98bcb..71f354585 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -532,7 +532,12 @@ def stress_transport(self) -> str: swarm of material points the solver creates and advects, reading the constitutive flux at the particles each step and never projecting it back to the mesh: no numerical diffusion of the history, at the cost of - the swarm (see :class:`~underworld3.systems.ddt.Lagrangian`). The default + the swarm (see :class:`~underworld3.systems.ddt.Lagrangian`). ``"none"`` + keeps the stress history on the grid and never transports it: the + choice when the displacement per step is far below a cell (a tidal or + seismic cycle), where every transport is a numerical cost with no + physics in it, and where a traced-back query across a split-node fault + can read the other side of the slit. The default is ``"semi_lagrangian"``. Set it before the constitutive model is assigned: assigning the model creates the history, and the choice cannot change after that. @@ -543,10 +548,10 @@ def stress_transport(self) -> str: def stress_transport(self, value): value = str(value) if value not in ("semi_lagrangian", "integration_point", "forward", - "lagrangian", "eulerian"): + "lagrangian", "eulerian", "none"): raise ValueError( "stress_transport must be 'semi_lagrangian', 'integration_point', " - f"'forward', 'lagrangian' or 'eulerian', not {value!r}.") + f"'forward', 'lagrangian', 'eulerian' or 'none', not {value!r}.") if self.Unknowns.DFDt is not None: raise RuntimeError( "the stress history already exists: set stress_transport before the " @@ -730,17 +735,18 @@ def _create_stress_history_ddt(self, order=2): proxy_location="cells", **lag_common, ) - elif self.stress_transport == "eulerian": + elif self.stress_transport in ("eulerian", "none"): if ddt_kwargs: raise NotImplementedError( f"{type(cm).__name__} asks its stress history for " f"{sorted(ddt_kwargs)}, which only the semi-Lagrangian flavour " "provides; use stress_transport='semi_lagrangian' for it.") + # "none": the same grid store, with the transport switched off self.Unknowns.DFDt = uw.systems.ddt.EulerianSUPG( self.mesh, sympy.Matrix.zeros(self.mesh.cdim, self.mesh.cdim), self.u.sym, - transport_on_update=True, + transport_on_update=(self.stress_transport == "eulerian"), **common, ) else: diff --git a/tests/test_1094_maxwell_step_load_history_flavours.py b/tests/test_1094_maxwell_step_load_history_flavours.py new file mode 100644 index 000000000..636df2e79 --- /dev/null +++ b/tests/test_1094_maxwell_step_load_history_flavours.py @@ -0,0 +1,67 @@ +"""A Maxwell block under a step traction: the creep benchmark, per history flavour. + +Plane strain, incompressible, a bar fixed at x = 0 and pulled by a constant +normal traction sigma at x = L with the long sides free: the deviatoric law +gives epsilon_xx = sigma / (4 mu) at once (the elastic jump) and a steady +creep rate d(epsilon_xx)/dt = sigma / (4 eta) thereafter, so the end velocity +settles to sigma L / (4 eta). ETD-1 holds the strain rate constant over a +step and integrates the stress ODE exactly for it, so with no history the +first step's velocity is sigma L / (4 eta (1 - e^-x)), x = dt mu / eta: the +jump spread over the step (0.946 of jump-over-dt plus creep at x = 1/8). +Every flavour of the stress history must reproduce this +(the history is the stress itself; with a velocity this small the transport +between steps is negligible and the flavours must agree with each other). + +Written when the semi-Lagrangian and Eulerian flavours disagreed by a quarter +on the second step of the box fault model (2026-10-17). +""" +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + +L, SIGMA, MU, ETA, DT = 3.0, 0.01, 1.0, 1.0, 0.125 + + +def _end_velocities(flavour, nsteps=4, tag="a"): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(L, 1.0), + cellSize=0.25, regular=False, qdegree=3) + v = uw.discretisation.MeshVariable(f"v_{tag}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"p_{tag}", mesh, 1, degree=1) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.stress_transport = flavour + stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=1, integrator="etd", objective_rate="none") + stokes.constitutive_model.Parameters.shear_modulus = MU + stokes.constitutive_model.Parameters.shear_viscosity_0 = ETA + stokes.bodyforce = sympy.Matrix([0.0, 0.0]) + stokes.tolerance = 1e-8 + stokes.add_dirichlet_bc((0.0, 0.0), "Left") + stokes.add_natural_bc(sympy.Matrix([[-SIGMA, 0.0]]), "Right") # a positive value pushes in + out = [] + pts = np.array([[L - 0.01, 0.5]]) + for k in range(nsteps): + stokes.solve(timestep=DT, zero_init_guess=(k == 0)) + out.append(float(np.asarray(uw.function.evaluate(v.sym, pts)).reshape(-1, 2)[0, 0])) + return np.array(out) + + +@pytest.mark.parametrize("flavour", ["semi_lagrangian", "eulerian", "integration_point", "none"]) +def test_creep_rate_and_elastic_jump(flavour): + u = _end_velocities(flavour, tag=flavour[:4]) + creep = SIGMA * L / (4 * ETA) + x = DT * MU / ETA + first = creep / (1.0 - np.exp(-x)) # ETD-1's exact first step from a zero history + assert abs(u[0] - first) < 0.08 * first, (flavour, u, first) + # later steps: the creep rate, within the mesh's few per cent + assert np.all(np.abs(u[1:] - creep) < 0.08 * creep), (flavour, u, creep) + + +def test_flavours_agree(): + us = {f: _end_velocities(f, nsteps=3, tag="g" + f[:3]) for f in ("semi_lagrangian", "eulerian", "none")} + a = us["semi_lagrangian"] + for f in ("eulerian", "none"): + assert np.allclose(a, us[f], rtol=0.02), us From 123751dc35d402cee75d4d194d3c57fd6ff042ca Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 11:48:58 +1100 Subject: [PATCH 40/59] Fault split: the native refinement hierarchy is the multigrid tail A mesh built with refinement >= 1 carries its coarse levels natively and a cut child already takes them through _coarse_level_meshes; the split only inherited an explicit (adapt) tail. It now takes the hierarchy below it, the finest level being the mesh it re-represents, so a refined cuboid with a conforming patch is its own geometric-multigrid hierarchy and needs no registered coarse meshes. Test: a refined patch box, split, carries one coarse level. Underworld development team with AI support from Claude Code --- src/underworld3/utilities/fault_split.py | 10 ++++++++++ tests/test_0848_fault_split_3d.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/underworld3/utilities/fault_split.py b/src/underworld3/utilities/fault_split.py index 0cd11243e..958064fed 100644 --- a/src/underworld3/utilities/fault_split.py +++ b/src/underworld3/utilities/fault_split.py @@ -2131,6 +2131,8 @@ def split_faults(mesh, names, verbose=False, groups=None, # NOT inherited (a split fault needs no patch — the keying ruling); # callers that key a zone re-adopt on the child explicitly. own_tail = getattr(mesh, "_custom_mg_coarse_meshes", None) + if own_tail is None and len(getattr(mesh, "dm_hierarchy", []) or []) > 1: + own_tail = mesh._coarse_level_meshes()[:-1] # as in split_fault if (own_tail is not None and getattr(out, "_custom_mg_coarse_meshes", None) is None): out._custom_mg_coarse_meshes = list(own_tail) @@ -2427,6 +2429,14 @@ def carry_keys(d, what): # split_fault) silently solved on GAMG. The FAC zone is not inherited: # a split fault needs no patch. own_tail = getattr(mesh, "_custom_mg_coarse_meshes", None) + if own_tail is None and len(getattr(mesh, "dm_hierarchy", []) or []) > 1: + # A mesh built with refinement >= 1 (a refined box, a refined box with + # a conforming patch) carries its coarse levels natively; the cut child + # takes them through _coarse_level_meshes, and so does the split. The + # finest level is this mesh itself, which the split re-represents, so + # it is not a level of its own (a refined cuboid needs no second mesh + # to get geometric multigrid — ruling 2026-10-17). + own_tail = mesh._coarse_level_meshes()[:-1] if own_tail is not None: child._custom_mg_coarse_meshes = list(own_tail) child._custom_mg_builder = getattr(mesh, "_custom_mg_builder", diff --git a/tests/test_0848_fault_split_3d.py b/tests/test_0848_fault_split_3d.py index f4dd74e4a..9e293860f 100644 --- a/tests/test_0848_fault_split_3d.py +++ b/tests/test_0848_fault_split_3d.py @@ -413,3 +413,22 @@ def metric(pts): info = stokes._rotated_freeslip_info assert info["velocity_pc"] == "custom-FMG", info["velocity_pc"] assert info["converged"] + + +@pytest.mark.level_2 +@pytest.mark.tier_b +def test_split_patch_box_keeps_the_refinement_hierarchy(): + """A refined box with a conforming patch is its own multigrid hierarchy: + the split takes the native coarse levels as its tail (the finest level is + the split mesh itself), so a refined cuboid needs no registered coarse + meshes for geometric multigrid.""" + mesh = uw.meshing.BoxInternalPatch(cellSize=0.4, patch_points=PATCH, + patch_name="FltA", refinement=1) + assert len(mesh.dm_hierarchy) == 2 + split = split_fault(mesh, "FltA") + tail = split._custom_mg_coarse_meshes + assert tail is not None and len(tail) == 1, tail + # the one coarse level is the unrefined patch box, half the finest's cells + n_coarse = tail[0].dm.getHeightStratum(0)[1] - tail[0].dm.getHeightStratum(0)[0] + n_fine = split.dm.getHeightStratum(0)[1] - split.dm.getHeightStratum(0)[0] + assert 6 * n_coarse <= n_fine <= 10 * n_coarse, (n_coarse, n_fine) From a4935262de67558f6686f2f8e3bf49544e8b72cc Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 12:03:46 +1100 Subject: [PATCH 41/59] Box fault driver: refined patch box as its own hierarchy, physical grading distance, tail comparison and profiling switches Underworld development team with AI support from Claude Code --- .../box_fault_cyclic/box_fault_cyclic.py | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py b/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py index 2d5c94b18..122af7aa0 100644 --- a/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py +++ b/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py @@ -40,6 +40,9 @@ ap.add_argument("--grading", type=float, default=0.0, help="distance over which the size grades back to --h (0: 8 h_fault)") ap.add_argument("--mesher", default="gmsh", choices=["gmsh", "adapt", "place"]) ap.add_argument("--adapt_levels", type=int, default=2, help="adapt-on-top levels toward the fault") +ap.add_argument("--refinement", type=int, default=2, help="gmsh route: uniform refinement levels of the patch box (its multigrid tail)") +ap.add_argument("--tail", default="hierarchy", choices=["hierarchy", "boxes"], + help="gmsh route: the multigrid tail is the native refinement hierarchy, or plain coarse boxes registered with set_custom_fmg (comparison)") ap.add_argument("--clearance", type=float, default=0.9, help="sheet placement clearance (0.9 on a bisection child)") ap.add_argument("--allow_gamg", action="store_true", help="accept a GAMG velocity block (no multigrid tail); otherwise refused") ap.add_argument("--Lx", type=float, default=3.0) @@ -70,6 +73,7 @@ help="flavour of the Maxwell stress history. Default none: the displacement per step is far below a cell, so transport is a cost not physics, and a traced-back query across the split fault reads the other side") ap.add_argument("--surface_grid", type=int, default=40, help="points along x of the surface displacement grid") ap.add_argument("--verbose", action="store_true") +ap.add_argument("--profile", action="store_true", help="cProfile the time loop on rank 0 (profile.txt in the run directory)") args = ap.parse_args() from mpi4py import MPI # noqa: E402 (collective bookkeeping only) @@ -89,11 +93,11 @@ def log(*a): # ----------------------------------------------------------------------------- mesh, fault, split # Three routes to a blind vertical fault whose rim is strictly inside the box: # gmsh — BoxInternalPatch: the rectangle is a conforming patch of a gmsh mesh graded -# to the fault. No cavity, so a two-cell setback works. The mesh has no -# multigrid tail of its own, so two plain boxes at 2h and 4h are REGISTERED -# as the coarse tail (set_custom_fmg, non-nested, barycentric transfers) and -# the rotated solve takes the custom-P FMG velocity block (measured serially: -# 3 velocity, 12 pressure iterations, converged). Default. +# to the fault, built at 2^k the target size and refined k times: no cavity +# (a two-cell setback works), the patch stays conforming, and the k coarse +# levels are the multigrid tail — nested, so the transfers are exact. The +# split keeps that tail and the rotated solve takes the custom-P FMG +# velocity block. Default. # adapt — a box with one refinement level, adapt-on-top toward the fault column, the # sheet placed by carving a cavity, the patch split; the tail rides through # by itself. The cavity reaches ~2 cell diameters above the sheet top, so @@ -151,12 +155,18 @@ def sheet_triangulation(cell): t0 = time.perf_counter() if args.mesher == "gmsh": + # the patch box at (2^k) x the target sizes, refined k times: the patch stays + # conforming under uniform refinement, the grading survives it, and the k + # coarse levels are the multigrid tail (nested, exact transfers) + f = 2 ** args.refinement patch = np.array([[xa, yf, zb], [xb, yf, zb], [xb, yf, ztop_fault], [xa, yf, ztop_fault]]) - cut = uw.meshing.BoxInternalPatch(cellSize=h, minCoords=(0.0, 0.0, 0.0), maxCoords=(args.Lx, args.Ly, args.H), + # the grading distance is PHYSICAL (gmsh's default is 8 x the patch size, which at + # the coarse size made the fine region four times wider: 78k cells for 16k) + cut = uw.meshing.BoxInternalPatch(cellSize=f * h, minCoords=(0.0, 0.0, 0.0), maxCoords=(args.Lx, args.Ly, args.H), patch_points=patch, patch_name="Fault", - patch_cellSize=hf if hf < h else None, - grading_distance=args.grading if args.grading > 0 else None, - qdegree=3) + patch_cellSize=f * hf if hf < h else None, + grading_distance=args.grading if args.grading > 0 else 8.0 * hf, + refinement=args.refinement, qdegree=3) elif args.mesher == "adapt": base = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0, 0.0), maxCoords=(args.Lx, args.Ly, args.H), cellSize=2.0 * h, regular=False, qdegree=3, refinement=1) @@ -223,12 +233,12 @@ def sheet_triangulation(cell): stokes.add_dirichlet_bc((0.0, None, 0.0), "Front") stokes.add_dirichlet_bc((0.0, None, 0.0), "Back") -if args.mesher == "gmsh": +if args.mesher == "gmsh" and args.tail == "boxes": from underworld3.utilities.custom_mg import set_custom_fmg coarse_tail = [uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0, 0.0), maxCoords=(args.Lx, args.Ly, args.H), cellSize=c, regular=False, qdegree=3) for c in (4.0 * h, 2.0 * h)] set_custom_fmg(stokes, coarse_tail, field_id=0) - log(f"coarse tail registered: {[comm.allreduce(int(m.dm.getHeightStratum(0)[1] - m.dm.getHeightStratum(0)[0]), op=MPI.SUM) for m in coarse_tail]} cells") + log("tail: plain coarse boxes registered (set_custom_fmg) in place of the native hierarchy") if args.fault_law == "coulomb": fault_contact.add_coulomb_fault_bc(stokes, args.fault_mu, "Fault", sigma_n="reaction", @@ -301,6 +311,11 @@ def save_cycle(step, t): t = 0.0 log(f"De {args.De}, S {args.S}, N {args.N}, phi {args.phi}, law {args.fault_law} mu {args.fault_mu} Tb {args.fault_Tb}; " f"dt {dt}, {nsteps} steps") +_prof = None +if args.profile and rank0: + import cProfile + _prof = cProfile.Profile() + _prof.enable() for k in range(nsteps): tk = time.perf_counter() t_new = t + dt @@ -339,4 +354,9 @@ def save_cycle(step, t): if rank0: with open(os.path.join(args.out, "records.json"), "w") as f: json.dump(records, f) +if _prof is not None: + import pstats + _prof.disable() + with open(os.path.join(args.out, "profile.txt"), "w") as f: + pstats.Stats(_prof, stream=f).sort_stats("cumulative").print_stats(60) log("done") From e148cde5938f514502d5021787a4cd119299f49d Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 12:32:13 +1100 Subject: [PATCH 42/59] fault_split: mark the 2-D cut child dropping its parent's multigrid tail; driver dumps binary profile statistics Underworld development team with AI support from Claude Code --- scripts/sessions/box_fault_cyclic/box_fault_cyclic.py | 1 + src/underworld3/utilities/fault_split.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py b/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py index 122af7aa0..c5d2c3e76 100644 --- a/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py +++ b/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py @@ -357,6 +357,7 @@ def save_cycle(step, t): if _prof is not None: import pstats _prof.disable() + _prof.dump_stats(os.path.join(args.out, "profile.prof")) with open(os.path.join(args.out, "profile.txt"), "w") as f: pstats.Stats(_prof, stream=f).sort_stats("cumulative").print_stats(60) log("done") diff --git a/src/underworld3/utilities/fault_split.py b/src/underworld3/utilities/fault_split.py index 958064fed..127978792 100644 --- a/src/underworld3/utilities/fault_split.py +++ b/src/underworld3/utilities/fault_split.py @@ -1752,6 +1752,13 @@ def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, coordinate_system_type=mesh.CoordinateSystem.coordinate_type, qdegree=mesh.qdegree, boundaries=Enum("boundaries", members), verbose=False) + # TODO(BUG): the cut child drops the parent's multigrid tail. A base built + # with refinement >= 1 carries its coarse levels natively and + # Mesh._adopt_cut_child (the add_conforming_surface / add_conforming_sheet + # route) adopts them; this constructor does not, so every solver on an + # add_fault() child of a refined base fell back to GAMG (measured + # 2026-10-17: 2-D box, refinement=2, add_fault -> tail 0, velocity KSP at + # its 200-iteration cap). Adopt the tail here as _adopt_cut_child does. cut.parent = mesh cut._relationship_kind = "refinement" cut._refine_dofs_coincide = False From f21f3d85ab2715c4950513cf128d1a97d9c28150 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 13:49:57 +1100 Subject: [PATCH 43/59] Custom multigrid: parallel transfers into a split finest level The velocity multigrid on a split-node fault mesh needed 4 iterations in serial and 22, 37, 55 on 2, 4, 8 ranks, where a plain or free-slip box held at 3 to 4. The split moves the fault's cell star onto one rank, so the finest partition is no longer the coarse one, and two defects in the custom-P transfer followed: - the cross-partition builder ran the barycentric builder's serial orphan repair on each rank's slice of the all-gathered coarse cloud, where most columns look empty, and overwrote thousands of fine rows with weight-one injections onto coarse nodes far away (20272 of 27785 x-rows wrong on 8 ranks, errors up to 2.9 on a box of length 3); - the co-partitioned builder snapped fine nodes outside the rank's local coarse hull to the nearest local coarse node, and the automatic rule only rebuilt a level across partitions on empty columns, which the wrong rows never produce; comparing the two by empty columns then kept the broken one. The parallel builders now build without the orphan repair (genuine orphans are dropped at install, as before), and a level is rebuilt across partitions whenever any fine node fell outside its local hull. The finest transfer reproduces the coordinate field to 1e-14 on 2 and 8 ranks. Test: tests/parallel/test_1095 (coordinate reproduction and the iteration count near serial on the refined patch box with a split fault). Underworld development team with AI support from Claude Code --- src/underworld3/utilities/custom_mg.py | 49 ++++++++- ...st_1095_custom_mg_split_finest_parallel.py | 104 ++++++++++++++++++ 2 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 tests/parallel/test_1095_custom_mg_split_finest_parallel.py diff --git a/src/underworld3/utilities/custom_mg.py b/src/underworld3/utilities/custom_mg.py index e99ca6b19..ef28590f8 100644 --- a/src/underworld3/utilities/custom_mg.py +++ b/src/underworld3/utilities/custom_mg.py @@ -55,7 +55,13 @@ # --------------------------------------------------------------------------- # # Prolongation builders (return scipy CSR matrices) # --------------------------------------------------------------------------- # -def barycentric_prolongation(coarse_coords, fine_coords): +#: What the last barycentric build saw: fine nodes outside the coarse hull +#: (snapped to the nearest coarse node — a wrong row, not an empty one) and the +#: orphan columns it repaired. Read by the parallel builders right after a call. +last_builder_stats = {"outside_hull": 0, "orphans_repaired": 0} + + +def barycentric_prolongation(coarse_coords, fine_coords, repair_orphans=True): """Prolongation by linear interpolation over a Delaunay triangulation of the coarse DOF cloud. Partition of unity (row sums = 1) and linear fields are reproduced exactly; fine points outside the coarse hull fall back to the @@ -98,6 +104,8 @@ def barycentric_prolongation(coarse_coords, fine_coords): tri = Delaunay(coarse_coords) simp = tri.find_simplex(fine_coords) tree = cKDTree(coarse_coords) + last_builder_stats["outside_hull"] = int((simp < 0).sum()) + last_builder_stats["orphans_repaired"] = 0 dim = coarse_coords.shape[1] rows, cols, vals = [], [], [] for i in range(fine_coords.shape[0]): @@ -133,7 +141,17 @@ def barycentric_prolongation(coarse_coords, fine_coords): # The alternative — switching to the global-support RBF builder — removes # the singularity but returns a DENSE transfer (nnz/row == n_coarse), which # makes PᵀAP dense and is unusable at production sizes. - orphans = np.nonzero(np.asarray((P != 0).sum(axis=0)).ravel() == 0)[0] + # In PARALLEL the repair is wrong by construction and is switched off by + # the parallel builders: a rank sees only its own fine nodes, so a coarse + # column reached from another rank (a ghost at a partition boundary, or + # most of an all-gathered coarse cloud) looks orphaned here and the repair + # overwrote a local fine row with a weight-one injection onto a coarse node + # far away (measured: thousands of rows at distance 0.6-0.9 on a box of + # length 3, and a velocity multigrid that needed 55 iterations on 8 ranks + # against 4 in serial). Genuine orphans are dropped at install instead. + orphans = (np.nonzero(np.asarray((P != 0).sum(axis=0)).ravel() == 0)[0] + if repair_orphans else np.zeros(0, dtype=np.int64)) + last_builder_stats["orphans_repaired"] = int(orphans.size) if orphans.size: k = int(min(orphans.size + 8, fine_coords.shape[0])) _, cand = cKDTree(fine_coords).query(coarse_coords[orphans], k=k) @@ -863,6 +881,15 @@ def _coarse_dof_layout(solver, coarse_mesh, field_id=None): return _level_dof_layout(cdm, field_id) +def _node_transfer_no_repair(builder, coarse_coords, fine_coords): + """The node-level transfer for a parallel build: the barycentric builder + with its serial orphan repair switched off (see the note in + :func:`barycentric_prolongation`); any other builder as it is.""" + if builder is barycentric_prolongation: + return builder(coarse_coords, fine_coords, repair_orphans=False) + return builder(coarse_coords, fine_coords) + + def _build_parallel_transfer(coarse_coords, fine_coords, coarse_layout, fine_layout, ncomp, builder, comm): """One reduced->reduced prolongation as an MPIAIJ matrix. @@ -875,7 +902,7 @@ def _build_parallel_transfer(coarse_coords, fine_coords, coarse_layout, l2g_c = coarse_layout.l2g l2g_f, fstart, fend = fine_layout.l2g, fine_layout.rstart, fine_layout.rend Pn = _drop_structural_zeros( - builder(coarse_coords, fine_coords)) # (n_f_nodes, n_c_nodes), local + _node_transfer_no_repair(builder, coarse_coords, fine_coords)) # (n_f_nodes, n_c_nodes), local nloc_f = fend - fstart nloc_c = coarse_layout.rend - coarse_layout.rstart @@ -939,7 +966,8 @@ def _build_crosspart_transfer(coarse_coords, fine_coords, coarse_layout, coords_u, cols_u = _gather_coarse_cloud(coarse_coords, coarse_layout, ncomp, comm) - Pn = _drop_structural_zeros(builder(coords_u, fine_coords)) # (n_f_local, Nu) + Pn = _drop_structural_zeros( + _node_transfer_no_repair(builder, coords_u, fine_coords)) # (n_f_local, Nu) nloc_f = fend - fstart nloc_c = coarse_layout.rend - coarse_layout.rstart @@ -1527,10 +1555,19 @@ def build(self, solver): # nonsense (KSP reason -11 at the first iteration). The # few genuine orphans are the repair's job below. n_zero = _count_zero_columns_parallel(P, comm) - if self.cross_partition == "auto" and n_zero > 0: + # A fine node outside its rank's LOCAL coarse hull is snapped + # to the nearest local coarse node: a WRONG row, which no + # zero-column count can see. It happens whenever the fine + # partition is not the coarse one — a split finest whose + # fault star was moved to one rank (measured: 3026 of 7500 + # nodes on that rank, 2192 wrong rows). Such a level is + # rebuilt across partitions whatever its column count. + n_out = comm.tompi4py().allreduce( + int(last_builder_stats.get("outside_hull", 0))) + if self.cross_partition == "auto" and (n_zero > 0 or n_out > 0): P_x = _build_crosspart_transfer(*args) n_x = _count_zero_columns_parallel(P_x, comm) - if n_x < n_zero: + if n_out > 0 or n_x < n_zero: P.destroy() P = P_x else: diff --git a/tests/parallel/test_1095_custom_mg_split_finest_parallel.py b/tests/parallel/test_1095_custom_mg_split_finest_parallel.py new file mode 100644 index 000000000..0dc498522 --- /dev/null +++ b/tests/parallel/test_1095_custom_mg_split_finest_parallel.py @@ -0,0 +1,104 @@ +"""Custom-P multigrid transfers into a SPLIT finest level, in parallel. + +A refined patch box (a cuboid with a conforming fault patch, refined twice so +its coarse levels are the multigrid tail) is split along the fault. The split +moves the fault's cell star onto one rank, so the finest partition is no +longer the coarse partition: on that rank thousands of fine nodes lie outside +the LOCAL coarse hull. Two defects followed (found 2026-10-17 on the box fault +model, where the velocity multigrid needed 4 iterations in serial and 22, 37, +55 on 2, 4, 8 ranks): + +* the co-partitioned builder snapped those nodes to the nearest local coarse + node (a wrong row no zero-column count can see), and the automatic rule only + rebuilt a level across partitions on empty COLUMNS; +* the cross-partition builder ran the barycentric builder's serial ORPHAN + REPAIR on each rank's slice of the all-gathered coarse cloud, where most + columns look empty, and overwrote thousands of fine rows with weight-one + injections onto coarse nodes far away (measured: 20272 of 27785 x-rows wrong + on 8 ranks, errors up to 2.9 on a box of length 3). + +The checks: the installed finest transfer reproduces the coordinate field in +the reduced numbering (exact for a Lagrange interpolation), and the velocity +solve takes no more iterations than a few above serial. +""" +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.mpi(min_size=2), pytest.mark.level_2, pytest.mark.tier_b, + pytest.mark.timeout(1800)] + +SERIAL_VEL_ITS = 4 # measured, tolerance 1e-4, this geometry + + +def _faulted_box(): + from underworld3.utilities.fault_split import split_fault + Lx, Ly, H = 3.0, 2.0, 1.0 + h, hf, f = 0.2, 0.1, 4 + yf, xa, xb, zb = 1.0, 1.0, 2.0, 0.5 + ztop = H - 2 * hf + patch = np.array([[xa, yf, zb], [xb, yf, zb], [xb, yf, ztop], [xa, yf, ztop]]) + fine = uw.meshing.BoxInternalPatch(cellSize=f * h, minCoords=(0, 0, 0), maxCoords=(Lx, Ly, H), + patch_points=patch, patch_name="Fault", + patch_cellSize=f * hf, grading_distance=8 * hf, + refinement=2, qdegree=3) + return split_fault(fine, "Fault") + + +def _solve(mesh): + v = uw.discretisation.MeshVariable("v", mesh, 3, degree=2) + p = uw.discretisation.MeshVariable("p", mesh, 1, degree=1) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + stokes.bodyforce = sympy.Matrix([0, 0, 0]) + stokes.tolerance = 1e-4 + stokes.add_dirichlet_bc((0.0, 0.0, 0.0), "Left") + stokes.add_natural_bc(sympy.Matrix([[-0.01, -0.01, 0.0]]), "Right") + stokes.add_dirichlet_bc((None, None, 0.0), "Bottom") + stokes.add_fault_bc(0.0, "Fault") + stokes.solve(zero_init_guess=True) + return stokes + + +def _x_reproduction(stokes, coarse_mesh, P): + """Max |P x_c - x_f| over the x-component rows, in the reduced numbering.""" + from mpi4py import MPI + from underworld3.utilities.custom_mg import _coarse_dof_layout, _level_dof_layout + comm = MPI.COMM_WORLD + Xc = np.asarray(coarse_mesh._get_coords_for_basis(2, True)) + Xf = np.asarray(stokes.mesh._get_coords_for_basis(2, True)) + lay_c = _coarse_dof_layout(stokes, coarse_mesh, 0) + lay_f = _level_dof_layout(stokes.dm, 0) + xc, xf = P.createVecRight(), P.createVecLeft() + for i in range(Xc.shape[0]): + g = int(lay_c.l2g[3 * i]) + if lay_c.rstart <= g < lay_c.rend: + xc.setValue(g, float(Xc[i, 0])) + for i in range(Xf.shape[0]): + g = int(lay_f.l2g[3 * i]) + if lay_f.rstart <= g < lay_f.rend: + xf.setValue(g, float(Xf[i, 0])) + xc.assemble() + xf.assemble() + y = P.createVecLeft() + P.mult(xc, y) + d = np.abs(y.getArray() - xf.getArray()) + mask = np.abs(xf.getArray()) > 0 # the x-rows set above (x = 0 is the fixed face) + return comm.allreduce(float(d[mask].max()) if mask.any() else 0.0, op=MPI.MAX) + + +def test_split_finest_transfer_reproduces_coordinates_and_converges(): + mesh = _faulted_box() + assert len(mesh._custom_mg_coarse_meshes) == 2 + stokes = _solve(mesh) + info = stokes._rotated_freeslip_info + assert info["velocity_pc"] == "custom-FMG" and info["converged"], info + hierarchy = stokes._custom_mg["hierarchy"] + raw = hierarchy.transfers + coarse = hierarchy.level_meshes[-2] + err = _x_reproduction(stokes, coarse, raw[-1]) + assert err < 1e-8, f"finest transfer does not reproduce x: max error {err:.3e}" + assert info["vel_its_last"][-1] <= SERIAL_VEL_ITS + 4, info["vel_its_last"] From ccc9dfa5152ad4cf5103fdcbdc47067446949397 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 14:27:30 +1100 Subject: [PATCH 44/59] Design note: distributing the add-on once in parallel One assignment for the whole add-on hierarchy, computed on the finest level before any surgery and applied to every add-on level through the parent-cell relation, with provenance riding the move; the base levels never move. Records the measured iteration counts and per-rank loads behind the decision, and rescues the diagnostic probes of 2026-10-17 into scripts/sessions/parallel_addon. Underworld development team with AI support from Claude Code --- .../parallel-addon-distribution-2026-10.md | 151 +++++++++++++ .../sessions/parallel_addon/probe_builders.py | 79 +++++++ .../parallel_addon/probe_coords_order.py | 21 ++ .../sessions/parallel_addon/probe_migrate.py | 22 ++ .../parallel_addon/probe_migration_sf.py | 55 +++++ .../sessions/parallel_addon/probe_place.py | 50 +++++ .../sessions/parallel_addon/probe_place3d.py | 37 ++++ .../parallel_addon/probe_place3d_b.py | 31 +++ .../parallel_addon/probe_place3d_c.py | 31 +++ .../sessions/parallel_addon/probe_scaling.py | 202 ++++++++++++++++++ .../parallel_addon/probe_scaling_2d.py | 29 +++ .../sessions/parallel_addon/probe_slots.py | 19 ++ 12 files changed, 727 insertions(+) create mode 100644 docs/developer/design/parallel-addon-distribution-2026-10.md create mode 100644 scripts/sessions/parallel_addon/probe_builders.py create mode 100644 scripts/sessions/parallel_addon/probe_coords_order.py create mode 100644 scripts/sessions/parallel_addon/probe_migrate.py create mode 100644 scripts/sessions/parallel_addon/probe_migration_sf.py create mode 100644 scripts/sessions/parallel_addon/probe_place.py create mode 100644 scripts/sessions/parallel_addon/probe_place3d.py create mode 100644 scripts/sessions/parallel_addon/probe_place3d_b.py create mode 100644 scripts/sessions/parallel_addon/probe_place3d_c.py create mode 100644 scripts/sessions/parallel_addon/probe_scaling.py create mode 100644 scripts/sessions/parallel_addon/probe_scaling_2d.py create mode 100644 scripts/sessions/parallel_addon/probe_slots.py diff --git a/docs/developer/design/parallel-addon-distribution-2026-10.md b/docs/developer/design/parallel-addon-distribution-2026-10.md new file mode 100644 index 000000000..e7a1650de --- /dev/null +++ b/docs/developer/design/parallel-addon-distribution-2026-10.md @@ -0,0 +1,151 @@ +# Distributing the add-on once (October 2026) + +Design note, 2026-10-17. The decision on how the add-on — the finest mesh built on a +static base hierarchy by `adapt`, a cut, a placement or a split, together with its +add-on levels — is partitioned in parallel, and the measurements behind it. It +follows the September ruling in +[fault-parallel-placement-2026-09](fault-parallel-placement-2026-09.md) (the base is +never rebalanced; load fixes go on the child; every transfer to the child must be +partition-agnostic) and the hierarchy of +[FAULT_MULTIGRID_HIERARCHIES](FAULT_MULTIGRID_HIERARCHIES.md). Implementation: +`utilities/addon_partition.py`; tests `tests/parallel/test_1096_*`. + +## The problem + +The base hierarchy is partitioned once and refined in place, so its levels are +co-partitioned, and `adapt` (the in-place `uwnvb` transform) keeps the add-on levels +co-partitioned with it. Every divergence comes from fault surgery, and each surgery +moves cells on its own terms: + +- `split_fault` / `add_fault` move each fault's cell star to the rank already holding + most of it (`_redistribute_fault_interior`), on the finest and again on each + replayed add-on level, each level to *its own* majority rank; a second pass + (`colocate_split_level`) then moves every level's star to the finest's owner. +- `place_sheet` / `place_thin_volume` gather the surgery region to one rank + (`_gather_region(s)`); the 3-D sheet's mask does not cover the cavity's growth + rounds, so on a partitioned adapt child the carve touched a shared point and the + placement refused where the same placement passes serially (`TODO(BUG)` at the mask). +- `adopt_hierarchy` moves *copies of the base's static levels* to follow a placed + mesh (`_colocate_level`). +- The 2-D `add_fault` cut constructs its child without the parent's tail, so solvers + on it fell to GAMG silently (`TODO(BUG)` at the constructor). + +The transfers then pair levels that are not co-partitioned by point location: the +cell-located builder all-gathers the fine nodes it cannot place locally as queries, +and the node-cloud builders needed two fixes on 2026-10-17 (f21f3d85) to stop +producing wrong rows on such a pair. Pair diagnostics, checkpoints and the placement +each carry their own partition handling. + +## Measured (hyperion, f21f3d85, under a 16-rank study) + +Refined patch box, split (`BoxInternalPatch(refinement=2)` then `split_fault`, +19072 cells, P2 velocity, tolerance 1e-4; `scripts/sessions/parallel_addon/probe_scaling.py`): + +| np | velocity iterations | finest P2 nodes per rank (max / mean) | +|---|---|---| +| 1 | 4 | 28274 | +| 2 | 5 | 17118 / 12501 (1.16) | +| 4 | 5 | 9695 / 7789 / 7599 / 5529 (1.27) | +| 8 | 6 | 7500 / 3555 / 4113 / 3461 / 3399 / 3837 / 3762 / 2147 (2.12) | + +The iteration count is now near serial; the owner rank carries twice the mean at +eight ranks, and 3026 of its 7500 nodes lie outside its local coarse hull, so the +finest transfer on that rank is built from remote answers. + +Adapt-on-top fault, 2-D, two add-on levels, np = 4 +(`scripts/sessions/fault_mg/parallel_load.py 2`): finest cells per rank +240 / 292 / 243 / 695 (max / mean 1.89); the base-to-first-add-on transfer misses +265 of 2528 owned nodes (10 %) and asks the other ranks for them; the add-on pair +misses 14 of 2992 after the levels are co-located. Six velocity iterations. + +A per-cell field rides a shell redistribution exactly through +`DMPlex.distributeField` on the migration star forest `distribute()` returns +(`probe_migration_sf.py`, np = 2: zero error). The earlier reading that the migration +forest "maps points to the wrong depth" came from reading remote point numbers +against the local chart; the forest is a point map, and `distributeField` is the API +for riding it. + +## The decision + +**One assignment for the whole add-on, computed on the finest level before any +surgery, applied to every add-on level through the parent-cell relation, once.** The +base levels never move. Concretely: + +1. **Units.** The unit of assignment is a cell of the base's finest level together + with all its descendants on the add-on levels (nested refinement: every add-on + cell has exactly one parent on the level below, found by centroid containment + while the levels are still co-partitioned, which is exact for a bisection + hierarchy). Every cell of a unit goes to the same rank, so every add-on pair stays + co-partitioned exactly, and the base-to-add-on pair is co-partitioned except + where a unit moved. +2. **Fault regions.** The cells the surgery will touch — the star of the vertices + within reach of the fault geometry, plus two growth layers so every point the + carve or the split touches is unshared — are lifted to units and glued: a region + is one indivisible block, sent to the rank already holding most of it, or left + where it is when it is already interior. Regions whose shells touch merge (the + existing `_gather_regions` rule). The split's pairs and the fault cell fans are + therefore rank-local by construction, and never because a later pass moved them. +3. **Balance.** The owner of a region is overloaded by the region's weight (its + finest cells). Units that belong to no region are peeled from an overloaded rank + to an underloaded neighbour across their common seam, one layer per sweep, until + every rank is within a tolerance of the mean or no seam can give. The load fix is + on the child; the base's partition is read, never written. +4. **Provenance rides.** Each level carries, per cell, its parent cell on the level + below and its own pre-move identity, as cell-wise fields moved by + `distributeField`. After the move every fine cell knows where its parent now + lives, and the transfer for every pair — the add-on pairs and the one + base-to-add-on pair — is the coarse cell's Lagrange basis evaluated at the fine + node (the existing dual-basis evaluation of `fault_mg`), with the parent's node + columns and vertex coordinates fetched through one star forest. Co-partitioned + pairs fetch locally; the base boundary fetches the moved regions' parents from + their ranks. No point location across partitions, no all-gathered query cloud. +5. **Surgery afterwards.** Cut, placement and split run on the partitioned levels + and move nothing; their seam tests become assertions of the precondition. A level + created by surgery inherits provenance from the cells it re-represents (the split + is one-to-one; a cut cell or a fill cell takes the pre-surgery cell holding its + centroid, a local test since the region is rank-interior). + +What this retires: `colocate_split_level`, `_colocate_level` and the per-level +`_redistribute_fault_interior` calls; the cross-partition query path of the transfer +builder becomes unreachable for an add-on built this way and is kept only for a +hierarchy registered by hand (`set_custom_fmg` with independently built meshes). + +## What was weighed and rejected + +- **Partition the base around the faults at construction.** Perfect co-partition and + balance, nothing ever moves. Rejected: it is a fix on the base, a moving fault + invalidates it every step, and the base is built before the model knows its faults. +- **Keep the base's partition and split through the seams** (the 2-D `conform` mode + one dimension up). Nothing moves and balance is the partitioner's. Rejected for + the split: the rank-interior fan is what the contact solve's pair block and the + fault-normal accumulation are built on, the 3-D cut through a seam needs the global + patch and a geometric side rule at every shared rim vertex, and the along-strike + seam — the generic case on a base graded toward the fault — is refused today. + The `conform` mode stays as it is for the 2-D band. +- **Leave the gathers and keep repairing the transfers.** The iteration counts are + already near serial after f21f3d85, but every consumer still handles the + partition separately, the owner rank carries twice the mean, and the next + surgery (the 3-D placement on an adapt child) refused for a reason that the one + partition step settles. + +## Stages and tests + +Every stage ships its regression test first, shown failing (Charter §8), under +`tests/parallel/` at 2, 4 and 8 ranks, and the serial reduction. + +1. The 2-D `add_fault` cut child carries its parent's tail (the `TODO(BUG)`): a + refined box, `add_fault`, the velocity preconditioner is the custom FMG and not + GAMG, serial and np = 2. +2. `addon_partition`: after `add_fault` / `split_fault` on an adapt child and on the + refined patch box, every add-on pair reports zero misses, every fault star is + rank-interior with no pair dropped, the finest cells per rank are within the + balance tolerance of the mean, and the velocity iteration count is within a few + of serial. The finest transfer reproduces the coordinate field (test_1095 stands). +3. The provenance transfer replaces location for every pair of such a hierarchy: + the base boundary's moved units are fetched, the transfer stats show no query. +4. The 3-D sheet placement on a partitioned adapt child (the refused fixture) places + and splits at np = 2, 4, 8 with the same cell and pair counts as serial up to the + fill's noise, and the gather reports nothing moved. + +Out of scope here, recorded for later: `extrude_sheet` still builds on one rank; +checkpoint reload of a partitioned add-on; the 3-D split through a seam. diff --git a/scripts/sessions/parallel_addon/probe_builders.py b/scripts/sessions/parallel_addon/probe_builders.py new file mode 100644 index 000000000..1bcac4c4a --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_builders.py @@ -0,0 +1,79 @@ +"""Build the finest transfer (last coarse wrap -> split finest) with each parallel builder +explicitly and test x-reproduction, zero columns and empty rows.""" +import numpy as np, sympy, underworld3 as uw +from mpi4py import MPI +from petsc4py import PETSc +from underworld3.utilities.fault_split import split_fault +from underworld3.utilities import custom_mg as cmg +comm = MPI.COMM_WORLD; rank0 = comm.rank == 0 +Lx, Ly, H = 3.0, 2.0, 1.0; h, hf, f = 0.2, 0.1, 4 +yf, xa, xb, zb = 1.0, 1.0, 2.0, 0.5; ztop = H - 2 * hf +patch = np.array([[xa, yf, zb], [xb, yf, zb], [xb, yf, ztop], [xa, yf, ztop]]) +fine = uw.meshing.BoxInternalPatch(cellSize=f * h, minCoords=(0, 0, 0), maxCoords=(Lx, Ly, H), patch_points=patch, patch_name="Fault", + patch_cellSize=f * hf, grading_distance=8 * hf, refinement=2, qdegree=3) +mesh = split_fault(fine, "Fault") +v = uw.discretisation.MeshVariable("v", mesh, 3, degree=2) +p = uw.discretisation.MeshVariable("p", mesh, 1, degree=1) +stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) +stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel +stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 +stokes.bodyforce = sympy.Matrix([0, 0, 0]); stokes.tolerance = 1e-4 +stokes.add_dirichlet_bc((0.0, 0.0, 0.0), "Left"); stokes.add_natural_bc(sympy.Matrix([[-0.01, -0.01, 0.0]]), "Right") +stokes.add_dirichlet_bc((None, None, 0.0), "Bottom"); stokes.add_fault_bc(0.0, "Fault") +stokes._setup_pointwise_functions(verbose=False) if hasattr(stokes, "_setup_pointwise_functions") else None +stokes._build(verbose=False) if hasattr(stokes, "_build") else None +cm = mesh._custom_mg_coarse_meshes[-1] +Xc = np.asarray(cm._get_coords_for_basis(2, True)); Xf = np.asarray(mesh._get_coords_for_basis(2, True)) +lay_c = cmg._coarse_dof_layout(stokes, cm, 0); lay_f = cmg._level_dof_layout(stokes.dm, 0) +pcomm = stokes.dm.comm +def check(P, name): + xc = P.createVecRight(); xf = P.createVecLeft() + for i in range(Xc.shape[0]): + g = int(lay_c.l2g[i * 3]); + if lay_c.rstart <= g < lay_c.rend: xc.setValue(g, float(Xc[i, 0])) + for i in range(Xf.shape[0]): + g = int(lay_f.l2g[i * 3]); + if lay_f.rstart <= g < lay_f.rend: xf.setValue(g, float(Xf[i, 0])) + xc.assemble(); xf.assemble(); y = P.createVecLeft(); P.mult(xc, y) + d = np.abs(y.getArray() - xf.getArray()); mask = np.abs(xf.getArray()) > 0 + nbad = comm.allreduce(int((d[mask] > 1e-6).sum()), op=MPI.SUM); ntot = comm.allreduce(int(mask.sum()), op=MPI.SUM) + ones = P.createVecRight(); ones.set(1.0); r = P.createVecLeft(); P.mult(ones, r) + nempty = comm.allreduce(int((np.abs(r.getArray()) < 1e-14).sum()), op=MPI.SUM) + nzc = cmg._count_zero_columns_parallel(P, pcomm) + if rank0: print(f"np {comm.size} {name}: x wrong on {nbad}/{ntot}, empty rows {nempty}, zero cols {nzc}", flush=True) +args = (Xc, Xf, lay_c, lay_f, 3, cmg.barycentric_prolongation, pcomm) +check(cmg._build_parallel_transfer(*args), "co-partitioned") +check(cmg._build_crosspart_transfer(*args), "cross-partition") + +# --- dissect wrong rows of the cross-partition transfer on rank 0 --- +coords_u, cols_u = cmg._gather_coarse_cloud(Xc, lay_c, 3, pcomm) +Px = cmg._build_crosspart_transfer(*args) +col_coord = {} +for j in range(coords_u.shape[0]): + g = int(cols_u[j, 0]) + if g >= 0: col_coord[g] = coords_u[j] +indptr, cols, data = Px.getValuesCSR() +fstart = lay_f.rstart +shown = 0 +for i in range(Xf.shape[0]): + g = int(lay_f.l2g[i * 3]) + if not (fstart <= g < lay_f.rend): continue + r = g - fstart + sl = slice(indptr[r], indptr[r + 1]) + cs, ws = cols[sl], data[sl] + xi = Xf[i] + xc_cols = [col_coord.get(int(c)) for c in cs] + px = sum(w * cc[0] for w, cc in zip(ws, xc_cols) if cc is not None) + if abs(px - xi[0]) > 1e-6 and shown < 4: + dists = [float(np.linalg.norm(cc - xi)) if cc is not None else -1 for cc in xc_cols] + if comm.rank == 0: + print(f"rank0 wrong row: fine x {xi.round(3)} -> cols {cs.tolist()} weights {np.round(ws, 3).tolist()} coarse-node distances {np.round(dists, 3).tolist()} (P x = {px:.3f})", flush=True) + shown += 1 +# how many local fine nodes fall outside the LOCAL coarse hull (co-partitioned builder fallback)? +from scipy.spatial import Delaunay +tri = Delaunay(Xc) +outside_local = int((tri.find_simplex(Xf) < 0).sum()) +tri_u = Delaunay(coords_u) +outside_union = int((tri_u.find_simplex(Xf) < 0).sum()) +per = comm.gather((Xf.shape[0], outside_local, outside_union), root=0) +if comm.rank == 0: print("per rank (fine nodes, outside LOCAL coarse hull, outside UNION hull):", per, flush=True) diff --git a/scripts/sessions/parallel_addon/probe_coords_order.py b/scripts/sessions/parallel_addon/probe_coords_order.py new file mode 100644 index 000000000..6e6f1ab7e --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_coords_order.py @@ -0,0 +1,21 @@ +"""On the split patch box: set v = x from v.coords and evaluate it back at random points. +A mismatch between the coordinate order and the section order shows as evaluation error.""" +import numpy as np, underworld3 as uw +from mpi4py import MPI +from underworld3.utilities.fault_split import split_fault +rank0 = MPI.COMM_WORLD.rank == 0 +Lx, Ly, H = 3.0, 2.0, 1.0; h, hf, f = 0.2, 0.1, 4 +yf, xa, xb, zb = 1.0, 1.0, 2.0, 0.5; ztop = H - 2 * hf +patch = np.array([[xa, yf, zb], [xb, yf, zb], [xb, yf, ztop], [xa, yf, ztop]]) +fine = uw.meshing.BoxInternalPatch(cellSize=f * h, minCoords=(0, 0, 0), maxCoords=(Lx, Ly, H), patch_points=patch, patch_name="Fault", + patch_cellSize=f * hf, grading_distance=8 * hf, refinement=2, qdegree=3) +for label, mesh in (("unsplit", fine), ("split", split_fault(fine, "Fault"))): + v = uw.discretisation.MeshVariable(f"v_{label}", mesh, 3, degree=2) + c = np.asarray(v.coords) + v.array[:, 0, 0] = c[:, 0]; v.array[:, 0, 1] = c[:, 1]; v.array[:, 0, 2] = c[:, 2] + rng = np.random.default_rng(0) + pts = np.column_stack([rng.uniform(0.05, 2.95, 400), rng.uniform(0.05, 1.95, 400), rng.uniform(0.05, 0.95, 400)]) + U = np.asarray(uw.function.global_evaluate(v.sym, pts)).reshape(-1, 3) + err = np.abs(U - pts).max(axis=1) + if rank0: + print(f"{label} np {MPI.COMM_WORLD.size}: v = x evaluated back: max err {err.max():.2e}, points with err > 1e-6: {(err > 1e-6).sum()} of {len(err)}", flush=True) diff --git a/scripts/sessions/parallel_addon/probe_migrate.py b/scripts/sessions/parallel_addon/probe_migrate.py new file mode 100644 index 000000000..a83d0f3eb --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_migrate.py @@ -0,0 +1,22 @@ +import numpy as np, underworld3 as uw +from mpi4py import MPI +from underworld3.utilities.fault_split import migrate_point_pairs, redistribute_by_assignment +comm = MPI.COMM_WORLD +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, qdegree=3) +sheet = base.add_fault([("Flt", np.array([[0.25, 0.5], [0.75, 0.5]]))]) +dm = sheet.dm +def coord_of(dm, q): + cs = dm.getCoordinateSection(); cv = dm.getCoordinatesLocal().array + vS, vE = dm.getDepthStratum(0); cdim = dm.getCoordinateDim() + verts = [int(v) for v in dm.getTransitiveClosure(int(q))[0] if vS <= v < vE] + return np.array([cv[cs.getOffset(v):cs.getOffset(v)+cdim] for v in verts]).mean(axis=0) +pairs = sheet._fault_point_pairs["Flt"] +gap = max([np.linalg.norm(coord_of(dm, a) - coord_of(dm, b)) for a, b in pairs.items()] or [0.0]) +print(comm.rank, "before: pairs", len(pairs), "max gap", gap, flush=True) +cS, cE = dm.getHeightStratum(0) +g, sf = redistribute_by_assignment(dm, np.zeros(cE - cS, dtype=np.int32)) +print(comm.rank, "sf", None if sf is None else sf.getGraph()[0], "new chart", g.getChart(), flush=True) +newp, dropped = migrate_point_pairs({"Flt": pairs}, sf, dm, g) +np_ = newp["Flt"] +gap2 = max([np.linalg.norm(coord_of(g, a) - coord_of(g, b)) for a, b in np_.items()] or [0.0]) +print(comm.rank, "after: pairs", len(np_), "dropped", dropped, "max gap", gap2, flush=True) diff --git a/scripts/sessions/parallel_addon/probe_migration_sf.py b/scripts/sessions/parallel_addon/probe_migration_sf.py new file mode 100644 index 000000000..11c3d1eb3 --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_migration_sf.py @@ -0,0 +1,55 @@ +"""Does a per-cell integer field ride a shell-partition redistribution? + +Checks the two PETSc routes a provenance array could take: the migration star +forest ``distribute()`` returns, read as a point map, and ``distributeField`` +on a cell-wise section. The field is each cell's centroid x-coordinate (a +quantity we can re-derive on the new mesh), so a wrong mapping is visible. +""" +import numpy as np +import underworld3 as uw +from mpi4py import MPI +from petsc4py import PETSc + +comm = MPI.COMM_WORLD +mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), + cellSize=0.1, qdegree=2) +dm = mesh.dm +cS, cE = dm.getHeightStratum(0) +cent = np.array([dm.computeCellGeometryFVM(c)[1][0] for c in range(cS, cE)]) +# move the right-hand half of every rank's cells to the next rank +assign = np.full(cE - cS, comm.rank, dtype=np.int32) +assign[cent > 0.5] = (comm.rank + 1) % comm.size +work = dm.clone() +sec = PETSc.Section().create(comm=PETSc.COMM_WORLD) +sec.setChart(*work.getChart()) +for c in range(cS, cE): + sec.setDof(c, 1) +sec.setUp() +vec = PETSc.Vec().createSeq(sec.getStorageSize(), comm=PETSc.COMM_SELF) +arr = vec.getArray() +for c in range(cS, cE): + arr[sec.getOffset(c)] = cent[c - cS] +vec.setArray(arr) +part = work.getPartitioner() +part.setType(PETSc.Partitioner.Type.SHELL) +order = np.argsort(assign, kind="stable").astype(np.int32) +sizes = np.bincount(assign, minlength=comm.size).astype(np.int32) +part.setShellPartition(comm.size, sizes=sizes, points=order) +sf = work.distribute() +print(f"rank {comm.rank}: migration sf {'None' if sf is None else type(sf).__name__}", flush=True) +ncS, ncE = work.getHeightStratum(0) +new_cent = np.array([work.computeCellGeometryFVM(c)[1][0] for c in range(ncS, ncE)]) +# route 1: migration SF as a point map (leaf = new local point, root = old (rank, point)) +if sf is not None: + nroots, ilocal, iremote = sf.getGraph() + nleaves = len(iremote) + cells_leaf = [(i if ilocal is None else int(ilocal[i])) for i in range(nleaves)] + depth_ok = sum(1 for i, q in enumerate(cells_leaf) if (ncS <= q < ncE) == (cS <= int(iremote[i][1]) < cE)) + print(f"rank {comm.rank}: sf nroots {nroots} (old chart size {dm.getChart()[1]}), leaves {nleaves} (new chart size {work.getChart()[1]}), " + f"depth preserved on {depth_ok}/{nleaves}", flush=True) + # route 2: distributeField + newsec, newvec = work.distributeField(sf, sec, vec) + narr = newvec.getArray() + got = np.array([narr[newsec.getOffset(c)] for c in range(ncS, ncE)]) + err = np.abs(got - new_cent).max() if len(got) else 0.0 + print(f"rank {comm.rank}: distributeField cells {ncE - ncS}, max |field - recomputed centroid| = {err:.2e}", flush=True) diff --git a/scripts/sessions/parallel_addon/probe_place.py b/scripts/sessions/parallel_addon/probe_place.py new file mode 100644 index 000000000..28c80efa0 --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_place.py @@ -0,0 +1,50 @@ +import numpy as np, underworld3 as uw, time +Lx, Ly, H = 3.0, 2.0, 1.0; h, hf = 0.15, 0.05 +yf = 1.0; xa, xb = 1.0, 2.0; zb = 0.5; setback = 4 * hf; ztop = H - setback +def dist(pts): + pts = np.asarray(pts, float).reshape(-1, 3) + dx = np.maximum(np.maximum(xa - pts[:, 0], pts[:, 0] - xb), 0.0) + dz = np.maximum(zb - pts[:, 2], 0.0) + return np.sqrt(dx**2 + (pts[:, 1] - yf)**2 + dz**2) +def metric(pts): + d = dist(pts); hh = np.where(d < hf, hf, np.minimum(hf + (h - hf) * (d - hf) / (6 * hf), h)); return 1 / hh**2 +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(Lx, Ly, H), cellSize=2 * h, regular=False, qdegree=3, refinement=1) +child = base.adapt(metric, max_levels=2) +X = child.X.coords +print("child cells", child.dm.getHeightStratum(0)[1], "points", len(X)) +# local edge lengths near the top above the fault +top = (X[:, 2] > 0.97) & (np.abs(X[:, 1] - yf) < 0.1) & (X[:, 0] > xa) & (X[:, 0] < xb) +print("vertices on top face above the fault:", top.sum()) +nx = int(round((xb - xa) / hf)) + 1; nz = int(round((H + 0.3 * hf - zb) / hf)) + 1 +xs = np.linspace(xa, xb, nx); zs = np.linspace(zb, H + 0.3 * hf, nz) +XX, ZZ = np.meshgrid(xs, zs, indexing="ij") +pts = np.column_stack([XX.ravel(), np.full(XX.size, yf), ZZ.ravel()]) +tris = [] +for i in range(nx - 1): + for j in range(nz - 1): + a, b, c, d = i * nz + j, (i + 1) * nz + j, (i + 1) * nz + j + 1, i * nz + j + 1 + tris += [[a, b, c], [a, c, d]] +tris = np.array(tris) +for sb, cl in ((5 * hf, 0.9), (6 * hf, 0.9), (6 * hf, 0.6)): + if sb == setback and cl == 0.9 and 'done_first' in dir(): + # variant: sheet NOT running past the top, stopping at ztop exactly + zs2 = np.linspace(zb, ztop, int(round((ztop - zb) / hf)) + 1) + XX2, ZZ2 = np.meshgrid(xs, zs2, indexing="ij"); pts2 = np.column_stack([XX2.ravel(), np.full(XX2.size, yf), ZZ2.ravel()]) + nz2 = len(zs2); tris2 = [] + for i in range(nx - 1): + for j in range(nz2 - 1): + a, b, c, d = i * nz2 + j, (i + 1) * nz2 + j, (i + 1) * nz2 + j + 1, i * nz2 + j + 1 + tris2 += [[a, b, c], [a, c, d]] + label = f"interior sheet to z={ztop:.2f}, no setback, clearance {cl}" + try: + t = time.perf_counter(); cut = child.add_conforming_sheet(pts2, np.array(tris2), "Fault", setback=0.0, clearance=cl, verbose=True) + print("OK:", label, f"{time.perf_counter()-t:.1f}s cells", cut.dm.getHeightStratum(0)[1]) + except Exception as e: print("REFUSED:", label, "->", str(e)[-160:]) + continue + done_first = True + label = f"setback {sb:.2f} clearance {cl}" + try: + t = time.perf_counter(); cut = child.add_conforming_sheet(pts, tris, "Fault", setback=sb, clearance=cl, verbose=True) + print("OK:", label, f"{time.perf_counter()-t:.1f}s cells", cut.dm.getHeightStratum(0)[1]) + except Exception as e: + print("REFUSED:", label, "->", str(e)[-160:]) diff --git a/scripts/sessions/parallel_addon/probe_place3d.py b/scripts/sessions/parallel_addon/probe_place3d.py new file mode 100644 index 000000000..c72b98d20 --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_place3d.py @@ -0,0 +1,37 @@ +import numpy as np, underworld3 as uw +from underworld3.utilities.place_surface import place_sheet +h_base = 0.1 +n = 5 +yy, zz = np.meshgrid(np.linspace(0.3, 0.7, n), np.linspace(0.3, 0.7, n), indexing="ij") +pts = np.column_stack([np.full(n * n, 0.5), yy.ravel(), zz.ravel()]) +tris = [] +for i in range(n - 1): + for j in range(n - 1): + a, b, c, d = i * n + j, (i + 1) * n + j, (i + 1) * n + j + 1, i * n + j + 1 + tris += [[a, b, c], [a, c, d]] +tris = np.array(tris) +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(1, 1, 1), cellSize=2 * h_base, refinement=1, qdegree=2) +for label, m in [("base", base)]: + try: + dm, info = place_sheet(m.dm, pts, tris, label="Flt", label_value=40) + print(label, "OK", {k: v for k, v in info.items() if not hasattr(v, "shape")}) + except Exception as e: + print(label, "FAIL", str(e)[:200]) +def metric(X): + d_plane = np.abs(X[:, 0] - 0.5) + d_rect = np.maximum(0.0, np.maximum(np.abs(X[:, 1] - 0.5) - 0.2, np.abs(X[:, 2] - 0.5) - 0.2)) + d = np.sqrt(d_plane**2 + d_rect**2) + h = np.minimum(h_base, h_base / 2 + (h_base / 2) * d / 0.15) + return 1.0 / h**2 +child = base.adapt(metric, max_levels=1) +import numpy as np +from petsc4py import PETSc +# boundary planarity check of the child: any boundary vertex off the box faces? +X = child.X.coords if hasattr(child.X, "coords") else None +for label, m in [("child", child)]: + try: + dm, info = place_sheet(m.dm, pts, tris, label="Flt", label_value=40) + print(label, "OK", {k: v for k, v in info.items() if not hasattr(v, "shape")}) + except Exception as e: + print(label, "FAIL", str(e)[:300]) +# which cells changed? compare volumes diff --git a/scripts/sessions/parallel_addon/probe_place3d_b.py b/scripts/sessions/parallel_addon/probe_place3d_b.py new file mode 100644 index 000000000..42419f557 --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_place3d_b.py @@ -0,0 +1,31 @@ +import numpy as np, underworld3 as uw +from underworld3.utilities.place_surface import place_sheet +h_base = 0.1 +n = 5 +yy, zz = np.meshgrid(np.linspace(0.3, 0.7, n), np.linspace(0.3, 0.7, n), indexing="ij") +pts = np.column_stack([np.full(n * n, 0.5), yy.ravel(), zz.ravel()]) +tris = [] +for i in range(n - 1): + for j in range(n - 1): + a, b, c, d = i * n + j, (i + 1) * n + j, (i + 1) * n + j + 1, i * n + j + 1 + tris += [[a, b, c], [a, c, d]] +tris = np.array(tris) +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(1, 1, 1), cellSize=2 * h_base, refinement=1, qdegree=2) +def metric(X): + d_plane = np.abs(X[:, 0] - 0.5) + d_rect = np.maximum(0.0, np.maximum(np.abs(X[:, 1] - 0.5) - 0.2, np.abs(X[:, 2] - 0.5) - 0.2)) + d = np.sqrt(d_plane**2 + d_rect**2) + h = np.minimum(h_base, h_base / 2 + (h_base / 2) * d / 0.15) + return 1.0 / h**2 +child = base.adapt(metric, max_levels=1) +from petsc4py import PETSc +dm = child.dm +cS, cE = dm.getHeightStratum(0) +vols = np.array([dm.computeCellGeometryFVM(c)[0] for c in range(cS, cE)]) +print("child cells", cE - cS, "min vol", vols.min(), "neg", (vols < 0).sum(), "sum", vols.sum(), flush=True) +for kw in [dict(clearance=0.3), dict(clearance=0.9), dict(clearance=1.2), dict(size=0.05), dict(size=0.05, clearance=0.9)]: + try: + d, info = place_sheet(child.dm, pts, tris, label="Flt", label_value=40, **kw) + print(kw, "OK", {k: v for k, v in info.items() if not hasattr(v, "shape")}, flush=True) + except Exception as e: + print(kw, "FAIL", str(e)[:160], flush=True) diff --git a/scripts/sessions/parallel_addon/probe_place3d_c.py b/scripts/sessions/parallel_addon/probe_place3d_c.py new file mode 100644 index 000000000..cc2b80aec --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_place3d_c.py @@ -0,0 +1,31 @@ +import numpy as np, underworld3 as uw, time +from underworld3.utilities.place_surface import place_sheet +h_base = 0.075; k = 2 +n = 7 +yy, zz = np.meshgrid(np.linspace(0.3, 0.7, n), np.linspace(0.3, 0.7, n), indexing="ij") +pts = np.column_stack([np.full(n * n, 0.5), yy.ravel(), zz.ravel()]) +rim = lambda i, j: i in (0, n - 1) or j in (0, n - 1) +tris = [] +for i in range(n - 1): + for j in range(n - 1): + a, b, c, d = i * n + j, (i + 1) * n + j, (i + 1) * n + j + 1, i * n + j + 1 + ra, rb, rc, rd = rim(i, j), rim(i + 1, j), rim(i + 1, j + 1), rim(i, j + 1) + tris += [[a, b, d], [b, c, d]] if ((ra and rb and rc) or (ra and rc and rd)) else [[a, b, c], [a, c, d]] +tris = np.array(tris) +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(1, 1, 1), cellSize=2 * h_base, refinement=1, qdegree=2) +def metric(X): + d_plane = np.abs(X[:, 0] - 0.5) + d_rect = np.maximum(0.0, np.maximum(np.abs(X[:, 1] - 0.5) - 0.2, np.abs(X[:, 2] - 0.5) - 0.2)) + d = np.sqrt(d_plane**2 + d_rect**2) + h_near, h_far, width = h_base / 2**k, h_base, 0.15 + h = np.minimum(h_far, h_near + (h_far - h_near) * d / width) + return 1.0 / h**2 +child = base.adapt(metric, max_levels=k) +print("child cells", child.dm.getHeightStratum(0)[1], flush=True) +for kw in [dict(clearance=1.2), dict(clearance=1.5), dict(size=0.02, clearance=0.9), dict(size=0.02, clearance=1.2), dict(size=0.04, clearance=0.9)]: + t0 = time.perf_counter() + try: + d, info = place_sheet(child.dm, pts, tris, label="Flt", label_value=40, **kw) + print(kw, "OK", {k_: v for k_, v in info.items() if not hasattr(v, "shape")}, round(time.perf_counter() - t0, 1), flush=True) + except Exception as e: + print(kw, "FAIL", str(e)[:160], round(time.perf_counter() - t0, 1), flush=True) diff --git a/scripts/sessions/parallel_addon/probe_scaling.py b/scripts/sessions/parallel_addon/probe_scaling.py new file mode 100644 index 000000000..c9ff875a4 --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_scaling.py @@ -0,0 +1,202 @@ +"""Velocity multigrid iterations against rank count, two configurations: + plain — UnstructuredSimplexBox(refinement=2), standard Stokes path (native hierarchy) + fault — BoxInternalPatch(refinement=2) + split_fault, rotated path, hierarchy tail +""" +import sys, time +import numpy as np, sympy, underworld3 as uw +from mpi4py import MPI +from underworld3.utilities.fault_split import split_fault +mode = sys.argv[1] +rank0 = MPI.COMM_WORLD.rank == 0 +Lx, Ly, H = 3.0, 2.0, 1.0 +h, hf, f = 0.2, 0.1, 4 +yf, xa, xb, zb = 1.0, 1.0, 2.0, 0.5; ztop = H - 2 * hf +t = time.perf_counter() +if mode in ("plain", "wall"): + mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(Lx, Ly, H), cellSize=f * h, regular=False, qdegree=3, refinement=2) +else: + patch = np.array([[xa, yf, zb], [xb, yf, zb], [xb, yf, ztop], [xa, yf, ztop]]) + fine = uw.meshing.BoxInternalPatch(cellSize=f * h, minCoords=(0, 0, 0), maxCoords=(Lx, Ly, H), patch_points=patch, patch_name="Fault", + patch_cellSize=f * hf, grading_distance=8 * hf, refinement=2, qdegree=3) + mesh = split_fault(fine, "Fault") +ncell = MPI.COMM_WORLD.allreduce(int(mesh.dm.getHeightStratum(0)[1] - mesh.dm.getHeightStratum(0)[0]), op=MPI.SUM) +v = uw.discretisation.MeshVariable("v", mesh, 3, degree=2) +p = uw.discretisation.MeshVariable("p", mesh, 1, degree=1) +stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) +stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel +stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 +stokes.bodyforce = sympy.Matrix([0, 0, 0]) +stokes.tolerance = 1e-4 +stokes.add_dirichlet_bc((0.0, 0.0, 0.0), "Left") +stokes.add_natural_bc(sympy.Matrix([[-0.01, -0.01, 0.0]]), "Right") +if mode != "wall": + stokes.add_dirichlet_bc((None, None, 0.0), "Bottom") +if mode.startswith("fault"): + stokes.add_fault_bc(0.0, "Fault") +if mode == "wall": + # rotated free-slip on the base in place of the Dirichlet u_z = 0: the rotated + # custom-P path with constrained (normal) rows but NO fault pairs + stokes.add_rotated_freeslip_bc(0, "Bottom") + from underworld3.utilities.custom_mg import set_custom_fmg + set_custom_fmg(stokes, list(mesh._coarse_level_meshes()[:-1]), field_id=0) +if mode == "fault_xp": + from underworld3.utilities.custom_mg import set_custom_fmg + set_custom_fmg(stokes, list(mesh._custom_mg_coarse_meshes), field_id=0, cross_partition=True) +t = time.perf_counter() +stokes.solve(zero_init_guess=True) +dt_solve = time.perf_counter() - t +if mode.startswith("fault") or mode == "wall": + info = stokes._rotated_freeslip_info + rep = f"velocity_pc {info.get('velocity_pc')} vel its {info.get('vel_its_last')} pres its {info.get('pres_its_last')} converged {info.get('converged')}" +else: + ksp = stokes.snes.getKSP(); pc = ksp.getPC() + try: + sub = pc.getFieldSplitSubKSP() + rep = f"pc {pc.getType()} vel pc {sub[0].getPC().getType()} vel its {sub[0].getIterationNumber()} pres its {sub[1].getIterationNumber()} outer its {ksp.getIterationNumber()} reason {ksp.getConvergedReason()}" + except Exception as e: + rep = f"pc {pc.getType()} outer its {ksp.getIterationNumber()} ({e})" +if mode.startswith("fault") or mode == "wall": + cache = getattr(stokes, "_rotated_linear_cache", None) or {} + Pl = cache.get("custom_Pl") or [] + H = (getattr(stokes, "_custom_mg", None) or {}).get("hierarchy") + raw = getattr(H, "transfers", None) or [] + from underworld3.utilities.custom_mg import _count_zero_columns_parallel + comm = MPI.COMM_WORLD + zc = [] + for l, Praw in enumerate(raw): + zc.append(f"raw L{l} zero cols {_count_zero_columns_parallel(Praw, Praw.comm)}") + if rank0: + print(" zero columns in the RAW transfers (before rotation / constrained-row zeroing): " + "; ".join(zc), flush=True) + # transfer quality: a Lagrange interpolation transfer maps the constant 1 to 1 + # (row weights sum to one); empty rows are fine DOFs with no coarse image + from petsc4py import PETSc + pu = [] + for l, Praw in enumerate(raw): + xc = Praw.createVecRight(); xc.set(1.0) + yf = Praw.createVecLeft(); Praw.mult(xc, yf) + a = np.abs(yf.getArray() - 1.0) + nbad = comm.allreduce(int((a > 1e-8).sum()), op=MPI.SUM) + nzero = comm.allreduce(int((np.abs(yf.getArray()) < 1e-14).sum()), op=MPI.SUM) + nrow = Praw.getSize()[0] + per_rank = comm.gather(int((np.abs(yf.getArray()) < 1e-14).sum()), root=0) + pu.append(f"L{l}: rows {nrow}, rows not summing to 1: {nbad}, empty rows: {nzero} (per rank {per_rank})") + if rank0: + print(" partition of unity of the RAW transfers: " + "; ".join(pu), flush=True) + try: + ctx = cache.get("ctx") or {} + vpc = ctx["pc"].getFieldSplitSubKSP()[0].getPC() + nl = vpc.getMGLevels() + sm = [f"L{l}: {vpc.getMGSmoother(l).getType()}/{vpc.getMGSmoother(l).getPC().getType()}" for l in range(nl)] + cs = vpc.getMGCoarseSolve() + if rank0: + print(f" live velocity MG: {nl} levels; smoothers {sm}; coarse {cs.getType()}/{cs.getPC().getType()}", flush=True) + except Exception as e: + if rank0: + print(f" live velocity MG: not readable ({type(e).__name__}: {e})", flush=True) + # does the transfer reproduce the coordinate field? y = P x_c vs x_f, in the + # reduced numbering of each level (ordering + location test in one) + try: + from underworld3.utilities.custom_mg import _level_dof_layout, _coarse_dof_layout + meshes = list(H.level_meshes) + rep_x = [] + for l in range(1, len(meshes)): + cm, fm = meshes[l - 1], meshes[l] + P = raw[l - 1] + lay_c = _coarse_dof_layout(stokes, cm, 0) if l - 1 < len(meshes) - 1 else _level_dof_layout(stokes.dm, 0) + lay_f = _level_dof_layout(stokes.dm, 0) if l == len(meshes) - 1 else _coarse_dof_layout(stokes, fm, 0) + xc_nodes = np.asarray(cm._get_coords_for_basis(2, True)); xf_nodes = np.asarray(fm._get_coords_for_basis(2, True)) + xc = P.createVecRight(); xf = P.createVecLeft() + ncomp = 3 + for comp in range(1): # x-coordinate only + for i in range(xc_nodes.shape[0]): + g = int(lay_c.l2g[i * ncomp + comp]) + if lay_c.rstart <= g < lay_c.rend: + xc.setValue(g, float(xc_nodes[i, 0])) + for i in range(xf_nodes.shape[0]): + g = int(lay_f.l2g[i * ncomp + comp]) + if lay_f.rstart <= g < lay_f.rend: + xf.setValue(g, float(xf_nodes[i, 0])) + xc.assemble(); xf.assemble() + y = P.createVecLeft(); P.mult(xc, y) + # compare only on the x-component rows (every 3rd reduced row is not guaranteed: use the rows we set) + d = np.abs(y.getArray() - xf.getArray()) + mask = np.abs(xf.getArray()) > 0 # rows we set (x > 0 everywhere but the x=0 face, which is constrained) + nbad = comm.allreduce(int((d[mask] > 1e-6).sum()), op=MPI.SUM) + ntot = comm.allreduce(int(mask.sum()), op=MPI.SUM) + dmax = comm.allreduce(float(d[mask].max()) if mask.any() else 0.0, op=MPI.MAX) + rep_x.append(f"L{l-1}->L{l}: x-rows checked {ntot}, |P x_c - x_f| > 1e-6 on {nbad}, max {dmax:.3e}") + if rank0: + print(" coordinate reproduction: " + "; ".join(rep_x), flush=True) + except Exception as e: + import traceback + if rank0: + print(f" coordinate reproduction: failed {type(e).__name__}: {e}", flush=True); traceback.print_exc() + # the finest level: do the basis coordinates (custom_mg's source) match the + # velocity variable's own DOF coordinates (section order), node by node? + try: + xb = np.asarray(mesh._get_coords_for_basis(2, True)) + xv = np.asarray(v.coords) + same_shape = xb.shape == xv.shape + dmax = float(np.abs(xb - xv).max()) if same_shape and xb.size else -1.0 + nmis = int((np.abs(xb - xv).max(axis=1) > 1e-10).sum()) if same_shape else -1 + rep2 = comm.gather((xb.shape, xv.shape, dmax, nmis), root=0) + if rank0: + print(" finest coords check (basis vs variable) per rank: " + "; ".join(f"{a} vs {b}: max diff {d:.2e}, nodes differing {n}" for a, b, d, n in rep2), flush=True) + # the solver's dm vs the mesh dm: same chart? + sd, md = stokes.dm.getChart(), mesh.dm.getChart() + rep3 = comm.gather((sd, md, stokes.dm.handle == mesh.dm.handle), root=0) + if rank0: + print(f" solver dm chart vs mesh dm chart per rank: {rep3}", flush=True) + except Exception as e: + if rank0: + print(f" finest coords check failed: {type(e).__name__}: {e}", flush=True) + # the finest reduced layout itself: how many local DOFs carry -1 (constrained + # OR an unresolved ghost), against the owned count and the ghost count + try: + from underworld3.utilities.custom_mg import _level_dof_layout + lay = _level_dof_layout(stokes.dm, 0) + n_minus = int((lay.l2g < 0).sum()); n_owned = lay.rend - lay.rstart; n_full = lay.n_full + # genuinely constrained velocity DOFs on this rank: Left face (3 per node) + Bottom (1 per node), by coordinates + xb = np.asarray(mesh._get_coords_for_basis(2, True)) + n_left = int((np.abs(xb[:, 0]) < 1e-9).sum()) * 3 + n_bot = int(((np.abs(xb[:, 2]) < 1e-9) & (np.abs(xb[:, 0]) > 1e-9)).sum()) + rep4 = comm.gather((n_full, n_owned, n_minus, n_left + n_bot), root=0) + if rank0: + print(" finest layout per rank (local DOFs, owned, l2g<0, expected constrained by geometry): " + str(rep4), flush=True) + except Exception as e: + if rank0: + print(f" finest layout check failed: {type(e).__name__}: {e}", flush=True) + # are the SOLVER dm's P2 node coordinates in the same local order as the mesh's? + try: + from petsc4py import PETSc + def p2_coords_of(dm): + dmold = dm.getCoordinateDM(); dmold.createDS(); dmnew = dmold.clone() + o = PETSc.Options(); o["probe_petscspace_degree"] = 2; o["probe_petscdualspace_lagrange_continuity"] = True; o["probe_petscdualspace_lagrange_node_endpoints"] = False + fe = PETSc.FE().createDefault(mesh.dim, mesh.cdim, mesh.isSimplex, mesh.qdegree, "probe_", PETSc.COMM_SELF) + dmnew.setField(0, fe); dmnew.createDS() + mi, _ = dmold.createInterpolation(dmnew) + g = dmnew.getGlobalVec(); lv = dmnew.getLocalVec() + mi.mult(dm.getCoordinates(), g); dmnew.globalToLocal(g, lv) + arr = lv.array.reshape(-1, mesh.cdim).copy() + dmnew.restoreGlobalVec(g); dmnew.restoreLocalVec(lv); return arr + xs = p2_coords_of(stokes.dm); xm = np.asarray(mesh._get_coords_for_basis(2, True)) + same = xs.shape == xm.shape + nd = int((np.abs(xs - xm).max(axis=1) > 1e-10).sum()) if same else -1 + # also: is the mesh's node set a permutation of the solver's? (sort both) + perm = bool(np.allclose(np.sort(xs, axis=0), np.sort(xm, axis=0))) if same else False + rep5 = comm.gather((xs.shape[0], xm.shape[0], nd, perm), root=0) + if rank0: + print(" solver-dm P2 coords vs mesh P2 coords per rank (n_solver, n_mesh, nodes differing in order, same set): " + str(rep5), flush=True) + except Exception as e: + import traceback + if rank0: + print(f" solver-dm coords check failed: {type(e).__name__}: {e}", flush=True); traceback.print_exc() + lev = [] + for l, P in enumerate(Pl): + m, n = P.getSize() + rn = raw[l].getSize()[1] if l < len(raw) else -1 + lev.append(f"L{l}: live {n} (raw {rn}, dropped {rn - n if rn >= 0 else '?'}) -> {m}") + if rank0: + print(" levels (coarse DOFs live/raw -> fine rows): " + "; ".join(lev), flush=True) +if rank0: + print(f"{mode} np {MPI.COMM_WORLD.size} cells {ncell} tail {len(getattr(mesh, '_custom_mg_coarse_meshes', None) or [])} hierarchy {len(mesh.dm_hierarchy)}: solve {dt_solve:.1f}s: {rep}", flush=True) diff --git a/scripts/sessions/parallel_addon/probe_scaling_2d.py b/scripts/sessions/parallel_addon/probe_scaling_2d.py new file mode 100644 index 000000000..8bd8c2754 --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_scaling_2d.py @@ -0,0 +1,29 @@ +"""2-D analogue: refined box + interior split fault (add_fault), rotated custom-P path, iterations vs ranks.""" +import time +import numpy as np, sympy, underworld3 as uw +from mpi4py import MPI +rank0 = MPI.COMM_WORLD.rank == 0 +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(3.0, 1.0), cellSize=0.2, regular=False, qdegree=3, refinement=2) +from underworld3.utilities.fault_split import split_fault +surf = uw.meshing.Surface("Fault", base, np.array([[1.0, 0.5, 0.0], [1.5, 0.5, 0.0], [2.0, 0.5, 0.0]])) +surf.discretize() +cut = base.add_conforming_surface(surf) +mesh = split_fault(cut, "Fault") +ncell = MPI.COMM_WORLD.allreduce(int(mesh.dm.getHeightStratum(0)[1] - mesh.dm.getHeightStratum(0)[0]), op=MPI.SUM) +v = uw.discretisation.MeshVariable("v", mesh, 2, degree=2) +p = uw.discretisation.MeshVariable("p", mesh, 1, degree=1) +stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) +stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel +stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 +stokes.bodyforce = sympy.Matrix([0, 0]) +stokes.tolerance = 1e-4 +stokes.add_dirichlet_bc((0.0, 0.0), "Left") +stokes.add_natural_bc(sympy.Matrix([[-0.01, -0.01]]), "Right") +stokes.add_dirichlet_bc((None, 0.0), "Bottom") +stokes.add_fault_bc(0.0, "Fault") +t = time.perf_counter() +stokes.solve(zero_init_guess=True) +info = stokes._rotated_freeslip_info +if rank0: + print(f"2d fault np {MPI.COMM_WORLD.size} cells {ncell} tail {len(getattr(mesh, '_custom_mg_coarse_meshes', None) or [])}: solve {time.perf_counter()-t:.1f}s: " + f"velocity_pc {info.get('velocity_pc')} vel its {info.get('vel_its_last')} pres its {info.get('pres_its_last')} converged {info.get('converged')}", flush=True) diff --git a/scripts/sessions/parallel_addon/probe_slots.py b/scripts/sessions/parallel_addon/probe_slots.py new file mode 100644 index 000000000..27cbbb296 --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_slots.py @@ -0,0 +1,19 @@ +import numpy as np, underworld3 as uw +from mpi4py import MPI +from underworld3.utilities.fault_split import split_fault +Lx, Ly, H = 3.0, 2.0, 1.0; h, hf, f = 0.2, 0.1, 4 +yf, xa, xb, zb = 1.0, 1.0, 2.0, 0.5; ztop = H - 2 * hf +patch = np.array([[xa, yf, zb], [xb, yf, zb], [xb, yf, ztop], [xa, yf, ztop]]) +fine = uw.meshing.BoxInternalPatch(cellSize=f * h, minCoords=(0, 0, 0), maxCoords=(Lx, Ly, H), patch_points=patch, patch_name="Fault", + patch_cellSize=f * hf, grading_distance=8 * hf, refinement=2, qdegree=3) +mesh = split_fault(fine, "Fault") +tail = mesh._custom_mg_coarse_meshes +if MPI.COMM_WORLD.rank == 0: + print("np", MPI.COMM_WORLD.size, "unsplit slot", getattr(fine, "_refine_slot", None) is not None and fine._refine_slot[1], + "split slot", getattr(mesh, "_refine_slot", None) is not None and mesh._refine_slot[1], + "tail slots", [w._refine_slot[1] for w in tail], "same token split/tail", getattr(mesh, "_refine_slot", (None,))[0] is tail[-1]._refine_slot[0] if getattr(mesh, "_refine_slot", None) else None, flush=True) +rp = getattr(mesh, "_adapt_prolongation", None) +rpf = getattr(fine, "_adapt_prolongation", None) +if MPI.COMM_WORLD.rank == 0: + print("np", MPI.COMM_WORLD.size, "split child _adapt_prolongation:", type(rp).__name__, getattr(rp, "shape", None) if rp is not None else None, + "| unsplit:", type(rpf).__name__, getattr(rpf, "shape", None) if rpf is not None else None, flush=True) From b0bb4dde4233cef0530076e50ef28c5b3bdc7e68 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 14:32:54 +1100 Subject: [PATCH 45/59] fault_split: the 2-D add_fault cut child takes the base's coarse levels as its tail A mesh built with refinement >= 1 is its own multigrid hierarchy and a cut re-represents its finest grid, so the cut-and-split child takes the coarse levels as its tail with itself as the finest level, as split_fault and Mesh._adopt_cut_child already do. The 2-D cut constructed its child bare and every solver on it fell back to algebraic multigrid silently. Test: tests/test_1096_addon_cut_child_tail.py (tail depth per refinement, and the fault solve runs on the custom FMG), shown failing before the fix. Underworld development team with AI support from Claude Code --- src/underworld3/utilities/fault_split.py | 21 +++++--- tests/test_1096_addon_cut_child_tail.py | 65 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 tests/test_1096_addon_cut_child_tail.py diff --git a/src/underworld3/utilities/fault_split.py b/src/underworld3/utilities/fault_split.py index 127978792..bdfd333a7 100644 --- a/src/underworld3/utilities/fault_split.py +++ b/src/underworld3/utilities/fault_split.py @@ -1752,19 +1752,26 @@ def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, coordinate_system_type=mesh.CoordinateSystem.coordinate_type, qdegree=mesh.qdegree, boundaries=Enum("boundaries", members), verbose=False) - # TODO(BUG): the cut child drops the parent's multigrid tail. A base built - # with refinement >= 1 carries its coarse levels natively and - # Mesh._adopt_cut_child (the add_conforming_surface / add_conforming_sheet - # route) adopts them; this constructor does not, so every solver on an - # add_fault() child of a refined base fell back to GAMG (measured - # 2026-10-17: 2-D box, refinement=2, add_fault -> tail 0, velocity KSP at - # its 200-iteration cap). Adopt the tail here as _adopt_cut_child does. cut.parent = mesh cut._relationship_kind = "refinement" cut._refine_dofs_coincide = False cut.regions = mesh.regions cut._parent_mesh_version = mesh._mesh_version mesh._registered_children.add(cut) + # A cut re-represents the parent's finest grid with the fault conformed, + # so the coarse levels of a base built with refinement >= 1 serve the cut + # mesh as its tail, the cut mesh being the finest level (the rule + # split_fault and Mesh._adopt_cut_child apply). A parent that owns an + # add-on tail hands it over in Mesh.add_fault, where the add-on levels are + # split too. Without this every solver on an add_fault child of a refined + # base fell back to algebraic multigrid (measured 2026-10-17: 2-D box, + # refinement=2, velocity KSP at its 200-iteration cap). + if (getattr(mesh, "_custom_mg_coarse_meshes", None) is None + and len(getattr(mesh, "dm_hierarchy", []) or []) > 1): + cut._custom_mg_coarse_meshes = list(mesh._coarse_level_meshes()[:-1]) + cut._custom_mg_builder = getattr(mesh, "_custom_mg_builder", + "barycentric") + cut._custom_mg_fac_zone = None out = cut for name, poly in segments: diff --git a/tests/test_1096_addon_cut_child_tail.py b/tests/test_1096_addon_cut_child_tail.py new file mode 100644 index 000000000..1ab697ff0 --- /dev/null +++ b/tests/test_1096_addon_cut_child_tail.py @@ -0,0 +1,65 @@ +"""The 2-D ``add_fault`` child of a refined base carries the base's coarse levels. + +A mesh built with ``refinement >= 1`` is its own geometric-multigrid hierarchy: +its coarse levels are the tail, the finest level is the mesh itself. A cut +re-represents that finest grid with the fault conformed, so the cut-and-split +child takes the coarse levels as its tail with itself as the finest level — +the rule ``split_fault`` and ``Mesh._adopt_cut_child`` already apply. The 2-D +cut path constructed its child bare, so every solver on an ``add_fault`` child +of a refined box fell back to algebraic multigrid silently (measured +2026-10-17: refinement=2, tail 0, velocity KSP at its 200-iteration cap). + +The contract asserted: the tail has exactly one level per base coarsening and +the velocity preconditioner of a fault solve on the child is the custom FMG. +""" +import numpy as np +import pytest + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + +TRACE = np.array([[0.30, 0.45], [0.70, 0.55]]) + + +def _refined_box(refinement): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, + refinement=refinement, qdegree=2) + + +def _cells(mesh): + cS, cE = mesh.dm.getHeightStratum(0) + return cE - cS + + +@pytest.mark.parametrize("refinement", [1, 2]) +def test_cut_child_takes_the_base_coarse_levels(refinement): + base = _refined_box(refinement) + child = base.add_fault([("Flt", TRACE)]) + tail = child._custom_mg_coarse_meshes + assert tail is not None and len(tail) == refinement, tail + # the levels are the base's own static wraps, coarsest first: each one + # about a quarter of the next (2-D uniform refinement), none of them the + # cut finest + sizes = [_cells(m) for m in tail] + [_cells(child)] + for coarse, fine in zip(sizes[:-1], sizes[1:]): + assert 3 * coarse <= fine <= 5 * coarse + 64, sizes + assert all(getattr(m, "_mg_static", False) for m in tail) + + +def test_fault_solve_on_the_cut_child_runs_on_the_geometric_tail(): + base = _refined_box(1) + child = base.add_fault([("Flt", TRACE)]) + x, y = child.X + stokes = uw.systems.Stokes(child) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 + stokes.tolerance = 1.0e-6 + stokes.petsc_use_pressure_nullspace = True + for side in ("Top", "Bottom", "Left", "Right"): + stokes.add_dirichlet_bc((y - 0.5, 0.0), side) + stokes.add_fault_bc(0, "Flt") + stokes.solve() + info = stokes._rotated_freeslip_info + assert info["velocity_pc"] == "custom-FMG" and info["converged"], info From 9068733338546d8748fd434f5ec221b9259cac5c Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 15:08:01 +1100 Subject: [PATCH 46/59] The add-on is partitioned once, before any surgery, as one hierarchy utilities/addon_partition.partition_addon computes one assignment on the finest add-on level and applies it to every add-on level through the parent-cell relation: the fault regions (the marked vertices' cell star plus growth layers, marked on EVERY level since a coarser level's star reaches further) are glued and sent to the rank holding most of them, and the load is balanced by diffusion over the seams, free units moving from a heavier rank to a lighter neighbour, the cells farthest from the faults first. Every level carries its provenance through the move (distributeField on the migration star forest) and resolves its parents' new addresses through two star forests, so the one pair the move separates - the base's finest static level against the first add-on level - is fetched by provenance_transfer_parallel instead of located across partitions. The base levels never move. add_fault (2-D cut and 3-D sheets), split_fault and split_faults partition before cutting, placing or splitting; the level replay arrives partitioned (partition=False) and a chain touching the seam afterwards is refused as a marking defect rather than redistributed. colocate_split_level and the per-level fault-interior moves retire; redistribute_by_assignment carries per-cell fields; _gather_regions' marking is factored into _region_cells for the partition to share. Measured on the 2-D adapt-on-top fixture: every fault region rank-interior on every level, every add-on pair exactly co-partitioned, every recorded parent contains its cell; finest cells max/mean 1.38 -> 1.09 at np 2. At np 4 and 8 the fault region (544 of 1360 cells) is the indivisible unit and bounds the load, which the test asserts. Tests: tests/parallel/test_1096_addon_partition_parallel.py (np 2, 4, 8); test_1089 and test_1095 unchanged and passing. Underworld development team with AI support from Claude Code --- .../parallel_addon/probe_partition_2d.py | 87 +++ .../parallel_addon/probe_sf_exchange.py | 27 + .../discretisation/discretisation_mesh.py | 51 +- src/underworld3/meshing/extrude.py | 2 +- src/underworld3/utilities/addon_partition.py | 591 ++++++++++++++++++ src/underworld3/utilities/custom_mg.py | 18 + src/underworld3/utilities/fault_mg.py | 228 ++++--- src/underworld3/utilities/fault_split.py | 171 +++-- src/underworld3/utilities/place_surface.py | 135 ++-- .../test_1096_addon_partition_parallel.py | 179 ++++++ 10 files changed, 1254 insertions(+), 235 deletions(-) create mode 100644 scripts/sessions/parallel_addon/probe_partition_2d.py create mode 100644 scripts/sessions/parallel_addon/probe_sf_exchange.py create mode 100644 src/underworld3/utilities/addon_partition.py create mode 100644 tests/parallel/test_1096_addon_partition_parallel.py diff --git a/scripts/sessions/parallel_addon/probe_partition_2d.py b/scripts/sessions/parallel_addon/probe_partition_2d.py new file mode 100644 index 000000000..7526535d7 --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_partition_2d.py @@ -0,0 +1,87 @@ +"""partition_addon on the 2-D adapt-on-top fault fixture: the invariants, at any np. + +mpirun -np N python probe_partition_2d.py [k=2] +""" +import sys +import numpy as np +import underworld3 as uw +from mpi4py import MPI +from petsc4py import PETSc +from underworld3.utilities import addon_partition as ap +from underworld3.utilities.fault_mg import _barycentric, _cell_vertex_table, addon_level_indices +from underworld3.utilities.place_surface import _region_cells + +comm = MPI.COMM_WORLD +k = int(sys.argv[1]) if len(sys.argv) > 1 else 2 +TIP_A, TIP_B = np.array([0.30, 0.45]), np.array([0.70, 0.55]) +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.1, refinement=1, qdegree=2) +pts = np.column_stack([np.linspace(TIP_A[0], TIP_B[0], 9), np.linspace(TIP_A[1], TIP_B[1], 9), np.zeros(9)]) +guide = uw.meshing.Surface("guide", base, pts); guide.discretize() +child = base.adapt(guide.refinement_metric_function(h_near=0.05 / 2**k, h_far=0.05, width=0.08, profile="linear"), max_levels=k) +trace = np.vstack([TIP_A, TIP_B]) + +def cells(m): + cS, cE = m.dm.getHeightStratum(0); return cE - cS + +def report(tag, m): + tail = m._custom_mg_coarse_meshes + idx = addon_level_indices(tail) + per = [comm.gather(cells(tail[i]), root=0) for i in idx] + [comm.gather(cells(m), root=0)] + if comm.rank == 0: + print(f"{tag}: add-on levels {len(idx)}; cells per rank per level (coarsest add-on .. finest): {per}", flush=True) + +report("before", child) +marks = ap.mark_polylines([trace]) +out = ap.partition_addon(child, marks, layers=1, verbose=True) +report("after ", out) +rec = out._addon_partition +if comm.rank == 0: + la = np.array(rec["load_after"]); lb = np.array(rec["load_before"]) + print(f"record: {rec['regions']} region(s) owners {rec['owners']}, units moved {rec['units_moved']}, sweeps {rec['sweeps']}, load max/mean {lb.max()/lb.mean():.2f} -> {la.max()/la.mean():.2f}", flush=True) + +# 1. regions interior on the new finest (the split's precondition): star + 2 layers of the trace on one rank +marks2 = marks(out.dm) +cell_region, canon, regions = _region_cells(out.dm, marks2, layers=2) +counts = np.asarray(comm.allgather([int((cell_region == r).sum()) for r in regions])) +if comm.rank == 0: + print(f"regions after: {len(regions)}; ranks holding each: {[int(np.count_nonzero(counts[:, j])) for j in range(len(regions))]} (1 = interior)", flush=True) + +# 2. add-on pairs co-partitioned: every fine cell's parent is local +tail = out._custom_mg_coarse_meshes +levels = [tail[i] for i in addon_level_indices(tail)] + [out] +static = [tail[i] for i in range(len(tail)) if i not in addon_level_indices(tail)] +ok = True +for l in range(1, len(levels)): + try: + ap._parent_cells_local(levels[l - 1], levels[l]) + except RuntimeError as e: + ok = False + print(f"rank {comm.rank}: pair {l-1}->{l} NOT co-partitioned: {e}", flush=True) +ok = comm.allreduce(ok, op=MPI.LAND) +if comm.rank == 0: + print(f"add-on pairs co-partitioned: {ok}", flush=True) + +# 3. provenance: fetch each parent's vertex coordinates by its CURRENT address and test containment +def check_parents(below, lev, tag): + par = lev._addon_parent + vxy_b, cv_b = _cell_vertex_table(below) + nb = cv_b.shape[0] + verts = vxy_b[cv_b].reshape(nb, -1) # (nb, (dim+1)*cdim) + sf = PETSc.SF().create(comm=PETSc.COMM_WORLD) + sf.setGraph(nb, None, np.ascontiguousarray(par, dtype=np.int32)); sf.setUp() + got = np.zeros((par.shape[0], verts.shape[1])) + unit = MPI.DOUBLE.Create_contiguous(verts.shape[1]); unit.Commit() + sf.bcastBegin(unit, np.ascontiguousarray(verts), got, MPI.REPLACE); sf.bcastEnd(unit, np.ascontiguousarray(verts), got, MPI.REPLACE) + unit.Free() + fcent, _v, _c = ap._cell_centroids(lev) + V = got.reshape(par.shape[0], cv_b.shape[1], -1) + lam = _barycentric(fcent, V) + bad = int((lam.min(axis=1) < -1e-9).sum()) + remote = int((par[:, 0] != comm.rank).sum()) + tot = comm.allreduce(bad); rem = comm.allreduce(remote); n = comm.allreduce(par.shape[0]) + if comm.rank == 0: + print(f"provenance {tag}: {n} cells, {rem} with a remote parent, {tot} NOT inside their parent", flush=True) + +check_parents(static[-1], levels[0], "base -> first add-on") +for l in range(1, len(levels)): + check_parents(levels[l - 1], levels[l], f"add-on {l-1} -> {l}") diff --git a/scripts/sessions/parallel_addon/probe_sf_exchange.py b/scripts/sessions/parallel_addon/probe_sf_exchange.py new file mode 100644 index 000000000..f0fe6d145 --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_sf_exchange.py @@ -0,0 +1,27 @@ +"""petsc4py star-forest semantics for the provenance exchange: bcast root +int64 pairs to leaves, reduce leaf pairs into roots (REPLACE).""" +import numpy as np +from mpi4py import MPI +from petsc4py import PETSc +comm = MPI.COMM_WORLD +r, n = comm.rank, comm.size +nroots = 4 +# every rank's leaves point at the next rank's roots, two leaves per root +remote = np.array([[(r + 1) % n, i // 2] for i in range(2 * nroots)], dtype=np.int32) +sf = PETSc.SF().create(comm=PETSc.COMM_WORLD) +sf.setGraph(nroots, None, remote) +sf.setUp() +root = np.array([[r, 10 * r + i] for i in range(nroots)], dtype=np.int64) +leaf = np.zeros((2 * nroots, 2), dtype=np.int64) +unit = MPI.INT64_T.Create_contiguous(2); unit.Commit() +sf.bcastBegin(unit, root, leaf, MPI.REPLACE); sf.bcastEnd(unit, root, leaf, MPI.REPLACE) +ok_b = bool((leaf[:, 0] == (r + 1) % n).all() and (leaf[:, 1] == 10 * ((r + 1) % n) + np.arange(2 * nroots) // 2).all()) +# reduce: leaves write their (rank, index) into roots; two leaves per root -> MAX picks the larger index +leaf2 = np.array([[r, i] for i in range(2 * nroots)], dtype=np.int64) +root2 = np.full((nroots, 2), -1, dtype=np.int64) +unit1 = MPI.INT64_T +# reduce needs contiguous arrays; do it column by column +col = root2[:, 1].copy(); src = leaf2[:, 1].copy() +sf.reduceBegin(unit1, src, col, MPI.MAX); sf.reduceEnd(unit1, src, col, MPI.MAX) +ok_r = bool((col == 2 * np.arange(nroots) + 1).all()) +print(f"rank {r}: bcast ok {ok_b}; reduce MAX ok {ok_r} (got {col.tolist()})", flush=True) diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index 74467aa3c..dedb48e59 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -8371,40 +8371,27 @@ def add_fault(self, faults, verbose=False, cut=True, exclude=None, # The split mesh INHERITS a mesh-owned geometric-MG tail: a cut # re-represents the same grid with the surface conformed (finer only # by the duplicated vertices), so the parent's coarse levels serve - # with the cut mesh as the finest level. The static background - # levels serve unchanged; the add-on levels are split too (above). - # Without this every solver on a split mesh fell to GAMG unless it - # called set_custom_fmg by hand. The FAC zone is NOT inherited: a + # with the cut mesh as the finest level (fault_split.add_fault hands + # them to the cut child — partitioned with it in parallel). The static + # background levels serve unchanged; the add-on levels are split too, + # each on its own partition, which the add-on partition made the + # finest's: nothing is moved here. The FAC zone is NOT inherited: a # split fault needs no patch (the keying ruling). - own_tail = getattr(self, "_custom_mg_coarse_meshes", None) - if (own_tail is not None - and getattr(child, "_custom_mg_coarse_meshes", None) is None): - tail = list(own_tail) - # a placed band's spines (cut=False) have no counterpart on the - # levels, which were never placed: only a CUT fault is replayed - if split_levels and cut: - from underworld3.utilities.fault_mg import ( - addon_level_indices, split_tail_levels) - idx = addon_level_indices(tail) - if idx: - split = split_tail_levels([tail[k] for k in idx], - child._fault_specs, - verbose=verbose) - if self.dm.getComm().getSize() > 1: - # each level's star onto the rank holding the finest's, - # so the transfers locate the band rank-locally - from underworld3.utilities.fault_mg import ( - colocate_split_level, fault_star_owner) - target = fault_star_owner(child) - split = [colocate_split_level(m, target) - if getattr(m, "_fault_point_pairs", None) else m - for m in split] - for k, m in zip(idx, split): - tail[k] = m + tail = getattr(child, "_custom_mg_coarse_meshes", None) + # a placed band's spines (cut=False) have no counterpart on the + # levels, which were never placed: only a CUT fault is replayed + if tail is not None and split_levels and cut: + from underworld3.utilities.fault_mg import ( + addon_level_indices, split_tail_levels) + tail = list(tail) + idx = addon_level_indices(tail) + if idx: + split = split_tail_levels([tail[k] for k in idx], + child._fault_specs, + verbose=verbose) + for k, m in zip(idx, split): + tail[k] = m child._custom_mg_coarse_meshes = tail - child._custom_mg_builder = getattr(self, "_custom_mg_builder", - "barycentric") - child._custom_mg_fac_zone = None return child diff --git a/src/underworld3/meshing/extrude.py b/src/underworld3/meshing/extrude.py index 13672b6c7..1fa529901 100644 --- a/src/underworld3/meshing/extrude.py +++ b/src/underworld3/meshing/extrude.py @@ -73,7 +73,7 @@ def _gather_sheet(dm, pairs): """Everything onto rank 0: ``(dm, pairs)`` in the gathered numbering.""" from underworld3.utilities.fault_split import redistribute_by_assignment cS, cE = dm.getHeightStratum(0) - gathered, pairs, _dropped = redistribute_by_assignment( + gathered, pairs, _dropped, _fields = redistribute_by_assignment( dm, np.zeros(cE - cS, dtype=np.int32), pairs=pairs) return gathered, pairs or {} diff --git a/src/underworld3/utilities/addon_partition.py b/src/underworld3/utilities/addon_partition.py new file mode 100644 index 000000000..55fabb097 --- /dev/null +++ b/src/underworld3/utilities/addon_partition.py @@ -0,0 +1,591 @@ +r"""One partition for the add-on hierarchy. + +The add-on is the finest mesh built on a static base hierarchy — an ``adapt`` +child, the mesh a fault is about to be cut into or placed into — together with +the add-on levels refined from the base toward the fault. ``adapt`` leaves the +add-on co-partitioned with the base; fault surgery needs the fault's cell star +rank-interior (the split's pairs and the placement's cavity are rank-local by +construction) and so used to move cells on its own terms, once per surgery +and once per level, after which no two levels shared a partition and every +transfer, gather and diagnostic carried its own cross-partition handling. + +:func:`partition_addon` moves the add-on **once, before any surgery**: one +assignment computed on the finest level and applied to every add-on level +through the parent-cell relation, so the add-on levels stay co-partitioned +with each other exactly, the fault regions are rank-interior, and the load is +balanced by peeling non-fault cells from the owner rank to its underloaded +neighbours. The base levels never move (load fixes go on the child). Every +level carries, per cell, the cell it came from and its parent on the level +below, so the one pair that is no longer co-partitioned — the base's finest +level against the first add-on level — is an exact relation to fetch, not a +search. Design note: +``docs/developer/design/parallel-addon-distribution-2026-10.md``. + +The unit of assignment is a cell of the coarsest add-on level with every +descendant it has on the finer levels (the finest mesh itself when there is +no add-on level below it). All the cells of a unit go to one rank. +""" +import numpy as np +from mpi4py import MPI +from petsc4py import PETSc + +import underworld3 as uw + +__all__ = ["partition_addon", "mark_labels", "mark_polylines", "mark_sheets"] + +# A fine cell's centroid lies inside its parent for nested refinement; the +# tolerance absorbs the round-off of the barycentric solve. +_INSIDE_TOL = 1e-9 + + +def _allreduce(comm, values, op=MPI.SUM): + """Reduce a float array over the communicator (buffer-based; the + pickling form applies the operator to Python objects).""" + out = np.array(values, dtype=float) + comm.Allreduce(MPI.IN_PLACE, out, op=op) + return out + + +# --------------------------------------------------------------------------- # +# Marks: which vertices the fault touches +# --------------------------------------------------------------------------- # +def mark_labels(labels, groups=None): + """The marking rule for labelled fault facets: region ``k`` on every vertex + of a facet of group ``k`` (one group per fault when ``groups`` is None). + + ``labels`` is a sequence of ``(name, value)`` pairs; ``groups`` a list of + lists of those pairs, the faults that must share a rank. Returns the + function of a level's DM that :func:`partition_addon` takes as + ``region_marks``.""" + if groups is None: + groups = [[lab] for lab in labels] + + def marks(dm): + fS, fE = dm.getHeightStratum(1) + vS, vE = dm.getDepthStratum(0) + pStart, pEnd = dm.getChart() + ids = np.zeros(pEnd - pStart, dtype=np.int32) + for g, group in enumerate(groups, start=1): + for name, value in group: + if not (dm.hasLabel(name) + and dm.getLabel(name).getStratumSize(int(value)) > 0): + continue + for f in dm.getLabel(name).getStratumIS(int(value)).getIndices(): + if fS <= int(f) < fE: + for q in dm.getTransitiveClosure(int(f))[0]: + if vS <= int(q) < vE: + i = int(q) - pStart + ids[i] = max(ids[i], g) + return ids + return marks + + +def _segment_distance(X, poly): + """Distance from each point of ``X`` (N, 2) to the polyline ``poly`` (M, 2).""" + best = np.full(X.shape[0], np.inf) + for a, b in zip(poly[:-1], poly[1:]): + ab = b - a + t = np.clip(((X - a) @ ab) / max(float(ab @ ab), 1e-300), 0.0, 1.0) + d = np.linalg.norm(X - (a + t[:, None] * ab), axis=1) + best = np.minimum(best, d) + return best + + +def mark_polylines(polylines): + """The marking rule for 2-D line cuts: region ``k`` on every vertex within + one local cell diameter of polyline ``k`` — the vertices the cut may pull + onto the trace and the corners of every cell it crosses, at the level's + own resolution.""" + polylines = [np.asarray(poly, dtype=float)[:, :2] for poly in polylines] + + def marks(dm): + from underworld3.utilities.edge_split import cell_diameters + vS, vE = dm.getDepthStratum(0) + cS, cE = dm.getHeightStratum(0) + pStart, pEnd = dm.getChart() + X = dm.getCoordinatesLocal().array.reshape( + -1, dm.getCoordinateDim())[: vE - vS] + h_cell = np.asarray(cell_diameters(dm)) + h_vertex = np.zeros(vE - vS) + for c in range(cS, cE): + for q in dm.getTransitiveClosure(c)[0]: + if vS <= int(q) < vE: + i = int(q) - vS + h_vertex[i] = max(h_vertex[i], h_cell[c - cS]) + ids = np.zeros(pEnd - pStart, dtype=np.int32) + for k, poly in enumerate(polylines, start=1): + near = np.flatnonzero(_segment_distance(X[:, :2], poly) <= h_vertex) + ids[near + vS - pStart] = np.maximum(ids[near + vS - pStart], k) + return ids + return marks + + +def mark_sheets(sheets, clearance): + """The marking rule for 3-D sheet placements: the placement's own mask — + region ``k`` on every vertex within ``(clearance + 1)`` local cell + diameters of sheet ``k`` (``(points, triangles)`` pairs) — so the carve, + its growth rounds and the split all fall inside the region once the + growth layers are added.""" + sheets = [(np.asarray(pts, dtype=float), np.asarray(tri, dtype=np.int64)) + for pts, tri in sheets] + + def marks(dm): + from underworld3.utilities.place_surface import ( + _coords, _sheet_distance_within, _tet_vertices, _vertex_h_3d) + vS, vE = dm.getDepthStratum(0) + pStart, pEnd = dm.getChart() + X = _coords(dm)[: vE - vS] + cells = _tet_vertices(dm) + h_vertex, _h_cell = _vertex_h_3d(dm, cells, len(X)) + reach = (float(clearance) + 1.0) * h_vertex + ids = np.zeros(pEnd - pStart, dtype=np.int32) + for k, (points, triangles) in enumerate(sheets, start=1): + d = _sheet_distance_within(X, points, triangles, reach) + near = np.flatnonzero(d < reach) + ids[near + vS - pStart] = np.maximum(ids[near + vS - pStart], k) + return ids + return marks + + +# --------------------------------------------------------------------------- # +# The parent relation and the units +# --------------------------------------------------------------------------- # +def _cell_centroids(mesh): + from underworld3.utilities.fault_mg import _cell_vertex_table + vxy, cv = _cell_vertex_table(mesh) + return vxy[cv].mean(axis=1), vxy, cv + + +def _parent_cells_local(coarse, fine): + """``parent[k]``: the local cell of ``coarse`` holding the centroid of local + cell ``k`` of ``fine``. Exact for nested refinement and for a grid that + re-represents the coarse one; raises if any centroid falls outside every + local coarse cell, which means the two levels are not co-partitioned — + the add-on has already been moved and must not be partitioned twice.""" + from underworld3.utilities.fault_mg import locate_in_cells_exhaustive + comm = fine.dm.getComm().tompi4py() + fcent, _vf, cvf = _cell_centroids(fine) + _cc, vxy_c, cv_c = _cell_centroids(coarse) + n_f, n_c = cvf.shape[0], cv_c.shape[0] + cell = np.full(n_f, -1, dtype=np.int64) + if n_f and n_c: + cell, lam = locate_in_cells_exhaustive(fcent, vxy_c, cv_c) + cell = np.where(lam.min(axis=1) < -_INSIDE_TOL, -1, cell).astype(np.int64) + n_bad = int(comm.allreduce(int((cell < 0).sum()) + (n_f > 0 and n_c == 0))) + if n_bad: + raise RuntimeError( + f"addon_partition: {n_bad} fine cell(s) have no local parent cell " + f"on the level below; the add-on levels are not co-partitioned " + f"(an add-on is partitioned once, as adapt made it).") + return cell + + +def _levels_of(mesh): + """``(base, static, addon)``: the static level the first add-on level's + transfer reads from (or None), the static wraps that form the tail under + the add-on, and the add-on levels coarsest first, excluding ``mesh``.""" + from underworld3.utilities.fault_mg import addon_level_indices + tail = getattr(mesh, "_custom_mg_coarse_meshes", None) + if tail is not None: + idx = set(addon_level_indices(tail)) + static = [m for k, m in enumerate(tail) if k not in idx] + addon = [m for k, m in enumerate(tail) if k in idx] + return (static[-1] if static else None), static, addon + # a mesh built with refinement >= 1: the split's rule makes the coarse + # levels below the finest the tail, the finest wrap being this very grid + wraps = (list(mesh._coarse_level_meshes())[:-1] + if len(getattr(mesh, "dm_hierarchy", []) or []) > 1 else []) + return (wraps[-1] if wraps else None), wraps, [] + + +# --------------------------------------------------------------------------- # +# Seam neighbours +# --------------------------------------------------------------------------- # +def _face_other_rank(dm): + """Per local face, the rank holding the cell across the seam, or -1 for a + face interior to this rank's cells. A shared face has one root and one + leaf: the leaf side reads the root's rank off the forest and the root + side gets the leaf's rank by one reduction.""" + fS, fE = dm.getHeightStratum(1) + pStart, pEnd = dm.getChart() + other = np.full(pEnd - pStart, -1, dtype=np.int64) + comm = dm.getComm().tompi4py() + if comm.size == 1: + return other[fS - pStart: fE - pStart] + sf = dm.getPointSF() + try: + nroots, ilocal, iremote = sf.getGraph() + except (ValueError, TypeError): + return other[fS - pStart: fE - pStart] + leaf = np.full(pEnd - pStart, -1, dtype=np.int64) + if ilocal is not None and len(ilocal): + leaf[np.asarray(ilocal)] = comm.rank + other[np.asarray(ilocal)] = np.asarray(iremote)[:, 0] + root = np.full(pEnd - pStart, -1, dtype=np.int64) + sf.reduceBegin(MPI.INT64_T, leaf, root, MPI.MAX) + sf.reduceEnd(MPI.INT64_T, leaf, root, MPI.MAX) + has_leaf = root >= 0 + other[has_leaf] = root[has_leaf] + return other[fS - pStart: fE - pStart] + + +def _unit_adjacency(dm, unit_of_cell, face_other): + """The face adjacency of the units: ``(ua, ub)`` pairs of local units that + share a face, and ``(u, rank)`` pairs of local units with a face on the + seam to ``rank``. Computed once; the assignment changes, the mesh does + not.""" + cS, cE = dm.getHeightStratum(0) + fS, fE = dm.getHeightStratum(1) + inner, seam = [], [] + for f in range(fS, fE): + sup = [int(c) for c in dm.getSupport(f)] + if len(sup) == 2: + ua, ub = unit_of_cell[sup[0] - cS], unit_of_cell[sup[1] - cS] + if ua != ub: + inner.append((ua, ub)) + elif len(sup) == 1 and face_other[f - fS] >= 0: + seam.append((unit_of_cell[sup[0] - cS], face_other[f - fS])) + inner = (np.asarray(inner, dtype=np.int64).reshape(-1, 2)) + seam = (np.asarray(seam, dtype=np.int64).reshape(-1, 2)) + return inner, seam + + +def _units_touching(inner, seam, assign_unit): + """``(unit, rank)`` pairs: every local unit with the ranks it touches + across a face, under the current assignment.""" + a, b = inner[:, 0], inner[:, 1] + pairs = np.concatenate([ + np.column_stack([a, assign_unit[b]]), + np.column_stack([b, assign_unit[a]]), + seam]) + return np.unique(pairs, axis=0) if pairs.size else pairs.reshape(0, 2) + + +# --------------------------------------------------------------------------- # +# Balance by diffusion +# --------------------------------------------------------------------------- # +def _peel_balance(dm, unit_of_cell, assign_unit, weight, unit_region, + unit_centroid, region_centres, tolerance, comm, + max_sweeps=100): + """Balance the load by diffusion over the seams: in each sweep a rank + offers free units (no fault region) across each face it shares with a + lighter neighbour, up to half the difference in load, the cells farthest + from the faults first; a receiver's intake is scaled so it never exceeds + the largest half-gap offered to it. A rank at the mean passes cells on, + which is what carries load around a fault region that walls its owner + off from the lightest rank. Stops when every rank is within + ``tolerance`` of the mean, when nothing can move, or at ``max_sweeps``. + Collective in every sweep. Returns the assignment and the sweep count.""" + size = comm.size + face_other = _face_other_rank(dm) + inner, seam = _unit_adjacency(dm, unit_of_cell, face_other) + far = (np.full(assign_unit.shape[0], np.inf) if region_centres.shape[0] == 0 + else np.min(np.linalg.norm( + unit_centroid[:, None, :] - region_centres[None, :, :], axis=2), + axis=1)) + free = unit_region == 0 + n_sweeps = 0 + for _sweep in range(max_sweeps): + load = _allreduce(comm, np.bincount(assign_unit, weights=weight, + minlength=size)) + mean = load.sum() / size + if load.max() <= mean * (1.0 + tolerance): + break + n_sweeps += 1 + touching = _units_touching(inner, seam, assign_unit) + mine = free & (assign_unit == comm.rank) + touching = touching[mine[touching[:, 0]]] + proposals = {} # receiver -> list of (unit, weight) + gap = np.zeros(size) # my half-gap to each receiver + lighter = np.flatnonzero(load < load[comm.rank] - tolerance * mean) + taken = np.zeros(assign_unit.shape[0], dtype=bool) + for s in lighter[np.argsort(load[lighter], kind="stable")]: + s = int(s) + if s == comm.rank: + continue + cand = touching[touching[:, 1] == s, 0] + cand = cand[~taken[cand]] + if cand.size == 0: + continue + cand = cand[np.lexsort((cand, -far[cand]))] + room = 0.5 * (load[comm.rank] - load[s]) + gap[s] = room + chosen = [] + for u in cand: + if room <= 0: + break + chosen.append((int(u), float(weight[u]))) + room -= weight[u] + taken[u] = True + proposals[s] = chosen + offered = np.zeros(size) + for s, chosen in proposals.items(): + offered[s] = sum(w for _u, w in chosen) + offered_all = _allreduce(comm, offered) + gap_all = _allreduce(comm, gap, op=MPI.MAX) + moved = 0 + for s, chosen in proposals.items(): + scale = (min(1.0, gap_all[s] / offered_all[s]) + if offered_all[s] > 0 else 0.0) + budget = scale * offered[s] + for u, w in chosen: + if budget <= 0: + break + assign_unit[u] = s + budget -= w + moved += 1 + if comm.allreduce(moved) == 0: + break + return assign_unit, n_sweeps + + +# --------------------------------------------------------------------------- # +# Provenance through the move +# --------------------------------------------------------------------------- # +def _resolve_parents(below, lev): + """Give level ``lev`` its parents' CURRENT addresses. + + Every cell of ``lev`` knows its parent on ``below`` as it was before the + move (``_addon_parent_old``: rank, local cell); every cell of ``below`` + knows where it was (``_addon_self_old``). A directory on the old owners + (one reduction) maps old to new, and one broadcast hands each fine cell + its parent's new address. Stored as ``lev._addon_parent`` (n_cells, 2): + rank and local cell on ``below`` as it stands now.""" + comm = lev.dm.getComm() + m4 = comm.tompi4py() + parent_old = lev._addon_parent_old + self_old = getattr(below, "_addon_self_old", None) + if self_old is None: + # the level below did not move: old addresses are current + lev._addon_parent = parent_old.copy() + return + n_old = int(below._addon_n_cells_old) + cS, cE = below.dm.getHeightStratum(0) + unit = MPI.INT64_T.Create_contiguous(2) + unit.Commit() + # directory: old cell -> (new rank, new local) + sf_dir = PETSc.SF().create(comm=comm) + sf_dir.setGraph(n_old, None, np.ascontiguousarray(self_old, dtype=np.int32)) + sf_dir.setUp() + now = np.column_stack([np.full(cE - cS, m4.rank), np.arange(cE - cS)] + ).astype(np.int64) + directory = np.full((n_old, 2), -1, dtype=np.int64) + # exactly one leaf per root, so REPLACE is the reduction (the predefined + # reductions do not take a derived datatype) + sf_dir.reduceBegin(unit, now, directory, MPI.REPLACE) + sf_dir.reduceEnd(unit, now, directory, MPI.REPLACE) + sf_dir.destroy() + # fetch: each fine cell reads its parent's entry + sf_get = PETSc.SF().create(comm=comm) + sf_get.setGraph(n_old, None, + np.ascontiguousarray(parent_old, dtype=np.int32)) + sf_get.setUp() + parent_new = np.full(parent_old.shape, -1, dtype=np.int64) + sf_get.bcastBegin(unit, directory, parent_new, MPI.REPLACE) + sf_get.bcastEnd(unit, directory, parent_new, MPI.REPLACE) + sf_get.destroy() + unit.Free() + if (parent_new < 0).any(): + raise RuntimeError( + "addon_partition internal: a cell's parent was not found after " + "the move.") + lev._addon_parent = parent_new + + +def _moved_wrap(level, dm, fields, pairs): + """A new Mesh over the redistributed ``dm`` of ``level``, carrying the + provenance fields and whatever fault geometry the level had.""" + moved = uw.discretisation.Mesh( + dm, simplex=level.dm.isSimplex(), qdegree=level.qdegree, + coordinate_system_type=level.CoordinateSystem.coordinate_type, + boundaries=level.boundaries, verbose=False) + moved._mg_static = False + for attr in ("_fault_traces", "_fault_specs", "_fault_surfaces", + "_fault_seam_normals"): + if getattr(level, attr, None) is not None: + setattr(moved, attr, getattr(level, attr)) + if pairs is not None: + moved._fault_point_pairs = pairs + parent_rank, parent_local, self_rank, self_local = fields + moved._addon_parent_old = np.column_stack([parent_rank, parent_local]) + moved._addon_self_old = np.column_stack([self_rank, self_local]) + cS, cE = level.dm.getHeightStratum(0) + moved._addon_n_cells_old = cE - cS + return moved + + +# --------------------------------------------------------------------------- # +# The entry point +# --------------------------------------------------------------------------- # +def partition_addon(mesh, region_marks, layers=1, tolerance=0.1, + verbose=False): + """Partition ``mesh`` and its add-on levels for the faults ``region_marks`` + marks. + + Parameters + ---------- + mesh : Mesh + The finest add-on level, as ``adapt`` made it (co-partitioned with + its levels), before any cut, placement or split. + region_marks : callable + ``region_marks(dm)`` returns chart-length region ids on a level's DM: + ``0`` for an unmarked point, ``k >= 1`` the fault region a vertex + belongs to (:func:`mark_polylines`, :func:`mark_sheets`, + :func:`mark_labels`). It is evaluated on EVERY add-on level, because + the same fault's cell star reaches further on a coarser level and the + split replayed there needs its own star rank-interior. Regions whose + stars touch merge. + layers : int + Growth layers beyond the marked vertices' cell star: one makes every + point of a star cell unshared (the split's seam rule); the 3-D carve's + growth rounds need two. + tolerance : float + Balance target: every rank's share of finest cells within this + fraction of the mean, if the seams allow it. + + Returns + ------- + Mesh + A new finest mesh whose add-on tail is moved the same way, with the + provenance of every level recorded (``_addon_parent``); or ``mesh`` + itself when every region is already rank-interior and the load is + within tolerance. Serial: ``mesh``. + """ + from underworld3.utilities.fault_split import redistribute_by_assignment + from underworld3.utilities.place_surface import _merge_regions, _region_cells + + comm = mesh.dm.getComm().tompi4py() + if comm.size == 1: + return mesh + if getattr(mesh, "_addon_partition", None) is not None: + raise NotImplementedError( + "addon_partition: this add-on is already partitioned. Partition " + "once, with every fault of the network in one call.") + + base, static, addon = _levels_of(mesh) + levels = addon + [mesh] + parents = [] + for l, lev in enumerate(levels): + below = base if l == 0 else levels[l - 1] + parents.append(None if below is None else _parent_cells_local(below, lev)) + cS0, cE0 = levels[0].dm.getHeightStratum(0) + n_units = cE0 - cS0 + unit_of = [np.arange(n_units, dtype=np.int64)] + for l in range(1, len(levels)): + unit_of.append(unit_of[l - 1][parents[l]]) + weight = np.bincount(unit_of[-1], minlength=n_units).astype(float) + + # fault regions on every level, lifted to units + unit_region = np.zeros(n_units, dtype=np.int32) + pairs = set() + for l, lev in enumerate(levels): + cell_region, _canon, _regions = _region_cells( + lev.dm, np.asarray(region_marks(lev.dm)), layers=layers) + for c in np.flatnonzero(cell_region): + u = unit_of[l][c] + k = int(cell_region[c]) + if unit_region[u] and unit_region[u] != k: + pairs.add((int(unit_region[u]), k)) + unit_region[u] = max(unit_region[u], k) + n_ids = int(comm.allreduce(int(unit_region.max()) if n_units else 0, + op=MPI.MAX)) + canon = _merge_regions(pairs, n_ids, comm) + for u in np.flatnonzero(unit_region): + unit_region[u] = canon[int(unit_region[u])] + regions = sorted(set(canon.values())) + local = np.array([float(weight[unit_region == k].sum()) for k in regions]) + counts = np.asarray(comm.allgather(local)).reshape(comm.size, len(regions)) + assign = np.full(n_units, comm.rank, dtype=np.int32) + owner = {} + for j, k in enumerate(regions): + col = counts[:, j] + owner[k] = int(np.argmax(col)) + if np.count_nonzero(col) > 1: + assign[unit_region == k] = owner[k] + + # the fault owners shed far cells to their underloaded neighbours + unit_centroid, _v, _cv = _cell_centroids(levels[0]) + centres = [] + for j, k in enumerate(regions): + sel = unit_region == k + s = (unit_centroid[sel] * weight[sel, None]).sum(axis=0) + tot = _allreduce(comm, np.concatenate([s, [weight[sel].sum()]])) + centres.append(tot[:-1] / tot[-1]) + centres = np.asarray(centres).reshape(len(regions), -1) + load_before = _allreduce(comm, np.bincount(assign, weights=weight, + minlength=comm.size)) + assign, n_sweeps = _peel_balance(levels[0].dm, unit_of[0], assign, weight, + unit_region, unit_centroid, centres, + tolerance, comm) + load_after = _allreduce(comm, np.bincount(assign, weights=weight, + minlength=comm.size)) + n_moved_units = int(comm.allreduce(int((assign != comm.rank).sum()))) + if n_moved_units == 0: + if verbose: + uw.pprint(f"[addon_partition] {len(regions)} fault region(s) " + f"already rank-interior, load within tolerance " + f"(max/mean {load_after.max() / load_after.mean():.2f}); " + f"nothing moves") + return mesh + + # apply the one assignment to every level, provenance riding along + moved = [] + for l, lev in enumerate(levels): + cS, cE = lev.dm.getHeightStratum(0) + n = cE - cS + a_level = assign[unit_of[l]] + parent_rank = np.full(n, comm.rank if parents[l] is not None else -1, + dtype=np.int64) + parent_local = (parents[l] if parents[l] is not None + else np.full(n, -1, dtype=np.int64)) + self_rank = np.full(n, comm.rank, dtype=np.int64) + self_local = np.arange(n, dtype=np.int64) + fields = [parent_rank, parent_local, self_rank, self_local] + old_pairs = getattr(lev, "_fault_point_pairs", None) + dm, new_pairs, dropped, new_fields = redistribute_by_assignment( + lev.dm, a_level, pairs=old_pairs, fields=fields) + if dropped: + raise RuntimeError( + f"addon_partition: {dropped} fault pair(s) split across ranks " + f"by the move on level {l}") + # every unit has a cell on every level, so a moved unit moves every + # level: the shell partition never hands the input back here + assert dm is not lev.dm + moved.append(_moved_wrap(lev, dm, new_fields, new_pairs)) + for l, lev in enumerate(moved): + below = base if l == 0 else moved[l - 1] + lev._addon_below = below + if below is None: + lev._addon_parent = None + else: + _resolve_parents(below, lev) + + out = moved[-1] + out.parent = mesh + out._relationship_kind = "refinement" + out._refine_dofs_coincide = False + out.regions = mesh.regions + out._parent_mesh_version = mesh._mesh_version + mesh._registered_children.add(out) + if static or moved[:-1]: + # the tail: the static wraps as they stand (never moved) and the + # add-on levels moved with the finest. A refined base's hierarchy is + # made explicit here, since the moved wrap no longer carries it. + out._custom_mg_coarse_meshes = list(static) + moved[:-1] + out._custom_mg_builder = getattr(mesh, "_custom_mg_builder", "barycentric") + out._custom_mg_fac_zone = getattr(mesh, "_custom_mg_fac_zone", None) + out._addon_partition = { + "regions": len(regions), "owners": owner, + "region_weight": {k: float(counts[:, j].sum()) + for j, k in enumerate(regions)}, + "units_moved": n_moved_units, "sweeps": n_sweeps, + "load_before": load_before.tolist(), "load_after": load_after.tolist(), + } + if verbose: + uw.pprint(f"[addon_partition] {len(regions)} fault region(s) to ranks " + f"{sorted(owner.values())}; {n_moved_units} unit(s) moved " + f"over {len(levels)} level(s); finest cells per rank " + f"max/mean {load_before.max() / load_before.mean():.2f} -> " + f"{load_after.max() / load_after.mean():.2f} after " + f"{n_sweeps} balance sweep(s)") + return out diff --git a/src/underworld3/utilities/custom_mg.py b/src/underworld3/utilities/custom_mg.py index ef28590f8..dc74a8ac2 100644 --- a/src/underworld3/utilities/custom_mg.py +++ b/src/underworld3/utilities/custom_mg.py @@ -1507,6 +1507,24 @@ def build(self, solver): comm = solver.dm.comm from underworld3.utilities import fault_mg for l in range(1, nlev): + # The pair the add-on partition moved apart (the base's finest + # static level against the first add-on level) carries the exact + # parent relation: each fine cell's parent is fetched from its + # rank through a star forest, nothing is located across + # partitions and nothing is all-gathered. Every other add-on + # pair is co-partitioned by the same partition and the + # rank-local builders below find every node where it stands. + pre = (fault_mg.provenance_source(self.level_meshes[l - 1], + self.level_meshes[l]) + if parallel else None) + if pre is not None: + P = fault_mg.provenance_transfer_parallel( + self.level_meshes[l - 1], self.level_meshes[l], pre, degree, + continuous, coords[l - 1], coords[l], maps[l - 1], maps[l], + nc, comm) + self.level_transfer_stats[l] = fault_mg.last_transfer_stats + Ps.append(P) + continue # Split-level multigrid: every pair touching a split level takes # the cell-located transfer. A split COARSE level defeats the # node-cloud builders (they triangulate the coarse nodes, and diff --git a/src/underworld3/utilities/fault_mg.py b/src/underworld3/utilities/fault_mg.py index 1a0c77ad4..706d19988 100644 --- a/src/underworld3/utilities/fault_mg.py +++ b/src/underworld3/utilities/fault_mg.py @@ -359,6 +359,146 @@ def cell_located_transfer_parallel(coarse, fine, degree, continuous, Call = np.concatenate(hit_cols) if len(hit_cols) > 1 else hit_cols[0] # --- assemble owned rows ---------------------------------------------- # + return _assemble_owned_rows(nodes, Wall, Call, l2g_f, fstart, fend, cstart, + cend, ncomp, nb, comm) + + +def locate_in_cells_exhaustive(points, vxy, cv): + """:func:`locate_in_cells`, then every point still outside its candidate is + tested against EVERY cell. On a graded mesh a large cell's centroid can be + farther from a point inside it than many small neighbours' centroids are, + so the nearest-centroid candidates can all miss. Returns ``(cell, lam)``; + a point inside no cell keeps its best candidate with ``lam.min() < 0``.""" + cell, lam = locate_in_cells(points, vxy, cv) + for k in np.flatnonzero(lam.min(axis=1) < -_INSIDE_TOL): + lam_all = _barycentric(np.repeat(points[k][None, :], cv.shape[0], axis=0), + vxy[cv]) + best = int(np.argmax(lam_all.min(axis=1))) + if lam_all[best].min() > lam[k].min(): + cell[k], lam[k] = best, lam_all[best] + return cell, lam + + +# --------------------------------------------------------------------------- # +# The provenance transfer: the one pair the add-on partition moved +# --------------------------------------------------------------------------- # +def provenance_source(coarse, fine): + """The mesh that records, per cell, where its parent on ``coarse`` lives — + ``fine`` itself or an ancestor of it (the add-on level as the add-on + partition left it, before the cut and the split re-represented it; see + ``addon_partition``). ``None`` when the pair carries no such record.""" + m = fine + while m is not None: + if (getattr(m, "_addon_below", None) is coarse + and getattr(m, "_addon_parent", None) is not None): + return m + m = getattr(m, "parent", None) + return None + + +def provenance_transfer_parallel(coarse, fine, pre, degree, continuous, + coarse_coords, fine_coords, coarse_layout, + fine_layout, ncomp, comm): + """The cell-located transfer for a pair the add-on partition moved apart, + built from the recorded parent relation instead of a search. + + ``pre`` is the fine level as the partition left it (``provenance_source``): + every cell of ``pre`` knows the rank and local index of its parent on + ``coarse``. A fine node's cell has a centroid, the centroid lies in one + local cell of ``pre`` (the surgery that made ``fine`` from ``pre`` was + rank-local, and a centroid is never on the fault, so this is the same + side-aware rule as :func:`cell_located_transfer_parallel`), and that + cell's parent is fetched — its node columns and vertex and node + coordinates — through one star forest. Co-resident parents are a local + copy; the moved units' parents come from their ranks. Nothing is + all-gathered. ``coarse_layout`` / ``fine_layout`` are + :class:`custom_mg.LevelLayout`. + """ + m4 = comm.tompi4py() + cn = np.asarray(coarse._cell_node_indices(degree, continuous)) + fn = np.asarray(fine._cell_node_indices(degree, continuous)) + Xc = np.asarray(coarse_coords) + Xf = np.asarray(fine_coords) + vxy_c, cv_c = _cell_vertex_table(coarse) + vxy_f, cv_f = _cell_vertex_table(fine) + vxy_p, cv_p = _cell_vertex_table(pre) + l2g_c = np.asarray(coarse_layout.l2g, dtype=np.int64).reshape(-1, ncomp) + l2g_f = np.asarray(fine_layout.l2g, dtype=np.int64).reshape(-1, ncomp) + fstart, fend = fine_layout.rstart, fine_layout.rend + cstart, cend = coarse_layout.rstart, coarse_layout.rend + n_f = Xf.shape[0] + nb = cn.shape[1] + nv = cv_c.shape[1] + dim = Xc.shape[1] + + owner = _fine_cell_of_node(fn, n_f) + if (owner < 0).any(): + raise RuntimeError("fault_mg: a fine node belongs to no cell") + mine = np.flatnonzero(((l2g_f >= fstart) & (l2g_f < fend)).any(axis=1)) + fcent = vxy_f[cv_f].mean(axis=1) + if mine.size: + pcell, plam = locate_in_cells_exhaustive(fcent[owner[mine]], vxy_p, cv_p) + else: + pcell = np.zeros(0, dtype=np.int64) + plam = np.zeros((0, nv)) + n_out = int(m4.allreduce(int((plam.min(axis=1) < -_INSIDE_TOL).sum()) + if mine.size else 0)) + if n_out: + raise RuntimeError( + f"fault_mg: {n_out} fine node(s) lie in no cell of the level as the " + f"add-on partition left it; the surgery was not rank-local.") + parent = np.asarray(pre._addon_parent, dtype=np.int32)[pcell] # (nm, 2) + + # one fetch per distinct parent: node columns, node coordinates and + # vertex coordinates, packed as doubles (indices exact below 2**53) + uniq, inv = np.unique(parent, axis=0, return_inverse=True) + inv = inv.ravel() + width = nb * ncomp + nb * dim + nv * dim + nc_loc = cv_c.shape[0] + root = np.empty((nc_loc, width)) + root[:, : nb * ncomp] = l2g_c[cn].reshape(nc_loc, -1) + root[:, nb * ncomp: nb * ncomp + nb * dim] = Xc[cn].reshape(nc_loc, -1) + root[:, nb * ncomp + nb * dim:] = vxy_c[cv_c].reshape(nc_loc, -1) + sf = PETSc.SF().create(comm=comm) + sf.setGraph(nc_loc, None, np.ascontiguousarray(uniq, dtype=np.int32)) + sf.setUp() + got = np.zeros((uniq.shape[0], width)) + unit = MPI.DOUBLE.Create_contiguous(width) + unit.Commit() + sf.bcastBegin(unit, root, got, MPI.REPLACE) + sf.bcastEnd(unit, root, got, MPI.REPLACE) + unit.Free() + sf.destroy() + cols_u = np.rint(got[:, : nb * ncomp]).astype(np.int64).reshape(-1, nb, ncomp) + Xn_u = got[:, nb * ncomp: nb * ncomp + nb * dim].reshape(-1, dim) + V_u = got[:, nb * ncomp + nb * dim:].reshape(-1, dim) + # the fetched parents as a virtual coarse mesh: cell k has nodes + # k*nb .. and vertices k*nv .. + nu = uniq.shape[0] + cn_u = np.arange(nu * nb).reshape(nu, nb) + cv_u = np.arange(nu * nv).reshape(nu, nv) + lam = _barycentric(Xf[mine], V_u[cv_u[inv]]) if mine.size else np.zeros((0, nv)) + W = (_node_weights(Xf[mine], inv, lam, Xn_u, cn_u, V_u, cv_u, degree) + if mine.size else np.zeros((0, nb))) + gcols = cols_u[inv] # (nm, nb, ncomp) + + global last_transfer_stats + last_transfer_stats = { + "owned_nodes": int(m4.allreduce(int(mine.size))), + "misses": 0, + "fetched_remote": int(m4.allreduce(int((uniq[:, 0] != m4.rank).sum()))), + "local_coarse_cells": m4.allgather(int(nc_loc)), + "local_fine_cells": m4.allgather(int(cv_f.shape[0])), + } + return _assemble_owned_rows(mine, W, gcols, l2g_f, fstart, fend, cstart, + cend, ncomp, nb, comm) + + +def _assemble_owned_rows(nodes, Wall, Call, l2g_f, fstart, fend, cstart, cend, + ncomp, nb, comm): + """The owned rows of a reduced->reduced MPIAIJ transfer from per-node + weights ``Wall (n, nb)`` on global coarse columns ``Call (n, nb, ncomp)``.""" + m4 = comm.tompi4py() nloc_f = fend - fstart nloc_c = cend - cstart rows_l, cols_l, vals_l = [], [], [] @@ -431,7 +571,10 @@ def split_tail_levels(levels, fault_specs, verbose=False): for k in range(len(levels) - 1, -1, -1): level = levels[k] try: - split = add_fault(level, entries, verbose=verbose, **kwargs) + # the level arrives partitioned with the finest (the add-on + # partition ran once, for every level): no second partition + split = add_fault(level, entries, verbose=verbose, partition=False, + **kwargs) except (ValueError, RuntimeError, NotImplementedError) as exc: warnings.warn( f"fault_mg: the fault(s) could not be carried onto add-on " @@ -444,89 +587,6 @@ def split_tail_levels(levels, fault_specs, verbose=False): return out -def _fault_star_cells(mesh): - """The local cells of every split fault's star on ``mesh``: cells incident to a - vertex of a Plus or Minus facet, plus one growth layer (the same two-layer - rule as the split's own redistribution, so a moved star stays - rank-interior). Returns local cell indices (``cS``-based).""" - dm = mesh.dm - cS, cE = dm.getHeightStratum(0) - fS, fE = dm.getHeightStratum(1) - vS, vE = dm.getDepthStratum(0) - names = [b.name for b in mesh.boundaries] - verts = set() - for name in getattr(mesh, "_fault_point_pairs", {}): - for side in ("Plus", "Minus"): - lab_name = f"{name}{side}" - if lab_name not in names: - continue - lab = dm.getLabel(lab_name) - value = mesh.boundaries[lab_name].value - if lab and lab.getStratumSize(value) > 0: - for f in lab.getStratumIS(value).getIndices(): - if fS <= int(f) < fE: - verts.update(int(q) for q in dm.getTransitiveClosure(int(f))[0] - if vS <= int(q) < vE) - - def star(vs): - cells = set() - for v in vs: - cells.update(int(q) for q in dm.getTransitiveClosure(v, useCone=False)[0] - if cS <= int(q) < cE) - return cells - - cells = star(verts) - ring = set() - for c in cells: - ring.update(int(q) for q in dm.getTransitiveClosure(c)[0] if vS <= int(q) < vE) - cells |= star(ring) - return np.array(sorted(c - cS for c in cells), dtype=np.int64) - - -def fault_star_owner(mesh): - """The rank holding most of the split faults' star on ``mesh`` (collective).""" - comm = mesh.dm.getComm().tompi4py() - counts = np.asarray(comm.allgather(int(_fault_star_cells(mesh).size))) - return int(np.argmax(counts)) - - -def colocate_split_level(level, target): - """Move a split level's fault star (two layers, as the split gathers it) onto - rank ``target`` — the rank holding the FINEST level's star — carrying the - pairing through as labels. The split gathers each level's star onto the - rank already holding most of it, which differs from level to level, and a - transfer between stars on different ranks locates most of the fault-band - nodes remotely (measured: 48 % of the finest nodes at np = 2, 58 % at - np = 8). With the stars on one rank the misses are the band's rim only. - Returns ``level`` itself when nothing moves. Collective.""" - from underworld3.utilities.fault_split import redistribute_by_assignment - - comm = level.dm.getComm().tompi4py() - if comm.size == 1: - return level - cS, cE = level.dm.getHeightStratum(0) - assign = np.full(cE - cS, comm.rank, dtype=np.int32) - assign[_fault_star_cells(level)] = target - dm, pairs, dropped = redistribute_by_assignment( - level.dm, assign, pairs=dict(level._fault_point_pairs)) - if dm is level.dm: - return level - if dropped: - raise RuntimeError( - f"fault_mg: {dropped} pair(s) lost co-locating a level's fault star") - moved = uw.discretisation.Mesh( - dm, simplex=level.dm.isSimplex(), qdegree=level.qdegree, - coordinate_system_type=level.CoordinateSystem.coordinate_type, - boundaries=level.boundaries, verbose=False) - moved._fault_point_pairs = pairs - for attr in ("_fault_traces", "_fault_specs", "_fault_surfaces", "_fault_seam_normals"): - if getattr(level, attr, None) is not None: - setattr(moved, attr, getattr(level, attr)) - moved._custom_mg_coarse_meshes = None - moved._mg_static = False - return moved - - # --------------------------------------------------------------------------- # # Per-level pair rotation # --------------------------------------------------------------------------- # diff --git a/src/underworld3/utilities/fault_split.py b/src/underworld3/utilities/fault_split.py index bdfd333a7..c2322096a 100644 --- a/src/underworld3/utilities/fault_split.py +++ b/src/underworld3/utilities/fault_split.py @@ -1598,7 +1598,7 @@ def _label_embedded_edges(dm, segments, values, exclude=None, verbose=False, def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, - blind=1, across_seams=False, **place_kwargs): + blind=1, across_seams=False, partition=True, **place_kwargs): """Cut AND split one or more faults into a mesh; return the split Mesh. The one-call form of the split-node pipeline: for each fault, the tips @@ -1648,6 +1648,12 @@ def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, split through it — a chain end on the seam is a crossing, not a clipped end, so it is neither blinded nor pinned; nothing is redistributed. See :func:`split_fault`. + partition : bool, optional + In parallel, partition the add-on hierarchy for these faults BEFORE + the cut (``addon_partition.partition_addon``: every fault's cell star + rank-interior on every add-on level, the load balanced, nothing moved + by the cut or the split afterwards). ``False`` for a level that is + already partitioned — the replay of the faults on the add-on levels. **place_kwargs 3-D only: options of :func:`place_surface.place_sheet` (``clearance``, ``size``, ``setback``). A bisection-refined mesh needs ``clearance`` @@ -1673,7 +1679,8 @@ def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, faults = [faults] if mesh.dim == 3: - return _add_fault_sheets(mesh, faults, verbose=verbose, **place_kwargs) + return _add_fault_sheets(mesh, faults, verbose=verbose, + partition=partition, **place_kwargs) if place_kwargs: raise TypeError(f"add_fault: {sorted(place_kwargs)} apply to 3-D sheets only") @@ -1709,6 +1716,18 @@ def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, candidate += 1 members[name] = values[name] = candidate + # The add-on is partitioned ONCE for the whole network, before the cut: + # each fault's cell star rank-interior on this level and on every add-on + # level below it (the replay needs its own star), the load balanced. The + # cut and the splits then move nothing. A level of the replay arrives + # partitioned (partition=False); the band meshed through the seams + # (across_seams) is split in place by design. + if uw.mpi.size > 1 and cut and partition and not across_seams: + from underworld3.utilities.addon_partition import (mark_polylines, + partition_addon) + mesh = partition_addon(mesh, mark_polylines([poly for _n, poly in segments]), + layers=1, verbose=verbose) + dm = mesh.dm if cut: for name, poly in segments: @@ -1727,17 +1746,11 @@ def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, verbose=verbose, blind=blind, across_seams=across_seams) - # In parallel the balanced partition's cuts are attracted to the refined - # fault band, so a cut chain generally touches the seam. Redistribute - # ONCE, keyed on EVERY fault's facets together, BEFORE any split: each - # fault's cell star (plus one growth layer) moves to the rank that - # already owns most of the union, the chains become rank-interior, and - # the splits below run with serial topology. Doing it here rather than - # per split also keeps a network's prior pairings valid — a pairing - # cannot yet migrate through a redistribution, so split_fault refuses - # exactly the per-split case this pre-pass avoids. Label-only faults - # never touch the seam (the placement clipped the band there); one - # that does is a placement defect and is refused, collectively. + # The split needs every chain rank-interior. The add-on partition above + # made it so (and made it so on every add-on level), so a chain touching + # the seam here is a defect of the marking, not a case to redistribute. + # Label-only faults never touch the seam (the placement clipped the band + # there); one that does is a placement defect. Both refusals collective. if uw.mpi.size > 1 and dm.getDimension() == 2 and not across_seams: labels = [(name, values[name]) for name, _poly in segments] if _fault_labels_touch_seam(dm, labels): @@ -1746,7 +1759,10 @@ def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, "add_fault(cut=False): a fault edge touches the " "partition seam. The embedded spines are rank-interior " "only when the band was placed with seams='ligament'.") - dm = _redistribute_fault_interior(dm, labels, verbose=verbose) + raise RuntimeError( + "add_fault: a cut chain touches the partition seam after the " + "add-on partition; the marking under-reached " + "(addon_partition.mark_polylines).") cut = Mesh(dm, simplex=mesh.dm.isSimplex(), coordinate_system_type=mesh.CoordinateSystem.coordinate_type, @@ -1766,8 +1782,13 @@ def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, # split too. Without this every solver on an add_fault child of a refined # base fell back to algebraic multigrid (measured 2026-10-17: 2-D box, # refinement=2, velocity KSP at its 200-iteration cap). - if (getattr(mesh, "_custom_mg_coarse_meshes", None) is None - and len(getattr(mesh, "dm_hierarchy", []) or []) > 1): + own_tail = getattr(mesh, "_custom_mg_coarse_meshes", None) + if own_tail is not None: + cut._custom_mg_coarse_meshes = list(own_tail) + cut._custom_mg_builder = getattr(mesh, "_custom_mg_builder", + "barycentric") + cut._custom_mg_fac_zone = None + elif len(getattr(mesh, "dm_hierarchy", []) or []) > 1: cut._custom_mg_coarse_meshes = list(mesh._coarse_level_meshes()[:-1]) cut._custom_mg_builder = getattr(mesh, "_custom_mg_builder", "barycentric") @@ -1795,15 +1816,17 @@ def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, return out -def _add_fault_sheets(mesh, faults, verbose=False, **place_kwargs): +def _add_fault_sheets(mesh, faults, verbose=False, partition=True, + **place_kwargs): """The 3-D form of :func:`add_fault`: each fault is a triangulated sheet (a ``FaultSurface`` or a ``(name, points, triangles)`` triple), placed into the mesh with :func:`place_surface.place_sheet` so its triangles become - interior faces, then every sheet is split (:func:`split_faults`, which - redistributes the network's cell stars rank-interior first). Like the 2-D - cut, the placed mesh carries NO multigrid tail of its own: the caller - (``Mesh.add_fault``) hands the parent's tail down, with its add-on levels - split the same way.""" + interior faces, then every sheet is split (:func:`split_faults`). In + parallel the add-on is partitioned for the sheets first (the placement's + own mask with two growth layers, on every add-on level), so the placement + gathers nothing and the split moves nothing. The placed mesh takes the + parent's multigrid tail; the caller (``Mesh.add_fault``) splits its add-on + levels the same way.""" from underworld3.discretisation import Mesh from underworld3.utilities.place_surface import place_sheet @@ -1820,6 +1843,13 @@ def _add_fault_sheets(mesh, faults, verbose=False, **place_kwargs): sheets.append((name, np.asarray(points, dtype=float), np.asarray(triangles, dtype=np.int64))) + if uw.mpi.size > 1 and partition: + from underworld3.utilities.addon_partition import (mark_sheets, + partition_addon) + mesh = partition_addon( + mesh, mark_sheets([(p, t) for _n, p, t in sheets], + place_kwargs.get("clearance", 0.6)), + layers=2, verbose=verbose) cut = mesh for name, points, triangles in sheets: boundaries = cut._boundaries_with(name) @@ -1835,6 +1865,12 @@ def _add_fault_sheets(mesh, faults, verbose=False, **place_kwargs): child.regions = cut.regions child._parent_mesh_version = cut._mesh_version child._surface_info = info + own_tail = getattr(cut, "_custom_mg_coarse_meshes", None) + if own_tail is not None: + child._custom_mg_coarse_meshes = list(own_tail) + child._custom_mg_builder = getattr(cut, "_custom_mg_builder", + "barycentric") + child._custom_mg_fac_zone = None cut._registered_children.add(child) cut = child out = split_faults(cut, [name for name, _p, _t in sheets], verbose=verbose) @@ -2056,32 +2092,61 @@ def points_by_value(lab_name): return out, n_dropped -def redistribute_by_assignment(dm, assign, pairs=None): +def redistribute_by_assignment(dm, assign, pairs=None, fields=None): """Redistribute ``dm`` so local cell ``k`` goes to rank ``assign[k]``. - Returns ``(new_dm, new_pairs, n_dropped)``: the pairing ``pairs`` (a - mesh's ``_fault_point_pairs``, or None) carried through the move with - :func:`stamp_point_pairs` / :func:`collect_point_pairs`. Nothing moves when - every cell is already where it is asked to be (the input comes back). The - shell-partitioner idiom the fault-interior move and the level co-location - both use.""" + Returns ``(new_dm, new_pairs, n_dropped, new_fields)``: the pairing + ``pairs`` (a mesh's ``_fault_point_pairs``, or None) carried through the + move with :func:`stamp_point_pairs` / :func:`collect_point_pairs`, and the + per-cell integer ``fields`` (a list of arrays over the local cells, or + None) carried through ``distributeField`` on the migration star forest, + each returned over the new local cells. Nothing moves when every cell is + already where it is asked to be (the input comes back). The + shell-partitioner idiom of the fault-interior move and the add-on + partition.""" comm = dm.getComm().tompi4py() assign = np.asarray(assign, dtype=np.int32) if comm.size == 1 or not comm.allreduce(int((assign != comm.rank).sum())): - return dm, pairs, 0 + return dm, pairs, 0, fields work = dm.clone() names = stamp_point_pairs(work, pairs) if pairs else [] + cS, cE = work.getHeightStratum(0) + sec = vec = None + if fields: + # one cell-wise section, the fields interleaved; integers travel as + # doubles, exact below 2**53 + nf = len(fields) + sec = PETSc.Section().create(comm=work.getComm()) + sec.setChart(*work.getChart()) + for c in range(cS, cE): + sec.setDof(c, nf) + sec.setUp() + vec = PETSc.Vec().createSeq(sec.getStorageSize(), comm=PETSc.COMM_SELF) + arr = vec.getArray() + for c in range(cS, cE): + o = sec.getOffset(c) + for j, f in enumerate(fields): + arr[o + j] = float(f[c - cS]) + vec.setArray(arr) part = work.getPartitioner() part.setType(PETSc.Partitioner.Type.SHELL) order = np.argsort(assign, kind="stable").astype(np.int32) sizes = np.bincount(assign, minlength=comm.size).astype(np.int32) part.setShellPartition(comm.size, sizes=sizes, points=order) - work.distribute() + sf = work.distribute() + new_fields = None + if fields: + newsec, newvec = work.distributeField(sf, sec, vec) + ncS, ncE = work.getHeightStratum(0) + narr = newvec.getArray() + offs = np.array([newsec.getOffset(c) for c in range(ncS, ncE)], dtype=np.int64) + new_fields = [np.rint(narr[offs + j]).astype(np.int64) if offs.size + else np.zeros(0, dtype=np.int64) for j in range(nf)] new_pairs, dropped = (collect_point_pairs(work, names) if pairs else (None, 0)) for lab in [l for n in names for l in _pair_label_names(n)]: if dm.hasLabel(lab): dm.removeLabel(lab) - return work, new_pairs, dropped + return work, new_pairs, dropped, new_fields def split_faults(mesh, names, verbose=False, groups=None, @@ -2121,20 +2186,13 @@ def split_faults(mesh, names, verbose=False, groups=None, if rest: label_groups.append([(n, value[n]) for n in rest]) if _fault_labels_touch_seam(mesh.dm, labels): - dm = _redistribute_fault_interior(mesh.dm, labels, - verbose=verbose, - groups=label_groups) - out = Mesh(dm, simplex=mesh.dm.isSimplex(), - coordinate_system_type=( - mesh.CoordinateSystem.coordinate_type), - qdegree=mesh.qdegree, boundaries=mesh.boundaries, - verbose=False) - out.parent = mesh - out._relationship_kind = "refinement" - out._refine_dofs_coincide = False - out.regions = mesh.regions - out._parent_mesh_version = mesh._mesh_version - mesh._registered_children.add(out) + # the add-on partition: every star rank-interior on every level, + # the pairing of any prior fault carried through the move + from underworld3.utilities.addon_partition import (mark_labels, + partition_addon) + out = partition_addon(mesh, mark_labels(labels, label_groups), + layers=1, verbose=verbose) + mesh = out for n in names: out = split_fault(out, n, verbose=verbose, across_seams=across_seams) # The network's child INHERITS a mesh-owned geometric-MG tail — the @@ -2268,20 +2326,13 @@ def split_fault(mesh, name, orientation=None, verbose=False, # union redistribution make every subsequent per-fault split a # no-move here. Both branches of the decision are collective. if _fault_labels_touch_seam(source_dm, labels): - if getattr(mesh, "_fault_point_pairs", {}): - # a prior fault's pairing holds point ids of the CURRENT - # distribution; redistribution renumbers every point, so - # carrying it through needs pairing migration along the - # migration SF — not built yet. Refuse loudly (and - # identically on every rank: the pairing dict and the - # seam verdict agree across ranks by construction). - raise NotImplementedError( - "fault_split: multi-fault networks in parallel need " - "pairing migration through the redistribution — pass " - "the whole network to ONE add_fault call (2-D), or " - "split the network in serial.") - source_dm = _redistribute_fault_interior( - source_dm, labels, verbose=verbose) + # the add-on partition: the star rank-interior on every level, + # a prior fault's pairing carried through the move as labels + from underworld3.utilities.addon_partition import (mark_labels, + partition_addon) + mesh = partition_addon(mesh, mark_labels(labels), layers=1, + verbose=verbose) + source_dm = mesh.dm splitter = (split_along_label if source_dm.getDimension() == 2 else split_along_label_3d) value = int(mesh.boundaries[name].value) diff --git a/src/underworld3/utilities/place_surface.py b/src/underworld3/utilities/place_surface.py index 5523f016e..415eab5e2 100644 --- a/src/underworld3/utilities/place_surface.py +++ b/src/underworld3/utilities/place_surface.py @@ -1671,59 +1671,46 @@ def _gather_region(dm, vertex_mark_chart, verbose=False): return work, n_region -def _gather_regions(dm, vertex_region_chart, verbose=False, layers=1): - """Redistribute so each marked REGION's cell star (+ ``layers``) is one rank's. +def _merge_regions(pairs, n_ids, comm): + """Union-find over the gathered set of touching region ``pairs`` — + identical on every rank. Returns ``canon`` mapping every region id in + ``1..n_ids`` to its merged id. Collective.""" + parent = list(range(n_ids + 1)) - A mask-driven port of the contact stream's ``_redistribute_fault_interior`` - (feature/fault-split-node c8693579 / 1d487319; the label-driven original - is measured at np=2..8 with serial-identical topology), generalised to - several regions (#670). ``vertex_region_chart`` is chart-length: ``0`` - for an unmarked point, ``k >= 1`` the region a vertex belongs to. Each - region's star and layer go to ONE rank, chosen per region — the rank - that already holds most of it — and a region that is already interior - to one rank is left where it is. Two regions whose stars or layers - touch (share a cell or a vertex, on any rank) are merged: their - cavities would share ring points, so they must be one rank's. Only the - marked stars move — everything else keeps its load-balanced home — via - one shell partition. + def find(a): + while parent[a] != a: + parent[a] = parent[parent[a]] + a = parent[a] + return a - ``layers`` is the growth beyond the star, one by default: the star is - the marked vertices' cells, and the layer makes every point in the - closure of a star cell unshared. Both the placement and the split - need it. Measured (#670): with ``layers=0`` the placement's own gate - ("the gathered region touches a shared point") fires at np=2, 3 and - 4 on the thin-volume suite — the carve drops cells beyond the marked - vertices' star, so the star alone under-reaches. + for a, b in sorted(set().union(*comm.allgather(pairs))): + ra, rb = find(a), find(b) + if ra != rb: + parent[max(ra, rb)] = min(ra, rb) + return {k: find(k) for k in range(1, n_ids + 1)} - Returns ``(new_dm, n_region, n_moved, owner, canon)``: the global size - of the regions (star and layer — the seam rule's footprint, a function - of the mesh alone), the count of cells that changed rank (``0`` when - none did; the input dm is then returned untouched), ``owner`` mapping - each merged region's id to its rank, and ``canon`` mapping every input - region id to its merged id. The decisions are COLLECTIVE: overlaps and - counts are gathered before any branch. - """ + +def _region_cells(dm, vertex_region_chart, layers=1): + """The cells of each marked region: the marked vertices' cell star grown by + ``layers``, with touching regions merged (the marking rule of + :func:`_gather_regions`, which applies it; :func:`addon_partition.partition_addon` + lifts it to its units). Returns ``(cell_region, canon, regions)``: the + merged region id per local cell (``0`` outside every region), the id map + and the sorted list of merged ids. Collective; the dm is read, not changed.""" comm = dm.getComm().tompi4py() ids_in = np.asarray(vertex_region_chart, dtype=np.int32) n_ids = int(comm.allreduce(int(ids_in.max()) if ids_in.size else 0, op=MPI.MAX)) - if n_ids == 0: - raise ValueError("place_sheet: the sheet meets no cell on any rank") - if comm.size == 1: - canon = {k: k for k in range(1, n_ids + 1)} - return dm, 0, 0, {k: 0 for k in canon}, canon - - work = dm.clone() - cS, cE = work.getHeightStratum(0) - vS, vE = work.getDepthStratum(0) - pStart, pEnd = work.getChart() + cS, cE = dm.getHeightStratum(0) + vS, vE = dm.getDepthStratum(0) + pStart, pEnd = dm.getChart() pairs = set() # (a, b): regions that touch, to be merged def reconcile(chart): # every rank agrees on every point it can see; where a rank's own # id loses to a neighbour's under MAX the two regions touch before = chart.copy() - after = _propagate_vertex(work, chart, MPI.MAX, np.maximum) + after = _propagate_vertex(dm, chart, MPI.MAX, np.maximum) touch = (before > 0) & (after != before) for a, b in zip(before[touch], after[touch]): pairs.add((int(a), int(b))) @@ -1736,7 +1723,7 @@ def claim_cells(chart): k = int(chart[v - pStart]) if k == 0: continue - for q in work.getTransitiveClosure(v, useCone=False)[0]: + for q in dm.getTransitiveClosure(v, useCone=False)[0]: if cS <= int(q) < cE: c = int(q) - cS if cell_region[c] and cell_region[c] != k: @@ -1751,7 +1738,7 @@ def claim_cells(chart): layer = np.zeros(pEnd - pStart, dtype=np.int32) for c in np.flatnonzero(cell_region): k = int(cell_region[c]) - for q in work.getTransitiveClosure(int(c) + cS)[0]: + for q in dm.getTransitiveClosure(int(c) + cS)[0]: if vS <= int(q) < vE: i = int(q) - pStart if layer[i] and layer[i] != k: @@ -1759,25 +1746,57 @@ def claim_cells(chart): layer[i] = max(layer[i], k) claim_cells(reconcile(layer)) - # merge touching regions: union-find on the gathered pair set, the same - # on every rank - parent = list(range(n_ids + 1)) - - def find(a): - while parent[a] != a: - parent[a] = parent[parent[a]] - a = parent[a] - return a - - for a, b in sorted(set().union(*comm.allgather(pairs))): - ra, rb = find(a), find(b) - if ra != rb: - parent[max(ra, rb)] = min(ra, rb) - canon = {k: find(k) for k in range(1, n_ids + 1)} + canon = _merge_regions(pairs, n_ids, comm) for c in np.flatnonzero(cell_region): cell_region[c] = canon[int(cell_region[c])] + return cell_region, canon, sorted(set(canon.values())) + + +def _gather_regions(dm, vertex_region_chart, verbose=False, layers=1): + """Redistribute so each marked REGION's cell star (+ ``layers``) is one rank's. + + A mask-driven port of the contact stream's ``_redistribute_fault_interior`` + (feature/fault-split-node c8693579 / 1d487319; the label-driven original + is measured at np=2..8 with serial-identical topology), generalised to + several regions (#670). ``vertex_region_chart`` is chart-length: ``0`` + for an unmarked point, ``k >= 1`` the region a vertex belongs to. Each + region's star and layer go to ONE rank, chosen per region — the rank + that already holds most of it — and a region that is already interior + to one rank is left where it is. Two regions whose stars or layers + touch (share a cell or a vertex, on any rank) are merged: their + cavities would share ring points, so they must be one rank's. Only the + marked stars move — everything else keeps its load-balanced home — via + one shell partition. + + ``layers`` is the growth beyond the star, one by default: the star is + the marked vertices' cells, and the layer makes every point in the + closure of a star cell unshared. Both the placement and the split + need it. Measured (#670): with ``layers=0`` the placement's own gate + ("the gathered region touches a shared point") fires at np=2, 3 and + 4 on the thin-volume suite — the carve drops cells beyond the marked + vertices' star, so the star alone under-reaches. - regions = sorted(set(canon.values())) + Returns ``(new_dm, n_region, n_moved, owner, canon)``: the global size + of the regions (star and layer — the seam rule's footprint, a function + of the mesh alone), the count of cells that changed rank (``0`` when + none did; the input dm is then returned untouched), ``owner`` mapping + each merged region's id to its rank, and ``canon`` mapping every input + region id to its merged id. The decisions are COLLECTIVE: overlaps and + counts are gathered before any branch. + """ + comm = dm.getComm().tompi4py() + ids_in = np.asarray(vertex_region_chart, dtype=np.int32) + n_ids = int(comm.allreduce(int(ids_in.max()) if ids_in.size else 0, + op=MPI.MAX)) + if n_ids == 0: + raise ValueError("place_sheet: the sheet meets no cell on any rank") + if comm.size == 1: + canon = {k: k for k in range(1, n_ids + 1)} + return dm, 0, 0, {k: 0 for k in canon}, canon + + work = dm.clone() + cS, cE = work.getHeightStratum(0) + cell_region, canon, regions = _region_cells(work, ids_in, layers=layers) local = np.array([int((cell_region == k).sum()) for k in regions], dtype=np.int64) counts = np.asarray(comm.allgather(local)) # (size, n_regions) diff --git a/tests/parallel/test_1096_addon_partition_parallel.py b/tests/parallel/test_1096_addon_partition_parallel.py new file mode 100644 index 000000000..ef8c2ec55 --- /dev/null +++ b/tests/parallel/test_1096_addon_partition_parallel.py @@ -0,0 +1,179 @@ +"""The add-on is partitioned once, before any surgery, as one hierarchy. + +``partition_addon`` computes one assignment on the finest add-on level — the +fault regions glued and sent to their majority rank, the load balanced by +diffusion over the seams — and applies it to every add-on level through the +parent-cell relation, with provenance riding the move. The contracts asserted +here are the ones the fault machinery and the multigrid transfers build on +(design note ``docs/developer/design/parallel-addon-distribution-2026-10.md``): + +* every fault region (the trace's cell star plus two growth layers) lies on + one rank, so the split's pairs and fans are rank-local by construction; +* every add-on pair is co-partitioned exactly: each fine cell's parent cell on + the level below is local; +* every cell's recorded parent, fetched by its current address, contains the + cell's centroid (the relation is exact, on the base boundary too); +* the finest cells per rank are within the balance tolerance of the mean, or + as close as the seams allow (the diffusion stops when nothing can move). + +Then ``add_fault`` on the partitioned add-on: no level is moved by the split, +the split-level transfers report no cross-rank miss, and the solve takes about +as many velocity iterations as in serial. +""" +import numpy as np +import pytest +from mpi4py import MPI +from petsc4py import PETSc + +import underworld3 as uw +from underworld3.utilities import addon_partition as ap +from underworld3.utilities.fault_mg import (_barycentric, _cell_vertex_table, + addon_level_indices) +from underworld3.utilities.place_surface import _region_cells + +pytestmark = [pytest.mark.mpi(min_size=2), pytest.mark.level_2, pytest.mark.tier_b, + pytest.mark.timeout(900)] + +comm = MPI.COMM_WORLD +TIP_A, TIP_B = np.array([0.30, 0.45]), np.array([0.70, 0.55]) +TRACE = np.vstack([TIP_A, TIP_B]) +H_BASE = 0.05 +K = 2 +TOLERANCE = 0.1 + + +def _adapted(): + base = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, + qdegree=2) + pts = np.column_stack([np.linspace(TIP_A[0], TIP_B[0], 9), + np.linspace(TIP_A[1], TIP_B[1], 9), np.zeros(9)]) + guide = uw.meshing.Surface("guide", base, pts) + guide.discretize() + return base.adapt(guide.refinement_metric_function( + h_near=H_BASE / 2**K, h_far=H_BASE, width=0.08, profile="linear"), + max_levels=K) + + +def _cells(mesh): + cS, cE = mesh.dm.getHeightStratum(0) + return cE - cS + + +def _levels(mesh): + """``(static, addon_levels_including_mesh)`` of a mesh-owned tail.""" + tail = mesh._custom_mg_coarse_meshes + idx = addon_level_indices(tail) + static = [tail[i] for i in range(len(tail)) if i not in idx] + return static, [tail[i] for i in idx] + [mesh] + + +def _parents_contain_centroids(below, lev): + """Fetch each cell's parent (by its CURRENT address) and test containment. + Returns the global count of cells NOT inside their parent.""" + par = lev._addon_parent + vxy_b, cv_b = _cell_vertex_table(below) + nb = cv_b.shape[0] + verts = np.ascontiguousarray(vxy_b[cv_b].reshape(nb, -1)) + sf = PETSc.SF().create(comm=PETSc.COMM_WORLD) + sf.setGraph(nb, None, np.ascontiguousarray(par, dtype=np.int32)) + sf.setUp() + got = np.zeros((par.shape[0], verts.shape[1])) + unit = MPI.DOUBLE.Create_contiguous(verts.shape[1]) + unit.Commit() + sf.bcastBegin(unit, verts, got, MPI.REPLACE) + sf.bcastEnd(unit, verts, got, MPI.REPLACE) + unit.Free() + sf.destroy() + fcent, _v, _c = ap._cell_centroids(lev) + lam = _barycentric(fcent, got.reshape(par.shape[0], cv_b.shape[1], -1)) + return comm.allreduce(int((lam.min(axis=1) < -1e-9).sum())) + + +def test_partition_invariants_on_the_adapt_child(): + child = _adapted() + marks = ap.mark_polylines([TRACE]) + out = ap.partition_addon(child, marks, layers=1, tolerance=TOLERANCE) + assert out is not child + record = out._addon_partition + assert record["regions"] == 1 + + # the fault region is rank-interior on the new finest AND on every add-on + # level (the replay splits each level on its own star) + static, levels = _levels(out) + for lev in levels: + cell_region, _canon, regions = _region_cells(lev.dm, marks(lev.dm), layers=1) + counts = np.asarray(comm.allgather( + [int((cell_region == r).sum()) for r in regions])) + assert all(np.count_nonzero(counts[:, j]) == 1 for j in range(len(regions))) + + # add-on pairs are co-partitioned: parents local, provenance exact + assert len(levels) == len(_levels(child)[1]) + for l in range(1, len(levels)): + ap._parent_cells_local(levels[l - 1], levels[l]) # raises if not + assert _parents_contain_centroids(levels[l - 1], levels[l]) == 0 + assert _parents_contain_centroids(static[-1], levels[0]) == 0 + # a cell of the coarsest add-on level has a remote parent exactly when + # its unit moved + remote = comm.allreduce(int((levels[0]._addon_parent[:, 0] != comm.rank).sum())) + assert remote == record["units_moved"] + + # the finest cells are shared out: within tolerance of the mean, or down + # to the one indivisible fault region + load = np.asarray(comm.allgather(_cells(out)), dtype=float) + mean = load.mean() + region = max(record["region_weight"].values()) + assert load.max() <= max(mean * (1.0 + TOLERANCE), region) + TOLERANCE * mean, \ + (load, record) + # the total is conserved on every level + for a, b in zip(_levels(child)[1], levels): + assert comm.allreduce(_cells(a)) == comm.allreduce(_cells(b)) + + +def test_partition_runs_once(): + child = _adapted() + out = ap.partition_addon(child, ap.mark_polylines([TRACE]), layers=1) + with pytest.raises(NotImplementedError): + ap.partition_addon(out, ap.mark_polylines([TRACE])) + + +def _solve(mesh): + x, y = mesh.X + stokes = uw.systems.Stokes(mesh) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 + stokes.tolerance = 1.0e-6 + stokes.petsc_use_pressure_nullspace = True + for side in ("Top", "Bottom", "Left", "Right"): + stokes.add_dirichlet_bc((y - 0.5, 0.0), side) + stokes.add_fault_bc(0, "Flt") + stokes.solve() + return stokes + + +def test_add_fault_moves_nothing_after_the_partition_and_converges(): + """``add_fault`` on the adapt child partitions once and then cuts and + splits in place: the cut child's parent is the partitioned mesh, every + split level's pairs are whole, the split-level transfers report no + cross-rank miss, and the solve converges in about the serial count.""" + from underworld3.utilities.fault_split import _fault_labels_touch_seam + child = _adapted() + out = child.add_fault([("Flt", TRACE)]) + partitioned = out.parent.parent # split <- cut <- partitioned + assert partitioned._addon_partition is not None + static, levels = _levels(out) + for lev in levels: + assert getattr(lev, "_fault_point_pairs", None), "a level was not split" + labels = [(f"Flt{side}", int(lev.boundaries[f"Flt{side}"].value)) + for side in ("Plus", "Minus")] + assert not _fault_labels_touch_seam(lev.dm, labels) + stokes = _solve(out) + info = stokes._rotated_freeslip_info + assert info["velocity_pc"] == "custom-FMG" and info["converged"], info + assert info["vel_its_last"][-1] <= 9, info["vel_its_last"] + stats = stokes._custom_mg["hierarchy"].level_transfer_stats + # pairs between add-on levels (and into the finest) are co-partitioned: + # the cell-located transfer finds every node locally + n_addon = len(levels) + for st in stats[-(n_addon - 1):] if n_addon > 1 else []: + assert st is not None and st["misses"] == 0, st From 293bf8ddcf8560748574ea7090c9271918f6895c Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 15:20:39 +1100 Subject: [PATCH 47/59] add_conforming_sheet partitions the add-on before placing the sheet The 3-D placement gathered the sheet's region itself with a mask that did not cover the cavity's growth rounds, so on a partitioned adapt child the carve touched a shared point and the surgery rank refused where the same placement passes serially. The Mesh API now partitions the add-on for the sheet first (the placement's mask with two growth layers, on every add-on level), the gather finds the region rank-interior and moves nothing, and the split that follows moves nothing either. _gather_region returned the region size where its callers and its docstring expected the moved count. Test: tests/parallel/test_1097 (the disc on an NVB child at np 2 and 4: nothing gathered, pairs whole, the fault solve on the geometric tail with no transfer miss), shown refusing with 'the gather mask under-reached' on the old path. Docs: the design note's measurements, the fault-split-multigrid Parallel section, the authority map row. Underworld development team with AI support from Claude Code --- .../parallel-addon-distribution-2026-10.md | 14 +++ docs/developer/index.md | 1 + .../subsystems/fault-split-multigrid.md | 43 ++++--- .../discretisation/discretisation_mesh.py | 18 ++- src/underworld3/utilities/fault_split.py | 13 +- src/underworld3/utilities/place_surface.py | 24 ++-- ..._place_sheet_partitioned_addon_parallel.py | 111 ++++++++++++++++++ 7 files changed, 191 insertions(+), 33 deletions(-) create mode 100644 tests/parallel/test_1097_place_sheet_partitioned_addon_parallel.py diff --git a/docs/developer/design/parallel-addon-distribution-2026-10.md b/docs/developer/design/parallel-addon-distribution-2026-10.md index e7a1650de..be580d519 100644 --- a/docs/developer/design/parallel-addon-distribution-2026-10.md +++ b/docs/developer/design/parallel-addon-distribution-2026-10.md @@ -65,6 +65,20 @@ forest "maps points to the wrong depth" came from reading remote point numbers against the local chart; the forest is a point map, and `distributeField` is the API for riding it. +## Measured after (same fixtures, the partition in place) + +Refined patch box, split: velocity iterations 5 / 6 / 6 at np 2 / 4 / 8 (4 in +serial), wall 65 / 34 / 25 s against 72 / 38 / 30 s before. Adapt-on-top fault, 2-D, +np 2: finest cells per rank 742 / 618 (max / mean 1.09, from 1.38); every add-on pair +co-partitioned, every recorded parent contains its cell; the base-to-add-on pair +fetched, no query. At np 4 and 8 on that 1360-cell fixture the fault region itself +(544 cells with its growth layers, lifted to units) exceeds the mean load, so the +owner keeps it whole and the balance stops there: a region is the indivisible unit, +as the September note's granularity remark says, and the test bounds the load by +the region's weight. The 3-D sheet placement on a partitioned adapt child, refused +before ("the gather mask under-reached" at np 2 on the disc fixture of test_0867), +places with nothing gathered and splits with nothing moved (test_1097). + ## The decision **One assignment for the whole add-on, computed on the finest level before any diff --git a/docs/developer/index.md b/docs/developer/index.md index b9a5aedc5..07aea810b 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -40,6 +40,7 @@ same topic are reference or historical material subordinate to the governing doc | Thin sheet (plane stress, sphere, faults) | [subsystems/thin-sheet.md](subsystems/thin-sheet.md) | | Fault multigrid (split-level hierarchy, parallel pairing) | [subsystems/fault-split-multigrid.md](subsystems/fault-split-multigrid.md) | | Fault networks in parallel (placement, split, tail) | [design/fault-parallel-placement-2026-09.md](design/fault-parallel-placement-2026-09.md) (subsystem reference: [conforming-surfaces-and-fault-zones.md](subsystems/conforming-surfaces-and-fault-zones.md)) | +| The add-on's partition in parallel (one partition for the add-on hierarchy) | [design/parallel-addon-distribution-2026-10.md](design/parallel-addon-distribution-2026-10.md) | | Analytic & benchmark solutions | [subsystems/analytic-solutions.md](subsystems/analytic-solutions.md) | | Units | [design/UNITS_SIMPLIFIED_DESIGN_2025-11.md](design/UNITS_SIMPLIFIED_DESIGN_2025-11.md) | | Testing tiers | [TESTING-RELIABILITY-SYSTEM.md](TESTING-RELIABILITY-SYSTEM.md) | diff --git a/docs/developer/subsystems/fault-split-multigrid.md b/docs/developer/subsystems/fault-split-multigrid.md index cb8041b1d..f19fdceef 100644 --- a/docs/developer/subsystems/fault-split-multigrid.md +++ b/docs/developer/subsystems/fault-split-multigrid.md @@ -101,22 +101,37 @@ zero). Two further rules came out of the same case: ## Parallel -Every split level is redistributed so its fault star is rank-interior (the split's own -rule), so the pair blocks are rank-local on every level. The transfer locates each -owned fine node in the *local* coarse mesh (owned plus ghost cells); the nodes whose -coarse cell lives elsewhere are all-gathered as query points, answered by the rank -holding the cell with global column indices, and the asker keeps the best answer. The -levels need not be co-partitioned. +The add-on is partitioned **once, before any surgery**, as one hierarchy +(`utilities/addon_partition.partition_addon`, design note +[parallel-addon-distribution-2026-10](../design/parallel-addon-distribution-2026-10.md)). +`add_fault`, `add_conforming_sheet`, `split_fault` and `split_faults` call it in +parallel: the fault regions — each fault's cell star plus growth layers, marked on +every add-on level because a coarser level's star reaches further — are glued and +sent to the rank already holding most of them, the load is balanced by diffusion +over the seams (free cells move from a heavier rank to a lighter neighbour, the +cells farthest from the faults first), and the one assignment is applied to every +add-on level through the parent-cell relation. The background levels never move. +The cut, the placement and the split then run on rank-interior regions and move +nothing; the replay of the faults on the add-on levels (`split_tail_levels`) arrives +on levels already partitioned with the finest. + +Every add-on pair is therefore co-partitioned exactly and the cell-located transfer +finds every node in its local coarse cells. The one pair the partition separates — +the base's finest static level against the first add-on level — carries the exact +relation: each level records, per cell, where its parent on the level below lives +(provenance riding the move through `distributeField`, the new addresses resolved +through two star forests), and `provenance_transfer_parallel` fetches each fine +cell's parent through one star forest. Nothing is located across partitions and +nothing is all-gathered. The transfer's record on the hierarchy +(`level_transfer_stats`) shows `misses` zero on every pair and `fetched_remote` on +the moved one. A coincident pairing rides a redistribution as a pair of DM labels -(`fault_split.stamp_point_pairs` / `collect_point_pairs`): one value per pair on its -Minus and Plus points. The migration star forest PETSc returns for an *initial* -distribution does not index the stratified numbering (measured: three quarters of the -points mapped to the wrong depth), so the forest is not used. `extrude_sheet` builds the -layer on one rank, distributes it with the labels on, redistributes the fault stars -rank-interior and reads the pairing back; a balanced partition readily puts the whole -Minus side on one rank and the whole Plus side on another, since the slit disconnects -them. +(`fault_split.stamp_point_pairs` / `collect_point_pairs`), and a per-cell field as +a cell-wise section through `distributeField` on the migration star forest +(`redistribute_by_assignment(fields=...)`). `extrude_sheet` builds the layer on +one rank and distributes it with the labels on; its fault stars are then made +rank-interior by the older per-fault move, which remains for that path. ## Measured (box in simple shear, straight fault, 2026-10-13) diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index dedb48e59..b56bc83bd 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -8288,12 +8288,24 @@ def add_conforming_sheet(self, points, triangles, name, *, "add_conforming_sheet is 3-D; in 2-D use " "add_conforming_surface.") - boundaries = self._boundaries_with(name) + # In parallel the add-on is partitioned for the sheet first — the + # placement's own mask with two growth layers, on this level and on + # every add-on level below it — so the carve and the fill run on a + # rank-interior region and the placement gathers nothing; a split of + # the sheet later moves nothing either. + host = self + if self.dm.getComm().getSize() > 1: + from underworld3.utilities.addon_partition import (mark_sheets, + partition_addon) + host = partition_addon( + self, mark_sheets([(points, triangles)], clearance), layers=2, + verbose=verbose) + boundaries = host._boundaries_with(name) cut_dm, info = place_sheet( - self.dm, points, triangles, label=name, + host.dm, points, triangles, label=name, label_value=boundaries[name].value, clearance=clearance, verbose=verbose, setback=setback, size=size) - return self._adopt_cut_child(cut_dm, boundaries, info, + return host._adopt_cut_child(cut_dm, boundaries, info, mg_coarsening_ratio, verbose) def extrude(self, thickness, layers=2, base="Base", surface="Surface", diff --git a/src/underworld3/utilities/fault_split.py b/src/underworld3/utilities/fault_split.py index c2322096a..ebda629bc 100644 --- a/src/underworld3/utilities/fault_split.py +++ b/src/underworld3/utilities/fault_split.py @@ -44,11 +44,14 @@ partition seam — the replicas are then rank-local and the star-forest carries over by renumbering alone, exactly as the deletion pass argues for its own seam freeze. A fault that DOES touch the seam is not the user's problem: -:func:`split_fault` (and :func:`add_fault`) REDISTRIBUTE first — the fault's -cell star plus one growth layer moves to the rank that already owns most of -it (:func:`_redistribute_fault_interior`), the chain or patch becomes -rank-interior, and the split runs with serial topology. Only a direct call -to the low-level splitters on a seam-touching fault is refused. The 2-D +:func:`split_fault` (and :func:`add_fault`) PARTITION the add-on first — one +assignment for the finest level and every add-on level below it, each fault's +cell star plus a growth layer glued and sent to the rank that already owns +most of it, the load balanced +(:func:`~underworld3.utilities.addon_partition.partition_addon`) — so the +chain or patch is rank-interior and the split runs with serial topology, +moving nothing itself. Only a direct call to the low-level splitters on a +seam-touching fault is refused. The 2-D split can instead run THROUGH the seam (``across_seams=True``, the path of a band meshed through it by ``place_thin_volume(seams="conform")``): the chain is assembled globally from the star-forest identities of its diff --git a/src/underworld3/utilities/place_surface.py b/src/underworld3/utilities/place_surface.py index 415eab5e2..20a6b61c4 100644 --- a/src/underworld3/utilities/place_surface.py +++ b/src/underworld3/utilities/place_surface.py @@ -1666,9 +1666,9 @@ def _gather_region(dm, vertex_mark_chart, verbose=False): it. The input is untouched. """ ids = (np.asarray(vertex_mark_chart) != 0).astype(np.int32) - work, n_region, _n_moved, _owner, _canon = _gather_regions( + work, _n_region, n_moved, _owner, _canon = _gather_regions( dm, ids, verbose=verbose, layers=1) - return work, n_region + return work, n_moved def _merge_regions(pairs, n_ids, comm): @@ -3300,15 +3300,17 @@ def place_sheet(dm, points, triangles, label=CUT_LABEL, label_value=1, # so a wider mask here only moves cells the surgery never touches (#670: # a +2 margin gathered 91% of a fixture whose cavity was 6%). The carve # asserts nothing shared afterwards, so an under-reach is loud. - # TODO(BUG): the mask does not cover the cavity GROWTH (_carve_cavity_3d's - # enclosed-cell and pinched-vertex rounds take whole vertex stars at the - # shell, one more diameter out), so on a partitioned adapt child the grown - # cavity touched a shared point and the surgery rank raised "the gather - # mask under-reached" (measured 2026-10-17: box 3x2x1, adapt to h 0.05 on - # the fault column, blind sheet with setback 0.25, 8 ranks; the same - # placement passes serially). Fix: on that refusal re-gather from the - # original dm with a wider margin and retry, rather than widening every - # gather (#670's cost note). See planning file: underworld.md. + # The Mesh API (add_conforming_sheet, add_fault) partitions the add-on + # for the sheet BEFORE calling here, with this mask plus two growth + # layers on every add-on level (addon_partition.mark_sheets), so the + # gather below finds the region rank-interior and moves nothing. + # TODO(BUG): on a dm handed in directly, this mask alone does not cover + # the cavity GROWTH (_carve_cavity_3d's enclosed-cell and pinched-vertex + # rounds take whole vertex stars at the shell, one more diameter out): on + # a partitioned adapt child the grown cavity touched a shared point and + # the surgery rank raised "the gather mask under-reached" (measured + # 2026-10-17 at 2 and 8 ranks; tests/parallel/test_1097 reproduces it + # through the old Mesh path). The direct path needs the second layer too. mark = np.zeros(pEnd - pStart, dtype=np.int32) mark[np.flatnonzero(d_sheet < (clearance + 1.0) * h_vertex) + vS - pStart] = 1 diff --git a/tests/parallel/test_1097_place_sheet_partitioned_addon_parallel.py b/tests/parallel/test_1097_place_sheet_partitioned_addon_parallel.py new file mode 100644 index 000000000..efb486c08 --- /dev/null +++ b/tests/parallel/test_1097_place_sheet_partitioned_addon_parallel.py @@ -0,0 +1,111 @@ +"""A 3-D sheet placed and split on a partitioned adapt child, in parallel. + +The fault workflow is base -> ``adapt`` toward the sheet -> ``add_conforming_sheet`` +-> ``split_fault``. Before the add-on partition the placement gathered the +sheet's region itself, with a mask that did not cover the cavity's growth rounds, +so on a partitioned adapt child the carve touched a shared point and the surgery +rank refused ("the gather mask under-reached") where the same placement passes +serially (measured 2026-10-17 on the box fault model at 8 ranks). The split then +moved the fault's star again, level by level. + +Now ``add_conforming_sheet`` partitions the add-on for the sheet first — the +placement's own mask with two growth layers, on every add-on level — so the +placement gathers nothing (``n_gathered`` is zero), the split moves nothing, and +the fault solve runs on the geometric tail. The placed mesh keeps the domain +volume and one orientation, as the serial test of the same fixture asserts +(``tests/test_0867_place_sheet_adapted_3d.py``). +""" +import numpy as np +import pytest +import sympy + +import underworld3 as uw +from underworld3.utilities.place_surface import _cell_volumes_signed6 + +pytestmark = [pytest.mark.mpi(min_size=2), pytest.mark.level_2, pytest.mark.tier_b, + pytest.mark.timeout(1500)] + +CENTRE = np.array([0.5, 0.5, 0.5]) +RADIUS = 0.2 +H_NEAR = 0.05 +TILTED = np.array([0.3, -0.5, 0.8]) / np.linalg.norm([0.3, -0.5, 0.8]) + + +def _disc_sheet(normal, n_rim=32): + helper = np.eye(3)[np.argmin(np.abs(normal))] + e1 = np.cross(normal, helper) + e1 /= np.linalg.norm(e1) + e2 = np.cross(normal, e1) + a = np.linspace(0.0, 2.0 * np.pi, n_rim, endpoint=False) + rim = CENTRE + RADIUS * (np.outer(np.cos(a), e1) + np.outer(np.sin(a), e2)) + pts = np.vstack([CENTRE, rim]) + tris = np.array([(0, 1 + i, 1 + (i + 1) % n_rim) for i in range(n_rim)], + dtype=np.int64) + return pts, tris + + +def _graded_metric(normal): + def metric(X): + rel = np.asarray(X)[:, :3] - CENTRE + zn = rel @ normal + r = np.linalg.norm(rel - zn[:, None] * normal, axis=1) + d = np.sqrt(np.maximum(r - RADIUS, 0.0) ** 2 + zn ** 2) + h = np.clip(H_NEAR + d * H_NEAR / 0.4, H_NEAR, 2.0 * H_NEAR) + return 1.0 / h**2 + return metric + + +def _placed_and_split(): + from underworld3.utilities.fault_split import split_fault + base = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0, 0.0), maxCoords=(1.0, 1.0, 1.0), + cellSize=0.2, regular=False, qdegree=2, refinement=1) + child = base.adapt(_graded_metric(TILTED), max_levels=1) + pts, tris = _disc_sheet(TILTED) + placed = child.add_conforming_sheet(pts, tris, "Fault", size=H_NEAR) + return placed, split_fault(placed, "Fault") + + +def test_sheet_placement_gathers_nothing_and_the_split_moves_nothing(): + from underworld3.utilities.fault_split import _fault_labels_touch_seam + comm = uw.mpi.comm + placed, split = _placed_and_split() + # the placement ran on a rank-interior region of the partitioned add-on + assert placed._surface_info["n_gathered"] == 0 + assert placed._surface_info["n_surface_facets"] > 0 + assert placed.parent._addon_partition is not None + v6 = _cell_volumes_signed6(placed.dm) + volume = comm.allreduce(float(np.abs(v6).sum()) / 6.0) + assert abs(volume - 1.0) < 1e-9 + sign_ok = bool((np.sign(v6) == np.sign(v6[0])).all()) if len(v6) else True + assert comm.allreduce(sign_ok, op=min) + # the split found its star rank-interior and moved nothing: the split + # mesh's parent is the placed mesh itself, every pair is whole + assert split.parent is placed + n_pairs = comm.allreduce(len(split._fault_point_pairs["Fault"])) + assert n_pairs > 0 + labels = [(f"Fault{side}", int(split.boundaries[f"Fault{side}"].value)) + for side in ("Plus", "Minus")] + assert not _fault_labels_touch_seam(split.dm, labels) + # the tail rode through: static base levels plus the moved add-on level + tail = split._custom_mg_coarse_meshes + assert tail is not None and len(tail) >= 2, tail + + +def test_fault_solve_on_the_placed_split_runs_on_the_geometric_tail(): + _placed, split = _placed_and_split() + v = uw.discretisation.MeshVariable("v", split, 3, degree=2) + p = uw.discretisation.MeshVariable("p", split, 1, degree=1) + stokes = uw.systems.Stokes(split, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + stokes.bodyforce = sympy.Matrix([0, 0, 0]) + stokes.tolerance = 1e-4 + stokes.add_dirichlet_bc((0.0, 0.0, 0.0), "Bottom") + stokes.add_dirichlet_bc((1.0, 0.0, 0.0), "Top") + stokes.add_fault_bc(0.0, "Fault") + stokes.solve(zero_init_guess=True) + info = stokes._rotated_freeslip_info + assert info["velocity_pc"] == "custom-FMG" and info["converged"], info + stats = stokes._custom_mg["hierarchy"].level_transfer_stats + assert all(st is None or st["misses"] == 0 for st in stats), stats From 6dd0392c357afa8be09cdfb599d2ed0de2f18ca7 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 15:23:39 +1100 Subject: [PATCH 48/59] Design note: measured loads after the add-on partition on the refined patch box Underworld development team with AI support from Claude Code --- .../parallel-addon-distribution-2026-10.md | 13 +++++++++++-- .../sessions/parallel_addon/probe_load_3d.py | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 scripts/sessions/parallel_addon/probe_load_3d.py diff --git a/docs/developer/design/parallel-addon-distribution-2026-10.md b/docs/developer/design/parallel-addon-distribution-2026-10.md index be580d519..063d1813b 100644 --- a/docs/developer/design/parallel-addon-distribution-2026-10.md +++ b/docs/developer/design/parallel-addon-distribution-2026-10.md @@ -67,8 +67,17 @@ for riding it. ## Measured after (same fixtures, the partition in place) -Refined patch box, split: velocity iterations 5 / 6 / 6 at np 2 / 4 / 8 (4 in -serial), wall 65 / 34 / 25 s against 72 / 38 / 30 s before. Adapt-on-top fault, 2-D, +Refined patch box, split (`scripts/sessions/parallel_addon/probe_load_3d.py`): + +| np | finest P2 nodes per rank (max / mean) | before | velocity iterations | wall | +|---|---|---|---|---| +| 2 | 13683 / 16090 (1.08) | 1.16 | 5 | 65 s (72) | +| 4 | 8170 / 8470 / 7766 / 7697 (1.06) | 1.27 | 6 | 34 s (38) | +| 8 | 5899 / 4062 / 4007 / 4165 / 3772 / 3642 / 3949 / 3907 (1.41) | 2.12 | 6 | 25 s (30) | + +At eight ranks the owner holds exactly the fault region (3524 of 19072 cells) and +nothing else, after 13 balance sweeps; the region is the indivisible unit. The +2-D adapt-on-top fault, np 2: finest cells per rank 742 / 618 (max / mean 1.09, from 1.38); every add-on pair co-partitioned, every recorded parent contains its cell; the base-to-add-on pair fetched, no query. At np 4 and 8 on that 1360-cell fixture the fault region itself diff --git a/scripts/sessions/parallel_addon/probe_load_3d.py b/scripts/sessions/parallel_addon/probe_load_3d.py new file mode 100644 index 000000000..572feaa14 --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_load_3d.py @@ -0,0 +1,18 @@ +"""Per-rank load of the split refined patch box after the add-on partition.""" +import numpy as np, underworld3 as uw +from mpi4py import MPI +from underworld3.utilities.fault_split import split_fault +comm = MPI.COMM_WORLD +Lx, Ly, H = 3.0, 2.0, 1.0; h, hf, f = 0.2, 0.1, 4 +yf, xa, xb, zb = 1.0, 1.0, 2.0, 0.5; ztop = H - 2 * hf +patch = np.array([[xa, yf, zb], [xb, yf, zb], [xb, yf, ztop], [xa, yf, ztop]]) +fine = uw.meshing.BoxInternalPatch(cellSize=f * h, minCoords=(0, 0, 0), maxCoords=(Lx, Ly, H), patch_points=patch, patch_name="Fault", + patch_cellSize=f * hf, grading_distance=8 * hf, refinement=2, qdegree=3) +mesh = split_fault(fine, "Fault", verbose=True) +rec = getattr(mesh.parent, "_addon_partition", None) +nodes = comm.gather(np.asarray(mesh._get_coords_for_basis(2, True)).shape[0], root=0) +cells = comm.gather(mesh.dm.getHeightStratum(0)[1] - mesh.dm.getHeightStratum(0)[0], root=0) +if comm.rank == 0: + n = np.array(nodes, float); c = np.array(cells, float) + print(f"np {comm.size}: finest P2 nodes per rank {nodes} max/mean {n.max()/n.mean():.2f}; cells {cells} max/mean {c.max()/c.mean():.2f}", flush=True) + if rec: print("record:", {k: v for k, v in rec.items() if k != 'owners'}, flush=True) From dd1d6eeab57865ae0e3f1743e378cff5a6aff832 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 17 Oct 2026 16:41:04 +1100 Subject: [PATCH 49/59] Adversarial review fixes: re-partition of a partitioned add-on, faults glued by name Two blocking findings. A cut, placed or split child of a partitioned host could not be partitioned again (the fault-network route places one sheet at a time), because the first add-on level's parent on the base was searched locally on a pair the first partition had moved apart; it is now read from the recorded provenance. And a split fault's facets are single-sided, so a support-based interior test never glued it and a second partition could peel its star across a seam; the faults a mesh carries are now glued by name (every pairing and its sides, every placed sheet), which also stops a domain-spanning internal boundary from being glued. The provenance is recorded when the surgery makes a mesh (inherit_provenance in the cut, the placement and the split), so it survives a later move of the nodes and the transfer needs no location. The provenance pair keeps the finest level's FAC patch. Empty region ids no longer reach the balance; a level that would sit on one rank is refused; the inside tolerances agree; the polyline reach is reconciled over the seam; the balance cannot overshoot by a unit; the remaining non-collective raises are collective; the 3-D sheet fault on a refined base takes its coarse levels in serial as in parallel. Tests: a second partition keeps the first fault whole (2-D, np 2/4/8); two sheets placed in turn and split together (3-D); the placement test compares facets and pairs with its own serial run and the tail by identity; fixtures shared in tests/parallel/fault_fixtures.py. Docs: the design note's unit and retirement statements corrected, supersession notes in the September placement note, FAULT_MULTIGRID_HIERARCHIES and the conforming-surfaces page; review record in docs/reviews/2026-10. Underworld development team with AI support from Claude Code --- .../design/FAULT_MULTIGRID_HIERARCHIES.md | 4 + .../fault-parallel-placement-2026-09.md | 9 + .../parallel-addon-distribution-2026-10.md | 73 +++--- .../conforming-surfaces-and-fault-zones.md | 8 + .../subsystems/fault-split-multigrid.md | 20 +- ...l-addon-distribution-adversarial-review.md | 100 ++++++++ docs/reviews/README.md | 1 + .../sessions/fault_mg/fig_parallel_load.py | 2 +- scripts/sessions/parallel_addon/README.md | 17 ++ .../sessions/parallel_addon/probe_migrate.py | 22 -- .../sessions/parallel_addon/probe_place.py | 50 ---- .../sessions/parallel_addon/probe_place3d.py | 37 --- .../parallel_addon/probe_place3d_b.py | 31 --- .../parallel_addon/probe_place3d_c.py | 31 --- .../discretisation/discretisation_mesh.py | 22 +- src/underworld3/utilities/addon_partition.py | 232 +++++++++++++++--- src/underworld3/utilities/custom_mg.py | 28 ++- src/underworld3/utilities/fault_mg.py | 50 ++-- src/underworld3/utilities/fault_split.py | 134 +++++----- src/underworld3/utilities/place_surface.py | 5 +- tests/parallel/fault_fixtures.py | 99 ++++++++ ...est_1089_fault_split_multigrid_parallel.py | 31 +-- .../test_1096_addon_partition_parallel.py | 131 +++++----- ..._place_sheet_partitioned_addon_parallel.py | 138 +++++++---- tests/test_1096_addon_cut_child_tail.py | 15 +- 25 files changed, 779 insertions(+), 511 deletions(-) create mode 100644 docs/reviews/2026-10/parallel-addon-distribution-adversarial-review.md create mode 100644 scripts/sessions/parallel_addon/README.md delete mode 100644 scripts/sessions/parallel_addon/probe_migrate.py delete mode 100644 scripts/sessions/parallel_addon/probe_place.py delete mode 100644 scripts/sessions/parallel_addon/probe_place3d.py delete mode 100644 scripts/sessions/parallel_addon/probe_place3d_b.py delete mode 100644 scripts/sessions/parallel_addon/probe_place3d_c.py create mode 100644 tests/parallel/fault_fixtures.py diff --git a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md index 24ac76f5b..ab293496a 100644 --- a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md +++ b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md @@ -153,6 +153,10 @@ A, the existing path, had three defects at np > 1: cell-located transfer, which asks the other ranks for the nodes it cannot locate locally, and unreached unknowns are dropped rather than repaired: A converges at np = 2 (15 iterations against 13 serial), C in 6 at np = 2 and 7 at np = 4 (k = 2). + (Superseded in October 2026: the add-on is now partitioned once before the cut, + so the split levels are co-partitioned with each other and nothing is asked of + other ranks on those pairs; see + [parallel-addon-distribution-2026-10](parallel-addon-distribution-2026-10.md).) The pairing rides every redistribution as a pair of DM labels. The migration star forest PETSc returns for an initial distribution does not index the stratified point diff --git a/docs/developer/design/fault-parallel-placement-2026-09.md b/docs/developer/design/fault-parallel-placement-2026-09.md index e0ab9a10d..7fd81b0d9 100644 --- a/docs/developer/design/fault-parallel-placement-2026-09.md +++ b/docs/developer/design/fault-parallel-placement-2026-09.md @@ -8,6 +8,15 @@ subsystem description is `../subsystems/conforming-surfaces-and-fault-zones.md` and the multigrid side is `fault-patch-multigrid-2026-08.md`. Issues: #670 (the gather), #671 (the tail), PRs #669 and #672. +```{note} +For the cut, the 3-D sheet placement and the split reached through the Mesh API, the +per-region gather and the per-split moves described here are superseded by one +partition of the add-on hierarchy before any surgery +([parallel-addon-distribution-2026-10](parallel-addon-distribution-2026-10.md)). The +gather remains the mechanism of a direct `place_sheet` call and of +`place_thin_volume`; the seam-ligament and seam-conform modes below are unchanged. +``` + ## The frame: adapt-on-top The base mesh is the persistent, distributed object. It holds the bulk diff --git a/docs/developer/design/parallel-addon-distribution-2026-10.md b/docs/developer/design/parallel-addon-distribution-2026-10.md index 063d1813b..aa62fca08 100644 --- a/docs/developer/design/parallel-addon-distribution-2026-10.md +++ b/docs/developer/design/parallel-addon-distribution-2026-10.md @@ -94,44 +94,53 @@ places with nothing gathered and splits with nothing moved (test_1097). surgery, applied to every add-on level through the parent-cell relation, once.** The base levels never move. Concretely: -1. **Units.** The unit of assignment is a cell of the base's finest level together - with all its descendants on the add-on levels (nested refinement: every add-on - cell has exactly one parent on the level below, found by centroid containment - while the levels are still co-partitioned, which is exact for a bisection - hierarchy). Every cell of a unit goes to the same rank, so every add-on pair stays - co-partitioned exactly, and the base-to-add-on pair is co-partitioned except - where a unit moved. +1. **Units.** The unit of assignment is a cell of the coarsest add-on level together + with all its descendants on the finer add-on levels (the finest mesh itself when + there is no add-on level below it). Every add-on cell has one parent on the level + below, found by centroid containment while the levels are co-partitioned (exact + for a bisection hierarchy). Every cell of a unit goes to the same rank, so every + add-on pair stays co-partitioned exactly, and the base-to-add-on pair is + co-partitioned except where a unit moved. 2. **Fault regions.** The cells the surgery will touch — the star of the vertices - within reach of the fault geometry, plus two growth layers so every point the - carve or the split touches is unshared — are lifted to units and glued: a region - is one indivisible block, sent to the rank already holding most of it, or left - where it is when it is already interior. Regions whose shells touch merge (the + within reach of the fault geometry, plus growth layers so every point the carve + or the split touches is unshared (one for a cut, two for a 3-D carve) — are + marked on every add-on level, lifted to units and glued: a region is one + indivisible block, sent to the rank already holding most of it, or left where it + is when it is already interior. Every interior surface the levels already carry + is glued the same way, so a second partition (a second sheet, a fault after a + material interface) keeps the first whole. Regions whose shells touch merge (the existing `_gather_regions` rule). The split's pairs and the fault cell fans are therefore rank-local by construction, and never because a later pass moved them. -3. **Balance.** The owner of a region is overloaded by the region's weight (its - finest cells). Units that belong to no region are peeled from an overloaded rank - to an underloaded neighbour across their common seam, one layer per sweep, until - every rank is within a tolerance of the mean or no seam can give. The load fix is - on the child; the base's partition is read, never written. +3. **Balance.** By diffusion over the seams: in each sweep any rank heavier than a + face-neighbouring rank by more than the tolerance offers it units that belong to + no region, up to half the difference, the cells farthest from the faults first, + and a receiver's intake is capped by the largest half-gap offered to it. Sweeps + stop when every rank is within the tolerance of the mean or nothing can move. The + load fix is on the child; the base's partition is read, never written. 4. **Provenance rides.** Each level carries, per cell, its parent cell on the level below and its own pre-move identity, as cell-wise fields moved by - `distributeField`. After the move every fine cell knows where its parent now - lives, and the transfer for every pair — the add-on pairs and the one - base-to-add-on pair — is the coarse cell's Lagrange basis evaluated at the fine - node (the existing dual-basis evaluation of `fault_mg`), with the parent's node - columns and vertex coordinates fetched through one star forest. Co-partitioned - pairs fetch locally; the base boundary fetches the moved regions' parents from - their ranks. No point location across partitions, no all-gathered query cloud. + `distributeField`; two star forests then give every cell its parent's current + address. The add-on pairs are co-partitioned and keep the existing cell-located + transfer, which now finds every node locally (its query exchange still runs as a + collective, with nothing to send). The base-to-add-on pair is built by + `provenance_transfer_parallel`: the coarse cell's Lagrange basis evaluated at the + fine node (the dual-basis evaluation of `fault_mg`), with the parent's node + columns and coordinates fetched through one star forest. Its contract is a + collective refusal if any fine node lies in no cell of the level the provenance + is recorded on. 5. **Surgery afterwards.** Cut, placement and split run on the partitioned levels - and move nothing; their seam tests become assertions of the precondition. A level - created by surgery inherits provenance from the cells it re-represents (the split - is one-to-one; a cut cell or a fill cell takes the pre-surgery cell holding its - centroid, a local test since the region is rank-interior). - -What this retires: `colocate_split_level`, `_colocate_level` and the per-level -`_redistribute_fault_interior` calls; the cross-partition query path of the transfer -builder becomes unreachable for an add-on built this way and is kept only for a -hierarchy registered by hand (`set_custom_fmg` with independently built meshes). + and move nothing; the split's seam test becomes a collective refusal. A level + created by surgery is related to the level it re-represents by locating its + cell centroids there (a local test, since the region is rank-interior), and that + level's record gives the parent on the base. + +What this retires: `colocate_split_level` and the per-level fault-interior moves of +the cut, placement and split pipelines. Still in place, outside this change: +`_colocate_level` in `custom_mg.adopt_hierarchy`, which the placed-band route +(`FaultNetwork.build` with the thin volume, `place_surface`'s ladder) uses because +`place_thin_volume` still gathers its own regions; the per-mesh fault-interior move +of `extrude_sheet`; and the node-cloud builders' cross-partition path, reachable for +a pair with an empty column on a hierarchy registered by hand. ## What was weighed and rejected diff --git a/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md b/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md index 8bc12808c..a0590e6df 100644 --- a/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md +++ b/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md @@ -282,6 +282,14 @@ a labelled edge, so a placed surface comes through any later repair with the facets it was placed with. The fill itself rarely needs repair (see above), but the composition remains sound. +```{note} +Since October 2026 the Mesh API (`add_conforming_sheet`, `add_fault`) partitions the +add-on for the surface before placing it, so the gather below finds the region +rank-interior and moves nothing (`info["n_gathered"]` is then zero). The gather is +what a direct call to `place_sheet` or `place_thin_volume` still does. See +[parallel-addon-distribution-2026-10](../design/parallel-addon-distribution-2026-10.md). +``` + The 3-D forms are **parallel** by gather-first: the surface's region is redistributed onto one rank, the serial carve-and-fill runs there, and every rank rebuilds its chart collectively (uninterpolate + `DMPlexInterpolate`). diff --git a/docs/developer/subsystems/fault-split-multigrid.md b/docs/developer/subsystems/fault-split-multigrid.md index f19fdceef..7a83a1477 100644 --- a/docs/developer/subsystems/fault-split-multigrid.md +++ b/docs/developer/subsystems/fault-split-multigrid.md @@ -116,15 +116,15 @@ nothing; the replay of the faults on the add-on levels (`split_tail_levels`) arr on levels already partitioned with the finest. Every add-on pair is therefore co-partitioned exactly and the cell-located transfer -finds every node in its local coarse cells. The one pair the partition separates — -the base's finest static level against the first add-on level — carries the exact -relation: each level records, per cell, where its parent on the level below lives -(provenance riding the move through `distributeField`, the new addresses resolved -through two star forests), and `provenance_transfer_parallel` fetches each fine -cell's parent through one star forest. Nothing is located across partitions and -nothing is all-gathered. The transfer's record on the hierarchy -(`level_transfer_stats`) shows `misses` zero on every pair and `fetched_remote` on -the moved one. +finds every node in its local coarse cells (`level_transfer_stats` records `misses` +zero; the query exchange still runs, with nothing to send). The one pair the +partition separates — the base's finest static level against the first add-on +level — carries the exact relation: each level records, per cell, where its parent +on the level below lives (provenance riding the move through `distributeField`, the +new addresses resolved through two star forests), and `provenance_transfer_parallel` +fetches each fine cell's parent through one star forest (its record carries +`fetched_remote`). Nothing on that pair is located across partitions; a fine node in +no recorded cell is a collective refusal. A coincident pairing rides a redistribution as a pair of DM labels (`fault_split.stamp_point_pairs` / `collect_point_pairs`), and a per-cell field as @@ -196,6 +196,4 @@ welded tails converge with the serial slip; the thick twin extruded with its pai welded. A network whose junction ligament is one fine cell wide closes at the next coarser spacing (the two labels meet), so such a network gets split levels only where the ligament survives — on `test_0866`'s kissing Y with one add-on level, none. -- Co-locating the add-on levels with the finest in parallel is an optimisation the - query path makes unnecessary for correctness; not measured at scale. - The one-rank build of `extrude_sheet` bounds the layer size by one rank's memory. diff --git a/docs/reviews/2026-10/parallel-addon-distribution-adversarial-review.md b/docs/reviews/2026-10/parallel-addon-distribution-adversarial-review.md new file mode 100644 index 000000000..6843b1bd9 --- /dev/null +++ b/docs/reviews/2026-10/parallel-addon-distribution-adversarial-review.md @@ -0,0 +1,100 @@ +# The add-on partitioned once: adversarial review record + +Branch `feature/parallel-addon-distribution`, from `feature/tidal-shell` at `f21f3d85`. +One round of three independent reviewers (parallel semantics and numerics; the wiring +into the cut, placement and split pipelines; tests, style, documentation and +contracts), read-only against the branch diff, 2026-10-17. Design note: +[parallel-addon-distribution-2026-10](../../developer/design/parallel-addon-distribution-2026-10.md). + +## What the branch does + +| Change | Why | +|---|---| +| `utilities/addon_partition.partition_addon`: one assignment for the add-on hierarchy, computed before any surgery and applied to every add-on level through the parent-cell relation; fault regions glued and sent to their majority rank, the load balanced by diffusion over the seams; provenance riding the move | every surgery moved cells on its own terms, so no two levels shared a partition and every consumer carried its own cross-partition handling | +| `add_fault`, `add_conforming_sheet`, `split_fault`, `split_faults` partition first; the level replay arrives partitioned | the split moved each level's star to its own majority rank; the 3-D placement refused on a partitioned adapt child ("the gather mask under-reached") | +| `fault_mg.provenance_transfer_parallel`: the pair the partition moved apart is built from the recorded parent, fetched through one star forest | the cell-located transfer all-gathered the misses as queries | +| the 2-D `add_fault` cut child takes the base's coarse levels | every solver on it fell back to GAMG | +| `colocate_split_level`, `fault_star_owner`, `_fault_star_cells` removed; `_redistribute_fault_interior` kept for `extrude_sheet` only | superseded | + +## Findings and their state + +Blocking: + +- **A second partition on a partitioned lineage was refused** (wiring review). The + "partition once" guard read only its argument; a placed or cut child of a + partitioned host fell through to a co-partition check that fails by design, so the + fault-network route (`add_conforming_sheet` per sheet), sheet-then-fault and two + `split_fault` calls all broke in parallel. Fixed: the first add-on level's parent + on the base is read from the recorded provenance when the pair is no longer + co-partitioned (`_base_parent_addresses`); the guard is gone. Tests: + `test_1096::test_a_second_partition_keeps_the_first_fault_whole`, + `test_1097::test_two_sheets_placed_in_turn_then_split_together`. +- **A split fault was not glued by a re-partition** (semantics review). Its facets + are single-sided after the split, so a support-based interior test marked nothing, + and the balance could peel the first fault's star across a seam. Fixed: glued + surfaces are named (`embedded_surface_names`: every pairing's label and sides, every + placed sheet), not detected; this also stops a domain-spanning internal boundary + being glued (should-fix in the same review). + +Should-fix, fixed: + +- Empty region ids gave a NaN fault centre and disordered the balance; regions with + no cell anywhere are dropped. +- The provenance branch skipped the finest level's FAC patch; it now sits inside the + builder branches. +- The provenance lookup located fine centroids in an ancestor's possibly stale + geometry; the relation is recorded when the surgery makes the mesh + (`inherit_provenance` in the cut, placement and split), and a mesh carrying its own + record needs no location at transfer time. +- `locate_in_cells_exhaustive` scanned every cell per miss; it widens to the nearest + 128 and 1024 cells first and scans only what still misses, and with the record + taken at surgery time it runs once per surgery, not per transfer. The two + inside-tolerances now agree. +- `mark_polylines` read a rank-local cell size at seam vertices; reconciled over the + star forest. +- The balance could overshoot by a whole unit and trade it back; a unit heavier than + twice the remaining gap is skipped. +- A level assigned wholly to one rank would be repartitioned by the Mesh constructor + under the provenance; refused collectively. +- Two non-collective raises inside collective sequences made collective. +- Serial and parallel tails differed for a sheet fault on a refined base with no + registered tail; the 3-D path takes the native coarse levels as the 2-D one does. +- Stale docstrings (`split_faults`, `split_fault`, `stamp_point_pairs`, + `_fault_labels_touch_seam`, `Mesh.add_fault`), the dead `groups` / `pairs` + parameters of `_redistribute_fault_interior`, the uncommented swallow in + `_face_other_rank`, the clearance default duplicated from `place_sheet`. +- Tests: the provenance pair's record (`fetched_remote`) and the per-level lineage + asserted; `test_1097` compares facets and pairs with its own serial run and the + tail by identity; the serial tail test by object identity; the fixtures shared in + `tests/parallel/fault_fixtures.py` (and `test_1089` moved onto them). +- Documentation: the design note's unit definition, its retirement list (honest about + `_colocate_level` in `adopt_hierarchy` and the extrude move), the query exchange + that still runs empty; supersession notes in the September placement note, + `FAULT_MULTIGRID_HIERARCHIES.md` and the conforming-surfaces subsystem page. +- Probes: the broken and unrelated ones deleted, a README dating the rest. + +Left open, recorded: + +- `place_thin_volume` (the 3-D band of `FaultNetwork.build`) still gathers its own + regions, and `adopt_hierarchy` still co-locates the tail behind it + (`_colocate_level`). The placed-band route is the next one to put on the partition. +- A direct `place_sheet` call keeps the under-reaching mask (`TODO(BUG)` at the mask). +- Python loops per cell, face and vertex in the partition and the section fill: + seconds per level at 1e5 cells per rank, tens at 1e6. Not measured at scale. +- The balance's seam adjacency is the original partition's; received units are not + passed on. It converges on the fixtures; not a bound. +- `_face_other_rank` and `place_surface._propagate_vertex` return early on a + `getGraph` exception before a star-forest collective (pre-existing pattern). +- Only np = 2 runs in CI; the 4- and 8-rank runs here are by hand. + +## Attacks that found nothing + +- Collective ordering in `partition_addon`, the balance sweeps, `_region_cells`, + `_merge_regions`, the provenance transfer (local work only inside the guards). +- `_resolve_parents`: the root count is each rank's own pre-move cell count; one leaf + per root, so REPLACE is the reduction. +- The base is never redistributed in place; the cached wraps keep their identity, so + `provenance_source` matches by identity, newest record first. +- The l2g packing through doubles is exact; constrained columns survive and drop. +- Serial behaviour of every path is unchanged except the two tail fixes. +- CI reach: every new test file is in a `scripts/test.sh` glob; markers complete. diff --git a/docs/reviews/README.md b/docs/reviews/README.md index 456d3e38e..7344f0638 100644 --- a/docs/reviews/README.md +++ b/docs/reviews/README.md @@ -265,6 +265,7 @@ docs/reviews/ | Nonlinear defaults and η_reg ([2026-10](2026-10/nonlinear-defaults-794-adversarial-review.md), PR #794) | Solvers/Constitutive | adversarial, 2 rounds | Passing | 2026-10-06 | Fixes on branch | | JIT setup cost ([2026-10](2026-10/jit-setup-cost-823-adversarial-review.md), #823) | JIT/Solvers | adversarial, 3 rounds | Passing | 2026-10-07 | Fixes on branch | | Restore exactness and step ratio ([2026-10](2026-10/restore-exact-adversarial-review.md)) | Checkpoint/Time stepping | adversarial, 1 round (3 reviewers) | Passing | 2026-10-12 | Fixes on branch | +| Parallel add-on distribution ([2026-10](2026-10/parallel-addon-distribution-adversarial-review.md)) | Faults/Multigrid/Parallel | adversarial, 1 round (3 reviewers) | Passing | 2026-10-17 | Fixes on branch | | Units System (2026-02) | Units/Scaling | 60+ | Passing | 2026-02-01 | Approved (2026-03-10) | | Data Access (2026-02) | Array/Math Interface | 75+ | Passing | 2026-02-01 | Approved (2026-03-10) | | Reduction Operations | Mesh/Swarm Arrays | 5 | ✅ 100% | 2025-10-25 | ✅ Approved | diff --git a/scripts/sessions/fault_mg/fig_parallel_load.py b/scripts/sessions/fault_mg/fig_parallel_load.py index d62092124..ef6ede887 100644 --- a/scripts/sessions/fault_mg/fig_parallel_load.py +++ b/scripts/sessions/fault_mg/fig_parallel_load.py @@ -75,7 +75,7 @@ for li, s in enumerate(st): if s: xs.append(li) - ys.append(100.0 * s["misses"] / max(s["owned_nodes"], 1)) + ys.append(100.0 * s.get("misses", 0) / max(s["owned_nodes"], 1)) if xs: ax.plot(xs, ys, "o-", label=f"{n} ranks") ax.set_xlabel("level (fine side of the transfer)") diff --git a/scripts/sessions/parallel_addon/README.md b/scripts/sessions/parallel_addon/README.md new file mode 100644 index 000000000..689b12ec4 --- /dev/null +++ b/scripts/sessions/parallel_addon/README.md @@ -0,0 +1,17 @@ +# Diagnostic probes: the parallel add-on (2026-10-17) + +Evidence for `docs/developer/design/parallel-addon-distribution-2026-10.md`. These are +diagnostics, kept as they were run, not model scripts: they read `sys.argv` and print +from rank 0 with mpi4py, which the Charter's section 11 rules out for session scripts +that are models. Run with `OMP_NUM_THREADS=1 mpirun -np N python ...`. + +| probe | what it measures | +|---|---| +| `probe_scaling.py fault` | velocity iterations, transfer quality (partition of unity, coordinate reproduction) on the split refined patch box; `plain`, `wall`, `fault_xp` modes for comparison | +| `probe_builders.py` | the two node-cloud builders on the finest pair, with wrong-row dissection (the f21f3d85 defects) | +| `probe_coords_order.py`, `probe_slots.py` | ruled-out hypotheses of the same hunt: node order, native-pair slots | +| `probe_scaling_2d.py` | the 2-D analogue attempt | +| `probe_migration_sf.py` | a per-cell field through `distributeField` on the migration star forest (exact) | +| `probe_sf_exchange.py` | star-forest broadcast and reduce semantics in petsc4py | +| `probe_partition_2d.py [k]` | `partition_addon` invariants on the 2-D adapt-on-top fault | +| `probe_load_3d.py` | per-rank load of the split refined patch box after the partition | diff --git a/scripts/sessions/parallel_addon/probe_migrate.py b/scripts/sessions/parallel_addon/probe_migrate.py deleted file mode 100644 index a83d0f3eb..000000000 --- a/scripts/sessions/parallel_addon/probe_migrate.py +++ /dev/null @@ -1,22 +0,0 @@ -import numpy as np, underworld3 as uw -from mpi4py import MPI -from underworld3.utilities.fault_split import migrate_point_pairs, redistribute_by_assignment -comm = MPI.COMM_WORLD -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, qdegree=3) -sheet = base.add_fault([("Flt", np.array([[0.25, 0.5], [0.75, 0.5]]))]) -dm = sheet.dm -def coord_of(dm, q): - cs = dm.getCoordinateSection(); cv = dm.getCoordinatesLocal().array - vS, vE = dm.getDepthStratum(0); cdim = dm.getCoordinateDim() - verts = [int(v) for v in dm.getTransitiveClosure(int(q))[0] if vS <= v < vE] - return np.array([cv[cs.getOffset(v):cs.getOffset(v)+cdim] for v in verts]).mean(axis=0) -pairs = sheet._fault_point_pairs["Flt"] -gap = max([np.linalg.norm(coord_of(dm, a) - coord_of(dm, b)) for a, b in pairs.items()] or [0.0]) -print(comm.rank, "before: pairs", len(pairs), "max gap", gap, flush=True) -cS, cE = dm.getHeightStratum(0) -g, sf = redistribute_by_assignment(dm, np.zeros(cE - cS, dtype=np.int32)) -print(comm.rank, "sf", None if sf is None else sf.getGraph()[0], "new chart", g.getChart(), flush=True) -newp, dropped = migrate_point_pairs({"Flt": pairs}, sf, dm, g) -np_ = newp["Flt"] -gap2 = max([np.linalg.norm(coord_of(g, a) - coord_of(g, b)) for a, b in np_.items()] or [0.0]) -print(comm.rank, "after: pairs", len(np_), "dropped", dropped, "max gap", gap2, flush=True) diff --git a/scripts/sessions/parallel_addon/probe_place.py b/scripts/sessions/parallel_addon/probe_place.py deleted file mode 100644 index 28c80efa0..000000000 --- a/scripts/sessions/parallel_addon/probe_place.py +++ /dev/null @@ -1,50 +0,0 @@ -import numpy as np, underworld3 as uw, time -Lx, Ly, H = 3.0, 2.0, 1.0; h, hf = 0.15, 0.05 -yf = 1.0; xa, xb = 1.0, 2.0; zb = 0.5; setback = 4 * hf; ztop = H - setback -def dist(pts): - pts = np.asarray(pts, float).reshape(-1, 3) - dx = np.maximum(np.maximum(xa - pts[:, 0], pts[:, 0] - xb), 0.0) - dz = np.maximum(zb - pts[:, 2], 0.0) - return np.sqrt(dx**2 + (pts[:, 1] - yf)**2 + dz**2) -def metric(pts): - d = dist(pts); hh = np.where(d < hf, hf, np.minimum(hf + (h - hf) * (d - hf) / (6 * hf), h)); return 1 / hh**2 -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(Lx, Ly, H), cellSize=2 * h, regular=False, qdegree=3, refinement=1) -child = base.adapt(metric, max_levels=2) -X = child.X.coords -print("child cells", child.dm.getHeightStratum(0)[1], "points", len(X)) -# local edge lengths near the top above the fault -top = (X[:, 2] > 0.97) & (np.abs(X[:, 1] - yf) < 0.1) & (X[:, 0] > xa) & (X[:, 0] < xb) -print("vertices on top face above the fault:", top.sum()) -nx = int(round((xb - xa) / hf)) + 1; nz = int(round((H + 0.3 * hf - zb) / hf)) + 1 -xs = np.linspace(xa, xb, nx); zs = np.linspace(zb, H + 0.3 * hf, nz) -XX, ZZ = np.meshgrid(xs, zs, indexing="ij") -pts = np.column_stack([XX.ravel(), np.full(XX.size, yf), ZZ.ravel()]) -tris = [] -for i in range(nx - 1): - for j in range(nz - 1): - a, b, c, d = i * nz + j, (i + 1) * nz + j, (i + 1) * nz + j + 1, i * nz + j + 1 - tris += [[a, b, c], [a, c, d]] -tris = np.array(tris) -for sb, cl in ((5 * hf, 0.9), (6 * hf, 0.9), (6 * hf, 0.6)): - if sb == setback and cl == 0.9 and 'done_first' in dir(): - # variant: sheet NOT running past the top, stopping at ztop exactly - zs2 = np.linspace(zb, ztop, int(round((ztop - zb) / hf)) + 1) - XX2, ZZ2 = np.meshgrid(xs, zs2, indexing="ij"); pts2 = np.column_stack([XX2.ravel(), np.full(XX2.size, yf), ZZ2.ravel()]) - nz2 = len(zs2); tris2 = [] - for i in range(nx - 1): - for j in range(nz2 - 1): - a, b, c, d = i * nz2 + j, (i + 1) * nz2 + j, (i + 1) * nz2 + j + 1, i * nz2 + j + 1 - tris2 += [[a, b, c], [a, c, d]] - label = f"interior sheet to z={ztop:.2f}, no setback, clearance {cl}" - try: - t = time.perf_counter(); cut = child.add_conforming_sheet(pts2, np.array(tris2), "Fault", setback=0.0, clearance=cl, verbose=True) - print("OK:", label, f"{time.perf_counter()-t:.1f}s cells", cut.dm.getHeightStratum(0)[1]) - except Exception as e: print("REFUSED:", label, "->", str(e)[-160:]) - continue - done_first = True - label = f"setback {sb:.2f} clearance {cl}" - try: - t = time.perf_counter(); cut = child.add_conforming_sheet(pts, tris, "Fault", setback=sb, clearance=cl, verbose=True) - print("OK:", label, f"{time.perf_counter()-t:.1f}s cells", cut.dm.getHeightStratum(0)[1]) - except Exception as e: - print("REFUSED:", label, "->", str(e)[-160:]) diff --git a/scripts/sessions/parallel_addon/probe_place3d.py b/scripts/sessions/parallel_addon/probe_place3d.py deleted file mode 100644 index c72b98d20..000000000 --- a/scripts/sessions/parallel_addon/probe_place3d.py +++ /dev/null @@ -1,37 +0,0 @@ -import numpy as np, underworld3 as uw -from underworld3.utilities.place_surface import place_sheet -h_base = 0.1 -n = 5 -yy, zz = np.meshgrid(np.linspace(0.3, 0.7, n), np.linspace(0.3, 0.7, n), indexing="ij") -pts = np.column_stack([np.full(n * n, 0.5), yy.ravel(), zz.ravel()]) -tris = [] -for i in range(n - 1): - for j in range(n - 1): - a, b, c, d = i * n + j, (i + 1) * n + j, (i + 1) * n + j + 1, i * n + j + 1 - tris += [[a, b, c], [a, c, d]] -tris = np.array(tris) -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(1, 1, 1), cellSize=2 * h_base, refinement=1, qdegree=2) -for label, m in [("base", base)]: - try: - dm, info = place_sheet(m.dm, pts, tris, label="Flt", label_value=40) - print(label, "OK", {k: v for k, v in info.items() if not hasattr(v, "shape")}) - except Exception as e: - print(label, "FAIL", str(e)[:200]) -def metric(X): - d_plane = np.abs(X[:, 0] - 0.5) - d_rect = np.maximum(0.0, np.maximum(np.abs(X[:, 1] - 0.5) - 0.2, np.abs(X[:, 2] - 0.5) - 0.2)) - d = np.sqrt(d_plane**2 + d_rect**2) - h = np.minimum(h_base, h_base / 2 + (h_base / 2) * d / 0.15) - return 1.0 / h**2 -child = base.adapt(metric, max_levels=1) -import numpy as np -from petsc4py import PETSc -# boundary planarity check of the child: any boundary vertex off the box faces? -X = child.X.coords if hasattr(child.X, "coords") else None -for label, m in [("child", child)]: - try: - dm, info = place_sheet(m.dm, pts, tris, label="Flt", label_value=40) - print(label, "OK", {k: v for k, v in info.items() if not hasattr(v, "shape")}) - except Exception as e: - print(label, "FAIL", str(e)[:300]) -# which cells changed? compare volumes diff --git a/scripts/sessions/parallel_addon/probe_place3d_b.py b/scripts/sessions/parallel_addon/probe_place3d_b.py deleted file mode 100644 index 42419f557..000000000 --- a/scripts/sessions/parallel_addon/probe_place3d_b.py +++ /dev/null @@ -1,31 +0,0 @@ -import numpy as np, underworld3 as uw -from underworld3.utilities.place_surface import place_sheet -h_base = 0.1 -n = 5 -yy, zz = np.meshgrid(np.linspace(0.3, 0.7, n), np.linspace(0.3, 0.7, n), indexing="ij") -pts = np.column_stack([np.full(n * n, 0.5), yy.ravel(), zz.ravel()]) -tris = [] -for i in range(n - 1): - for j in range(n - 1): - a, b, c, d = i * n + j, (i + 1) * n + j, (i + 1) * n + j + 1, i * n + j + 1 - tris += [[a, b, c], [a, c, d]] -tris = np.array(tris) -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(1, 1, 1), cellSize=2 * h_base, refinement=1, qdegree=2) -def metric(X): - d_plane = np.abs(X[:, 0] - 0.5) - d_rect = np.maximum(0.0, np.maximum(np.abs(X[:, 1] - 0.5) - 0.2, np.abs(X[:, 2] - 0.5) - 0.2)) - d = np.sqrt(d_plane**2 + d_rect**2) - h = np.minimum(h_base, h_base / 2 + (h_base / 2) * d / 0.15) - return 1.0 / h**2 -child = base.adapt(metric, max_levels=1) -from petsc4py import PETSc -dm = child.dm -cS, cE = dm.getHeightStratum(0) -vols = np.array([dm.computeCellGeometryFVM(c)[0] for c in range(cS, cE)]) -print("child cells", cE - cS, "min vol", vols.min(), "neg", (vols < 0).sum(), "sum", vols.sum(), flush=True) -for kw in [dict(clearance=0.3), dict(clearance=0.9), dict(clearance=1.2), dict(size=0.05), dict(size=0.05, clearance=0.9)]: - try: - d, info = place_sheet(child.dm, pts, tris, label="Flt", label_value=40, **kw) - print(kw, "OK", {k: v for k, v in info.items() if not hasattr(v, "shape")}, flush=True) - except Exception as e: - print(kw, "FAIL", str(e)[:160], flush=True) diff --git a/scripts/sessions/parallel_addon/probe_place3d_c.py b/scripts/sessions/parallel_addon/probe_place3d_c.py deleted file mode 100644 index cc2b80aec..000000000 --- a/scripts/sessions/parallel_addon/probe_place3d_c.py +++ /dev/null @@ -1,31 +0,0 @@ -import numpy as np, underworld3 as uw, time -from underworld3.utilities.place_surface import place_sheet -h_base = 0.075; k = 2 -n = 7 -yy, zz = np.meshgrid(np.linspace(0.3, 0.7, n), np.linspace(0.3, 0.7, n), indexing="ij") -pts = np.column_stack([np.full(n * n, 0.5), yy.ravel(), zz.ravel()]) -rim = lambda i, j: i in (0, n - 1) or j in (0, n - 1) -tris = [] -for i in range(n - 1): - for j in range(n - 1): - a, b, c, d = i * n + j, (i + 1) * n + j, (i + 1) * n + j + 1, i * n + j + 1 - ra, rb, rc, rd = rim(i, j), rim(i + 1, j), rim(i + 1, j + 1), rim(i, j + 1) - tris += [[a, b, d], [b, c, d]] if ((ra and rb and rc) or (ra and rc and rd)) else [[a, b, c], [a, c, d]] -tris = np.array(tris) -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(1, 1, 1), cellSize=2 * h_base, refinement=1, qdegree=2) -def metric(X): - d_plane = np.abs(X[:, 0] - 0.5) - d_rect = np.maximum(0.0, np.maximum(np.abs(X[:, 1] - 0.5) - 0.2, np.abs(X[:, 2] - 0.5) - 0.2)) - d = np.sqrt(d_plane**2 + d_rect**2) - h_near, h_far, width = h_base / 2**k, h_base, 0.15 - h = np.minimum(h_far, h_near + (h_far - h_near) * d / width) - return 1.0 / h**2 -child = base.adapt(metric, max_levels=k) -print("child cells", child.dm.getHeightStratum(0)[1], flush=True) -for kw in [dict(clearance=1.2), dict(clearance=1.5), dict(size=0.02, clearance=0.9), dict(size=0.02, clearance=1.2), dict(size=0.04, clearance=0.9)]: - t0 = time.perf_counter() - try: - d, info = place_sheet(child.dm, pts, tris, label="Flt", label_value=40, **kw) - print(kw, "OK", {k_: v for k_, v in info.items() if not hasattr(v, "shape")}, round(time.perf_counter() - t0, 1), flush=True) - except Exception as e: - print(kw, "FAIL", str(e)[:160], round(time.perf_counter() - t0, 1), flush=True) diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index b56bc83bd..39de6a19f 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -7960,6 +7960,10 @@ def _adopt_cut_child(self, cut_dm, boundaries, info, mg_coarsening_ratio, child.regions = self.regions child._parent_mesh_version = self._mesh_version child._surface_info = info + if getattr(self, "_embedded_surfaces", None): + child._embedded_surfaces = set(self._embedded_surfaces) + from underworld3.utilities.addon_partition import inherit_provenance + inherit_provenance(child, self) # Mesh-owned custom-P geometric-MG tail. Adding a surface refines this # mesh, so this mesh plus everything below it is a valid coarse tail and @@ -8305,8 +8309,13 @@ def add_conforming_sheet(self, points, triangles, name, *, host.dm, points, triangles, label=name, label_value=boundaries[name].value, clearance=clearance, verbose=verbose, setback=setback, size=size) - return host._adopt_cut_child(cut_dm, boundaries, info, - mg_coarsening_ratio, verbose) + child = host._adopt_cut_child(cut_dm, boundaries, info, + mg_coarsening_ratio, verbose) + # a placed sheet is a fault surface to be: every later partition + # keeps it whole (addon_partition.embedded_surface_names) + child._embedded_surfaces = ( + set(getattr(host, "_embedded_surfaces", ()) or ()) | {name}) + return child def extrude(self, thickness, layers=2, base="Base", surface="Surface", qdegree=None, verbose=False): @@ -8344,11 +8353,10 @@ def add_fault(self, faults, verbose=False, cut=True, exclude=None, crossings are represented as OFFSET segments (a one-to-two-cell ligament). The result inherits a MESH-OWNED geometric-MG tail (the parent's coarse levels, the cut mesh finest — a cut is the same - grid re-represented); a parent without one yields a standalone - mesh — no tail, since - the coarse levels do not carry the fault (see - :meth:`add_conforming_surface`); solvers take their - algebraic-multigrid defaults. + grid re-represented): the parent's registered tail, or the coarse + levels of a parent built with ``refinement >= 1``. In parallel the + add-on is partitioned once for the faults before the cut + (``utilities.addon_partition``), so nothing moves afterwards. ``cut=False`` labels the mesh edges already on each polyline (the embedded spines of a placed band); ``across_seams=True`` then diff --git a/src/underworld3/utilities/addon_partition.py b/src/underworld3/utilities/addon_partition.py index 55fabb097..d030a5cdc 100644 --- a/src/underworld3/utilities/addon_partition.py +++ b/src/underworld3/utilities/addon_partition.py @@ -13,8 +13,9 @@ assignment computed on the finest level and applied to every add-on level through the parent-cell relation, so the add-on levels stay co-partitioned with each other exactly, the fault regions are rank-interior, and the load is -balanced by peeling non-fault cells from the owner rank to its underloaded -neighbours. The base levels never move (load fixes go on the child). Every +balanced by diffusion over the seams: any rank heavier than a neighbour passes +it cells that belong to no fault region, the owner's farthest first. The base +levels never move (load fixes go on the child). Every level carries, per cell, the cell it came from and its parent on the level below, so the one pair that is no longer co-partitioned — the base's finest level against the first add-on level — is an exact relation to fetch, not a @@ -30,12 +31,12 @@ from petsc4py import PETSc import underworld3 as uw +# A fine cell's centroid lies inside its parent for nested refinement; the +# tolerance is the cell locator's own, so a point it accepts is accepted here. +from underworld3.utilities.fault_mg import _INSIDE_TOL __all__ = ["partition_addon", "mark_labels", "mark_polylines", "mark_sheets"] -# A fine cell's centroid lies inside its parent for nested refinement; the -# tolerance absorbs the round-off of the barycentric solve. -_INSIDE_TOL = 1e-9 def _allreduce(comm, values, op=MPI.SUM): @@ -106,12 +107,17 @@ def marks(dm): X = dm.getCoordinatesLocal().array.reshape( -1, dm.getCoordinateDim())[: vE - vS] h_cell = np.asarray(cell_diameters(dm)) - h_vertex = np.zeros(vE - vS) + from underworld3.utilities.place_surface import _propagate_vertex + h_chart = np.zeros(pEnd - pStart) for c in range(cS, cE): for q in dm.getTransitiveClosure(c)[0]: if vS <= int(q) < vE: - i = int(q) - vS - h_vertex[i] = max(h_vertex[i], h_cell[c - cS]) + i = int(q) - pStart + h_chart[i] = max(h_chart[i], h_cell[c - cS]) + # a seam vertex sees only its local cells: the largest incident cell + # over every rank, so the reach is a function of the mesh alone + h_chart = _propagate_vertex(dm, h_chart, MPI.MAX, np.maximum) + h_vertex = h_chart[vS - pStart: vE - pStart] ids = np.zeros(pEnd - pStart, dtype=np.int32) for k, poly in enumerate(polylines, start=1): near = np.flatnonzero(_segment_distance(X[:, :2], poly) <= h_vertex) @@ -147,6 +153,78 @@ def marks(dm): return marks +def embedded_surface_names(mesh): + """The fault surfaces ``mesh`` already carries, as boundary names: every + split fault (its own label and its ``Plus`` / ``Minus`` sides) and every + sheet placed into it or an ancestor (``_embedded_surfaces``, recorded by + the placement). These are glued by every later partition, so a second + fault or sheet never breaks up the first. A material interface or a + domain-spanning internal boundary is not among them.""" + names = set(getattr(mesh, "_embedded_surfaces", ()) or ()) + for name in (getattr(mesh, "_fault_point_pairs", {}) or {}): + names.update((name, f"{name}Plus", f"{name}Minus")) + return names + + +def _embedded_surface_marks(mesh, names, first_id): + """Chart-length ids gluing the named surfaces: ``first_id + j`` on every + vertex of a facet of the ``j``-th name (sorted). ``names`` is the same on + every rank, so the ids are.""" + dm = mesh.dm + fS, fE = dm.getHeightStratum(1) + vS, vE = dm.getDepthStratum(0) + pStart, pEnd = dm.getChart() + ids = np.zeros(pEnd - pStart, dtype=np.int32) + present = {b.name: int(b.value) for b in mesh.boundaries} + for j, name in enumerate(names): + if name not in present or not dm.hasLabel(name): + continue + lab = dm.getLabel(name) + if lab.getStratumSize(present[name]) == 0: + continue + for f in lab.getStratumIS(present[name]).getIndices(): + if fS <= int(f) < fE: + for q in dm.getTransitiveClosure(int(f))[0]: + if vS <= int(q) < vE: + i = int(q) - pStart + ids[i] = max(ids[i], first_id + j) + return ids + + +def inherit_provenance(child, host): + """Record on ``child`` — a mesh made from ``host`` by a cut, a placement or + a split — the parent of each of its cells on the level ``host`` records + them against, while the two geometries still agree. The surgery was + rank-local (the partition made the region interior), so every child + centroid lies in a local host cell. Recording it here, rather than + locating later, keeps the relation exact after the child's nodes move. + A no-op when ``host`` carries no record (serial, or never partitioned). + Collective.""" + from underworld3.utilities.fault_mg import locate_in_cells_exhaustive + parent = getattr(host, "_addon_parent", None) + if parent is None or getattr(host, "_addon_below", None) is None: + return + comm = child.dm.getComm().tompi4py() + fcent, _v, cvf = _cell_centroids(child) + _c, vxy_h, cv_h = _cell_centroids(host) + cell = np.zeros(cvf.shape[0], dtype=np.int64) + n_bad = 0 + if cvf.shape[0]: + if cv_h.shape[0] == 0: + n_bad = cvf.shape[0] + else: + cell, lam = locate_in_cells_exhaustive(fcent, vxy_h, cv_h) + n_bad = int((lam.min(axis=1) < -_INSIDE_TOL).sum()) + n_bad = int(comm.allreduce(n_bad)) + if n_bad: + raise RuntimeError( + f"addon_partition: {n_bad} cell(s) of a surgery's result lie in no " + f"local cell of the mesh it was made from; the surgery was not " + f"rank-local.") + child._addon_parent = np.asarray(parent, dtype=np.int64)[cell] + child._addon_below = host._addon_below + + # --------------------------------------------------------------------------- # # The parent relation and the units # --------------------------------------------------------------------------- # @@ -180,6 +258,45 @@ def _parent_cells_local(coarse, fine): return cell +def _base_parent_addresses(base, lev, comm): + """``(n_cells, 2)``: rank and local cell of each cell's parent on ``base``. + + Located in this rank's base cells while the two are co-partitioned (as + ``adapt`` leaves them). Once a partition has moved the add-on, the pair is + not co-partitioned any more and the relation is READ, not searched: the + mesh that recorded it (``fault_mg.provenance_source`` — ``lev`` itself, or + the level a cut or a placement re-represented) is located locally, the + surgery having been rank-local, and its recorded parent is taken. That is + what lets a second sheet, or a fault after a material interface, be + partitioned on top of a first.""" + from underworld3.utilities.fault_mg import (locate_in_cells_exhaustive, + provenance_source) + pre = provenance_source(base, lev) + if pre is None: + local = _parent_cells_local(base, lev) + return np.column_stack([np.full(local.shape[0], comm.rank, + dtype=np.int64), local]) + if pre is lev: + return np.asarray(lev._addon_parent, dtype=np.int64).copy() + fcent, _v, cvf = _cell_centroids(lev) + _c, vxy_p, cv_p = _cell_centroids(pre) + cell = np.zeros(cvf.shape[0], dtype=np.int64) + n_bad = 0 + if cvf.shape[0]: + if cv_p.shape[0] == 0: + n_bad = cvf.shape[0] + else: + cell, lam = locate_in_cells_exhaustive(fcent, vxy_p, cv_p) + n_bad = int((lam.min(axis=1) < -_INSIDE_TOL).sum()) + n_bad = int(comm.allreduce(n_bad)) + if n_bad: + raise RuntimeError( + f"addon_partition: {n_bad} cell(s) lie in no local cell of the " + f"level their provenance is recorded on; the surgery since the " + f"last partition was not rank-local.") + return np.asarray(pre._addon_parent, dtype=np.int64)[cell] + + def _levels_of(mesh): """``(base, static, addon)``: the static level the first add-on level's transfer reads from (or None), the static wraps that form the tail under @@ -216,6 +333,8 @@ def _face_other_rank(dm): try: nroots, ilocal, iremote = sf.getGraph() except (ValueError, TypeError): + # Sanctioned: petsc4py raises on a forest with no graph set, which is + # a rank that shares no point; every face is then interior. return other[fS - pStart: fE - pStart] leaf = np.full(pEnd - pStart, -1, dtype=np.int64) if ilocal is not None and len(ilocal): @@ -312,8 +431,10 @@ def _peel_balance(dm, unit_of_cell, assign_unit, weight, unit_region, gap[s] = room chosen = [] for u in cand: - if room <= 0: - break + # a unit heavier than twice the room left would overshoot the + # gap and could be traded back next sweep + if room < 0.5 * weight[u]: + continue chosen.append((int(u), float(weight[u]))) room -= weight[u] taken[u] = True @@ -385,7 +506,7 @@ def _resolve_parents(below, lev): sf_get.bcastEnd(unit, directory, parent_new, MPI.REPLACE) sf_get.destroy() unit.Free() - if (parent_new < 0).any(): + if comm.tompi4py().allreduce(int((parent_new < 0).sum())): raise RuntimeError( "addon_partition internal: a cell's parent was not found after " "the move.") @@ -401,7 +522,7 @@ def _moved_wrap(level, dm, fields, pairs): boundaries=level.boundaries, verbose=False) moved._mg_static = False for attr in ("_fault_traces", "_fault_specs", "_fault_surfaces", - "_fault_seam_normals"): + "_fault_seam_normals", "_embedded_surfaces"): if getattr(level, attr, None) is not None: setattr(moved, attr, getattr(level, attr)) if pairs is not None: @@ -433,8 +554,10 @@ def partition_addon(mesh, region_marks, layers=1, tolerance=0.1, belongs to (:func:`mark_polylines`, :func:`mark_sheets`, :func:`mark_labels`). It is evaluated on EVERY add-on level, because the same fault's cell star reaches further on a coarser level and the - split replayed there needs its own star rank-interior. Regions whose - stars touch merge. + split replayed there needs its own star rank-interior. Every fault + surface the levels already carry (:func:`embedded_surface_names`) is + glued as well, so an add-on can be partitioned again for a further + fault without breaking up the first. Regions whose stars touch merge. layers : int Growth layers beyond the marked vertices' cell star: one makes every point of a star cell unshared (the split's seam rule); the 3-D carve's @@ -457,17 +580,17 @@ def partition_addon(mesh, region_marks, layers=1, tolerance=0.1, comm = mesh.dm.getComm().tompi4py() if comm.size == 1: return mesh - if getattr(mesh, "_addon_partition", None) is not None: - raise NotImplementedError( - "addon_partition: this add-on is already partitioned. Partition " - "once, with every fault of the network in one call.") - base, static, addon = _levels_of(mesh) levels = addon + [mesh] - parents = [] - for l, lev in enumerate(levels): - below = base if l == 0 else levels[l - 1] - parents.append(None if below is None else _parent_cells_local(below, lev)) + # the add-on levels are co-partitioned with each other (adapt made them + # so, or an earlier partition did): their parents are local. The first + # add-on level's parent on the base is local too unless an earlier + # partition moved it, in which case the recorded address is used + parents = [None] + for l in range(1, len(levels)): + parents.append(_parent_cells_local(levels[l - 1], levels[l])) + base_parent = (None if base is None + else _base_parent_addresses(base, levels[0], comm)) cS0, cE0 = levels[0].dm.getHeightStratum(0) n_units = cE0 - cS0 unit_of = [np.arange(n_units, dtype=np.int64)] @@ -475,12 +598,22 @@ def partition_addon(mesh, region_marks, layers=1, tolerance=0.1, unit_of.append(unit_of[l - 1][parents[l]]) weight = np.bincount(unit_of[-1], minlength=n_units).astype(float) - # fault regions on every level, lifted to units + # fault regions on every level, lifted to units: the faults asked for, + # and every interior surface the levels already carry (placed or split + # by an earlier call), so a second partition keeps those whole + marks = [np.asarray(region_marks(lev.dm), dtype=np.int32) for lev in levels] + n_asked = int(comm.allreduce(max((int(m.max()) for m in marks if m.size), + default=0), op=MPI.MAX)) + glued = sorted(set().union(*comm.allgather( + set().union(*[embedded_surface_names(lev) for lev in levels])))) + for l, lev in enumerate(levels): + marks[l] = np.maximum(marks[l], + _embedded_surface_marks(lev, glued, n_asked + 1)) unit_region = np.zeros(n_units, dtype=np.int32) pairs = set() for l, lev in enumerate(levels): cell_region, _canon, _regions = _region_cells( - lev.dm, np.asarray(region_marks(lev.dm)), layers=layers) + lev.dm, marks[l], layers=layers) for c in np.flatnonzero(cell_region): u = unit_of[l][c] k = int(cell_region[c]) @@ -495,6 +628,11 @@ def partition_addon(mesh, region_marks, layers=1, tolerance=0.1, regions = sorted(set(canon.values())) local = np.array([float(weight[unit_region == k].sum()) for k in regions]) counts = np.asarray(comm.allgather(local)).reshape(comm.size, len(regions)) + # an id no level marked anywhere (a fault outside the mesh, a glued + # surface absent from these levels) is not a region + present = counts.sum(axis=0) > 0 + regions = [k for k, keep in zip(regions, present) if keep] + counts = counts[:, present] assign = np.full(n_units, comm.rank, dtype=np.int32) owner = {} for j, k in enumerate(regions): @@ -520,7 +658,17 @@ def partition_addon(mesh, region_marks, layers=1, tolerance=0.1, load_after = _allreduce(comm, np.bincount(assign, weights=weight, minlength=comm.size)) n_moved_units = int(comm.allreduce(int((assign != comm.rank).sum()))) + record = { + "regions": len(regions), "owners": owner, + "region_weight": {k: float(counts[:, j].sum()) + for j, k in enumerate(regions)}, + "units_moved": n_moved_units, "sweeps": n_sweeps, + "load_before": load_before.tolist(), "load_after": load_after.tolist(), + } if n_moved_units == 0: + # already as the partition would leave it: the mesh stands, with the + # record of the decision + mesh._addon_partition = record if verbose: uw.pprint(f"[addon_partition] {len(regions)} fault region(s) " f"already rank-interior, load within tolerance " @@ -528,16 +676,31 @@ def partition_addon(mesh, region_marks, layers=1, tolerance=0.1, f"nothing moves") return mesh + # Every unit has a cell on every level, so a rank that receives no unit + # holds nothing on any level. A DM with every point on one rank reads as + # not distributed, and the Mesh constructor would partition it afresh, + # which the provenance cannot ride: refuse instead (collectively). + owners_used = int(np.count_nonzero(_allreduce( + comm, np.bincount(assign, minlength=comm.size)))) + if owners_used < 2: + raise RuntimeError( + "addon_partition: the whole add-on would sit on one rank (its " + "fault regions cover it); run on fewer ranks or refine the base.") + # apply the one assignment to every level, provenance riding along moved = [] for l, lev in enumerate(levels): cS, cE = lev.dm.getHeightStratum(0) n = cE - cS a_level = assign[unit_of[l]] - parent_rank = np.full(n, comm.rank if parents[l] is not None else -1, - dtype=np.int64) - parent_local = (parents[l] if parents[l] is not None - else np.full(n, -1, dtype=np.int64)) + if l > 0: + parent_rank = np.full(n, comm.rank, dtype=np.int64) + parent_local = parents[l] + elif base_parent is not None: + parent_rank, parent_local = base_parent[:, 0], base_parent[:, 1] + else: + parent_rank = np.full(n, -1, dtype=np.int64) + parent_local = np.full(n, -1, dtype=np.int64) self_rank = np.full(n, comm.rank, dtype=np.int64) self_local = np.arange(n, dtype=np.int64) fields = [parent_rank, parent_local, self_rank, self_local] @@ -549,8 +712,7 @@ def partition_addon(mesh, region_marks, layers=1, tolerance=0.1, f"addon_partition: {dropped} fault pair(s) split across ranks " f"by the move on level {l}") # every unit has a cell on every level, so a moved unit moves every - # level: the shell partition never hands the input back here - assert dm is not lev.dm + # level and the shell partition never hands the input back here moved.append(_moved_wrap(lev, dm, new_fields, new_pairs)) for l, lev in enumerate(moved): below = base if l == 0 else moved[l - 1] @@ -574,13 +736,7 @@ def partition_addon(mesh, region_marks, layers=1, tolerance=0.1, out._custom_mg_coarse_meshes = list(static) + moved[:-1] out._custom_mg_builder = getattr(mesh, "_custom_mg_builder", "barycentric") out._custom_mg_fac_zone = getattr(mesh, "_custom_mg_fac_zone", None) - out._addon_partition = { - "regions": len(regions), "owners": owner, - "region_weight": {k: float(counts[:, j].sum()) - for j, k in enumerate(regions)}, - "units_moved": n_moved_units, "sweeps": n_sweeps, - "load_before": load_before.tolist(), "load_after": load_after.tolist(), - } + out._addon_partition = record if verbose: uw.pprint(f"[addon_partition] {len(regions)} fault region(s) to ranks " f"{sorted(owner.values())}; {n_moved_units} unit(s) moved " diff --git a/src/underworld3/utilities/custom_mg.py b/src/underworld3/utilities/custom_mg.py index dc74a8ac2..e0d509fdc 100644 --- a/src/underworld3/utilities/custom_mg.py +++ b/src/underworld3/utilities/custom_mg.py @@ -1507,24 +1507,24 @@ def build(self, solver): comm = solver.dm.comm from underworld3.utilities import fault_mg for l in range(1, nlev): - # The pair the add-on partition moved apart (the base's finest - # static level against the first add-on level) carries the exact - # parent relation: each fine cell's parent is fetched from its - # rank through a star forest, nothing is located across - # partitions and nothing is all-gathered. Every other add-on - # pair is co-partitioned by the same partition and the - # rank-local builders below find every node where it stands. + # A pair the add-on partition recorded (the base's finest static + # level against the first add-on level, which the partition moved + # apart, and any unsplit pair of moved add-on levels) carries the + # exact parent relation: each fine cell's parent is fetched from + # its rank through a star forest, and nothing is located across + # partitions. Split coarse levels are never recorded against, so + # their pairs keep the side-aware cell-located transfer. pre = (fault_mg.provenance_source(self.level_meshes[l - 1], self.level_meshes[l]) if parallel else None) - if pre is not None: + + def provenance_transfer(): P = fault_mg.provenance_transfer_parallel( self.level_meshes[l - 1], self.level_meshes[l], pre, degree, continuous, coords[l - 1], coords[l], maps[l - 1], maps[l], nc, comm) self.level_transfer_stats[l] = fault_mg.last_transfer_stats - Ps.append(P) - continue + return P # Split-level multigrid: every pair touching a split level takes # the cell-located transfer. A split COARSE level defeats the # node-cloud builders (they triangulate the coarse nodes, and @@ -1540,7 +1540,9 @@ def build(self, solver): if self.has_split_levels and ( fault_mg.is_split_level(self.level_meshes[l - 1]) or fault_mg.is_split_level(self.level_meshes[l])): - if parallel: + if pre is not None: + P = provenance_transfer() + elif parallel: P = fault_mg.cell_located_transfer_parallel( self.level_meshes[l - 1], self.level_meshes[l], degree, continuous, coords[l - 1], coords[l], maps[l - 1], @@ -1558,7 +1560,9 @@ def build(self, solver): if parallel: args = (coords[l - 1], coords[l], maps[l - 1], maps[l], nc, self.builder, comm) - if self.cross_partition is True: + if pre is not None: + P = provenance_transfer() + elif self.cross_partition is True: P = _build_crosspart_transfer(*args) else: P = _build_parallel_transfer(*args) diff --git a/src/underworld3/utilities/fault_mg.py b/src/underworld3/utilities/fault_mg.py index 706d19988..5dcef02d2 100644 --- a/src/underworld3/utilities/fault_mg.py +++ b/src/underworld3/utilities/fault_mg.py @@ -364,15 +364,28 @@ def cell_located_transfer_parallel(coarse, fine, degree, continuous, def locate_in_cells_exhaustive(points, vxy, cv): - """:func:`locate_in_cells`, then every point still outside its candidate is - tested against EVERY cell. On a graded mesh a large cell's centroid can be - farther from a point inside it than many small neighbours' centroids are, - so the nearest-centroid candidates can all miss. Returns ``(cell, lam)``; - a point inside no cell keeps its best candidate with ``lam.min() < 0``.""" + """:func:`locate_in_cells`, widening the search for every point still outside + its candidate. On a graded mesh a large cell's centroid can be farther from a + point inside it than many small neighbours' centroids are, so the nearest + sixteen can all miss: the misses are retried against the nearest 128 and + 1024, and what still misses is tested against every cell, one point at a + time (memory one cell table, not points times cells). Returns + ``(cell, lam)``; a point inside no cell keeps its best candidate with + ``lam.min() < -_INSIDE_TOL``.""" cell, lam = locate_in_cells(points, vxy, cv) + if cv.shape[0] == 0: + return cell, lam + for k in (128, 1024): + miss = np.flatnonzero(lam.min(axis=1) < -_INSIDE_TOL) + if miss.size == 0: + return cell, lam + c2, l2 = locate_in_cells(points[miss], vxy, cv, k=k) + better = l2.min(axis=1) > lam[miss].min(axis=1) + cell[miss[better]] = c2[better] + lam[miss[better]] = l2[better] + V = vxy[cv] for k in np.flatnonzero(lam.min(axis=1) < -_INSIDE_TOL): - lam_all = _barycentric(np.repeat(points[k][None, :], cv.shape[0], axis=0), - vxy[cv]) + lam_all = _barycentric(np.repeat(points[k][None, :], cv.shape[0], axis=0), V) best = int(np.argmax(lam_all.min(axis=1))) if lam_all[best].min() > lam[k].min(): cell[k], lam[k] = best, lam_all[best] @@ -402,13 +415,13 @@ def provenance_transfer_parallel(coarse, fine, pre, degree, continuous, """The cell-located transfer for a pair the add-on partition moved apart, built from the recorded parent relation instead of a search. - ``pre`` is the fine level as the partition left it (``provenance_source``): - every cell of ``pre`` knows the rank and local index of its parent on - ``coarse``. A fine node's cell has a centroid, the centroid lies in one - local cell of ``pre`` (the surgery that made ``fine`` from ``pre`` was - rank-local, and a centroid is never on the fault, so this is the same - side-aware rule as :func:`cell_located_transfer_parallel`), and that - cell's parent is fetched — its node columns and vertex and node + ``pre`` carries the record (``provenance_source``): every cell of ``pre`` + knows the rank and local index of its parent on ``coarse``. It is ``fine`` + itself whenever the record was inherited when ``fine`` was made (the cut, + the placement and the split call ``addon_partition.inherit_provenance``), + and then a node's own cell is the recorded cell. Otherwise the fine cell's + centroid is located in ``pre`` (rank-local, since the surgery was, and a + centroid is never on the fault). The recorded parent is then fetched — its node columns and vertex and node coordinates — through one star forest. Co-resident parents are a local copy; the moved units' parents come from their ranks. Nothing is all-gathered. ``coarse_layout`` / ``fine_layout`` are @@ -432,11 +445,15 @@ def provenance_transfer_parallel(coarse, fine, pre, degree, continuous, dim = Xc.shape[1] owner = _fine_cell_of_node(fn, n_f) - if (owner < 0).any(): + if m4.allreduce(int((owner < 0).sum())): raise RuntimeError("fault_mg: a fine node belongs to no cell") mine = np.flatnonzero(((l2g_f >= fstart) & (l2g_f < fend)).any(axis=1)) fcent = vxy_f[cv_f].mean(axis=1) - if mine.size: + if pre is fine: + # the record is on this very mesh: a node's cell IS the recorded cell + pcell = owner[mine] + plam = np.zeros((mine.size, nv)) + elif mine.size: pcell, plam = locate_in_cells_exhaustive(fcent[owner[mine]], vxy_p, cv_p) else: pcell = np.zeros(0, dtype=np.int64) @@ -485,7 +502,6 @@ def provenance_transfer_parallel(coarse, fine, pre, degree, continuous, global last_transfer_stats last_transfer_stats = { "owned_nodes": int(m4.allreduce(int(mine.size))), - "misses": 0, "fetched_remote": int(m4.allreduce(int((uniq[:, 0] != m4.rank).sum()))), "local_coarse_cells": m4.allgather(int(nc_loc)), "local_fine_cells": m4.allgather(int(cv_f.shape[0])), diff --git a/src/underworld3/utilities/fault_split.py b/src/underworld3/utilities/fault_split.py index ebda629bc..a8adf083d 100644 --- a/src/underworld3/utilities/fault_split.py +++ b/src/underworld3/utilities/fault_split.py @@ -1777,6 +1777,8 @@ def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, cut.regions = mesh.regions cut._parent_mesh_version = mesh._mesh_version mesh._registered_children.add(cut) + from underworld3.utilities.addon_partition import inherit_provenance + inherit_provenance(cut, mesh) # A cut re-represents the parent's finest grid with the fault conformed, # so the coarse levels of a base built with refinement >= 1 serve the cut # mesh as its tail, the cut mesh being the finest level (the rule @@ -1849,9 +1851,12 @@ def _add_fault_sheets(mesh, faults, verbose=False, partition=True, if uw.mpi.size > 1 and partition: from underworld3.utilities.addon_partition import (mark_sheets, partition_addon) + import inspect + clearance = place_kwargs.get( + "clearance", + inspect.signature(place_sheet).parameters["clearance"].default) mesh = partition_addon( - mesh, mark_sheets([(p, t) for _n, p, t in sheets], - place_kwargs.get("clearance", 0.6)), + mesh, mark_sheets([(p, t) for _n, p, t in sheets], clearance), layers=2, verbose=verbose) cut = mesh for name, points, triangles in sheets: @@ -1868,7 +1873,16 @@ def _add_fault_sheets(mesh, faults, verbose=False, partition=True, child.regions = cut.regions child._parent_mesh_version = cut._mesh_version child._surface_info = info + child._embedded_surfaces = ( + set(getattr(cut, "_embedded_surfaces", ()) or ()) | {name}) + from underworld3.utilities.addon_partition import inherit_provenance + inherit_provenance(child, cut) own_tail = getattr(cut, "_custom_mg_coarse_meshes", None) + if own_tail is None and len(getattr(cut, "dm_hierarchy", []) or []) > 1: + # a mesh built with refinement >= 1: its coarse levels are the + # tail, the placed mesh re-representing its finest grid (the 2-D + # add_fault rule), in serial as in parallel + own_tail = list(cut._coarse_level_meshes()[:-1]) if own_tail is not None: child._custom_mg_coarse_meshes = list(own_tail) child._custom_mg_builder = getattr(cut, "_custom_mg_builder", @@ -1919,9 +1933,10 @@ def _fault_labels_touch_seam(dm, labels): ``labels`` is a sequence of ``(name, value)`` pairs. The test matches the 2-D split's refusal exactly: a fault facet that is itself shared, or has a shared cone vertex, would put a chain vertex on the seam — - the configuration :func:`_redistribute_fault_interior` exists to - dissolve. Every rank returns the same verdict (allreduce), so the - caller's redistribute-or-not decision is collective by construction. + the configuration the add-on partition + (:func:`~underworld3.utilities.addon_partition.partition_addon`) + dissolves. Every rank returns the same verdict (allreduce), so the + caller's partition-or-not decision is collective by construction. """ if uw.mpi.size == 1: return False @@ -1947,8 +1962,7 @@ def _fault_labels_touch_seam(dm, labels): return bool(uw.mpi.comm.allreduce(touch, op=MPI.LOR)) -def _redistribute_fault_interior(dm, labels, verbose=False, groups=None, - pairs=None): +def _redistribute_fault_interior(dm, labels, verbose=False): """Redistribute a cut mesh so each fault's cell star is rank-interior. The default partition's balance cuts are ATTRACTED to the locally @@ -1964,65 +1978,46 @@ def _redistribute_fault_interior(dm, labels, verbose=False, groups=None, construction. The coincident pair blocks of the contact solve are rank-local for the same reason. ``labels`` is a sequence of ``(name, value)`` pairs — a NETWORK is redistributed in one move, - keyed on the union of its faults, which is what lets every split - that follows run without migrating any prior pairing. Returns a - NEW dm; the input is untouched. - - ``groups`` partitions ``labels`` into the faults that must share a - rank (a junction-connected cluster); each group is one region of - :func:`place_surface._gather_regions`, moved to its own rank — or not - moved at all when its star is already interior to one — so two - faults a domain apart are never gathered together (#670). Without - ``groups`` the whole network is one region, as before. - ``_fault_point_pairs``) the pairing is carried through the move and - ``(dm, pairs, n_dropped)`` comes back — see - :func:`redistribute_by_assignment`. + keyed on the union of its faults. Returns a NEW dm; the input is + untouched. + + The add-on pipelines (cut, placement, split) partition the whole add-on + hierarchy instead (``addon_partition.partition_addon``); this per-mesh + move remains for the extruded layer, which is built on one rank and + distributed afresh (``meshing.extrude.extrude_sheet``). """ from underworld3.utilities.place_surface import _gather_regions comm = dm.getComm().tompi4py() if comm.size == 1: - return (dm, pairs, 0) if pairs is not None else dm - if groups is None: - groups = [list(labels)] - + return dm fS, fE = dm.getHeightStratum(1) vS, vE = dm.getDepthStratum(0) pStart, pEnd = dm.getChart() ids = np.zeros(pEnd - pStart, dtype=np.int32) - for g, group in enumerate(groups, start=1): - for name, value in group: - if not (dm.hasLabel(name) - and dm.getLabel(name).getStratumSize(int(value)) > 0): - continue - for f in dm.getLabel(name).getStratumIS(int(value)).getIndices(): - if fS <= int(f) < fE: - for q in dm.getTransitiveClosure(int(f))[0]: - if vS <= int(q) < vE: - i = int(q) - pStart - ids[i] = max(ids[i], g) + for name, value in labels: + if not (dm.hasLabel(name) + and dm.getLabel(name).getStratumSize(int(value)) > 0): + continue + for f in dm.getLabel(name).getStratumIS(int(value)).getIndices(): + if fS <= int(f) < fE: + for q in dm.getTransitiveClosure(int(f))[0]: + if vS <= int(q) < vE: + ids[int(q) - pStart] = 1 if comm.allreduce(int(ids.max()) if ids.size else 0, op=MPI.MAX) == 0: names = sorted(name for name, _value in labels) raise RuntimeError( f"fault_split: no facets labelled {names} found on any rank.") - # the pairing rides the move as labels (stamped on the input, removed - # again below, so the input stays untouched) - names = stamp_point_pairs(dm, pairs) if pairs is not None else [] work, n_region, n_moved, owner, _canon = _gather_regions(dm, ids, layers=1) if work is dm: work = dm.clone() # the contract: a new dm, untouched input - new_pairs, dropped = (collect_point_pairs(work, names) - if pairs is not None else (None, 0)) - for lab in [l for n in names for l in _pair_label_names(n)]: - if dm.hasLabel(lab): - dm.removeLabel(lab) if verbose: names = sorted(name for name, _value in labels) uw.pprint(f"[fault_split] {names}: {len(owner)} region(s) of " f"{n_region} cells, {n_moved} moved (owners " f"{sorted(owner.values())}); local cells now " f"{work.getHeightStratum(0)[1]}") - return (work, new_pairs, dropped) if pairs is not None else work + return work def _pair_label_names(name): @@ -2032,10 +2027,10 @@ def _pair_label_names(name): def stamp_point_pairs(dm, pairs_by_fault): """Write a coincident-point pairing into two labels per fault (one value per pair, on its Minus point and on its Plus point) so a redistribution - carries it: labels ride every point migration, where the migration star - forest PETSc returns for an INITIAL distribution does not index the - stratified numbering (measured: three quarters of the points mapped to the - wrong depth). The ids are globally unique (each rank's pairs offset by the + carries it: labels ride every point migration, with no reading of the + migration star forest's point map (per-cell values ride the same move + through ``distributeField`` in :func:`redistribute_by_assignment`). The + ids are globally unique (each rank's pairs offset by the ranks before it). Returns the fault names, for :func:`collect_point_pairs`. Collective.""" comm = dm.getComm().tompi4py() @@ -2156,20 +2151,18 @@ def split_faults(mesh, names, verbose=False, groups=None, across_seams=False): """Split a NETWORK of already-labelled faults, any dimension, any np. - The parallel obstruction to sequential :func:`split_fault` calls is - the redistribution each one performs: a prior fault's pairing holds - point ids of the current distribution and cannot yet migrate. So the - network redistributes ONCE, keyed on the union of every fault's - facets (each star moves to the rank already owning most of it), and - every split that follows runs with serial topology — the same - pre-pass the 2-D ``add_fault`` performs for freshly cut chains, made + In parallel the add-on is partitioned ONCE for the whole network + (``addon_partition.partition_addon``: every fault's star rank-interior + on every add-on level, the load balanced), and every split that + follows runs with serial topology and moves nothing — the same + partition the 2-D ``add_fault`` performs before its cut, made available for faults that already carry labels (the 3-D embedded patches, a reloaded mesh). ``groups``, a list of lists of names, says which faults must share a - rank (a junction-connected cluster); each group is redistributed to - its own rank, and one whose star is already interior to a rank is - not moved (#670). Faults not named in any group form one more. + rank (a junction-connected cluster); each group is one region of the + partition, sent to the rank already holding most of it. Faults not + named in any group form one more. ``across_seams`` splits THROUGH the seams instead (2-D, the band meshed through them): nothing is redistributed, see @@ -2278,11 +2271,11 @@ def split_fault(mesh, name, orientation=None, verbose=False, fault moves, re-cut the base mesh at the new position and split again — the same non-cumulative pattern as :meth:`Mesh.adapt`. - The split mesh carries NO geometric-multigrid tail. The coarse levels do - not carry the fault, so an essential condition on it would constrain zero - coarse DOFs and leave the custom-P coarse operator singular (see the - warning on :meth:`Mesh.add_conforming_surface`); solvers on this mesh get - their algebraic-multigrid defaults instead. + The split mesh takes the multigrid tail of ``mesh``: its own registered + levels, or the coarse levels of a mesh built with ``refinement >= 1``. + ``Mesh.add_fault`` splits the add-on levels of that tail as well; a tail + left welded under a split finest level is solved through the rotated + fault frame (see ``utilities.fault_mg``). Parameters ---------- @@ -2321,13 +2314,10 @@ def split_fault(mesh, name, orientation=None, verbose=False, if uw.mpi.size > 1 and not across: labels = [(name, int(mesh.boundaries[name].value))] # The split requires the fault's cell star rank-interior, and - # balance cuts are attracted to the refined fault region — gather - # the (thin) star onto one rank first. 3-D always redistributes - # (the patch star essentially always straddles the seam on a - # graded mesh); 2-D redistributes only when the chain actually - # touches the seam, which is what lets add_fault's one-shot - # union redistribution make every subsequent per-fault split a - # no-move here. Both branches of the decision are collective. + # balance cuts are attracted to the refined fault region. A mesh + # that add_fault or add_conforming_sheet partitioned is already so + # and nothing moves here; otherwise the add-on is partitioned for + # this fault. The decision is collective. if _fault_labels_touch_seam(source_dm, labels): # the add-on partition: the star rank-interior on every level, # a prior fault's pairing carried through the move as labels @@ -2487,6 +2477,10 @@ def carry_keys(d, what): child._parent_mesh_version = mesh._mesh_version child._fault_point_pairs = dict(prior) child._fault_point_pairs[name] = pairs + if getattr(mesh, "_embedded_surfaces", None): + child._embedded_surfaces = set(mesh._embedded_surfaces) + from underworld3.utilities.addon_partition import inherit_provenance + inherit_provenance(child, mesh) if prior_normals or seam_normals: child._fault_seam_normals = dict(prior_normals) child._fault_seam_normals[name] = seam_normals diff --git a/src/underworld3/utilities/place_surface.py b/src/underworld3/utilities/place_surface.py index 20a6b61c4..1df55b68d 100644 --- a/src/underworld3/utilities/place_surface.py +++ b/src/underworld3/utilities/place_surface.py @@ -3309,8 +3309,9 @@ def place_sheet(dm, points, triangles, label=CUT_LABEL, label_value=1, # rounds take whole vertex stars at the shell, one more diameter out): on # a partitioned adapt child the grown cavity touched a shared point and # the surgery rank raised "the gather mask under-reached" (measured - # 2026-10-17 at 2 and 8 ranks; tests/parallel/test_1097 reproduces it - # through the old Mesh path). The direct path needs the second layer too. + # 2026-10-17 at 2 and 8 ranks on the Mesh path before it partitioned; no + # test exercises the direct path). The gather here needs the second layer + # too. See planning file: underworld.md. mark = np.zeros(pEnd - pStart, dtype=np.int32) mark[np.flatnonzero(d_sheet < (clearance + 1.0) * h_vertex) + vS - pStart] = 1 diff --git a/tests/parallel/fault_fixtures.py b/tests/parallel/fault_fixtures.py new file mode 100644 index 000000000..a16e625d6 --- /dev/null +++ b/tests/parallel/fault_fixtures.py @@ -0,0 +1,99 @@ +"""Fixtures the parallel fault tests share. + +The 2-D adapt-on-top fault (a refined unit box, an add-on of ``k`` levels toward +a straight trace, the trace cut and split) with its simple-shear solve, and the +3-D disc sheet on a bisection child. Charter section 5: one copy, imported by +every test that needs it. +""" +import numpy as np + +import underworld3 as uw + +TIP_A, TIP_B = np.array([0.30, 0.45]), np.array([0.70, 0.55]) +TRACE = np.vstack([TIP_A, TIP_B]) +H_BASE = 0.05 + + +def base_2d(): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, + qdegree=2) + + +def adapted_2d(k, base=None): + """The add-on mesh of ``k`` levels toward the trace.""" + base = base_2d() if base is None else base + pts = np.column_stack([np.linspace(TIP_A[0], TIP_B[0], 9), + np.linspace(TIP_A[1], TIP_B[1], 9), np.zeros(9)]) + guide = uw.meshing.Surface(f"guide_{k}", base, pts) + guide.discretize() + return base.adapt(guide.refinement_metric_function( + h_near=H_BASE / 2**k, h_far=H_BASE, width=0.08, profile="linear"), + max_levels=k) + + +def faulted_2d(k, split_levels=True): + return adapted_2d(k).add_fault([("Flt", TRACE)], split_levels=split_levels) + + +def shear_fault_solve(mesh, law=0): + """Simple shear on the walls, a fault condition ``law`` on ``Flt``; returns + ``(stokes, info, peak slip)`` with the slip reduced over the ranks.""" + x, y = mesh.X + stokes = uw.systems.Stokes(mesh) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 + stokes.tolerance = 1.0e-6 + stokes.petsc_use_pressure_nullspace = True + for side in ("Top", "Bottom", "Left", "Right"): + stokes.add_dirichlet_bc((y - 0.5, 0.0), side) + stokes.add_fault_bc(law, "Flt") + stokes.solve() + info = stokes._rotated_freeslip_info + from underworld3.utilities.fault_contact import fault_slip + _s, V, _leak = fault_slip(stokes, "Flt", info) + local = float(np.abs(V).max()) if len(V) else 0.0 + return stokes, info, float(uw.mpi.comm.allreduce(local, op=max)) + + +# --- the 3-D disc sheet on a bisection child (tests/test_0867 carries the serial +# copy; the two directories are separate import roots) ---------------------- # +CENTRE = np.array([0.5, 0.5, 0.5]) +RADIUS = 0.2 +H_NEAR = 0.05 +TILTED = np.array([0.3, -0.5, 0.8]) / np.linalg.norm([0.3, -0.5, 0.8]) + + +def disc_sheet(normal=TILTED, n_rim=32, centre=CENTRE, radius=RADIUS): + """A planar disc as a centre fan.""" + helper = np.eye(3)[np.argmin(np.abs(normal))] + e1 = np.cross(normal, helper) + e1 /= np.linalg.norm(e1) + e2 = np.cross(normal, e1) + a = np.linspace(0.0, 2.0 * np.pi, n_rim, endpoint=False) + rim = centre + radius * (np.outer(np.cos(a), e1) + np.outer(np.sin(a), e2)) + pts = np.vstack([centre, rim]) + tris = np.array([(0, 1 + i, 1 + (i + 1) % n_rim) for i in range(n_rim)], + dtype=np.int64) + return pts, tris + + +def graded_disc_metric(normal=TILTED, discs=((CENTRE, RADIUS),)): + """``M = 1/h^2`` from the exact distance to the nearest disc, + ``h_near -> 2 h_near``.""" + def metric(X): + d = np.full(len(X), np.inf) + for centre, radius in discs: + rel = np.asarray(X)[:, :3] - centre + zn = rel @ normal + r = np.linalg.norm(rel - zn[:, None] * normal, axis=1) + d = np.minimum(d, np.sqrt(np.maximum(r - radius, 0.0) ** 2 + zn ** 2)) + h = np.clip(H_NEAR + d * H_NEAR / 0.4, H_NEAR, 2.0 * H_NEAR) + return 1.0 / h**2 + return metric + + +def base_3d(): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0, 0.0), maxCoords=(1.0, 1.0, 1.0), + cellSize=0.2, regular=False, qdegree=2, refinement=1) diff --git a/tests/parallel/test_1089_fault_split_multigrid_parallel.py b/tests/parallel/test_1089_fault_split_multigrid_parallel.py index e1fdf55e8..9ea82ba64 100644 --- a/tests/parallel/test_1089_fault_split_multigrid_parallel.py +++ b/tests/parallel/test_1089_fault_split_multigrid_parallel.py @@ -21,44 +21,21 @@ import sympy import underworld3 as uw +from fault_fixtures import faulted_2d, shear_fault_solve pytestmark = [pytest.mark.mpi(min_size=2), pytest.mark.level_2, pytest.mark.tier_b, pytest.mark.timeout(1800)] -TIP_A, TIP_B = np.array([0.30, 0.45]), np.array([0.70, 0.55]) SERIAL_PEAK_SLIP = 0.159581 # k = 2, frictionless, solve tolerance 1e-6 def _faulted_box(k, split_levels=True): - base = uw.meshing.UnstructuredSimplexBox( - minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, - qdegree=2) - pts = np.column_stack([np.linspace(TIP_A[0], TIP_B[0], 9), - np.linspace(TIP_A[1], TIP_B[1], 9), np.zeros(9)]) - guide = uw.meshing.Surface("guide", base, pts) - guide.discretize() - child = base.adapt(guide.refinement_metric_function( - h_near=0.05 / 2**k, h_far=0.05, width=0.08, profile="linear"), max_levels=k) - return child.add_fault([("Flt", np.vstack([TIP_A, TIP_B]))], - split_levels=split_levels) + return faulted_2d(k, split_levels=split_levels) def _solve(mesh): - x, y = mesh.X - stokes = uw.systems.Stokes(mesh) - stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel - stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 - stokes.tolerance = 1.0e-6 - stokes.petsc_use_pressure_nullspace = True - for side in ("Top", "Bottom", "Left", "Right"): - stokes.add_dirichlet_bc((y - 0.5, 0.0), side) - stokes.add_fault_bc(0, "Flt") - stokes.solve() - info = stokes._rotated_freeslip_info - from underworld3.utilities.fault_contact import fault_slip - _s, V, _leak = fault_slip(stokes, "Flt", info) - local = float(np.abs(V).max()) if len(V) else 0.0 - return info, float(uw.mpi.comm.allreduce(local, op=max)) + _stokes, info, slip = shear_fault_solve(mesh) + return info, slip def test_split_levels_converge_like_serial(): diff --git a/tests/parallel/test_1096_addon_partition_parallel.py b/tests/parallel/test_1096_addon_partition_parallel.py index ef8c2ec55..cf9041120 100644 --- a/tests/parallel/test_1096_addon_partition_parallel.py +++ b/tests/parallel/test_1096_addon_partition_parallel.py @@ -7,18 +7,23 @@ here are the ones the fault machinery and the multigrid transfers build on (design note ``docs/developer/design/parallel-addon-distribution-2026-10.md``): -* every fault region (the trace's cell star plus two growth layers) lies on - one rank, so the split's pairs and fans are rank-local by construction; +* every fault region (the trace's cell star plus a growth layer) lies on one + rank, on EVERY add-on level, so the split's pairs and fans are rank-local by + construction wherever the faults are replayed; * every add-on pair is co-partitioned exactly: each fine cell's parent cell on the level below is local; * every cell's recorded parent, fetched by its current address, contains the cell's centroid (the relation is exact, on the base boundary too); * the finest cells per rank are within the balance tolerance of the mean, or - as close as the seams allow (the diffusion stops when nothing can move). + as close as the fault region allows: the region is one indivisible unit. -Then ``add_fault`` on the partitioned add-on: no level is moved by the split, -the split-level transfers report no cross-rank miss, and the solve takes about -as many velocity iterations as in serial. +Then ``add_fault`` on the partitioned add-on: the cut, the split and the level +replay move nothing (each level's lineage runs back to the partitioned tail), +the split-level transfers report no cross-rank miss, the base boundary is +fetched by provenance, and the solve converges in about the serial count. + +Measured 2026-10-17 on this fixture (k = 2): velocity iterations 6 at np 2 and +4 (serial 6; test_1087); finest cells per rank 742 / 618 at np 2. """ import numpy as np import pytest @@ -26,6 +31,7 @@ from petsc4py import PETSc import underworld3 as uw +from fault_fixtures import TRACE, adapted_2d, shear_fault_solve from underworld3.utilities import addon_partition as ap from underworld3.utilities.fault_mg import (_barycentric, _cell_vertex_table, addon_level_indices) @@ -35,26 +41,10 @@ pytest.mark.timeout(900)] comm = MPI.COMM_WORLD -TIP_A, TIP_B = np.array([0.30, 0.45]), np.array([0.70, 0.55]) -TRACE = np.vstack([TIP_A, TIP_B]) -H_BASE = 0.05 K = 2 TOLERANCE = 0.1 -def _adapted(): - base = uw.meshing.UnstructuredSimplexBox( - minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, - qdegree=2) - pts = np.column_stack([np.linspace(TIP_A[0], TIP_B[0], 9), - np.linspace(TIP_A[1], TIP_B[1], 9), np.zeros(9)]) - guide = uw.meshing.Surface("guide", base, pts) - guide.discretize() - return base.adapt(guide.refinement_metric_function( - h_near=H_BASE / 2**K, h_far=H_BASE, width=0.08, profile="linear"), - max_levels=K) - - def _cells(mesh): cS, cE = mesh.dm.getHeightStratum(0) return cE - cS @@ -91,12 +81,12 @@ def _parents_contain_centroids(below, lev): def test_partition_invariants_on_the_adapt_child(): - child = _adapted() + child = adapted_2d(K) marks = ap.mark_polylines([TRACE]) out = ap.partition_addon(child, marks, layers=1, tolerance=TOLERANCE) - assert out is not child record = out._addon_partition assert record["regions"] == 1 + assert record["units_moved"] > 0 and out is not child # the fault region is rank-interior on the new finest AND on every add-on # level (the replay splits each level on its own star) @@ -107,11 +97,13 @@ def test_partition_invariants_on_the_adapt_child(): [int((cell_region == r).sum()) for r in regions])) assert all(np.count_nonzero(counts[:, j]) == 1 for j in range(len(regions))) - # add-on pairs are co-partitioned: parents local, provenance exact + # add-on pairs are co-partitioned and the recorded parents are exact: an + # independent fetch of each parent by its current address contains the + # cell's centroid, on the add-on pairs and on the base boundary assert len(levels) == len(_levels(child)[1]) for l in range(1, len(levels)): - ap._parent_cells_local(levels[l - 1], levels[l]) # raises if not assert _parents_contain_centroids(levels[l - 1], levels[l]) == 0 + assert comm.allreduce(int((levels[l]._addon_parent[:, 0] != comm.rank).sum())) == 0 assert _parents_contain_centroids(static[-1], levels[0]) == 0 # a cell of the coarsest add-on level has a remote parent exactly when # its unit moved @@ -119,7 +111,10 @@ def test_partition_invariants_on_the_adapt_child(): assert remote == record["units_moved"] # the finest cells are shared out: within tolerance of the mean, or down - # to the one indivisible fault region + # to the one indivisible fault region. The slack beyond the region is the + # diffusion's own stop rule: a rank stops offering when every lighter + # neighbour is within tolerance of it, so a load up to tolerance x mean + # above the region can remain on the owner. load = np.asarray(comm.allgather(_cells(out)), dtype=float) mean = load.mean() region = max(record["region_weight"].values()) @@ -130,50 +125,66 @@ def test_partition_invariants_on_the_adapt_child(): assert comm.allreduce(_cells(a)) == comm.allreduce(_cells(b)) -def test_partition_runs_once(): - child = _adapted() - out = ap.partition_addon(child, ap.mark_polylines([TRACE]), layers=1) - with pytest.raises(NotImplementedError): - ap.partition_addon(out, ap.mark_polylines([TRACE])) - - -def _solve(mesh): - x, y = mesh.X - stokes = uw.systems.Stokes(mesh) - stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel - stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 - stokes.tolerance = 1.0e-6 - stokes.petsc_use_pressure_nullspace = True - for side in ("Top", "Bottom", "Left", "Right"): - stokes.add_dirichlet_bc((y - 0.5, 0.0), side) - stokes.add_fault_bc(0, "Flt") - stokes.solve() - return stokes +def test_a_second_partition_keeps_the_first_fault_whole(): + """A split add-on partitioned again for a second fault: the first fault's + pairs survive the move whole (an interior labelled surface is glued like + a marked one), both faults' stars are rank-interior, and the base + boundary's relation is read from the first partition's record rather + than searched (the pair is no longer co-partitioned).""" + from underworld3.utilities.fault_split import _fault_labels_touch_seam + # a third of the box from the first trace: the two regions' shells (the + # stars plus a layer at the base spacing, about 0.15 each) do not touch, + # so they stay two regions + second = np.array([[0.30, 0.12], [0.70, 0.12]]) + split = adapted_2d(K).add_fault([("Flt", TRACE)]) + n_pairs = comm.allreduce(len(split._fault_point_pairs["Flt"])) + out = ap.partition_addon(split, ap.mark_polylines([second]), layers=1, + tolerance=TOLERANCE) + assert out._addon_partition["regions"] == 2 # the new and the glued + static, levels = _levels(out) + assert comm.allreduce(len(out._fault_point_pairs["Flt"])) == n_pairs + for lev in levels: + labels = [(f"Flt{side}", int(lev.boundaries[f"Flt{side}"].value)) + for side in ("Plus", "Minus")] + assert not _fault_labels_touch_seam(lev.dm, labels) + cell_region, _c, regions = _region_cells( + lev.dm, ap.mark_polylines([second])(lev.dm), layers=1) + counts = np.asarray(comm.allgather( + [int((cell_region == r).sum()) for r in regions])) + assert all(np.count_nonzero(counts[:, j]) == 1 for j in range(len(regions))) + assert _parents_contain_centroids(static[-1], levels[0]) == 0 def test_add_fault_moves_nothing_after_the_partition_and_converges(): - """``add_fault`` on the adapt child partitions once and then cuts and - splits in place: the cut child's parent is the partitioned mesh, every - split level's pairs are whole, the split-level transfers report no - cross-rank miss, and the solve converges in about the serial count.""" from underworld3.utilities.fault_split import _fault_labels_touch_seam - child = _adapted() + child = adapted_2d(K) out = child.add_fault([("Flt", TRACE)]) - partitioned = out.parent.parent # split <- cut <- partitioned - assert partitioned._addon_partition is not None + # split <- cut <- partitioned <- child, for the finest and for every + # replayed add-on level: nothing was moved after the partition + partitioned = out.parent.parent + assert partitioned.parent is child + assert partitioned._addon_partition["units_moved"] > 0 static, levels = _levels(out) + p_static, p_levels = _levels(partitioned) + assert [s is t for s, t in zip(static, p_static)] == [True] * len(static) + for lev, pre in zip(levels[:-1], p_levels[:-1]): + assert lev.parent.parent is pre for lev in levels: assert getattr(lev, "_fault_point_pairs", None), "a level was not split" labels = [(f"Flt{side}", int(lev.boundaries[f"Flt{side}"].value)) for side in ("Plus", "Minus")] assert not _fault_labels_touch_seam(lev.dm, labels) - stokes = _solve(out) - info = stokes._rotated_freeslip_info + stokes, info, _slip = shear_fault_solve(out) assert info["velocity_pc"] == "custom-FMG" and info["converged"], info assert info["vel_its_last"][-1] <= 9, info["vel_its_last"] stats = stokes._custom_mg["hierarchy"].level_transfer_stats - # pairs between add-on levels (and into the finest) are co-partitioned: - # the cell-located transfer finds every node locally - n_addon = len(levels) - for st in stats[-(n_addon - 1):] if n_addon > 1 else []: - assert st is not None and st["misses"] == 0, st + # the base boundary (static finest -> first add-on level) is the pair + # the partition moved apart: fetched by provenance, remote parents + # exactly where units moved + boundary = stats[len(static)] + assert "fetched_remote" in boundary, boundary + assert boundary["fetched_remote"] > 0 + # every add-on pair is co-partitioned: the cell-located transfer finds + # every node locally (the old co-location left 14 of 2992 remote) + for st in stats[len(static) + 1:]: + assert st is not None and st["misses"] == 0 and "fetched_remote" not in st, st diff --git a/tests/parallel/test_1097_place_sheet_partitioned_addon_parallel.py b/tests/parallel/test_1097_place_sheet_partitioned_addon_parallel.py index efb486c08..ac85bcf66 100644 --- a/tests/parallel/test_1097_place_sheet_partitioned_addon_parallel.py +++ b/tests/parallel/test_1097_place_sheet_partitioned_addon_parallel.py @@ -5,95 +5,84 @@ sheet's region itself, with a mask that did not cover the cavity's growth rounds, so on a partitioned adapt child the carve touched a shared point and the surgery rank refused ("the gather mask under-reached") where the same placement passes -serially (measured 2026-10-17 on the box fault model at 8 ranks). The split then -moved the fault's star again, level by level. +serially (measured 2026-10-17 on the box fault model at 8 ranks, and on this +fixture at 2 ranks). The split then moved the fault's star again. Now ``add_conforming_sheet`` partitions the add-on for the sheet first — the placement's own mask with two growth layers, on every add-on level — so the -placement gathers nothing (``n_gathered`` is zero), the split moves nothing, and -the fault solve runs on the geometric tail. The placed mesh keeps the domain -volume and one orientation, as the serial test of the same fixture asserts -(``tests/test_0867_place_sheet_adapted_3d.py``). +placement gathers nothing, the split moves nothing, the placed mesh keeps the +domain volume and one orientation (as the serial test of the same fixture, +``tests/test_0867``, asserts), the facet and pair counts are the serial ones +(this module's ``__main__`` run at np = 1 on the same host), and the fault +solve runs on the geometric tail with no transfer miss. """ import numpy as np import pytest import sympy import underworld3 as uw +from fault_fixtures import H_NEAR, base_3d, disc_sheet, graded_disc_metric +from serial_reference import emit, serial_reference from underworld3.utilities.place_surface import _cell_volumes_signed6 pytestmark = [pytest.mark.mpi(min_size=2), pytest.mark.level_2, pytest.mark.tier_b, pytest.mark.timeout(1500)] -CENTRE = np.array([0.5, 0.5, 0.5]) -RADIUS = 0.2 -H_NEAR = 0.05 -TILTED = np.array([0.3, -0.5, 0.8]) / np.linalg.norm([0.3, -0.5, 0.8]) - - -def _disc_sheet(normal, n_rim=32): - helper = np.eye(3)[np.argmin(np.abs(normal))] - e1 = np.cross(normal, helper) - e1 /= np.linalg.norm(e1) - e2 = np.cross(normal, e1) - a = np.linspace(0.0, 2.0 * np.pi, n_rim, endpoint=False) - rim = CENTRE + RADIUS * (np.outer(np.cos(a), e1) + np.outer(np.sin(a), e2)) - pts = np.vstack([CENTRE, rim]) - tris = np.array([(0, 1 + i, 1 + (i + 1) % n_rim) for i in range(n_rim)], - dtype=np.int64) - return pts, tris - - -def _graded_metric(normal): - def metric(X): - rel = np.asarray(X)[:, :3] - CENTRE - zn = rel @ normal - r = np.linalg.norm(rel - zn[:, None] * normal, axis=1) - d = np.sqrt(np.maximum(r - RADIUS, 0.0) ** 2 + zn ** 2) - h = np.clip(H_NEAR + d * H_NEAR / 0.4, H_NEAR, 2.0 * H_NEAR) - return 1.0 / h**2 - return metric - def _placed_and_split(): from underworld3.utilities.fault_split import split_fault - base = uw.meshing.UnstructuredSimplexBox( - minCoords=(0.0, 0.0, 0.0), maxCoords=(1.0, 1.0, 1.0), - cellSize=0.2, regular=False, qdegree=2, refinement=1) - child = base.adapt(_graded_metric(TILTED), max_levels=1) - pts, tris = _disc_sheet(TILTED) + base = base_3d() + child = base.adapt(graded_disc_metric(), max_levels=1) + pts, tris = disc_sheet() placed = child.add_conforming_sheet(pts, tris, "Fault", size=H_NEAR) - return placed, split_fault(placed, "Fault") + return base, placed, split_fault(placed, "Fault") + + +def _counts(placed, split): + """The quantities compared with the serial run: sheet facets, pairs, cells.""" + comm = uw.mpi.comm + return [float(placed._surface_info["n_surface_facets"]), + float(comm.allreduce(len(split._fault_point_pairs["Fault"]))), + float(comm.allreduce(split.dm.getHeightStratum(0)[1] + - split.dm.getHeightStratum(0)[0]))] def test_sheet_placement_gathers_nothing_and_the_split_moves_nothing(): from underworld3.utilities.fault_split import _fault_labels_touch_seam comm = uw.mpi.comm - placed, split = _placed_and_split() + base, placed, split = _placed_and_split() # the placement ran on a rank-interior region of the partitioned add-on assert placed._surface_info["n_gathered"] == 0 - assert placed._surface_info["n_surface_facets"] > 0 - assert placed.parent._addon_partition is not None + assert placed.parent._addon_partition["units_moved"] > 0 v6 = _cell_volumes_signed6(placed.dm) volume = comm.allreduce(float(np.abs(v6).sum()) / 6.0) assert abs(volume - 1.0) < 1e-9 sign_ok = bool((np.sign(v6) == np.sign(v6[0])).all()) if len(v6) else True assert comm.allreduce(sign_ok, op=min) - # the split found its star rank-interior and moved nothing: the split - # mesh's parent is the placed mesh itself, every pair is whole + # the split found its star rank-interior and moved nothing assert split.parent is placed - n_pairs = comm.allreduce(len(split._fault_point_pairs["Fault"])) - assert n_pairs > 0 labels = [(f"Fault{side}", int(split.boundaries[f"Fault{side}"].value)) for side in ("Plus", "Minus")] assert not _fault_labels_touch_seam(split.dm, labels) - # the tail rode through: static base levels plus the moved add-on level + # the serial run of this fixture: the same sheet facets and pairs; the + # cell count within the fill's partition noise (gmsh refills the cavity + # from a slightly different ring) + reference = serial_reference(__file__, "counts") + facets, pairs, cells = _counts(placed, split) + ref_facets, ref_pairs, ref_cells = reference["values"] + assert facets == ref_facets and pairs == ref_pairs, (facets, pairs, reference) + assert abs(cells - ref_cells) <= 0.02 * ref_cells, (cells, reference) + # the tail rode through: this fixture has no intermediate add-on level, so + # it is exactly the base's static wraps, the adapt child being the first + # level above them (the same objects, never moved) tail = split._custom_mg_coarse_meshes - assert tail is not None and len(tail) >= 2, tail + wraps = base._coarse_level_meshes() + assert tail is not None and len(tail) == len(wraps) + assert all(t is w for t, w in zip(tail, wraps)) def test_fault_solve_on_the_placed_split_runs_on_the_geometric_tail(): - _placed, split = _placed_and_split() + _base, _placed, split = _placed_and_split() v = uw.discretisation.MeshVariable("v", split, 3, degree=2) p = uw.discretisation.MeshVariable("p", split, 1, degree=1) stokes = uw.systems.Stokes(split, velocityField=v, pressureField=p) @@ -108,4 +97,49 @@ def test_fault_solve_on_the_placed_split_runs_on_the_geometric_tail(): info = stokes._rotated_freeslip_info assert info["velocity_pc"] == "custom-FMG" and info["converged"], info stats = stokes._custom_mg["hierarchy"].level_transfer_stats - assert all(st is None or st["misses"] == 0 for st in stats), stats + # the finest pair is the moved one: fetched by provenance + assert stats[-1] is not None and stats[-1]["fetched_remote"] > 0, stats + + +def test_two_sheets_placed_in_turn_then_split_together(): + """The fault-network route places sheets one at a time and splits them + together. The second placement partitions an add-on the first already + partitioned and placed into: the first sheet stays rank-interior (its + facets are glued), nothing is gathered, and the network splits with + every pair whole.""" + from underworld3.utilities.fault_split import (_fault_labels_touch_seam, + split_faults) + from fault_fixtures import CENTRE, TILTED + comm = uw.mpi.comm + c1, c2 = CENTRE - 0.17 * TILTED, CENTRE + 0.17 * TILTED + base = base_3d() + child = base.adapt(graded_disc_metric(discs=((c1, 0.15), (c2, 0.15))), + max_levels=1) + p1, t1 = disc_sheet(centre=c1, radius=0.15) + p2, t2 = disc_sheet(centre=c2, radius=0.15) + one = child.add_conforming_sheet(p1, t1, "FltA", size=H_NEAR) + two = one.add_conforming_sheet(p2, t2, "FltB", size=H_NEAR) + assert one._surface_info["n_gathered"] == 0 + assert two._surface_info["n_gathered"] == 0 + split = split_faults(two, ["FltA", "FltB"]) + for name in ("FltA", "FltB"): + assert comm.allreduce(len(split._fault_point_pairs[name])) > 0 + labels = [(f"{name}{side}", int(split.boundaries[f"{name}{side}"].value)) + for side in ("Plus", "Minus")] + assert not _fault_labels_touch_seam(split.dm, labels) + v6 = _cell_volumes_signed6(split.dm) + assert abs(comm.allreduce(float(np.abs(v6).sum()) / 6.0) - 1.0) < 1e-9 + + +def _fingerprint(mesh): + """Cell count and volume, from the cells themselves (the shared + fingerprint integrates a field, and this mesh carries none).""" + v6 = _cell_volumes_signed6(mesh.dm) + comm = uw.mpi.comm + return [float(comm.allreduce(len(v6))), + comm.allreduce(float(np.abs(v6).sum()) / 6.0)] + + +if __name__ == "__main__": + _base, _placed, _split = _placed_and_split() + emit(_counts(_placed, _split), _fingerprint(_split)) diff --git a/tests/test_1096_addon_cut_child_tail.py b/tests/test_1096_addon_cut_child_tail.py index 1ab697ff0..d4c8e424d 100644 --- a/tests/test_1096_addon_cut_child_tail.py +++ b/tests/test_1096_addon_cut_child_tail.py @@ -28,23 +28,16 @@ def _refined_box(refinement): refinement=refinement, qdegree=2) -def _cells(mesh): - cS, cE = mesh.dm.getHeightStratum(0) - return cE - cS - - @pytest.mark.parametrize("refinement", [1, 2]) def test_cut_child_takes_the_base_coarse_levels(refinement): base = _refined_box(refinement) child = base.add_fault([("Flt", TRACE)]) tail = child._custom_mg_coarse_meshes + # the levels are the base's own static wraps, coarsest first, the cut + # mesh re-representing the finest one + wraps = base._coarse_level_meshes() assert tail is not None and len(tail) == refinement, tail - # the levels are the base's own static wraps, coarsest first: each one - # about a quarter of the next (2-D uniform refinement), none of them the - # cut finest - sizes = [_cells(m) for m in tail] + [_cells(child)] - for coarse, fine in zip(sizes[:-1], sizes[1:]): - assert 3 * coarse <= fine <= 5 * coarse + 64, sizes + assert all(t is w for t, w in zip(tail, wraps[:-1])) assert all(getattr(m, "_mg_static", False) for m in tail) From 155bb49afc825f38ec390940b94cc38ecb2962be Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sun, 18 Oct 2026 10:01:54 +1100 Subject: [PATCH 50/59] Compiled topology tables for the fault and add-on machinery; band routes partitioned utilities/_plex_topology.pyx builds, in one C loop per stratum, the tables the fault, placement and add-on code assembled one petsc4py call at a time: closures by depth, supports, a per-cell section and its offsets, each cell's dof rows. The partition, the region growth shared with the placement gather, the marking rules, the redistribution's per-cell fields, the edge and cell-size tables, the mesh's point-location index and per-cell node table are now array operations on them, and the node-cloud transfer builders assemble once instead of a row per call. Every converted function is identical to its previous version, entry for entry, serially and on four ranks. The 3-D partition at 87,525 cells on four ranks went from 6.87 s to 1.35 s, the 2-D one at 94,910 from 2.94 s to 0.64 s. The point-location index takes each cell's vertex count from the closure: the element table gives 4 for a 1-D surface mesh's line cell, and the old slice of the closure took the edge point itself as a vertex there. The same count in three other mesh functions is flagged TODO(BUG). FaultNetwork.build (3-D band) and place_fault_ribbon_2d in gather mode partition before placing (mark_band, a superset of the placement's mask), so the placement moves nothing and adopt_hierarchy keeps the base's own levels under the partitioned host instead of co-locating copies. A direct place_sheet call gathers with two growth layers; with one it refused on a partitioned adapt child. A pre-existing mismatch is flagged TODO(BUG): the native checkpoint payload is written under /restart/petsc while the split-mesh reader looks at the top level, so test_0864's two-sided round trip fails here and on feature/tidal-shell. Tests: test_1098 (tables against DMPlex, serial and 3 ranks), test_1099 (band route, 2 and 4 ranks), test_1097 direct placement (shown failing first). Quick tier A: 1484 passed, 1 failed (the flagged checkpoint test); parallel batch at 2 ranks all passed. Underworld development team with AI support from Claude Code --- .../fault-parallel-placement-2026-09.md | 8 +- .../parallel-addon-distribution-2026-10.md | 44 +++++ .../conforming-surfaces-and-fault-zones.md | 3 +- ...l-addon-distribution-adversarial-review.md | 26 ++- scripts/sessions/parallel_addon/README.md | 4 + .../check_topology_equivalence.py | 139 +++++++++++++ .../parallel_addon/check_transfer_assembly.py | 48 +++++ .../parallel_addon/probe_partition_cost.py | 47 +++++ .../parallel_addon/probe_split_cost.py | 31 +++ setup.py | 11 ++ .../discretisation/discretisation_mesh.py | 152 ++++++--------- .../discretisation_mesh_variables.py | 7 + src/underworld3/meshing/fault_network.py | 16 +- src/underworld3/utilities/_plex_topology.pyx | 184 ++++++++++++++++++ src/underworld3/utilities/addon_partition.py | 172 ++++++++++------ src/underworld3/utilities/custom_mg.py | 109 +++++------ src/underworld3/utilities/edge_split.py | 30 +-- src/underworld3/utilities/fault_mg.py | 23 ++- src/underworld3/utilities/fault_split.py | 51 ++--- src/underworld3/utilities/place_surface.py | 120 +++++++----- ..._place_sheet_partitioned_addon_parallel.py | 17 ++ ...099_band_placement_partitioned_parallel.py | 78 ++++++++ tests/test_1098_plex_topology_tables.py | 84 ++++++++ 23 files changed, 1085 insertions(+), 319 deletions(-) create mode 100644 scripts/sessions/parallel_addon/check_topology_equivalence.py create mode 100644 scripts/sessions/parallel_addon/check_transfer_assembly.py create mode 100644 scripts/sessions/parallel_addon/probe_partition_cost.py create mode 100644 scripts/sessions/parallel_addon/probe_split_cost.py create mode 100644 src/underworld3/utilities/_plex_topology.pyx create mode 100644 tests/parallel/test_1099_band_placement_partitioned_parallel.py create mode 100644 tests/test_1098_plex_topology_tables.py diff --git a/docs/developer/design/fault-parallel-placement-2026-09.md b/docs/developer/design/fault-parallel-placement-2026-09.md index 7fd81b0d9..1e55eea31 100644 --- a/docs/developer/design/fault-parallel-placement-2026-09.md +++ b/docs/developer/design/fault-parallel-placement-2026-09.md @@ -12,9 +12,11 @@ and the multigrid side is `fault-patch-multigrid-2026-08.md`. Issues: For the cut, the 3-D sheet placement and the split reached through the Mesh API, the per-region gather and the per-split moves described here are superseded by one partition of the add-on hierarchy before any surgery -([parallel-addon-distribution-2026-10](parallel-addon-distribution-2026-10.md)). The -gather remains the mechanism of a direct `place_sheet` call and of -`place_thin_volume`; the seam-ligament and seam-conform modes below are unchanged. +([parallel-addon-distribution-2026-10](parallel-addon-distribution-2026-10.md)), and +so is the band routes' gather (`FaultNetwork.build`, `place_fault_ribbon_2d` in +gather mode). The gather remains the mechanism of a direct `place_sheet` or +`place_thin_volume` call; the seam-ligament and seam-conform modes below are +unchanged. ``` ## The frame: adapt-on-top diff --git a/docs/developer/design/parallel-addon-distribution-2026-10.md b/docs/developer/design/parallel-addon-distribution-2026-10.md index aa62fca08..ef723d9cb 100644 --- a/docs/developer/design/parallel-addon-distribution-2026-10.md +++ b/docs/developer/design/parallel-addon-distribution-2026-10.md @@ -142,6 +142,50 @@ the cut, placement and split pipelines. Still in place, outside this change: of `extrude_sheet`; and the node-cloud builders' cross-partition path, reachable for a pair with an empty column on a hierarchy registered by hand. +## The band routes and the direct placement (18 October) + +`FaultNetwork.build` (the 3-D band) and `place_fault_ribbon_2d` in gather mode now +partition before placing, with `mark_band`: a superset of the thin-volume +placement's own mask, at the placement's reach plus one band width from the band's +path (the skin lies within a mitred half width of it), with two growth layers. The +placement then moves nothing, `adopt_hierarchy` keeps the base's own levels under +the partitioned host instead of co-locating copies of them, and the pair the +partition separated is read from the recorded provenance (`test_1099`). The ribbon's +ligament and conform modes mesh across the seams in place and are unchanged. + +A direct `place_sheet` call, without the Mesh API's partition, now gathers with two +growth layers, the rule the partition uses for sheets. With one, the carve's growth +rounds reached a shared point on a partitioned adapt child and the placement +refused (`test_1097::test_direct_place_sheet_on_a_partitioned_adapt_child`, shown +failing before). + +## Cost: the per-point loops compiled (18 October) + +The partition, the region growth shared with the placement gather, the marking +rules, the redistribution's per-cell fields, and the mesh's own point-location +index and per-cell node table walked the mesh one petsc4py call per point. +`utilities/_plex_topology.pyx` builds the tables they need in one C loop per +stratum: closures by depth, supports, a per-cell section and its offsets, and each +cell's dof rows. The loops above them are now array operations. The node-cloud +transfer builders set one matrix row per Python call; they now assemble once +(`custom_mg._assemble_node_transfer`). Every converted function was checked against +its previous version, entry for entry, serially and on four ranks +(`scripts/sessions/parallel_addon/check_topology_equivalence.py`, +`check_transfer_assembly.py`); `tests/test_1098` pins the tables to DMPlex's own +point-by-point answers. + +| partition_addon, 4 ranks | cells | before | after | Python calls | +|---|---|---|---|---| +| 3-D adapt child, one sheet | 87525 | 6.87 s | 1.35 s | 1.46 M to 48 k | +| 2-D adapt child, k = 2 | 94910 | 2.94 s | 0.64 s | | + +What remains of the 3-D time is mostly PETSc building interpolation matrices for the +mesh's coordinate tables in the Mesh constructor, in C. A fault split of 19,072 +cells takes 3.6 s, of which the splitter's own per-point Python is about 0.1 s and +the four Mesh constructions most of the rest; the splitter's whole-mesh rebuild +(one cone per cell, one callback per point) grows linearly and is the next loop to +compile if splits at a million cells per rank are needed. + ## What was weighed and rejected - **Partition the base around the faults at construction.** Perfect co-partition and diff --git a/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md b/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md index a0590e6df..b4f1c7299 100644 --- a/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md +++ b/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md @@ -283,7 +283,8 @@ facets it was placed with. The fill itself rarely needs repair (see above), but the composition remains sound. ```{note} -Since October 2026 the Mesh API (`add_conforming_sheet`, `add_fault`) partitions the +Since October 2026 the Mesh API (`add_conforming_sheet`, `add_fault`) and the band +routes (`FaultNetwork.build`, `place_fault_ribbon_2d` in gather mode) partition the add-on for the surface before placing it, so the gather below finds the region rank-interior and moves nothing (`info["n_gathered"]` is then zero). The gather is what a direct call to `place_sheet` or `place_thin_volume` still does. See diff --git a/docs/reviews/2026-10/parallel-addon-distribution-adversarial-review.md b/docs/reviews/2026-10/parallel-addon-distribution-adversarial-review.md index 6843b1bd9..8cccc4156 100644 --- a/docs/reviews/2026-10/parallel-addon-distribution-adversarial-review.md +++ b/docs/reviews/2026-10/parallel-addon-distribution-adversarial-review.md @@ -73,18 +73,30 @@ Should-fix, fixed: `FAULT_MULTIGRID_HIERARCHIES.md` and the conforming-surfaces subsystem page. - Probes: the broken and unrelated ones deleted, a README dating the rest. -Left open, recorded: +Left open at the review, and their state on 18 October: -- `place_thin_volume` (the 3-D band of `FaultNetwork.build`) still gathers its own - regions, and `adopt_hierarchy` still co-locates the tail behind it - (`_colocate_level`). The placed-band route is the next one to put on the partition. -- A direct `place_sheet` call keeps the under-reaching mask (`TODO(BUG)` at the mask). -- Python loops per cell, face and vertex in the partition and the section fill: - seconds per level at 1e5 cells per rank, tens at 1e6. Not measured at scale. +- `place_thin_volume` behind `FaultNetwork.build` and the 2-D ribbon gathered its + own regions and `adopt_hierarchy` co-located the tail. **Done**: both routes + partition first with `mark_band`; `adopt_hierarchy` does not co-locate behind a + partitioned host (`test_1099`). +- A direct `place_sheet` call kept the under-reaching mask. **Done**: two growth + layers (`test_1097::test_direct_place_sheet_on_a_partitioned_adapt_child`, shown + failing first). +- Python loops per cell, face and vertex. **Done** for the partition, the region + growth, the marks, the redistribution's fields, the mesh's point-location index + and node table, and the node-cloud transfer assembly (`_plex_topology.pyx`; + equivalence checked entry for entry; `test_1098`). The 3-D partition at 87,525 + cells on four ranks went from 6.87 s to 1.35 s. The splitter's whole-mesh rebuild + is still per point (0.1 s of a 3.6 s split at 19,072 cells). +- When nothing needs to move, `partition_addon` stamps its record on the input mesh, + so a later placement on that same base that does not partition skips the tail's + co-location; its transfers then take the cross-partition path, correct and slower. - The balance's seam adjacency is the original partition's; received units are not passed on. It converges on the fixtures; not a bound. - `_face_other_rank` and `place_surface._propagate_vertex` return early on a `getGraph` exception before a star-forest collective (pre-existing pattern). +- `DMPlex.getPeriodicity()` segfaults on a non-periodic mesh in this petsc4py; the + periodic test reads the cell-coordinate handle instead. - Only np = 2 runs in CI; the 4- and 8-rank runs here are by hand. ## Attacks that found nothing diff --git a/scripts/sessions/parallel_addon/README.md b/scripts/sessions/parallel_addon/README.md index 689b12ec4..ec13719b8 100644 --- a/scripts/sessions/parallel_addon/README.md +++ b/scripts/sessions/parallel_addon/README.md @@ -15,3 +15,7 @@ that are models. Run with `OMP_NUM_THREADS=1 mpirun -np N python ...`. | `probe_sf_exchange.py` | star-forest broadcast and reduce semantics in petsc4py | | `probe_partition_2d.py [k]` | `partition_addon` invariants on the 2-D adapt-on-top fault | | `probe_load_3d.py` | per-rank load of the split refined patch box after the partition | +| `probe_partition_cost.py [h] [k] [dim]` | wall time and cProfile of `partition_addon` on a larger adapt child | +| `probe_split_cost.py [dim]` | wall time and cProfile of a fault split | +| `check_topology_equivalence.py` | every function moved onto `_plex_topology` against its previous version (needs the old files in `/tmp/parallel-addon/old/`, `git show dd1d6eea:`) | +| `check_transfer_assembly.py` | the vectorised node-transfer assembly against the per-row builders it replaced | diff --git a/scripts/sessions/parallel_addon/check_topology_equivalence.py b/scripts/sessions/parallel_addon/check_topology_equivalence.py new file mode 100644 index 000000000..bfca67973 --- /dev/null +++ b/scripts/sessions/parallel_addon/check_topology_equivalence.py @@ -0,0 +1,139 @@ +"""Every function converted to the compiled topology tables against its previous +(per-point Python) version, taken from the commit before the conversion: the +results must be identical. Serial and parallel. + + git show dd1d6eea: > /tmp/parallel-addon/old/_old.py (see README) + mpirun -np N python check_topology_equivalence.py +""" +import ast, importlib.util, sys, types +import numpy as np +import underworld3 as uw +from mpi4py import MPI +from petsc4py import PETSc + +comm = MPI.COMM_WORLD +OLD = "/tmp/parallel-addon/old/" + + +def load(name): + spec = importlib.util.spec_from_file_location(name, OLD + name + ".py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def method(file, cls_name, meth): + """An old Mesh method as a plain function of ``self``.""" + tree = ast.parse(open(OLD + file + ".py").read()) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == meth: + src = ast.get_source_segment(open(OLD + file + ".py").read(), node) + import textwrap + ns = {"numpy": np, "uw": uw, "PETSc": PETSc} + exec(textwrap.dedent(src), ns) + return ns[meth] + raise KeyError(meth) + + +es_old, ps_old, ap_old, fs_old = (load("edge_split_old"), load("place_surface_old"), + load("addon_partition_old"), load("fault_split_old")) +from underworld3.utilities import addon_partition as ap, edge_split as es, place_surface as ps, fault_split as fs + +fails = [] + + +def same(tag, a, b): + ok = (np.array_equal(np.asarray(a), np.asarray(b)) if not isinstance(a, (set, dict, tuple)) + else a == b) + ok = comm.allreduce(bool(ok), op=MPI.LAND) + if comm.rank == 0: + print(f" {'ok ' if ok else 'DIFF'} {tag}", flush=True) + if not ok: + fails.append(tag) + + +meshes = { + "2d": uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.03, qdegree=2), + "3d": uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(1, 1, 1), cellSize=0.12, regular=False, qdegree=2), +} +cni_old = method("discretisation_mesh_old", "Mesh", "_cell_node_indices") +rows_old = method("discretisation_mesh_old", "Mesh", "_coord_rows_for_points") +kd_old = method("discretisation_mesh_old", "Mesh", "_build_kd_tree_index") +for tag, mesh in meshes.items(): + if comm.rank == 0: + print(f"{tag} np {comm.size}", flush=True) + dm = mesh.dm + for deg in (1, 2): + mesh._cell_node_array.clear() + new = mesh._cell_node_indices(deg, True) + saved = mesh._cell_node_array + mesh._cell_node_array = {} + from math import comb + ns = cni_old.__globals__; ns["comb"] = comb + old = cni_old(mesh, deg, True) + mesh._cell_node_array = saved + same(f"_cell_node_indices degree {deg}", new, old) + # the point-location index + proxy = types.SimpleNamespace(_nav_dm=mesh._nav_dm, dm=mesh.dm, _nav_coords=mesh._nav_coords, + element=mesh.element, dim=mesh.dim, cdim=mesh.cdim, _index=None) + proxy._coord_rows_for_points = lambda nav, pts: rows_old(proxy, nav, pts) + kd_old(proxy) + mesh._index = None + mesh._build_kd_tree_index() + same("kd index control points", mesh._indexCoords, proxy._indexCoords) + same("kd index cell map", mesh._indexMap, proxy._indexMap) + same("kd index centroids", mesh._nav_centroids, proxy._nav_centroids) + same("kd index reach", mesh._local_cell_reach, proxy._local_cell_reach) + from underworld3.utilities.fault_mg import _cell_vertex_table + vxy, cv = _cell_vertex_table(mesh) + vxy_o = np.asarray(mesh._get_coords_for_basis(1, True)) + cv_o = np.asarray(mesh._cell_node_indices(1, True)) + same("_cell_vertex_table: each cell's vertex coordinates", vxy[cv], vxy_o[cv_o]) + same("_edge_lengths", es._edge_lengths(dm), es_old._edge_lengths(dm)) + same("_cell_edges", np.array(es._cell_edges(dm)), np.array(es_old._cell_edges(dm))) + same("_edge_strength", np.column_stack(es._edge_strength(dm)), np.column_stack(es_old._edge_strength(dm))) + if tag == "3d": + T = ps._tet_vertices(dm) + same("_tet_vertices", T, ps_old._tet_vertices(dm)) + hv_n, hc_n = ps._vertex_h_3d(dm, T, dm.getDepthStratum(0)[1] - dm.getDepthStratum(0)[0]) + hv_o, hc_o = ps_old._vertex_h_3d(dm, T, dm.getDepthStratum(0)[1] - dm.getDepthStratum(0)[0]) + same("_vertex_h_3d", hv_n, hv_o) + nv = dm.getDepthStratum(0)[1] - dm.getDepthStratum(0)[0] + same("_true_wall_vertex_mask", ps._true_wall_vertex_mask(dm, nv), ps_old._true_wall_vertex_mask(dm, nv)) + # region growth: two regions near each other, one far + pStart, pEnd = dm.getChart(); vS, vE = dm.getDepthStratum(0) + X = dm.getCoordinatesLocal().array.reshape(-1, mesh.cdim)[: vE - vS] + ids = np.zeros(pEnd - pStart, dtype=np.int32) + ids[vS - pStart + np.flatnonzero(np.linalg.norm(X - 0.3, axis=1) < 0.08)] = 1 + ids[vS - pStart + np.flatnonzero(np.linalg.norm(X - 0.42, axis=1) < 0.06)] = 2 + ids[vS - pStart + np.flatnonzero(np.linalg.norm(X - 0.8, axis=1) < 0.08)] = 3 + for layers in (0, 1, 2): + rn = ps._region_cells(dm, ids, layers=layers) + ro = ps_old._region_cells(dm, ids, layers=layers) + same(f"_region_cells layers {layers}: cells", rn[0], ro[0]) + same(f"_region_cells layers {layers}: merge", tuple(sorted(rn[1].items())), tuple(sorted(ro[1].items()))) + # marks + poly = np.array([[0.3, 0.45], [0.7, 0.55]]) if tag == "2d" else None + if poly is not None: + same("mark_polylines", ap.mark_polylines([poly])(dm), ap_old.mark_polylines([poly])(dm)) + labels = [(b.name, int(b.value)) for b in mesh.boundaries if dm.hasLabel(b.name)][:3] + same("mark_labels", ap.mark_labels(labels)(dm), ap_old.mark_labels(labels)(dm)) + same("_fault_labels_touch_seam", fs._fault_labels_touch_seam(dm, labels), + fs_old._fault_labels_touch_seam(dm, labels)) + # unit adjacency, as sets of pairs (order is not part of the contract) + cS, cE = dm.getHeightStratum(0) + unit = np.arange(cE - cS) // 3 + fo = ap._face_other_rank(dm) + i_n, s_n = ap._unit_adjacency(dm, unit, fo) + i_o, s_o = ap_old._unit_adjacency(dm, unit, fo) + same("_unit_adjacency inner", set(map(tuple, i_n.tolist())), set(map(tuple, i_o.tolist()))) + same("_unit_adjacency seam", set(map(tuple, s_n.tolist())), set(map(tuple, s_o.tolist()))) + # the redistribution's per-cell fields + if comm.size > 1: + assign = (np.arange(cE - cS) + comm.rank) % comm.size + fields = [np.arange(cE - cS) + 1000 * comm.rank, np.full(cE - cS, comm.rank)] + _w, _p, _d, f_n = fs.redistribute_by_assignment(dm, assign.astype(np.int32), fields=fields) + _w, _p, _d, f_o = fs_old.redistribute_by_assignment(dm, assign.astype(np.int32), fields=fields) + same("redistribute_by_assignment fields", np.column_stack(f_n), np.column_stack(f_o)) +if comm.rank == 0: + print("FAILURES:", fails if fails else "none", flush=True) diff --git a/scripts/sessions/parallel_addon/check_transfer_assembly.py b/scripts/sessions/parallel_addon/check_transfer_assembly.py new file mode 100644 index 000000000..400d8e67f --- /dev/null +++ b/scripts/sessions/parallel_addon/check_transfer_assembly.py @@ -0,0 +1,48 @@ +"""The vectorised node-transfer assembly against the per-row builders it +replaced (custom_mg from the commit before): the same MPIAIJ entries, on a +2-D and a 3-D refined box's coarse-to-fine pair, both builders. + + git show dd1d6eea:src/underworld3/utilities/custom_mg.py > /tmp/parallel-addon/old/custom_mg_old.py + mpirun -np N python check_transfer_assembly.py +""" +import importlib.util +import numpy as np +import sympy +import underworld3 as uw +from mpi4py import MPI +from underworld3.utilities import custom_mg as new + +spec = importlib.util.spec_from_file_location("custom_mg_old", "/tmp/parallel-addon/old/custom_mg_old.py") +old = importlib.util.module_from_spec(spec); spec.loader.exec_module(old) +comm = MPI.COMM_WORLD +bad = [] +for dim in (2, 3): + mesh = (uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.1, refinement=1, qdegree=2) + if dim == 2 else + uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(1, 1, 1), cellSize=0.3, regular=False, refinement=1, qdegree=2)) + v = uw.discretisation.MeshVariable(f"v{dim}", mesh, dim, degree=2) + p = uw.discretisation.MeshVariable(f"p{dim}", mesh, 1, degree=1) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.add_dirichlet_bc((0.0,) * dim, "Bottom") + stokes._setup_pointwise_functions(verbose=False) if hasattr(stokes, "_setup_pointwise_functions") else None + stokes._build(verbose=False) if hasattr(stokes, "_build") else None + wraps = mesh._coarse_level_meshes() + cm = wraps[0] + Xc = np.asarray(cm._get_coords_for_basis(2, True)); Xf = np.asarray(mesh._get_coords_for_basis(2, True)) + lay_c = new._coarse_dof_layout(stokes, cm, 0); lay_f = new._level_dof_layout(stokes.dm, 0) + for name in ("_build_parallel_transfer", "_build_crosspart_transfer"): + args = (Xc, Xf, lay_c, lay_f, dim, new.barycentric_prolongation, stokes.dm.comm) + A = getattr(new, name)(*args) + args_o = (Xc, Xf, lay_c, lay_f, dim, old.barycentric_prolongation, stokes.dm.comm) + B = getattr(old, name)(*args_o) + ia, ja, va = A.getValuesCSR(); ib, jb, vb = B.getValuesCSR() + same = (A.getSizes() == B.getSizes() and np.array_equal(ia, ib) and np.array_equal(ja, jb) + and np.array_equal(va, vb)) + same = comm.allreduce(bool(same), op=MPI.LAND) + if comm.rank == 0: + print(f"{dim}-D {name}: {'identical' if same else 'DIFFERENT'} ({A.getSize()})", flush=True) + if not same: + bad.append((dim, name)) +if comm.rank == 0: + print("FAILURES:", bad or "none", flush=True) diff --git a/scripts/sessions/parallel_addon/probe_partition_cost.py b/scripts/sessions/parallel_addon/probe_partition_cost.py new file mode 100644 index 000000000..7425cb46f --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_partition_cost.py @@ -0,0 +1,47 @@ +"""Cost of partition_addon on a larger adapt-on-top fault: wall time of the call +and rank 0's cProfile of it (top cumulative entries). + +mpirun -np N python probe_partition_cost.py [cell_size=0.02] [k=2] [dim=2] +""" +import cProfile, io, pstats, sys, time +import numpy as np +import underworld3 as uw +from mpi4py import MPI +from underworld3.utilities import addon_partition as ap + +comm = MPI.COMM_WORLD +h = float(sys.argv[1]) if len(sys.argv) > 1 else 0.02 +k = int(sys.argv[2]) if len(sys.argv) > 2 else 2 +dim = int(sys.argv[3]) if len(sys.argv) > 3 else 2 +if dim == 2: + base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=h, refinement=1, qdegree=2) + A, B = np.array([0.3, 0.45]), np.array([0.7, 0.55]) + pts = np.column_stack([np.linspace(A[0], B[0], 9), np.linspace(A[1], B[1], 9), np.zeros(9)]) + guide = uw.meshing.Surface("guide", base, pts); guide.discretize() + child = base.adapt(guide.refinement_metric_function(h_near=h / 2**k, h_far=h, width=4 * h, profile="linear"), max_levels=k) + marks = ap.mark_polylines([np.vstack([A, B])]) + layers = 1 +else: + base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(1, 1, 1), cellSize=h, regular=False, qdegree=2, refinement=1) + c = np.array([0.5, 0.5, 0.5]); n = np.array([0.0, 1.0, 0.0]) + def metric(X): + d = np.abs((np.asarray(X)[:, :3] - c) @ n) + hh = np.clip(h / 2**k + d * 0.5, h / 2**k, h) + return 1.0 / hh**2 + child = base.adapt(metric, max_levels=k) + a = np.linspace(0, 2 * np.pi, 24, endpoint=False) + rim = c + 0.25 * np.column_stack([np.cos(a), np.zeros(24), np.sin(a)]) + sheet_pts = np.vstack([c, rim]); tris = np.array([(0, 1 + i, 1 + (i + 1) % 24) for i in range(24)]) + marks = ap.mark_sheets([(sheet_pts, tris)], 0.6) + layers = 2 +cS, cE = child.dm.getHeightStratum(0) +ncell = comm.allreduce(cE - cS) +prof = cProfile.Profile() +comm.Barrier(); t = time.perf_counter(); prof.enable() +out = ap.partition_addon(child, marks, layers=layers) +prof.disable(); comm.Barrier(); dt = time.perf_counter() - t +rec = out._addon_partition +if comm.rank == 0: + print(f"np {comm.size} dim {dim} h {h} k {k}: finest cells {ncell}, partition {dt:.2f} s, units moved {rec['units_moved']}, sweeps {rec['sweeps']}", flush=True) + s = io.StringIO(); st = pstats.Stats(prof, stream=s); st.sort_stats("tottime").print_stats(25); st.print_callers("_get_coords_for_basis") + print("\n".join(l for l in s.getvalue().splitlines() if ("ncalls" in l or "(" in l)), flush=True) diff --git a/scripts/sessions/parallel_addon/probe_split_cost.py b/scripts/sessions/parallel_addon/probe_split_cost.py new file mode 100644 index 000000000..aa118644c --- /dev/null +++ b/scripts/sessions/parallel_addon/probe_split_cost.py @@ -0,0 +1,31 @@ +"""Cost of split_fault: wall time and the top own-time entries, 2-D cut and 3-D patch. + +python probe_split_cost.py [dim=3] +""" +import cProfile, io, pstats, sys, time +import numpy as np +import underworld3 as uw +from underworld3.utilities.fault_split import split_fault + +dim = int(sys.argv[1]) if len(sys.argv) > 1 else 3 +if dim == 3: + patch = np.array([[1.0, 1.0, 0.5], [2.0, 1.0, 0.5], [2.0, 1.0, 0.8], [1.0, 1.0, 0.8]]) + mesh = uw.meshing.BoxInternalPatch(cellSize=0.8, minCoords=(0, 0, 0), maxCoords=(3, 2, 1), patch_points=patch, + patch_name="Fault", patch_cellSize=0.4, grading_distance=0.8, refinement=2, qdegree=3) + name = "Fault" +else: + base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.005, qdegree=2) + from underworld3.utilities.fault_split import add_fault + prof0 = cProfile.Profile() + mesh = None +cS, cE = (mesh.dm.getHeightStratum(0) if mesh is not None else (0, 0)) +prof = cProfile.Profile(); t = time.perf_counter(); prof.enable() +if dim == 3: + out = split_fault(mesh, name) +else: + out = base.add_fault([("Flt", np.array([[0.3, 0.45], [0.7, 0.55]]))]) + cS, cE = base.dm.getHeightStratum(0) +prof.disable(); dt = time.perf_counter() - t +print(f"dim {dim}: cells {cE - cS}, split {dt:.2f} s", flush=True) +s = io.StringIO(); pstats.Stats(prof, stream=s).sort_stats("tottime").print_stats(14) +print("\n".join(l for l in s.getvalue().splitlines() if "(" in l)[:4000], flush=True) diff --git a/setup.py b/setup.py index 924d753b3..acc1ea96f 100644 --- a/setup.py +++ b/setup.py @@ -273,6 +273,17 @@ def configure(): extra_compile_args=extra_compile_args, **conf, ), + # Bulk DMPlex topology tables (closures by depth, supports, per-cell + # sections) in one C loop per stratum, for the fault and add-on machinery. + Extension( + "underworld3.utilities._plex_topology", + sources=[ + "src/underworld3/utilities/_plex_topology.pyx", + ], + extra_compile_args=extra_compile_args, + define_macros=[("NPY_NO_DEPRECATED_API", "NPY_1_7_API_VERSION")], + **conf, + ), Extension( "underworld3.function._dmfield_wrapper", sources=[ diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index 39de6a19f..8806ba7db 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -5685,35 +5685,28 @@ def _cell_node_indices(self, degree, continuous): if key in self._cell_node_array: return self._cell_node_array[key] + from underworld3.utilities._plex_topology import closure_dof_rows + dmnew = self._basis_coordinate_dm(degree, continuous) - section = dmnew.getLocalSection() cStart, cEnd = self.dm.getHeightStratum(0) - cdim = self.cdim - - rows = [] - for cell in range(cStart, cEnd): - cell_rows = [] - for point in dmnew.getTransitiveClosure(cell)[0]: - ndof = section.getDof(point) // cdim - if ndof: - offset = section.getOffset(point) // cdim - cell_rows.extend(range(offset, offset + ndof)) - rows.append(cell_rows) + rows = closure_dof_rows(dmnew, dmnew.getLocalSection(), + numpy.arange(cStart, cEnd), self.cdim) dmnew.destroy() expected = comb(degree + self.dim, self.dim) - if not self.isSimplex or any(len(r) != expected for r in rows): + counts = (rows >= 0).sum(axis=1) + if not self.isSimplex or (counts != expected).any(): # A tensor-product Q_k cell carries (k+1)^dim nodes, so the monomial # basis of total degree <= k would not be square against them and # the dual-basis construction does not apply. Say so here rather # than return a ragged array the caller has to second-guess. - got = sorted({len(r) for r in rows}) + got = sorted(set(counts.tolist())) raise NotImplementedError( f"_cell_node_indices needs a simplex mesh: expected " f"{expected} nodes per cell for degree {degree} in {self.dim}D, " f"got {got}") - self._cell_node_array[key] = numpy.asarray(rows, dtype=numpy.int64) + self._cell_node_array[key] = rows.reshape(cEnd - cStart, expected) return self._cell_node_array[key] def _get_coords_for_basis(self, degree, continuous): @@ -5762,13 +5755,10 @@ def _coord_rows_for_points(self, nav_dm, points): so the rows returned here are bit-identical to the previous affine indexing (verified) and serial navigation is unchanged. """ - coord_section = nav_dm.getCoordinateSection() - cdim = self.cdim - return numpy.fromiter( - (coord_section.getOffset(p) // cdim for p in points), - dtype=numpy.int64, - count=len(points), - ) + from underworld3.utilities._plex_topology import section_offsets + + return section_offsets(nav_dm.getCoordinateSection(), + numpy.asarray(points, dtype=numpy.int64)) // self.cdim def _navigation_coords_from_dm(self, nav_dm): """Section-consistent LOCAL coordinate rows for a navigation DM. @@ -5806,84 +5796,45 @@ def _build_kd_tree_index(self): nav_coords = self._nav_coords cStart, cEnd = nav_dm.getHeightStratum(0) - fStart, fEnd = nav_dm.getHeightStratum(1) - pStart, pEnd = nav_dm.getDepthStratum(0) - cell_num_faces = self.element.entities[1] - cell_num_points = self.element.entities[self.dim] - face_num_points = self.element.face_entities[self.dim] - control_points_list = [] - control_points_cell_list = [] - centroids_list = [] + from underworld3.utilities._plex_topology import closure_points + + # Each cell's vertices in closure order, read as one table. The count + # is the closure's own: the element table's vertex count does not + # describe a 1-D surface mesh (it gave 4 for a line cell, and the + # earlier per-cell slice then took the edge point itself as a vertex). + cells = numpy.arange(cStart, cEnd) + verts = closure_points(nav_dm, cells, 0) + if (verts < 0).any(): + raise RuntimeError( + "_build_kd_tree_index: the cells do not all have the same " + "number of vertices") + cell_num_points = verts.shape[1] + n_cells = cEnd - cStart + # Use raw internal array for KD-tree construction (avoid unit-aware wrapping) + P = nav_coords[self._coord_rows_for_points(nav_dm, verts.ravel())].reshape( + n_cells, cell_num_points, self.cdim) + centroids = P.mean(axis=1) # Largest distance from a cell centroid to one of that cell's own # vertices, maximised over local cells. A convex cell is the convex # hull of its vertices, so every point of it lies within this distance # of its centroid — which makes it the rejection radius the locator # needs (see _get_closest_local_cells_internal). - cell_reach = 0.0 - - for cell, cell_id in enumerate(range(cStart, cEnd)): - - cell_faces = nav_dm.getCone(cell_id) - points = nav_dm.getTransitiveClosure(cell_id)[0][-cell_num_points:] - # Use raw internal array for KD-tree construction (avoid unit-aware wrapping) - cell_point_coords = nav_coords[self._coord_rows_for_points(nav_dm, points)] - cell_centroid = cell_point_coords.mean(axis=0) - centroids_list.append(cell_centroid) - cell_reach = max(cell_reach, float(numpy.linalg.norm( - cell_point_coords - cell_centroid, axis=1).max())) - - # for face in range(cell_num_faces): - - # points = self.dm.getTransitiveClosure(cell_faces[face])[0][ - # -face_num_points: - # ] - # point_coords = self.data[points - pStart] - - # face_centroid = point_coords.mean(axis=0) - # cell_centroid = cell_point_coords.mean(axis=0) - - # # 2D case - # if self.dim == 2: - # vector = point_coords[1] - point_coords[0] - # normal = numpy.array((-vector[1], vector[0])) - - # # 3D simplex case (probably also OK for hexes) - # else: - # normal = numpy.cross( - # (point_coords[1] - point_coords[0]), - # (point_coords[2] - point_coords[0]), - # ) - - # inward_outward = numpy.sign(normal.dot(face_centroid - cell_centroid)) - # normal *= inward_outward / numpy.sqrt(normal.dot(normal)) - - # inside_control_point = -1e-3 * normal + face_centroid - - # control_points_list.append(inside_control_point) - # control_points_cell_list.append(cell_id) - # control_points_list.append(cell_centroid) - # control_points_cell_list.append(cell_id) - - # Add points near the cell vertices - - for i in range(cell_point_coords.shape[0]): - control_points_list.append(0.99 * cell_point_coords[i] + 0.01 * cell_centroid) - control_points_cell_list.append(cell_id) - - # Add centroid - control_points_list.append(cell_centroid) - control_points_cell_list.append(cell_id) + cell_reach = (float(numpy.linalg.norm(P - centroids[:, None, :], + axis=2).max()) + if n_cells else 0.0) + # Control points: each cell's vertices pulled 1% toward its centroid, + # then the centroid itself, cell by cell. + control = numpy.concatenate( + [0.99 * P + 0.01 * centroids[:, None, :], centroids[:, None, :]], axis=1) # A rank can own zero cells (small mesh / imbalanced partition): - # shape the point arrays explicitly so an empty list becomes a - # well-formed (0, cdim) array rather than a 1-D numpy.array([]), - # which crashed the KDTree construction (issue #399). - self._indexCoords = numpy.array( - control_points_list, dtype=numpy.float64).reshape(-1, self.cdim) + # shape the point arrays explicitly so an empty table is a + # well-formed (0, cdim) array, which the KDTree accepts (issue #399). + self._indexCoords = numpy.ascontiguousarray( + control, dtype=numpy.float64).reshape(-1, self.cdim) self._index = uw.kdtree.KDTree(self._indexCoords) - # self._index.build_index() - self._indexMap = numpy.array(control_points_cell_list, dtype=numpy.int64) + self._indexMap = numpy.repeat(cells, cell_num_points + 1).astype(numpy.int64) # Cell-centroid kdtree, built from the nav-DM cells in the # same enumeration order as _indexMap, so the indices it @@ -5891,8 +5842,8 @@ def _build_kd_tree_index(self): # We keep _nav_centroids separate from _centroids (which is # the main-DM cell centroids set in __init__) so the FE-side # ``_centroids`` semantics are unchanged on manifold meshes. - self._nav_centroids = numpy.array( - centroids_list, dtype=numpy.float64).reshape(-1, self.cdim) + self._nav_centroids = numpy.ascontiguousarray( + centroids, dtype=numpy.float64).reshape(-1, self.cdim) self._centroid_index = uw.kdtree.KDTree(self._nav_centroids) # Rejection radius for the lost-point walk. Rebuilt with the kd-tree @@ -6017,6 +5968,11 @@ def _mark_faces_inside_and_out(self): pStart, pEnd = nav_dm.getDepthStratum(0) num_local_cells = cEnd - cStart cell_num_faces = self.element.entities[1] + # TODO(BUG): the element table gives 4 vertices for a 1-D surface + # mesh's line cell, so a slice of the last cell_num_points closure + # points takes the edge point as a vertex there; read the count from + # the closure (utilities._plex_topology.closure_points), as + # _build_kd_tree_index does. cell_num_points = self.element.entities[self.dim] face_num_points = self.element.face_entities[self.dim] @@ -6237,6 +6193,11 @@ def _mark_local_boundary_faces_inside_and_out(self): fStart, fEnd = nav_dm.getHeightStratum(1) pStart, pEnd = nav_dm.getDepthStratum(0) cell_num_faces = self.element.entities[1] + # TODO(BUG): the element table gives 4 vertices for a 1-D surface + # mesh's line cell, so a slice of the last cell_num_points closure + # points takes the edge point as a vertex there; read the count from + # the closure (utilities._plex_topology.closure_points), as + # _build_kd_tree_index does. cell_num_points = self.element.entities[self.dim] face_num_points = self.element.face_entities[self.dim] @@ -6914,6 +6875,11 @@ def _audit_cell_face_geometry(self): if cEnd == cStart: return 0.0, 0.0 cell_num_faces = self.element.entities[1] + # TODO(BUG): the element table gives 4 vertices for a 1-D surface + # mesh's line cell, so a slice of the last cell_num_points closure + # points takes the edge point as a vertex there; read the count from + # the closure (utilities._plex_topology.closure_points), as + # _build_kd_tree_index does. cell_num_points = self.element.entities[self.dim] face_num_points = self.element.face_entities[self.dim] diff --git a/src/underworld3/discretisation/discretisation_mesh_variables.py b/src/underworld3/discretisation/discretisation_mesh_variables.py index ebdda503b..6d9672ecb 100644 --- a/src/underworld3/discretisation/discretisation_mesh_variables.py +++ b/src/underworld3/discretisation/discretisation_mesh_variables.py @@ -1223,6 +1223,13 @@ def _petsc_payload_name(data_file, data_name): ``None`` when the file was written without ``petsc_reload=True``. """ + # TODO(BUG): the writer puts the native payload under + # /restart/petsc/topologies (Mesh._write_petsc_reload_file, since + # 36c01399) while this looks for a top-level "topologies" group, so a + # payload written with petsc_reload=True is never found and a split + # mesh's read_timestep refuses (tests/test_0864_split_checkpoint_ + # roundtrip.py::test_roundtrip_keeps_the_two_sides_distinct fails on + # feature/tidal-shell too). Found 2026-10-18; not fixed on this branch. import h5py found = None diff --git a/src/underworld3/meshing/fault_network.py b/src/underworld3/meshing/fault_network.py index 2d27e6a8e..f569aea39 100644 --- a/src/underworld3/meshing/fault_network.py +++ b/src/underworld3/meshing/fault_network.py @@ -567,8 +567,18 @@ def _build_3d_band(self, h_far=None, qdegree=2, realisation="split", "embedded mid-surface. Keep the network interior, " "or place the zone with place_thin_volume directly " "(no embed).") + # In parallel the add-on is partitioned for the band first, so the + # placement gathers nothing, the split moves nothing, and the tail + # is not co-located behind it (utilities.addon_partition). + host = base + if base.dm.getComm().getSize() > 1: + from underworld3.utilities.addon_partition import (mark_band, + partition_addon) + host = partition_addon( + base, mark_band(expanded, self.width, carve_clearance), + layers=2) dm, info = place_thin_volume( - base.dm, expanded, self.width, label="Band", label_value=71, + host.dm, expanded, self.width, label="Band", label_value=71, clearance=carve_clearance, size=h, mesher="network", embed=[np.asarray(P, dtype=float) for _n, P in self.prepared]) values = {} @@ -586,6 +596,8 @@ def _build_3d_band(self, h_far=None, qdegree=2, realisation="split", dm, simplex=True, qdegree=qdegree, coordinate_system_type=base.CoordinateSystem.coordinate_type, boundaries=Enum("boundaries", members), verbose=False) + from underworld3.utilities.addon_partition import inherit_provenance + inherit_provenance(mesh, host) band = mesh.cells_labelled("Band", 71) if realisation == "split": # the pieces the placement kept apart stay apart in the split's @@ -620,7 +632,7 @@ def _build_3d_band(self, h_far=None, qdegree=2, realisation="split", # tail — every solver fell back to GAMG. The keying ruling # stands: the split needs no FAC patch; the weak plane's patch # is its band. - adopt_hierarchy(mesh, base, + adopt_hierarchy(mesh, host, fac_zone=None if realisation == "split" else band) # honoured footprints: band cells within the USER patch's own # in-plane outline and half a width of its plane — planar patches diff --git a/src/underworld3/utilities/_plex_topology.pyx b/src/underworld3/utilities/_plex_topology.pyx new file mode 100644 index 000000000..8971ba941 --- /dev/null +++ b/src/underworld3/utilities/_plex_topology.pyx @@ -0,0 +1,184 @@ +# cython: language_level=3, boundscheck=False, wraparound=False +"""Bulk DMPlex topology tables, built in C. + +The fault and add-on machinery asks the same questions of every cell, face or +vertex of a mesh — which vertices close this cell, which cells support this +face — and asked them one point at a time through petsc4py, a Python call per +point. These functions answer them for a whole stratum in one C loop and return +numpy tables, so the logic above them can be written as array operations. + +All of them are rank-local: they read this rank's chart and call no collective. +""" +import numpy as np +cimport numpy as cnp + +from petsc4py.PETSc cimport DM, PetscDM, Section, PetscSection + +cnp.import_array() + +cdef extern from "petsc.h": + ctypedef int PetscInt + ctypedef int PetscErrorCode + ctypedef enum PetscBool: + PETSC_FALSE + PETSC_TRUE + PetscErrorCode DMPlexGetChart(PetscDM, PetscInt *, PetscInt *) + PetscErrorCode DMPlexGetDepthStratum(PetscDM, PetscInt, PetscInt *, PetscInt *) + PetscErrorCode DMPlexGetTransitiveClosure(PetscDM, PetscInt, PetscBool, + PetscInt *, PetscInt **) + PetscErrorCode DMPlexRestoreTransitiveClosure(PetscDM, PetscInt, PetscBool, + PetscInt *, PetscInt **) + PetscErrorCode DMPlexGetSupportSize(PetscDM, PetscInt, PetscInt *) + PetscErrorCode DMPlexGetSupport(PetscDM, PetscInt, const PetscInt **) + PetscErrorCode PetscSectionSetChart(PetscSection, PetscInt, PetscInt) + PetscErrorCode PetscSectionSetDof(PetscSection, PetscInt, PetscInt) + PetscErrorCode PetscSectionSetUp(PetscSection) + PetscErrorCode PetscSectionGetOffset(PetscSection, PetscInt, PetscInt *) + + +cdef inline void _check(PetscErrorCode ierr, str what) except *: + if ierr != 0: + raise RuntimeError(f"PETSc error {ierr} in {what}") + + +def closure_points(DM dm, points, int depth): + """The points of depth ``depth`` in the transitive closure of each of + ``points``, in closure order: an ``(n, k)`` int64 table, ``k`` the largest + count found, short rows padded with -1. ``depth`` 0 gives a cell's or a + face's vertices, 1 a cell's edges.""" + cdef cnp.int64_t[::1] pts = np.ascontiguousarray(points, dtype=np.int64) + cdef Py_ssize_t n = pts.shape[0] + cdef PetscInt dS, dE, size = 0, i, p + cdef PetscInt *closure = NULL + cdef Py_ssize_t r, j, cnt, kmax = 0 + _check(DMPlexGetDepthStratum(dm.dm, depth, &dS, &dE), "DMPlexGetDepthStratum") + # first pass: the row width + for r in range(n): + p = pts[r] + _check(DMPlexGetTransitiveClosure(dm.dm, p, PETSC_TRUE, &size, &closure), + "DMPlexGetTransitiveClosure") + cnt = 0 + for i in range(size): + if dS <= closure[2 * i] < dE: + cnt += 1 + _check(DMPlexRestoreTransitiveClosure(dm.dm, p, PETSC_TRUE, &size, &closure), + "DMPlexRestoreTransitiveClosure") + if cnt > kmax: + kmax = cnt + out = np.full((n, kmax), -1, dtype=np.int64) + cdef cnp.int64_t[:, ::1] o = out + for r in range(n): + p = pts[r] + _check(DMPlexGetTransitiveClosure(dm.dm, p, PETSC_TRUE, &size, &closure), + "DMPlexGetTransitiveClosure") + j = 0 + for i in range(size): + if dS <= closure[2 * i] < dE: + o[r, j] = closure[2 * i] + j += 1 + _check(DMPlexRestoreTransitiveClosure(dm.dm, p, PETSC_TRUE, &size, &closure), + "DMPlexRestoreTransitiveClosure") + return out + + +def supports(DM dm, points): + """The support of each of ``points``: an ``(n, k)`` int64 table, ``k`` the + largest support found (two for the faces of a manifold mesh), short rows + padded with -1.""" + cdef cnp.int64_t[::1] pts = np.ascontiguousarray(points, dtype=np.int64) + cdef Py_ssize_t n = pts.shape[0] + cdef PetscInt size, i, p + cdef const PetscInt *sup = NULL + cdef Py_ssize_t r, kmax = 0 + for r in range(n): + _check(DMPlexGetSupportSize(dm.dm, pts[r], &size), + "DMPlexGetSupportSize") + if size > kmax: + kmax = size + out = np.full((n, kmax), -1, dtype=np.int64) + cdef cnp.int64_t[:, ::1] o = out + for r in range(n): + p = pts[r] + _check(DMPlexGetSupportSize(dm.dm, p, &size), "DMPlexGetSupportSize") + _check(DMPlexGetSupport(dm.dm, p, &sup), "DMPlexGetSupport") + for i in range(size): + o[r, i] = sup[i] + return out + + +def fill_section(Section section, DM dm, points, int ndof): + """Set ``section`` to the chart of ``dm`` with ``ndof`` dofs on each of + ``points`` and none elsewhere, and set it up.""" + cdef cnp.int64_t[::1] pts = np.ascontiguousarray(points, dtype=np.int64) + cdef PetscInt pStart, pEnd + cdef Py_ssize_t r + _check(DMPlexGetChart(dm.dm, &pStart, &pEnd), "DMPlexGetChart") + _check(PetscSectionSetChart(section.sec, pStart, pEnd), "PetscSectionSetChart") + for r in range(pts.shape[0]): + _check(PetscSectionSetDof(section.sec, pts[r], ndof), + "PetscSectionSetDof") + _check(PetscSectionSetUp(section.sec), "PetscSectionSetUp") + + +def section_offsets(Section section, points): + """The offset of each of ``points`` in ``section``: an int64 array.""" + cdef cnp.int64_t[::1] pts = np.ascontiguousarray(points, dtype=np.int64) + out = np.empty(pts.shape[0], dtype=np.int64) + cdef cnp.int64_t[::1] o = out + cdef PetscInt off + cdef Py_ssize_t r + for r in range(pts.shape[0]): + _check(PetscSectionGetOffset(section.sec, pts[r], &off), + "PetscSectionGetOffset") + o[r] = off + return out + + +cdef extern from "petsc.h": + PetscErrorCode PetscSectionGetDof(PetscSection, PetscInt, PetscInt *) + + +def closure_dof_rows(DM dm, Section section, points, int block): + """The rows of each point's closure dofs in a local vector laid out by + ``section``, in closure order, counted in blocks of ``block`` (the + coordinate dimension for a coordinate field, so a row is one node): an + ``(n, k)`` int64 table, ``k`` the largest count, short rows padded with -1. + A point with ``d`` dofs at offset ``o`` contributes rows ``o / block`` to + ``(o + d) / block - 1``.""" + cdef cnp.int64_t[::1] pts = np.ascontiguousarray(points, dtype=np.int64) + cdef Py_ssize_t n = pts.shape[0] + cdef PetscInt size = 0, i, p, q, dof, off + cdef PetscInt *closure = NULL + cdef Py_ssize_t r, j, cnt, kmax = 0, m + for r in range(n): + p = pts[r] + _check(DMPlexGetTransitiveClosure(dm.dm, p, PETSC_TRUE, &size, &closure), + "DMPlexGetTransitiveClosure") + cnt = 0 + for i in range(size): + _check(PetscSectionGetDof(section.sec, closure[2 * i], &dof), + "PetscSectionGetDof") + cnt += dof // block + _check(DMPlexRestoreTransitiveClosure(dm.dm, p, PETSC_TRUE, &size, &closure), + "DMPlexRestoreTransitiveClosure") + if cnt > kmax: + kmax = cnt + out = np.full((n, kmax), -1, dtype=np.int64) + cdef cnp.int64_t[:, ::1] o = out + for r in range(n): + p = pts[r] + _check(DMPlexGetTransitiveClosure(dm.dm, p, PETSC_TRUE, &size, &closure), + "DMPlexGetTransitiveClosure") + j = 0 + for i in range(size): + q = closure[2 * i] + _check(PetscSectionGetDof(section.sec, q, &dof), "PetscSectionGetDof") + if dof: + _check(PetscSectionGetOffset(section.sec, q, &off), + "PetscSectionGetOffset") + for m in range(dof // block): + o[r, j] = off // block + m + j += 1 + _check(DMPlexRestoreTransitiveClosure(dm.dm, p, PETSC_TRUE, &size, &closure), + "DMPlexRestoreTransitiveClosure") + return out diff --git a/src/underworld3/utilities/addon_partition.py b/src/underworld3/utilities/addon_partition.py index d030a5cdc..da7e0a0d0 100644 --- a/src/underworld3/utilities/addon_partition.py +++ b/src/underworld3/utilities/addon_partition.py @@ -35,7 +35,8 @@ # tolerance is the cell locator's own, so a point it accepts is accepted here. from underworld3.utilities.fault_mg import _INSIDE_TOL -__all__ = ["partition_addon", "mark_labels", "mark_polylines", "mark_sheets"] +__all__ = ["partition_addon", "mark_labels", "mark_polylines", "mark_sheets", + "mark_band"] @@ -50,6 +51,20 @@ def _allreduce(comm, values, op=MPI.SUM): # --------------------------------------------------------------------------- # # Marks: which vertices the fault touches # --------------------------------------------------------------------------- # +def _mark_facet_vertices(dm, facets, value, ids): + """Raise ``ids`` (chart-length) to at least ``value`` on every vertex of + the given facets (points of the height-1 stratum; others are skipped).""" + from underworld3.utilities._plex_topology import closure_points + fS, fE = dm.getHeightStratum(1) + pStart, _pEnd = dm.getChart() + facets = np.asarray(facets, dtype=np.int64) + facets = facets[(facets >= fS) & (facets < fE)] + if facets.size: + v = closure_points(dm, facets, 0) + v = v[v >= 0] - pStart + np.maximum.at(ids, v, value) + + def mark_labels(labels, groups=None): """The marking rule for labelled fault facets: region ``k`` on every vertex of a facet of group ``k`` (one group per fault when ``groups`` is None). @@ -62,21 +77,15 @@ def mark_labels(labels, groups=None): groups = [[lab] for lab in labels] def marks(dm): - fS, fE = dm.getHeightStratum(1) - vS, vE = dm.getDepthStratum(0) pStart, pEnd = dm.getChart() ids = np.zeros(pEnd - pStart, dtype=np.int32) for g, group in enumerate(groups, start=1): for name, value in group: - if not (dm.hasLabel(name) + if (dm.hasLabel(name) and dm.getLabel(name).getStratumSize(int(value)) > 0): - continue - for f in dm.getLabel(name).getStratumIS(int(value)).getIndices(): - if fS <= int(f) < fE: - for q in dm.getTransitiveClosure(int(f))[0]: - if vS <= int(q) < vE: - i = int(q) - pStart - ids[i] = max(ids[i], g) + _mark_facet_vertices( + dm, dm.getLabel(name).getStratumIS(int(value)).getIndices(), + g, ids) return ids return marks @@ -100,20 +109,19 @@ def mark_polylines(polylines): polylines = [np.asarray(poly, dtype=float)[:, :2] for poly in polylines] def marks(dm): + from underworld3.utilities._plex_topology import closure_points from underworld3.utilities.edge_split import cell_diameters + from underworld3.utilities.place_surface import _propagate_vertex vS, vE = dm.getDepthStratum(0) cS, cE = dm.getHeightStratum(0) pStart, pEnd = dm.getChart() X = dm.getCoordinatesLocal().array.reshape( -1, dm.getCoordinateDim())[: vE - vS] - h_cell = np.asarray(cell_diameters(dm)) - from underworld3.utilities.place_surface import _propagate_vertex h_chart = np.zeros(pEnd - pStart) - for c in range(cS, cE): - for q in dm.getTransitiveClosure(c)[0]: - if vS <= int(q) < vE: - i = int(q) - pStart - h_chart[i] = max(h_chart[i], h_cell[c - cS]) + if cE > cS: + h_cell = np.asarray(cell_diameters(dm)) + CV = closure_points(dm, np.arange(cS, cE), 0) - pStart + np.maximum.at(h_chart, CV, np.broadcast_to(h_cell[:, None], CV.shape)) # a seam vertex sees only its local cells: the largest incident cell # over every rank, so the reach is a function of the mesh alone h_chart = _propagate_vertex(dm, h_chart, MPI.MAX, np.maximum) @@ -153,6 +161,50 @@ def marks(dm): return marks +def mark_band(paths, width, clearance): + """The marking rule for a placed fault band (``place_thin_volume``): a + superset of the placement's own mask, so its gather finds the region + rank-interior. The placement marks every vertex within + ``max(clearance h, 0.6 width) + h`` of the band's skin (``h`` the + smallest incident cell diameter); the skin lies within a mitred half + width of the band's path, so a vertex within that reach plus one band + width of the path is marked here. ``paths`` are the paths the band is + built along: ``(n, 2)`` polylines in 2-D, planar convex ``(n, 3)`` + polygons in 3-D (the patches).""" + paths = [np.asarray(P, dtype=float) for P in paths] + + def marks(dm): + from underworld3.utilities.place_surface import ( + _coords, _sheet_distance_within, _tet_vertices, _vertex_h_3d) + vS, vE = dm.getDepthStratum(0) + pStart, pEnd = dm.getChart() + X = _coords(dm)[: vE - vS] + h_vertex, _h_cell = _vertex_h_3d(dm, _tet_vertices(dm) if dm.getDimension() == 3 + else _cell_vertices(dm), len(X)) + reach = (np.maximum(float(clearance) * h_vertex, 0.6 * float(width)) + + h_vertex + float(width)) + ids = np.zeros(pEnd - pStart, dtype=np.int32) + for k, P in enumerate(paths, start=1): + if dm.getDimension() == 2: + d = _segment_distance(X[:, :2], P[:, :2]) + else: + fan = np.array([(0, i, i + 1) for i in range(1, len(P) - 1)], + dtype=np.int64) + d = _sheet_distance_within(X, P, fan, reach) + near = np.flatnonzero(d < reach) + ids[near + vS - pStart] = np.maximum(ids[near + vS - pStart], k) + return ids + return marks + + +def _cell_vertices(dm): + """(n_cells, dim + 1) local vertex indices of every simplex.""" + from underworld3.utilities._plex_topology import closure_points + vS, _vE = dm.getDepthStratum(0) + cS, cE = dm.getHeightStratum(0) + return (closure_points(dm, np.arange(cS, cE), 0) - vS).reshape( + cE - cS, dm.getDimension() + 1) + def embedded_surface_names(mesh): """The fault surfaces ``mesh`` already carries, as boundary names: every split fault (its own label and its ``Plus`` / ``Minus`` sides) and every @@ -171,8 +223,6 @@ def _embedded_surface_marks(mesh, names, first_id): vertex of a facet of the ``j``-th name (sorted). ``names`` is the same on every rank, so the ids are.""" dm = mesh.dm - fS, fE = dm.getHeightStratum(1) - vS, vE = dm.getDepthStratum(0) pStart, pEnd = dm.getChart() ids = np.zeros(pEnd - pStart, dtype=np.int32) present = {b.name: int(b.value) for b in mesh.boundaries} @@ -180,14 +230,9 @@ def _embedded_surface_marks(mesh, names, first_id): if name not in present or not dm.hasLabel(name): continue lab = dm.getLabel(name) - if lab.getStratumSize(present[name]) == 0: - continue - for f in lab.getStratumIS(present[name]).getIndices(): - if fS <= int(f) < fE: - for q in dm.getTransitiveClosure(int(f))[0]: - if vS <= int(q) < vE: - i = int(q) - pStart - ids[i] = max(ids[i], first_id + j) + if lab.getStratumSize(present[name]) > 0: + _mark_facet_vertices(dm, lab.getStratumIS(present[name]).getIndices(), + first_id + j, ids) return ids @@ -353,31 +398,32 @@ def _unit_adjacency(dm, unit_of_cell, face_other): share a face, and ``(u, rank)`` pairs of local units with a face on the seam to ``rank``. Computed once; the assignment changes, the mesh does not.""" + from underworld3.utilities._plex_topology import supports cS, cE = dm.getHeightStratum(0) fS, fE = dm.getHeightStratum(1) - inner, seam = [], [] - for f in range(fS, fE): - sup = [int(c) for c in dm.getSupport(f)] - if len(sup) == 2: - ua, ub = unit_of_cell[sup[0] - cS], unit_of_cell[sup[1] - cS] - if ua != ub: - inner.append((ua, ub)) - elif len(sup) == 1 and face_other[f - fS] >= 0: - seam.append((unit_of_cell[sup[0] - cS], face_other[f - fS])) - inner = (np.asarray(inner, dtype=np.int64).reshape(-1, 2)) - seam = (np.asarray(seam, dtype=np.int64).reshape(-1, 2)) - return inner, seam - - -def _units_touching(inner, seam, assign_unit): + sup = supports(dm, np.arange(fS, fE)) + if sup.shape[1] < 2: + sup = np.column_stack([sup, np.full((sup.shape[0], 2 - sup.shape[1]), -1)]) + both = (sup[:, 0] >= 0) & (sup[:, 1] >= 0) + ua = unit_of_cell[sup[both, 0] - cS] + ub = unit_of_cell[sup[both, 1] - cS] + inner = np.column_stack([ua, ub])[ua != ub].astype(np.int64) + one = (sup[:, 0] >= 0) & (sup[:, 1] < 0) & (face_other >= 0) + seam = np.column_stack([unit_of_cell[sup[one, 0] - cS], + face_other[one]]).astype(np.int64) + return inner.reshape(-1, 2), seam.reshape(-1, 2) + + +def _units_touching(inner, seam, assign_unit, n_ranks): """``(unit, rank)`` pairs: every local unit with the ranks it touches - across a face, under the current assignment.""" + across a face, under the current assignment, each pair once. A pair is + encoded as ``unit * n_ranks + rank`` so the deduplication is a 1-D sort.""" a, b = inner[:, 0], inner[:, 1] - pairs = np.concatenate([ - np.column_stack([a, assign_unit[b]]), - np.column_stack([b, assign_unit[a]]), - seam]) - return np.unique(pairs, axis=0) if pairs.size else pairs.reshape(0, 2) + keys = np.unique(np.concatenate([ + a * n_ranks + assign_unit[b], + b * n_ranks + assign_unit[a], + seam[:, 0] * n_ranks + seam[:, 1]])) + return np.column_stack([keys // n_ranks, keys % n_ranks]) # --------------------------------------------------------------------------- # @@ -411,7 +457,7 @@ def _peel_balance(dm, unit_of_cell, assign_unit, weight, unit_region, if load.max() <= mean * (1.0 + tolerance): break n_sweeps += 1 - touching = _units_touching(inner, seam, assign_unit) + touching = _units_touching(inner, seam, assign_unit, size) mine = free & (assign_unit == comm.rank) touching = touching[mine[touching[:, 0]]] proposals = {} # receiver -> list of (unit, weight) @@ -614,17 +660,27 @@ def partition_addon(mesh, region_marks, layers=1, tolerance=0.1, for l, lev in enumerate(levels): cell_region, _canon, _regions = _region_cells( lev.dm, marks[l], layers=layers) - for c in np.flatnonzero(cell_region): - u = unit_of[l][c] - k = int(cell_region[c]) - if unit_region[u] and unit_region[u] != k: - pairs.add((int(unit_region[u]), k)) - unit_region[u] = max(unit_region[u], k) + sel = np.flatnonzero(cell_region) + u, k = unit_of[l][sel], cell_region[sel] + # a unit takes the largest id among its cells' and its own; the ids + # that meet in one unit touch + best = unit_region.copy() + np.maximum.at(best, u, k) + held = np.concatenate([k, unit_region[u]]) + top = np.concatenate([best[u], best[u]]) + meet = (held > 0) & (held != top) + if meet.any(): + for a, b in np.unique(np.column_stack([held[meet], top[meet]]), + axis=0).tolist(): + pairs.add((int(a), int(b))) + unit_region = best n_ids = int(comm.allreduce(int(unit_region.max()) if n_units else 0, op=MPI.MAX)) canon = _merge_regions(pairs, n_ids, comm) - for u in np.flatnonzero(unit_region): - unit_region[u] = canon[int(unit_region[u])] + lookup = np.zeros(n_ids + 1, dtype=np.int32) + for key, value in canon.items(): + lookup[key] = value + unit_region = lookup[unit_region] regions = sorted(set(canon.values())) local = np.array([float(weight[unit_region == k].sum()) for k in regions]) counts = np.asarray(comm.allgather(local)).reshape(comm.size, len(regions)) diff --git a/src/underworld3/utilities/custom_mg.py b/src/underworld3/utilities/custom_mg.py index e0d509fdc..70beb40b3 100644 --- a/src/underworld3/utilities/custom_mg.py +++ b/src/underworld3/utilities/custom_mg.py @@ -890,6 +890,45 @@ def _node_transfer_no_repair(builder, coarse_coords, fine_coords): return builder(coarse_coords, fine_coords) +def _assemble_node_transfer(Pn, coarse_cols, fine_layout, coarse_layout, ncomp, + comm): + """A node-level transfer ``Pn`` (scipy CSR, fine local nodes by coarse + nodes) as the reduced->reduced MPIAIJ prolongation, assembled in one call. + + Component ``c`` of fine node ``i`` is row ``fine_layout.l2g[i*ncomp + c]`` + and takes, for each weight ``w`` on coarse node ``j``, the column + ``coarse_cols[j, c]``. Only this rank's OWNED rows are set (ghost nodes + are another rank's); constrained coarse DOFs (column ``-1``) drop out, so + the map is reduced->reduced.""" + import scipy.sparse as sp + l2g_f = np.asarray(fine_layout.l2g, dtype=np.int64) + fstart, fend = fine_layout.rstart, fine_layout.rend + nloc_f = fend - fstart + nloc_c = coarse_layout.rend - coarse_layout.rstart + Pn = Pn.tocsr() + node = np.repeat(np.arange(Pn.shape[0]), np.diff(Pn.indptr)) + rows, cols, vals = [], [], [] + for c in range(ncomp): + grow = l2g_f[node * ncomp + c] + gcol = coarse_cols[Pn.indices, c] + keep = (grow >= fstart) & (grow < fend) & (gcol >= 0) + rows.append(grow[keep] - fstart) + cols.append(gcol[keep]) + vals.append(Pn.data[keep]) + rows, cols, vals = (np.concatenate(rows), np.concatenate(cols), + np.concatenate(vals)) + n_cols = int(comm.tompi4py().allreduce(nloc_c)) + loc = sp.csr_matrix((vals, (rows, cols)), shape=(nloc_f, n_cols)) + loc.sum_duplicates() + loc.sort_indices() + P = PETSc.Mat().createAIJ( + size=((nloc_f, None), (nloc_c, None)), + csr=(loc.indptr.astype(PETSc.IntType), loc.indices.astype(PETSc.IntType), + loc.data), comm=comm) + P.assemble() + return P + + def _build_parallel_transfer(coarse_coords, fine_coords, coarse_layout, fine_layout, ncomp, builder, comm): """One reduced->reduced prolongation as an MPIAIJ matrix. @@ -900,33 +939,11 @@ def _build_parallel_transfer(coarse_coords, fine_coords, coarse_layout, coarse ``l2g`` to global columns (off-rank columns are fine for MPIAIJ). Constrained coarse DOFs (``l2g == -1``) drop out -> reduced->reduced.""" l2g_c = coarse_layout.l2g - l2g_f, fstart, fend = fine_layout.l2g, fine_layout.rstart, fine_layout.rend Pn = _drop_structural_zeros( _node_transfer_no_repair(builder, coarse_coords, fine_coords)) # (n_f_nodes, n_c_nodes), local - nloc_f = fend - fstart - nloc_c = coarse_layout.rend - coarse_layout.rstart - - P = PETSc.Mat().create(comm=comm) - P.setSizes(((nloc_f, None), (nloc_c, None))) - P.setType("aij") - P.setUp() - for i in range(Pn.shape[0]): # fine local node - js = Pn.indices[Pn.indptr[i]:Pn.indptr[i + 1]] - ws = Pn.data[Pn.indptr[i]:Pn.indptr[i + 1]] - for c in range(ncomp): - grow = int(l2g_f[i * ncomp + c]) - if grow < fstart or grow >= fend: # set OWNED rows only - continue - gcols, vals = [], [] - for jj, w in zip(js.tolist(), ws.tolist()): - gcol = int(l2g_c[jj * ncomp + c]) - if gcol >= 0: # skip constrained coarse DOFs - gcols.append(gcol) - vals.append(w) - if gcols: - P.setValues([grow], gcols, vals, addv=PETSc.InsertMode.INSERT_VALUES) - P.assemble() - return P + return _assemble_node_transfer( + Pn, np.asarray(l2g_c, dtype=np.int64).reshape(-1, ncomp), fine_layout, + coarse_layout, ncomp, comm) def _gather_coarse_cloud(coarse_coords, coarse_layout, ncomp, comm): @@ -962,36 +979,13 @@ def _build_crosspart_transfer(coarse_coords, fine_coords, coarse_layout, small — that is what makes a coarse MG level coarse), so point location spans partitions. Columns are the coarse GLOBAL reduced indices (off-rank columns are fine for MPIAIJ); constrained coarse DOFs (col < 0) drop out.""" - l2g_f, fstart, fend = fine_layout.l2g, fine_layout.rstart, fine_layout.rend coords_u, cols_u = _gather_coarse_cloud(coarse_coords, coarse_layout, ncomp, comm) Pn = _drop_structural_zeros( _node_transfer_no_repair(builder, coords_u, fine_coords)) # (n_f_local, Nu) - nloc_f = fend - fstart - nloc_c = coarse_layout.rend - coarse_layout.rstart - - P = PETSc.Mat().create(comm=comm) - P.setSizes(((nloc_f, None), (nloc_c, None))) - P.setType("aij") - P.setUp() - for i in range(Pn.shape[0]): # fine local node - js = Pn.indices[Pn.indptr[i]:Pn.indptr[i + 1]] - ws = Pn.data[Pn.indptr[i]:Pn.indptr[i + 1]] - for c in range(ncomp): - grow = int(l2g_f[i * ncomp + c]) - if grow < fstart or grow >= fend: # set OWNED rows only - continue - gcols, vals = [], [] - for jj, w in zip(js.tolist(), ws.tolist()): - gcol = int(cols_u[jj, c]) - if gcol >= 0: # skip constrained coarse DOFs - gcols.append(gcol) - vals.append(w) - if gcols: - P.setValues([grow], gcols, vals, addv=PETSc.InsertMode.INSERT_VALUES) - P.assemble() - return P + return _assemble_node_transfer(Pn, np.asarray(cols_u, dtype=np.int64), + fine_layout, coarse_layout, ncomp, comm) def _count_zero_columns_parallel(P, comm): @@ -2330,11 +2324,16 @@ def adopt_hierarchy(mesh, base_mesh, fac_zone=None, builder=None): own = getattr(base_mesh, "_custom_mg_coarse_meshes", None) tail = (list(own) + [base_mesh] if own is not None else list(base_mesh._coarse_level_meshes())) - # In parallel the placed mesh is gathered onto its surgery rank while - # the tail is load-balanced: co-locate every level with the finest so - # the transfers pair rank-locally (the coarse levels are small; the - # fine mesh and its FAC patch never move). - if mesh.dm.getComm().getSize() > 1: + # A base the add-on partition produced (addon_partition.partition_addon, + # called by the placement routes before they place) is aligned with its + # tail already: the placement moved nothing, the pair it separated is + # read from the recorded provenance, and nothing is co-located. + # Otherwise the placed mesh was gathered onto its surgery rank while the + # tail is load-balanced: co-locate every level with the finest so the + # transfers pair rank-locally (the coarse levels are small; the fine + # mesh and its FAC patch never move). + if (mesh.dm.getComm().getSize() > 1 + and getattr(base_mesh, "_addon_partition", None) is None): tail = [_colocate_level(level, mesh) for level in tail] mesh._custom_mg_coarse_meshes = tail mesh._custom_mg_builder = (builder if builder is not None diff --git a/src/underworld3/utilities/edge_split.py b/src/underworld3/utilities/edge_split.py index 2d1073d21..f75eadd39 100644 --- a/src/underworld3/utilities/edge_split.py +++ b/src/underworld3/utilities/edge_split.py @@ -62,32 +62,32 @@ def _register_transform(): from underworld3.utilities import _nvb_transform # noqa: F401 (registers on import) +def _edge_ends(dm): + """The two vertex points of every edge, ``(n_edges, 2)``, in cone order, + read as one table.""" + from underworld3.utilities._plex_topology import closure_points + eS, eE = dm.getDepthStratum(1) + return closure_points(dm, np.arange(eS, eE), 0).reshape(eE - eS, 2) + + def _edge_lengths(dm): """Length of every edge, indexed by ``edge_point - edge_start``.""" cdim = dm.getCoordinateDim() vS, _vE = dm.getDepthStratum(0) eS, eE = dm.getDepthStratum(1) X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, cdim) - ends = np.array([dm.getCone(e) for e in range(eS, eE)], dtype=np.int64) - vS + ends = _edge_ends(dm) - vS d = X[ends[:, 0]] - X[ends[:, 1]] return np.sqrt(np.einsum("ij,ij->i", d, d)) def _cell_edges(dm): - """Edge points of each cell, as a list indexed by ``cell - cell_start``. - - In 2-D a cell's cone is already its edges; in 3-D the cone holds faces, so - the edges come from the transitive closure filtered to the edge stratum. - """ - eS, eE = dm.getDepthStratum(1) + """Edge points of each cell, as a list indexed by ``cell - cell_start``: + the edge stratum of the cell's closure, in closure order (in 2-D that is + the cone), read as one table.""" + from underworld3.utilities._plex_topology import closure_points cS, cE = dm.getHeightStratum(0) - if dm.getDimension() == 2: - return [np.asarray(dm.getCone(c), dtype=np.int64) for c in range(cS, cE)] - out = [] - for c in range(cS, cE): - closure = dm.getTransitiveClosure(c)[0] - out.append(np.array([p for p in closure if eS <= p < eE], dtype=np.int64)) - return out + return list(closure_points(dm, np.arange(cS, cE), 1)) def cell_diameters(dm, return_tables=False): @@ -177,7 +177,7 @@ def _edge_strength(dm): vS, _vE = dm.getDepthStratum(0) eS, eE = dm.getDepthStratum(1) X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, cdim) - ends = np.array([dm.getCone(e) for e in range(eS, eE)], dtype=np.int64) - vS + ends = _edge_ends(dm) - vS d = X[ends[:, 0]] - X[ends[:, 1]] length = np.sqrt(np.einsum("ij,ij->i", d, d)) mid = 0.5 * (X[ends[:, 0]] + X[ends[:, 1]]) diff --git a/src/underworld3/utilities/fault_mg.py b/src/underworld3/utilities/fault_mg.py index 5dcef02d2..19b863dcf 100644 --- a/src/underworld3/utilities/fault_mg.py +++ b/src/underworld3/utilities/fault_mg.py @@ -66,9 +66,26 @@ def is_split_level(mesh): def _cell_vertex_table(mesh): """Vertex coordinates ``(n_v, cdim)`` and the ``(n_cells, dim + 1)`` table of - vertex rows per cell — the P1 node table, which the mesh caches.""" - vxy = np.asarray(mesh._get_coords_for_basis(1, True)) - cv = np.asarray(mesh._cell_node_indices(1, True)) + vertex rows per cell. + + Read straight from the DM: the vertex coordinates through the coordinate + section and each cell's vertices from its closure, with no interpolation + built. A periodic mesh carries its coordinates cell by cell, so there the + P1 node table of the mesh is used, which handles them.""" + from underworld3.utilities._plex_topology import closure_points, section_offsets + dm = mesh.dm + cell_coordinates = dm.getCellCoordinatesLocal() + if cell_coordinates is not None and cell_coordinates.handle: + return (np.asarray(mesh._get_coords_for_basis(1, True)), + np.asarray(mesh._cell_node_indices(1, True))) + cdim = dm.getCoordinateDim() + vS, vE = dm.getDepthStratum(0) + cS, cE = dm.getHeightStratum(0) + X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, cdim) + rows = section_offsets(dm.getCoordinateSection(), np.arange(vS, vE)) // cdim + vxy = X[rows] + cv = (closure_points(dm, np.arange(cS, cE), 0) - vS).reshape( + cE - cS, dm.getDimension() + 1) return vxy, cv diff --git a/src/underworld3/utilities/fault_split.py b/src/underworld3/utilities/fault_split.py index a8adf083d..764ea4f90 100644 --- a/src/underworld3/utilities/fault_split.py +++ b/src/underworld3/utilities/fault_split.py @@ -1940,6 +1940,7 @@ def _fault_labels_touch_seam(dm, labels): """ if uw.mpi.size == 1: return False + from underworld3.utilities._plex_topology import closure_points shared = _shared_points(dm) pStart, _pEnd = dm.getChart() fS, fE = dm.getHeightStratum(1) @@ -1950,15 +1951,15 @@ def _fault_labels_touch_seam(dm, labels): if not (dm.hasLabel(name) and dm.getLabel(name).getStratumSize(int(value)) > 0): continue - for f in dm.getLabel(name).getStratumIS(int(value)).getIndices(): - f = int(f) - if not (fS <= f < fE): - continue - if shared[f - pStart] or any( - shared[int(q) - pStart] - for q in dm.getTransitiveClosure(f)[0]): - touch = True - break + f = np.asarray(dm.getLabel(name).getStratumIS(int(value)).getIndices(), + dtype=np.int64) + f = f[(f >= fS) & (f < fE)] + if not f.size: + continue + # the facet itself and every point below it in its closure + below = [closure_points(dm, f, d) for d in range(dm.getDimension() - 1)] + pts = np.concatenate([f] + [b[b >= 0] for b in below]) + touch = bool(shared[pts - pStart].any()) return bool(uw.mpi.comm.allreduce(touch, op=MPI.LOR)) @@ -1991,19 +1992,8 @@ def _redistribute_fault_interior(dm, labels, verbose=False): comm = dm.getComm().tompi4py() if comm.size == 1: return dm - fS, fE = dm.getHeightStratum(1) - vS, vE = dm.getDepthStratum(0) - pStart, pEnd = dm.getChart() - ids = np.zeros(pEnd - pStart, dtype=np.int32) - for name, value in labels: - if not (dm.hasLabel(name) - and dm.getLabel(name).getStratumSize(int(value)) > 0): - continue - for f in dm.getLabel(name).getStratumIS(int(value)).getIndices(): - if fS <= int(f) < fE: - for q in dm.getTransitiveClosure(int(f))[0]: - if vS <= int(q) < vE: - ids[int(q) - pStart] = 1 + from underworld3.utilities.addon_partition import mark_labels + ids = (mark_labels(list(labels), [list(labels)])(dm) > 0).astype(np.int32) if comm.allreduce(int(ids.max()) if ids.size else 0, op=MPI.MAX) == 0: names = sorted(name for name, _value in labels) raise RuntimeError( @@ -2112,18 +2102,17 @@ def redistribute_by_assignment(dm, assign, pairs=None, fields=None): if fields: # one cell-wise section, the fields interleaved; integers travel as # doubles, exact below 2**53 + from underworld3.utilities._plex_topology import (fill_section, + section_offsets) nf = len(fields) + cells = np.arange(cS, cE) sec = PETSc.Section().create(comm=work.getComm()) - sec.setChart(*work.getChart()) - for c in range(cS, cE): - sec.setDof(c, nf) - sec.setUp() + fill_section(sec, work, cells, nf) vec = PETSc.Vec().createSeq(sec.getStorageSize(), comm=PETSc.COMM_SELF) arr = vec.getArray() - for c in range(cS, cE): - o = sec.getOffset(c) - for j, f in enumerate(fields): - arr[o + j] = float(f[c - cS]) + offs = section_offsets(sec, cells) + for j, f in enumerate(fields): + arr[offs + j] = np.asarray(f, dtype=float) vec.setArray(arr) part = work.getPartitioner() part.setType(PETSc.Partitioner.Type.SHELL) @@ -2136,7 +2125,7 @@ def redistribute_by_assignment(dm, assign, pairs=None, fields=None): newsec, newvec = work.distributeField(sf, sec, vec) ncS, ncE = work.getHeightStratum(0) narr = newvec.getArray() - offs = np.array([newsec.getOffset(c) for c in range(ncS, ncE)], dtype=np.int64) + offs = section_offsets(newsec, np.arange(ncS, ncE)) new_fields = [np.rint(narr[offs + j]).astype(np.int64) if offs.size else np.zeros(0, dtype=np.int64) for j in range(nf)] new_pairs, dropped = (collect_point_pairs(work, names) if pairs diff --git a/src/underworld3/utilities/place_surface.py b/src/underworld3/utilities/place_surface.py index 1df55b68d..fd567acc7 100644 --- a/src/underworld3/utilities/place_surface.py +++ b/src/underworld3/utilities/place_surface.py @@ -1330,11 +1330,10 @@ def place_along_lines(dm, lines, label=CUT_LABEL, label_value=1, def _tet_vertices(dm): """(n_cells, 4) local vertex indices of every tetrahedron.""" - vS, vE = dm.getDepthStratum(0) + from underworld3.utilities._plex_topology import closure_points + vS, _vE = dm.getDepthStratum(0) cS, cE = dm.getHeightStratum(0) - return np.array([[int(p) - vS for p in dm.getTransitiveClosure(c)[0] - if vS <= p < vE] for c in range(cS, cE)], - dtype=np.int64).reshape(cE - cS, 4) + return (closure_points(dm, np.arange(cS, cE), 0) - vS).reshape(cE - cS, 4) def _face_vertex_triple(dm, f, vS): @@ -1594,9 +1593,10 @@ def _vertex_h_3d(dm, cells, n_vertices): vS, _vE = dm.getDepthStratum(0) pStart, pEnd = dm.getChart() work = np.full(pEnd - pStart, np.inf) - for c, tet in enumerate(cells): - idx = tet + vS - pStart - work[idx] = np.minimum(work[idx], h_cell[c]) + if len(cells): + cells = np.asarray(cells, dtype=np.int64) + np.minimum.at(work, cells + vS - pStart, + np.broadcast_to(h_cell[:, None], cells.shape)) work = _propagate_vertex(dm, work, MPI.MIN, np.minimum) return work[vS - pStart: vS - pStart + n_vertices], h_cell @@ -1612,13 +1612,15 @@ def _true_wall_vertex_mask(dm, n_vertices): """ vS, _vE = dm.getDepthStratum(0) pStart, pEnd = dm.getChart() + from underworld3.utilities._plex_topology import closure_points, supports shared = _shared_point_flags(dm) mark = np.zeros(pEnd - pStart, dtype=np.int32) - for f in range(*dm.getHeightStratum(1)): - if len(dm.getSupport(f)) == 1 and not shared[f - pStart]: - for q in dm.getTransitiveClosure(f)[0]: - if dm.getPointDepth(int(q)) == 0: - mark[int(q) - pStart] = 1 + fS, fE = dm.getHeightStratum(1) + faces = np.arange(fS, fE) + n_support = (supports(dm, faces) >= 0).sum(axis=1) + wall = faces[(n_support == 1) & (shared[faces - pStart] == 0)] + if wall.size: + mark[closure_points(dm, wall, 0).ravel() - pStart] = 1 mark = _propagate_vertex(dm, mark, MPI.MAX, np.maximum) return mark[vS - pStart: vS - pStart + n_vertices] == 1 @@ -1656,8 +1658,9 @@ def find(a): return (comp + 1).astype(np.int32) -def _gather_region(dm, vertex_mark_chart, verbose=False): - """Redistribute so every marked vertex's cell star (+1 layer) is one rank's. +def _gather_region(dm, vertex_mark_chart, verbose=False, layers=1): + """Redistribute so every marked vertex's cell star (+ ``layers``) is one + rank's. The one-region form of :func:`_gather_regions` (a 0/1 mask is one region). Returns ``(new_dm, n_moved)``: the global count of cells @@ -1667,7 +1670,7 @@ def _gather_region(dm, vertex_mark_chart, verbose=False): """ ids = (np.asarray(vertex_mark_chart) != 0).astype(np.int32) work, _n_region, n_moved, _owner, _canon = _gather_regions( - dm, ids, verbose=verbose, layers=1) + dm, ids, verbose=verbose, layers=layers) return work, n_moved @@ -1702,33 +1705,40 @@ def _region_cells(dm, vertex_region_chart, layers=1): n_ids = int(comm.allreduce(int(ids_in.max()) if ids_in.size else 0, op=MPI.MAX)) cS, cE = dm.getHeightStratum(0) - vS, vE = dm.getDepthStratum(0) pStart, pEnd = dm.getChart() pairs = set() # (a, b): regions that touch, to be merged + def add_pairs(a, b): + if a.size: + for x, y in np.unique(np.column_stack([a, b]), axis=0).tolist(): + pairs.add((int(x), int(y))) + def reconcile(chart): # every rank agrees on every point it can see; where a rank's own # id loses to a neighbour's under MAX the two regions touch before = chart.copy() after = _propagate_vertex(dm, chart, MPI.MAX, np.maximum) touch = (before > 0) & (after != before) - for a, b in zip(before[touch], after[touch]): - pairs.add((int(a), int(b))) + add_pairs(before[touch], after[touch]) return after + from underworld3.utilities._plex_topology import closure_points + # each cell's vertices, as chart indices + CV = closure_points(dm, np.arange(cS, cE), 0) - pStart cell_region = np.zeros(cE - cS, dtype=np.int32) + def note_touching(ids, best): + # every nonzero id that differs from its row's largest touches it + rows, cols = np.nonzero((ids > 0) & (ids != best[:, None])) + add_pairs(ids[rows, cols], best[rows]) + def claim_cells(chart): - for v in range(vS, vE): - k = int(chart[v - pStart]) - if k == 0: - continue - for q in dm.getTransitiveClosure(v, useCone=False)[0]: - if cS <= int(q) < cE: - c = int(q) - cS - if cell_region[c] and cell_region[c] != k: - pairs.add((int(cell_region[c]), k)) - cell_region[c] = max(cell_region[c], k) + # a cell takes the largest id among its own and its vertices'; the + # ids that meet in one cell touch + ids = np.column_stack([cell_region, chart[CV]]) + best = ids.max(axis=1) + note_touching(ids, best) + cell_region[:] = best claim_cells(reconcile(ids_in.copy())) # Growth layers: a point is unshared exactly when all its incident @@ -1736,20 +1746,17 @@ def claim_cells(chart): # closures unshared. for _grow in range(int(layers)): layer = np.zeros(pEnd - pStart, dtype=np.int32) - for c in np.flatnonzero(cell_region): - k = int(cell_region[c]) - for q in dm.getTransitiveClosure(int(c) + cS)[0]: - if vS <= int(q) < vE: - i = int(q) - pStart - if layer[i] and layer[i] != k: - pairs.add((int(layer[i]), k)) - layer[i] = max(layer[i], k) + held = np.broadcast_to(cell_region[:, None], CV.shape) + np.maximum.at(layer, CV, held) + # a vertex in cells of two ids: each cell's id touches the vertex's + note_touching(held.reshape(-1, 1), layer[CV].reshape(-1)) claim_cells(reconcile(layer)) canon = _merge_regions(pairs, n_ids, comm) - for c in np.flatnonzero(cell_region): - cell_region[c] = canon[int(cell_region[c])] - return cell_region, canon, sorted(set(canon.values())) + lookup = np.zeros(n_ids + 1, dtype=np.int32) + for k, v in canon.items(): + lookup[k] = v + return lookup[cell_region], canon, sorted(set(canon.values())) def _gather_regions(dm, vertex_region_chart, verbose=False, layers=1): @@ -3303,15 +3310,13 @@ def place_sheet(dm, points, triangles, label=CUT_LABEL, label_value=1, # The Mesh API (add_conforming_sheet, add_fault) partitions the add-on # for the sheet BEFORE calling here, with this mask plus two growth # layers on every add-on level (addon_partition.mark_sheets), so the - # gather below finds the region rank-interior and moves nothing. - # TODO(BUG): on a dm handed in directly, this mask alone does not cover - # the cavity GROWTH (_carve_cavity_3d's enclosed-cell and pinched-vertex - # rounds take whole vertex stars at the shell, one more diameter out): on - # a partitioned adapt child the grown cavity touched a shared point and - # the surgery rank raised "the gather mask under-reached" (measured - # 2026-10-17 at 2 and 8 ranks on the Mesh path before it partitioned; no - # test exercises the direct path). The gather here needs the second layer - # too. See planning file: underworld.md. + # gather below finds the region rank-interior and moves nothing. A dm + # handed in directly is gathered with the same two layers: the carve's + # growth rounds (_carve_cavity_3d's enclosed-cell and pinched-vertex + # rounds) take whole vertex stars at the shell, one more diameter out, + # and with one layer the grown cavity touched a shared point (measured + # 2026-10-17 on a partitioned adapt child at 2 and 8 ranks; + # tests/parallel/test_1097). mark = np.zeros(pEnd - pStart, dtype=np.int32) mark[np.flatnonzero(d_sheet < (clearance + 1.0) * h_vertex) + vS - pStart] = 1 @@ -3320,7 +3325,7 @@ def place_sheet(dm, points, triangles, label=CUT_LABEL, label_value=1, [_owned_cell_volume(dm)], dtype=float) comm.Allreduce(MPI.IN_PLACE, volume_before, op=MPI.SUM) - dm_work, moved = _gather_region(dm, mark, verbose=verbose) + dm_work, moved = _gather_region(dm, mark, verbose=verbose, layers=2) if moved: vS, vE = dm_work.getDepthStratum(0) pStart, pEnd = dm_work.getChart() @@ -9298,7 +9303,6 @@ def place_fault_ribbon_2d(base_mesh, traces, width, *, margin_rings=2, f"{labels}") if spines is None: spines = [(label, np.asarray(P, dtype=float)) for label, P in traces] - dm = base_mesh.dm spacing_all, rungs_all, extended = [], [], [] for label, P in spines: P = np.asarray(P, dtype=float) @@ -9311,6 +9315,18 @@ def place_fault_ribbon_2d(base_mesh, traces, width, *, margin_rings=2, float(np.linalg.norm(np.diff(P, axis=0), axis=1).mean())) rungs_all.append(len(P)) + # In parallel, gather mode: the add-on is partitioned for the bands + # first, so each placement's gather moves nothing and the tail is not + # co-located behind it (utilities.addon_partition). The ligament and + # conform modes mesh across the seams in place, by design. + host = base_mesh + if base_mesh.dm.getComm().getSize() > 1 and seams == "gather": + from underworld3.utilities.addon_partition import (mark_band, + partition_addon) + host = partition_addon(base_mesh, mark_band(extended, width, clearance), + layers=2, verbose=verbose) + dm = host.dm + if mesher == "network": # ONE placement call for the whole network: the ribbons are fused # in CAD, so touching strands and shared bands are ordinary cells @@ -9342,6 +9358,8 @@ def place_fault_ribbon_2d(base_mesh, traces, width, *, margin_rings=2, dm, simplex=True, qdegree=base_mesh.qdegree, coordinate_system_type=base_mesh.CoordinateSystem.coordinate_type, boundaries=base_mesh.boundaries, verbose=False) + from underworld3.utilities.addon_partition import inherit_provenance + inherit_provenance(mesh, host) # The placed mesh OWNS the base's multigrid tail (the coarse levels do # not need the fault): every solver on it drives FMG automatically, # and the band label is its FAC patch key for volumetric rheologies. @@ -9349,7 +9367,7 @@ def place_fault_ribbon_2d(base_mesh, traces, width, *, margin_rings=2, band_all = np.zeros(int(mesh.dm.getHeightStratum(0)[1]), dtype=bool) for k in range(len(spines)): band_all |= mesh.cells_labelled(band_label, band_value + k) - adopt_hierarchy(mesh, base_mesh, fac_zone=band_all) + adopt_hierarchy(mesh, host, fac_zone=band_all) if split: # ONE network call — cut all, then split all (chained add_fault # calls do not compose: each split re-derives the pairing records diff --git a/tests/parallel/test_1097_place_sheet_partitioned_addon_parallel.py b/tests/parallel/test_1097_place_sheet_partitioned_addon_parallel.py index ac85bcf66..b45eb2982 100644 --- a/tests/parallel/test_1097_place_sheet_partitioned_addon_parallel.py +++ b/tests/parallel/test_1097_place_sheet_partitioned_addon_parallel.py @@ -140,6 +140,23 @@ def _fingerprint(mesh): comm.allreduce(float(np.abs(v6).sum()) / 6.0)] +def test_direct_place_sheet_on_a_partitioned_adapt_child(): + """``place_sheet`` called on a DM directly, without the Mesh API's + partition: its own gather must cover the cavity's growth rounds. Before + the fix the mask grew one layer, the grown cavity touched a shared point + and the surgery rank refused ("the gather mask under-reached") on this + fixture at 2 ranks, where the same placement passes serially.""" + from underworld3.utilities.place_surface import place_sheet + comm = uw.mpi.comm + child = base_3d().adapt(graded_disc_metric(), max_levels=1) + pts, tris = disc_sheet() + dm, info = place_sheet(child.dm, pts, tris, label="Fault", label_value=40, + size=H_NEAR) + assert info["n_surface_facets"] > 0 + v6 = _cell_volumes_signed6(dm) + assert abs(comm.allreduce(float(np.abs(v6).sum()) / 6.0) - 1.0) < 1e-9 + + if __name__ == "__main__": _base, _placed, _split = _placed_and_split() emit(_counts(_placed, _split), _fingerprint(_split)) diff --git a/tests/parallel/test_1099_band_placement_partitioned_parallel.py b/tests/parallel/test_1099_band_placement_partitioned_parallel.py new file mode 100644 index 000000000..021a8e665 --- /dev/null +++ b/tests/parallel/test_1099_band_placement_partitioned_parallel.py @@ -0,0 +1,78 @@ +"""The placed fault band on a partitioned add-on, in parallel. + +The band route (``FaultNetwork.build`` in 3-D, ``place_fault_ribbon_2d`` in +gather mode) placed its band with ``place_thin_volume``, which gathered the +band's region onto a surgery rank; ``adopt_hierarchy`` then moved copies of +the base's coarse levels to follow it (``_colocate_level``), so every +transfer of the tail paired a level with one built for it. Both routes now +partition the add-on for the band first (``addon_partition.mark_band``, a +superset of the placement's own mask): the placement moves nothing, the tail +is the base's own levels with the partitioned host above them, and the pair +the partition separated is read from the recorded provenance. + +Asserted: no cell moved in the placement (the record the placement keeps); +the tail's static levels are the base's own cached wraps, by identity, and +the level above them is the partitioned host; the split pairs are whole; the +solve runs on the geometric tail, the separated pair built by provenance. +""" +import numpy as np +import pytest + +import underworld3 as uw + +pytestmark = [pytest.mark.mpi(min_size=2), pytest.mark.level_2, pytest.mark.tier_b, + pytest.mark.timeout(1800)] + +H = 0.08 +WIDTH = 0.04 +P_A = np.array([[0.30, 0.50, 0.30], [0.70, 0.50, 0.30], + [0.70, 0.50, 0.70], [0.30, 0.50, 0.70]]) + + +def _network(): + fs = uw.meshing.FaultSurface("Main", P_A) + fs.triangulate() + net = uw.meshing.FaultNetwork([fs]) + net.prepare(h=H, ligament=1.0, verbose=False) + net.build(width=WIDTH, realisation="split", h_far=0.24, margin_rings=0.5) + return net + + +def test_band_placement_moves_nothing_and_keeps_the_base_levels(): + from underworld3.utilities.fault_split import _fault_labels_touch_seam + net = _network() + mesh = net.mesh + comm = uw.mpi.comm + assert net.info["n_moved"] == 0, net.info + tail = mesh._custom_mg_coarse_meshes + host = tail[-1] + assert host._addon_partition is not None + wraps = host.parent._coarse_level_meshes() + assert len(tail) == len(wraps) + assert all(t is w for t, w in zip(tail[:-1], wraps[:-1])) + assert comm.allreduce(len(mesh._fault_point_pairs["Main"])) > 0 + labels = [(f"Main{side}", int(mesh.boundaries[f"Main{side}"].value)) + for side in ("Plus", "Minus")] + assert not _fault_labels_touch_seam(mesh.dm, labels) + + +def test_band_solve_runs_on_the_tail_with_the_provenance_pair(): + net = _network() + mesh = net.mesh + x, y, z = mesh.X + v = uw.discretisation.MeshVariable("vB", mesh, 3, degree=2) + p = uw.discretisation.MeshVariable("pB", mesh, 1, degree=0, continuous=False) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + stokes.bodyforce = [0.0, 0.0, 0.0] + for wall in ("Bottom", "Top", "Left", "Right", "Front", "Back"): + stokes.add_dirichlet_bc((y - 0.5, 0.0, 0.0), wall) + net.apply(stokes) + stokes.petsc_use_pressure_nullspace = True + stokes.tolerance = 1e-4 + info = net.solve(stokes) + assert info.get("converged") and info.get("velocity_pc") == "custom-FMG", info + stats = stokes._custom_mg["hierarchy"].level_transfer_stats + n_static = len(mesh._custom_mg_coarse_meshes) - 1 + assert stats[n_static] is not None and "fetched_remote" in stats[n_static], stats diff --git a/tests/test_1098_plex_topology_tables.py b/tests/test_1098_plex_topology_tables.py new file mode 100644 index 000000000..a51d2dc8f --- /dev/null +++ b/tests/test_1098_plex_topology_tables.py @@ -0,0 +1,84 @@ +"""The compiled DMPlex topology tables answer exactly what DMPlex answers. + +``utilities/_plex_topology`` builds, in one C loop per stratum, the tables the +fault, placement and add-on code used to assemble one petsc4py call at a time: +each point's closure in one depth, each point's support, a per-cell section and +its offsets, each cell's dof rows. The baseline is DMPlex itself, queried point +by point: the tables must agree entry for entry, in closure order, on 2-D and +3-D simplex meshes, on whatever partition the run has (the module is rank-local; +run under ``mpirun`` it is checked on every rank's chart). +""" +import numpy as np +import pytest +from petsc4py import PETSc + +import underworld3 as uw +from underworld3.utilities import _plex_topology as pt + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + + +@pytest.fixture(scope="module", params=[2, 3], ids=["2d", "3d"]) +def mesh(request): + if request.param == 2: + return uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, qdegree=2) + return uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0, 0.0), maxCoords=(1.0, 1.0, 1.0), cellSize=0.3, + regular=False, qdegree=2) + + +def _closure_in_depth(dm, p, depth): + dS, dE = dm.getDepthStratum(depth) + return [int(q) for q in dm.getTransitiveClosure(p)[0] if dS <= q < dE] + + +def test_closure_points_match_the_plex(mesh): + dm = mesh.dm + cS, cE = dm.getHeightStratum(0) + fS, fE = dm.getHeightStratum(1) + for points in (np.arange(cS, cE), np.arange(fS, fE)): + for depth in range(dm.getDimension()): + table = pt.closure_points(dm, points, depth) + for row, p in zip(table, points): + ref = _closure_in_depth(dm, int(p), depth) + assert row[row >= 0].tolist() == ref + assert (row[len(ref):] == -1).all() + + +def test_supports_match_the_plex(mesh): + dm = mesh.dm + for stratum in (dm.getHeightStratum(1), dm.getDepthStratum(0)): + points = np.arange(*stratum) + table = pt.supports(dm, points) + for row, p in zip(table, points): + assert row[row >= 0].tolist() == [int(q) for q in dm.getSupport(int(p))] + + +def test_cell_section_and_offsets_match_the_plex(mesh): + dm = mesh.dm + cS, cE = dm.getHeightStratum(0) + cells = np.arange(cS, cE) + section = PETSc.Section().create(comm=dm.getComm()) + pt.fill_section(section, dm, cells, 3) + assert section.getChart() == dm.getChart() + assert section.getStorageSize() == 3 * len(cells) + offsets = pt.section_offsets(section, cells) + assert offsets.tolist() == [section.getOffset(int(c)) for c in cells] + assert sorted(offsets.tolist()) == list(range(0, 3 * len(cells), 3)) + + +def test_closure_dof_rows_match_the_plex(mesh): + dm = mesh.dm.getCoordinateDM() + section = mesh.dm.getCoordinateSection() + cdim = mesh.dm.getCoordinateDim() + cS, cE = mesh.dm.getHeightStratum(0) + table = pt.closure_dof_rows(dm, section, np.arange(cS, cE), cdim) + for row, c in zip(table, range(cS, cE)): + ref = [] + for q in dm.getTransitiveClosure(c)[0]: + n = section.getDof(int(q)) // cdim + if n: + o = section.getOffset(int(q)) // cdim + ref.extend(range(o, o + n)) + assert row[row >= 0].tolist() == ref From 8fe9ad767fa43e4679fc5b8e3220faed6f88b0c1 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sun, 18 Oct 2026 15:46:31 +1100 Subject: [PATCH 51/59] Mesh element table: the 1-D line cell The table that says how many faces and vertices a cell has had no 1-D entry, so a 1-D surface mesh (an annulus boundary, extract_surface) fell through to the tetrahedron entry and read 4 faces and 4 vertices per line cell. The face control-point builders, the geometry audit and the visualisation slice cell closures by those counts, and a line's closure has 3 points, so the slice took the line itself as a vertex. A line cell now has two faces, its two vertices, and the three TODO(BUG) markers left by the previous commit are resolved at the source rather than per function. Point location on a 1-manifold now evaluates exactly: the annulus case of test_0772's surface evaluate, an expected failure that blamed exactly this, passes serially and on 2 and 3 ranks, and is no longer marked. Test: tests/test_1100_element_table_matches_closures.py (the table against every cell's cone and closure on a 1-D surface, triangles, quadrilaterals, tetrahedra and a 2-D surface in 3-D), shown failing on the line case first. Every serial and parallel test building a 1-D or manifold mesh passes. Underworld development team with AI support from Claude Code --- .../discretisation/discretisation_mesh.py | 29 ++++----- tests/test_0772_surface_extract.py | 16 ++--- ...est_1100_element_table_matches_closures.py | 62 +++++++++++++++++++ 3 files changed, 78 insertions(+), 29 deletions(-) create mode 100644 tests/test_1100_element_table_matches_closures.py diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index 8806ba7db..6233411a4 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -652,8 +652,11 @@ def __init__( self.qdegree = qdegree # Populate the element information for this mesh. This is intended to be - # human readable because the mesh is quite simple: either quads / tris in 2D - # tetrahedra / hexahedra in 3D + # human readable because the mesh is quite simple: lines in 1D (a surface + # submesh of a 2-D domain), quads / tris in 2D, tetrahedra / hexahedra + # in 3D. ``entities[k]`` counts a cell's points of codimension k + # (entities[1] its faces, entities[dim] its vertices); face_entities[dim] + # counts a face's vertices. from dataclasses import dataclass @@ -663,7 +666,12 @@ class ElementInfo: entities: tuple face_entities: tuple - if self.isSimplex: + if self.dim == 1: + # A line cell, simplex or not: two faces, which are its two + # vertices. Without this a 1-D mesh fell through to the + # tetrahedron entry and read 4 faces and 4 vertices per cell. + self._element = ElementInfo("line", (1, 2), (0, 1)) + elif self.isSimplex: if self.dim == 2: self._element = ElementInfo("triangle", (1, 3, 3), (0, 1, 2)) else: @@ -5968,11 +5976,6 @@ def _mark_faces_inside_and_out(self): pStart, pEnd = nav_dm.getDepthStratum(0) num_local_cells = cEnd - cStart cell_num_faces = self.element.entities[1] - # TODO(BUG): the element table gives 4 vertices for a 1-D surface - # mesh's line cell, so a slice of the last cell_num_points closure - # points takes the edge point as a vertex there; read the count from - # the closure (utilities._plex_topology.closure_points), as - # _build_kd_tree_index does. cell_num_points = self.element.entities[self.dim] face_num_points = self.element.face_entities[self.dim] @@ -6193,11 +6196,6 @@ def _mark_local_boundary_faces_inside_and_out(self): fStart, fEnd = nav_dm.getHeightStratum(1) pStart, pEnd = nav_dm.getDepthStratum(0) cell_num_faces = self.element.entities[1] - # TODO(BUG): the element table gives 4 vertices for a 1-D surface - # mesh's line cell, so a slice of the last cell_num_points closure - # points takes the edge point as a vertex there; read the count from - # the closure (utilities._plex_topology.closure_points), as - # _build_kd_tree_index does. cell_num_points = self.element.entities[self.dim] face_num_points = self.element.face_entities[self.dim] @@ -6875,11 +6873,6 @@ def _audit_cell_face_geometry(self): if cEnd == cStart: return 0.0, 0.0 cell_num_faces = self.element.entities[1] - # TODO(BUG): the element table gives 4 vertices for a 1-D surface - # mesh's line cell, so a slice of the last cell_num_points closure - # points takes the edge point as a vertex there; read the count from - # the closure (utilities._plex_topology.closure_points), as - # _build_kd_tree_index does. cell_num_points = self.element.entities[self.dim] face_num_points = self.element.face_entities[self.dim] diff --git a/tests/test_0772_surface_extract.py b/tests/test_0772_surface_extract.py index f180c13fc..b0ea1da73 100644 --- a/tests/test_0772_surface_extract.py +++ b/tests/test_0772_surface_extract.py @@ -95,17 +95,11 @@ def test_extract_surface_roundtrip_bit_exact(builder): # --------------------------------------------------------------------------- # on-surface symbolic evaluate # --------------------------------------------------------------------------- -@pytest.mark.parametrize("builder", [ - _shell, - pytest.param(_annulus, marks=pytest.mark.xfail( - reason="Point-location evaluate on a 1-manifold surface (dim=1, " - "cdim=2) is not yet supported: the in-cell test's FE element-entity " - "handling for edge cells is incomplete. Full dim=1 manifold support " - "(point-location + FE solve) is a tracked follow-up; the free-surface " - "smoother uses restrict/prolongate + surface-graph adjacency, which " - "do not need 1D point-location.", - strict=False)), -]) +# The 1-manifold case (an annulus boundary, dim=1, cdim=2) was an expected +# failure while the mesh's element table had no line entry: point location +# read 4 vertices per line cell. With the entry it evaluates exactly +# (2026-10-18, serial and 2 and 3 ranks; tests/test_1100). +@pytest.mark.parametrize("builder", [_shell, _annulus]) def test_extract_surface_evaluate(builder): m = builder() surf = m.extract_surface("Upper") diff --git a/tests/test_1100_element_table_matches_closures.py b/tests/test_1100_element_table_matches_closures.py new file mode 100644 index 000000000..b6bf92dfb --- /dev/null +++ b/tests/test_1100_element_table_matches_closures.py @@ -0,0 +1,62 @@ +"""The mesh's element table describes the cells the mesh actually has. + +``mesh.element`` says how many faces a cell has (``entities[1]``), how many +vertices (``entities[dim]``) and how many vertices a face has +(``face_entities[dim]``); the face-control-point builders, the geometry audit +and the visualisation slice cell closures by these counts. The table had no +1-D entry, so a 1-D surface mesh (an annulus boundary, ``extract_surface``) +fell through to the tetrahedron entry and read 4 faces and 4 vertices per line +cell; slicing the last 4 points of a line's 3-point closure took the line +itself as a vertex. + +The baseline is DMPlex: every cell's cone size, the vertices in its closure, +and the vertices of each face's closure, on a 1-D surface, 2-D triangles and +quadrilaterals, 3-D tetrahedra and a 2-D surface in 3-D. +""" +import numpy as np +import pytest + +import underworld3 as uw +from underworld3.utilities._plex_topology import closure_points + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + + +def _annulus_surface(): + annulus = uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.5, cellSize=0.2) + return annulus.extract_surface("Upper") + + +def _shell_surface(): + shell = uw.meshing.SphericalShell(radiusOuter=1.0, radiusInner=0.5, + cellSize=0.3) + return shell.extract_surface("Upper") + + +MESHES = { + "line (annulus surface)": _annulus_surface, + "triangle": lambda: uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.2), + "quadrilateral": lambda: uw.meshing.StructuredQuadBox(elementRes=(4, 3)), + "tetrahedron": lambda: uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0, 0.0), maxCoords=(1.0, 1.0, 1.0), cellSize=0.4), + "triangle in 3-D (shell surface)": _shell_surface, +} + + +@pytest.mark.parametrize("name", list(MESHES)) +def test_element_table_matches_the_cells(name): + mesh = MESHES[name]() + dm, el = mesh.dm, mesh.element + cS, cE = dm.getHeightStratum(0) + fS, fE = dm.getHeightStratum(1) + assert cE > cS + cells = np.arange(cS, cE) + cone_sizes = {dm.getConeSize(int(c)) for c in cells} + assert cone_sizes == {el.entities[1]}, (name, cone_sizes, el) + verts = closure_points(dm, cells, 0) + assert verts.shape[1] == el.entities[mesh.dim] and (verts >= 0).all(), \ + (name, verts.shape, el) + face_verts = closure_points(dm, np.arange(fS, fE), 0) + assert face_verts.shape[1] == el.face_entities[mesh.dim] \ + and (face_verts >= 0).all(), (name, face_verts.shape, el) From aed17e233cad8a18e17556da83ab0b8fefce2c97 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 23 Oct 2026 13:48:14 +1100 Subject: [PATCH 52/59] Custom-P multigrid on ranks that hold none of a coarse level A coarse level is small, and a placed fault mesh is gathered onto its surgery rank, so on a large run most ranks own no cell of the coarsest level and many own no fine cell either. Found on kaiju at np 104 with a 124-cell coarsest level (the faulted annulus): ranks without cells raised, the others waited in the next all-gather. Four places assumed a rank holds something: - _colocate_level shaped its centroid arrays from the cells it found. An empty rank gave shape (0,), so np.zeros((0, shape[1])) raised IndexError and the vstack of the all-gathered centroids failed on every rank. Both arrays are now (-1, cdim) from the DM's coordinate dimension. - build() took each level's component count as local DOFs over local nodes: ZeroDivisionError on a rank with no node. It is the field's count, now taken over the whole level. - _node_transfer_no_repair triangulated the rank's local coarse cloud: "No points given" on an empty rank. A rank with no fine node returns no rows; one with too few coarse nodes to span a simplex counts its fine nodes as outside the local hull, which already sends the "auto" build across partitions. - _fac_patch_split_parallel indexed the coarse selection with a clipped index on rows that have no coarse node: IndexError on a rank with fine rows and no coarse nodes (seen at np 16). tests/parallel/test_1103 (written first): colocating a 4-cell coarsest level with empty ranks, and a custom-P FMG Poisson solve on a 4-cell coarsest level against GAMG. Before: IndexError / ZeroDivisionError on 7 of 8 ranks and a hang on the eighth; after the first three fixes, IndexError at np 16. Now 2 passed at np 4, 5, 8, 16 and 32. The parallel custom-MG tests (1017, 1064, 1095, 1096, 1097, 1099): 22 passed, 3 skipped at np 2 and 4. tests/parallel/test_*.py runs it in CI. Underworld development team with AI support from Claude Code --- src/underworld3/utilities/custom_mg.py | 41 +++++++-- ...test_1103_custom_mg_empty_rank_parallel.py | 86 +++++++++++++++++++ 2 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 tests/parallel/test_1103_custom_mg_empty_rank_parallel.py diff --git a/src/underworld3/utilities/custom_mg.py b/src/underworld3/utilities/custom_mg.py index 70beb40b3..d191b1988 100644 --- a/src/underworld3/utilities/custom_mg.py +++ b/src/underworld3/utilities/custom_mg.py @@ -826,7 +826,11 @@ def _fac_patch_split_parallel(P, coords_c, coords_f, lay_c, lay_f, nc, comm, ref = np.asarray([c for c in ref_cnodes if c >= 0], dtype=np.int64) if ref.size: csel[ref] = True - halo_rows = (~row_is_patch) & (bg_cnode >= 0) & csel[np.clip(bg_cnode, 0, None)] + # Index csel only where a row has a background coarse node: a rank can + # hold fine rows and no coarse node at all, and then csel is empty. + halo_rows = np.zeros(n, dtype=bool) + with_bg = (~row_is_patch) & (bg_cnode >= 0) + halo_rows[with_bg] = csel[bg_cnode[with_bg]] node_in_sub = node_is_patch.copy() if n: node_in_sub[node_of_row[halo_rows]] = True @@ -884,7 +888,18 @@ def _coarse_dof_layout(solver, coarse_mesh, field_id=None): def _node_transfer_no_repair(builder, coarse_coords, fine_coords): """The node-level transfer for a parallel build: the barycentric builder with its serial orphan repair switched off (see the note in - :func:`barycentric_prolongation`); any other builder as it is.""" + :func:`barycentric_prolongation`); any other builder as it is. + + A rank can hold none of a small level. With no fine node it has no rows. + With too few coarse nodes to span a simplex it cannot place its fine nodes + locally: they count as outside the local hull, which is what sends the + "auto" build across partitions, where the coarse cloud is the whole level.""" + import scipy.sparse as sp + n_fine, n_coarse = fine_coords.shape[0], coarse_coords.shape[0] + if n_fine == 0 or n_coarse <= coarse_coords.shape[1]: + last_builder_stats["outside_hull"] = n_fine + last_builder_stats["orphans_repaired"] = 0 + return sp.csr_matrix((n_fine, n_coarse)) if builder is barycentric_prolongation: return builder(coarse_coords, fine_coords, repair_orphans=False) return builder(coarse_coords, fine_coords) @@ -1472,10 +1487,16 @@ def build(self, solver): rmap, nfull = (_reduced_map(solver.dm, self.field_id) if finest else _coarse_reduced_map(solver, mesh, self.field_id)) maps.append(rmap) - nc = nfull // c.shape[0] - if nfull % c.shape[0] != 0: + # The component count is the field's: take it over the whole level, + # since a rank can hold no node of a small one. + nodes, dofs = c.shape[0], nfull + if parallel: + level_comm = solver.dm.comm.tompi4py() + nodes, dofs = level_comm.allreduce(nodes), level_comm.allreduce(dofs) + nc = dofs // nodes + if dofs % nodes != 0: raise RuntimeError( - f"level {k}: full DOFs {nfull} not divisible by nodes {c.shape[0]}") + f"level {k}: full DOFs {dofs} not divisible by nodes {nodes}") coords.append(c) ncomp.append(nc) @@ -2261,7 +2282,11 @@ def _colocate_level(coarse_mesh, fine_mesh): if comm.size == 1: return coarse_mesh cS, cE = dm.getHeightStratum(0) - cen_c = np.array([dm.computeCellGeometryFVM(c)[1] for c in range(cS, cE)]) + # Shaped from the coordinate dimension, not from the cells found: a coarse + # level is small, and on a large run most ranks own none of it. + cdim = dm.getCoordinateDim() + cen_c = np.array([dm.computeCellGeometryFVM(c)[1] for c in range(cS, cE)], + dtype=float).reshape(-1, cdim) fdm = fine_mesh.dm fS, fE = fdm.getHeightStratum(0) # OWNED fine cells only: a ghost cell belongs to another rank @@ -2272,8 +2297,8 @@ def _colocate_level(coarse_mesh, fine_mesh): except (ValueError, TypeError): ghost = set() owned_f = [c for c in range(fS, fE) if c not in ghost] - cen_f = (np.array([fdm.computeCellGeometryFVM(c)[1] for c in owned_f]) - if owned_f else np.zeros((0, cen_c.shape[1]))) + cen_f = np.array([fdm.computeCellGeometryFVM(c)[1] for c in owned_f], + dtype=float).reshape(-1, cdim) # every rank offers its nearest owned fine cell to EVERY coarse centroid # in the mesh (all-gathered: the coarse levels are small) cen_all = np.vstack(comm.allgather(cen_c)) diff --git a/tests/parallel/test_1103_custom_mg_empty_rank_parallel.py b/tests/parallel/test_1103_custom_mg_empty_rank_parallel.py new file mode 100644 index 000000000..f6fdbdba8 --- /dev/null +++ b/tests/parallel/test_1103_custom_mg_empty_rank_parallel.py @@ -0,0 +1,86 @@ +"""Custom-P multigrid on more ranks than its coarsest level has cells. + +A coarse level is small by construction, so on a large run most ranks own none +of its cells, and the fine mesh of a placed fault is gathered onto one rank, so +most ranks own none of the finest cells either. Found on kaiju at np 104 (a +124-cell coarsest level, the faulted annulus): ``_colocate_level`` shaped its +centroid arrays from the cells it found, an empty rank raised IndexError, and +the ranks that had cells waited in the next all-gather for ever. + +Run with at least four ranks; the 1 x 1 box at cell size 1 has four cells. +""" +import numpy as np +import pytest + +import underworld3 as uw +from underworld3.utilities import custom_mg + +pytestmark = [pytest.mark.mpi(min_size=4), pytest.mark.level_2, pytest.mark.tier_b, + pytest.mark.timeout(300)] + + +def _box(cellSize, refinement=None): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=cellSize, + regular=True, refinement=refinement, qdegree=2) + + +def _cells(mesh): + start, end = mesh.dm.getHeightStratum(0) + return end - start + + +def _wrap(dm, like): + return uw.discretisation.Mesh( + dm.clone(), simplex=True, + coordinate_system_type=like.CoordinateSystem.coordinate_type, + qdegree=2, boundaries=like.boundaries) + + +def _poisson(mesh): + p = uw.systems.Poisson(mesh) + p.constitutive_model = uw.constitutive_models.DiffusionModel + p.constitutive_model.Parameters.diffusivity = 1 + p.f = 0.0 + p.add_dirichlet_bc(0.0, "Bottom") + p.add_dirichlet_bc(1.0, "Top") + p.petsc_options["ksp_rtol"] = 1e-8 + p.petsc_options["ksp_type"] = "cg" + return p + + +def test_colocating_a_level_with_empty_ranks(): + """Every rank returns, and the coarse level keeps all its cells.""" + base = _box(1.0, refinement=3) + coarsest = base._coarse_level_meshes()[0] + total = uw.mpi.comm.allreduce(_cells(coarsest)) + empty = uw.mpi.comm.allreduce(int(_cells(coarsest) == 0 and _cells(base) == 0)) + assert total < uw.mpi.size and empty > 0, (total, empty) # the case under test + + moved = custom_mg._colocate_level(coarsest, base) + assert uw.mpi.comm.allreduce(_cells(moved)) == total + + +def test_custom_fmg_with_a_coarsest_level_smaller_than_the_ranks(): + """The custom-P solve installs its multigrid and agrees with GAMG.""" + m0 = _box(1.0) + dm1 = m0.dm.refine() + dm2 = dm1.refine() + dm3 = dm2.refine() + assert uw.mpi.comm.allreduce(_cells(m0)) < uw.mpi.size # the case under test + + reference = _poisson(_wrap(dm3, m0)) + reference.preconditioner = "gamg" + reference.solve() + + solver = _poisson(_wrap(dm3, m0)) + custom_mg.set_custom_fmg(solver, [_wrap(m0.dm, m0), _wrap(dm1, m0), _wrap(dm2, m0)], + builder="barycentric") + solver.solve() + + assert solver.snes.getKSP().getPC().getType() == "mg" + assert solver.snes.getConvergedReason() > 0 + diff = np.asarray(solver.Unknowns.u.data) - np.asarray(reference.Unknowns.u.data) + num = uw.mpi.comm.allreduce(float(np.sum(diff ** 2))) + den = uw.mpi.comm.allreduce(float(np.sum(np.asarray(reference.Unknowns.u.data) ** 2))) + assert (num / den) ** 0.5 < 1.0e-6 From bfae3e657c9c11f96823fa362de37933dc809d0d Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 23 Oct 2026 14:49:50 +1100 Subject: [PATCH 53/59] The fault cut and the provenance transfer on ranks that hold nothing Two more places that assumed every rank holds part of a level, both on the first faulted-mesh build: - line_cut._edge_vertices built its (n_edges, 2) table from the edges it found; a rank with none got shape (0,), and _crossing_parameters' ends[:, 0] raised IndexError there while the other ranks waited. It is now reshape(-1, 2). - fault_mg.provenance_transfer_parallel packed its star-forest roots with reshape(nc_loc, -1); with no coarse cell, reshape(0, -1) cannot infer the width and raised ValueError. The widths are now explicit. The raise was caught by build_transfers' per-rank retry, so the failing ranks started an RBF rebuild alone and the job died in mismatched collectives (MPI_ERR_BUFFER, "Message truncated") -- the kaiju np 32 / 52 signature. Measured on a faulted annulus at np 32 (cell 0.35, four refinements) and np 8 (cell 0.8): the three or two ranks with no coarse cell raised here. tests/parallel/test_1103 gains a line cut on a mesh where most ranks hold no cell (written first: IndexError on 7 of 8 ranks and a hang on the eighth; now 3 passed at np 8). The provenance fix has no CI-sized test yet: the faulted annulus at np 8 still diverges later, inside the rotated solve, and is kept out of the suite until that is understood. Provenance-path tests (1096, 1097, 1099) at np 2: 9 passed. Underworld development team with AI support from Claude Code --- src/underworld3/utilities/fault_mg.py | 8 +++++--- src/underworld3/utilities/line_cut.py | 4 +++- .../test_1103_custom_mg_empty_rank_parallel.py | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/underworld3/utilities/fault_mg.py b/src/underworld3/utilities/fault_mg.py index 19b863dcf..290017487 100644 --- a/src/underworld3/utilities/fault_mg.py +++ b/src/underworld3/utilities/fault_mg.py @@ -490,9 +490,11 @@ def provenance_transfer_parallel(coarse, fine, pre, degree, continuous, width = nb * ncomp + nb * dim + nv * dim nc_loc = cv_c.shape[0] root = np.empty((nc_loc, width)) - root[:, : nb * ncomp] = l2g_c[cn].reshape(nc_loc, -1) - root[:, nb * ncomp: nb * ncomp + nb * dim] = Xc[cn].reshape(nc_loc, -1) - root[:, nb * ncomp + nb * dim:] = vxy_c[cv_c].reshape(nc_loc, -1) + # explicit widths: a rank can own no cell of the coarse level, and + # reshape(0, -1) cannot infer one + root[:, : nb * ncomp] = l2g_c[cn].reshape(nc_loc, nb * ncomp) + root[:, nb * ncomp: nb * ncomp + nb * dim] = Xc[cn].reshape(nc_loc, nb * dim) + root[:, nb * ncomp + nb * dim:] = vxy_c[cv_c].reshape(nc_loc, nv * dim) sf = PETSc.SF().create(comm=comm) sf.setGraph(nc_loc, None, np.ascontiguousarray(uniq, dtype=np.int32)) sf.setUp() diff --git a/src/underworld3/utilities/line_cut.py b/src/underworld3/utilities/line_cut.py index 515baf5ee..fff7e0288 100644 --- a/src/underworld3/utilities/line_cut.py +++ b/src/underworld3/utilities/line_cut.py @@ -108,7 +108,9 @@ def _edge_vertices(dm): """(n_edges, 2) local vertex indices of every edge, in cone order.""" vS, _vE = dm.getDepthStratum(0) eS, eE = dm.getDepthStratum(1) - return np.array([dm.getCone(e) for e in range(eS, eE)], dtype=np.int64) - vS + # (0, 2) on a rank that holds no edge, so the edge arithmetic still applies + return np.array([dm.getCone(e) for e in range(eS, eE)], + dtype=np.int64).reshape(-1, 2) - vS def _coords(dm): diff --git a/tests/parallel/test_1103_custom_mg_empty_rank_parallel.py b/tests/parallel/test_1103_custom_mg_empty_rank_parallel.py index f6fdbdba8..5b131dbbc 100644 --- a/tests/parallel/test_1103_custom_mg_empty_rank_parallel.py +++ b/tests/parallel/test_1103_custom_mg_empty_rank_parallel.py @@ -84,3 +84,17 @@ def test_custom_fmg_with_a_coarsest_level_smaller_than_the_ranks(): num = uw.mpi.comm.allreduce(float(np.sum(diff ** 2))) den = uw.mpi.comm.allreduce(float(np.sum(np.asarray(reference.Unknowns.u.data) ** 2))) assert (num / den) ** 0.5 < 1.0e-6 + + + +def test_cutting_a_line_with_empty_ranks(): + """The fault line cut runs on every rank and splits the edges it crosses.""" + from underworld3.utilities.line_cut import cut_along_lines + mesh = _box(1.0) + empty = uw.mpi.comm.allreduce(int(_cells(mesh) == 0)) + assert empty > 0, empty # the case under test + line = np.array([[-0.1, 0.55], [1.1, 0.55]]) # wall to wall + cut, _info = cut_along_lines(mesh.dm, [line]) + start, end = cut.getHeightStratum(0) + assert uw.mpi.comm.allreduce(end - start) > uw.mpi.comm.allreduce(_cells(mesh)) + From 371d3bd172cef69355e84b70f1ab4113fb17b48c Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 23 Oct 2026 23:22:50 +1100 Subject: [PATCH 54/59] The rotated continuation switches to Newton against the convergence reference The rotated nonlinear loop tests convergence against ref = max(r0, ||F(0)||), the rest-state residual (the warm-start rtol cure), but its Picard->Newton continuation switch still read rnorm <= switch_rtol * r0, the warm start's own residual. A warm start is already in Newton's basin, yet had to shrink its residual a hundredfold under Picard first: on the fault campaign, rebuild steps took 23-27 nonlinear iterations against 4 for ordinary steps. The switch now reads ref, and the result dict records the iteration it fired at (continuation_switch_iteration). Measured on test_1018's power-law box, re-solved from a converged state after a 1% change in the body force: switch at iteration 12, 13 iterations in all, before; switch at iteration 0, 2 iterations, after. The cold solve is unchanged (switch at 15, 17 iterations): there ref is r0. The generic path was already consistent: its continuation ramp keys alpha and the stop on F_ref = max(||F(0)||, F_start). No other solver loop compares a threshold with r0. test_rotated_continuation_warm_start_switches_to_newton_at_once (written first; with only the record added it failed "assert 12 in (0, 1)"). Rotated suite (test_1018 x2, 0062, 1066, 1021, 1058, 0203, 0019, 0020, 0013, 1070, 1073): 133 passed. Underworld development team with AI support from Claude Code --- src/underworld3/utilities/rotated_bc.py | 12 ++++++++++-- tests/test_1018_rotated_freeslip.py | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index 33b5bdad6..83ec12fbe 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -1018,6 +1018,8 @@ def solve_rotated_freeslip(solver, boundaries, remove_rotation_gauge=True, * ``"rnorm"``, ``"rnorm0"`` — final and initial rotated residual norms ‖F̂‖ (feed the solve report); * ``"continuation_switched"`` — whether the Picard→Newton tangent switch fired; + * ``"continuation_switch_iteration"`` — the iteration it fired at (``None`` + if it did not); * ``"rotation_reused"`` — the GEOMETRY tier (Q/Qt/constrained rows/ prolongation) came from the cross-solve cache; * ``"workspace_reused"`` — the iteration-0 operator fast path fired @@ -1338,6 +1340,7 @@ def rotated_residual(uvec, keep_cartesian=False): # rotated solve as unconverged — including ones that converged at rel 1e-8. exit_reason = _SNES_DIVERGED_MAX_IT phase = "picard" if continuation else "newton" + switch_iteration = None for iters in range(max_it): Fhat = rotated_residual(u, keep_cartesian=True) rnorm = Fhat.norm() @@ -1357,12 +1360,16 @@ def rotated_residual(uvec, keep_cartesian=False): # Continuation: switch the frozen (Picard, α=0) tangent to the consistent # (Newton, α=1) tangent once the residual has dropped into Newton's basin (the # loose newton_switch_rtol) and at least `picard` Picard iterations have run. - if continuation and phase == "picard" and rnorm <= switch_rtol * r0 and iters >= picard: + # Measured against the convergence reference, not r0: a warm start is already + # in the basin, and against its own residual it had to shrink that a + # hundredfold under Picard first (12 of 13 iterations on the power-law box). + if continuation and phase == "picard" and rnorm <= switch_rtol * ref and iters >= picard: solver._set_newton_alpha(1.0) phase = "newton" + switch_iteration = iters if verbose: mpi.pprint(f"[rotated_bc] continuation: Picard→Newton at iter {iters} " - f"(rel |F̂| {rnorm/(r0+1e-300):.2e})") + f"(rel |F̂| {rnorm/(ref+1e-300):.2e})") # Iteration 0 may ride the cross-solve fast path: the cached Ahat still # holds the rotated, constraint-eliminated operator and the cached # KSP/PC is set up on it. Every LATER iteration always reassembles — @@ -1598,6 +1605,7 @@ def rotated_residual(uvec, keep_cartesian=False): "schur_pre": "none" if use_lu else (ctx or {}).get("schur_pre"), "velocity_pc_type": None if use_lu else (ctx or {}).get("velocity_pc_type"), "continuation_switched": continuation and phase == "newton", + "continuation_switch_iteration": switch_iteration, "rotation_reused": rotation_reused, "workspace_reused": workspace_reused} diff --git a/tests/test_1018_rotated_freeslip.py b/tests/test_1018_rotated_freeslip.py index 6a7f56c30..ffa130479 100644 --- a/tests/test_1018_rotated_freeslip.py +++ b/tests/test_1018_rotated_freeslip.py @@ -1031,6 +1031,31 @@ def test_rotated_freeslip_continuation_tangent(plaw_box_ref): "picard did not extend the continuation Picard phase") +def test_rotated_continuation_warm_start_switches_to_newton_at_once(plaw_box_ref): + """A warm re-solve under consistent_jacobian='continuation' is already in + Newton's basin, and must switch at once. The switch is measured against the + same reference scale as convergence (the larger of the start's residual and + the rest-state residual), not against the warm start's own residual: against + that, a good start must still shrink its residual a hundredfold under Picard + before Newton is allowed. Measured 2026-10-23 on this box, the body force + changed by 1% from a converged state: the switch at iteration 12 and 13 + iterations in all, against 17 for the cold solve.""" + mesh, _, _ = plaw_box_ref + s, _, _ = _powerlaw_stokes(mesh, "wsR", cj="continuation") + for wall in ("Top", "Bottom", "Left", "Right"): + s.add_rotated_freeslip_bc(0, wall) + s.solve() + assert s._rotated_freeslip_info["continuation_switched"] + + x, _y = mesh.X + s.bodyforce = sympy.Matrix([[0.0, -2.02 * sympy.cos(sympy.pi * x)]]) + s.solve(zero_init_guess=False) + info = s._rotated_freeslip_info + assert info["converged"], info + assert info["continuation_switch_iteration"] in (0, 1), info["continuation_switch_iteration"] + assert info["nonlinear_iterations"] <= 4, info["nonlinear_iterations"] + + def test_rotated_freeslip_picard_under_newton_stages_picard_then_newton(plaw_box_ref): """picard>0 under the consistent-Newton tangent (the default since 2026-10-05) stages at least N Picard iterations, then Newton, on the blended Jacobian solve() compiles From 210c7307f7d25aea5d03958ee467434b13be5594 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 24 Oct 2026 23:14:49 +0000 Subject: [PATCH 55/59] The rotated solve reports its residual history, line-search steps and reference A campaign that wants to record how its nonlinear solves behave (the zg95 annulus rollback runs, where fault rebuilds are the point of the experiment) had to scrape -uw_verbose logs for numbers the loop already computes. The result dict now carries them: - "fnorm_rest": the rest-state residual ||F^(0)|| (warm starts; None cold) - "ref": max(r0, fnorm_rest), what the convergence and Picard->Newton switch tests compare against - "rnorm_history": ||F^|| at every iteration start (its or its+1 entries; the last equals "rnorm") - "line_search_alpha": the step accepted for each increment, one per increment (1.0 full step or the lift; a halved alpha on a cutback; 0.0 when the increment was not applied: a line-search stall or the step-norm exit) _backtracking_line_search returns (u, improved, alpha); its only caller is the rotated loop. No extra residual evaluations or solves. test_rotated_solve_records_its_diagnostics (written first; on 371d3bd it failed with KeyError 'rnorm_history'): cold and warm re-solves of the power-law box, checking the lengths, the history's ends against rnorm0 and rnorm, fnorm_rest None cold and > 0 warm, ref = max(rnorm0, fnorm_rest), and a full final step at the solution. test_1018: 27 passed (kaiju, 1 rank, overlay on 371d3bd). Written on kaiju (local commit 16ae9d2b) and relayed through the hub, since kaiju holds no GitHub credentials. Underworld development team with AI support from Claude Code --- src/underworld3/utilities/rotated_bc.py | 22 +++++++++++--- tests/test_1018_rotated_freeslip.py | 38 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index 83ec12fbe..372126e3d 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -910,7 +910,8 @@ def _backtracking_line_search(u, d, rnorm, rotated_residual, Q, Qt, normal_rows, and replaced by the accepted iterate. Returns ``(u, improved)``; ``improved`` is False when no step reduced the residual below ``rnorm`` — the iteration has stalled (typically because it is already at the solution) and the caller should - stop rather than accept a non-decreasing step.""" + stop rather than accept a non-decreasing step. Also returns the accepted step + length ``alpha`` (0.0 on a stall), which the loop records per increment.""" alpha = 1.0 for _ls in range(max_halvings): utry = u.copy() @@ -921,10 +922,10 @@ def _backtracking_line_search(u, d, rnorm, rotated_residual, Q, Qt, normal_rows, Ftry.destroy() if fnorm < rnorm: u.destroy() - return utry, True + return utry, True, alpha utry.destroy() alpha *= 0.5 - return u, False + return u, False, 0.0 def solve_rotated_freeslip(solver, boundaries, remove_rotation_gauge=True, @@ -1253,6 +1254,10 @@ def solve_rotated_freeslip(solver, boundaries, remove_rotation_gauge=True, Mp = ctx["Mp"] if ctx is not None else None diag_scale = cache["diag_scale"] if cache is not None else None lin_its = [] + # per-iteration records for campaign diagnostics: |F̂| at each iteration start, + # and the step accepted for each increment (0.0: not applied) + rnorm_history = [] + line_search_alpha = [] # velocity / pressure sub-KSP LAST-APPLICATION counts, one per Newton increment # (iterative path only — the direct-LU path has no sub-KSPs and records None) vel_its_last = [] @@ -1344,6 +1349,7 @@ def rotated_residual(uvec, keep_cartesian=False): for iters in range(max_it): Fhat = rotated_residual(u, keep_cartesian=True) rnorm = Fhat.norm() + rnorm_history.append(float(rnorm)) if r0 is None: r0 = rnorm ref = max(r0, fnorm_rest) if fnorm_rest is not None else r0 @@ -1492,9 +1498,13 @@ def rotated_residual(uvec, keep_cartesian=False): _impose_normal_constraint(u, Q, Qt, normal_rows, datum_map) step_converged = False improved = True + line_search_alpha.append(1.0) elif not step_converged: - u, improved = _backtracking_line_search( + u, improved, alpha = _backtracking_line_search( u, d, rnorm, rotated_residual, Q, Qt, normal_rows, datum_map) + line_search_alpha.append(alpha) + else: + line_search_alpha.append(0.0) # step-norm exit: the increment is not applied # every per-iteration temporary dies HERE, on all exit paths dhat.destroy() d.destroy() @@ -1598,6 +1608,10 @@ def rotated_residual(uvec, keep_cartesian=False): "ksp_reason": last_reason, "nonlinear_iterations": newton_its, "converged": converged, "ksp_its": lin_its, "rnorm": rnorm, "rnorm0": r0, + # the rest-state residual ‖F̂(0)‖ (warm starts only; None cold) and the + # reference the convergence and switch tests compare against + "fnorm_rest": fnorm_rest, "ref": ref, + "rnorm_history": rnorm_history, "line_search_alpha": line_search_alpha, "vel_its_last": vel_its_last, "pres_its_last": pres_its_last, # keyed on use_lu, NOT on `ctx is None`: the iterative path also exits # with ctx unset when a warm start is already converged at increment 0. diff --git a/tests/test_1018_rotated_freeslip.py b/tests/test_1018_rotated_freeslip.py index ffa130479..f7cc3c5bc 100644 --- a/tests/test_1018_rotated_freeslip.py +++ b/tests/test_1018_rotated_freeslip.py @@ -1056,6 +1056,44 @@ def test_rotated_continuation_warm_start_switches_to_newton_at_once(plaw_box_ref assert info["nonlinear_iterations"] <= 4, info["nonlinear_iterations"] +def test_rotated_solve_records_its_diagnostics(plaw_box_ref): + """The rotated solve reports what it measured, so a campaign can record it per + step without scraping verbose logs: the rest-state residual ‖F̂(0)‖ and the + reference the convergence and switch tests use, the residual at every iteration + start, and the accepted line-search step of every increment (0.0 when the + increment was not applied).""" + mesh, _, _ = plaw_box_ref + s, _, _ = _powerlaw_stokes(mesh, "dgR", cj="continuation") + for wall in ("Top", "Bottom", "Left", "Right"): + s.add_rotated_freeslip_bc(0, wall) + + def check(info): + its = info["nonlinear_iterations"] + hist, alpha = info["rnorm_history"], info["line_search_alpha"] + assert len(hist) in (its, its + 1), (len(hist), its) + assert hist[0] == info["rnorm0"] and hist[-1] == info["rnorm"], (hist[0], hist[-1]) + assert len(alpha) == its, (len(alpha), its) + assert all(a == 0.0 or 0.0 < a <= 1.0 for a in alpha), alpha + return its, alpha + + s.solve() + cold = s._rotated_freeslip_info + assert cold["converged"], cold + check(cold) + assert cold["fnorm_rest"] is None # a cold start's r0 IS the rest scale + assert cold["ref"] == cold["rnorm0"] + + x, _y = mesh.X + s.bodyforce = sympy.Matrix([[0.0, -2.02 * sympy.cos(sympy.pi * x)]]) + s.solve(zero_init_guess=False) + warm = s._rotated_freeslip_info + assert warm["converged"], warm + _its, alpha = check(warm) + assert warm["fnorm_rest"] > 0.0 + assert warm["ref"] == max(warm["rnorm0"], warm["fnorm_rest"]) + assert alpha[-1] == 1.0, alpha # a full step is accepted at the solution + + def test_rotated_freeslip_picard_under_newton_stages_picard_then_newton(plaw_box_ref): """picard>0 under the consistent-Newton tangent (the default since 2026-10-05) stages at least N Picard iterations, then Newton, on the blended Jacobian solve() compiles From dd6be744ad5ca625ebad459e588705b9181fe5e2 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 23 Oct 2026 23:22:50 +1100 Subject: [PATCH 56/59] The rotated continuation switches to Newton against the convergence reference The rotated nonlinear loop tests convergence against ref = max(r0, ||F(0)||), the rest-state residual (the warm-start rtol cure), but its Picard->Newton continuation switch still read rnorm <= switch_rtol * r0, the warm start's own residual. A warm start is already in Newton's basin, yet had to shrink its residual a hundredfold under Picard first: on the fault campaign, rebuild steps took 23-27 nonlinear iterations against 4 for ordinary steps. The switch now reads ref, and the result dict records the iteration it fired at (continuation_switch_iteration). Measured on test_1018's power-law box, re-solved from a converged state after a 1% change in the body force: switch at iteration 12, 13 iterations in all, before; switch at iteration 0, 2 iterations, after. The cold solve is unchanged (switch at 15, 17 iterations): there ref is r0. The generic path was already consistent: its continuation ramp keys alpha and the stop on F_ref = max(||F(0)||, F_start). No other solver loop compares a threshold with r0. test_rotated_continuation_warm_start_switches_to_newton_at_once (written first; with only the record added it failed "assert 12 in (0, 1)"). Rotated suite (test_1018 x2, 0062, 1066, 1021, 1058, 0203, 0019, 0020, 0013, 1070, 1073): 133 passed. Underworld development team with AI support from Claude Code --- src/underworld3/utilities/rotated_bc.py | 12 ++++++++++-- tests/test_1018_rotated_freeslip.py | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index fd2dbc788..3849e3ab1 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -1093,6 +1093,8 @@ def solve_rotated_freeslip(solver, boundaries, remove_rotation_gauge=True, * ``"rnorm"``, ``"rnorm0"`` — final and initial rotated residual norms ‖F̂‖ (feed the solve report); * ``"continuation_switched"`` — whether the Picard→Newton tangent switch fired; + * ``"continuation_switch_iteration"`` — the iteration it fired at (``None`` + if it did not); * ``"rotation_reused"`` — the GEOMETRY tier (Q/Qt/constrained rows/ prolongation) came from the cross-solve cache; * ``"workspace_reused"`` — the iteration-0 operator fast path fired @@ -1417,6 +1419,7 @@ def rotated_residual(uvec, keep_cartesian=False): # rotated solve as unconverged — including ones that converged at rel 1e-8. exit_reason = _SNES_DIVERGED_MAX_IT phase = "picard" if continuation else "newton" + switch_iteration = None for iters in range(max_it): Fhat = rotated_residual(u, keep_cartesian=True) rnorm = Fhat.norm() @@ -1436,12 +1439,16 @@ def rotated_residual(uvec, keep_cartesian=False): # Continuation: switch the frozen (Picard, α=0) tangent to the consistent # (Newton, α=1) tangent once the residual has dropped into Newton's basin (the # loose newton_switch_rtol) and at least `picard` Picard iterations have run. - if continuation and phase == "picard" and rnorm <= switch_rtol * r0 and iters >= picard: + # Measured against the convergence reference, not r0: a warm start is already + # in the basin, and against its own residual it had to shrink that a + # hundredfold under Picard first (12 of 13 iterations on the power-law box). + if continuation and phase == "picard" and rnorm <= switch_rtol * ref and iters >= picard: solver._set_newton_alpha(1.0) phase = "newton" + switch_iteration = iters if verbose: mpi.pprint(f"[rotated_bc] continuation: Picard→Newton at iter {iters} " - f"(rel |F̂| {rnorm/(r0+1e-300):.2e})") + f"(rel |F̂| {rnorm/(ref+1e-300):.2e})") # Iteration 0 may ride the cross-solve fast path: the cached Ahat still # holds the rotated, constraint-eliminated operator and the cached # KSP/PC is set up on it. Every LATER iteration always reassembles — @@ -1681,6 +1688,7 @@ def rotated_residual(uvec, keep_cartesian=False): "schur_pre": "none" if use_lu else (ctx or {}).get("schur_pre"), "velocity_pc_type": None if use_lu else (ctx or {}).get("velocity_pc_type"), "continuation_switched": continuation and phase == "newton", + "continuation_switch_iteration": switch_iteration, "rotation_reused": rotation_reused, "workspace_reused": workspace_reused} diff --git a/tests/test_1018_rotated_freeslip.py b/tests/test_1018_rotated_freeslip.py index 6a7f56c30..ffa130479 100644 --- a/tests/test_1018_rotated_freeslip.py +++ b/tests/test_1018_rotated_freeslip.py @@ -1031,6 +1031,31 @@ def test_rotated_freeslip_continuation_tangent(plaw_box_ref): "picard did not extend the continuation Picard phase") +def test_rotated_continuation_warm_start_switches_to_newton_at_once(plaw_box_ref): + """A warm re-solve under consistent_jacobian='continuation' is already in + Newton's basin, and must switch at once. The switch is measured against the + same reference scale as convergence (the larger of the start's residual and + the rest-state residual), not against the warm start's own residual: against + that, a good start must still shrink its residual a hundredfold under Picard + before Newton is allowed. Measured 2026-10-23 on this box, the body force + changed by 1% from a converged state: the switch at iteration 12 and 13 + iterations in all, against 17 for the cold solve.""" + mesh, _, _ = plaw_box_ref + s, _, _ = _powerlaw_stokes(mesh, "wsR", cj="continuation") + for wall in ("Top", "Bottom", "Left", "Right"): + s.add_rotated_freeslip_bc(0, wall) + s.solve() + assert s._rotated_freeslip_info["continuation_switched"] + + x, _y = mesh.X + s.bodyforce = sympy.Matrix([[0.0, -2.02 * sympy.cos(sympy.pi * x)]]) + s.solve(zero_init_guess=False) + info = s._rotated_freeslip_info + assert info["converged"], info + assert info["continuation_switch_iteration"] in (0, 1), info["continuation_switch_iteration"] + assert info["nonlinear_iterations"] <= 4, info["nonlinear_iterations"] + + def test_rotated_freeslip_picard_under_newton_stages_picard_then_newton(plaw_box_ref): """picard>0 under the consistent-Newton tangent (the default since 2026-10-05) stages at least N Picard iterations, then Newton, on the blended Jacobian solve() compiles From 99259d351a42dc7708944ee5d6ef75e28941c8f6 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 24 Oct 2026 23:14:49 +0000 Subject: [PATCH 57/59] The rotated solve reports its residual history, line-search steps and reference A campaign that wants to record how its nonlinear solves behave (the zg95 annulus rollback runs, where fault rebuilds are the point of the experiment) had to scrape -uw_verbose logs for numbers the loop already computes. The result dict now carries them: - "fnorm_rest": the rest-state residual ||F^(0)|| (warm starts; None cold) - "ref": max(r0, fnorm_rest), what the convergence and Picard->Newton switch tests compare against - "rnorm_history": ||F^|| at every iteration start (its or its+1 entries; the last equals "rnorm") - "line_search_alpha": the step accepted for each increment, one per increment (1.0 full step or the lift; a halved alpha on a cutback; 0.0 when the increment was not applied: a line-search stall or the step-norm exit) _backtracking_line_search returns (u, improved, alpha); its only caller is the rotated loop. No extra residual evaluations or solves. test_rotated_solve_records_its_diagnostics (written first; on 371d3bd it failed with KeyError 'rnorm_history'): cold and warm re-solves of the power-law box, checking the lengths, the history's ends against rnorm0 and rnorm, fnorm_rest None cold and > 0 warm, ref = max(rnorm0, fnorm_rest), and a full final step at the solution. test_1018: 27 passed (kaiju, 1 rank, overlay on 371d3bd). Written on kaiju (local commit 16ae9d2b) and relayed through the hub, since kaiju holds no GitHub credentials. Underworld development team with AI support from Claude Code --- src/underworld3/utilities/rotated_bc.py | 22 +++++++++++--- tests/test_1018_rotated_freeslip.py | 38 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index 3849e3ab1..9e8cc7ebb 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -985,7 +985,8 @@ def _backtracking_line_search(u, d, rnorm, rotated_residual, Q, Qt, normal_rows, and replaced by the accepted iterate. Returns ``(u, improved)``; ``improved`` is False when no step reduced the residual below ``rnorm`` — the iteration has stalled (typically because it is already at the solution) and the caller should - stop rather than accept a non-decreasing step.""" + stop rather than accept a non-decreasing step. Also returns the accepted step + length ``alpha`` (0.0 on a stall), which the loop records per increment.""" alpha = 1.0 for _ls in range(max_halvings): utry = u.copy() @@ -996,10 +997,10 @@ def _backtracking_line_search(u, d, rnorm, rotated_residual, Q, Qt, normal_rows, Ftry.destroy() if fnorm < rnorm: u.destroy() - return utry, True + return utry, True, alpha utry.destroy() alpha *= 0.5 - return u, False + return u, False, 0.0 def solve_rotated_freeslip(solver, boundaries, remove_rotation_gauge=True, @@ -1332,6 +1333,10 @@ def solve_rotated_freeslip(solver, boundaries, remove_rotation_gauge=True, Mp = ctx["Mp"] if ctx is not None else None diag_scale = cache["diag_scale"] if cache is not None else None lin_its = [] + # per-iteration records for campaign diagnostics: |F̂| at each iteration start, + # and the step accepted for each increment (0.0: not applied) + rnorm_history = [] + line_search_alpha = [] # velocity / pressure sub-KSP LAST-APPLICATION counts, one per Newton increment # (iterative path only — the direct-LU path has no sub-KSPs and records None) vel_its_last = [] @@ -1423,6 +1428,7 @@ def rotated_residual(uvec, keep_cartesian=False): for iters in range(max_it): Fhat = rotated_residual(u, keep_cartesian=True) rnorm = Fhat.norm() + rnorm_history.append(float(rnorm)) if r0 is None: r0 = rnorm ref = max(r0, fnorm_rest) if fnorm_rest is not None else r0 @@ -1575,9 +1581,13 @@ def rotated_residual(uvec, keep_cartesian=False): _impose_normal_constraint(u, Q, Qt, normal_rows, datum_map) step_converged = False improved = True + line_search_alpha.append(1.0) elif not step_converged: - u, improved = _backtracking_line_search( + u, improved, alpha = _backtracking_line_search( u, d, rnorm, rotated_residual, Q, Qt, normal_rows, datum_map) + line_search_alpha.append(alpha) + else: + line_search_alpha.append(0.0) # step-norm exit: the increment is not applied # every per-iteration temporary dies HERE, on all exit paths dhat.destroy() d.destroy() @@ -1681,6 +1691,10 @@ def rotated_residual(uvec, keep_cartesian=False): "ksp_reason": last_reason, "nonlinear_iterations": newton_its, "converged": converged, "ksp_its": lin_its, "rnorm": rnorm, "rnorm0": r0, + # the rest-state residual ‖F̂(0)‖ (warm starts only; None cold) and the + # reference the convergence and switch tests compare against + "fnorm_rest": fnorm_rest, "ref": ref, + "rnorm_history": rnorm_history, "line_search_alpha": line_search_alpha, "vel_its_last": vel_its_last, "pres_its_last": pres_its_last, # keyed on use_lu, NOT on `ctx is None`: the iterative path also exits # with ctx unset when a warm start is already converged at increment 0. diff --git a/tests/test_1018_rotated_freeslip.py b/tests/test_1018_rotated_freeslip.py index ffa130479..f7cc3c5bc 100644 --- a/tests/test_1018_rotated_freeslip.py +++ b/tests/test_1018_rotated_freeslip.py @@ -1056,6 +1056,44 @@ def test_rotated_continuation_warm_start_switches_to_newton_at_once(plaw_box_ref assert info["nonlinear_iterations"] <= 4, info["nonlinear_iterations"] +def test_rotated_solve_records_its_diagnostics(plaw_box_ref): + """The rotated solve reports what it measured, so a campaign can record it per + step without scraping verbose logs: the rest-state residual ‖F̂(0)‖ and the + reference the convergence and switch tests use, the residual at every iteration + start, and the accepted line-search step of every increment (0.0 when the + increment was not applied).""" + mesh, _, _ = plaw_box_ref + s, _, _ = _powerlaw_stokes(mesh, "dgR", cj="continuation") + for wall in ("Top", "Bottom", "Left", "Right"): + s.add_rotated_freeslip_bc(0, wall) + + def check(info): + its = info["nonlinear_iterations"] + hist, alpha = info["rnorm_history"], info["line_search_alpha"] + assert len(hist) in (its, its + 1), (len(hist), its) + assert hist[0] == info["rnorm0"] and hist[-1] == info["rnorm"], (hist[0], hist[-1]) + assert len(alpha) == its, (len(alpha), its) + assert all(a == 0.0 or 0.0 < a <= 1.0 for a in alpha), alpha + return its, alpha + + s.solve() + cold = s._rotated_freeslip_info + assert cold["converged"], cold + check(cold) + assert cold["fnorm_rest"] is None # a cold start's r0 IS the rest scale + assert cold["ref"] == cold["rnorm0"] + + x, _y = mesh.X + s.bodyforce = sympy.Matrix([[0.0, -2.02 * sympy.cos(sympy.pi * x)]]) + s.solve(zero_init_guess=False) + warm = s._rotated_freeslip_info + assert warm["converged"], warm + _its, alpha = check(warm) + assert warm["fnorm_rest"] > 0.0 + assert warm["ref"] == max(warm["rnorm0"], warm["fnorm_rest"]) + assert alpha[-1] == 1.0, alpha # a full step is accepted at the solution + + def test_rotated_freeslip_picard_under_newton_stages_picard_then_newton(plaw_box_ref): """picard>0 under the consistent-Newton tangent (the default since 2026-10-05) stages at least N Picard iterations, then Newton, on the blended Jacobian solve() compiles From 4698938f5db923cad28ad3dd6785d31a536bc0aa Mon Sep 17 00:00:00 2001 From: lmoresi Date: Thu, 29 Oct 2026 09:54:45 +1100 Subject: [PATCH 58/59] Fault-split integration review: guide corrected, scripts out, tiers, gauge vectors - guides/faults-in-convection.md (a capability guide, read as a skill) said solve(picard=N) raises on the rotated path and that the Newton switch reads the warm start's own residual, with newton_switch_rtol = 1.0 as the workaround; on this branch picard=N stages Picard then Newton and the switch reads the convergence reference (371d3bd1). Corrected. - Campaign drivers and probes leave the repository (Louis, 2026-10-28): scripts/sessions/parallel_addon, tidal_shell, box_fault_cyclic, and the last fault_mg file; the design note's probe references say where they live. - test_1093 duplicated development's test_1053 (same fixture and checks) and is removed. New tests start at tier B: test_1098, test_1100's table test, and the two newest tests of test_1018, whose tiers now go on each test. - The rotated solve destroys its rotation-gauge mode vectors (one leaked per mode per solve) and warns when the projected-out rigid-rotation component dominates a converged residual, as the pressure gauge does (a net torque on free-slip walls has no solution and reported converged in silence). - FAULT_MULTIGRID_HIERARCHIES no longer lists co-location as to come. Underworld development team with AI support from Claude Code --- .../design/FAULT_MULTIGRID_HIERARCHIES.md | 4 +- .../parallel-addon-distribution-2026-10.md | 11 +- docs/developer/guides/faults-in-convection.md | 17 +- .../box_fault_cyclic/box_fault_cyclic.py | 363 ------------- .../box_fault_cyclic/box_fault_plot.py | 100 ---- .../box_fault_cyclic/figures/setup.png | Bin 118780 -> 0 bytes .../box_fault_cyclic/figures/setup.typ | 155 ------ scripts/sessions/parallel_addon/README.md | 21 - .../check_topology_equivalence.py | 139 ----- .../parallel_addon/check_transfer_assembly.py | 48 -- .../sessions/parallel_addon/probe_builders.py | 79 --- .../parallel_addon/probe_coords_order.py | 21 - .../sessions/parallel_addon/probe_load_3d.py | 18 - .../parallel_addon/probe_migration_sf.py | 55 -- .../parallel_addon/probe_partition_2d.py | 87 ---- .../parallel_addon/probe_partition_cost.py | 47 -- .../sessions/parallel_addon/probe_scaling.py | 202 ------- .../parallel_addon/probe_scaling_2d.py | 29 -- .../parallel_addon/probe_sf_exchange.py | 27 - .../sessions/parallel_addon/probe_slots.py | 19 - .../parallel_addon/probe_split_cost.py | 31 -- .../sessions/tidal_shell/tidal_berne_fig.py | 56 -- scripts/sessions/tidal_shell/tidal_cff.py | 111 ---- .../sessions/tidal_shell/tidal_cycle_plot.py | 41 -- .../sessions/tidal_shell/tidal_farfield.py | 75 --- .../tidal_shell/tidal_farfield_strain.py | 76 --- .../tidal_shell/tidal_fault_compare.py | 32 -- .../sessions/tidal_shell/tidal_mesh_fig.py | 112 ---- .../sessions/tidal_shell/tidal_mu_overlay.py | 38 -- scripts/sessions/tidal_shell/tidal_shell.py | 491 ------------------ .../tidal_shell/tidal_site_analysis.py | 97 ---- .../tidal_shell/tidal_slip_profile.py | 55 -- .../sessions/tidal_shell/tidal_strength.py | 48 -- .../sessions/tidal_shell/tidal_sweep_plot.py | 46 -- src/underworld3/utilities/rotated_bc.py | 10 + tests/test_1018_rotated_freeslip.py | 30 +- .../test_1093_ve_etd_expression_viscosity.py | 62 --- tests/test_1098_plex_topology_tables.py | 2 +- ...est_1100_element_table_matches_closures.py | 2 +- 39 files changed, 59 insertions(+), 2798 deletions(-) delete mode 100644 scripts/sessions/box_fault_cyclic/box_fault_cyclic.py delete mode 100644 scripts/sessions/box_fault_cyclic/box_fault_plot.py delete mode 100644 scripts/sessions/box_fault_cyclic/figures/setup.png delete mode 100644 scripts/sessions/box_fault_cyclic/figures/setup.typ delete mode 100644 scripts/sessions/parallel_addon/README.md delete mode 100644 scripts/sessions/parallel_addon/check_topology_equivalence.py delete mode 100644 scripts/sessions/parallel_addon/check_transfer_assembly.py delete mode 100644 scripts/sessions/parallel_addon/probe_builders.py delete mode 100644 scripts/sessions/parallel_addon/probe_coords_order.py delete mode 100644 scripts/sessions/parallel_addon/probe_load_3d.py delete mode 100644 scripts/sessions/parallel_addon/probe_migration_sf.py delete mode 100644 scripts/sessions/parallel_addon/probe_partition_2d.py delete mode 100644 scripts/sessions/parallel_addon/probe_partition_cost.py delete mode 100644 scripts/sessions/parallel_addon/probe_scaling.py delete mode 100644 scripts/sessions/parallel_addon/probe_scaling_2d.py delete mode 100644 scripts/sessions/parallel_addon/probe_sf_exchange.py delete mode 100644 scripts/sessions/parallel_addon/probe_slots.py delete mode 100644 scripts/sessions/parallel_addon/probe_split_cost.py delete mode 100644 scripts/sessions/tidal_shell/tidal_berne_fig.py delete mode 100644 scripts/sessions/tidal_shell/tidal_cff.py delete mode 100644 scripts/sessions/tidal_shell/tidal_cycle_plot.py delete mode 100644 scripts/sessions/tidal_shell/tidal_farfield.py delete mode 100644 scripts/sessions/tidal_shell/tidal_farfield_strain.py delete mode 100644 scripts/sessions/tidal_shell/tidal_fault_compare.py delete mode 100644 scripts/sessions/tidal_shell/tidal_mesh_fig.py delete mode 100644 scripts/sessions/tidal_shell/tidal_mu_overlay.py delete mode 100644 scripts/sessions/tidal_shell/tidal_shell.py delete mode 100644 scripts/sessions/tidal_shell/tidal_site_analysis.py delete mode 100644 scripts/sessions/tidal_shell/tidal_slip_profile.py delete mode 100644 scripts/sessions/tidal_shell/tidal_strength.py delete mode 100644 scripts/sessions/tidal_shell/tidal_sweep_plot.py delete mode 100644 tests/test_1093_ve_etd_expression_viscosity.py diff --git a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md index fcd95c121..21b68ea46 100644 --- a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md +++ b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md @@ -206,7 +206,7 @@ cell-located transfer (serial and parallel) and the per-level frames in layer with every level split, extruded in parallel; 3-D sheet faults on add-on meshes. Not yet: fault networks through the levels (a network is replayed on every level, but a one-cell junction ligament closes at the coarser spacing and the replay then stops -there, leaving that level and those beneath welded), faults near a boundary, -co-locating the levels with the finest as an optimisation, and the ribbon replay that +there, leaving that level and those beneath welded), faults near a boundary, and the +ribbon replay that would give a placed ladder its split levels automatically (a driver builds them by hand: `guides/faults-in-convection.md`). diff --git a/docs/developer/design/parallel-addon-distribution-2026-10.md b/docs/developer/design/parallel-addon-distribution-2026-10.md index ef723d9cb..d08b75f93 100644 --- a/docs/developer/design/parallel-addon-distribution-2026-10.md +++ b/docs/developer/design/parallel-addon-distribution-2026-10.md @@ -8,7 +8,8 @@ follows the September ruling in never rebalanced; load fixes go on the child; every transfer to the child must be partition-agnostic) and the hierarchy of [FAULT_MULTIGRID_HIERARCHIES](FAULT_MULTIGRID_HIERARCHIES.md). Implementation: -`utilities/addon_partition.py`; tests `tests/parallel/test_1096_*`. +`utilities/addon_partition.py`; tests `tests/parallel/test_1096_*`. The probes +named below are kept with the campaign, outside this repository. ## The problem @@ -39,7 +40,7 @@ each carry their own partition handling. ## Measured (hyperion, f21f3d85, under a 16-rank study) Refined patch box, split (`BoxInternalPatch(refinement=2)` then `split_fault`, -19072 cells, P2 velocity, tolerance 1e-4; `scripts/sessions/parallel_addon/probe_scaling.py`): +19072 cells, P2 velocity, tolerance 1e-4; `probe_scaling.py`): | np | velocity iterations | finest P2 nodes per rank (max / mean) | |---|---|---| @@ -53,7 +54,7 @@ eight ranks, and 3026 of its 7500 nodes lie outside its local coarse hull, so th finest transfer on that rank is built from remote answers. Adapt-on-top fault, 2-D, two add-on levels, np = 4 -(`scripts/sessions/fault_mg/parallel_load.py 2`): finest cells per rank +(`parallel_load.py 2`): finest cells per rank 240 / 292 / 243 / 695 (max / mean 1.89); the base-to-first-add-on transfer misses 265 of 2528 owned nodes (10 %) and asks the other ranks for them; the add-on pair misses 14 of 2992 after the levels are co-located. Six velocity iterations. @@ -67,7 +68,7 @@ for riding it. ## Measured after (same fixtures, the partition in place) -Refined patch box, split (`scripts/sessions/parallel_addon/probe_load_3d.py`): +Refined patch box, split (`probe_load_3d.py`): | np | finest P2 nodes per rank (max / mean) | before | velocity iterations | wall | |---|---|---|---|---| @@ -170,7 +171,7 @@ cell's dof rows. The loops above them are now array operations. The node-cloud transfer builders set one matrix row per Python call; they now assemble once (`custom_mg._assemble_node_transfer`). Every converted function was checked against its previous version, entry for entry, serially and on four ranks -(`scripts/sessions/parallel_addon/check_topology_equivalence.py`, +(`check_topology_equivalence.py`, `check_transfer_assembly.py`); `tests/test_1098` pins the tables to DMPlex's own point-by-point answers. diff --git a/docs/developer/guides/faults-in-convection.md b/docs/developer/guides/faults-in-convection.md index 4dd8df77a..10e7dc9a1 100644 --- a/docs/developer/guides/faults-in-convection.md +++ b/docs/developer/guides/faults-in-convection.md @@ -77,18 +77,21 @@ hierarchy: 114-150 velocity iterations per increment against 91-162 without. block-constraint multipliers do not. A closed box needs `petsc_use_pressure_nullspace = True`. -- Picard steps on this path need `stokes.consistent_jacobian = "continuation"`; - `solve(picard=N)` with the default tangent raises. +- `solve(picard=N)` with the default (consistent Newton) tangent takes at least N + Picard iterations on a blended Jacobian, then Newton; + `stokes.consistent_jacobian = "continuation"` switches from Picard to Newton by + itself. - The first solve on a faulted mesh starts from rest. A warm start from a velocity computed without the fault stalls the Picard line search (measured 8e-2 of the reference residual after 2 iterations); from rest the solve converges in 18-34 Picard and 5-8 Newton iterations on the states measured. - The loop switches from Picard to Newton when the residual has fallen by - `newton_switch_rtol` from THIS solve's starting residual, while its convergence test - is relative to the cold reference. A warm start already near convergence therefore - stays in Picard to the end (measured 13-27 iterations at ~0.85 per iteration). After - the first solve set `stokes.newton_switch_rtol = 1.0`: warm steps then take 2 Newton - iterations. + `newton_switch_rtol` relative to the same reference as its convergence test (the + larger of this solve's starting residual and the rest-state residual), so a warm + start near convergence reaches Newton without first shrinking its own residual a + hundredfold under Picard. (Before 2026-10-23 the switch was measured from the warm + start's own residual, warm steps stayed in Picard to the end, and the advice was + `newton_switch_rtol = 1.0`; that is no longer needed.) ## A moving trench diff --git a/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py b/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py deleted file mode 100644 index c5d2c3e76..000000000 --- a/scripts/sessions/box_fault_cyclic/box_fault_cyclic.py +++ /dev/null @@ -1,363 +0,0 @@ -"""A strike-slip fault under cyclic loading: the 3-D box toy model. - -Wiki: Box-fault-cyclic-loading. A Maxwell (or viscous) block, x in [0, Lx], -y in [0, Ly], z in [0, H]. The x = 0 face is fixed; the x = Lx face carries a -harmonic traction with a normal component N sin(wt + phi) and a horizontal -shear component S sin(wt); the y faces are traction-free (or held at -u_x = u_z = 0 with --side_bc uxuz0); the base is free-slip; the top is free. - -The fault is a vertical plane y = Ly/2 of length l along x, reaching from -depth w below the surface to a setback of a few cells under the top face -(the standing 3-D ruling: a fault reaches daylight blind, under a damage -region, never split through the surface). The sheet is placed with -add_conforming_sheet(setback=...) and split into coincident node pairs; the -damage band above it to the surface is a low-viscosity region located by -the placement's surface trace. - -Non-dimensional: shear modulus 1, loading period 1, Maxwell time De periods -(Wallace number Wa = 1/De), tractions as fractions of the shear modulus. - -Parallel-safe: every diagnostic is a collective reduction or an all-gathered -pair table; the surface displacement is a collective evaluation on a fixed -grid; files are written from rank 0 only. -""" -import argparse -import json -import os -import time - -import numpy as np -import sympy - -import underworld3 as uw -from underworld3.utilities.fault_split import split_fault -from underworld3.utilities import fault_contact - -ap = argparse.ArgumentParser() -ap.add_argument("--out", required=True) -ap.add_argument("--h", type=float, default=0.15, help="cell size away from the fault") -ap.add_argument("--h_fault", type=float, default=0.05, help="cell size on the fault (gmsh mesher; 0: uniform)") -ap.add_argument("--grading", type=float, default=0.0, help="distance over which the size grades back to --h (0: 8 h_fault)") -ap.add_argument("--mesher", default="gmsh", choices=["gmsh", "adapt", "place"]) -ap.add_argument("--adapt_levels", type=int, default=2, help="adapt-on-top levels toward the fault") -ap.add_argument("--refinement", type=int, default=2, help="gmsh route: uniform refinement levels of the patch box (its multigrid tail)") -ap.add_argument("--tail", default="hierarchy", choices=["hierarchy", "boxes"], - help="gmsh route: the multigrid tail is the native refinement hierarchy, or plain coarse boxes registered with set_custom_fmg (comparison)") -ap.add_argument("--clearance", type=float, default=0.9, help="sheet placement clearance (0.9 on a bisection child)") -ap.add_argument("--allow_gamg", action="store_true", help="accept a GAMG velocity block (no multigrid tail); otherwise refused") -ap.add_argument("--Lx", type=float, default=3.0) -ap.add_argument("--Ly", type=float, default=2.0) -ap.add_argument("--H", type=float, default=1.0) -ap.add_argument("--fault_len", type=float, default=1.0, help="fault length along x") -ap.add_argument("--fault_depth", type=float, default=0.5, help="depth of the fault bottom below the surface") -ap.add_argument("--setback", type=float, default=2.0, help="setback of the fault top below the surface, in fault cells (adapt, gmsh) or cells (place)") -ap.add_argument("--damage", type=float, default=0.01, - help="viscosity ratio in the damage band above the fault top (1: no damage band)") -ap.add_argument("--damage_width", type=float, default=0.0, help="half-width of the damage band (0: one cell)") -ap.add_argument("--De", type=float, default=1.0, help="Maxwell time in periods (0: viscous block with --eta)") -ap.add_argument("--eta", type=float, default=1.0) -ap.add_argument("--S", type=float, default=0.01, help="shear traction amplitude on the loaded face") -ap.add_argument("--N", type=float, default=0.01, help="normal traction amplitude on the loaded face") -ap.add_argument("--phi", type=float, default=80.0, help="phase of the normal traction ahead of the shear (deg)") -ap.add_argument("--fault_law", default="coulomb", choices=["frictionless", "viscous", "coulomb"]) -ap.add_argument("--fault_eta", type=float, default=0.0) -ap.add_argument("--fault_mu", type=float, default=0.4) -ap.add_argument("--fault_V0", type=float, default=1e-3) -ap.add_argument("--fault_Tb", type=float, default=0.0, help="constant effective overburden on the fault (compression positive)") -ap.add_argument("--side_bc", default="free", choices=["free", "uxuz0"]) -ap.add_argument("--periods", type=float, default=2.0) -ap.add_argument("--steps_per_period", type=int, default=32) -ap.add_argument("--tol", type=float, default=1e-6) -ap.add_argument("--picard", type=int, default=0) -ap.add_argument("--stress_transport", default="none", choices=["none", "eulerian", "semi_lagrangian", "integration_point"], - help="flavour of the Maxwell stress history. Default none: the displacement per step is far below a cell, so transport is a cost not physics, and a traced-back query across the split fault reads the other side") -ap.add_argument("--surface_grid", type=int, default=40, help="points along x of the surface displacement grid") -ap.add_argument("--verbose", action="store_true") -ap.add_argument("--profile", action="store_true", help="cProfile the time loop on rank 0 (profile.txt in the run directory)") -args = ap.parse_args() - -from mpi4py import MPI # noqa: E402 (collective bookkeeping only) -comm = MPI.COMM_WORLD -rank0 = comm.rank == 0 -if rank0: - os.makedirs(args.out, exist_ok=True) - with open(os.path.join(args.out, "args.json"), "w") as f: - json.dump(vars(args), f, indent=1) - - -def log(*a): - if rank0: - print(*a, flush=True) - - -# ----------------------------------------------------------------------------- mesh, fault, split -# Three routes to a blind vertical fault whose rim is strictly inside the box: -# gmsh — BoxInternalPatch: the rectangle is a conforming patch of a gmsh mesh graded -# to the fault, built at 2^k the target size and refined k times: no cavity -# (a two-cell setback works), the patch stays conforming, and the k coarse -# levels are the multigrid tail — nested, so the transfers are exact. The -# split keeps that tail and the rotated solve takes the custom-P FMG -# velocity block. Default. -# adapt — a box with one refinement level, adapt-on-top toward the fault column, the -# sheet placed by carving a cavity, the patch split; the tail rides through -# by itself. The cavity reaches ~2 cell diameters above the sheet top, so -# the setback must be >= 5 fine cells (0.20 refused, 0.25 passed), and the -# parallel placement currently under-reaches its gather on this geometry -# (TODO(BUG) in place_surface.py; serial passes). -# place — a uniform box, cavity placement with setback (needs three to four cells). -h = args.h -hf = args.h_fault if args.h_fault > 0 else h -yf = 0.5 * args.Ly -xa, xb = 0.5 * (args.Lx - args.fault_len), 0.5 * (args.Lx + args.fault_len) -zb = args.H - args.fault_depth -setback = args.setback * (h if args.mesher == "place" else hf) -ztop_fault = args.H - setback -trace = [[xa, yf, args.H], [xb, yf, args.H]] # the would-be surface trace (a straight fault) - - -def fault_distance(pts): - """Exact distance from points to the fault plane's column (axis-aligned rectangle in - the plane y = yf from the fault bottom up THROUGH the free surface): the refinement - must reach the surface above the fault, both for the damage band and so the - placement's cavity (clearance x local h, plus the dropped cells) stays clear of - the top face — a coarse cell above the fault top reached it (measured).""" - pts = np.asarray(pts, dtype=float).reshape(-1, 3) - dx = np.maximum(np.maximum(xa - pts[:, 0], pts[:, 0] - xb), 0.0) - dz = np.maximum(zb - pts[:, 2], 0.0) - dy = np.abs(pts[:, 1] - yf) - return np.sqrt(dx ** 2 + dy ** 2 + dz ** 2) - - -def fault_metric(pts): - """M = 1/h^2: h_fault within one fault cell of the plane, grading linearly to h.""" - d = fault_distance(pts) - width = args.grading if args.grading > 0 else 6.0 * hf - hh = np.where(d < hf, hf, np.minimum(hf + (h - hf) * (d - hf) / width, h)) - return 1.0 / hh ** 2 - - -def sheet_triangulation(cell): - """The fault rectangle as a triangulated sheet of size `cell`, run past the top so the - placement clips it and stops it `setback` below the surface.""" - nx = max(2, int(round(args.fault_len / cell)) + 1) - nz = max(2, int(round((args.H + 0.3 * cell - zb) / cell)) + 1) - xs = np.linspace(xa, xb, nx) - zs = np.linspace(zb, args.H + 0.3 * cell, nz) - XX, ZZ = np.meshgrid(xs, zs, indexing="ij") - pts = np.column_stack([XX.ravel(), np.full(XX.size, yf), ZZ.ravel()]) - tris = [] - for i in range(nx - 1): - for j in range(nz - 1): - a, b, c, d = i * nz + j, (i + 1) * nz + j, (i + 1) * nz + j + 1, i * nz + j + 1 - tris += [[a, b, c], [a, c, d]] - return pts, np.array(tris, dtype=np.int64) - - -t0 = time.perf_counter() -if args.mesher == "gmsh": - # the patch box at (2^k) x the target sizes, refined k times: the patch stays - # conforming under uniform refinement, the grading survives it, and the k - # coarse levels are the multigrid tail (nested, exact transfers) - f = 2 ** args.refinement - patch = np.array([[xa, yf, zb], [xb, yf, zb], [xb, yf, ztop_fault], [xa, yf, ztop_fault]]) - # the grading distance is PHYSICAL (gmsh's default is 8 x the patch size, which at - # the coarse size made the fine region four times wider: 78k cells for 16k) - cut = uw.meshing.BoxInternalPatch(cellSize=f * h, minCoords=(0.0, 0.0, 0.0), maxCoords=(args.Lx, args.Ly, args.H), - patch_points=patch, patch_name="Fault", - patch_cellSize=f * hf if hf < h else None, - grading_distance=args.grading if args.grading > 0 else 8.0 * hf, - refinement=args.refinement, qdegree=3) -elif args.mesher == "adapt": - base = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0, 0.0), maxCoords=(args.Lx, args.Ly, args.H), - cellSize=2.0 * h, regular=False, qdegree=3, refinement=1) - child = base.adapt(fault_metric, max_levels=args.adapt_levels, verbose=args.verbose) - n_tail = len(getattr(child, "_custom_mg_coarse_meshes", None) or []) - log(f"adapt: {comm.allreduce(int(child.dm.getHeightStratum(0)[1] - child.dm.getHeightStratum(0)[0]), op=MPI.SUM)} cells, " - f"multigrid tail of {n_tail} coarse levels") - pts, tris = sheet_triangulation(hf) - cut = child.add_conforming_sheet(pts, tris, "Fault", setback=setback, clearance=args.clearance, - verbose=args.verbose) -else: - base = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0, 0.0), maxCoords=(args.Lx, args.Ly, args.H), - cellSize=h, regular=False, qdegree=3) - pts, tris = sheet_triangulation(h) - cut = base.add_conforming_sheet(pts, tris, "Fault", setback=setback, verbose=args.verbose) -if hasattr(cut, "_surface_info") and cut._surface_info.get("surface_trace"): - trace = cut._surface_info["surface_trace"] -mesh = split_fault(cut, "Fault") -n_tail = len(getattr(mesh, "_custom_mg_coarse_meshes", None) or []) -ncell = comm.allreduce(int(mesh.dm.getHeightStratum(0)[1] - mesh.dm.getHeightStratum(0)[0]), op=MPI.SUM) -log(f"mesh ({args.mesher}) h {h} h_fault {hf}: {ncell} cells after fault + split ({time.perf_counter() - t0:.1f}s); " - f"fault x [{xa:.2f}, {xb:.2f}], z [{zb:.2f}, {ztop_fault:.3f}], setback {setback:.3f}; " - f"multigrid tail {n_tail} levels") -x, y, z = mesh.X - -# ----------------------------------------------------------------------------- solver -v = uw.discretisation.MeshVariable("v", mesh, 3, degree=2) -p = uw.discretisation.MeshVariable("p", mesh, 1, degree=1) -stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) -stokes.bodyforce = sympy.Matrix([0.0, 0.0, 0.0]) -stokes.tolerance = args.tol - -# the damage band: a low-viscosity region from the fault top to the surface, -# the width of a cell (or --damage_width) either side of the trace -wd = args.damage_width if args.damage_width > 0 else hf -ztop = ztop_fault - 0.5 * wd -g_y = sympy.exp(-((y - yf) / wd) ** 2) -g_z = 0.5 * (1 + sympy.tanh((z - ztop) / (0.5 * wd))) -g_x = 0.25 * (1 + sympy.tanh((x - xa) / (0.5 * wd))) * (1 + sympy.tanh((xb - x) / (0.5 * wd))) -damage = g_x * g_y * g_z # 1 in the band, 0 away from it -eta_factor = 1.0 - (1.0 - args.damage) * damage if args.damage < 1.0 else 1.0 - -if args.De > 0: - stokes.stress_transport = args.stress_transport # before the model: it creates the history - stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( - stokes.Unknowns, order=1, integrator="etd", objective_rate="none") - stokes.constitutive_model.Parameters.shear_modulus = 1.0 - stokes.constitutive_model.Parameters.shear_viscosity_0 = args.De * eta_factor -else: - stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel - stokes.constitutive_model.Parameters.shear_viscosity_0 = args.eta * eta_factor - -# the harmonic end load: two uniform P1 fields hold the instantaneous amplitudes so the -# traction changes each step without recompiling the residual -N_t = uw.discretisation.MeshVariable("N_t", mesh, 1, degree=1) -S_t = uw.discretisation.MeshVariable("S_t", mesh, 1, degree=1) -stokes.add_dirichlet_bc((0.0, 0.0, 0.0), "Left") # x = 0 fixed -# sign convention (measured): a positive value in add_natural_bc pushes INTO the domain, so -# the traction sigma.n = (N, S, 0) on the outward normal +x enters negated: N > 0 is tension -# (the loaded end moves +x), S > 0 is shear traction in +y on the x = Lx face. -stokes.add_natural_bc(sympy.Matrix([[-N_t.sym[0], -S_t.sym[0], 0.0]]), "Right") -stokes.add_dirichlet_bc((None, None, 0.0), "Bottom") # free-slip base -if args.side_bc == "uxuz0": - stokes.add_dirichlet_bc((0.0, None, 0.0), "Front") - stokes.add_dirichlet_bc((0.0, None, 0.0), "Back") - -if args.mesher == "gmsh" and args.tail == "boxes": - from underworld3.utilities.custom_mg import set_custom_fmg - coarse_tail = [uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0, 0.0), maxCoords=(args.Lx, args.Ly, args.H), - cellSize=c, regular=False, qdegree=3) for c in (4.0 * h, 2.0 * h)] - set_custom_fmg(stokes, coarse_tail, field_id=0) - log("tail: plain coarse boxes registered (set_custom_fmg) in place of the native hierarchy") - -if args.fault_law == "coulomb": - fault_contact.add_coulomb_fault_bc(stokes, args.fault_mu, "Fault", sigma_n="reaction", - V0=args.fault_V0, background=args.fault_Tb) -else: - stokes.add_fault_bc(args.fault_eta if args.fault_law == "viscous" else 0.0, "Fault") - - -load_now = {"N": 0.0, "S": 0.0} - - -def set_load(t): - w = 2 * np.pi - load_now["N"] = args.N * np.sin(w * t + np.radians(args.phi)) - load_now["S"] = args.S * np.sin(w * t) - N_t.array[...] = load_now["N"] - S_t.array[...] = load_now["S"] - - -# ----------------------------------------------------------------------------- diagnostics -# surface displacement on a fixed grid (collective evaluation), accumulated over the run -gx = np.linspace(0.02 * args.Lx, 0.98 * args.Lx, args.surface_grid) -gy = np.linspace(0.02 * args.Ly, 0.98 * args.Ly, max(4, int(round(args.surface_grid * args.Ly / args.Lx)))) -GX, GY = np.meshgrid(gx, gy, indexing="ij") -surf_pts = np.column_stack([GX.ravel(), GY.ravel(), np.full(GX.size, args.H - 1e-6)]) -disp = np.zeros((surf_pts.shape[0], 3)) -disp_cycle = np.zeros_like(disp) - -pair_tab = None # coords, cumulative signed slip, per-cycle slip, keyed by the pair order - - -def fault_diagnostics(info, dt): - """Signed strike slip (v+ - v-).x̂ at every pair, all-gathered; cumulative and per cycle.""" - global pair_tab - coords, jumps, normals = fault_contact.fault_pair_jumps(stokes, "Fault", info, gather=True) - if pair_tab is None: - pair_tab = {"coords": coords, "cum": np.zeros(len(coords)), "cycle": np.zeros(len(coords)), - "act": np.zeros(len(coords))} - leak = np.einsum("ij,ij->i", jumps, normals) - rate = jumps[:, 0] # strike direction is x - pair_tab["cum"] += rate * dt - pair_tab["cycle"] += rate * dt - pair_tab["act"] += np.abs(rate) * dt - _c, sig = fault_contact.fault_normal_traction(stokes, "Fault", info) - sig = np.asarray(sig) - sig_all = np.concatenate(comm.allgather(sig.ravel())) if comm.size > 1 else sig.ravel() - return {"slip_rate_mean": float(rate.mean()) if len(rate) else 0.0, - "slip_rate_absmax": float(np.abs(rate).max()) if len(rate) else 0.0, - "leak_max": float(np.abs(leak).max()) if len(leak) else 0.0, - "sigma_n_mean": float(sig_all.mean()) if len(sig_all) else 0.0, - "sigma_n_min": float(sig_all.min()) if len(sig_all) else 0.0, - "sigma_n_max": float(sig_all.max()) if len(sig_all) else 0.0, - "n_pairs": int(len(rate))} - - -def save_cycle(step, t): - if rank0: - np.savez(os.path.join(args.out, f"cycle_{step:04d}.npz"), t=t, - pair_coords=pair_tab["coords"], slip_cum=pair_tab["cum"], slip_cycle=pair_tab["cycle"], - activity=pair_tab["act"], surf_pts=surf_pts, disp=disp, disp_cycle=disp_cycle, - trace=np.array(trace, dtype=float)) - pair_tab["cycle"][:] = 0.0 - disp_cycle[:] = 0.0 - - -# ----------------------------------------------------------------------------- time loop -dt = 1.0 / args.steps_per_period -nsteps = int(round(args.periods * args.steps_per_period)) -records = [] -t = 0.0 -log(f"De {args.De}, S {args.S}, N {args.N}, phi {args.phi}, law {args.fault_law} mu {args.fault_mu} Tb {args.fault_Tb}; " - f"dt {dt}, {nsteps} steps") -_prof = None -if args.profile and rank0: - import cProfile - _prof = cProfile.Profile() - _prof.enable() -for k in range(nsteps): - tk = time.perf_counter() - t_new = t + dt - set_load(t_new) # implicit: the load at the end of the step - if args.De > 0: - stokes.solve(timestep=dt, zero_init_guess=(k == 0), verbose=args.verbose, picard=args.picard) - else: - stokes.solve(zero_init_guess=(k == 0), verbose=args.verbose, picard=args.picard) - t = t_new - info = getattr(stokes, "_rotated_freeslip_info", None) or {} - if k == 0: - vpc = info.get("velocity_pc") - log(f"velocity block: {vpc}; velocity its (last) {info.get('vel_its_last')}, pressure its (last) {info.get('pres_its_last')}") - if vpc == "GAMG" and not args.allow_gamg: - raise SystemExit("the velocity block fell back to GAMG: the mesh carries no multigrid tail. " - "Use --mesher adapt (or pass --allow_gamg to accept it).") - rec = {"step": k, "t": t, "converged": bool(info.get("converged", True)), - "nonlinear": info.get("nonlinear_iterations"), "t_step": round(time.perf_counter() - tk, 1)} - rec.update(fault_diagnostics(info, dt)) - U = np.asarray(uw.function.global_evaluate(v.sym, surf_pts)).reshape(-1, 3) - disp += U * dt - disp_cycle += U * dt - rec["surf_ux_absmax"] = float(np.abs(U[:, 0]).max()) - right = surf_pts[:, 0] > 0.9 * args.Lx - rec["surf_ux_right"] = float(U[right, 0].mean()) # signed: +N on the loaded face must pull it out (+x) - rec["load_N"], rec["load_S"] = float(load_now["N"]), float(load_now["S"]) - records.append(rec) - log(f"step {k:4d} t {t:.4f} its {rec['nonlinear']} {rec['t_step']}s | slip rate mean {rec['slip_rate_mean']:+.2e} " - f"max {rec['slip_rate_absmax']:.2e} leak {rec['leak_max']:.1e} | sigma_n [{rec['sigma_n_min']:+.3f}, {rec['sigma_n_max']:+.3f}]" - f" | load N {rec['load_N']:+.4f} S {rec['load_S']:+.4f} | surface u_x(right) {rec['surf_ux_right']:+.2e}") - if (k + 1) % args.steps_per_period == 0 or k == nsteps - 1: - cyc = pair_tab["cycle"] - log(f" cycle {(k + 1) // args.steps_per_period}: net slip mean {cyc.mean():+.3e}, max {cyc.max():+.3e} min {cyc.min():+.3e}; " - f"cumulative mean {pair_tab['cum'].mean():+.3e}; surface net |u| max {np.linalg.norm(disp_cycle, axis=1).max():.2e}") - save_cycle(k, t) - if rank0: - with open(os.path.join(args.out, "records.json"), "w") as f: - json.dump(records, f) -if _prof is not None: - import pstats - _prof.disable() - _prof.dump_stats(os.path.join(args.out, "profile.prof")) - with open(os.path.join(args.out, "profile.txt"), "w") as f: - pstats.Stats(_prof, stream=f).sort_stats("cumulative").print_stats(60) -log("done") diff --git a/scripts/sessions/box_fault_cyclic/box_fault_plot.py b/scripts/sessions/box_fault_cyclic/box_fault_plot.py deleted file mode 100644 index 6f435c339..000000000 --- a/scripts/sessions/box_fault_cyclic/box_fault_plot.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Figures for a box_fault_cyclic run. - -Usage: python box_fault_plot.py - -Reads //records.json and cycle_*.npz; writes -/boxfault_.png with - (a) the load components and the mean slip rate over time, - (b) the net slip per cycle on the fault plane (x along strike, z depth), - (c) the cumulative slip on the fault plane, - (d) the surface net displacement over the last cycle, with the fault trace. -""" -import glob -import json -import os -import sys - -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import numpy as np - -sim, run = sys.argv[1], sys.argv[2] -d = os.path.join(sim, run) -with open(os.path.join(d, "records.json")) as f: - rec = json.load(f) -with open(os.path.join(d, "args.json")) as f: - args = json.load(f) -cycles = sorted(glob.glob(os.path.join(d, "cycle_*.npz"))) -if not cycles: - sys.exit("no cycle files yet") -last = np.load(cycles[-1]) - -t = np.array([r["t"] for r in rec]) -rate = np.array([r["slip_rate_mean"] for r in rec]) -N = np.array([r.get("load_N", np.nan) for r in rec]) -S = np.array([r.get("load_S", np.nan) for r in rec]) -sig_min = np.array([r["sigma_n_min"] for r in rec]) -sig_max = np.array([r["sigma_n_max"] for r in rec]) - -fig, axes = plt.subplots(2, 2, figsize=(13, 8.5)) -ax = axes[0, 0] -ax.plot(t, S, color="#059669", label=r"$\sigma_{xy}$ shear load") -ax.plot(t, N, color="#c2410c", ls="--", label=r"$\sigma_{xx}$ normal load") -ax.set_ylabel("traction on the loaded face") -ax.axhline(0, color="0.7", lw=0.6) -ax2 = ax.twinx() -ax2.fill_between(t, sig_min, sig_max, color="0.88", label=r"$\sigma_n$ range on the fault", zorder=0) -ax2.plot(t, rate, color="#1f3a6b", label="mean slip rate (x)", zorder=3) -ax2.set_ylabel("slip rate / normal traction", color="#1f3a6b") -ax.set_zorder(ax2.get_zorder() + 1) # the load curves draw over the band -ax.patch.set_visible(False) -h1, l1 = ax.get_legend_handles_labels() -h2, l2 = ax2.get_legend_handles_labels() -ax.legend(h1 + h2, l1 + l2, fontsize=8, loc="upper left", bbox_to_anchor=(0.0, -0.18), ncol=2, frameon=False) -ax.set_xlabel("t / period") -ax.set_title(f"{run}: De {args['De']}, mu {args['fault_mu']}, Tb {args['fault_Tb']}, phi {args['phi']}", fontsize=10) - -pc = last["pair_coords"] -for ax, key, title in ((axes[0, 1], "slip_cycle", "net slip over the last cycle"), - (axes[1, 0], "slip_cum", "cumulative slip")): - val = last[key] - vmax = np.abs(val).max() if len(val) else 1.0 - sc = ax.scatter(pc[:, 0], pc[:, 2], c=val, cmap="RdBu_r", vmin=-vmax, vmax=vmax, s=18) - ax.set_aspect("equal") - ax.set_xlabel("x (along strike)") - ax.set_ylabel("z") - ax.set_title(title, fontsize=10) - plt.colorbar(sc, ax=ax, shrink=0.8, label="signed slip, right-lateral +") - -ax = axes[1, 1] -sp = last["surf_pts"] -dc = last["disp_cycle"] -nx = len(np.unique(sp[:, 0])) -ny = len(np.unique(sp[:, 1])) -X = sp[:, 0].reshape(nx, ny) -Y = sp[:, 1].reshape(nx, ny) -mag = np.linalg.norm(dc[:, :2], axis=1).reshape(nx, ny) -pcm = ax.pcolormesh(X, Y, mag, cmap="magma", shading="auto") -stride = max(1, nx // 16) -ax.quiver(X[::stride, ::stride], Y[::stride, ::stride], - dc[:, 0].reshape(nx, ny)[::stride, ::stride], dc[:, 1].reshape(nx, ny)[::stride, ::stride], - color="white", scale=None, width=0.004) -tr = last["trace"] -if len(tr): - ax.plot(tr[:, 0], tr[:, 1], color="#c2410c", lw=2) -ax.set_aspect("equal") -ax.set_xlabel("x") -ax.set_ylabel("y") -ax.set_title("surface net displacement over the last cycle", fontsize=10) -plt.colorbar(pcm, ax=ax, shrink=0.8, label="|u| net per cycle") - -fig.tight_layout() -out = os.path.join(sim, f"boxfault_{run}.png") -fig.savefig(out, dpi=130) -print("wrote", out) -for c in cycles: - z = np.load(c) - sc_ = z["slip_cycle"] - print(f" t {float(z['t']):.2f}: net slip per cycle mean {sc_.mean():+.3e} (max {sc_.max():+.3e}, min {sc_.min():+.3e}); " - f"cumulative mean {z['slip_cum'].mean():+.3e}; surface net |u| max {np.linalg.norm(z['disp_cycle'][:, :2], axis=1).max():.2e}") diff --git a/scripts/sessions/box_fault_cyclic/figures/setup.png b/scripts/sessions/box_fault_cyclic/figures/setup.png deleted file mode 100644 index ed2c9cef00acfaba50f9b1b81cc58e8997090e74..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 118780 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 diff --git a/scripts/sessions/box_fault_cyclic/figures/setup.typ b/scripts/sessions/box_fault_cyclic/figures/setup.typ deleted file mode 100644 index 406e7dbcf..000000000 --- a/scripts/sessions/box_fault_cyclic/figures/setup.typ +++ /dev/null @@ -1,155 +0,0 @@ -// Model set-up: a surface-breaking strike-slip fault in a 3-D viscoelastic -// block under a harmonic end load (the box toy model of the tidal cycle). -// House style: cetz-figures skill. Compile: typst compile --format png --ppi 220 setup.typ setup.png -#import "@preview/cetz:0.3.4" - -#set page(width: auto, height: auto, margin: 10pt) -#set text(size: 9pt) - -// ── Colours (hex, named by role) ────────────────────────────────────── -#let edge-colour = rgb("#1f3a6b") // navy — block edges -#let hidden-colour = rgb("#9aa5b8") // grey-blue — hidden edges -#let face-fill = rgb(31, 58, 107, 14) // faint navy wash -#let fixed-fill = rgb(31, 58, 107, 70) // darker wash — the fixed face -#let fault-colour = rgb("#c2410c") // rust — the fault -#let fault-fill = rgb(194, 65, 12, 60) -#let later-colour = rgb("#7c3aed") // violet — faults added later -#let later-fill = rgb(124, 58, 237, 28) -#let load-colour = rgb("#059669") // emerald — applied tractions -#let slip-colour = rgb("#1f3a6b") -#let shear-colour = rgb("#059669") -#let normal-colour = rgb("#c2410c") -#let grey = rgb("#6b7280") - -// ── Oblique (cabinet) projection: x right, y receding, z up ─────────── -#let proj(p) = (p.at(0) + 0.42 * p.at(1), p.at(2) + 0.26 * p.at(1)) -#let P(x, y, z) = proj((x, y, z)) - -// block (model units) and fault: vertical, normal y, strike x, breaking the surface -#let Lx = 3.2 -#let Ly = 1.8 -#let H = 1.0 -#let yf = 0.85 -#let xa = 1.05 -#let xb = 2.35 -#let w = 0.6 -// a later fault, drawn faintly -#let yf2 = 1.35 -#let xa2 = 1.6 -#let xb2 = 2.7 - -#let seg(a, b, col, th: 0.8pt, dash: none) = { - import cetz.draw: * - line(a, b, stroke: (paint: col, thickness: th, dash: dash)) -} -#let arrow(a, b, col, th: 1.0pt) = { - import cetz.draw: * - line(a, b, stroke: (paint: col, thickness: th), mark: (end: ">", fill: col, scale: 0.6)) -} - -#let panel-a = cetz.canvas(length: 1.9cm, { - import cetz.draw: * - - // 1. hidden edges - seg(P(0, Ly, 0), P(Lx, Ly, 0), hidden-colour, dash: "dashed") - seg(P(0, Ly, 0), P(0, Ly, H), hidden-colour, dash: "dashed") - seg(P(0, 0, 0), P(0, Ly, 0), hidden-colour, dash: "dashed") - - // 2. the later fault (faint), then the fault — both inside the block - line(P(xa2, yf2, H), P(xb2, yf2, H), P(xb2, yf2, H - w), P(xa2, yf2, H - w), close: true, - fill: later-fill, stroke: (paint: later-colour, thickness: 0.7pt, dash: "dotted")) - line(P(xa, yf, H), P(xb, yf, H), P(xb, yf, H - w), P(xa, yf, H - w), close: true, - fill: fault-fill, stroke: (paint: fault-colour, thickness: 0.9pt, dash: "dashed")) - - // 3. visible faces: top, front (y = 0), right (x = Lx); fixed face at x = 0 shows through - line(P(0, 0, H), P(Lx, 0, H), P(Lx, Ly, H), P(0, Ly, H), close: true, - fill: face-fill, stroke: (paint: edge-colour, thickness: 0.9pt)) - line(P(0, 0, 0), P(Lx, 0, 0), P(Lx, 0, H), P(0, 0, H), close: true, - fill: face-fill, stroke: (paint: edge-colour, thickness: 0.9pt)) - line(P(Lx, 0, 0), P(Lx, Ly, 0), P(Lx, Ly, H), P(Lx, 0, H), close: true, - fill: face-fill, stroke: (paint: edge-colour, thickness: 0.9pt)) - // the fixed face x = 0, hatched by short strokes along its visible edges - for k in range(0, 7) { - let z = H * k / 6 - seg(P(0, 0, z), P(0, 0.22, z + 0.05), edge-colour, th: 0.6pt) - } - content(P(0, 0.05, 0.5 * H), text(fill: edge-colour, size: 8pt)[fixed\ $bold(u) = 0$], anchor: "east", padding: 4pt) - - // surface traces (solid) over the top wash: later fault faint, the fault strong - seg(P(xa2, yf2, H), P(xb2, yf2, H), later-colour, th: 1.0pt, dash: "dotted") - seg(P(xa, yf, H), P(xb, yf, H), fault-colour, th: 1.8pt) - - // 4. slip sense on the surface (right-lateral in the first half cycle) - let xm = 0.5 * (xa + xb) - arrow(P(xm - 0.25, yf - 0.17, H), P(xm + 0.25, yf - 0.17, H), slip-colour) - arrow(P(xm + 0.25, yf + 0.17, H), P(xm - 0.25, yf + 0.17, H), slip-colour) - - // 5. fault dimensions and label - seg(P(xa, yf, H - w - 0.08), P(xb, yf, H - w - 0.08), grey, th: 0.5pt) - content(P(xm, yf, H - w - 0.08), text(fill: grey, $ell$), anchor: "north", padding: 2pt) - seg(P(xb + 0.08, yf, H), P(xb + 0.08, yf, H - w), grey, th: 0.5pt) - content(P(xb + 0.08, yf, H - 0.5 * w), text(fill: grey, $w$), anchor: "west", padding: 2pt) - content(P(xm, yf, H - 0.55 * w), - text(fill: fault-colour, size: 8pt)[strike-slip fault\ Coulomb $mu$, $T_b$], - anchor: "center") - content(P(xb2 + 0.05, yf2, H - 0.3 * w), text(fill: later-colour, size: 7.5pt)[further faults\ (later)], anchor: "west", padding: 2pt) - - // 6. the harmonic end load on x = Lx: normal and horizontal shear tractions - arrow(P(Lx, 0.5 * Ly, 0.55 * H), P(Lx + 0.5, 0.5 * Ly, 0.55 * H), load-colour, th: 1.2pt) - content(P(Lx + 0.55, 0.5 * Ly, 0.55 * H), text(fill: load-colour, $sigma_(x x)(t)$), anchor: "west") - arrow(P(Lx, 0.25 * Ly, 0.18 * H), P(Lx, 0.70 * Ly, 0.18 * H), load-colour, th: 1.2pt) - content(P(Lx, 0.72 * Ly, 0.18 * H), text(fill: load-colour, $sigma_(x y)(t)$), anchor: "north-west", padding: 1pt) - - // 7. axes - let o = P(-0.55, 0, -0.35) - arrow(o, proj((-0.55 + 0.45, 0, -0.35)), grey) - content(proj((-0.55 + 0.5, 0, -0.35)), text(fill: grey, $hat(x)$), anchor: "west") - arrow(o, proj((-0.55, 0.6, -0.35)), grey) - content(proj((-0.55, 0.7, -0.35)), text(fill: grey, $hat(y)$), anchor: "south-west") - arrow(o, proj((-0.55, 0, -0.35 + 0.45)), grey) - content(proj((-0.55, 0, -0.35 + 0.5)), text(fill: grey, $hat(z)$), anchor: "south") - - // 8. boundary annotations - content(P(0.5 * Lx, Ly, H), text(fill: edge-colour, size: 8pt)[free surface], anchor: "south", padding: 2pt) - content(P(0.5 * Lx, 0, 0), text(fill: edge-colour, size: 8pt)[base: free-slip], anchor: "north", padding: 3pt) - content(P(0.30 * Lx, 0, 0.32 * H), text(fill: edge-colour, size: 8pt)[$y$ faces: traction-free\ (or $u_x = u_z = 0$)], anchor: "center") - content(P(0.74 * Lx, 0, 0), text(fill: edge-colour, size: 8pt)[Maxwell block, Wallace number $"Wa" = T_"orbit" \/ T_M$], anchor: "north-west", padding: 3pt) -}) - -// ── Panel (b): the load cycle on the driven face ────────────────────── -#let panel-b = cetz.canvas(length: 1.0cm, { - import cetz.draw: * - let W = 5.0 - let A = 1.1 - let phase = 80.0 // deg: the normal traction leads the shear (placeholder: stripe value) - let n = 120 - seg((0, -A - 0.3), (0, A + 0.3), grey, th: 0.6pt) - arrow((0, 0), (W + 0.3, 0), grey, th: 0.6pt) - content((W + 0.35, 0), text(fill: grey, $t \/ T_"orbit"$), anchor: "west") - for k in range(0, 5) { - let x = W * k / 4 - seg((x, -0.06), (x, 0.06), grey, th: 0.6pt) - } - content((W, -0.1), text(fill: grey, size: 8pt)[1], anchor: "north") - let pts-s = range(0, n + 1).map(i => (W * i / n, A * calc.sin(2 * calc.pi * i / n))) - let pts-n = range(0, n + 1).map(i => (W * i / n, A * calc.sin(2 * calc.pi * i / n + phase * calc.pi / 180))) - line(..pts-s, stroke: (paint: shear-colour, thickness: 1.2pt)) - line(..pts-n, stroke: (paint: normal-colour, thickness: 1.2pt, dash: "dashed")) - seg((W + 0.5, A + 0.05), (W + 1.0, A + 0.05), shear-colour, th: 1.2pt) - content((W + 1.1, A + 0.05), text(fill: shear-colour, size: 8pt)[$sigma_(x y)$ shear, $S sin(omega t)$], anchor: "west") - seg((W + 0.5, A - 0.35), (W + 1.0, A - 0.35), normal-colour, th: 1.2pt, dash: "dashed") - content((W + 1.1, A - 0.35), text(fill: normal-colour, size: 8pt)[$sigma_(x x)$ normal, $N sin(omega t + phi.alt)$], anchor: "west") - let xs = W * 0.25 - let xn = W * (0.25 - phase / 360) - seg((xn, -A - 0.15), (xs, -A - 0.15), grey, th: 0.6pt) - seg((xn, -A - 0.22), (xn, -A - 0.08), grey, th: 0.6pt) - seg((xs, -A - 0.22), (xs, -A - 0.08), grey, th: 0.6pt) - content((0.5 * (xn + xs), -A - 0.2), text(fill: grey, size: 8pt)[$phi.alt$], anchor: "north", padding: 2pt) - content((W * 0.25, -A * 0.45), text(fill: grey, size: 7.5pt)[right-lateral\ half cycle], anchor: "center") - content((W * 0.75, A * 0.5), text(fill: grey, size: 7.5pt)[left-lateral\ half cycle], anchor: "center") -}) - -#grid(columns: 2, column-gutter: 14pt, align: bottom, - [#panel-a #v(2pt) #text(size: 8pt)[(a) block, boundary conditions and the fault]], - [#panel-b #v(2pt) #text(size: 8pt)[(b) the harmonic end load: normal and shear tractions with a phase lag $phi.alt$]], -) diff --git a/scripts/sessions/parallel_addon/README.md b/scripts/sessions/parallel_addon/README.md deleted file mode 100644 index ec13719b8..000000000 --- a/scripts/sessions/parallel_addon/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Diagnostic probes: the parallel add-on (2026-10-17) - -Evidence for `docs/developer/design/parallel-addon-distribution-2026-10.md`. These are -diagnostics, kept as they were run, not model scripts: they read `sys.argv` and print -from rank 0 with mpi4py, which the Charter's section 11 rules out for session scripts -that are models. Run with `OMP_NUM_THREADS=1 mpirun -np N python ...`. - -| probe | what it measures | -|---|---| -| `probe_scaling.py fault` | velocity iterations, transfer quality (partition of unity, coordinate reproduction) on the split refined patch box; `plain`, `wall`, `fault_xp` modes for comparison | -| `probe_builders.py` | the two node-cloud builders on the finest pair, with wrong-row dissection (the f21f3d85 defects) | -| `probe_coords_order.py`, `probe_slots.py` | ruled-out hypotheses of the same hunt: node order, native-pair slots | -| `probe_scaling_2d.py` | the 2-D analogue attempt | -| `probe_migration_sf.py` | a per-cell field through `distributeField` on the migration star forest (exact) | -| `probe_sf_exchange.py` | star-forest broadcast and reduce semantics in petsc4py | -| `probe_partition_2d.py [k]` | `partition_addon` invariants on the 2-D adapt-on-top fault | -| `probe_load_3d.py` | per-rank load of the split refined patch box after the partition | -| `probe_partition_cost.py [h] [k] [dim]` | wall time and cProfile of `partition_addon` on a larger adapt child | -| `probe_split_cost.py [dim]` | wall time and cProfile of a fault split | -| `check_topology_equivalence.py` | every function moved onto `_plex_topology` against its previous version (needs the old files in `/tmp/parallel-addon/old/`, `git show dd1d6eea:`) | -| `check_transfer_assembly.py` | the vectorised node-transfer assembly against the per-row builders it replaced | diff --git a/scripts/sessions/parallel_addon/check_topology_equivalence.py b/scripts/sessions/parallel_addon/check_topology_equivalence.py deleted file mode 100644 index bfca67973..000000000 --- a/scripts/sessions/parallel_addon/check_topology_equivalence.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Every function converted to the compiled topology tables against its previous -(per-point Python) version, taken from the commit before the conversion: the -results must be identical. Serial and parallel. - - git show dd1d6eea: > /tmp/parallel-addon/old/_old.py (see README) - mpirun -np N python check_topology_equivalence.py -""" -import ast, importlib.util, sys, types -import numpy as np -import underworld3 as uw -from mpi4py import MPI -from petsc4py import PETSc - -comm = MPI.COMM_WORLD -OLD = "/tmp/parallel-addon/old/" - - -def load(name): - spec = importlib.util.spec_from_file_location(name, OLD + name + ".py") - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -def method(file, cls_name, meth): - """An old Mesh method as a plain function of ``self``.""" - tree = ast.parse(open(OLD + file + ".py").read()) - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef) and node.name == meth: - src = ast.get_source_segment(open(OLD + file + ".py").read(), node) - import textwrap - ns = {"numpy": np, "uw": uw, "PETSc": PETSc} - exec(textwrap.dedent(src), ns) - return ns[meth] - raise KeyError(meth) - - -es_old, ps_old, ap_old, fs_old = (load("edge_split_old"), load("place_surface_old"), - load("addon_partition_old"), load("fault_split_old")) -from underworld3.utilities import addon_partition as ap, edge_split as es, place_surface as ps, fault_split as fs - -fails = [] - - -def same(tag, a, b): - ok = (np.array_equal(np.asarray(a), np.asarray(b)) if not isinstance(a, (set, dict, tuple)) - else a == b) - ok = comm.allreduce(bool(ok), op=MPI.LAND) - if comm.rank == 0: - print(f" {'ok ' if ok else 'DIFF'} {tag}", flush=True) - if not ok: - fails.append(tag) - - -meshes = { - "2d": uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.03, qdegree=2), - "3d": uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(1, 1, 1), cellSize=0.12, regular=False, qdegree=2), -} -cni_old = method("discretisation_mesh_old", "Mesh", "_cell_node_indices") -rows_old = method("discretisation_mesh_old", "Mesh", "_coord_rows_for_points") -kd_old = method("discretisation_mesh_old", "Mesh", "_build_kd_tree_index") -for tag, mesh in meshes.items(): - if comm.rank == 0: - print(f"{tag} np {comm.size}", flush=True) - dm = mesh.dm - for deg in (1, 2): - mesh._cell_node_array.clear() - new = mesh._cell_node_indices(deg, True) - saved = mesh._cell_node_array - mesh._cell_node_array = {} - from math import comb - ns = cni_old.__globals__; ns["comb"] = comb - old = cni_old(mesh, deg, True) - mesh._cell_node_array = saved - same(f"_cell_node_indices degree {deg}", new, old) - # the point-location index - proxy = types.SimpleNamespace(_nav_dm=mesh._nav_dm, dm=mesh.dm, _nav_coords=mesh._nav_coords, - element=mesh.element, dim=mesh.dim, cdim=mesh.cdim, _index=None) - proxy._coord_rows_for_points = lambda nav, pts: rows_old(proxy, nav, pts) - kd_old(proxy) - mesh._index = None - mesh._build_kd_tree_index() - same("kd index control points", mesh._indexCoords, proxy._indexCoords) - same("kd index cell map", mesh._indexMap, proxy._indexMap) - same("kd index centroids", mesh._nav_centroids, proxy._nav_centroids) - same("kd index reach", mesh._local_cell_reach, proxy._local_cell_reach) - from underworld3.utilities.fault_mg import _cell_vertex_table - vxy, cv = _cell_vertex_table(mesh) - vxy_o = np.asarray(mesh._get_coords_for_basis(1, True)) - cv_o = np.asarray(mesh._cell_node_indices(1, True)) - same("_cell_vertex_table: each cell's vertex coordinates", vxy[cv], vxy_o[cv_o]) - same("_edge_lengths", es._edge_lengths(dm), es_old._edge_lengths(dm)) - same("_cell_edges", np.array(es._cell_edges(dm)), np.array(es_old._cell_edges(dm))) - same("_edge_strength", np.column_stack(es._edge_strength(dm)), np.column_stack(es_old._edge_strength(dm))) - if tag == "3d": - T = ps._tet_vertices(dm) - same("_tet_vertices", T, ps_old._tet_vertices(dm)) - hv_n, hc_n = ps._vertex_h_3d(dm, T, dm.getDepthStratum(0)[1] - dm.getDepthStratum(0)[0]) - hv_o, hc_o = ps_old._vertex_h_3d(dm, T, dm.getDepthStratum(0)[1] - dm.getDepthStratum(0)[0]) - same("_vertex_h_3d", hv_n, hv_o) - nv = dm.getDepthStratum(0)[1] - dm.getDepthStratum(0)[0] - same("_true_wall_vertex_mask", ps._true_wall_vertex_mask(dm, nv), ps_old._true_wall_vertex_mask(dm, nv)) - # region growth: two regions near each other, one far - pStart, pEnd = dm.getChart(); vS, vE = dm.getDepthStratum(0) - X = dm.getCoordinatesLocal().array.reshape(-1, mesh.cdim)[: vE - vS] - ids = np.zeros(pEnd - pStart, dtype=np.int32) - ids[vS - pStart + np.flatnonzero(np.linalg.norm(X - 0.3, axis=1) < 0.08)] = 1 - ids[vS - pStart + np.flatnonzero(np.linalg.norm(X - 0.42, axis=1) < 0.06)] = 2 - ids[vS - pStart + np.flatnonzero(np.linalg.norm(X - 0.8, axis=1) < 0.08)] = 3 - for layers in (0, 1, 2): - rn = ps._region_cells(dm, ids, layers=layers) - ro = ps_old._region_cells(dm, ids, layers=layers) - same(f"_region_cells layers {layers}: cells", rn[0], ro[0]) - same(f"_region_cells layers {layers}: merge", tuple(sorted(rn[1].items())), tuple(sorted(ro[1].items()))) - # marks - poly = np.array([[0.3, 0.45], [0.7, 0.55]]) if tag == "2d" else None - if poly is not None: - same("mark_polylines", ap.mark_polylines([poly])(dm), ap_old.mark_polylines([poly])(dm)) - labels = [(b.name, int(b.value)) for b in mesh.boundaries if dm.hasLabel(b.name)][:3] - same("mark_labels", ap.mark_labels(labels)(dm), ap_old.mark_labels(labels)(dm)) - same("_fault_labels_touch_seam", fs._fault_labels_touch_seam(dm, labels), - fs_old._fault_labels_touch_seam(dm, labels)) - # unit adjacency, as sets of pairs (order is not part of the contract) - cS, cE = dm.getHeightStratum(0) - unit = np.arange(cE - cS) // 3 - fo = ap._face_other_rank(dm) - i_n, s_n = ap._unit_adjacency(dm, unit, fo) - i_o, s_o = ap_old._unit_adjacency(dm, unit, fo) - same("_unit_adjacency inner", set(map(tuple, i_n.tolist())), set(map(tuple, i_o.tolist()))) - same("_unit_adjacency seam", set(map(tuple, s_n.tolist())), set(map(tuple, s_o.tolist()))) - # the redistribution's per-cell fields - if comm.size > 1: - assign = (np.arange(cE - cS) + comm.rank) % comm.size - fields = [np.arange(cE - cS) + 1000 * comm.rank, np.full(cE - cS, comm.rank)] - _w, _p, _d, f_n = fs.redistribute_by_assignment(dm, assign.astype(np.int32), fields=fields) - _w, _p, _d, f_o = fs_old.redistribute_by_assignment(dm, assign.astype(np.int32), fields=fields) - same("redistribute_by_assignment fields", np.column_stack(f_n), np.column_stack(f_o)) -if comm.rank == 0: - print("FAILURES:", fails if fails else "none", flush=True) diff --git a/scripts/sessions/parallel_addon/check_transfer_assembly.py b/scripts/sessions/parallel_addon/check_transfer_assembly.py deleted file mode 100644 index 400d8e67f..000000000 --- a/scripts/sessions/parallel_addon/check_transfer_assembly.py +++ /dev/null @@ -1,48 +0,0 @@ -"""The vectorised node-transfer assembly against the per-row builders it -replaced (custom_mg from the commit before): the same MPIAIJ entries, on a -2-D and a 3-D refined box's coarse-to-fine pair, both builders. - - git show dd1d6eea:src/underworld3/utilities/custom_mg.py > /tmp/parallel-addon/old/custom_mg_old.py - mpirun -np N python check_transfer_assembly.py -""" -import importlib.util -import numpy as np -import sympy -import underworld3 as uw -from mpi4py import MPI -from underworld3.utilities import custom_mg as new - -spec = importlib.util.spec_from_file_location("custom_mg_old", "/tmp/parallel-addon/old/custom_mg_old.py") -old = importlib.util.module_from_spec(spec); spec.loader.exec_module(old) -comm = MPI.COMM_WORLD -bad = [] -for dim in (2, 3): - mesh = (uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.1, refinement=1, qdegree=2) - if dim == 2 else - uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(1, 1, 1), cellSize=0.3, regular=False, refinement=1, qdegree=2)) - v = uw.discretisation.MeshVariable(f"v{dim}", mesh, dim, degree=2) - p = uw.discretisation.MeshVariable(f"p{dim}", mesh, 1, degree=1) - stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) - stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel - stokes.add_dirichlet_bc((0.0,) * dim, "Bottom") - stokes._setup_pointwise_functions(verbose=False) if hasattr(stokes, "_setup_pointwise_functions") else None - stokes._build(verbose=False) if hasattr(stokes, "_build") else None - wraps = mesh._coarse_level_meshes() - cm = wraps[0] - Xc = np.asarray(cm._get_coords_for_basis(2, True)); Xf = np.asarray(mesh._get_coords_for_basis(2, True)) - lay_c = new._coarse_dof_layout(stokes, cm, 0); lay_f = new._level_dof_layout(stokes.dm, 0) - for name in ("_build_parallel_transfer", "_build_crosspart_transfer"): - args = (Xc, Xf, lay_c, lay_f, dim, new.barycentric_prolongation, stokes.dm.comm) - A = getattr(new, name)(*args) - args_o = (Xc, Xf, lay_c, lay_f, dim, old.barycentric_prolongation, stokes.dm.comm) - B = getattr(old, name)(*args_o) - ia, ja, va = A.getValuesCSR(); ib, jb, vb = B.getValuesCSR() - same = (A.getSizes() == B.getSizes() and np.array_equal(ia, ib) and np.array_equal(ja, jb) - and np.array_equal(va, vb)) - same = comm.allreduce(bool(same), op=MPI.LAND) - if comm.rank == 0: - print(f"{dim}-D {name}: {'identical' if same else 'DIFFERENT'} ({A.getSize()})", flush=True) - if not same: - bad.append((dim, name)) -if comm.rank == 0: - print("FAILURES:", bad or "none", flush=True) diff --git a/scripts/sessions/parallel_addon/probe_builders.py b/scripts/sessions/parallel_addon/probe_builders.py deleted file mode 100644 index 1bcac4c4a..000000000 --- a/scripts/sessions/parallel_addon/probe_builders.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Build the finest transfer (last coarse wrap -> split finest) with each parallel builder -explicitly and test x-reproduction, zero columns and empty rows.""" -import numpy as np, sympy, underworld3 as uw -from mpi4py import MPI -from petsc4py import PETSc -from underworld3.utilities.fault_split import split_fault -from underworld3.utilities import custom_mg as cmg -comm = MPI.COMM_WORLD; rank0 = comm.rank == 0 -Lx, Ly, H = 3.0, 2.0, 1.0; h, hf, f = 0.2, 0.1, 4 -yf, xa, xb, zb = 1.0, 1.0, 2.0, 0.5; ztop = H - 2 * hf -patch = np.array([[xa, yf, zb], [xb, yf, zb], [xb, yf, ztop], [xa, yf, ztop]]) -fine = uw.meshing.BoxInternalPatch(cellSize=f * h, minCoords=(0, 0, 0), maxCoords=(Lx, Ly, H), patch_points=patch, patch_name="Fault", - patch_cellSize=f * hf, grading_distance=8 * hf, refinement=2, qdegree=3) -mesh = split_fault(fine, "Fault") -v = uw.discretisation.MeshVariable("v", mesh, 3, degree=2) -p = uw.discretisation.MeshVariable("p", mesh, 1, degree=1) -stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) -stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel -stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 -stokes.bodyforce = sympy.Matrix([0, 0, 0]); stokes.tolerance = 1e-4 -stokes.add_dirichlet_bc((0.0, 0.0, 0.0), "Left"); stokes.add_natural_bc(sympy.Matrix([[-0.01, -0.01, 0.0]]), "Right") -stokes.add_dirichlet_bc((None, None, 0.0), "Bottom"); stokes.add_fault_bc(0.0, "Fault") -stokes._setup_pointwise_functions(verbose=False) if hasattr(stokes, "_setup_pointwise_functions") else None -stokes._build(verbose=False) if hasattr(stokes, "_build") else None -cm = mesh._custom_mg_coarse_meshes[-1] -Xc = np.asarray(cm._get_coords_for_basis(2, True)); Xf = np.asarray(mesh._get_coords_for_basis(2, True)) -lay_c = cmg._coarse_dof_layout(stokes, cm, 0); lay_f = cmg._level_dof_layout(stokes.dm, 0) -pcomm = stokes.dm.comm -def check(P, name): - xc = P.createVecRight(); xf = P.createVecLeft() - for i in range(Xc.shape[0]): - g = int(lay_c.l2g[i * 3]); - if lay_c.rstart <= g < lay_c.rend: xc.setValue(g, float(Xc[i, 0])) - for i in range(Xf.shape[0]): - g = int(lay_f.l2g[i * 3]); - if lay_f.rstart <= g < lay_f.rend: xf.setValue(g, float(Xf[i, 0])) - xc.assemble(); xf.assemble(); y = P.createVecLeft(); P.mult(xc, y) - d = np.abs(y.getArray() - xf.getArray()); mask = np.abs(xf.getArray()) > 0 - nbad = comm.allreduce(int((d[mask] > 1e-6).sum()), op=MPI.SUM); ntot = comm.allreduce(int(mask.sum()), op=MPI.SUM) - ones = P.createVecRight(); ones.set(1.0); r = P.createVecLeft(); P.mult(ones, r) - nempty = comm.allreduce(int((np.abs(r.getArray()) < 1e-14).sum()), op=MPI.SUM) - nzc = cmg._count_zero_columns_parallel(P, pcomm) - if rank0: print(f"np {comm.size} {name}: x wrong on {nbad}/{ntot}, empty rows {nempty}, zero cols {nzc}", flush=True) -args = (Xc, Xf, lay_c, lay_f, 3, cmg.barycentric_prolongation, pcomm) -check(cmg._build_parallel_transfer(*args), "co-partitioned") -check(cmg._build_crosspart_transfer(*args), "cross-partition") - -# --- dissect wrong rows of the cross-partition transfer on rank 0 --- -coords_u, cols_u = cmg._gather_coarse_cloud(Xc, lay_c, 3, pcomm) -Px = cmg._build_crosspart_transfer(*args) -col_coord = {} -for j in range(coords_u.shape[0]): - g = int(cols_u[j, 0]) - if g >= 0: col_coord[g] = coords_u[j] -indptr, cols, data = Px.getValuesCSR() -fstart = lay_f.rstart -shown = 0 -for i in range(Xf.shape[0]): - g = int(lay_f.l2g[i * 3]) - if not (fstart <= g < lay_f.rend): continue - r = g - fstart - sl = slice(indptr[r], indptr[r + 1]) - cs, ws = cols[sl], data[sl] - xi = Xf[i] - xc_cols = [col_coord.get(int(c)) for c in cs] - px = sum(w * cc[0] for w, cc in zip(ws, xc_cols) if cc is not None) - if abs(px - xi[0]) > 1e-6 and shown < 4: - dists = [float(np.linalg.norm(cc - xi)) if cc is not None else -1 for cc in xc_cols] - if comm.rank == 0: - print(f"rank0 wrong row: fine x {xi.round(3)} -> cols {cs.tolist()} weights {np.round(ws, 3).tolist()} coarse-node distances {np.round(dists, 3).tolist()} (P x = {px:.3f})", flush=True) - shown += 1 -# how many local fine nodes fall outside the LOCAL coarse hull (co-partitioned builder fallback)? -from scipy.spatial import Delaunay -tri = Delaunay(Xc) -outside_local = int((tri.find_simplex(Xf) < 0).sum()) -tri_u = Delaunay(coords_u) -outside_union = int((tri_u.find_simplex(Xf) < 0).sum()) -per = comm.gather((Xf.shape[0], outside_local, outside_union), root=0) -if comm.rank == 0: print("per rank (fine nodes, outside LOCAL coarse hull, outside UNION hull):", per, flush=True) diff --git a/scripts/sessions/parallel_addon/probe_coords_order.py b/scripts/sessions/parallel_addon/probe_coords_order.py deleted file mode 100644 index 6e6f1ab7e..000000000 --- a/scripts/sessions/parallel_addon/probe_coords_order.py +++ /dev/null @@ -1,21 +0,0 @@ -"""On the split patch box: set v = x from v.coords and evaluate it back at random points. -A mismatch between the coordinate order and the section order shows as evaluation error.""" -import numpy as np, underworld3 as uw -from mpi4py import MPI -from underworld3.utilities.fault_split import split_fault -rank0 = MPI.COMM_WORLD.rank == 0 -Lx, Ly, H = 3.0, 2.0, 1.0; h, hf, f = 0.2, 0.1, 4 -yf, xa, xb, zb = 1.0, 1.0, 2.0, 0.5; ztop = H - 2 * hf -patch = np.array([[xa, yf, zb], [xb, yf, zb], [xb, yf, ztop], [xa, yf, ztop]]) -fine = uw.meshing.BoxInternalPatch(cellSize=f * h, minCoords=(0, 0, 0), maxCoords=(Lx, Ly, H), patch_points=patch, patch_name="Fault", - patch_cellSize=f * hf, grading_distance=8 * hf, refinement=2, qdegree=3) -for label, mesh in (("unsplit", fine), ("split", split_fault(fine, "Fault"))): - v = uw.discretisation.MeshVariable(f"v_{label}", mesh, 3, degree=2) - c = np.asarray(v.coords) - v.array[:, 0, 0] = c[:, 0]; v.array[:, 0, 1] = c[:, 1]; v.array[:, 0, 2] = c[:, 2] - rng = np.random.default_rng(0) - pts = np.column_stack([rng.uniform(0.05, 2.95, 400), rng.uniform(0.05, 1.95, 400), rng.uniform(0.05, 0.95, 400)]) - U = np.asarray(uw.function.global_evaluate(v.sym, pts)).reshape(-1, 3) - err = np.abs(U - pts).max(axis=1) - if rank0: - print(f"{label} np {MPI.COMM_WORLD.size}: v = x evaluated back: max err {err.max():.2e}, points with err > 1e-6: {(err > 1e-6).sum()} of {len(err)}", flush=True) diff --git a/scripts/sessions/parallel_addon/probe_load_3d.py b/scripts/sessions/parallel_addon/probe_load_3d.py deleted file mode 100644 index 572feaa14..000000000 --- a/scripts/sessions/parallel_addon/probe_load_3d.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Per-rank load of the split refined patch box after the add-on partition.""" -import numpy as np, underworld3 as uw -from mpi4py import MPI -from underworld3.utilities.fault_split import split_fault -comm = MPI.COMM_WORLD -Lx, Ly, H = 3.0, 2.0, 1.0; h, hf, f = 0.2, 0.1, 4 -yf, xa, xb, zb = 1.0, 1.0, 2.0, 0.5; ztop = H - 2 * hf -patch = np.array([[xa, yf, zb], [xb, yf, zb], [xb, yf, ztop], [xa, yf, ztop]]) -fine = uw.meshing.BoxInternalPatch(cellSize=f * h, minCoords=(0, 0, 0), maxCoords=(Lx, Ly, H), patch_points=patch, patch_name="Fault", - patch_cellSize=f * hf, grading_distance=8 * hf, refinement=2, qdegree=3) -mesh = split_fault(fine, "Fault", verbose=True) -rec = getattr(mesh.parent, "_addon_partition", None) -nodes = comm.gather(np.asarray(mesh._get_coords_for_basis(2, True)).shape[0], root=0) -cells = comm.gather(mesh.dm.getHeightStratum(0)[1] - mesh.dm.getHeightStratum(0)[0], root=0) -if comm.rank == 0: - n = np.array(nodes, float); c = np.array(cells, float) - print(f"np {comm.size}: finest P2 nodes per rank {nodes} max/mean {n.max()/n.mean():.2f}; cells {cells} max/mean {c.max()/c.mean():.2f}", flush=True) - if rec: print("record:", {k: v for k, v in rec.items() if k != 'owners'}, flush=True) diff --git a/scripts/sessions/parallel_addon/probe_migration_sf.py b/scripts/sessions/parallel_addon/probe_migration_sf.py deleted file mode 100644 index 11c3d1eb3..000000000 --- a/scripts/sessions/parallel_addon/probe_migration_sf.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Does a per-cell integer field ride a shell-partition redistribution? - -Checks the two PETSc routes a provenance array could take: the migration star -forest ``distribute()`` returns, read as a point map, and ``distributeField`` -on a cell-wise section. The field is each cell's centroid x-coordinate (a -quantity we can re-derive on the new mesh), so a wrong mapping is visible. -""" -import numpy as np -import underworld3 as uw -from mpi4py import MPI -from petsc4py import PETSc - -comm = MPI.COMM_WORLD -mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), - cellSize=0.1, qdegree=2) -dm = mesh.dm -cS, cE = dm.getHeightStratum(0) -cent = np.array([dm.computeCellGeometryFVM(c)[1][0] for c in range(cS, cE)]) -# move the right-hand half of every rank's cells to the next rank -assign = np.full(cE - cS, comm.rank, dtype=np.int32) -assign[cent > 0.5] = (comm.rank + 1) % comm.size -work = dm.clone() -sec = PETSc.Section().create(comm=PETSc.COMM_WORLD) -sec.setChart(*work.getChart()) -for c in range(cS, cE): - sec.setDof(c, 1) -sec.setUp() -vec = PETSc.Vec().createSeq(sec.getStorageSize(), comm=PETSc.COMM_SELF) -arr = vec.getArray() -for c in range(cS, cE): - arr[sec.getOffset(c)] = cent[c - cS] -vec.setArray(arr) -part = work.getPartitioner() -part.setType(PETSc.Partitioner.Type.SHELL) -order = np.argsort(assign, kind="stable").astype(np.int32) -sizes = np.bincount(assign, minlength=comm.size).astype(np.int32) -part.setShellPartition(comm.size, sizes=sizes, points=order) -sf = work.distribute() -print(f"rank {comm.rank}: migration sf {'None' if sf is None else type(sf).__name__}", flush=True) -ncS, ncE = work.getHeightStratum(0) -new_cent = np.array([work.computeCellGeometryFVM(c)[1][0] for c in range(ncS, ncE)]) -# route 1: migration SF as a point map (leaf = new local point, root = old (rank, point)) -if sf is not None: - nroots, ilocal, iremote = sf.getGraph() - nleaves = len(iremote) - cells_leaf = [(i if ilocal is None else int(ilocal[i])) for i in range(nleaves)] - depth_ok = sum(1 for i, q in enumerate(cells_leaf) if (ncS <= q < ncE) == (cS <= int(iremote[i][1]) < cE)) - print(f"rank {comm.rank}: sf nroots {nroots} (old chart size {dm.getChart()[1]}), leaves {nleaves} (new chart size {work.getChart()[1]}), " - f"depth preserved on {depth_ok}/{nleaves}", flush=True) - # route 2: distributeField - newsec, newvec = work.distributeField(sf, sec, vec) - narr = newvec.getArray() - got = np.array([narr[newsec.getOffset(c)] for c in range(ncS, ncE)]) - err = np.abs(got - new_cent).max() if len(got) else 0.0 - print(f"rank {comm.rank}: distributeField cells {ncE - ncS}, max |field - recomputed centroid| = {err:.2e}", flush=True) diff --git a/scripts/sessions/parallel_addon/probe_partition_2d.py b/scripts/sessions/parallel_addon/probe_partition_2d.py deleted file mode 100644 index 7526535d7..000000000 --- a/scripts/sessions/parallel_addon/probe_partition_2d.py +++ /dev/null @@ -1,87 +0,0 @@ -"""partition_addon on the 2-D adapt-on-top fault fixture: the invariants, at any np. - -mpirun -np N python probe_partition_2d.py [k=2] -""" -import sys -import numpy as np -import underworld3 as uw -from mpi4py import MPI -from petsc4py import PETSc -from underworld3.utilities import addon_partition as ap -from underworld3.utilities.fault_mg import _barycentric, _cell_vertex_table, addon_level_indices -from underworld3.utilities.place_surface import _region_cells - -comm = MPI.COMM_WORLD -k = int(sys.argv[1]) if len(sys.argv) > 1 else 2 -TIP_A, TIP_B = np.array([0.30, 0.45]), np.array([0.70, 0.55]) -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.1, refinement=1, qdegree=2) -pts = np.column_stack([np.linspace(TIP_A[0], TIP_B[0], 9), np.linspace(TIP_A[1], TIP_B[1], 9), np.zeros(9)]) -guide = uw.meshing.Surface("guide", base, pts); guide.discretize() -child = base.adapt(guide.refinement_metric_function(h_near=0.05 / 2**k, h_far=0.05, width=0.08, profile="linear"), max_levels=k) -trace = np.vstack([TIP_A, TIP_B]) - -def cells(m): - cS, cE = m.dm.getHeightStratum(0); return cE - cS - -def report(tag, m): - tail = m._custom_mg_coarse_meshes - idx = addon_level_indices(tail) - per = [comm.gather(cells(tail[i]), root=0) for i in idx] + [comm.gather(cells(m), root=0)] - if comm.rank == 0: - print(f"{tag}: add-on levels {len(idx)}; cells per rank per level (coarsest add-on .. finest): {per}", flush=True) - -report("before", child) -marks = ap.mark_polylines([trace]) -out = ap.partition_addon(child, marks, layers=1, verbose=True) -report("after ", out) -rec = out._addon_partition -if comm.rank == 0: - la = np.array(rec["load_after"]); lb = np.array(rec["load_before"]) - print(f"record: {rec['regions']} region(s) owners {rec['owners']}, units moved {rec['units_moved']}, sweeps {rec['sweeps']}, load max/mean {lb.max()/lb.mean():.2f} -> {la.max()/la.mean():.2f}", flush=True) - -# 1. regions interior on the new finest (the split's precondition): star + 2 layers of the trace on one rank -marks2 = marks(out.dm) -cell_region, canon, regions = _region_cells(out.dm, marks2, layers=2) -counts = np.asarray(comm.allgather([int((cell_region == r).sum()) for r in regions])) -if comm.rank == 0: - print(f"regions after: {len(regions)}; ranks holding each: {[int(np.count_nonzero(counts[:, j])) for j in range(len(regions))]} (1 = interior)", flush=True) - -# 2. add-on pairs co-partitioned: every fine cell's parent is local -tail = out._custom_mg_coarse_meshes -levels = [tail[i] for i in addon_level_indices(tail)] + [out] -static = [tail[i] for i in range(len(tail)) if i not in addon_level_indices(tail)] -ok = True -for l in range(1, len(levels)): - try: - ap._parent_cells_local(levels[l - 1], levels[l]) - except RuntimeError as e: - ok = False - print(f"rank {comm.rank}: pair {l-1}->{l} NOT co-partitioned: {e}", flush=True) -ok = comm.allreduce(ok, op=MPI.LAND) -if comm.rank == 0: - print(f"add-on pairs co-partitioned: {ok}", flush=True) - -# 3. provenance: fetch each parent's vertex coordinates by its CURRENT address and test containment -def check_parents(below, lev, tag): - par = lev._addon_parent - vxy_b, cv_b = _cell_vertex_table(below) - nb = cv_b.shape[0] - verts = vxy_b[cv_b].reshape(nb, -1) # (nb, (dim+1)*cdim) - sf = PETSc.SF().create(comm=PETSc.COMM_WORLD) - sf.setGraph(nb, None, np.ascontiguousarray(par, dtype=np.int32)); sf.setUp() - got = np.zeros((par.shape[0], verts.shape[1])) - unit = MPI.DOUBLE.Create_contiguous(verts.shape[1]); unit.Commit() - sf.bcastBegin(unit, np.ascontiguousarray(verts), got, MPI.REPLACE); sf.bcastEnd(unit, np.ascontiguousarray(verts), got, MPI.REPLACE) - unit.Free() - fcent, _v, _c = ap._cell_centroids(lev) - V = got.reshape(par.shape[0], cv_b.shape[1], -1) - lam = _barycentric(fcent, V) - bad = int((lam.min(axis=1) < -1e-9).sum()) - remote = int((par[:, 0] != comm.rank).sum()) - tot = comm.allreduce(bad); rem = comm.allreduce(remote); n = comm.allreduce(par.shape[0]) - if comm.rank == 0: - print(f"provenance {tag}: {n} cells, {rem} with a remote parent, {tot} NOT inside their parent", flush=True) - -check_parents(static[-1], levels[0], "base -> first add-on") -for l in range(1, len(levels)): - check_parents(levels[l - 1], levels[l], f"add-on {l-1} -> {l}") diff --git a/scripts/sessions/parallel_addon/probe_partition_cost.py b/scripts/sessions/parallel_addon/probe_partition_cost.py deleted file mode 100644 index 7425cb46f..000000000 --- a/scripts/sessions/parallel_addon/probe_partition_cost.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Cost of partition_addon on a larger adapt-on-top fault: wall time of the call -and rank 0's cProfile of it (top cumulative entries). - -mpirun -np N python probe_partition_cost.py [cell_size=0.02] [k=2] [dim=2] -""" -import cProfile, io, pstats, sys, time -import numpy as np -import underworld3 as uw -from mpi4py import MPI -from underworld3.utilities import addon_partition as ap - -comm = MPI.COMM_WORLD -h = float(sys.argv[1]) if len(sys.argv) > 1 else 0.02 -k = int(sys.argv[2]) if len(sys.argv) > 2 else 2 -dim = int(sys.argv[3]) if len(sys.argv) > 3 else 2 -if dim == 2: - base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=h, refinement=1, qdegree=2) - A, B = np.array([0.3, 0.45]), np.array([0.7, 0.55]) - pts = np.column_stack([np.linspace(A[0], B[0], 9), np.linspace(A[1], B[1], 9), np.zeros(9)]) - guide = uw.meshing.Surface("guide", base, pts); guide.discretize() - child = base.adapt(guide.refinement_metric_function(h_near=h / 2**k, h_far=h, width=4 * h, profile="linear"), max_levels=k) - marks = ap.mark_polylines([np.vstack([A, B])]) - layers = 1 -else: - base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(1, 1, 1), cellSize=h, regular=False, qdegree=2, refinement=1) - c = np.array([0.5, 0.5, 0.5]); n = np.array([0.0, 1.0, 0.0]) - def metric(X): - d = np.abs((np.asarray(X)[:, :3] - c) @ n) - hh = np.clip(h / 2**k + d * 0.5, h / 2**k, h) - return 1.0 / hh**2 - child = base.adapt(metric, max_levels=k) - a = np.linspace(0, 2 * np.pi, 24, endpoint=False) - rim = c + 0.25 * np.column_stack([np.cos(a), np.zeros(24), np.sin(a)]) - sheet_pts = np.vstack([c, rim]); tris = np.array([(0, 1 + i, 1 + (i + 1) % 24) for i in range(24)]) - marks = ap.mark_sheets([(sheet_pts, tris)], 0.6) - layers = 2 -cS, cE = child.dm.getHeightStratum(0) -ncell = comm.allreduce(cE - cS) -prof = cProfile.Profile() -comm.Barrier(); t = time.perf_counter(); prof.enable() -out = ap.partition_addon(child, marks, layers=layers) -prof.disable(); comm.Barrier(); dt = time.perf_counter() - t -rec = out._addon_partition -if comm.rank == 0: - print(f"np {comm.size} dim {dim} h {h} k {k}: finest cells {ncell}, partition {dt:.2f} s, units moved {rec['units_moved']}, sweeps {rec['sweeps']}", flush=True) - s = io.StringIO(); st = pstats.Stats(prof, stream=s); st.sort_stats("tottime").print_stats(25); st.print_callers("_get_coords_for_basis") - print("\n".join(l for l in s.getvalue().splitlines() if ("ncalls" in l or "(" in l)), flush=True) diff --git a/scripts/sessions/parallel_addon/probe_scaling.py b/scripts/sessions/parallel_addon/probe_scaling.py deleted file mode 100644 index c9ff875a4..000000000 --- a/scripts/sessions/parallel_addon/probe_scaling.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Velocity multigrid iterations against rank count, two configurations: - plain — UnstructuredSimplexBox(refinement=2), standard Stokes path (native hierarchy) - fault — BoxInternalPatch(refinement=2) + split_fault, rotated path, hierarchy tail -""" -import sys, time -import numpy as np, sympy, underworld3 as uw -from mpi4py import MPI -from underworld3.utilities.fault_split import split_fault -mode = sys.argv[1] -rank0 = MPI.COMM_WORLD.rank == 0 -Lx, Ly, H = 3.0, 2.0, 1.0 -h, hf, f = 0.2, 0.1, 4 -yf, xa, xb, zb = 1.0, 1.0, 2.0, 0.5; ztop = H - 2 * hf -t = time.perf_counter() -if mode in ("plain", "wall"): - mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(Lx, Ly, H), cellSize=f * h, regular=False, qdegree=3, refinement=2) -else: - patch = np.array([[xa, yf, zb], [xb, yf, zb], [xb, yf, ztop], [xa, yf, ztop]]) - fine = uw.meshing.BoxInternalPatch(cellSize=f * h, minCoords=(0, 0, 0), maxCoords=(Lx, Ly, H), patch_points=patch, patch_name="Fault", - patch_cellSize=f * hf, grading_distance=8 * hf, refinement=2, qdegree=3) - mesh = split_fault(fine, "Fault") -ncell = MPI.COMM_WORLD.allreduce(int(mesh.dm.getHeightStratum(0)[1] - mesh.dm.getHeightStratum(0)[0]), op=MPI.SUM) -v = uw.discretisation.MeshVariable("v", mesh, 3, degree=2) -p = uw.discretisation.MeshVariable("p", mesh, 1, degree=1) -stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) -stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel -stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 -stokes.bodyforce = sympy.Matrix([0, 0, 0]) -stokes.tolerance = 1e-4 -stokes.add_dirichlet_bc((0.0, 0.0, 0.0), "Left") -stokes.add_natural_bc(sympy.Matrix([[-0.01, -0.01, 0.0]]), "Right") -if mode != "wall": - stokes.add_dirichlet_bc((None, None, 0.0), "Bottom") -if mode.startswith("fault"): - stokes.add_fault_bc(0.0, "Fault") -if mode == "wall": - # rotated free-slip on the base in place of the Dirichlet u_z = 0: the rotated - # custom-P path with constrained (normal) rows but NO fault pairs - stokes.add_rotated_freeslip_bc(0, "Bottom") - from underworld3.utilities.custom_mg import set_custom_fmg - set_custom_fmg(stokes, list(mesh._coarse_level_meshes()[:-1]), field_id=0) -if mode == "fault_xp": - from underworld3.utilities.custom_mg import set_custom_fmg - set_custom_fmg(stokes, list(mesh._custom_mg_coarse_meshes), field_id=0, cross_partition=True) -t = time.perf_counter() -stokes.solve(zero_init_guess=True) -dt_solve = time.perf_counter() - t -if mode.startswith("fault") or mode == "wall": - info = stokes._rotated_freeslip_info - rep = f"velocity_pc {info.get('velocity_pc')} vel its {info.get('vel_its_last')} pres its {info.get('pres_its_last')} converged {info.get('converged')}" -else: - ksp = stokes.snes.getKSP(); pc = ksp.getPC() - try: - sub = pc.getFieldSplitSubKSP() - rep = f"pc {pc.getType()} vel pc {sub[0].getPC().getType()} vel its {sub[0].getIterationNumber()} pres its {sub[1].getIterationNumber()} outer its {ksp.getIterationNumber()} reason {ksp.getConvergedReason()}" - except Exception as e: - rep = f"pc {pc.getType()} outer its {ksp.getIterationNumber()} ({e})" -if mode.startswith("fault") or mode == "wall": - cache = getattr(stokes, "_rotated_linear_cache", None) or {} - Pl = cache.get("custom_Pl") or [] - H = (getattr(stokes, "_custom_mg", None) or {}).get("hierarchy") - raw = getattr(H, "transfers", None) or [] - from underworld3.utilities.custom_mg import _count_zero_columns_parallel - comm = MPI.COMM_WORLD - zc = [] - for l, Praw in enumerate(raw): - zc.append(f"raw L{l} zero cols {_count_zero_columns_parallel(Praw, Praw.comm)}") - if rank0: - print(" zero columns in the RAW transfers (before rotation / constrained-row zeroing): " + "; ".join(zc), flush=True) - # transfer quality: a Lagrange interpolation transfer maps the constant 1 to 1 - # (row weights sum to one); empty rows are fine DOFs with no coarse image - from petsc4py import PETSc - pu = [] - for l, Praw in enumerate(raw): - xc = Praw.createVecRight(); xc.set(1.0) - yf = Praw.createVecLeft(); Praw.mult(xc, yf) - a = np.abs(yf.getArray() - 1.0) - nbad = comm.allreduce(int((a > 1e-8).sum()), op=MPI.SUM) - nzero = comm.allreduce(int((np.abs(yf.getArray()) < 1e-14).sum()), op=MPI.SUM) - nrow = Praw.getSize()[0] - per_rank = comm.gather(int((np.abs(yf.getArray()) < 1e-14).sum()), root=0) - pu.append(f"L{l}: rows {nrow}, rows not summing to 1: {nbad}, empty rows: {nzero} (per rank {per_rank})") - if rank0: - print(" partition of unity of the RAW transfers: " + "; ".join(pu), flush=True) - try: - ctx = cache.get("ctx") or {} - vpc = ctx["pc"].getFieldSplitSubKSP()[0].getPC() - nl = vpc.getMGLevels() - sm = [f"L{l}: {vpc.getMGSmoother(l).getType()}/{vpc.getMGSmoother(l).getPC().getType()}" for l in range(nl)] - cs = vpc.getMGCoarseSolve() - if rank0: - print(f" live velocity MG: {nl} levels; smoothers {sm}; coarse {cs.getType()}/{cs.getPC().getType()}", flush=True) - except Exception as e: - if rank0: - print(f" live velocity MG: not readable ({type(e).__name__}: {e})", flush=True) - # does the transfer reproduce the coordinate field? y = P x_c vs x_f, in the - # reduced numbering of each level (ordering + location test in one) - try: - from underworld3.utilities.custom_mg import _level_dof_layout, _coarse_dof_layout - meshes = list(H.level_meshes) - rep_x = [] - for l in range(1, len(meshes)): - cm, fm = meshes[l - 1], meshes[l] - P = raw[l - 1] - lay_c = _coarse_dof_layout(stokes, cm, 0) if l - 1 < len(meshes) - 1 else _level_dof_layout(stokes.dm, 0) - lay_f = _level_dof_layout(stokes.dm, 0) if l == len(meshes) - 1 else _coarse_dof_layout(stokes, fm, 0) - xc_nodes = np.asarray(cm._get_coords_for_basis(2, True)); xf_nodes = np.asarray(fm._get_coords_for_basis(2, True)) - xc = P.createVecRight(); xf = P.createVecLeft() - ncomp = 3 - for comp in range(1): # x-coordinate only - for i in range(xc_nodes.shape[0]): - g = int(lay_c.l2g[i * ncomp + comp]) - if lay_c.rstart <= g < lay_c.rend: - xc.setValue(g, float(xc_nodes[i, 0])) - for i in range(xf_nodes.shape[0]): - g = int(lay_f.l2g[i * ncomp + comp]) - if lay_f.rstart <= g < lay_f.rend: - xf.setValue(g, float(xf_nodes[i, 0])) - xc.assemble(); xf.assemble() - y = P.createVecLeft(); P.mult(xc, y) - # compare only on the x-component rows (every 3rd reduced row is not guaranteed: use the rows we set) - d = np.abs(y.getArray() - xf.getArray()) - mask = np.abs(xf.getArray()) > 0 # rows we set (x > 0 everywhere but the x=0 face, which is constrained) - nbad = comm.allreduce(int((d[mask] > 1e-6).sum()), op=MPI.SUM) - ntot = comm.allreduce(int(mask.sum()), op=MPI.SUM) - dmax = comm.allreduce(float(d[mask].max()) if mask.any() else 0.0, op=MPI.MAX) - rep_x.append(f"L{l-1}->L{l}: x-rows checked {ntot}, |P x_c - x_f| > 1e-6 on {nbad}, max {dmax:.3e}") - if rank0: - print(" coordinate reproduction: " + "; ".join(rep_x), flush=True) - except Exception as e: - import traceback - if rank0: - print(f" coordinate reproduction: failed {type(e).__name__}: {e}", flush=True); traceback.print_exc() - # the finest level: do the basis coordinates (custom_mg's source) match the - # velocity variable's own DOF coordinates (section order), node by node? - try: - xb = np.asarray(mesh._get_coords_for_basis(2, True)) - xv = np.asarray(v.coords) - same_shape = xb.shape == xv.shape - dmax = float(np.abs(xb - xv).max()) if same_shape and xb.size else -1.0 - nmis = int((np.abs(xb - xv).max(axis=1) > 1e-10).sum()) if same_shape else -1 - rep2 = comm.gather((xb.shape, xv.shape, dmax, nmis), root=0) - if rank0: - print(" finest coords check (basis vs variable) per rank: " + "; ".join(f"{a} vs {b}: max diff {d:.2e}, nodes differing {n}" for a, b, d, n in rep2), flush=True) - # the solver's dm vs the mesh dm: same chart? - sd, md = stokes.dm.getChart(), mesh.dm.getChart() - rep3 = comm.gather((sd, md, stokes.dm.handle == mesh.dm.handle), root=0) - if rank0: - print(f" solver dm chart vs mesh dm chart per rank: {rep3}", flush=True) - except Exception as e: - if rank0: - print(f" finest coords check failed: {type(e).__name__}: {e}", flush=True) - # the finest reduced layout itself: how many local DOFs carry -1 (constrained - # OR an unresolved ghost), against the owned count and the ghost count - try: - from underworld3.utilities.custom_mg import _level_dof_layout - lay = _level_dof_layout(stokes.dm, 0) - n_minus = int((lay.l2g < 0).sum()); n_owned = lay.rend - lay.rstart; n_full = lay.n_full - # genuinely constrained velocity DOFs on this rank: Left face (3 per node) + Bottom (1 per node), by coordinates - xb = np.asarray(mesh._get_coords_for_basis(2, True)) - n_left = int((np.abs(xb[:, 0]) < 1e-9).sum()) * 3 - n_bot = int(((np.abs(xb[:, 2]) < 1e-9) & (np.abs(xb[:, 0]) > 1e-9)).sum()) - rep4 = comm.gather((n_full, n_owned, n_minus, n_left + n_bot), root=0) - if rank0: - print(" finest layout per rank (local DOFs, owned, l2g<0, expected constrained by geometry): " + str(rep4), flush=True) - except Exception as e: - if rank0: - print(f" finest layout check failed: {type(e).__name__}: {e}", flush=True) - # are the SOLVER dm's P2 node coordinates in the same local order as the mesh's? - try: - from petsc4py import PETSc - def p2_coords_of(dm): - dmold = dm.getCoordinateDM(); dmold.createDS(); dmnew = dmold.clone() - o = PETSc.Options(); o["probe_petscspace_degree"] = 2; o["probe_petscdualspace_lagrange_continuity"] = True; o["probe_petscdualspace_lagrange_node_endpoints"] = False - fe = PETSc.FE().createDefault(mesh.dim, mesh.cdim, mesh.isSimplex, mesh.qdegree, "probe_", PETSc.COMM_SELF) - dmnew.setField(0, fe); dmnew.createDS() - mi, _ = dmold.createInterpolation(dmnew) - g = dmnew.getGlobalVec(); lv = dmnew.getLocalVec() - mi.mult(dm.getCoordinates(), g); dmnew.globalToLocal(g, lv) - arr = lv.array.reshape(-1, mesh.cdim).copy() - dmnew.restoreGlobalVec(g); dmnew.restoreLocalVec(lv); return arr - xs = p2_coords_of(stokes.dm); xm = np.asarray(mesh._get_coords_for_basis(2, True)) - same = xs.shape == xm.shape - nd = int((np.abs(xs - xm).max(axis=1) > 1e-10).sum()) if same else -1 - # also: is the mesh's node set a permutation of the solver's? (sort both) - perm = bool(np.allclose(np.sort(xs, axis=0), np.sort(xm, axis=0))) if same else False - rep5 = comm.gather((xs.shape[0], xm.shape[0], nd, perm), root=0) - if rank0: - print(" solver-dm P2 coords vs mesh P2 coords per rank (n_solver, n_mesh, nodes differing in order, same set): " + str(rep5), flush=True) - except Exception as e: - import traceback - if rank0: - print(f" solver-dm coords check failed: {type(e).__name__}: {e}", flush=True); traceback.print_exc() - lev = [] - for l, P in enumerate(Pl): - m, n = P.getSize() - rn = raw[l].getSize()[1] if l < len(raw) else -1 - lev.append(f"L{l}: live {n} (raw {rn}, dropped {rn - n if rn >= 0 else '?'}) -> {m}") - if rank0: - print(" levels (coarse DOFs live/raw -> fine rows): " + "; ".join(lev), flush=True) -if rank0: - print(f"{mode} np {MPI.COMM_WORLD.size} cells {ncell} tail {len(getattr(mesh, '_custom_mg_coarse_meshes', None) or [])} hierarchy {len(mesh.dm_hierarchy)}: solve {dt_solve:.1f}s: {rep}", flush=True) diff --git a/scripts/sessions/parallel_addon/probe_scaling_2d.py b/scripts/sessions/parallel_addon/probe_scaling_2d.py deleted file mode 100644 index 8bd8c2754..000000000 --- a/scripts/sessions/parallel_addon/probe_scaling_2d.py +++ /dev/null @@ -1,29 +0,0 @@ -"""2-D analogue: refined box + interior split fault (add_fault), rotated custom-P path, iterations vs ranks.""" -import time -import numpy as np, sympy, underworld3 as uw -from mpi4py import MPI -rank0 = MPI.COMM_WORLD.rank == 0 -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(3.0, 1.0), cellSize=0.2, regular=False, qdegree=3, refinement=2) -from underworld3.utilities.fault_split import split_fault -surf = uw.meshing.Surface("Fault", base, np.array([[1.0, 0.5, 0.0], [1.5, 0.5, 0.0], [2.0, 0.5, 0.0]])) -surf.discretize() -cut = base.add_conforming_surface(surf) -mesh = split_fault(cut, "Fault") -ncell = MPI.COMM_WORLD.allreduce(int(mesh.dm.getHeightStratum(0)[1] - mesh.dm.getHeightStratum(0)[0]), op=MPI.SUM) -v = uw.discretisation.MeshVariable("v", mesh, 2, degree=2) -p = uw.discretisation.MeshVariable("p", mesh, 1, degree=1) -stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) -stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel -stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 -stokes.bodyforce = sympy.Matrix([0, 0]) -stokes.tolerance = 1e-4 -stokes.add_dirichlet_bc((0.0, 0.0), "Left") -stokes.add_natural_bc(sympy.Matrix([[-0.01, -0.01]]), "Right") -stokes.add_dirichlet_bc((None, 0.0), "Bottom") -stokes.add_fault_bc(0.0, "Fault") -t = time.perf_counter() -stokes.solve(zero_init_guess=True) -info = stokes._rotated_freeslip_info -if rank0: - print(f"2d fault np {MPI.COMM_WORLD.size} cells {ncell} tail {len(getattr(mesh, '_custom_mg_coarse_meshes', None) or [])}: solve {time.perf_counter()-t:.1f}s: " - f"velocity_pc {info.get('velocity_pc')} vel its {info.get('vel_its_last')} pres its {info.get('pres_its_last')} converged {info.get('converged')}", flush=True) diff --git a/scripts/sessions/parallel_addon/probe_sf_exchange.py b/scripts/sessions/parallel_addon/probe_sf_exchange.py deleted file mode 100644 index f0fe6d145..000000000 --- a/scripts/sessions/parallel_addon/probe_sf_exchange.py +++ /dev/null @@ -1,27 +0,0 @@ -"""petsc4py star-forest semantics for the provenance exchange: bcast root -int64 pairs to leaves, reduce leaf pairs into roots (REPLACE).""" -import numpy as np -from mpi4py import MPI -from petsc4py import PETSc -comm = MPI.COMM_WORLD -r, n = comm.rank, comm.size -nroots = 4 -# every rank's leaves point at the next rank's roots, two leaves per root -remote = np.array([[(r + 1) % n, i // 2] for i in range(2 * nroots)], dtype=np.int32) -sf = PETSc.SF().create(comm=PETSc.COMM_WORLD) -sf.setGraph(nroots, None, remote) -sf.setUp() -root = np.array([[r, 10 * r + i] for i in range(nroots)], dtype=np.int64) -leaf = np.zeros((2 * nroots, 2), dtype=np.int64) -unit = MPI.INT64_T.Create_contiguous(2); unit.Commit() -sf.bcastBegin(unit, root, leaf, MPI.REPLACE); sf.bcastEnd(unit, root, leaf, MPI.REPLACE) -ok_b = bool((leaf[:, 0] == (r + 1) % n).all() and (leaf[:, 1] == 10 * ((r + 1) % n) + np.arange(2 * nroots) // 2).all()) -# reduce: leaves write their (rank, index) into roots; two leaves per root -> MAX picks the larger index -leaf2 = np.array([[r, i] for i in range(2 * nroots)], dtype=np.int64) -root2 = np.full((nroots, 2), -1, dtype=np.int64) -unit1 = MPI.INT64_T -# reduce needs contiguous arrays; do it column by column -col = root2[:, 1].copy(); src = leaf2[:, 1].copy() -sf.reduceBegin(unit1, src, col, MPI.MAX); sf.reduceEnd(unit1, src, col, MPI.MAX) -ok_r = bool((col == 2 * np.arange(nroots) + 1).all()) -print(f"rank {r}: bcast ok {ok_b}; reduce MAX ok {ok_r} (got {col.tolist()})", flush=True) diff --git a/scripts/sessions/parallel_addon/probe_slots.py b/scripts/sessions/parallel_addon/probe_slots.py deleted file mode 100644 index 27cbbb296..000000000 --- a/scripts/sessions/parallel_addon/probe_slots.py +++ /dev/null @@ -1,19 +0,0 @@ -import numpy as np, underworld3 as uw -from mpi4py import MPI -from underworld3.utilities.fault_split import split_fault -Lx, Ly, H = 3.0, 2.0, 1.0; h, hf, f = 0.2, 0.1, 4 -yf, xa, xb, zb = 1.0, 1.0, 2.0, 0.5; ztop = H - 2 * hf -patch = np.array([[xa, yf, zb], [xb, yf, zb], [xb, yf, ztop], [xa, yf, ztop]]) -fine = uw.meshing.BoxInternalPatch(cellSize=f * h, minCoords=(0, 0, 0), maxCoords=(Lx, Ly, H), patch_points=patch, patch_name="Fault", - patch_cellSize=f * hf, grading_distance=8 * hf, refinement=2, qdegree=3) -mesh = split_fault(fine, "Fault") -tail = mesh._custom_mg_coarse_meshes -if MPI.COMM_WORLD.rank == 0: - print("np", MPI.COMM_WORLD.size, "unsplit slot", getattr(fine, "_refine_slot", None) is not None and fine._refine_slot[1], - "split slot", getattr(mesh, "_refine_slot", None) is not None and mesh._refine_slot[1], - "tail slots", [w._refine_slot[1] for w in tail], "same token split/tail", getattr(mesh, "_refine_slot", (None,))[0] is tail[-1]._refine_slot[0] if getattr(mesh, "_refine_slot", None) else None, flush=True) -rp = getattr(mesh, "_adapt_prolongation", None) -rpf = getattr(fine, "_adapt_prolongation", None) -if MPI.COMM_WORLD.rank == 0: - print("np", MPI.COMM_WORLD.size, "split child _adapt_prolongation:", type(rp).__name__, getattr(rp, "shape", None) if rp is not None else None, - "| unsplit:", type(rpf).__name__, getattr(rpf, "shape", None) if rpf is not None else None, flush=True) diff --git a/scripts/sessions/parallel_addon/probe_split_cost.py b/scripts/sessions/parallel_addon/probe_split_cost.py deleted file mode 100644 index aa118644c..000000000 --- a/scripts/sessions/parallel_addon/probe_split_cost.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Cost of split_fault: wall time and the top own-time entries, 2-D cut and 3-D patch. - -python probe_split_cost.py [dim=3] -""" -import cProfile, io, pstats, sys, time -import numpy as np -import underworld3 as uw -from underworld3.utilities.fault_split import split_fault - -dim = int(sys.argv[1]) if len(sys.argv) > 1 else 3 -if dim == 3: - patch = np.array([[1.0, 1.0, 0.5], [2.0, 1.0, 0.5], [2.0, 1.0, 0.8], [1.0, 1.0, 0.8]]) - mesh = uw.meshing.BoxInternalPatch(cellSize=0.8, minCoords=(0, 0, 0), maxCoords=(3, 2, 1), patch_points=patch, - patch_name="Fault", patch_cellSize=0.4, grading_distance=0.8, refinement=2, qdegree=3) - name = "Fault" -else: - base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.005, qdegree=2) - from underworld3.utilities.fault_split import add_fault - prof0 = cProfile.Profile() - mesh = None -cS, cE = (mesh.dm.getHeightStratum(0) if mesh is not None else (0, 0)) -prof = cProfile.Profile(); t = time.perf_counter(); prof.enable() -if dim == 3: - out = split_fault(mesh, name) -else: - out = base.add_fault([("Flt", np.array([[0.3, 0.45], [0.7, 0.55]]))]) - cS, cE = base.dm.getHeightStratum(0) -prof.disable(); dt = time.perf_counter() - t -print(f"dim {dim}: cells {cE - cS}, split {dt:.2f} s", flush=True) -s = io.StringIO(); pstats.Stats(prof, stream=s).sort_stats("tottime").print_stats(14) -print("\n".join(l for l in s.getvalue().splitlines() if "(" in l)[:4000], flush=True) diff --git a/scripts/sessions/tidal_shell/tidal_berne_fig.py b/scripts/sessions/tidal_shell/tidal_berne_fig.py deleted file mode 100644 index 4a37b1883..000000000 --- a/scripts/sessions/tidal_shell/tidal_berne_fig.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Berne et al. (2024) Fig. 2 analogue: the locked shell's driving tractions on -the stripe planes (shear, normal), the Coulomb traction tau_c = |tau| - mu (T_b - sigma_n) -(sigma_n tension positive; positive tau_c = failure), and the frictional shell's -accumulated lateral slip, all against mean anomaly, for the last cycle; plus the -spin-up parameter Xi(t) = (S(t) - S(t + T)) / S(t) on the mean slip (their Eq. 21). -Usage: """ -import json, os, sys -import numpy as np -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -sim, locked, fric = sys.argv[1:4] -mu, Tb = float(sys.argv[4]), float(sys.argv[5]) -L = json.load(open(os.path.join(sim, locked, "records.json"))) -F = json.load(open(os.path.join(sim, fric, "records.json"))) -tL = np.array([r["t"] for r in L]); tF = np.array([r["t"] for r in F]) -names = sorted(L[0]["drive"].keys()) -last_L = tL > tL.max() - 1.0 + 1e-9 -last_F = tF > tF.max() - 1.0 + 1e-9 -phL = (tL[last_L] - np.floor(tL[last_L] - 1e-9)) * 360.0 -phF = (tF[last_F] - np.floor(tF[last_F] - 1e-9)) * 360.0 -fig, axes = plt.subplots(2, 2, figsize=(13, 8.5)) -tau_all, sn_all, tc_all, s_all = [], [], [], [] -for nm in names: - tau = np.array([r["drive"][nm]["tau"] for r in L])[last_L] - sn = np.array([r["drive"][nm]["sigma_n"] for r in L])[last_L] - tc = np.abs(tau) - mu * (Tb - sn) - axes[0, 0].plot(phL, tau, label=nm); axes[0, 1].plot(phL, sn, label=nm); axes[1, 0].plot(phL, tc, label=nm) - tau_all.append(tau); sn_all.append(sn); tc_all.append(tc) - if nm in F[0]["slip"]: - cum = np.array([r["slip"][nm]["cumulative"] for r in F])[last_F] - s = cum - cum[0] - axes[1, 1].plot(phF, s, label=nm); s_all.append(s) -for ax, arrs in ((axes[0, 0], tau_all), (axes[0, 1], sn_all), (axes[1, 0], tc_all), (axes[1, 1], s_all)): - if arrs: - ax.plot(phL if ax is not axes[1, 1] else phF, np.mean(arrs, axis=0), "k", lw=2.2, label="combined") - ax.axhline(0, color="k", lw=0.6); ax.grid(alpha=0.3); ax.set_xlim(0, 360); ax.set_xlabel("mean anomaly (deg)") -axes[0, 0].set_title("driving shear traction on the stripe planes (locked shell)") -axes[0, 1].set_title("driving normal traction (tension positive)") -axes[1, 0].set_title(f"Coulomb traction |tau| - mu (T_b - sigma_n), mu = {mu}, T_b = {Tb} (> 0: failure)") -axes[1, 1].set_title(f"accumulated lateral slip, frictional shell ({fric})") -axes[0, 0].legend(fontsize=7) -fig.suptitle(f"Berne et al. Fig. 2 analogue: last cycle of {locked} / {fric}", fontsize=11) -fig.tight_layout(); fig.savefig(os.path.join(sim, f"berne_fig2_{fric}.png"), dpi=120) - -# spin-up parameter on the mean slip rate magnitude over the stripes -fig2, ax = plt.subplots(figsize=(7, 4)) -S = np.array([np.mean([abs(r["slip"][nm]["signed_mean"]) for nm in r["slip"]]) for r in F]) -dt = tF[1] - tF[0]; nper = int(round(1.0 / dt)) -xi = (S[:-nper] - S[nper:]) / np.maximum(S[:-nper], 1e-12) -ax.plot(tF[:-nper], xi); ax.set_xscale("log"); ax.axhline(0, color="k", lw=0.6); ax.grid(alpha=0.3) -ax.set_xlabel("time (periods)"); ax.set_ylabel("spin-up parameter Xi"); ax.set_title(f"cycle-to-cycle mismatch of the mean slip rate, {fric}") -fig2.tight_layout(); fig2.savefig(os.path.join(sim, f"berne_spinup_{fric}.png"), dpi=120) -print("net slip over the last cycle per stripe:", {nm: round(float(s[-1]), 5) for nm, s in zip(names, s_all)}) -print("Xi at the last available cycle:", round(float(xi[-1]), 4) if len(xi) else None) diff --git a/scripts/sessions/tidal_shell/tidal_cff.py b/scripts/sessions/tidal_shell/tidal_cff.py deleted file mode 100644 index 00b114c7a..000000000 --- a/scripts/sessions/tidal_shell/tidal_cff.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Coulomb failure stress on receiver planes parallel to the tiger stripes, in the -south polar view, for a run against a reference run at the same step: - - CFF = |tau_s| + mu sigma_n (sigma_n positive in tension: unclamping promotes slip) - dCFF = (tau_s - tau_s_ref) sgn(tau_s_ref) + mu (sigma_n - sigma_n_ref) - -Usage: tidal_cff.py [mu] -> /cff__vs__.png -The receiver plane at a grid point is parallel to the stripes (the local direction -of the stripe family in the azimuthal projection) with its in-plane normal.""" -import glob, os, sys -import numpy as np -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -sim, run, ref = sys.argv[1:4] -mu = float(sys.argv[4]) if len(sys.argv) > 4 else 0.4 -planes = sys.argv[5] if len(sys.argv) > 5 else "stripes" # "stripes" or "optimal" -normalised = len(sys.argv) > 6 and sys.argv[6] == "normalised" # each run scaled by its own rms shear - - -def receiver_frame(d): - """Stripe-parallel tangent and in-plane normal at the polar grid points.""" - rho, psi = d["rho"].ravel(), d["psi"].ravel() - c, e1, e2 = d["centre"], d["e1"], d["e2"] - a, b = rho * np.cos(psi), rho * np.sin(psi) - eps = 1e-5 - def pmap(a, b): - r = np.maximum(np.hypot(a, b), 1e-30) - return np.cos(r)[:, None] * c[None] + (np.sin(r) / r)[:, None] * (a[:, None] * e1[None] + b[:, None] * e2[None]) - p = pmap(a, b) - t = (pmap(a + eps, b) - pmap(a - eps, b)) / (2 * eps) # along the stripes - t -= (t * p).sum(1)[:, None] * p - t /= np.maximum(np.linalg.norm(t, axis=1), 1e-30)[:, None] - n = np.cross(p, t) # in-plane normal - return p, t, n - - -def optimal(S, t, n): - """Coulomb stress maximised over all in-plane orientations (Mohr circle): - CFF_max = R sqrt(1 + mu^2) + mu s_mean, with R = (s1 - s3)/2, on the planes at - angle arctan(1/mu)/2 from the s1 direction. Returns CFF_max and the angle of the - weakest plane's normal from the stripe-normal direction (degrees).""" - s_tt = np.einsum("ni,nij,nj->n", t, S, t) - s_nn = np.einsum("ni,nij,nj->n", n, S, n) - s_tn = 0.5 * (np.einsum("ni,nij,nj->n", t, S, n) + np.einsum("ni,nij,nj->n", n, S, t)) - mean = 0.5 * (s_tt + s_nn) - R = np.sqrt((0.5 * (s_tt - s_nn)) ** 2 + s_tn ** 2) - cff = R * np.sqrt(1 + mu ** 2) + mu * mean - theta1 = 0.5 * np.arctan2(2 * s_tn, s_tt - s_nn) # s1 direction from t - beta = 0.5 * np.arctan(1.0 / mu) # weakest planes at +-beta from s1 - ang = np.degrees(theta1 + beta) - ang = (ang + 90) % 180 - 90 - return cff, ang - - -def resolve(S, t, n): - tau = np.einsum("ni,nij,nj->n", t, S, n) - sn = np.einsum("ni,nij,nj->n", n, S, n) - return tau, sn - - -files = sorted(glob.glob(os.path.join(sim, run, "polar_*.npz"))) -for f in files: - step = os.path.basename(f)[6:10] - g = os.path.join(sim, ref, f"polar_{step}.npz") - if not os.path.exists(g): - continue - A, B = np.load(f), np.load(g) - p, t, n = receiver_frame(A) - SA, SB = A["S"], B["S"] - if normalised: - # the rheology comparison: scale each run's stress by its own rms shear on the - # receiver planes so the maps compare pattern and phase, not amplitude - _ta, _ = resolve(SA, t, n); _tb, _ = resolve(SB, t, n) - SA = SA / (np.sqrt(np.mean(_ta ** 2)) + 1e-30); SB = SB / (np.sqrt(np.mean(_tb ** 2)) + 1e-30) - if planes == "optimal": - cff_a, ang_a = optimal(SA, t, n) - cff_b, ang_b = optimal(SB, t, n) - dcff = cff_a - cff_b - else: - tau_a, sn_a = resolve(SA, t, n) - tau_b, sn_b = resolve(SB, t, n) - cff_a = np.abs(tau_a) + mu * sn_a - cff_b = np.abs(tau_b) + mu * sn_b - dcff = (tau_a - tau_b) * np.sign(tau_b + 1e-30) + mu * (sn_a - sn_b) - shape = A["rho"].shape - X, Y = A["rho"] * np.cos(A["psi"]), A["rho"] * np.sin(A["psi"]) - faults = A["faults"] if A["faults"].size else B["faults"] - ncol = 4 if planes == "optimal" else 3 - fig, axes = plt.subplots(1, ncol, figsize=(5.6 * ncol, 5.4)) - lim = max(np.abs(cff_a).max(), np.abs(cff_b).max()); dlim = np.abs(dcff).max() + 1e-30 - label = "CFF on the weakest plane" if planes == "optimal" else "CFF on stripe-parallel planes" - panels = [(axes[0], cff_a.reshape(shape), f"{label}: {run}", -lim, lim, "RdBu_r"), - (axes[1], cff_b.reshape(shape), f"{label}: {ref} (reference)", -lim, lim, "RdBu_r"), - (axes[2], dcff.reshape(shape), f"dCFF {run} - {ref}", -dlim, dlim, "RdBu_r")] - if planes == "optimal": - panels.append((axes[3], ang_a.reshape(shape), f"weakest-plane normal from the stripe normal (deg): {run}", -90, 90, "twilight")) - for ax, fld, ttl, vmin, vmax, cmap in panels: - pc = ax.pcolormesh(X, Y, fld, cmap=cmap, vmin=vmin, vmax=vmax, shading="auto") - for fp in faults: - ax.plot(fp[:, 0], fp[:, 1], "k", lw=2) - for r in (0.25, 0.5, 0.75): - ax.add_patch(plt.Circle((0, 0), r, fill=False, color="gray", lw=0.5, alpha=0.6)) - ax.set_aspect("equal"); ax.set_xlim(-0.9, 0.9); ax.set_ylim(-0.9, 0.9); ax.set_xticks([]); ax.set_yticks([]) - ax.set_title(ttl, fontsize=10, loc="left"); fig.colorbar(pc, ax=ax, shrink=0.8) - fig.suptitle(f"Coulomb failure stress ({'weakest plane at each point' if planes == 'optimal' else 'stripe-parallel receiver planes'}), " - f"mu = {mu}{', each run normalised by its rms shear' if normalised else ''}: step {int(step)}, t = {float(A['t']):.3f} periods", fontsize=10) - out = os.path.join(sim, f"cff{'opt' if planes == 'optimal' else ''}{'norm' if normalised else ''}_{run}_vs_{ref}_{step}.png") - fig.savefig(out, dpi=110, bbox_inches="tight"); plt.close(fig) - print("wrote", out, "dCFF range", f"{dcff.min():.4f} {dcff.max():.4f}") diff --git a/scripts/sessions/tidal_shell/tidal_cycle_plot.py b/scripts/sessions/tidal_shell/tidal_cycle_plot.py deleted file mode 100644 index 061758e20..000000000 --- a/scripts/sessions/tidal_shell/tidal_cycle_plot.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Slip cycle of each stripe against orbital phase, cycle by cycle (the -asymmetry that develops under cyclic loading): signed slip rate along the -stripe, cumulative slip, and the normal traction (clamping) on the stripe. -Usage: [ ...] -> /cycle_.png and a per-cycle table.""" -import json, os, sys -import numpy as np -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -sim = sys.argv[1] -for run in sys.argv[2:]: - rs = json.load(open(os.path.join(sim, run, "records.json"))) - t = np.array([r["t"] for r in rs]) - names = sorted(rs[0]["slip"].keys()) - ncyc = int(np.ceil(t.max() - 1e-9)) - fig, axes = plt.subplots(len(names), 3, figsize=(16, 3.0 * len(names)), squeeze=False) - print(f"{run}: net slip per cycle (signed, along the stripe) and clamping range") - for i, nm in enumerate(names): - rate = np.array([r["slip"][nm]["signed_mean"] for r in rs]) - cum = np.array([r["slip"][nm]["cumulative"] for r in rs]) - sn = np.array([r["slip"][nm].get("sigma_n_mean", np.nan) for r in rs]) - for c in range(ncyc): - m = (t > c) & (t <= c + 1 + 1e-9) - ph = (t[m] - c) * 360.0 - axes[i, 0].plot(ph, rate[m], label=f"cycle {c + 1}") - axes[i, 1].plot(ph, cum[m] - (cum[m][0] - rate[m][0] * (t[m][0] - c)), label=f"cycle {c + 1}") - axes[i, 2].plot(ph, sn[m], label=f"cycle {c + 1}") - net = cum[m][-1] - (cum[m][0] - rate[m][0] * (t[1] - t[0])) - print(f" {nm} cycle {c + 1}: net slip {net:+.5f}, |rate| max {np.abs(rate[m]).max():.4f}, " - f"sigma_n mean range {np.nanmin(sn[m]):+.4f} .. {np.nanmax(sn[m]):+.4f}") - axes[i, 0].set_ylabel(nm); axes[i, 0].axhline(0, color="k", lw=0.5) - axes[i, 1].axhline(0, color="k", lw=0.5); axes[i, 2].axhline(0, color="k", lw=0.5) - for ax in axes[i]: - ax.grid(alpha=0.3); ax.set_xlim(0, 360) - axes[0, 0].set_title("signed slip rate along the stripe"); axes[0, 1].set_title("slip within the cycle (from the cycle start)") - axes[0, 2].set_title("normal traction on the stripe (negative = clamped)") - for ax in axes[-1]: ax.set_xlabel("orbital phase (deg)") - axes[0, 0].legend(fontsize=7) - fig.suptitle(run, fontsize=11); fig.tight_layout() - fig.savefig(os.path.join(sim, f"cycle_{run}.png"), dpi=110); plt.close(fig) diff --git a/scripts/sessions/tidal_shell/tidal_farfield.py b/scripts/sessions/tidal_shell/tidal_farfield.py deleted file mode 100644 index 979c6deba..000000000 --- a/scripts/sessions/tidal_shell/tidal_farfield.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Net tangential displacement of the shell over one orbit: the far-field -permanent deformation the stripes leave. Reads disp_*.npz (cumulative -displacement at cycle boundaries) of a faulted and an unfaulted run, takes -the last orbit's increment of each, and maps faulted minus unfaulted on the -Mollweide and in the polar view, with the stripes' own slip for scale. -Usage: """ -import glob, json, os, sys -import numpy as np -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -sim, fr, ur = sys.argv[1:4] - - -def last_orbit(run): - files = sorted(glob.glob(os.path.join(sim, run, "disp_*.npz"))) - a, b = np.load(files[-2]), np.load(files[-1]) - return b, b["disp_global"] - a["disp_global"], b["disp_polar"] - a["disp_polar"], float(b["t"]) - float(a["t"]) - - -F, dgF, dpF, TF = last_orbit(fr) -U, dgU, dpU, TU = last_orbit(ur) -LON, LAT = F["lon"], F["lat"]; grid = F["grid"] -net_g = dgF - dgU; net_p = dpF - dpU -mag_g = np.linalg.norm(net_g, axis=1).reshape(LAT.shape) -mag_p = np.linalg.norm(net_p, axis=1).reshape(F["rho"].shape) -# the stripes' own net slip for scale -rs = json.load(open(os.path.join(sim, fr, "records.json"))) -t = np.array([r["t"] for r in rs]); last = t > t.max() - 1 + 1e-9 -names = sorted(rs[0]["slip"].keys()) -# the orbit's net slip is the cumulative at its end minus the cumulative at the end -# of the previous orbit (the record BEFORE the first step of the last cycle) -i0 = int(np.argmax(last)) -net_slip = np.mean([rs[-1]["slip"][n]["cumulative"] - rs[i0 - 1]["slip"][n]["cumulative"] for n in names]) -# the unfaulted shell's own residual (numerical): the noise floor -floor = np.linalg.norm(dgU, axis=1) -print(f"orbit length {TF:.3f}; stripe-mean net slip over the orbit {net_slip:+.5f}") -print(f"far-field net displacement |faulted - unfaulted|: max {mag_g.max():.5f}, median {np.median(mag_g):.5f}; " - f"unfaulted residual max {floor.max():.6f} (noise floor)") -# where it lives: fraction of the global rms within 0.5 rad of the stripe set's centre -c = F["centre"]; ang = np.degrees(np.arccos(np.clip(grid @ c, -1, 1))) -w = np.cos(LAT).ravel() -for rad in (15, 30, 60, 90): - m = ang < rad - print(f" within {rad:3d} deg of the stripes: rms {np.sqrt((w[m]*np.linalg.norm(net_g[m],axis=1)**2).sum()/w[m].sum()):.5f}; " - f"beyond: rms {np.sqrt((w[~m]*np.linalg.norm(net_g[~m],axis=1)**2).sum()/max(w[~m].sum(),1e-30)):.5f}") - -fig = plt.figure(figsize=(18, 5.2)) -ax = fig.add_subplot(1, 2, 1, projection="mollweide") -pc = ax.pcolormesh(LON, LAT, mag_g, cmap="magma", shading="auto") -ax.set_title(f"net tangential displacement over one orbit, faulted minus unfaulted (stripe net slip {net_slip:+.4f})", fontsize=9, loc="left") -ax.grid(alpha=0.3); fig.colorbar(pc, ax=ax, shrink=0.6, orientation="horizontal", pad=0.05) -ax = fig.add_subplot(1, 2, 2) -X, Y = F["rho"] * np.cos(F["psi"]), F["rho"] * np.sin(F["psi"]) -pc = ax.pcolormesh(X, Y, mag_p, cmap="magma", shading="auto") -# arrows in the polar projection: the displacement's components along e1, e2 -e1, e2 = F["e1"], F["e2"] -pg = F["pgrid"] -rho = F["rho"].ravel(); psi = F["psi"].ravel() -d_ = pg - (pg @ c)[:, None] * c; d_ /= np.maximum(np.linalg.norm(d_, axis=1), 1e-30)[:, None] -# local radial (away from the centre) and azimuthal unit vectors on the sphere -e_r = np.cos(rho)[:, None] * d_ - np.sin(rho)[:, None] * c[None] -e_a = np.cross(pg, e_r) -ur_, ua_ = (net_p * e_r).sum(1), (net_p * e_a).sum(1) -ex = ur_ * np.cos(psi) - ua_ * np.sin(psi); ey = ur_ * np.sin(psi) + ua_ * np.cos(psi) -sk = (slice(None, None, 6), slice(None, None, 4)) -ax.quiver(X[sk], Y[sk], ex.reshape(X.shape)[sk], ey.reshape(X.shape)[sk], color="w", scale=mag_p.max() * 25, width=0.003) -for fp in F["faults"]: - ax.plot(fp[:, 0], fp[:, 1], color="cyan", lw=2) -ax.set_aspect("equal"); ax.set_xlim(-0.9, 0.9); ax.set_ylim(-0.9, 0.9); ax.set_xticks([]); ax.set_yticks([]) -ax.set_title("polar view: magnitude and direction of the net displacement", fontsize=9, loc="left") -fig.colorbar(pc, ax=ax, shrink=0.8) -out = os.path.join(sim, f"farfield_{fr}_vs_{ur}.png") -fig.savefig(out, dpi=120, bbox_inches="tight"); print("wrote", out) diff --git a/scripts/sessions/tidal_shell/tidal_farfield_strain.py b/scripts/sessions/tidal_shell/tidal_farfield_strain.py deleted file mode 100644 index bd4b7be34..000000000 --- a/scripts/sessions/tidal_shell/tidal_farfield_strain.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Surface strain of the net displacement per orbit (faulted minus unfaulted): -dilatation and maximum shear on the polar azimuthal grid (geodesic polar -coordinates about the stripe set's centre, unit sphere), masked near the centre. -Reports the rms strain by distance from the set against the tidal strain scale A. -Usage: [A]""" -import glob, os, sys -import numpy as np -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -sim, fr, ur = sys.argv[1:4] -A = float(sys.argv[4]) if len(sys.argv) > 4 else 0.01 - - -def last_orbit(run): - f = sorted(glob.glob(os.path.join(sim, run, "disp_*.npz"))) - a, b = np.load(f[-2]), np.load(f[-1]) - return b, b["disp_polar"] - a["disp_polar"] - - -F, dF = last_orbit(fr); U, dU = last_orbit(ur) -net = dF - dU -rho, psi = F["rho"], F["psi"] # (npsi, nrho) grids -pg = F["pgrid"]; c = F["centre"] -r_ = rho.ravel() -d_ = pg - (pg @ c)[:, None] * c; d_ /= np.maximum(np.linalg.norm(d_, axis=1), 1e-30)[:, None] -e_r = np.cos(r_)[:, None] * d_ - np.sin(r_)[:, None] * c[None] # geodesic radial direction -e_p = np.cross(pg, e_r) # azimuthal - - -def strain_of(disp): - u_r = (disp * e_r).sum(1).reshape(rho.shape); u_p = (disp * e_p).sum(1).reshape(rho.shape) - r1 = rho[0]; p1 = psi[:, 0] - du_r_dr = np.gradient(u_r, r1, axis=1); du_p_dr = np.gradient(u_p, r1, axis=1) - du_r_dp = np.gradient(u_r, p1, axis=0); du_p_dp = np.gradient(u_p, p1, axis=0) - sr, cr = np.sin(rho), np.cos(rho) - e_rr = du_r_dr - e_pp = du_p_dp / sr + u_r * cr / sr - e_rp = 0.5 * (du_r_dp / sr + du_p_dr - u_p * cr / sr) - return e_rr + e_pp, np.sqrt((0.5 * (e_rr - e_pp)) ** 2 + e_rp ** 2) - - -dil, shear = strain_of(net) -# the noise floor of this finite-difference measure: the same strain of the UNFAULTED -# shell's own residual over the orbit (a periodic shell has none; what remains is the -# grid interpolation of a P2 field, differenced) -dil_n, shear_n = strain_of(dU) -mask = rho < 0.03 -dil = np.where(mask, np.nan, dil); shear = np.where(mask, np.nan, shear) -# the stripes' own offsets are displacement discontinuities, so the finite-difference -# strain inside the stripe block (corners at ~0.33 rad) is slip over grid spacing, not -# a far-field quantity: the colour scale is set from the field beyond 0.35 rad -far = rho > 0.35 -lim_d = np.nanpercentile(np.abs(dil[far]), 99); lim_s = np.nanpercentile(shear[far], 99) -X, Y = rho * np.cos(psi), rho * np.sin(psi) -fig, axes = plt.subplots(1, 2, figsize=(13, 5.6)) -for ax, fld, ttl, cmap, sym in ((axes[0], dil, "dilatation of the net displacement per orbit", "RdBu_r", True), - (axes[1], shear, "maximum shear strain of the net displacement per orbit", "magma", False)): - lim = lim_d if sym else lim_s; kw = {"vmin": -lim, "vmax": lim} if sym else {"vmin": 0, "vmax": lim} - pc = ax.pcolormesh(X, Y, fld, cmap=cmap, shading="auto", **kw) - for fp in F["faults"]: - ax.plot(fp[:, 0], fp[:, 1], color="cyan" if not sym else "k", lw=2) - for rr in (0.25, 0.5, 0.75): - ax.add_patch(plt.Circle((0, 0), rr, fill=False, color="gray", lw=0.5, alpha=0.6)) - ax.set_aspect("equal"); ax.set_xlim(-0.9, 0.9); ax.set_ylim(-0.9, 0.9); ax.set_xticks([]); ax.set_yticks([]) - ax.set_title(ttl, fontsize=9, loc="left"); fig.colorbar(pc, ax=ax, shrink=0.8) -fig.suptitle(f"{fr} minus {ur}: far-field strain per orbit (polar view, tidal strain scale A = {A})", fontsize=10) -out = os.path.join(sim, f"farfield_strain_{fr}_vs_{ur}.png"); fig.savefig(out, dpi=120, bbox_inches="tight"); print("wrote", out) -w = np.sin(rho) -print(" (inside 0.35 rad the strain is the stripes' own offsets over the grid spacing; far-field bands only)") -for lo, hi in ((0.35, 0.5), (0.5, 0.7), (0.7, 0.9)): - m = (rho >= lo) & (rho < hi) - rs = lambda f: np.sqrt((w[m] * f[m] ** 2).sum() / w[m].sum()) - print(f" {np.degrees(lo):3.0f}-{np.degrees(hi):3.0f} deg from the set: rms shear strain {rs(shear):.2e} ({rs(shear)/A:.3f} A), " - f"rms dilatation {rs(dil):.2e}; NOISE FLOOR (unfaulted residual) shear {rs(shear_n):.2e}, dilatation {rs(dil_n):.2e}") diff --git a/scripts/sessions/tidal_shell/tidal_fault_compare.py b/scripts/sessions/tidal_shell/tidal_fault_compare.py deleted file mode 100644 index fdf771b80..000000000 --- a/scripts/sessions/tidal_shell/tidal_fault_compare.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Faulted against unfaulted shell: the global velocity and stress diagnostics -over the cycle, and the slip on each stripe. Usage: """ -import json, os, sys -import numpy as np -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -sim, fr, rr = sys.argv[1:4] -F = json.load(open(os.path.join(sim, fr, "records.json"))) -R = json.load(open(os.path.join(sim, rr, "records.json"))) -n = min(len(F), len(R)); F, R = F[:n], R[:n] -t = np.array([r["t"] for r in F]) -fig, axes = plt.subplots(1, 3, figsize=(17, 4.6)) -for key, ax, ttl in (("vt_rms", axes[0], "rms tangential speed"), ("stress_inv_rms", axes[1], "rms in-plane stress invariant")): - ax.plot(t, [r[key] for r in R], "k", label="unfaulted") - ax.plot(t, [r[key] for r in F], "r", label="4 stripes") - ax.set_title(ttl); ax.set_xlabel("t (periods)"); ax.grid(alpha=0.3); ax.legend(fontsize=8) -names = sorted(F[0]["slip"].keys()) -for nm in names: - axes[2].plot(t, [r["slip"][nm]["max"] for r in F], label=f"{nm} max") -axes[2].set_title("slip on the stripes (max over the trace)"); axes[2].set_xlabel("t (periods)"); axes[2].grid(alpha=0.3); axes[2].legend(fontsize=7) -fig.tight_layout(); fig.savefig(os.path.join(sim, f"{fr}_vs_{rr}.png"), dpi=120) -last = t > t.max() - 1.0 + 1e-9 -def rms(key, rs): return float(np.sqrt(np.mean([r[key] ** 2 for r, m in zip(rs, last) if m]))) -print(f"{fr} vs {rr} over the last period:") -print(f" rms tangential speed faulted {rms('vt_rms', F):.5f} unfaulted {rms('vt_rms', R):.5f} ratio {rms('vt_rms', F)/rms('vt_rms', R):.4f}") -print(f" rms stress invariant faulted {rms('stress_inv_rms', F):.5f} unfaulted {rms('stress_inv_rms', R):.5f} ratio {rms('stress_inv_rms', F)/rms('stress_inv_rms', R):.4f}") -print(f" membrane rel err faulted {rms('rel_err_membrane', F):.4f} unfaulted {rms('rel_err_membrane', R):.4f}") -for nm in names: - v = [r["slip"][nm]["max"] for r, m in zip(F, last) if m] - print(f" {nm}: max slip over the cycle {max(v):.4f}, rms {np.sqrt(np.mean(np.square(v))):.4f}") diff --git a/scripts/sessions/tidal_shell/tidal_mesh_fig.py b/scripts/sessions/tidal_shell/tidal_mesh_fig.py deleted file mode 100644 index b39a544fd..000000000 --- a/scripts/sessions/tidal_shell/tidal_mesh_fig.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Render the spherical sheet mesh used in the tidal study, with the four tiger stripes. - -Usage: python tidal_mesh_fig.py [h] (h defaults to 0.08, the production cell size) - -Two panels: the whole sphere viewed from the south-polar side with the sub-planet -point (+x) marked, and a close-up of the stripe set. The mesh is the split mesh the -driver uses (SphericalManifold + add_fault with the driver's default geometry); -the stripes are drawn as the great-circle arcs passed to the cut. -""" -import os -import sys - -import numpy as np -import pyvista as pv - -pv.OFF_SCREEN = True -import underworld3 as uw -import underworld3.visualisation as vis - -sim = sys.argv[1] -h = float(sys.argv[2]) if len(sys.argv) > 2 else 0.08 -os.makedirs(sim, exist_ok=True) - -# the driver's default stripe geometry (tidal_shell.py: --faults 4 --fault_len 0.52 -# --fault_spacing 0.14 --fault_lat -90 --fault_lon 0 --fault_azimuth -90) -n_faults, fault_len, spacing, lat0, lon0, azim = 4, 0.52, 0.14, -90.0, 0.0, -90.0 - - -def stripe_points(offset, half_len, n=9): - th0, ph0 = np.radians(lat0), np.radians(lon0) - c = np.array([np.cos(th0) * np.cos(ph0), np.cos(th0) * np.sin(ph0), np.sin(th0)]) - east = np.array([-np.sin(ph0), np.cos(ph0), 0.0]) - sub = np.cross(c, east) - az = np.radians(azim) - e1 = np.cos(az) * sub + np.sin(az) * np.cross(c, sub) - e2 = np.cross(c, e1) - cp = np.cos(offset) * c + np.sin(offset) * e2 - a = np.linspace(-half_len, half_len, n) - return np.array([np.cos(ai) * cp + np.sin(ai) * e1 for ai in a]) - - -sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=h, degree=1, qdegree=5) -offsets = (np.arange(n_faults) - 0.5 * (n_faults - 1)) * spacing -faults = [(f"Stripe{i}", stripe_points(off, 0.5 * fault_len)) for i, off in enumerate(offsets)] -split = sphere.add_fault(faults) - -pv_mesh = vis.mesh_to_pv_mesh(split) -edges = pv_mesh.extract_all_edges() -n_cells = pv_mesh.n_cells -n_pts = pv_mesh.n_points - -# the realised cut: the facets carrying each stripe's label (the cut snaps existing -# mesh nodes onto the requested arc, so these are mesh edges; no refinement) -dm = split.dm -fS, fE = dm.getHeightStratum(1) -pS, pE = dm.getDepthStratum(0) -X = split.X.coords -lines = [] -n_pairs = {} -for name, _ in faults: - lab = dm.getLabel(name) - segs = [] - for v in lab.getValueIS().getIndices(): - iss = dm.getStratumIS(name, v) - if iss is None: - continue - for p in iss.getIndices(): - if fS <= p < fE: - segs.append(dm.getCone(p) - pS) - segs = np.array(segs) - pts = X[segs.reshape(-1)] * 1.002 - cells = np.column_stack([np.full(len(segs), 2), np.arange(0, 2 * len(segs), 2), np.arange(1, 2 * len(segs), 2)]) - lines.append(pv.PolyData(pts, lines=cells.reshape(-1))) - n_pairs[name] = len(split._fault_point_pairs[name]) -subplanet = pv.PolyData(np.array([[1.0, 0.0, 0.0]])) -south = pv.PolyData(np.array([[0.0, 0.0, -1.0]])) - -pl = pv.Plotter(off_screen=True, shape=(1, 2), window_size=(2000, 1000), border=False) -for k in range(2): - pl.subplot(0, k) - pl.set_background("white") - pl.add_mesh(pv_mesh, color="#f2f2f2", show_edges=False, lighting=False) - pl.add_mesh(edges, color="#404040", line_width=0.8 if k == 0 else 1.4, lighting=False) - for blk in lines: - pl.add_mesh(blk, color="#c0392b", line_width=4 if k == 0 else 6, lighting=False, render_lines_as_tubes=True) - pl.add_mesh(subplanet, color="#1f77b4", point_size=18, render_points_as_spheres=True, lighting=False) - pl.add_mesh(south, color="#c0392b", point_size=10, render_points_as_spheres=True, lighting=False) - pl.camera.parallel_projection = True - if k == 0: - # oblique: sub-planet point at the right, south pole toward the viewer - pl.camera.position = (2.0, -1.5, -3.0) - pl.camera.focal_point = (0.0, 0.0, 0.0) - pl.camera.up = (1.0, 0.0, 0.0) - pl.camera.parallel_scale = 1.15 - pl.add_point_labels(subplanet, ["sub-planet point (+x)"], font_size=22, text_color="#1f77b4", - point_color="#1f77b4", point_size=1, shape=None, always_visible=True) - pl.add_text(f"SphericalManifold, h = {h}: {n_cells} triangles, {n_pts} nodes (split)", - position="upper_left", font_size=14, color="black") - else: - # polar close-up: looking up at the south pole, +x (sub-planet direction) to the top - pl.camera.position = (0.0, 0.0, -4.0) - pl.camera.focal_point = (0.0, 0.0, -1.0) - pl.camera.up = (1.0, 0.0, 0.0) - pl.camera.parallel_scale = 0.45 - pl.add_text("South polar region: four stripes, length 0.52, spacing 0.14 rad\n" - "(mesh nodes snapped onto the arcs and duplicated, no local refinement; +x toward the top)", - position="upper_left", font_size=14, color="black") - -out = os.path.join(sim, f"mesh_h{h:g}.png") -pl.screenshot(out) -pl.close() -print("wrote", out, "cells", n_cells, "points", n_pts, "unsplit points", sphere.X.coords.shape[0], "pairs", n_pairs) diff --git a/scripts/sessions/tidal_shell/tidal_mu_overlay.py b/scripts/sessions/tidal_shell/tidal_mu_overlay.py deleted file mode 100644 index 7249421a0..000000000 --- a/scripts/sessions/tidal_shell/tidal_mu_overlay.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Accumulated lateral slip (mean over the stripes) through the last cycle for -several friction coefficients, and the net slip per orbit against mu. -Usage: [ ...]""" -import json, os, sys -import numpy as np -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -sim = sys.argv[1] -fig, axes = plt.subplots(1, 3, figsize=(17, 4.8)) -mus, nets, peaks = [], [], [] -for arg in sys.argv[2:]: - run, mu = arg.split(":") - try: - mu = float(mu) - except ValueError: - pass - rs = json.load(open(os.path.join(sim, run, "records.json"))) - t = np.array([r["t"] for r in rs]); last = t > t.max() - 1.0 + 1e-9 - names = sorted(rs[0]["slip"].keys()) - cum = np.mean([[r["slip"][nm]["cumulative"] for r in rs] for nm in names], axis=0)[last] - rate = np.mean([[r["slip"][nm]["signed_mean"] for r in rs] for nm in names], axis=0)[last] - ph = (t[last] - np.floor(t[last] - 1e-9)) * 360.0 - axes[0].plot(ph, cum - cum[0], label=f"{mu}" if isinstance(mu, str) else f"mu = {mu}") - axes[1].plot(ph, rate, label=f"{mu}" if isinstance(mu, str) else f"mu = {mu}") - mus.append(mu); nets.append(cum[-1] - cum[0]); peaks.append(np.abs(rate).max()) -axes[0].set_title("accumulated lateral slip through the last cycle (mean over the stripes)") -axes[1].set_title("signed slip rate (mean over the stripes)") -for ax in axes[:2]: - ax.axhline(0, color="k", lw=0.6); ax.grid(alpha=0.3); ax.set_xlim(0, 360); ax.set_xlabel("mean anomaly (deg)"); ax.legend(fontsize=8) -xs = mus if all(not isinstance(m, str) for m in mus) else np.arange(len(mus)) -axes[2].plot(xs, nets, "o-", label="net slip per orbit"); axes[2].plot(xs, peaks, "s--", label="peak slip rate") -if xs is not mus: axes[2].set_xticks(xs); axes[2].set_xticklabels(mus, rotation=20, fontsize=8) -axes[2].set_xlabel("friction coefficient mu"); axes[2].grid(alpha=0.3); axes[2].legend(fontsize=8); axes[2].set_title("asymmetry (net slip per orbit) against friction") -out = sys.argv[1 + len(sys.argv[2:]) + 1] if False else os.environ.get("OVERLAY_OUT", "berne_mu_overlay.png") -fig.tight_layout(); fig.savefig(os.path.join(sim, out), dpi=120) -print({m: (round(n, 5), round(p, 4)) for m, n, p in zip(mus, nets, peaks)}) diff --git a/scripts/sessions/tidal_shell/tidal_shell.py b/scripts/sessions/tidal_shell/tidal_shell.py deleted file mode 100644 index 0da586ff9..000000000 --- a/scripts/sessions/tidal_shell/tidal_shell.py +++ /dev/null @@ -1,491 +0,0 @@ -"""Global tidal response of a thin (visco)elastic shell on a sphere. - -The shell floats on an ocean: no basal drag, and its radial motion is the tide's, -w = d/dt [h2 V / g], prescribed as the normal velocity of the sheet (the datum of -the tangency constraint). The sheet solves for the tangential velocity under the -plane-stress membrane closure; the strain rate carries the curvature term w/R P. - -Non-dimensional: R = 1, orbital period 1, tidal radial amplitude A (the h2 V/g -scale), shear modulus 1, Maxwell time De (= eta / mu) in periods. Eccentricity -tide, synchronous rotation, degree 2 (Wahr et al. 2009 pattern): - - W(x, t) = A [ -3/2 P20 cos(nt) + 3/4 ( 3 (x^2 - y^2) cos(nt) + 8 x y sin(nt) ) ], - P20 = (3 z^2 - 1) / 2, n = 2 pi, - -with the sub-planet point on +x and the spin axis +z. Closed form for the viscous -limit on a uniform membrane: a degree-2 radial pattern W Y2 gives the tangential -velocity (3/11) W grad_s Y2, which is checked every step (--check). - -Parallel-safe: every diagnostic is a collective reduction; the maps are a -collective evaluation on a longitude-latitude grid drawn on rank 0. -""" -import argparse -import json -import os -import time - -import numpy as np -import sympy - -import underworld3 as uw -from underworld3.utilities.rotated_bc import SHEET_TANGENCY - -ap = argparse.ArgumentParser() -ap.add_argument("--out", required=True) -ap.add_argument("--h", type=float, default=0.15, help="cell size on the unit sphere") -ap.add_argument("--A", type=float, default=0.01, help="radial tidal amplitude (h2 V/g scale)") -ap.add_argument("--De", type=float, default=0.0, help="Maxwell time in periods; 0: viscous sheet") -ap.add_argument("--eta", type=float, default=1.0, help="viscosity of the viscous sheet (De = 0)") -ap.add_argument("--L", type=float, default=0.08, help="shell thickness / R (Enceladus ~ 20/252)") -ap.add_argument("--periods", type=float, default=2.0) -ap.add_argument("--steps_per_period", type=int, default=32) -ap.add_argument("--tol", type=float, default=1e-8) -ap.add_argument("--objective", default="none", help="objective rate of the Maxwell history: none (tides rotate negligibly per step) or jaumann") -ap.add_argument("--check", type=int, default=1, help="compare with the membrane closed form each step") -ap.add_argument("--fig_every", type=int, default=8) -ap.add_argument("--faults", type=int, default=0, help="number of parallel tiger-stripe faults near the south pole") -ap.add_argument("--fault_len", type=float, default=0.52, help="fault length (rad; Enceladus ~130 km on R 252 km)") -ap.add_argument("--fault_spacing", type=float, default=0.14, help="spacing between the stripes (rad; ~35 km)") -ap.add_argument("--fault_eta", type=float, default=0.0, help="viscous interface law coefficient (0: frictionless)") -ap.add_argument("--fault_law", default="frictionless", choices=["frictionless", "viscous", "coulomb"], - help="coulomb: regularised Coulomb friction with the strength fed by the no-opening reaction") -ap.add_argument("--fault_mu", type=float, default=0.4, help="Coulomb friction coefficient") -ap.add_argument("--fault_V0", type=float, default=1e-3, help="Coulomb regularisation slip rate (well below the tidal slip rates)") -ap.add_argument("--fault_Tb", type=float, default=0.0, help="Coulomb: constant effective overburden as a STRESS (compression positive); enters the law as L * T_b") -ap.add_argument("--probe_stripes", type=int, default=0, help="unfaulted run: record the driving tractions on N virtual stripe traces (Berne et al. Fig 2a,b)") -ap.add_argument("--fault_lat", type=float, default=-90.0, help="latitude of the stripe set's centre (deg)") -ap.add_argument("--fault_lon", type=float, default=0.0, help="longitude of the stripe set's centre (deg; 0 = sub-planet meridian)") -ap.add_argument("--accumulate_displacement", type=int, default=0, help="integrate the tangential velocity on the global and polar grids every step; save the cumulative displacement at figure steps (far-field net deformation per orbit)") -ap.add_argument("--save_global_stress", type=int, default=0, help="save the 3x3 stress on the lat-lon grid every step (npz), for the site/orientation analysis") -ap.add_argument("--fault_azimuth", type=float, default=-90.0, help="stripe direction: angle from the sub-planet direction at the set's centre, counter-clockwise viewed from outside (deg); -90 = east-west") -ap.add_argument("--verbose", action="store_true") -args = ap.parse_args() - -comm = uw.mpi.comm -rank0 = uw.mpi.rank == 0 -os.makedirs(args.out, exist_ok=True) - - -def log(*a): - if rank0: - print(*a, flush=True) - - -# ----------------------------------------------------------------------------- mesh and sheet -sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=args.h, degree=1, qdegree=5) - - -def stripe_points(offset, half_len, n=9, lat0=-90.0): - """A stripe as a polyline on the unit sphere: the exponential map at the set's - centre (latitude lat0 on the x-z meridian), the stripe along the local e1 - direction at a distance `offset` along e2.""" - th0, ph0 = np.radians(lat0), np.radians(args.fault_lon) - c = np.array([np.cos(th0) * np.cos(ph0), np.cos(th0) * np.sin(ph0), np.sin(th0)]) # centre - east = np.array([-np.sin(ph0), np.cos(ph0), 0.0]) - sub = np.cross(c, east) # toward the sub-planet point (+x) along the surface - az = np.radians(args.fault_azimuth) - e1 = np.cos(az) * sub + np.sin(az) * np.cross(c, sub) # stripe direction - e2 = np.cross(c, e1) # across the stripes - # each stripe is ONE great-circle arc: through the point at `offset` along e2 - # from the centre, in the direction e1 (which is tangent there too). A chain of - # arcs between points of the exponential-map locus has a kink at every vertex, - # and the cut lost part of such a stripe silently (measured: the outer stripe - # of the south-polar set kept 11 of 16 facets at 9 points, 8 at a 0.005 shift - # of the offset, and fragmented at 13 points). - cp = np.cos(offset) * c + np.sin(offset) * e2 - a = np.linspace(-half_len, half_len, n) - return np.array([np.cos(ai) * cp + np.sin(ai) * e1 for ai in a]) - - -fault_names = [] -if args.faults > 0: - offsets = (np.arange(args.faults) - 0.5 * (args.faults - 1)) * args.fault_spacing - faults = [] - for i, off in enumerate(offsets): - name = f"Stripe{i}" - faults.append((name, stripe_points(off, 0.5 * args.fault_len, lat0=args.fault_lat))) - fault_names.append(name) - sphere = sphere.add_fault(faults) -x, y, z = sphere.X -sheet = uw.systems.ThinSheet(sphere) -for name in fault_names: - if args.fault_law == "coulomb": - from underworld3.utilities.fault_contact import add_coulomb_fault_bc - # the sheet's fault reaction is thickness-integrated (a force per unit length, - # L times the stress), so the overburden given as a stress enters as L * T_b - add_coulomb_fault_bc(sheet, args.fault_mu, name, sigma_n="reaction", V0=args.fault_V0, - background=args.fault_Tb * args.L) - else: - sheet.add_fault_bc(args.fault_eta if args.fault_law == "viscous" else 0.0, name) -cum_slip = {name: 0.0 for name in fault_names} # signed, along the stripe direction -# per-pair accumulation along each trace (Berne et al. Fig. 4a: the cycle-integrated -# lateral slip along the stripes): keyed by the pair coordinates seen on the first step -_prof = {} # name -> dict(coords, act, net, act_cycle, net_cycle) -if args.De > 0: - sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( - sheet.Unknowns, order=1, integrator="etd", objective_rate=args.objective) - sheet.constitutive_model.Parameters.shear_modulus = 1.0 - sheet.constitutive_model.Parameters.shear_viscosity_0 = args.De # Maxwell time De periods -else: - sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel - sheet.constitutive_model.Parameters.shear_viscosity_0 = args.eta -sheet.thickness = args.L -sheet.tolerance = args.tol - -n_orb = 2 * sympy.pi -P20 = (3 * z ** 2 - 1) / 2 -pattern_c = -sympy.Rational(3, 2) * P20 + sympy.Rational(9, 4) * (x ** 2 - y ** 2) # multiplies cos(nt) -pattern_s = 6 * x * y # multiplies sin(nt) - - -def radial_velocity(t): - """w = dW/dt at time t (a sympy expression of position).""" - c, s = float(np.cos(2 * np.pi * t)), float(np.sin(2 * np.pi * t)) - return args.A * 2 * np.pi * (-pattern_c * s + pattern_s * c) - - -# closed form of the tangential velocity for the viscous membrane: (3/11) grad_s of the -# degree-2 radial pattern (both patterns are degree-2 harmonics on the unit sphere) -X = sympy.Matrix([x, y, z]) -rhat = X / sympy.sqrt(x ** 2 + y ** 2 + z ** 2) -P = sympy.eye(3) - rhat * rhat.T - - -def grad_s(f): - return P * sympy.Matrix([f.diff(v) for v in (x, y, z)]) - - -v_c = sympy.Rational(3, 11) * grad_s(pattern_c) -v_s = sympy.Rational(3, 11) * grad_s(pattern_s) - -# collective diagnostics: node arrays reduced over ranks -coords = np.asarray(sheet.u.coords) -rh = coords / np.linalg.norm(coords, axis=1)[:, None] -from mpi4py import MPI # reductions only; the solver handles the rest - -# the map grid (collective evaluation, drawn on rank 0) -lon = np.linspace(-np.pi, np.pi, 181) -lat = np.linspace(-np.pi / 2, np.pi / 2, 91) -LON, LAT = np.meshgrid(lon, lat) -grid = np.column_stack([(np.cos(LAT) * np.cos(LON)).ravel(), (np.cos(LAT) * np.sin(LON)).ravel(), np.sin(LAT).ravel()]) -# local tangent basis on the grid (east, north) for the in-plane 2x2 stress -_e_east = np.column_stack([-np.sin(LON).ravel(), np.cos(LON).ravel(), np.zeros(LON.size)]) -_e_north = np.column_stack([(-np.sin(LAT) * np.cos(LON)).ravel(), (-np.sin(LAT) * np.sin(LON)).ravel(), np.cos(LAT).ravel()]) -# the faceted mesh lies inside the sphere (sagitta ~ h^2/8); evaluate just inside it -_grid_in = grid * (1.0 - 0.3 * args.h ** 2) - - -# The sheet stress holds velocity gradients, which the collective cross-rank -# evaluator cannot take (PETSc error 75 at four ranks on the faulted sphere; the -# same class as the add-on yield indicator): evaluate it rank-locally at the -# sheet's own P1 nodes into six scalar variables and evaluate THOSE collectively. -_S_names = ("xx", "yy", "zz", "xy", "xz", "yz") -_S_idx = ((0, 0), (1, 1), (2, 2), (0, 1), (0, 2), (1, 2)) -_S_vars = [uw.discretisation.MeshVariable(f"S{n}", sphere, 1, degree=1) for n in _S_names] -_S_coords = np.asarray(_S_vars[0].coords) -_S_coords_in = _S_coords * (1.0 - 1e-9) - - -def stress_to_nodes(): - """Fill the six stress-component variables from the sheet stress (every rank - evaluates its own nodes; the writes are collective).""" - if _S_coords.shape[0]: - S = np.asarray(uw.function.evaluate(sheet.stress, _S_coords_in)).reshape(-1, 3, 3) - else: - S = np.zeros((0, 3, 3)) - for var, (i, j) in zip(_S_vars, _S_idx): - var.array[:, 0, 0] = S[:, i, j] - - -def stress_on(points): - """The 3x3 sheet stress at arbitrary points, from the nodal components (collective).""" - comps = [np.asarray(uw.function.global_evaluate(var.sym[0], points)).ravel() for var in _S_vars] - S = np.zeros((len(points), 3, 3)) - for c, (i, j) in zip(comps, _S_idx): - S[:, i, j] = c - S[:, j, i] = c - return S - - -# a coarser grid for the per-step stress diagnostics (collective evaluation) -_lon_d = np.linspace(-np.pi, np.pi, 73)[:-1] -_lat_d = np.linspace(-np.pi / 2, np.pi / 2, 37)[1:-1] -_LON_d, _LAT_d = np.meshgrid(_lon_d, _lat_d) -_grid_d = np.column_stack([(np.cos(_LAT_d) * np.cos(_LON_d)).ravel(), (np.cos(_LAT_d) * np.sin(_LON_d)).ravel(), - np.sin(_LAT_d).ravel()]) * (1.0 - 0.3 * args.h ** 2) -_w_d = np.cos(_LAT_d).ravel() # area weights -_ee_d = np.column_stack([-np.sin(_LON_d).ravel(), np.cos(_LON_d).ravel(), np.zeros(_LON_d.size)]) -_en_d = np.column_stack([(-np.sin(_LAT_d) * np.cos(_LON_d)).ravel(), (-np.sin(_LAT_d) * np.sin(_LON_d)).ravel(), - np.cos(_LAT_d).ravel()]) -_pat_c = np.asarray(sympy.lambdify((x, y, z), pattern_c, "numpy")(*_grid_d.T)).ravel() -_pat_s = np.asarray(sympy.lambdify((x, y, z), pattern_s, "numpy")(*_grid_d.T)).ravel() - - -def stress_diagnostics(t): - """Area-weighted rms of the in-plane stress invariant and the stress trace, - and the trace's projection on the two forcing patterns (its phase).""" - S = stress_on(_grid_d) - s_ee = np.einsum("ni,nij,nj->n", _ee_d, S, _ee_d) - s_nn = np.einsum("ni,nij,nj->n", _en_d, S, _en_d) - s_en = 0.5 * (np.einsum("ni,nij,nj->n", _ee_d, S, _en_d) + np.einsum("ni,nij,nj->n", _en_d, S, _ee_d)) - tr = s_ee + s_nn - inv2 = np.sqrt(0.5 * ((s_ee - 0.5 * tr) ** 2 + (s_nn - 0.5 * tr) ** 2) + s_en ** 2) - W = _w_d.sum() - cc, ss = (_w_d * _pat_c ** 2).sum(), (_w_d * _pat_s ** 2).sum() - return {"stress_inv_rms": float(np.sqrt((_w_d * inv2 ** 2).sum() / W)), - "stress_tr_rms": float(np.sqrt((_w_d * tr ** 2).sum() / W)), - "tr_on_cos": float((_w_d * tr * _pat_c).sum() / cc), - "tr_on_sin": float((_w_d * tr * _pat_s).sum() / ss)} - - -def diagnostics(t): - u = np.asarray(sheet.u.array[:, 0, :]) - un = (u * rh).sum(axis=1) - ut = u - un[:, None] * rh - w_ex = np.asarray(uw.function.evaluate(radial_velocity(t), coords)).ravel() if coords.shape[0] else np.zeros(0) - c, s = np.cos(2 * np.pi * t), np.sin(2 * np.pi * t) - v_ex = np.asarray(uw.function.evaluate(args.A * 2 * np.pi * (-v_c * s + v_s * c), coords)).reshape(-1, 3) if coords.shape[0] else np.zeros((0, 3)) - loc = np.array([float((ut ** 2).sum()), float(((ut - v_ex) ** 2).sum()), float((v_ex ** 2).sum()), - float(np.abs(un - w_ex).max()) if un.size else 0.0, float(coords.shape[0])]) - tot = np.zeros_like(loc) - comm.Allreduce(loc, tot, op=MPI.SUM) - nrm = comm.allreduce(loc[3], op=MPI.MAX) - n = max(tot[4], 1.0) - return {"vt_rms": float(np.sqrt(tot[0] / n)), "vex_rms": float(np.sqrt(tot[2] / n)), - "rel_err_membrane": float(np.sqrt(tot[1] / max(tot[2], 1e-300))), "normal_datum_err": float(nrm)} - - -def figure(step, t): - # collective evaluations on the grid (every rank; drawn on rank 0): a rank-local - # evaluate() would only know the rank's own cells - S = stress_on(_grid_in) - U = np.asarray(uw.function.global_evaluate(sheet.u.sym, _grid_in)).reshape(-1, 3) - if not rank0: - return - import matplotlib - matplotlib.use("Agg") - import matplotlib.pyplot as plt - # the in-plane 2x2 stress in the (east, north) basis and its principal values - s_ee = np.einsum("ni,nij,nj->n", _e_east, S, _e_east) - s_nn = np.einsum("ni,nij,nj->n", _e_north, S, _e_north) - s_en = 0.5 * (np.einsum("ni,nij,nj->n", _e_east, S, _e_north) + np.einsum("ni,nij,nj->n", _e_north, S, _e_east)) - mean, rad = 0.5 * (s_ee + s_nn), np.sqrt((0.5 * (s_ee - s_nn)) ** 2 + s_en ** 2) - s_max = (mean + rad).reshape(LAT.shape) - s_min = (mean - rad).reshape(LAT.shape) - un = (U * grid).sum(axis=1) - ut = np.linalg.norm(U - un[:, None] * grid, axis=1).reshape(LAT.shape) - fig, axes = plt.subplots(1, 3, figsize=(19, 4.6), subplot_kw={"projection": "mollweide"}) - for ax, fld, ttl, cmap in ((axes[0], un.reshape(LAT.shape), "radial velocity w (prescribed)", "RdBu_r"), - (axes[1], ut, "tangential speed", "viridis"), - (axes[2], s_max, "largest in-plane principal stress", "RdBu_r")): - lim = np.abs(fld).max() + 1e-30 - pc = ax.pcolormesh(LON, LAT, fld, cmap=cmap, shading="auto", - **({"vmin": -lim, "vmax": lim} if cmap == "RdBu_r" else {})) - ax.set_title(ttl, fontsize=10, loc="left"); ax.grid(alpha=0.3) - fig.colorbar(pc, ax=ax, shrink=0.6, orientation="horizontal", pad=0.05) - fig.suptitle(f"tidal shell: step {step}, t = {t:.3f} periods, De = {args.De}, A = {args.A}", fontsize=10) - fig.savefig(os.path.join(args.out, f"tide_{step:04d}.png"), dpi=110, bbox_inches="tight") - plt.close(fig) - - -# polar view: azimuthal (exponential-map) grid around the stripe set's centre -_pole_lat = args.fault_lat if args.faults > 0 else -90.0 -_th0, _ph0 = np.radians(_pole_lat), np.radians(args.fault_lon) -_pc = np.array([np.cos(_th0) * np.cos(_ph0), np.cos(_th0) * np.sin(_ph0), np.sin(_th0)]) -_sub = np.cross(_pc, np.array([-np.sin(_ph0), np.cos(_ph0), 0.0])) -_az = np.radians(args.fault_azimuth) -_pe1 = np.cos(_az) * _sub + np.sin(_az) * np.cross(_pc, _sub); _pe2 = np.cross(_pc, _pe1) -_prho = np.linspace(0.0, 0.9, 61)[1:] -_ppsi = np.linspace(-np.pi, np.pi, 145) -_PR, _PP = np.meshgrid(_prho, _ppsi) -_pa, _pb = (_PR * np.cos(_PP)).ravel(), (_PR * np.sin(_PP)).ravel() -_pd = (_pa[:, None] * _pe1[None] + _pb[:, None] * _pe2[None]) / np.maximum(_PR.ravel(), 1e-30)[:, None] -_pgrid = np.cos(_PR.ravel())[:, None] * _pc[None] + np.sin(_PR.ravel())[:, None] * _pd -_pgrid_in = _pgrid * (1.0 - 0.3 * args.h ** 2) -_pee = np.cross(_pgrid, _pd) # azimuthal direction -_pee /= np.maximum(np.linalg.norm(_pee, axis=1), 1e-30)[:, None] -_pen = np.cross(_pee, _pgrid) # radial-in-plane direction - - -def to_polar(points): - """Points on the sphere -> (a, b) in the azimuthal projection about the centre.""" - pts = np.asarray(points) / np.linalg.norm(points, axis=1)[:, None] - cosr = np.clip(pts @ _pc, -1, 1) - rho = np.arccos(cosr) - d = pts - cosr[:, None] * _pc[None] - d /= np.maximum(np.linalg.norm(d, axis=1), 1e-30)[:, None] - return rho * (d @ _pe1), rho * (d @ _pe2) - - -_fault_polylines = [to_polar(pts) for _n, pts in (faults if args.faults > 0 else [])] - -# virtual stripe traces for the driving tractions of an unfaulted (locked) shell -_probe = [] -if args.probe_stripes > 0 and args.faults == 0: - offsets = (np.arange(args.probe_stripes) - 0.5 * (args.probe_stripes - 1)) * args.fault_spacing - for i, off in enumerate(offsets): - pts = stripe_points(off, 0.5 * args.fault_len, n=33, lat0=args.fault_lat) - tt = np.gradient(pts, axis=0); tt -= (tt * pts).sum(1)[:, None] * pts - tt /= np.maximum(np.linalg.norm(tt, axis=1), 1e-30)[:, None] - nn = np.cross(pts, tt) - _probe.append((f"Stripe{i}", pts * (1.0 - 0.3 * args.h ** 2), tt, nn)) - _fault_polylines = [to_polar(pts) for _n, pts, _t, _nn in _probe] - - -def driving_tractions(): - """Shear (along the stripe, signed) and normal (tension positive) traction on - each virtual stripe, averaged along it: the locked shell's driving tractions.""" - out = {} - for name, pts, tt, nn in _probe: - S = stress_on(pts) - tau = np.einsum("ni,nij,nj->n", tt, S, nn) - sn = np.einsum("ni,nij,nj->n", nn, S, nn) - out[name] = {"tau": float(tau.mean()), "sigma_n": float(sn.mean()), "tau_max": float(np.abs(tau).max())} - return out - - -def polar_figure(step, t): - S = stress_on(_pgrid_in) - U = np.asarray(uw.function.global_evaluate(sheet.u.sym, _pgrid_in)).reshape(-1, 3) - if not rank0: - return - # the polar fields, for post-processing against a reference run (Coulomb stress changes) - np.savez(os.path.join(args.out, f"polar_{step:04d}.npz"), t=t, S=S, U=U, grid=_pgrid, rho=_PR, psi=_PP, - centre=_pc, e1=_pe1, e2=_pe2, faults=np.array([np.column_stack(fp) for fp in _fault_polylines]) if _fault_polylines else np.zeros((0, 0, 2))) - import matplotlib - matplotlib.use("Agg") - import matplotlib.pyplot as plt - s_ee = np.einsum("ni,nij,nj->n", _pee, S, _pee); s_nn = np.einsum("ni,nij,nj->n", _pen, S, _pen) - s_en = 0.5 * (np.einsum("ni,nij,nj->n", _pee, S, _pen) + np.einsum("ni,nij,nj->n", _pen, S, _pee)) - mean, rad = 0.5 * (s_ee + s_nn), np.sqrt((0.5 * (s_ee - s_nn)) ** 2 + s_en ** 2) - s_max = (mean + rad).reshape(_PR.shape); inv = rad.reshape(_PR.shape) - un = (U * _pgrid).sum(axis=1) - ut = np.linalg.norm(U - un[:, None] * _pgrid, axis=1).reshape(_PR.shape) - A_, B_ = (_PR * np.cos(_PP)), (_PR * np.sin(_PP)) - fig, axes = plt.subplots(1, 3, figsize=(17, 5.4)) - for ax, fld, ttl, cmap, sym in ((axes[0], s_max, "largest in-plane principal stress", "RdBu_r", True), - (axes[1], inv, "in-plane stress invariant (max shear)", "magma", False), - (axes[2], ut, "tangential speed", "viridis", False)): - lim = np.abs(fld).max() + 1e-30 - pc = ax.pcolormesh(A_, B_, fld, cmap=cmap, shading="auto", **({"vmin": -lim, "vmax": lim} if sym else {})) - for pa, pb in _fault_polylines: - ax.plot(pa, pb, color="w" if cmap != "RdBu_r" else "k", lw=2.0) - for r in (0.25, 0.5, 0.75): - ax.add_patch(plt.Circle((0, 0), r, fill=False, color="gray", lw=0.5, alpha=0.6)) - ax.set_aspect("equal"); ax.set_xlim(-0.9, 0.9); ax.set_ylim(-0.9, 0.9) - ax.set_title(ttl, fontsize=10, loc="left"); ax.set_xticks([]); ax.set_yticks([]) - fig.colorbar(pc, ax=ax, shrink=0.8) - fig.suptitle(f"south polar view (centre lat {_pole_lat:.0f}, radius 0.9 rad): step {step}, t = {t:.3f} periods, De = {args.De}", - fontsize=10) - fig.savefig(os.path.join(args.out, f"polar_{step:04d}.png"), dpi=110, bbox_inches="tight") - plt.close(fig) - - -# cumulative tangential displacement on the grids (rank 0 holds it; the evaluations are collective) -_disp_g = np.zeros((grid.shape[0], 3)); _disp_p = np.zeros((_pgrid.shape[0], 3)) - - -def accumulate_displacement(dt_): - global _disp_g, _disp_p - Ug = np.asarray(uw.function.global_evaluate(sheet.u.sym, _grid_in)).reshape(-1, 3) - Up = np.asarray(uw.function.global_evaluate(sheet.u.sym, _pgrid_in)).reshape(-1, 3) - if rank0: - Ug -= (Ug * grid).sum(1)[:, None] * grid; Up -= (Up * _pgrid).sum(1)[:, None] * _pgrid - _disp_g += dt_ * Ug; _disp_p += dt_ * Up - - -def save_displacement(step, t): - if rank0: - np.savez(os.path.join(args.out, f"disp_{step:04d}.npz"), t=t, disp_global=_disp_g, grid=grid, lon=LON, lat=LAT, - disp_polar=_disp_p, pgrid=_pgrid, rho=_PR, psi=_PP, centre=_pc, e1=_pe1, e2=_pe2, - faults=np.array([np.column_stack(fp) for fp in _fault_polylines]) if _fault_polylines else np.zeros((0, 0, 2))) - - -# ----------------------------------------------------------------------------- time loop -dt = 1.0 / args.steps_per_period -nsteps = int(round(args.periods * args.steps_per_period)) -records = [] -t = 0.0 -log(f"sphere h {args.h}, {comm.allreduce(int(sphere.dm.getHeightStratum(0)[1]), op=MPI.SUM)} cells; " - f"De {args.De}, A {args.A}, dt {dt}, {nsteps} steps") -for k in range(nsteps): - t0 = time.perf_counter() - t_mid = t + dt # implicit: the datum at the end of the step - sheet.normal_velocity = radial_velocity(t_mid) - if args.De > 0: - sheet.solve(timestep=dt, zero_init_guess=(k == 0), verbose=args.verbose) - else: - sheet.solve(zero_init_guess=(k == 0), verbose=args.verbose) - t = t_mid - info = sheet._rotated_freeslip_info or {} - rec = {"step": k, "t": t, "dt": dt, "converged": bool(info.get("converged")), - "nonlinear": info.get("nonlinear_iterations"), "t_step": round(time.perf_counter() - t0, 1)} - stress_to_nodes() - if args.accumulate_displacement: - accumulate_displacement(dt) - if (k + 1) % args.steps_per_period == 0 or k == nsteps - 1: - save_displacement(k, t) # at every cycle boundary - if _prof and ((k + 1) % args.steps_per_period == 0 or k == nsteps - 1): - if rank0: - np.savez(os.path.join(args.out, f"slipprof_{k:04d}.npz"), t=t, - **{f"{n}_{key}": v for n, pr in _prof.items() for key, v in pr.items()}) - for pr in _prof.values(): - pr["act_cycle"][:] = 0.0; pr["net_cycle"][:] = 0.0 - if args.save_global_stress: - Sg = stress_on(_grid_in) # collective - if rank0: - np.savez(os.path.join(args.out, f"gstress_{k:04d}.npz"), t=t, S=Sg, lon=LON, lat=LAT) - if args.check: - rec.update(diagnostics(t)) - rec.update(stress_diagnostics(t)) - if _probe: - rec["drive"] = driving_tractions() - if fault_names: - from underworld3.utilities.fault_contact import fault_pair_jumps - slips = {} - for name in fault_names: - _c, jumps, normals = fault_pair_jumps(sheet, name, info, gather=True) # every rank holds the fault - v = np.linalg.norm(jumps, axis=1) if len(jumps) else np.zeros(1) - # signed slip along the stripe direction (the stripes run along e1 at the - # set's centre; its tangent projection at each pair) - if len(jumps): - pc_ = np.asarray(_c) / np.linalg.norm(_c, axis=1)[:, None] - tdir = _pe1[None] - (pc_ @ _pe1)[:, None] * pc_ - tdir /= np.maximum(np.linalg.norm(tdir, axis=1), 1e-30)[:, None] - signed = float(np.mean(np.einsum("ij,ij->i", jumps, tdir))) - else: - signed = 0.0 - cum_slip[name] += signed * dt - if len(jumps): - vs = np.einsum("ij,ij->i", jumps, tdir) # signed slip rate per pair - if name not in _prof: - _prof[name] = {"coords": np.asarray(_c).copy(), "act": np.zeros(len(vs)), "net": np.zeros(len(vs)), - "act_cycle": np.zeros(len(vs)), "net_cycle": np.zeros(len(vs))} - pr = _prof[name] - if len(vs) == len(pr["act"]): - from scipy.spatial import cKDTree - _d, idx = cKDTree(np.asarray(_c)).query(pr["coords"]) # same pairs, any order - pr["act"] += np.abs(vs[idx]) * dt; pr["net"] += vs[idx] * dt - pr["act_cycle"] += np.abs(vs[idx]) * dt; pr["net_cycle"] += vs[idx] * dt - # clamping: the normal traction on the stripe from the no-opening reaction - # (negative in compression); rank-local per pair, reduced collectively - from underworld3.utilities.fault_contact import fault_normal_traction - _cs, sig = fault_normal_traction(sheet, name, info) - sig = np.asarray(sig) / args.L # resultant -> stress (negative in compression) - loc = np.array([float(np.sum(sig)), float(len(sig)), float(np.min(sig)) if len(sig) else np.inf, - float(np.max(sig)) if len(sig) else -np.inf]) - tot = comm.allreduce(loc[0], op=MPI.SUM), comm.allreduce(loc[1], op=MPI.SUM) - sn_min, sn_max = comm.allreduce(loc[2], op=MPI.MIN), comm.allreduce(loc[3], op=MPI.MAX) - slips[name] = {"max": float(v.max()), "rms": float(np.sqrt((v ** 2).mean())), - "signed_mean": signed, "cumulative": cum_slip[name], - "sigma_n_mean": float(tot[0] / max(tot[1], 1.0)), "sigma_n_min": float(sn_min), "sigma_n_max": float(sn_max), - "leak": float(np.abs(np.einsum("ij,ij->i", jumps, normals)).max()) if len(jumps) else 0.0} - rec["slip"] = slips - records.append(rec) - log("STEP", json.dumps(rec)) - if k % args.fig_every == 0 or k == nsteps - 1: - figure(k, t) - polar_figure(k, t) - if rank0: - json.dump(records, open(os.path.join(args.out, "records.json"), "w"), indent=1) -log("DONE") diff --git a/scripts/sessions/tidal_shell/tidal_site_analysis.py b/scripts/sessions/tidal_shell/tidal_site_analysis.py deleted file mode 100644 index 758636c23..000000000 --- a/scripts/sessions/tidal_shell/tidal_site_analysis.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Where and how a frictional stripe would slip asymmetrically: from the locked -shell's global stress (gstress_*.npz, last period), the shear and normal traction -on a plane of given azimuth at every grid point, their amplitudes, the phase of -the clamping relative to the shear, and an asymmetry index from the paper's 1-D -Coulomb picture (ED Fig. 4): slip rate ~ max(0, |tau| - mu (T_b - sigma_n)) with -the sign of tau, so - - asym = (I_right - I_left) / (I_right + I_left), I = integral of the positive - Coulomb traction over the right- / left-lateral windows. - -Azimuth: the plane's strike measured counter-clockwise from local east (viewed -from outside). Usage: """ -import glob, os, sys -import numpy as np -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -sim, run = sys.argv[1:3] -mu, Tb = float(sys.argv[3]), float(sys.argv[4]) -az_arg = sys.argv[5] if len(sys.argv) > 5 else "120" -files = sorted(glob.glob(os.path.join(sim, run, "gstress_*.npz"))) -d0 = np.load(files[-1]); LON, LAT = d0["lon"], d0["lat"] -t_all = np.array([float(np.load(f)["t"]) for f in files]) -last = t_all > t_all.max() - 1.0 + 1e-9 -S = np.array([np.load(f)["S"] for f, m in zip(files, last) if m]) # (nt, npts, 3, 3) -t = t_all[last] -lon, lat = LON.ravel(), LAT.ravel() -r = np.column_stack([np.cos(lat) * np.cos(lon), np.cos(lat) * np.sin(lon), np.sin(lat)]) -east = np.column_stack([-np.sin(lon), np.cos(lon), np.zeros_like(lon)]) -north = np.cross(r, east) - - -def tractions(az_deg): - a = np.radians(az_deg) - strike = np.cos(a) * east + np.sin(a) * north - nrm = np.cross(r, strike) - tau = np.einsum("ni,tnij,nj->tn", strike, S, nrm) - sn = np.einsum("ni,tnij,nj->tn", nrm, S, nrm) - return tau, sn - - -def analyse(tau, sn): - M = np.column_stack([np.cos(2 * np.pi * t), np.sin(2 * np.pi * t)]) - ct = np.linalg.lstsq(M, tau, rcond=None)[0]; cn = np.linalg.lstsq(M, sn, rcond=None)[0] - amp_t, amp_n = np.hypot(*ct), np.hypot(*cn) - ph_t, ph_n = np.arctan2(ct[1], ct[0]), np.arctan2(cn[1], cn[0]) - dphi = np.degrees((ph_n - ph_t + np.pi) % (2 * np.pi) - np.pi) # clamping phase relative to shear - coul = np.abs(tau) - mu * (Tb - sn) - pos = np.maximum(coul, 0.0) - I_r = (pos * (tau > 0)).sum(axis=0); I_l = (pos * (tau < 0)).sum(axis=0) - asym = (I_r - I_l) / np.maximum(I_r + I_l, 1e-30) - frac = (pos > 0).mean(axis=0) # fraction of the cycle failing - # timing: the centroid of each failure window relative to the shear extreme of - # its sign (degrees, positive = late); the difference between the two windows - ph = 2 * np.pi * t - def centroid_offset(sign): - w = pos * (np.sign(tau) == sign) - ex = ph_t if sign > 0 else ph_t + np.pi # phase of the shear extreme - rel = (ph[:, None] - ex[None, :] + np.pi) % (2 * np.pi) - np.pi - return np.degrees((w * rel).sum(axis=0) / np.maximum(w.sum(axis=0), 1e-30)) - timing = centroid_offset(+1) - centroid_offset(-1) - timing = np.where((I_r > 0) & (I_l > 0), timing, np.nan) - return amp_t, amp_n, dphi, asym, frac, timing - - -amp_t, amp_n, dphi, asym, frac, timing = analyse(*tractions(float(az_arg))) -title = f"planes at azimuth {az_arg} deg from local east" -fields = [(amp_t, "shear traction amplitude", "viridis", None), (amp_n, "normal traction amplitude", "viridis", None), - (dphi, "phase of the clamping relative to the shear (deg)", "twilight", (-180, 180)), - (asym, "size asymmetry (I_right - I_left)/(I_right + I_left)", "RdBu_r", (-1, 1)), - (timing, "timing asymmetry: onset offset, right minus left window (deg)", "RdBu_r", (-90, 90)), - (frac, "fraction of the cycle at failure", "magma", (0, 1))] -n = len(fields) -fig, axes = plt.subplots(2, 3, figsize=(19, 8), subplot_kw={"projection": "mollweide"}) -for ax, (fld, ttl, cmap, lim) in zip(axes.ravel(), fields): - kw = {} if lim is None else {"vmin": lim[0], "vmax": lim[1]} - pc = ax.pcolormesh(LON, LAT, fld.reshape(LAT.shape), cmap=cmap, shading="auto", **kw) - ax.set_title(ttl, fontsize=9, loc="left"); ax.grid(alpha=0.3) - fig.colorbar(pc, ax=ax, shrink=0.6, orientation="horizontal", pad=0.05) -for ax in axes.ravel()[n:]: - ax.set_visible(False) -fig.suptitle(f"locked shell {run}, mu = {mu}, T_b = {Tb}: {title}", fontsize=11) -out = os.path.join(sim, f"sites_{run}_az{az_arg}.png") -fig.savefig(out, dpi=110, bbox_inches="tight"); print("wrote", out) -# the sites run frictionally -for name, la, lo, az in (("pole az30", -90, 0, 30), ("pole az90", -90, 0, 90), ("lat-45 lon0 az30", -45, 0, 30), - ("lat-45 lon90 az30", -45, 90, 30), ("equator lon0 az45", 0, 0, 45)): - i = np.argmin((np.degrees(lat) - la) ** 2 + (np.cos(np.radians(la)) * (np.degrees(lon) - lo)) ** 2) - # local azimuth from east: the driver's azimuth is from the sub-planet direction; at the - # south pole that direction is +x, which is "north" in the local (east, north) frame - # of the pole's lat-lon grid at lon 0 -> azimuth from east = az + 90 there; at other - # sites the sub-planet direction is local west-ish (lon 0) or south-ish (lon 90) - for az_e in (az + 90, az): - a_t, a_n, dp, asy, fr, tm = analyse(*tractions(az_e % 180)) - print(f" {name:20s} plane az_east {az_e % 180:5.0f}: |tau| {a_t[i]:.4f} |sn| {a_n[i]:.4f} clamp phase {dp[i]:+6.0f} " - f"size asym {asy[i]:+.2f} timing asym {tm[i]:+6.0f} failing {fr[i]:.2f}") diff --git a/scripts/sessions/tidal_shell/tidal_slip_profile.py b/scripts/sessions/tidal_shell/tidal_slip_profile.py deleted file mode 100644 index 6d1157186..000000000 --- a/scripts/sessions/tidal_shell/tidal_slip_profile.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Slip along the stripes over the last orbit, Berne et al. Fig. 4 style: the -cycle-integrated lateral slip |ds| (activity) coloured along each trace in the -polar view and as 1-D profiles against distance along the stripe, relative to the -maximum over all stripes; and the same for the signed net offset of the orbit. -Usage: """ -import glob, os, sys -import numpy as np -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -sim, run = sys.argv[1:3] -f = sorted(glob.glob(os.path.join(sim, run, "slipprof_*.npz")))[-1] -d = np.load(f) -names = sorted({k.rsplit("_", 1)[0] for k in d.files if k.endswith("_coords")}) -pol = np.load(sorted(glob.glob(os.path.join(sim, run, "polar_*.npz")))[-1]) -c, e1, e2 = pol["centre"], pol["e1"], pol["e2"] - - -def to_polar(pts): - pts = pts / np.linalg.norm(pts, axis=1)[:, None] - cosr = np.clip(pts @ c, -1, 1); rho = np.arccos(cosr) - dd = pts - cosr[:, None] * c[None]; dd /= np.maximum(np.linalg.norm(dd, axis=1), 1e-30)[:, None] - return rho * (dd @ e1), rho * (dd @ e2) - - -act_max = max(d[f"{n}_act_cycle"].max() for n in names) -net_max = max(np.abs(d[f"{n}_net_cycle"]).max() for n in names) -fig, axes = plt.subplots(1, 3, figsize=(18, 5.4)) -for ax, key, vmax, cmap, ttl in ((axes[0], "act_cycle", act_max, "inferno", "cycle-integrated lateral slip along the stripes (relative to the maximum)"), - (axes[1], "net_cycle", net_max, "RdBu_r", "net offset over the orbit (right-lateral positive)")): - for n in names: - X = d[f"{n}_coords"]; a, b = to_polar(X) - s = X @ e1; o = np.argsort(s) - v = d[f"{n}_{key}"] - sc = ax.scatter(a[o], b[o], c=(v[o] / vmax if key == "act_cycle" else v[o]), cmap=cmap, - vmin=(0 if key == "act_cycle" else -net_max), vmax=(1 if key == "act_cycle" else net_max), s=60, zorder=3) - ax.plot(a[o], b[o], color="gray", lw=4, zorder=2, alpha=0.4) - for r in (0.1, 0.2, 0.3): - ax.add_patch(plt.Circle((0, 0), r, fill=False, color="gray", lw=0.5, alpha=0.6)) - ax.set_aspect("equal"); ax.set_xlim(-0.4, 0.4); ax.set_ylim(-0.4, 0.4); ax.set_xticks([]); ax.set_yticks([]) - ax.set_title(ttl, fontsize=9, loc="left"); fig.colorbar(sc, ax=ax, shrink=0.8) -ax = axes[2] -for n in names: - X = d[f"{n}_coords"]; s = X @ e1; o = np.argsort(s); s = s[o] - s[o][0] - ax.plot(s, d[f"{n}_act_cycle"][o] / act_max, "-", label=f"{n} activity") - ax.plot(s, d[f"{n}_net_cycle"][o] / net_max, "--", label=f"{n} net (scaled)") -ax.set_xlabel("distance along the stripe"); ax.set_ylabel("relative to the maximum"); ax.grid(alpha=0.3); ax.legend(fontsize=7, ncol=2) -ax.set_title("1-D profiles along each stripe", fontsize=9, loc="left") -fig.suptitle(f"{run}: slip along the tiger stripes over the last orbit (t = {float(d['t']):.2f}); max activity {act_max:.4f}, max net {net_max:.4f}", fontsize=10) -out = os.path.join(sim, f"slipprofile_{run}.png"); fig.savefig(out, dpi=120, bbox_inches="tight"); print("wrote", out) -for n in names: - X = d[f"{n}_coords"]; s = X @ e1; o = np.argsort(s); v = d[f"{n}_act_cycle"][o]; w = d[f"{n}_net_cycle"][o] - print(f" {n}: {len(v)} pairs, activity max {v.max():.4f} at {np.argmax(v)/(len(v)-1):.2f} of the length, " - f"ends {v[0]/v.max():.2f} / {v[-1]/v.max():.2f} of max; net offset max {w.max():+.4f} min {w.min():+.4f}") diff --git a/scripts/sessions/tidal_shell/tidal_strength.py b/scripts/sessions/tidal_shell/tidal_strength.py deleted file mode 100644 index cf3c4fdd5..000000000 --- a/scripts/sessions/tidal_shell/tidal_strength.py +++ /dev/null @@ -1,48 +0,0 @@ -"""How far the tidal shear overcomes the fault strength: the driving shear on the -stripes (locked shell) against the Coulomb strength mu (T_b - sigma_n) read on the -frictional shell, the slip rate, and the build-up released at each onset. -Usage: """ -import json, os, sys -import numpy as np -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -sim, locked, fric = sys.argv[1:4] -mu, Tb = float(sys.argv[4]), float(sys.argv[5]) -L = json.load(open(os.path.join(sim, locked, "records.json"))) -F = json.load(open(os.path.join(sim, fric, "records.json"))) -tL = np.array([r["t"] for r in L]); tF = np.array([r["t"] for r in F]) -lastL = tL > tL.max() - 1 + 1e-9; lastF = tF > tF.max() - 1 + 1e-9 -names = sorted(F[0]["slip"].keys()) -tau = np.mean([[r["drive"][n]["tau"] for r in L] for n in names], axis=0)[lastL] -ph = (tL[lastL] - np.floor(tL[lastL] - 1e-9)) * 360 -sn = np.mean([[r["slip"][n]["sigma_n_mean"] for r in F] for n in names], axis=0)[lastF] -rate = np.mean([[r["slip"][n]["signed_mean"] for r in F] for n in names], axis=0)[lastF] -phF = (tF[lastF] - np.floor(tF[lastF] - 1e-9)) * 360 -strength = mu * np.maximum(Tb - sn, 0) -tau_i = np.interp(phF, ph, tau) -excess = np.abs(tau_i) - strength -fig, axes = plt.subplots(1, 2, figsize=(14, 4.8)) -ax = axes[0] -ax.plot(phF, np.abs(tau_i), "k", lw=2, label="|driving shear| (locked shell)") -ax.plot(phF, strength, "r", lw=2, label="strength mu (T_b - sigma_n) (frictional shell)") -ax.fill_between(phF, strength, np.abs(tau_i), where=np.abs(tau_i) > strength, color="orange", alpha=0.35, label="excess: shear above strength") -ax.set_xlabel("mean anomaly (deg)"); ax.set_xlim(0, 360); ax.grid(alpha=0.3); ax.legend(fontsize=8) -ax.set_title("driving shear against fault strength (stripe mean, last cycle)") -ax2 = axes[1] -ax2.plot(phF, excess, "orange", lw=2, label="excess |tau| - strength") -ax2.plot(phF, rate / np.abs(rate).max() * np.abs(excess).max(), "b--", lw=1.5, label="slip rate (scaled)") -ax2.axhline(0, color="k", lw=0.6); ax2.set_xlim(0, 360); ax2.grid(alpha=0.3); ax2.legend(fontsize=8); ax2.set_xlabel("mean anomaly (deg)") -ax2.set_title("Coulomb excess and the slip it drives") -fig.tight_layout(); fig.savefig(os.path.join(sim, f"strength_{fric}.png"), dpi=120) -# numbers: onsets (excess crossing zero upward), the strength and shear there, peak excess per window -sgn = np.sign(excess); on = np.where((sgn[1:] > 0) & (sgn[:-1] <= 0))[0] + 1 -print(f"{fric}: peak |driving shear| {np.abs(tau_i).max():.4f}, strength range {strength.min():.4f} .. {strength.max():.4f} " - f"(fully unclamped at sigma_n = T_b: {mu*Tb:.4f} at sigma_n 0)") -print(f" fraction of the cycle with |tau| > strength: {(excess > 0).mean():.2f}") -for k in on: - w = np.arange(k, len(excess)); w = w[: np.argmax(excess[w] <= 0) or len(w)] - print(f" onset at {phF[k]:5.0f} deg: |tau| {abs(tau_i[k]):.4f}, strength {strength[k]:.4f}, strength falling at " - f"{(strength[k]-strength[k-1])/(phF[k]-phF[k-1])*360:+.4f}/period vs |tau| changing at {(abs(tau_i[k])-abs(tau_i[k-1]))/(phF[k]-phF[k-1])*360:+.4f}/period; " - f"window peak excess {excess[w].max():.4f} ({excess[w].max()/max(strength[w].mean(),1e-12):.0%} of the strength), slip in window {np.trapezoid(rate[w], phF[w]/360):+.4f}") diff --git a/scripts/sessions/tidal_shell/tidal_sweep_plot.py b/scripts/sessions/tidal_shell/tidal_sweep_plot.py deleted file mode 100644 index e758287ff..000000000 --- a/scripts/sessions/tidal_shell/tidal_sweep_plot.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Stress amplitude and phase of the tidally flexed shell against the Maxwell -time: reads records.json of each run, fits the trace projections on the two -forcing patterns to a sinusoid over the last period.""" -import json, os, sys -import numpy as np -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -sim = sys.argv[1] -runs = sys.argv[2:] -fig, axes = plt.subplots(1, 3, figsize=(17, 4.6)) -summary = [] -for run in runs: - rs = json.load(open(os.path.join(sim, run, "records.json"))) - t = np.array([r["t"] for r in rs]); inv = np.array([r["stress_inv_rms"] for r in rs]) - tc = np.array([r["tr_on_cos"] for r in rs]); ts = np.array([r["tr_on_sin"] for r in rs]) - vt = np.array([r["vt_rms"] for r in rs]) - axes[0].plot(t, inv, label=run); axes[1].plot(t, tc, label=f"{run} cos"); axes[1].plot(t, ts, "--", label=f"{run} sin") - axes[2].plot(t, vt, label=run) - last = t > t.max() - 1.0 + 1e-9 - # the forcing's cos-pattern coefficient is A cos(2 pi t): fit tc = a cos + b sin - M = np.column_stack([np.cos(2 * np.pi * t[last]), np.sin(2 * np.pi * t[last])]) - a, b = np.linalg.lstsq(M, tc[last], rcond=None)[0] - amp, phase = np.hypot(a, b), np.degrees(np.arctan2(b, a)) - summary.append((run, inv[last].max(), amp, phase)) -axes[0].set_title("rms in-plane stress invariant"); axes[1].set_title("stress trace projected on the forcing patterns") -axes[2].set_title("rms tangential speed"); -for ax in axes: ax.set_xlabel("t (periods)"); ax.grid(alpha=0.3); ax.legend(fontsize=7) -fig.tight_layout(); fig.savefig(os.path.join(sim, "sweep.png"), dpi=120) -# Maxwell transfer function for the membrane (strain in phase with the displacement): -# trace amplitude on the cos pattern = (24/11) mu A * wt/sqrt(1+wt^2), lag behind the -# displacement = atan(1/wt), wt = 2 pi De; the viscous sheet: 6 eta (4/11) w A at -90 deg -import re -A_amp, mu_mod, eta_v = 0.01, 1.0, 1.0 -print("run max|inv| trace amp (meas) phase meas (deg, vs cos(nt) = the displacement) | Maxwell prediction amp, lag") -for r_, inv_, amp_, ph_ in summary: - m = re.search(r"De([0-9.]+)", r_) - De = float(m.group(1)) if m else 0.0 - if De > 0: - wt = 2 * np.pi * De - amp_th = (24 / 11) * mu_mod * A_amp * wt / np.sqrt(1 + wt ** 2) - lag_th = -np.degrees(np.arctan(1 / wt)) - else: - amp_th, lag_th = 6 * eta_v * (4 / 11) * 2 * np.pi * A_amp, -90.0 - print(f"{r_:9s} {inv_:.4f} {amp_:.4f} {ph_:7.1f} | {amp_th:.4f} {lag_th:7.1f}") diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index 3ab7dfbd7..071606852 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -1406,6 +1406,8 @@ def solve_rotated_freeslip(solver, boundaries, remove_rotation_gauge=True, rnull_gauge = [0.0] def _collect_rotation_modes(nullspace): + for m in rot_modes: + m.destroy() rot_modes.clear() if nullspace is None: return @@ -1714,6 +1716,14 @@ def rotated_residual(uvec, keep_cartesian=False): # above would report convergence while mass conservation is violated. # Surface the component: readable state always, loud when it dominates. solver._rotated_rotation_gauge_residual = rnull_gauge[0] if rot_modes else 0.0 + if rot_modes and converged and rnull_gauge[0] > 10.0 * max(rnorm, atol): + mpi.pprint(f"[rotated_bc] WARNING: converged in the rotation-gauge QUOTIENT " + f"space, but the projected-out rigid-rotation component " + f"({rnull_gauge[0]:.2e}) dominates the residual ({rnorm:.2e}): the " + f"forcing has a net torque, which free-slip walls cannot balance.") + for m in rot_modes: + m.destroy() + rot_modes.clear() if use_pnull: solver._rotated_pressure_gauge_residual = pnull_gauge[0] if converged and pnull_gauge[0] > 10.0 * max(rnorm, atol): diff --git a/tests/test_1018_rotated_freeslip.py b/tests/test_1018_rotated_freeslip.py index f7cc3c5bc..214d3de3b 100644 --- a/tests/test_1018_rotated_freeslip.py +++ b/tests/test_1018_rotated_freeslip.py @@ -11,7 +11,9 @@ from underworld3 import analytic as A from underworld3.utilities import custom_mg -pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] +# level on the module, tier on each test: the two newest are tier B (proven, +# not yet hardened) +pytestmark = [pytest.mark.level_1] _EPS = np.finfo(float).eps @@ -42,6 +44,7 @@ def _solcx_essential(mesh, sol): return v +@pytest.mark.tier_a def test_rotated_freeslip_box_reproduces_essential(): """Box: rotated free-slip on all 4 axis-aligned walls == native essential free-slip.""" mesh = uw.meshing.StructuredQuadBox( @@ -69,6 +72,7 @@ def test_rotated_freeslip_box_reproduces_essential(): assert sol.velocity_error(v) < 1e-3 +@pytest.mark.tier_a def test_rotated_linear_workspace_reuses_unchanged_operator(): """Repeated linear solves reuse the rotated workspace across solves. @@ -329,6 +333,7 @@ def _rampable_rotated_stokes(mesh, k_expr, tag, forcing=None): return s, v +@pytest.mark.tier_a def test_rotated_workspace_constant_ramp_invalidates(): """THE blind-spot regression (PR #418 review, unresolved finding): a rampable UWexpression constant changes value with NO state-counter bump @@ -382,6 +387,7 @@ def test_rotated_workspace_constant_ramp_invalidates(): assert half < 1e-6, f"ramped solve is not the halved velocity ({half:.2e})" +@pytest.mark.tier_a def test_rotated_workspace_deform_invalidates(): """mesh.deform between solves: the geometry changed, so the whole workspace must be rebuilt and the answer must match a fresh solver on the deformed @@ -558,6 +564,7 @@ def test_rotated_workspace_deform_invalidates(): f"survived the deform.") +@pytest.mark.tier_a @pytest.mark.level_2 def test_rotated_freeslip_spherical_shell_3d(): """3D spherical shell, free-slip inner+outer (the Zhong #248 configuration): @@ -689,6 +696,7 @@ def fit(mask): ) +@pytest.mark.tier_a def test_p2_triangle_mass_has_zero_vertex_row_sums(): """WHY mass='auto' is P1-projected on a 3D P2 trace, and not the consistent solve. @@ -714,6 +722,7 @@ def test_p2_triangle_mass_has_zero_vertex_row_sums(): assert np.all(row_sums[3:] > 0.0), "P2 triangle midpoint rows should be positive" +@pytest.mark.tier_a @pytest.mark.level_2 def test_rotated_freeslip_spherical3d_reaction_topography(): """3D reaction loads must be divided by boundary mass to recover pointwise stress. @@ -749,6 +758,7 @@ def test_rotated_freeslip_spherical3d_reaction_topography(): assert np.isclose(cmb_midpoints, 0.77060, rtol=0.05) +@pytest.mark.tier_a def test_rotated_freeslip_annulus_zero_leakage(): """Annulus: per-node radial free-slip on both arcs → machine-zero v_r leakage, with the analytic radial normal.""" @@ -785,6 +795,7 @@ def test_rotated_freeslip_annulus_zero_leakage(): assert rotfrac < 1e-8, f"rotation gauge {rotfrac:.2e} not removed" +@pytest.mark.tier_a def test_rotated_freeslip_geometric_fmg_velocity_block(): """The rotated free-slip velocity block is driven by GEOMETRIC FMG on a custom prolongation (set_custom_fmg) — no direct solve — and still reproduces the @@ -838,6 +849,7 @@ def test_rotated_freeslip_geometric_fmg_velocity_block(): f"(inexact Schur application — is the velocity sub-KSP `preonly` again?)") +@pytest.mark.tier_a def test_rotated_freeslip_boundary_normal_traction_solcx(): """sigma_nn recovered by boundary_normal_traction on the top boundary reproduces the exact SolCx analytic sigma_yy (mean-removed) to a few percent. Exercises the @@ -876,6 +888,7 @@ def test_rotated_freeslip_boundary_normal_traction_solcx(): assert relL2 < 0.10, f"sigma_nn relL2 vs analytic sigma_yy {relL2:.3f} too large" +@pytest.mark.tier_a def test_rotated_freeslip_sigma_nn_lumped_no_overshoot(): """The default (lumped) sigma_nn de-smear is MONOTONE at the SolCx viscosity jump: its total variation matches the analytic (no Gibbs overshoot), whereas the @@ -962,6 +975,7 @@ def plaw_box_ref(): return mesh, np.copy(vE.data), its +@pytest.mark.tier_a def test_rotated_freeslip_nonlinear_matches_essential(plaw_box_ref): """Frozen (Picard) tangent through the rotated path: genuinely iterates and converges to the native essential nonlinear free-slip answer (both impose v_n=0 — @@ -989,6 +1003,7 @@ def test_rotated_freeslip_nonlinear_matches_essential(plaw_box_ref): assert leak < 1e-10, f"{lab} wall-normal velocity {leak:.2e} not machine-zero" +@pytest.mark.tier_a def test_rotated_freeslip_newton_tangent(plaw_box_ref): """consistent_jacobian=True is genuine Newton through the rotated path: it converges in about the same small iteration count as the native essential Newton @@ -1007,6 +1022,7 @@ def test_rotated_freeslip_newton_tangent(plaw_box_ref): assert np.linalg.norm(vR.data - vE) / np.linalg.norm(vE) < 1e-6 +@pytest.mark.tier_a def test_rotated_freeslip_continuation_tangent(plaw_box_ref): """consistent_jacobian='continuation' works through the rotated path: a staged Picard→Newton solve (α=0 to the loose newton_switch_rtol, then α=1) that switches @@ -1031,6 +1047,7 @@ def test_rotated_freeslip_continuation_tangent(plaw_box_ref): "picard did not extend the continuation Picard phase") +@pytest.mark.tier_b def test_rotated_continuation_warm_start_switches_to_newton_at_once(plaw_box_ref): """A warm re-solve under consistent_jacobian='continuation' is already in Newton's basin, and must switch at once. The switch is measured against the @@ -1056,6 +1073,7 @@ def test_rotated_continuation_warm_start_switches_to_newton_at_once(plaw_box_ref assert info["nonlinear_iterations"] <= 4, info["nonlinear_iterations"] +@pytest.mark.tier_b def test_rotated_solve_records_its_diagnostics(plaw_box_ref): """The rotated solve reports what it measured, so a campaign can record it per step without scraping verbose logs: the rest-state residual ‖F̂(0)‖ and the @@ -1094,6 +1112,7 @@ def check(info): assert alpha[-1] == 1.0, alpha # a full step is accepted at the solution +@pytest.mark.tier_a def test_rotated_freeslip_picard_under_newton_stages_picard_then_newton(plaw_box_ref): """picard>0 under the consistent-Newton tangent (the default since 2026-10-05) stages at least N Picard iterations, then Newton, on the blended Jacobian solve() compiles @@ -1113,6 +1132,7 @@ def test_rotated_freeslip_picard_under_newton_stages_picard_then_newton(plaw_box assert np.linalg.norm(vR.data - vE) / np.linalg.norm(vE) < 1e-6 +@pytest.mark.tier_a def test_rotated_freeslip_nonlinear_warm_start(plaw_box_ref): """Warm-start (zero_init_guess=False) through the nonlinear rotated path: a 2-step 'time loop' re-solving from the previous converged state stays correct and @@ -1128,6 +1148,7 @@ def test_rotated_freeslip_nonlinear_warm_start(plaw_box_ref): assert s._rotated_freeslip_info["nonlinear_iterations"] <= 3 +@pytest.mark.tier_a def test_rotated_freeslip_nonlinear_geometric_fmg(): """The NONLINEAR rotated free-slip velocity block is driven by geometric FMG on the custom prolongation (set_custom_fmg) — the rotated prolongation is built once @@ -1154,6 +1175,7 @@ def test_rotated_freeslip_nonlinear_geometric_fmg(): assert rel < 5e-3, f"nonlinear FMG rotated free-slip differs from essential by {rel:.2e}" +@pytest.mark.tier_a def test_rotated_freeslip_nonlinear_annulus_zero_leakage(): """Genuinely-rotated frame under nonlinear iteration: a power-law annulus with per-node radial free-slip on both arcs genuinely iterates, gives machine-zero @@ -1195,6 +1217,7 @@ def test_rotated_freeslip_nonlinear_annulus_zero_leakage(): assert leak < 1e-10, f"{lab} radial leakage {leak:.2e} not machine-zero" +@pytest.mark.tier_a def test_rotated_freeslip_dynamic_topography_field(): """dynamic_topography writes h = -(sigma_nn - mean)/(rho g) onto a scalar surface MeshVariable (the free-surface hand-off): the field at the top vertices reproduces @@ -1266,6 +1289,7 @@ def _annulus_datum_solve(mode, tag): return mesh, v +@pytest.mark.tier_a def test_rotated_freeslip_datum_zero_matches_freeslip(): """datum={'Upper': 0} (the prescribed-datum path with a zero datum) reproduces plain rotated free-slip to iterative round-off — the held / free-slip case is untouched. @@ -1277,6 +1301,7 @@ def test_rotated_freeslip_datum_zero_matches_freeslip(): assert d < 1e-10, f"zero datum perturbs the free-slip solution by {d:.2e} (> round-off)" +@pytest.mark.tier_a def test_rotated_freeslip_prescribed_normal_datum(): """A prescribed non-zero wall-normal datum u.n = cos(theta) is imposed to the SAME (machine) precision as u.n = 0 at the constrained surface nodes.""" @@ -1293,6 +1318,7 @@ def test_rotated_freeslip_prescribed_normal_datum(): assert vn.max() > 0.9 and vn.min() < -0.9, "prescribed normal velocity magnitude wrong" +@pytest.mark.tier_a def test_rotated_freeslip_nonlinear_prescribed_normal_datum(): """The prescribed wall-normal datum u.n = cos(theta) through the NONLINEAR rotated path (power-law annulus): the loop genuinely iterates and every iterate is kept @@ -1336,6 +1362,7 @@ def test_rotated_freeslip_nonlinear_prescribed_normal_datum(): assert vn.max() > 0.9 and vn.min() < -0.9, "prescribed normal velocity magnitude wrong" +@pytest.mark.tier_a def test_rotated_solve_fields_carry_inhomogeneous_dirichlet_walls(): """The copy-back gap: essential DOFs are absent from the global vector, so the rotated path's field scatter left them at ZERO wherever the Dirichlet @@ -1377,6 +1404,7 @@ def test_rotated_solve_fields_carry_inhomogeneous_dirichlet_walls(): assert np.abs(vd[mask, 1]).max() < 1e-10 +@pytest.mark.tier_a @pytest.mark.parametrize("use_lu", [False, True]) def test_the_rotated_solve_reports_its_own_convergence(use_lu): """The converged reason must describe the ROTATED solve, not a linear step. diff --git a/tests/test_1093_ve_etd_expression_viscosity.py b/tests/test_1093_ve_etd_expression_viscosity.py deleted file mode 100644 index 08da7ce76..000000000 --- a/tests/test_1093_ve_etd_expression_viscosity.py +++ /dev/null @@ -1,62 +0,0 @@ -"""The ETD Maxwell integrator with a spatially varying viscosity. - -The exponential (ETD) coefficients alpha = exp(-dt/tau) and phi are formed -from the Maxwell time tau = eta/mu. When the viscosity is a sympy EXPRESSION -of position (a damage band, a temperature-dependent viscosity) rather than a -number, the coefficient update could not take its float, fell back to the -viscous limit (alpha = phi = 0) and the model silently lost its elastic -response: a block under a step traction moved at the steady viscous rate -instead of taking the elastic jump (measured on the box fault study, -2026-10-17: first-step end velocity 7e-3 against 6e-2). - -The check: a viscosity written as an expression whose VALUE is uniform must -give the same first step as the constant it equals; and a genuinely varying -viscosity must not collapse onto the viscous limit. -""" -import numpy as np -import pytest -import sympy - -import underworld3 as uw - -pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] - - -def _first_step_end_velocity(eta_expr, dt=0.125, tag="a"): - mesh = uw.meshing.UnstructuredSimplexBox(minCoords=(0.0, 0.0), maxCoords=(3.0, 1.0), - cellSize=0.25, regular=False, qdegree=3) - x, y = mesh.X - v = uw.discretisation.MeshVariable(f"v_{tag}", mesh, 2, degree=2) - p = uw.discretisation.MeshVariable(f"p_{tag}", mesh, 1, degree=1) - stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) - stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( - stokes.Unknowns, order=1, integrator="etd", objective_rate="none") - stokes.constitutive_model.Parameters.shear_modulus = 1.0 - stokes.constitutive_model.Parameters.shear_viscosity_0 = eta_expr(x, y) - stokes.bodyforce = sympy.Matrix([0.0, 0.0]) - stokes.tolerance = 1e-8 - stokes.add_dirichlet_bc((0.0, 0.0), "Left") - stokes.add_dirichlet_bc((None, 0.0), "Bottom") - stokes.add_natural_bc(sympy.Matrix([[-0.01, 0.0]]), "Right") # tension on the free end - stokes.solve(timestep=dt, zero_init_guess=True) - pts = np.array([[2.95, 0.5]]) - return float(np.asarray(uw.function.evaluate(v.sym, pts)).reshape(-1, 2)[0, 0]) - - -def test_uniform_expression_matches_the_constant(): - u_const = _first_step_end_velocity(lambda x, y: 1.0, tag="c") - # numerically uniform, but a sympy expression of position: float() of it fails - u_expr = _first_step_end_velocity(lambda x, y: 1.0 + 1.0e-12 * x, tag="e") - assert abs(u_expr - u_const) < 1e-6 * abs(u_const), (u_const, u_expr) - - -def test_varying_viscosity_keeps_the_elastic_response(): - # a weak band in the middle third: the response must stay near the elastic - # jump of the constant case (the band is small), not fall to the viscous rate, - # which is an order of magnitude smaller at this dt (tau = 1 period, dt = 1/8) - u_const = _first_step_end_velocity(lambda x, y: 1.0, tag="c2") - u_visc = _first_step_end_velocity(lambda x, y: 1.0, dt=1.0e6, tag="v") # dt >> tau: viscous limit - u_band = _first_step_end_velocity( - lambda x, y: 1.0 - 0.99 * sympy.exp(-((x - 1.5) / 0.1) ** 2) * sympy.exp(-((y - 0.5) / 0.1) ** 2), tag="b") - assert u_visc < 0.3 * u_const # the two limits are distinct here - assert u_band > 0.7 * u_const, (u_const, u_visc, u_band) # the band did not make it viscous diff --git a/tests/test_1098_plex_topology_tables.py b/tests/test_1098_plex_topology_tables.py index a51d2dc8f..bc72d5184 100644 --- a/tests/test_1098_plex_topology_tables.py +++ b/tests/test_1098_plex_topology_tables.py @@ -15,7 +15,7 @@ import underworld3 as uw from underworld3.utilities import _plex_topology as pt -pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] +pytestmark = [pytest.mark.level_1, pytest.mark.tier_b] # proven, not yet hardened @pytest.fixture(scope="module", params=[2, 3], ids=["2d", "3d"]) diff --git a/tests/test_1100_element_table_matches_closures.py b/tests/test_1100_element_table_matches_closures.py index b6bf92dfb..d3d287360 100644 --- a/tests/test_1100_element_table_matches_closures.py +++ b/tests/test_1100_element_table_matches_closures.py @@ -19,7 +19,7 @@ import underworld3 as uw from underworld3.utilities._plex_topology import closure_points -pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] +pytestmark = [pytest.mark.level_1, pytest.mark.tier_b] # proven, not yet hardened def _annulus_surface(): From 799ef0211535d303c84e6d227bb66dd7376aa6c3 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Thu, 29 Oct 2026 17:19:56 +1100 Subject: [PATCH 59/59] Faults-in-convection guide: the chain's 2S ladder sits on the finest base level The prose placed the 2S and 4S ladders one and two levels below the finest. The guide's own code (host = base_levels[len - k] for k = chain..1) and its cell counts (lean chain 642, 2924, 10950, 11612) put 2S on the finest base level beside the S ladder and 4S one level down; the coarser levels stay welded. Underworld development team with AI support from Claude Code --- docs/developer/guides/faults-in-convection.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/developer/guides/faults-in-convection.md b/docs/developer/guides/faults-in-convection.md index 10e7dc9a1..00d86dc0e 100644 --- a/docs/developer/guides/faults-in-convection.md +++ b/docs/developer/guides/faults-in-convection.md @@ -25,9 +25,10 @@ WELDED hierarchy (A in `design/FAULT_MULTIGRID_HIERARCHIES.md`): only the finest carries the fault. Its coarse spaces cannot represent the slip, the weak bridge or the ladder cells, and the velocity sub-solve caps at 200 iterations once Newton starts on a mobilising lid. A placed ladder is not replayed onto the levels by `add_fault`, so the -split levels have to be built by hand: the same ladder at twice and four times the rung -spacing, placed on the base levels one and two steps below the finest and split along -their own rungs, as the mid levels. +split levels have to be built by hand as the mid levels: the same ladder at twice the +rung spacing placed on the finest base level (beside the faulted mesh's own ladder), +and at four times the rung spacing on the base level one step below, each split along +its own rungs. The coarser base levels stay welded. ```python from underworld3.utilities.custom_mg import set_custom_fmg