Class GWMesh#
- class flow.gwplex.GWMesh(*args, **kwargs)[source]#
Water table (groundwater) + generic duricrust — opt-in near-surface hydrology and chemical armoring. See
docs/DESIGN_WATERTABLE_DURICRUST.mdand the technical guidedocs/tech_guide/groundwater.rst.Enabled by the YAML
groundwater:block (self.gwOn/self.duriOn); when absent, every path here is a no-op and goSPL is byte-identical to a run without it. The whole update runs once per step inupdateGroundwater(after flow accumulation, before erosion), so each process reads the previous process’s state — a slow, stable, explicit/sequential coupling.Water table. An implicit (backward-Euler) Dupuit-Boussinesq head solve on the DMPlex (
_solveHead):(I + (Δt/S)·L(T))·h = h_old + (Δt/S)·Rwith transmissivityT = Ksat·max(h − z_bed, b_min), a seepage free boundary (h ≤ zat sea / lakes / open outlets) and a dry-aquifer floor (h ≥ z_bed).z_bedis prescribed (aquifer_basescalar/map) or tied to the bedrock (aquifer_base: from_soil⇒lHbed − bedrock_depth). RechargeR = f_infil·max(0, rain − evap)is zeroed under water and ice. Opt-in baseflow closure returns the seepage discharge to the rivers (conserve_baseflow).Duricrust. A generic capillary-fringe crust (
_updateDuricrust) grows where the water-table depth sits in the fringe band (ΦGaussian onwt = z − h), fed by a weathering supplyΨ(climate proxy, an explicit Maher-Chamberlain rate, or the soil-production congruency; regolith-limited when soil is tracked). Its indurationduriF ∈ [0,1]armors erodibility through the single_surfaceArmoringKhook (1 − armor_max·duriF), which slows erosion of crusted cells and drives relief inversion. When stratigraphy is on, the induration is archived per layer (stratDuri) so buried crusts are preserved and re-arm on exhumation (stacked-duricrust / cratonic laterite behaviour).State & lifecycle. Persistent Vecs (
headL/headG,duriHL/duriHG,rechargeL,baseflowL) and the cached elliptic solver (_ksp_gw, fgmres + hypre BoomerAMG) are registered indestroy_DMPlex;headandduriHare model memory written to / restored from the output HDF5 on restart. Compatible with dual lithology and provenance (independent per-layer fields; armoring composes at the shared K hook).Methods
Per-step groundwater / duricrust update.
Initialise
__init__(*args, **kwargs)Initialise the groundwater / duricrust state.
Public Methods
Per-step groundwater / duricrust update.
Private Methods
Cached KSP for the elliptic head solve: fgmres + hypre BoomerAMG (algebraic multigrid).
Implicit (backward-Euler) Dupuit-Boussinesq water-table head solve (DESIGN_WATERTABLE_DURICRUST.md §2/§3).
_gwZbed(z)Aquifer-base elevation
z_bed(m).Per-node steepest-descent slope (m/m, ≥ 0) from the flow-direction receivers built by
flowAccumulation—(z − z_rcv)/distto the primary receiverrcvID[:,0]._baseflowClosure(hold, hnew, seep)Baseflow (seepage-return) accounting (DESIGN_WATERTABLE_DURICRUST.md §3 step 7), opt-in via
conserve_baseflow._lakeExchangeFlux(hloc, zbed, bmin, zeroKp)Signed groundwater flux exchanged with the surface at every node (m³/yr), stored in
self.gwLakeFluxfor the lake volume coupling (§15).Optional Arrhenius temperature factor for the weathering supply,
exp(Ea/Rg · (1/T_ref − 1/T))(dimensionless, 1 atT = T_ref).Solute-supply rate
Ψfeeding in-situ fringe precipitation (DESIGN_WATERTABLE_DURICRUST.md §3a).Evolve the generic capillary-fringe duricrust over
self.dt(DESIGN_WATERTABLE_DURICRUST.md §3, step 5).Rate (m/yr) at which weathering-produced regolith can feed the duricrust, the cap on chemical crust formation when soil is tracked (§8): the soil production rate
prodSoil · rain(chemical∝physical weathering congruency).Sync the live per-node induration
duriFwith the per-layer stratigraphic archivestratDuri(DESIGN_WATERTABLE_DURICRUST.md §9).Per-node current surface lithology class — the dominant provenance class of the top non-empty stratigraphic layer (from
stratP), falling back to the static bedrocksource_classwhere a column has no sediment.Resolve the per-species weatherability to a list of scalar or ``(lpoints,)`` array entries (Level-B extension 1 — lithology → chemistry).
Cached KSP for the solute-transport solve: a direct LU factorisation (
preonly+lu; MUMPS in parallel),gw_solute_prefix._soluteAdvecCoeffs(h, T)Upwind finite-volume coefficients for the steady solute advection
∇·(q c)by the groundwater fluxq = −T∇h(area-normalised, per cell)._dischargeWeight(divq, R)Absolute-accumulation gate (opt-in
discharge_gate): a per-node weightG ∈ [0,1]that restricts crust formation to groundwater discharge zones._solveSoluteTransport(source, dmask, dval)Solve one steady tracer transport
M c = sourcewith Dirichlet nodesdmaskpinned todval(M= upwind advection of §``_soluteAdvecCoeffs`` at the current head)._soluteSolveRHS(M, rhs, guess)Solve
M c = rhsfor the (pre-assembled) transport operatorMwith a warm-startguess, returning the local concentration (clipped ≥ 0).Level-B per-step solute update (G2), per tracer: dissolve → transport → precipitate → export, conservatively accounted.
Route the groundwater-exported (seepage/baseflow) solute down the surface drainage network to the shoreline — the river dissolved load (Level-B extension 2, DESIGN_GEOCHEM_EXTENSIONS.md §2), per species.
Public functions#
- GWMesh.updateGroundwater()[source]#
Per-step groundwater / duricrust update.
Recharge
R = f_infil * max(0, rain - evap)(m/yr), held at 0 under standing water (marineseaIDor a ponded continental lakepitIDs>-1 & lFill>hl) and under ice, where rain does not infiltrate. Stored inself.rechargeLfor output.Head solve (
_solveHead): the implicit Dupuit-Boussinesq water table from that recharge; setsself.wtDepth = z - h.Duricrust (
_updateDuricrust, only whenduriOn): evolve the capillary-fringe crustduriHand the indurationduriF/ armor multiplierduriKarmorfrom the new water-table depth.Geochemistry (
_updateSolute, only whengwGeochemOn— Level B): per tracer, dissolve → transport (downq = -T∇h) → precipitate at the fringe (feedingduriH, replacing the Level-A supply) → export; a closed mass budget with per-species crust typing, optional solute-source provenance and a dissolved ocean flux.River routing (
_routeRiverSolute, only whengwRiverLoad): route the exported solute down the surface drainage network to the coast (per species; optional in-transit loss + marine coupling).Stratigraphic archive (
_recordInduration, whenduriOnand stratigraphy is on): record the induration degree and, with geochem, the crust’s dominant species / source per layer (exhumation re-arm + formation write-down).
No-op when
gwOnis off. The head, solute-transport and river-routing solves are collective (KSP); recharge, duricrust and the archive are purely rank-local (per-node).
Private functions#
- GWMesh._makeGWKSP()[source]#
Cached KSP for the elliptic head solve: fgmres + hypre BoomerAMG (algebraic multigrid). The head operator
I + (Δt/S)·L(T)is a stiff, high-condition 2-D elliptic operator; block-Jacobi/ILU does NOT converge on it (it stalls atDIVERGED_ITSand returns a garbage iterate that drifts to the surface) — an elliptic solve needs a multigrid PC. Same rationale as the flexure biharmonic wanting a strong solver. Options prefixgw_scopes any override (env-gw_pc_type ...).
- GWMesh._solveHead()[source]#
Implicit (backward-Euler) Dupuit-Boussinesq water-table head solve (DESIGN_WATERTABLE_DURICRUST.md §2/§3). One elliptic solve per step:
\[(I + \tfrac{\Delta t}{S}\,L(T))\,h = h_{old} + \tfrac{\Delta t}{S}\,R\]with transmissivity \(T = K_h\max(h - z_{bed}, b_{min})\) (unconfined), \(L(T)=-\nabla\cdot(T\nabla)\) the FV neg-Laplacian (from
jacobiancoeff, area-normalised — the same operator the marine Picard solver uses). Reduces to the steady elliptic limit as \(\Delta t/S\) grows, so it is quasi-steady at century steps.Unconfined non-linearity
T(h):gwPicardItsPicard sweeps (lagTon the previous iterate; operator rebuilt per sweep).Seepage free boundary
h <= z: Dirichleth = zat the base seepage set (marineseaID+ ponded lakes + openoutletIDs) viazeroRowsLocal; after each Picard block, cliph = min(h, z)and add any newly-over-topped cell to the Dirichlet set, up togwSeepagePassespasses (break on anAllreduce’d new-seepage count — collective-safe).aquifer_baseis prescribed (scalar/map) OR, withaquifer_base: from_soil, tied to the bedrock elevationz_bed = lHbed − bedrock_depth(Phase 5 soil coupling;_gwZbed).Baseflow closure (opt-in
conserve_baseflow): the seepage-return discharge is accounted intoself.baseflowL(_baseflowClosure).
Scratch: uses
self.tmp(global rhs); leaves it defined. Writesself.headL/headGandself.wtDepth = z - h.
- GWMesh._gwZbed(z)[source]#
Aquifer-base elevation
z_bed(m). Prescribed byaquifer_base(scalar or per-vertex map,z_bed = z − aquifer_base), OR — withaquifer_base: from_soil(Phase-5 soil coupling) — tied to the bedrock elevationz_bed = lHbed − bedrock_depth, the “permeable regolith over impermeable bedrock” model.from_soilneedssoilSPL(lHbed); it falls back to the surface (a self-consistent no-aquifer floor) with a one-time warning if soil is off.Depositional basins (`from_soil` + stratigraphy). Under Option-2.5
lHbed = z − Lsoilis only the base of the thin weathering regolith; deposited sediment lives in the stratigraphy, notLsoil. So in a filled basin the porous sediment column is the aquifer and its base is the bottom of the (non-sentinel) stratigraphic pile, deeper thanlHbed. The base is therefore taken as the deeper (lower) oflHbed − bedrock_depthandz − Σ sediment thickness— so uplands (thin pile) keep the regolith base while basins deepen to the fill base. Rank-local.
- GWMesh._surfaceSlope()[source]#
Per-node steepest-descent slope (m/m, ≥ 0) from the flow-direction receivers built by
flowAccumulation—(z − z_rcv)/distto the primary receiverrcvID[:,0]. Flats / sinks / outlets (dist=0or a self-receiver) return 0. A cheap proxy for the optional slope-dependent infiltration (goSPL stores no explicit slope). Rank-local.
- GWMesh._baseflowClosure(hold, hnew, seep)[source]#
Baseflow (seepage-return) accounting (DESIGN_WATERTABLE_DURICRUST.md §3 step 7), opt-in via
conserve_baseflow. Over the quasi-steady step the net recharge that does not go into aquifer storage discharges back to the surface-water network at the seepage nodes:\[Q_{seep} = \sum_{owned}(R\,A) - \sum_{owned} S\,(h-h_{old})\,A/\Delta t\](m³/yr). The global budget is reduced across ranks (
Allreduce) and distributed over the owned seepage nodes weighted by cell area, soΣ_owned baseflowL ≈ Σ_owned rechargein the steady limit (ΔS→0) — river discharge stays≈ rain − evap. Stored inself.baseflowL(diagnostic/output); re-injection into the surface-flow source (making rivers physically baseflow-fed, which redistributes erosion) is the next increment. Collective (twoAllreduce); no rank-local collective gate.
- GWMesh._lakeExchangeFlux(hloc, zbed, bmin, zeroKp)[source]#
Signed groundwater flux exchanged with the surface at every node (m³/yr), stored in
self.gwLakeFluxfor the lake volume coupling (§15). It is the FV divergence of the lateral groundwater flow,∇·(T∇h)·A = −(L·h)·A(L= the area-normalised neg-Laplacian fromjacobiancoeff, soL·h = −∇·(T∇h)): positive = the aquifer discharges INTO the surface (a gaining lake), negative = the surface leaks INTO the aquifer (a losing lake). One extra operator assemble + mat-vec, only whenlake_exchangeis on. Uses the un-zeroed operator (the seepage-Dirichlet rows would otherwise null the flux exactly at the lake nodes we need).
- GWMesh._arrhenius()[source]#
Optional Arrhenius temperature factor for the weathering supply,
exp(Ea/Rg · (1/T_ref − 1/T))(dimensionless, 1 atT = T_ref).Returns
1.0(no temperature dependence) whenweather_Ea <= 0OR no temperature map is available — so duricrust runs soil-free by default. Reuses the soiltempMap(self.tempFile/tempData/tempRef) when present; loaded once and cached. Rank-local.
- GWMesh._weatheringSupply()[source]#
Solute-supply rate
Ψfeeding in-situ fringe precipitation (DESIGN_WATERTABLE_DURICRUST.md §3a). Three modes (duriWeatherMode); all plug in at the same place, so the formation ODE is mode-agnostic. Supply-only (no mass debit) — see §3a Level-A caveat. Rank-local.proxy (default):
Ψ = max(0, rain − evap)^p · arrhenius(T)— a climate/temperature stand-in, no new inputs.rate (Level A): Maher-Chamberlain kinetic×thermodynamic law
W = R·C_eq·(1 − exp(−Dw/(R·L)))·arrhenius·weatherabilitydriven by the rechargeRthe head solve already computes;L = LsoilwhensoilSPLis on (capped by the regolith production supply), else the prescribedpath_length.prodsoil: reuse the temperature-scaled
soilSPL.prodSoildirectly, scaled by water availability (the chemical∝physical congruency). Falls back to the proxy when soil is not tracked.
- GWMesh._updateDuricrust()[source]#
Evolve the generic capillary-fringe duricrust over
self.dt(DESIGN_WATERTABLE_DURICRUST.md §3, step 5). Per-node, rank-local — no collective;localToGlobalonduriHat the end for the halo.Fringe favourability
Φ = exp(−((wt − d0)/w)²)— a Gaussian band on the water-table depthwt = z − hcentred on the mean fringe depthd0(half-widthw); →1 at the fringe, →0 far above/below. With the opt-indischarge_gate(_dischargeWeight)Φis additionally restricted to groundwater discharge zones (absolute-accumulation crust).Formation
dduriH/dt = k_form·Φ·Ψ·(1 − duriH/duriH_max)(self-limiting toduriH_max);Ψfrom_weatheringSupply. When soil is tracked (cptSoil) the formation supply is additionally regolith-limited — capped by_regolithSupplyRate(chemical crust growth cannot outpace physical regolith production; §8 soil coupling).Breakdown: the per-step surface incision (
z_last − z > 0) strips the crust top (−k_break·incision), plus a slow disequilibrium decay away from the fringe (−k_decay·(1−Φ)·duriH). Clipped to[0, duriH_max](a crust eroded through resets to 0).
Writes
duriH(duriHL/duriHG), the indurationduriF = duriH/duriH_maxand the armor multiplierduriKarmor = 1 − armor_max·duriF(consumed by the Phase-4 erodibility hook).
- GWMesh._regolithSupplyRate()[source]#
Rate (m/yr) at which weathering-produced regolith can feed the duricrust, the cap on chemical crust formation when soil is tracked (§8): the soil production rate
prodSoil · rain(chemical∝physical weathering congruency). Returns+inf(no cap) whenprodSoilis unavailable, so a soil-off run is unaffected. Rank-local.
- GWMesh._recordInduration()[source]#
Sync the live per-node induration
duriFwith the per-layer stratigraphic archivestratDuri(DESIGN_WATERTABLE_DURICRUST.md §9). Two directions:Exhumation (read-up) — a previously buried, indurated layer now at the surface (its overburden eroded through last step) re-arms the live crust:
duriF = max(duriF, stratDuri[top])at the top non-empty layer (found as in_surfaceComposition), refreshingduriKarmor. The stacked-duricrust / relief-inversion behaviour of cratonic laterites.Formation (write-down) — the live crust has a real thickness
duriHthat spans a depth range, not just the surface layer, so the induration is written into every layer whose top lies within ``duriH`` below the surface:stratDuri[k] = max(stratDuri[k], duriF)for allkwithdepth_above(k) < duriH(depth_above= the summed thickness of the layers abovek). A thick crust therefore indurates several thin layers; each is preserved when later buried (deposeStratfresh layers start at 0) and re-arms the surface when re-exhumed — so an exhumed crust resists incision over its full thickness, not one layer’s worth.
With Level-B geochemistry on, the same write-down also stamps the crust’s dominant solute species into
stratCrustType(and, with in-model provenance, its dominant source region intostratCrustSource) across the crust’s depth range, so a stratigraphic section preserves what each crust layer is and where its chemistry came from — the categorical companions to thestratDuridegree. These are archive-only (no exhumation read-up).No-op (surface-only
duriF, no archive) whenstratDuriis unallocated (stratNb == 0). Composition-only — no geometry change. Rank-local (per-node); no collective.
- GWMesh._surfaceSourceClass()[source]#
Per-node current surface lithology class — the dominant provenance class of the top non-empty stratigraphic layer (from
stratP), falling back to the static bedrocksource_classwhere a column has no sediment. As erosion exhumes deeper layers (or deposition buries the surface under transported sediment of a different provenance), the top layer — and hence this label — changes, so asurface_class-driven weatherability tracks the rock actually exposed each step.Uses the same top-non-empty-layer scan as
_recordInduration/_surfaceComposition. Needs provenance (stratP); returns the plainsource_class(orNone) when the per-layer class record is absent. Rank-local; no collective.
- GWMesh._resolveGeoWeather()[source]#
Resolve the per-species weatherability to a list of scalar or ``(lpoints,)`` array entries (Level-B extension 1 — lithology → chemistry). Lets lithology control which species each region yields:
(a) per-vertex map — a species’
weatherability: [file, key]is loaded and subset to the local partition (self.locIDs), exactly likeduriWeatherability/gwInfiltration.(b) table by a standalone lithology map — a species’
weatherability_by_class: [...]is gathered by a per-vertex integerlithology: [file, key]map (independent of provenance).(c) table by the provenance label — the same
weatherability_by_classgathered by a per-vertex class label:weatherability_from: source_classuses the static bedrock class, whileweatherability_from: surface_classuses the dynamic current surface class (the top stratigraphic layer, recomputed each step, so weatherability tracks exhumation/burial). Both need no new input beyond provenance.otherwise the scalar (the default; byte-identical to the old path).
The lithology label for (b)/(c) is the
lithology:map when given, else the provenance class (staticsource_classor dynamicsurface_class). Resolved lazily (first_updateSolute) so it runs after provenance seedssource_class; the dynamicsurface_classform is re-resolved every step. Rank-local, partition-exact, no collective.
- GWMesh._makeSoluteKSP()[source]#
Cached KSP for the solute-transport solve: a direct LU factorisation (
preonly+lu; MUMPS in parallel),gw_solute_prefix.The upwind advection operator is a non-symmetric M-matrix, but its diagonal is only weakly dominant: at a flat, poorly-drained saturated node the lateral outflow, the seepage sink and the recharge floor are all small, so the operator is badly conditioned. A Krylov + block-Jacobi solve then STALLS there for the strong (high-weatherability) tracers — it returns a non-converged iterate with a concentration ~10× too large, which silently breaks the per-step
dissolved = precipitated + exportedmass balance (the ocean/marine budget then drifts by an order of magnitude). A direct solve is exact, so it is both robust to the conditioning and conservative to round-off; the operator is the same size / sparsity as the flexure biharmonic that already uses this path, and the per-species factorisation is reused across the provenance right-hand sides. Env-overridable via thegw_solute_prefix (e.g. request an iterative solver for meshes too large to factorise).
- GWMesh._soluteAdvecCoeffs(h, T)[source]#
Upwind finite-volume coefficients for the steady solute advection
∇·(q c)by the groundwater fluxq = −T∇h(area-normalised, per cell). Reusesjacobiancoeff— its off-diagonals are the area-normalised face conductancesC_ik/A_i(correct for flat and global meshes alike), so the signed face fluxi→kisf_ik = (C_ik/A_i)·(h_i − h_k). First-order upwinding puts the outflow (f>0) on the diagonal (carriesc_i) and the inflow (f<0) on the neighbour column (carriesc_k). Returns(adv, divq):advis the(lpoints, 1+maxnb)array for_assembleDiffMatCSR(col 0 = diagonal), anddivq = Σ_k f_ikis the lateral divergence (used for the seepage sink / export).
- GWMesh._dischargeWeight(divq, R)[source]#
Absolute-accumulation gate (opt-in
discharge_gate): a per-node weightG ∈ [0,1]that restricts crust formation to groundwater discharge zones. The lateral groundwater fluxq = −T∇hconverges into a discharge cell (div q < 0), delivering dissolved load that emerges at the surface and precipitates — the absolute-accumulation (lateral) style of valley/footslope ferricrete, as opposed to the in-situ / relative accumulation that indurates wherever the water table is shallow.G = (−div q)⁺ / ((−div q)⁺ + R)— the fraction of the cell’s upward discharge that was imported laterally (vs supplied by local rechargeR). Both terms are area-normalised rates (m/yr), soGis dimensionless and self-scaling (no new tuning constant):G→1at a strongly convergent valley floor,G→0at a recharge (divergent) rise. Returns all-ones when the gate is off (backwards-compatible). Rank-local.
- GWMesh._solveSoluteTransport(source, dmask, dval)[source]#
Solve one steady tracer transport
M c = sourcewith Dirichlet nodesdmaskpinned todval(M= upwind advection of §``_soluteAdvecCoeffs`` at the current head). The seepage set is pinned (cleaves the aquifer there — the export sink), which anchors the M-matrix. G1 machinery: the physical dissolution source / fringe precipitation / baseflow export are added in G2/G3. Collective (KSP); returns the local concentration array.
- GWMesh._soluteSolveRHS(M, rhs, guess)[source]#
Solve
M c = rhsfor the (pre-assembled) transport operatorMwith a warm-startguess, returning the local concentration (clipped ≥ 0). A thin wrapper so_updateSolutecan reuse one assembled operator for several right-hand sides (the per-source-class provenance solves, G5). Collective (KSP).
- GWMesh._updateSolute()[source]#
Level-B per-step solute update (G2), per tracer: dissolve → transport → precipitate → export, conservatively accounted.
Dissolution — a chemical-weathering source (the Level-A
_weatheringSupplyscaled per tracer byweatherability), on subaerial land only, debiting the conserved source pool.Transport — the steady
M c = Dsolve of_soluteAdvecCoeffs(upwind advection byq = −T∇h+ the vertical seepage sink), plus a precipitation sink at the capillary fringe added to the diagonal (k_p·Φwhere the tracer is super-saturated — a one-step-laggedc > c_satgate).Precipitation — the sink mass
k_p·Φ·cfeeds the duricrustduriH(Level-B thus replaces the Level-A proxy supply as the crust source; the induration/armoring then follow unchanged).Export — by domain mass balance, the solute that is neither precipitated nor left in solution has discharged to the surface network (→ ocean; the flux is formalised in G3).
Budget per tracer (owned nodes):
dissolved = precipitated + exported + Δ(in solution)— accumulated ingwDissolved/gwPrecip/gwOceanFluxfor the conservation guard. Rank-local accounting (KSP solve collective).
- GWMesh._routeRiverSolute()[source]#
Route the groundwater-exported (seepage/baseflow) solute down the surface drainage network to the shoreline — the river dissolved load (Level-B extension 2, DESIGN_GEOCHEM_EXTENSIONS.md §2), per species.
For each tracer an implicit accumulation solve reuses the cached flow-accumulation matrix
fMati(the_getSedFluxpattern) with that species’ per-node seepage exportgwSoluteFluxSp[:, k](already an extensive m³/yr flux — no area weighting) as the source:conservative (
river_decay = 0):(I − Wᵀ) L = s— a passive tracer; solute reaching a coast/outlet leaves the continent, solute into a closed basin is trapped (evaporite), both from the filled-topo matrix with no special handling.in-transit loss (
river_decay = κ > 0): a first-order removal along the network adds a diagonal sink,(I − Wᵀ + κ I) L = s(built asfMati.shift(κ)); the lost massκ·Σ Lis a per-species diagnostic (in-channel precipitation / uptake).
Per-species fields
riverSoluteSp/riverSoluteToOceanSp/riverSoluteLostare summed into the totalsriverSolute/riverSoluteToOcean. Withmarine_couplingon, the delivered coastal flux accumulates into a per-species marine reservoirmarineSolute(m³, integrated) and the per-node coastal inputmarineSoluteInput.Called at the end of
updateGroundwater—flowAccumulation(which cachesfMati) runs earlier in the step, so the matrix is fresh and no reordering is needed. No-op unlessriver_loadis on; the flow matrix must exist (guarded for the first call). Collective (KSP per tracer).