Skip to content

Commit 4968cd1

Browse files
authored
Merge branch 'development' into bugfix/topography-penalty-633
2 parents e8dc357 + 5aa513c commit 4968cd1

26 files changed

Lines changed: 3361 additions & 178 deletions

.claude/skills/nonlinear-solver/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ case felt like whack-a-mole. Check them first.
7777

7878
| Trap | Symptom | Fix |
7979
|---|---|---|
80-
| Consistent Newton makes the velocity block **non-symmetric**; a Chebyshev/Richardson MG smoother assumes SPD | smoother diverges / stalls → `DIVERGED_LINEAR_SOLVE` or an endless grind | **Now the default**the FMG bundle ships `mg_levels_ksp_type=gmres` + `pc_type=sor` + `norm_type=none`. Only an issue if you override it, or on GAMG (which uses PETSc's chebyshev default) |
80+
| **Perfect plasticity's consistent tangent is SINGULAR along the flow**: on the hard-`Min` plastic branch η = τ_y/2ε̇_II, so 2η + 2η′ε̇_II = 0 — the velocity block is symmetric but semi-definite in every yielded cell. (An earlier version of this row blamed *asymmetry*; that is wrong for any η(ε̇_II) law — the rank-one term η′ ε̇⊗ε̇/ε̇_II is symmetric. Pressure-dependent yield adds a non-symmetric v–p coupling, not a non-symmetric velocity block. Corrected 2026-08-26, maintainer review.) | benign while yielded cells are few (the viscous neighbours regularise); with a large yielded fraction the velocity sub-solve caps out and Newton stalls at ~1e-3, no failure reason | give the plastic branch a positive tangent: a small δ soft-min (`yield_mode="softmin"`, powermean, `yield_anchor="yield"`), a rounded viscosity floor, or rate-strengthening ξ; Picard converges regardless (full 2η stiffness) but is linear-rate. The FMG bundle's `gmres`+`sor` smoother is Newton-safe either way |
8181
| `preconditioner="fmg"` (vs explicit `pc_type=mg` + manual mg opts) | outer KSP "converges" in **1 iteration** → no real Newton correction → stall → `DIVERGED_LINE_SEARCH` | use explicit `pc_type=mg` with the smoother opts above; bound the outer KSP (`ksp_max_it`~80) so a hostile step fails fast |
8282
| Cold plastic start `v=0`, or any rigid/unyielded point | `DIVERGED_FNORM_NAN` at iteration 0 | **Not** a div/0: `ε̇=0` gives `η_pl=+inf`, which `Min` and the sqrt soft-min carry correctly to the viscous branch. Only a soft-min form that computes `η_ve·η_pl/(η_ve+η_pl)` breaks (`inf/inf`). Fixed in the power-mean; if you hand-roll a blend, write the harmonic mean as `η_ve/(1+η_ve/η_pl)`. **Do not reach for a strain-rate floor** — it hides this rather than fixing it |
8383
| LU velocity block with all-Dirichlet-ish BC | pressure nullspace singular | attach the Stokes nullspace / avoid a bare LU there |

docs/advanced/fault-networks.md

Lines changed: 194 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,113 @@ net = uw.meshing.FaultNetwork(
1515
[("Main", main_pts), ("Splay", splay_pts), ("Cross", cross_pts)],
1616
hierarchy=["Main", "Splay", "Cross"]) # seniority order
1717

18-
mesh = net.prepare(h=0.006).build() # junctions -> mesh -> split
18+
mesh = net.prepare(h=0.006).build(width=0.01) # junctions -> mesh -> split
1919

2020
v = uw.discretisation.MeshVariable("V", mesh, 2, degree=2)
2121
p = uw.discretisation.MeshVariable("P", mesh, 1, degree=0,
2222
continuous=False)
2323
stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p)
24-
stokes.constitutive_model = uw.constitutive_models.ViscoPlasticFlowModel
25-
stokes.constitutive_model.yield_mode = "min"
26-
stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0
27-
stokes.constitutive_model.Parameters.yield_stress = \
28-
net.damage_yield(v, dial=0.05) # the junction glue
29-
stokes.consistent_jacobian = True
30-
net.apply_contact(stokes) # no-opening pairs, all pieces
24+
stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel
25+
stokes.constitutive_model.Parameters.shear_viscosity_0 = \
26+
net.junction_patch(eta_0=1.0) # the junction glue (linear)
27+
net.apply(stokes) # no-opening pairs, all pieces
3128
# ... wall boundary conditions ...
3229
info = net.solve(stokes)
3330
print(net.slips(stokes)) # peak slip per piece
3431
```
3532

33+
## One specification, two realisations
34+
35+
A fault is specified once — a trace, its rank in the hierarchy, and the
36+
properties it carries — and then *realised*. Which realisation you get
37+
is a keyword on `build`, not a different set of calls:
38+
39+
```python
40+
net.prepare(h=0.006)
41+
mesh = net.build(width=0.01) # cut, node-pair contact
42+
mesh = net.build(width=0.002, realisation="ti") # volumetric weak plane
43+
net.apply(stokes, eta_1=0.01) # eta_1: TI only
44+
```
45+
46+
Both realisations place the same ribbon band along the same prepared
47+
pieces, so the cells are identical and results from the two may be
48+
compared directly. The band is meshed around the trace's own points and
49+
segments, which become mesh vertices and edges, so **the mesh can be cut
50+
whatever the width is** — measured complete, with exact vertex
51+
coincidence, down to a band a tenth of the background element size. The
52+
realisation is a free choice, not something the mesh grants or refuses.
53+
54+
What differs is what `width` *means*. For the split it is a resolution
55+
parameter: the band exists to give the cut its own vertices, and its
56+
thickness is not physics. For the weak plane it is constitutive — the
57+
layer thickness that sets the slip rate through `V = 2 e_nt w` — so it
58+
wants two or three elements across it. That is the whole asymmetry, and
59+
it is about the rheology rather than the mesh.
60+
61+
`slips()` reports each realisation in its own quantity: the tangential
62+
jump between the two nodes of a cut pair, or the jump in tangential
63+
velocity across the layer, sampled one half-width plus a cell either
64+
side of the spine. Both are the fault's own throughput; a probe placed
65+
further out reads the surrounding flow as well and over-reads short
66+
strands. The gauge is rank-local: each rank reports only the probe
67+
pairs it owns and omits a piece it holds no pair of, so a
68+
max-reduction across ranks recovers the network's answer. This
69+
matters because `evaluate` answers for any point it is handed,
70+
extrapolating from the nearest local cell when the point is not in
71+
the local mesh — a band-less rank would otherwise report a far-field
72+
extrapolation as the band's slip.
73+
74+
`build(width=None)` keeps the older no-band path — graded refinement
75+
cut directly. It is split-only, and its mesh is not the one a weak
76+
plane would use, so do not compare across that choice.
77+
78+
**The band is material, not scaffolding.** It is easy to read the band
79+
as something the weak plane needs and the split merely tolerates. It is
80+
not: the band is a meshed region of material *around* the fault, and a
81+
segmented fault does its interesting work exactly there — at the strand
82+
tips, and in the ligaments where one cut stops short of the next. Damage
83+
in those places needs cells to live in, and the band is where they are.
84+
`net.band` is the mask, `net.footprints` the per-strand ones, in either
85+
realisation.
86+
87+
`net.band_yield(tau_y)` gives a rheology for the whole band: von
88+
Mises yield confined to it, everything outside set far too strong to
89+
yield. Read the next paragraph before using it as glue.
90+
91+
```python
92+
stokes.constitutive_model = uw.constitutive_models.ViscoPlasticFlowModel
93+
stokes.constitutive_model.Parameters.yield_stress = net.band_yield(4.0)
94+
stokes.consistent_jacobian = True
95+
```
96+
97+
A released fault flank sits far below `tau_y` and is untouched, so
98+
the breakdown appears where the mechanics puts it — but that is tips
99+
and bends as much as joints. Measured on the S-fault rig: at the
100+
strength that repairs a stepover, more than half the yielded band cells
101+
were on strand flanks and free tips, and one step weaker the whole
102+
main strand had become a weak fault. A uniform threshold cannot pick
103+
out the welds alone, because the stress concentration at a weld is not
104+
far enough above the tip and bend concentrations. `band_yield` is a
105+
damage model for the band; the junction glue is `junction_patch`.
106+
107+
The two realisations' interface parameters correspond, which is worth
108+
keeping in view when comparing them: the zero-thickness limit of a band
109+
of viscosity `eta_band` and width `w` is an interface viscosity
110+
`eta_f = eta_band / w`, which is the `conds` argument of
111+
`add_fault_bc`. The weak plane's `V = 2 e_nt w` is precisely what the
112+
contact replaces with a genuine slip rate.
113+
114+
**Properties belong to the fault.** `net.surface(name)` returns the
115+
retained {class}`~underworld3.meshing.surfaces.Surface` for a piece.
116+
Friction, accumulated slip, a damage state live there, on the fault,
117+
and outlive any one realisation of it:
118+
119+
```python
120+
main = net.surface("Main")
121+
friction = main.add_variable("mu", size=1)
122+
friction.data[:] = 0.6
123+
```
124+
36125
## The recipe, and why each piece is the way it is
37126

38127
**Hierarchy.** At an X crossing the senior fault runs through and the
@@ -48,17 +137,70 @@ same answer when the junction patch is refined 2x. Make the join as
48137
small as the mesh allows; buy fidelity with elements, not physical
49138
size.
50139

51-
**The glue.** `damage_yield` places a compact viscoplastic plug at
52-
each junction: yield `dial * (1 + 2 * edot_II)` inside, effectively
53-
rigid outside, sharp `Piecewise` boundaries. The strength and the
54-
rate-regularisation move together on ONE dial (separating them makes
55-
the solve harsh without making the zone weaker). Zone stress is
56-
proportional to the dial down to a ~100x viscosity contrast with
57-
Newton-from-cold still converging — the compact plug conditions like a
58-
hole, not like a thin weak layer, so the classic thin-inclusion
59-
Schur breakdown never appears. `dial=0.05` is near-invisible in the
60-
stress field at unchanged cost; `dial=0.01` reaches the transmission
61-
ceiling of an inviscid plug at roughly double cost.
140+
**The glue, and where it goes.** The split only goes wrong at the
141+
joints: away from them the cut *is* the target every volumetric
142+
representation converges to, and adding weakness along a whole strand
143+
makes the fault over-weak in a way that depends on the band width. So
144+
the glue is placed, not found. `junction_cells()` reads the places off
145+
the mesh itself: the ribbon (the band with its extrapolated margins)
146+
is everything the weak-plane realisation would treat as fault, the cut
147+
chains are what the split sliced, and a band cell whose nearest spine
148+
point lies in a piece's margin *and* which sits inside a second
149+
piece's ribbon is where two pieces meet without being joined — a
150+
kissing branch, an abutting pair, the intact bridge of a stepover.
151+
Free tips are excluded on purpose: a margin that runs into intact
152+
material is a tip, and damage there lengthens the fault instead of
153+
joining it (measured: with the free tips included, nearly every
154+
yielded cell was at a tip and the main strand grew 1-6% longer in
155+
slip). The cells are dilated by one vertex ring, and that ring is
156+
not optional: the weld's stiffness lives in the intact material
157+
around the two tips, and the bare junction cells recover only a
158+
fifth to a quarter of the deficit even when fully plastic.
159+
160+
Two pieces that continue one another along a line — an abutting pair,
161+
a stepover's continuation — are placed on **one spine**: two ribbons
162+
laid along the same line interleave their vertices into sliver cells
163+
(measured: 7800 cells below 1e-6 in area, and the velocity solve
164+
five times slower). `build()` groups such pieces (end tangents within
165+
25 degrees, the far start within half a width of the line, within the
166+
margins' reach), bridges the gap with spine vertices at the local rung,
167+
and cuts each piece at its own ends; the gap is spine the split does
168+
not cut, which is exactly what the junction rule reads.
169+
170+
For the rule to see a joint, the ribbons have to meet across it.
171+
`build()` sees to that: at an end that sits on a prepared junction the
172+
tip margin is extended until the ribbon reaches the other piece's cut,
173+
so the whole ligament lies in both ribbons; free tips keep the default
174+
margin. An abutting pair that `prepare()` did not record as a junction
175+
(a gap wider than the ligament) is covered as far as the default
176+
margins overlap — a gap wider than that is two faults, and stays
177+
welded, which is what a gap of intact rock means.
178+
179+
`junction_patch(eta_0, ratio=0.01)` then makes those cells weak
180+
isotropic material, `eta = ratio * eta_0`. A viscosity ratio rather
181+
than a yield stress, because the joint only has to be broken and a
182+
ratio needs no stress scale — nothing about the block or the loading
183+
has to be known to set it. Measured against the two end members on
184+
the S-fault rig (the fault *longer*, one continuous cut, and the fault
185+
*cut*, abutting cuts, at two resolutions): the patch recovers
186+
0.8-0.97 of the continuous fault's transmission across the joint; the
187+
slip crosses on the cut itself (the segment's pair jump reaches the
188+
continuous fault's); the rest of the network keeps the split's answer
189+
(main strand within 2.5%); the weak patch reproduces a fully plastic
190+
patch on the same cells to 1-2% and is insensitive to the ratio from
191+
0.01 to 0.001; the solve is linear and costs the split's velocity
192+
iterations, with only the pressure block noticing the contrast
193+
(hence 0.01, not smaller). Gluing a joint does change the partition
194+
between the strands that meet there — a reconnected main line takes
195+
back slip a through-going branch was carrying past the weld — which
196+
is the junction working, not the patch leaking.
197+
198+
`damage_yield` is the older glue: a viscoplastic plug of radius
199+
`max(2.5 h, 1.2 pull)` at each *prepared* junction point, yield
200+
`dial * (1 + 2 * edot_II)` inside, strength and rate-regularisation on
201+
one dial, sharp `Piecewise` boundaries. It stays available for
202+
studies of the glue itself, and it does not see stepover bridges,
203+
which are not prepared junctions.
62204

63205
**No prescribed reconnection.** Nothing tells the network how to link
64206
up: the stress lobes of the abutting tips decide. A collinear gap
@@ -120,15 +262,45 @@ directly. Place is currently OPT-IN for networks: on graded
120262
solve is pathological — an open operator-health work item; on uniform
121263
bases it is healthy.
122264

265+
`build(width=...)` gives the 3-D network the same finite-width
266+
contract as 2-D: the margin-expanded patches are thickened by
267+
`±width/2` into ONE fused band (junctions free), and each un-expanded
268+
patch is embedded in the band as a conforming mid-surface — so the
269+
same mesh is cut and split (`realisation="split"`) or left whole for
270+
the volumetric weak plane (`realisation="ti"`), exactly as in 2-D.
271+
The honoured footprints are exact for planar patches (band cells
272+
within the patch's own in-plane outline and half a width of its
273+
plane), the weak-plane director is the patch normal, and `slips()`
274+
reports the plane form of each gauge: the tangential pair jump for
275+
the split, the in-plane velocity jump across the layer for the weak
276+
plane. Junction glue in 3-D remains `damage_yield`'s tubes about the
277+
junction segments — `junction_cells` is the 2-D ribbon rule and
278+
refuses in 3-D. Interior networks only: a band that reaches the
279+
domain boundary is refused loudly (its embedded mid-surface cannot
280+
yet be clipped against the boundary).
281+
123282
v1 scope, refused loudly outside it: planar patches (the
124283
`rim_polygon` contract), convex rims, genuine X crossings (a
125284
near-miss — close but not crossing — is refused rather than guessed
126-
at); parallel MULTI-fault splitting (the pairing does not yet migrate
127-
through redistribution — single faults are parallel-validated).
285+
at). Multi-fault networks split and solve in parallel: `split_faults`
286+
redistributes ONCE, keyed on the union of the network's facets, and
287+
every split then runs with serial topology. Either realisation runs
288+
its velocity block on the geometric multigrid tail the band's base
289+
mesh owns (`build` adopts it on the final mesh; `net.solve` says so
290+
when a solve falls back to algebraic multigrid). Placement is
291+
gather-first, so the cells gmsh fills into the carved cavity — the
292+
band and its graded surround — live on one rank; only the base's far
293+
field is balanced. On the crossing-patches fixture that is 8012 of
294+
8405 cells on one rank at np=4, and the solve is not faster than
295+
serial: parallel is a correctness mode for this path, not a speed-up,
296+
until the placed region is rebalanced.
128297

129298
## Limitations
130299

131-
- 3-D: planar convex patches, X crossings only, serial (above).
300+
- 3-D: planar convex patches, X crossings only (above). Finite-width
301+
bands are interior-only (an outcropping band is refused), and
302+
`junction_cells` is the 2-D ribbon rule — 3-D junction glue is
303+
`damage_yield`'s tubes.
132304
- One damage dial per network in `damage_yield` (per-junction values:
133305
build the expression with `uw.meshing.damage_zone_yield` directly).
134306
- Time-dependent damage (wear-in/healing) is study-level for now: see

0 commit comments

Comments
 (0)