Barr & Houseman faulted-medium analytic solution in uw.analytic - #550
Conversation
The linear (n=1) plane-strain solution for a fault terminating inside a viscous medium, from the Appendix of Barr & Houseman 1996 (GJI 125, 473-490). Implementation follows that of @gthyagi, who has been using this solution for fault benchmarking. Why it is worth having: every fault result we have is currently measured against another discretisation — a gmsh-union ribbon control — so there is no absolute standard. This is one, and it has a fault in it: an internal boundary carrying zero shear traction, continuous normal velocity and continuous normal stress, with the tip inside the medium. The structure is the interesting part. In polar coordinates about the tip, the stream function separates into a Fourier series in m = q/2: whole-integer m is continuous deformation, half-integer m IS the fault discontinuity, and boundedness at r=0 admits only one negative index, m = -1/2. That single mode carries the whole singularity, which is why slip goes as sqrt(r) and stress as 1/sqrt(r). The exponents are a property of the fault's own Fourier mode rather than an assumption, and the test asserting the slip is carried entirely by that mode says so. The tests verify the field IS a Stokes solution rather than comparing it to a stored answer: div u = 0, the momentum balance vanishes identically in both components, zero shear traction on both faces of the fault, normal velocity continuous across it, and slip = 2 U0 sqrt(r/R0) — the paper's own normalisation. If those hold together the transcription is the solution, whatever a solver later does with it. That mattered here. The half-integer sine terms of u_theta appear with one sign in the paper's boundary datum (A8b) and the opposite sign in its solution (A9b). Incompressibility settles it: for u_r = A sqrt(R) f and u_theta = sqrt(R) g, div u = 0 forces g' = -(3/2) A f, which is (A8b)'s sign. Verified by flipping it — the Stokes test fails with a divergence of (0.75 cos(t/2) + 2.25 cos(3t/2))/sqrt(r). Plane strain only. The paper's thin-viscous-sheet solution (A10-A13) has non-zero in-plane divergence and is a different equation set from our incompressible Stokes, so it is not a benchmark for this solver. Not yet wired to a UW3 solve: the analytic domain is a disc with the fault running from the centre to the perimeter, and 2-D faults cannot reach a boundary yet (#549). The solution and its verification stand on their own until that lands. Underworld development team with AI support from Claude Code
There was a problem hiding this comment.
Pull request overview
Adds a new analytic benchmark solution (Barr & Houseman 1996, plane strain, Newtonian n=1) to uw.function.analytic, with tests that symbolically verify the expressions satisfy incompressible Stokes and the fault interface conditions. This provides an absolute (closed-form) reference for fault benchmarking, independent of discretisation.
Changes:
- Introduces
BarrHousemananalytic solution implementation (SymPy form + NumPy evaluator) and re-exports it underunderworld3.function.analytic. - Adds a focused test suite that verifies the solution by symbolic PDE/traction checks plus branch-cut/slip behaviour checks.
- Adds an import hook in the Cython analytic module to keep a single public analytic namespace.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| tests/test_0210_barr_houseman_analytic.py | New symbolic + numeric verification tests for the Barr–Houseman fault-tip solution. |
| src/underworld3/function/analytic.pyx | Re-exports BarrHouseman into the existing uw.function.analytic namespace. |
| src/underworld3/function/_barr_houseman.py | Implements the Barr–Houseman plane-strain (n=1) analytic solution with SymPy expressions and a NumPy evaluator. |
Suppressed comments (2)
src/underworld3/function/_barr_houseman.py:211
slip()will currently returnnanfor negativerwithout an explicit error, which can hide caller mistakes (unlikeevaluate(), which validates its inputs). Adding a simple non-negativity check makes the API fail-fast and keeps downstream results from silently becomingnan.
r = np.asarray(r, dtype=float)
return 2.0 * self.U0 * np.sqrt(r / self.R0)
src/underworld3/function/_barr_houseman.py:188
- The closed-form expressions are duplicated in both
_polar()(SymPy) andevaluate()(NumPy). Even with the cross-check test, this is a drift hazard (a future edit might update one side only). Consider extracting the shared formula into a single helper that takes{sin, cos, sqrt}callables (or lambdifying the SymPy expressions once and caching the resulting NumPy callables) so there is only one source of truth.
u_r = (self.U0 / 4) * (
R**2 * (np.sin(t) - np.sin(3 * t))
- R**3 * (2 * np.sin(2 * t) - 2 * np.sin(4 * t))
+ np.sqrt(R) * (np.cos(t / 2) + 3 * np.cos(3 * t / 2))
)
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # ------------------------------------------------------------------ sympy | ||
| @property | ||
| def symbols(self): | ||
| """The polar symbols ``(r, theta)`` the expressions are written in.""" | ||
| return sympy.symbols("r theta", positive=True) | ||
|
|
Adversarial reviewWe tried to break the transcription independently rather than re-running its own tests. Two coverage gaps found, both of which we closed by independent verification — the solution itself survives. 1. The symbolic Stokes check pins U0 = R0 = eta = 1, so a parameter-scaling error in the pressure would pass. A transcription with, say, a missing R0 power in 2. The PR text claims three fault conditions; the tests assert two. Zero shear traction and normal-velocity continuity are tested; normal-stress continuity is claimed and never asserted. We checked it: with the extension-positive convention, 3. tier_a on a new test file. The testing doctrine reserves tier A for explicitly reviewed tests. These are deterministic symbolic identities, and with this review plus the two additions above we would endorse the marking; without them it is self-assigned. What we could not fault: the branch-cut test is a genuine negative control (a bare Cross-PR note: the disc-with-daylighting-fault configuration this ultimately drives needs #552 (ribbon outcrop, now green) and #553 (transversal slit) — the interior verification here stands alone, as the description says. Underworld development team with AI support from Claude Code |
Both from the adversarial review on #550. The symbolic Stokes and fault-condition checks pinned U0 = R0 = eta = 1, so a parameter-scaling error could pass. Demonstrated rather than assumed: give the singular pressure term the wrong power of R0 and the unit-parameter momentum residual is exactly zero, while the same transcription with the parameters free gives U0 eta (1 - R0) cos(3 theta / 2) / (2 sqrt(R0) r^(3/2)) The checks now run with the parameters symbolic, which costs nothing and is a strictly stronger statement. BarrHouseman accepts SymPy parameters for the symbolic paths and refuses them, naming the reason, on the numeric ones. The paper states three fault conditions and the tests asserted two: normal-stress continuity was claimed in the description and never checked. It holds — with the extension-positive convention, sigma_tt = 2 eta e_tt + p has identically zero jump across the fault — and is now asserted, so the claim is load-bearing. Underworld development team with AI support from Claude Code
|
Both findings accepted and fixed in 006d0ae. Thank you — the first one was a real blind spot, not a theoretical one. 1. Parameter scaling. We did not take this on faith; we built the corruption you described and measured it. Giving the singular pressure term the wrong power of
So the test was blind in exactly the way you said. 2. Normal-stress continuity. Correct — claimed in the description, asserted nowhere. Now asserted, and it holds as you found: with the extension-positive convention 3. tier_a. Taking the endorsement as conditional on the two above, which are now in. If you would rather it sat at tier_b until @gthyagi has compared it against his implementation, say so and we will move it — the marking is not load-bearing for anything else. On the cross-PR note: #552 and #553 are better news than we knew when this was opened. The description's "not yet wired to a solve" paragraph is now the only thing standing between this and the full manufactured benchmark, and it stops being true as soon as #552 lands and #553 is decided. We have left the paragraph as-is rather than pre-announcing a capability that has not merged. Underworld development team with AI support from Claude Code |
Review finding on #550. `theta` carried `positive=True` while the fault conditions are checked at `theta = 0`, which that assumption excludes — SymPy would be within its rights to simplify a substitution the assumption says cannot occur. It is now `real=True`; `r` stays positive, which is honest since the solution is singular at the origin. The symbols were also rebuilt on every property access, so identity across calls depended on SymPy's global symbol cache rather than on us. Cached on the instance instead. Underworld development team with AI support from Claude Code
|
I independently verified the analytical solution on current head The important transcription point is slightly stronger than the PR description: BH96 equation A8b has
BH96 is an extension of BH92, but its Appendix disc is a separate exact The pressure sign in the PR also matches the papers' extension-positive convention; it must be negated only when comparing with a compression-positive numerical pressure. I built the current branch as an isolated macOS arm64 wheel and ran an independent SymPy verification script plus Result: 12 passed. I have verified the analytical implementation. |
|
I have verified only the analytical solution for BH96 so far. I’m currently building the numerical model and will compare its results with the analytical solution. Please keep this PR open until I complete the comparison. |
|
Follow-up: I also verified this against a numerical UW3 Stokes model with an actual mesh and MeshVariables. Because Numerical setup:
All three solves converged ( The pressure sign is also confirmed numerically: using the required opposite sign gives the convergent errors above, while comparing UW3 pressure directly with the paper's extension-positive pressure gives approximately 199% error at every resolution. Conclusion: the Mesh/MeshVariable Stokes solution converges to the PR's analytic field. This validates the analytical implementation numerically, but it does not claim that the current |
…ot both Every term of the velocity carries a positive power of r, so the limit at the fault tip exists and is zero. Refusing r = 0 for the velocity was over-strict, and it bit immediately: a mesh node lands exactly on the tip in any benchmark that puts the tip inside the domain, which is the whole point of the solution. evaluate_velocity now accepts the tip and returns zero there; evaluate_pressure refuses it and says why, since the pressure carries the r^(-1/2) term of the m = -1/2 mode and genuinely diverges. evaluate() returns both and inherits the pressure's refusal. Underworld development team with AI support from Claude Code
Prompted by Louis: a benchmark boundary does not have to be pinned everywhere, and leaving the normal component free on one wall is worth doing. With velocity Dirichlet on EVERY wall the pressure is determined only up to a constant, and the datum must additionally carry exactly zero net flux; a traction condition on one component removes both requirements rather than patching them. It is also what the paper does — Barr & Houseman's left-hand boundary carries a constant normal stress, not a prescribed normal velocity. evaluate_traction returns sigma . n with sigma = tau + p I, extension positive as the paper has it, refusing the tip where the stress diverges. The stress is built from SymPy-derived strain rates, lambdified once and rotated into Cartesian. The test is a cross-check rather than a restatement: it arrives at the zero-shear fault condition by this different route, with a negative control off the fault. Underworld development team with AI support from Claude Code
Additional BH92 near-tip comparisonI compared the finest completed BH92 UW3 rectangular-fault-zone model with the BH96 half-integer singular mode implemented in this PR. BH92 numerical model
BH96 normalisation
For the singular-exponent comparison, I fitted along the fault extension (
At For clarity, a single-power-law fit over the wider This provides an independent consistency check: the BH92 |
Rebase of PR #550 (commits 4c8caba..18f36a2) onto the package that landed in #571. The mathematics is @gthyagi's implementation, unchanged and not re-derived; what moved is where it lives and which conventions it is stated in. function/_barr_houseman.py -> analytic/barr_houseman.py class BarrHouseman -> class FaultedMedium, registered The naming follows kramer.py, the package's other single-paper module: module named for the authors, class named for the problem. The old import path needs no shim — BarrHouseman was never in the released compiled extension, so #571's shim correctly does not forward it. It is a full member of the contract: fields built through set_fields rather than around it, symbolic on mesh.X, swept by test_1024 and test_1028. Three ways it does not fit, stated rather than bent: Not a function of position. The field is multivalued about the tip, so the class carries two representations of one solution — the polar fault-frame expressions, where the fault conditions can be stated exactly at theta = 0 and 2 pi, and the contract's fn_* on mesh.X. A test pins the second to the first. The branch cut has to lie ON the fault; from tan(theta/2) = (r-x)/y that is 2*atan2(r-x, y), but near the fault r - x cancels and theta loses accuracy as 1/theta^2. Since (r-x)(r+x) = y^2, scaling both atan2 arguments by the positive (r+x) leaves the angle alone and removes it: measured at 1e-6 radians off the fault, the momentum residual is 3e-6 formed directly and 3e-15 formed this way, so sample_points can sit just off each face. No walls. apply_boundary_conditions refuses, naming the reason and handing over boundary_velocity(), fault_normal_velocity() and slip(). The fault is an internal boundary with two coincident faces (#549) and its conditions are per-component; applying the perimeter datum and leaving the fault unconstrained would solve a different problem and report a plausible error. No body force. Boundary-driven, like EllipticalInclusion, so the family's negative control cannot fire and it is excluded by name in both sweeps with the claim asserted. For this solution the momentum residual certifies the pressure sign instead: 3.6e-16 with UW3's compression-positive pressure, 1.06 with the paper's extension-positive one, while tr(sigma) + d p sits at 5.7e-16 either way. Their pressure is extension-positive and ours is not, so fn_pressure is minus their (A9c) — flipped once, and asserted against the printed form rather than left to a comment. Recorded as an erratum with the two defects @gthyagi found in printed A9b. Per-PR tier: 23s for the test file, about 6s across the two sweeps. Underworld development team with AI support from Claude Code
The analytic subsystem document gains the faulted medium: why it is worth having (the only absolute standard in the suite for a fault calculation), the three ways it does not fit the contract and what was done about each, the conditioning measurement behind the Cartesian theta, and the erratum. Also corrects the per-PR tier's file glob in the two-tiers table — it read test_102[0-8], and the new file is test_1029. The CI batch glob is tests/test_102*py, so the file was always covered; the table was not. Underworld development team with AI support from Claude Code
The pressure-sign negative control. The family pins its body-force convention by flipping the force and watching the momentum residual go to order unity; this solution has no force to flip, so without a substitute its momentum gate was only an assertion that a small number is small. Flipping the PRESSURE instead moves it from 3.6e-16 to 1.06, so the gate is demonstrably able to fail. Note that tr(sigma) + d p does not move: set_fields builds the stress from the pressure, so tracelessness cannot see the sign of a term it cancels by construction. The compiled path. The family's argument for one SymPy form is that it compiles — the same expression is a Dirichlet datum, a JIT kernel and an error norm. Here that rests on a Piecewise around two atan2 calls, which is exactly what a C printer could get wrong, so evaluate() is checked against the independent NumPy evaluator rather than assumed: they agree to 1.7e-15. This also shows how to place the disc inside a mesh, which is what the eventual benchmark will need. Underworld development team with AI support from Claude Code
|
@gthyagi — thank you for the verification; it is the most thorough external check anything in this suite has had. The A9b finding (printed sign and This PR went CONFLICTING through no fault of its own: #571 landed the What the integration required:
Gates on the installed package, at defaults and at Tests: 18 in the file (every original check preserved, including the symbolic-parameter strengthening from Not merging — waiting on your word that the comparison is complete. Underworld development team with AI support from Claude Code |
Rename analytic/barr_houseman.py to barr_houseman_96.py so the module path identifies the paper that supplies the closed-form Cartesian solution and cannot be confused with the related BH92 study. Update the package import and developer documentation, and add a focused assertion that uw.analytic.barr_houseman_96.FaultedMedium resolves to the registered public class.
|
I have updated the green integration branch in commit The explicit path is now: uw.analytic.barr_houseman_96.FaultedMediumThe year suffix distinguishes the closed-form Barr & Houseman (1996) solution from the related BH92 study, which does not provide the corresponding full Cartesian analytical field. The registered convenience API remains Verification on the rebuilt
This is a naming-only change; the analytical expressions and the completed BH92/BH96 comparison are unchanged. |
…itions (#578) Underworld development team with AI support from Claude Code
#578 replaced the boundary-condition mixins with composed functions, so the refusal's explanation now names what the solution actually needs — a component-wise Dirichlet condition on an internal boundary — rather than the two classes that no longer exist. Underworld development team with AI support from Claude Code
…ature/pr550-integrate
|
@gthyagi — heads up on two changes to Your rename is in (
free_slip(solver, boundaries, normal=None)
prescribed_velocity(solver, boundaries, velocity)
prescribed_scalar(solver, boundaries, field)
Worth knowing for the comparison you are running: under the mixins, a solution that refuses was an exception to the pattern. After #578 it is the ordinary shape — every solution states its own conditions, and this one's answer is "not yet these". Branch is at |
Numerical checks completedI rebuilt the updated integration branch at
Serial / 8-rank relative errors:
For the MPI comparison, field errors were evaluated on globally gathered and coordinate-deduplicated velocity and pressure DOFs, so partition-local interpolation does not enter the result. The updated branch preserves the analytical result and the serial and 8-rank numerical solutions are equivalent to numerical precision. My numerical comparison is complete. |
|
I'll take that as a completed review, @gthyagi and get this all merged away. |
PR #550 was written against the pre-#571 layout, where the analytic solutions were a compiled extension at function/analytic.pyx and a new elementary solution was re-exported from there. #571 moved the suite to underworld3.analytic and left a deprecation shim, so the PR's edits landed on a file that no longer holds that role — git followed the rename and carried the re-export onto analytic/_reference/_velic.pyx, which is the kernels module, not the namespace. The ported solution is analytic/barr_houseman_96.py (class FaultedMedium), already exported from analytic/__init__.py and registered in the family, so the re-export has nothing left to do and is removed. function/_barr_houseman.py and tests/test_0210_barr_houseman_analytic.py go with it: test_1029_analytic_faulted_medium.py carries all eight of 0210's tests under the same names and ten more, including the contract, registry and tip-off-origin gates the AnalyticSolution base requires. Underworld development team with AI support from Claude Code
Conflict resolved — the solution now sits in
|
analytic/barr_houseman_96.py |
the solution, class FaultedMedium, exported from analytic/__init__.py and registered in the family |
test_1029_analytic_faulted_medium.py |
18 tests |
| removed | function/_barr_houseman.py, tests/test_0210_barr_houseman_analytic.py, the _velic.pyx re-export |
test_1029 carries all eight of test_0210's tests under the same names, plus
ten more: the contract evaluation path, the registry membership, the
tip-off-origin residual gates, the sample-point placement, the refusal to
pretend the fault is a wall, and a negative control that flipping the pressure
sign breaks the momentum balance.
Before trusting the ported version we checked it method by method against this
branch's head rather than assuming: it carries evaluate_traction and the split
evaluate_velocity / evaluate_pressure from the two most recent commits here,
plus the AnalyticSolution contract methods the base class requires. @gthyagi —
if anything landed after e4f1d9bb that is on neither line, it is not here.
Verified: full ./uw test 1543 passed, 32 skipped, 2 xfailed; the analytic
contract and conformance suites 116 passed.
Underworld development team with AI support from Claude Code

The linear (
n = 1) plane-strain analytic solution for a fault terminating inside a viscous medium, from the Appendix of Barr & Houseman 1996 (GJI 125, 473–490).Implementation follows that of @gthyagi, who has been using this solution for fault benchmarking — hence the review request. A second independent implementation is the strongest check available on this, so please do compare against yours.
Why
Every fault result in the current campaign is measured against a gmsh-union ribbon control — that is, against another discretisation. There is no absolute standard. This is one, and it has a real fault in it: an internal boundary carrying zero shear traction, continuous normal velocity and continuous normal stress, with the tip inside the medium.
The structure is the interesting part
In polar coordinates about the tip, the stream function separates into a Fourier series in
m = q/2:m— continuous deformationm— the fault discontinuity itselfand boundedness of the velocity at
r = 0admits only one negative index,m = -1/2. That single mode carries the entire singularity, which is why slip goes assqrt(r)and stress as1/sqrt(r). The exponents are a property of the fault's own Fourier mode rather than an assumption, and one of the tests asserts exactly that — drop the half-integer term and the slip vanishes.The tests verify it IS a solution
Not a comparison against a stored answer, and not against a UW3 solve — symbolic checks that the field satisfies the equations:
div u = 0eta lap(u) + grad(p)0in both componentstau_r_theta = 0attheta = 0and2 piu_thetaequal on both faces2 U0 sqrt(r/R0), so2 U0at the perimeter — the paper's own anchorIf those hold simultaneously the transcription is the solution, whatever a solver later does with it.
A transcription trap, settled by mathematics
The half-integer sine terms of
u_thetaappear with one sign in the paper's boundary datum (A8b) and the opposite sign in its solution (A9b), and the same mismatch appears in the plane-stress pair (A12b vs A13b).Incompressibility settles it without anyone adjudicating a scanned minus sign: for
u_r = A sqrt(R) f(theta)andu_theta = sqrt(R) g(theta),div u = 0forcesg' = -(3/2) A f, which integrates to (A8b)'s sign. Verified by flipping it — the Stokes test then fails with a divergence of(0.75 cos(t/2) + 2.25 cos(3t/2))/sqrt(r), non-zero purely in the fault modes.@gthyagi — this is the specific point worth checking against your implementation.
Scope and conventions
d_j tau_ij + d_i p = 0. The module follows the paper and documents it; negate to compare against a compression-positive solver.B = 2 eta_0atn = 1(their eq 1), sotau = 2 eta edotas usual.arctan2(y, x) mod 2*pi. A bareatan2would put it on the negative x axis and silently return the wrong face; there is a test for that.Not yet wired to a solve
The analytic domain is a disc with the fault running from the centre to the perimeter — the fault reaches the boundary, which 2-D UW3 cannot do yet (#549). The solution and its verification stand on their own until that lands, and the benchmark can be connected immediately afterwards.
Tests:
test_0210_barr_houseman_analytic(7).test_1015_analytic_solcxandtest_1062_constrained_solcxpass unchanged (12 total); style gate clean.Underworld development team with AI support from Claude Code