Source code for flow.gwplex

import os
import petsc4py
import numpy as np

from time import process_time
from mpi4py import MPI

from gospl.tools.constants import ICE_COVER_MIN

if "READTHEDOCS" not in os.environ:
    from gospl._fortran import jacobiancoeff

MPIrank = petsc4py.PETSc.COMM_WORLD.Get_rank()
MPIsize = petsc4py.PETSc.COMM_WORLD.Get_size()


[docs] class GWMesh(object): r""" Water table (groundwater) + generic duricrust — opt-in near-surface hydrology and chemical armoring. See ``docs/DESIGN_WATERTABLE_DURICRUST.md`` and the technical guide ``docs/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 in ``updateGroundwater`` (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)·R`` with transmissivity ``T = Ksat·max(h − z_bed, b_min)``, a seepage free boundary (``h ≤ z`` at sea / lakes / open outlets) and a dry-aquifer floor (``h ≥ z_bed``). ``z_bed`` is prescribed (``aquifer_base`` scalar/map) or tied to the bedrock (``aquifer_base: from_soil`` ⇒ ``lHbed − bedrock_depth``). Recharge ``R = 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 on ``wt = 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 induration ``duriF ∈ [0,1]`` **armors erodibility** through the single ``_surfaceArmoringK`` hook (``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 in ``destroy_DMPlex``; ``head`` and ``duriH`` are 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). """ def __init__(self, *args, **kwargs): """ Initialise the groundwater / duricrust state. Allocated only when ``self.gwOn`` (set by ``inputparser._readGroundwater``); otherwise the cached-solver handles are still set to ``None`` so ``destroy_DMPlex`` and any ``getattr`` guards are safe. """ # Cached elliptic head solver + operator (built lazily on first solve). # Set unconditionally so destroy_DMPlex / guards never hit a missing attr. self._gwMat = None self._ksp_gw = None # Cached Level-B solute-transport solver + operator (built lazily; G1+). self._soluteMat = None self._ksp_solute = None if getattr(self, "gwOn", False): # --- PETSc state (persistent, halo-synced; in destroy_DMPlex) --- # Water-table head h (elevation of the saturated surface, m). self.headL = self.hLocal.duplicate() self.headG = self.hGlobal.duplicate() # Duricrust thickness duriH (m). self.duriHL = self.hLocal.duplicate() self.duriHG = self.hGlobal.duplicate() # Net recharge R this step (m/yr) — diagnostic/output. self.rechargeL = self.hLocal.duplicate() # Seepage return to rivers (m^3/yr) — only used when conserve_baseflow. self.baseflowL = self.hLocal.duplicate() # Resolve a per-vertex aquifer_base map (done here — needs locIDs). # `[file, key]` -> file + ".npz" subset to the local partition; the # z_bed depth below the surface (m) then varies in space (regolith / # weathering-front depth). Scalar / 'from_soil' left untouched. basemap = getattr(self, "_gwAquiferBaseMap", None) if basemap is not None: data = np.load(basemap[0] + ".npz") self.gwAquiferBase = data[basemap[1]][self.locIDs].astype( np.float64 ) # Seed the head at the aquifer BASE (a dry start) so recharge fills # it UP to the steady water table. Seeding at the surface is wrong: # recharge would push h above z, the seepage clip would pin it there, # and it could never drain below the surface (spurious full # saturation). For a scalar / map aquifer_base, z_bed = z − base; # for `from_soil` (Phase 5, a string) fall back to the surface. base = self.gwAquiferBase if isinstance(base, str): self.hGlobal.copy(result=self.headG) self.hLocal.copy(result=self.headL) else: self.headL.setArray(self.hLocal.getArray() - base) self.dm.localToGlobal(self.headL, self.headG) self.dm.globalToLocal(self.headG, self.headL) self.duriHL.set(0.0) self.duriHG.set(0.0) self.rechargeL.set(0.0) self.baseflowL.set(0.0) # --- numpy state (rank-local, no halo) --- self.wtDepth = np.zeros(self.lpoints, dtype=np.float64) # z - h (m) self.duriF = np.zeros(self.lpoints, dtype=np.float64) # induration 0..1 self.duriKarmor = np.ones(self.lpoints, dtype=np.float64) # K multiplier (<=1) self.gwSeepIDs = np.zeros(0, dtype=np.int64) # Dirichlet seepage nodes # Surface at the previous groundwater update — the duricrust breakdown # term reads the per-step incision (z_last − z > 0 = surface lowered). self.gwZlast = self.hLocal.getArray().copy() # Signed across-bed groundwater flux (m³/yr, >0 = into the surface), # for the opt-in lake ↔ aquifer volume coupling. 0 until the first # head solve, so the cascade's first read is a no-op. self.gwLakeFlux = np.zeros(self.lpoints, dtype=np.float64) # Running total of net groundwater volume exchanged with lakes (m³, # owned nodes) — diagnostic for the lake-exchange budget. self.gwLakeInflow = 0.0 # Cached local temperature (K) for the optional Arrhenius weathering # term (loaded lazily from the soil tempMap when weather_Ea > 0), and # a lazily-resolved per-vertex `weatherability` map (rate mode). self._gwTempK = None self._duriWeatherArr = None # Resolve a per-vertex infiltration map (done here — needs locIDs, # unavailable at parse time). `[file, key]` -> file + ".npz", subset # to the local partition. Scalar `gwInfiltration` is left untouched. infilmap = getattr(self, "_gwInfilMap", None) if infilmap is not None: data = np.load(infilmap[0] + ".npz") self.gwInfiltration = data[infilmap[1]][self.locIDs].astype( np.float64 ) # --- Level-B geochemistry state (opt-in; DESIGN_WATERTABLE_GEOCHEM.md # G0). Allocated only when `gwGeochemOn`; nothing is solved yet (G1+). # The solute is an (lpoints, n_species) array — single-tracer when # n_species=1, multi-tracer otherwise — sharing one transport template. if getattr(self, "gwGeochemOn", False): nsp = int(self.gwNspecies) if getattr(self, "duriOn", False) and MPIrank == 0 and self.verbose: print( "[gw] geochem on: the duricrust source is the transported " "precipitated solute — the per-species `precip_rate` (a " "1/yr rate) governs crust growth; the Level-A `form_rate` " "is NOT used. Scale `precip_rate` down for long timesteps.", flush=True, ) # Per-species parameters (parser lists -> numpy, len n_species). # `gwGeoWeather` stays a RAW list (each entry a scalar, a # per-vertex `[file, key]` map, or a table used with a lithology # label) — resolved lazily into `_gwGeoWeatherArr` on first use. self._gwGeoWeatherArr = None self.gwGeoRiverDecay = np.asarray(self.gwGeoRiverDecay, dtype=np.float64) self.gwGeoCsat = np.asarray(self.gwGeoCsat, dtype=np.float64) self.gwGeoPrecip = np.asarray(self.gwGeoPrecip, dtype=np.float64) self.gwGeoVsolid = np.asarray(self.gwGeoVsolid, dtype=np.float64) # Groundwater solute concentration and the dissolvable source pool # (rank-local, per tracer). The pool is seeded per species as # `source_pool · cell area` (weatherable rock mass, default 1e6); # dissolution debits it, so a pool too small for the run's # weathering rate exhausts and the tracer goes inert. Set # `source_pool` large (a thick saprolite source) to keep weathering # rate-limited over long, fast-weathering runs. `_solutePoolWarned` # gates the one-time exhaustion warning. self.gwGeoSourcePool = np.asarray(self.gwGeoSourcePool, dtype=np.float64) self.gwSolute = np.zeros((self.lpoints, nsp), dtype=np.float64) self.gwSourcePool = self.larea[:, None] * self.gwGeoSourcePool[None, :] self._solutePoolWarned = False # Cumulative mass budget per tracer (m³-equiv, owned nodes) for the # conservation guard / diagnostics: dissolved = precipitated + # exported (ocean) + currently in solution. self.gwDissolved = np.zeros(nsp, dtype=np.float64) self.gwPrecip = np.zeros(nsp, dtype=np.float64) self.gwOceanFlux = np.zeros(nsp, dtype=np.float64) # Per-node baseflow-carried solute export (m³/yr, summed over # tracers) — the spatial output field (G3) — and its per-species # split (`soluteflux_<name>` output + the river-routing source). self.gwSoluteFlux = np.zeros(self.lpoints, dtype=np.float64) self.gwSoluteFluxSp = np.zeros((self.lpoints, nsp), dtype=np.float64) # Per-node cumulative crust precipitated by each tracer, and the # dominant crust-forming tracer (G4 typing: -1 = no crust). self.gwCrustBySpecies = np.zeros((self.lpoints, nsp), dtype=np.float64) self.gwCrustType = np.full(self.lpoints, -1, dtype=np.int32) # Solute-source provenance (G5, only with in-model provenance on): # cumulative crust attributed to each source-rock class (where the # solute dissolved), and the dominant source class per node. if getattr(self, "provOn", False): self.gwCrustProv = np.zeros( (self.lpoints, int(self.provNb)), dtype=np.float64 ) self.gwCrustSource = np.full(self.lpoints, -1, dtype=np.int32) # Scratch Vec pair for the per-species transport solve (G1+). self.soluteL = self.hLocal.duplicate() self.soluteG = self.hGlobal.duplicate() self.soluteL.set(0.0) self.soluteG.set(0.0) # ext 2: river dissolved-load routing — accumulated solute flux # (m³/yr) down the surface network + its ocean-delivered total. if getattr(self, "gwRiverLoad", False): self.riverSoluteG = self.hGlobal.duplicate() self.riverSoluteL = self.hLocal.duplicate() self.riverSoluteG.set(0.0) self.riverSoluteL.set(0.0) self.riverSolute = np.zeros(self.lpoints, dtype=np.float64) self.riverSoluteToOcean = 0.0 # Per-species routing: routed river load by tracer (the total # fields above are Σ over k; the per-species seepage source # `gwSoluteFluxSp` is allocated with the geochem state above). self.riverSoluteSp = np.zeros((self.lpoints, nsp), dtype=np.float64) self.riverSoluteToOceanSp = np.zeros(nsp, dtype=np.float64) self.riverSoluteLost = np.zeros(nsp, dtype=np.float64) # in-transit # Marine coupling: cumulative delivered inventory per tracer # (m³, integrated over time) + per-node coastal input this step. if getattr(self, "gwMarineCoupling", False): self.marineSolute = np.zeros(nsp, dtype=np.float64) self.marineSoluteInput = np.zeros(self.lpoints, dtype=np.float64) return
[docs] def updateGroundwater(self): """ Per-step groundwater / duricrust update. 1. **Recharge** ``R = f_infil * max(0, rain - evap)`` (m/yr), held at 0 under standing water (marine ``seaID`` or a ponded continental lake ``pitIDs>-1 & lFill>hl``) and under ice, where rain does not infiltrate. Stored in ``self.rechargeL`` for output. 2. **Head solve** (``_solveHead``): the implicit Dupuit-Boussinesq water table from that recharge; sets ``self.wtDepth = z - h``. 3. **Duricrust** (``_updateDuricrust``, only when ``duriOn``): evolve the capillary-fringe crust ``duriH`` and the induration ``duriF`` / armor multiplier ``duriKarmor`` from the new water-table depth. 4. **Geochemistry** (``_updateSolute``, only when ``gwGeochemOn`` — Level B): per tracer, dissolve → transport (down ``q = -T∇h``) → precipitate at the fringe (feeding ``duriH``, replacing the Level-A supply) → export; a closed mass budget with per-species crust typing, optional solute-source provenance and a dissolved ocean flux. 5. **River routing** (``_routeRiverSolute``, only when ``gwRiverLoad``): route the exported solute down the surface drainage network to the coast (per species; optional in-transit loss + marine coupling). 6. **Stratigraphic archive** (``_recordInduration``, when ``duriOn`` and 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 ``gwOn`` is off. The head, solute-transport and river-routing solves are collective (KSP); recharge, duricrust and the archive are purely rank-local (per-node). """ if not getattr(self, "gwOn", False): return t0 = process_time() rain = self.rainVal evap = getattr(self, "evapVal", None) net = np.maximum(0.0, rain if evap is None else (rain - evap)) # Effective infiltration fraction, optionally modulated (opt-in) by: # - surface lithology (coarse infiltrates more than fine) via the exposed # coarse fraction and `fine_infil_factor` (dual lithology only); # - terrain slope (steeper ⇒ less infiltration, more runoff) via # f/(1 + slope/infil_slope_ref). See DESIGN §3. finf = self.gwInfiltration if self.gwFineInfilFactor != 1.0 and getattr(self, "stratLith", False): fc = self._surfaceComposition() # exposed coarse fraction finf = finf * (fc + (1.0 - fc) * self.gwFineInfilFactor) if self.gwInfilSlopeRef > 0.0 and getattr(self, "rcvID", None) is not None: finf = finf / (1.0 + self._surfaceSlope() / self.gwInfilSlopeRef) R = finf * net # No rain-recharge where the surface is not subaerial land: # - standing water (marine `seaID`, or a ponded continental lake) — the # head is pinned to the surface there; # - ice-covered land — precipitation falls as snow/ice and does not # infiltrate the ground (parallels the soil ice-freeze gate). Subglacial # meltwater recharge is added back below (opt-in). sub = np.zeros(self.lpoints, dtype=bool) sub[self.seaID] = True pitIDs = getattr(self, "pitIDs", None) lFill = getattr(self, "lFill", None) if pitIDs is not None and lFill is not None: sub |= (pitIDs > -1) & (lFill > self.hLocal.getArray()) if getattr(self, "iceOn", False): iceHL = getattr(self, "iceHL", None) if iceHL is not None: sub |= iceHL.getArray() > ICE_COVER_MIN R = np.where(sub, 0.0, R) # Subglacial-meltwater recharge (opt-in): a fraction of the glacial # meltwater (`iceMeltRiverL`, m³/yr) infiltrates the aquifer where it melts # out — the one recharge path allowed under ice (the ice gate above zeroes # the rain path). Converted to a rate (m/yr) by the cell area; never on the # sea. See DESIGN_WATERTABLE_DURICRUST.md §3. if self.gwSubglacial > 0.0 and getattr(self, "iceOn", False): imr = getattr(self, "iceMeltRiverL", None) if imr is not None: Rsub = self.gwSubglacial * imr.getArray() / self.larea Rsub[self.seaID] = 0.0 R = R + Rsub self.rechargeL.setArray(R) # Phase 2: solve the implicit water-table head from this recharge. self._solveHead() # Phase 3: evolve the capillary-fringe duricrust from the new water table # (breakdown/decay; the Level-A formation supply is off when geochem is on). if getattr(self, "duriOn", False): self._updateDuricrust() # Level-B (G2): dissolve → transport → precipitate the solute; the # precipitation feeds the crust `duriH` (replacing the Level-A supply). if getattr(self, "gwGeochemOn", False): self._updateSolute() # ext 2: route the exported solute down the rivers to the shoreline # (river dissolved load). Uses the flow matrix built earlier this step. self._routeRiverSolute() # Phase 6: sync the live crust with the per-layer stratigraphic induration # archive (exhumation re-arm + formation write-down) after both sources. if getattr(self, "duriOn", False): self._recordInduration() if MPIrank == 0 and self.verbose: print( "Update Groundwater Table (%0.02f seconds)" % (process_time() - t0), flush=True, ) return
[docs] def _makeGWKSP(self): """ 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 at ``DIVERGED_ITS`` and 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 prefix ``gw_`` scopes any override (env ``-gw_pc_type ...``). """ ksp = petsc4py.PETSc.KSP().create(petsc4py.PETSc.COMM_WORLD) ksp.setType("fgmres") ksp.getPC().setType("hypre") ksp.setTolerances(rtol=1.0e-10, max_it=500) ksp.setInitialGuessNonzero(True) ksp.setOptionsPrefix("gw_") ksp.setFromOptions() return ksp
[docs] def _solveHead(self): r""" Implicit (backward-Euler) Dupuit-Boussinesq water-table head solve (DESIGN_WATERTABLE_DURICRUST.md §2/§3). One elliptic solve per step: .. math:: (I + \tfrac{\Delta t}{S}\,L(T))\,h = h_{old} + \tfrac{\Delta t}{S}\,R with transmissivity :math:`T = K_h\max(h - z_{bed}, b_{min})` (unconfined), :math:`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 :math:`\Delta t/S` grows, so it is quasi-steady at century steps. - **Unconfined non-linearity** ``T(h)``: ``gwPicardIts`` Picard sweeps (lag ``T`` on the previous iterate; operator rebuilt per sweep). - **Seepage free boundary** ``h <= z``: Dirichlet ``h = z`` at the base seepage set (marine ``seaID`` + ponded lakes + open ``outletIDs``) via ``zeroRowsLocal``; after each Picard block, clip ``h = min(h, z)`` and add any newly-over-topped cell to the Dirichlet set, up to ``gwSeepagePasses`` passes (break on an ``Allreduce``'d new-seepage count — collective-safe). ``aquifer_base`` is prescribed (scalar/map) OR, with ``aquifer_base: from_soil``, tied to the bedrock elevation ``z_bed = lHbed − bedrock_depth`` (Phase 5 soil coupling; ``_gwZbed``). - **Baseflow closure** (opt-in ``conserve_baseflow``): the seepage-return discharge is accounted into ``self.baseflowL`` (``_baseflowClosure``). Scratch: uses ``self.tmp`` (global rhs); leaves it defined. Writes ``self.headL/headG`` and ``self.wtDepth = z - h``. """ z = self.hLocal.getArray() zbed = self._gwZbed(z) # scalar/map, or lHbed − d_bedrock bmin = float(self.gwMinSatThick) gwdt = self.dt / float(self.gwSpecificYield) # Δt/S R = self.rechargeL.getArray() inIDs = self.inIDs zeroKp = np.zeros(self.lpoints, dtype=np.float64) IntType = petsc4py.PETSc.IntType # Base seepage (Dirichlet h = z): standing water + open outlets. seep = np.zeros(self.lpoints, dtype=bool) seep[self.seaID] = True pitIDs = getattr(self, "pitIDs", None) lFill = getattr(self, "lFill", None) if pitIDs is not None and lFill is not None: seep |= (pitIDs > -1) & (lFill > z) outletIDs = getattr(self, "outletIDs", None) if outletIDs is not None and len(outletIDs) > 0: seep[outletIDs] = True if self._ksp_gw is None: self._ksp_gw = self._makeGWKSP() ksp = self._ksp_gw self.dm.globalToLocal(self.headG, self.headL) hloc = self.headL.getArray().copy() hold = hloc.copy() npass = 0 seep_converged = False for _ in range(int(self.gwSeepagePasses)): npass += 1 for _ in range(int(self.gwPicardIts)): T = self.gwKsat * np.maximum(hloc - zbed, bmin) # transmissivity (m²/yr) coeffs = gwdt * jacobiancoeff(hloc, T, zeroKp) # (Δt/S)·L(T) coeffs[:, 0] += 1.0 # I + (Δt/S)·L(T) M = self._assembleDiffMatCSR(coeffs) owned_seep = np.where(seep & (inIDs == 1))[0].astype(IntType) M.zeroRowsLocal(owned_seep, diag=1.0) # collective; h = rhs there rhs = hold + gwdt * R rhs[seep] = z[seep] # pin h = z at seepage self.headL.setArray(rhs) self.dm.localToGlobal(self.headL, self.tmp) # rhs (global) self.headL.setArray(hloc) self.dm.localToGlobal(self.headL, self.headG) # nonzero guess ksp.setOperators(M, M) ksp.solve(self.tmp, self.headG) M.destroy() self.dm.globalToLocal(self.headG, self.headL) hloc = self.headL.getArray().copy() # Free boundaries: seepage (upper, h <= surface — discover new # seepage cells) and the dry-aquifer floor (lower, h >= z_bed — the # head cannot drop below the impermeable base). over = (hloc > z) & (~seep) n_new = MPI.COMM_WORLD.allreduce( int((over & (inIDs == 1)).sum()), op=MPI.SUM ) seep |= over hloc = np.clip(hloc, zbed, z) if n_new == 0: seep_converged = True break if MPIrank == 0 and self.verbose: # `reason > 0` = KSP converged; a non-converged elliptic solve (e.g. # a too-weak PC) silently corrupts the water table, so surface it. reason = int(ksp.getConvergedReason()) print( "[gw] head solve: %d seepage pass(es)%s, last KSP reason %d%s" % ( npass, "" if seep_converged else " (seepage set still growing)", reason, "" if reason > 0 else " -- WARNING: KSP did not converge", ), flush=True, ) self.headL.setArray(hloc) self.dm.localToGlobal(self.headL, self.headG) # Water-table depth, BOUNDED by the aquifer thickness (z − z_bed): a water # table cannot sit below its aquifer base. Where the head drains below the # base (a dry aquifer — kept solvable by the `min_sat_thickness` floor on # T), `z − h` would otherwise report a spurious depth far exceeding the # aquifer (e.g. tens of m in a few-m regolith with `aquifer_base: # from_soil`), which also zeroes the capillary-fringe favourability Φ and # wrongly suppresses the duricrust there. Clamp to [0, z − z_bed]. self.wtDepth = np.clip(z - hloc, 0.0, np.maximum(z - zbed, 0.0)) # Phase 5: account the seepage-return (baseflow) discharge (opt-in). if getattr(self, "gwConserveBaseflow", False): self._baseflowClosure(hold, hloc, seep) # Opt-in lake ↔ aquifer volume coupling: the signed across-bed flux. if getattr(self, "gwLakeExchange", False): self._lakeExchangeFlux(hloc, zbed, bmin, zeroKp) return
[docs] def _lakeExchangeFlux(self, hloc, zbed, bmin, zeroKp): r""" Signed groundwater flux exchanged with the surface at every node (m³/yr), stored in ``self.gwLakeFlux`` for 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 from ``jacobiancoeff``, so ``L·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 when ``lake_exchange`` is on. Uses the *un-zeroed* operator (the seepage-Dirichlet rows would otherwise null the flux exactly at the lake nodes we need). """ T = self.gwKsat * np.maximum(hloc - zbed, bmin) Lop = self._assembleDiffMatCSR(jacobiancoeff(hloc, T, zeroKp)) # pure L self.headL.setArray(hloc) self.dm.localToGlobal(self.headL, self.headG) Lop.mult(self.headG, self.tmp) # tmp = L·h (global) Lop.destroy() self.dm.globalToLocal(self.tmp, self.headL) Lh = self.headL.getArray() self.gwLakeFlux = -Lh * self.larea # >0 aquifer→surface # restore headG/headL to the head (tmp/headL were reused as scratch) self.headL.setArray(hloc) self.dm.localToGlobal(self.headL, self.headG) return
[docs] def _gwZbed(self, z): r""" Aquifer-base elevation ``z_bed`` (m). Prescribed by ``aquifer_base`` (scalar or per-vertex map, ``z_bed = z − aquifer_base``), OR — with ``aquifer_base: from_soil`` (Phase-5 soil coupling) — tied to the bedrock elevation ``z_bed = lHbed − bedrock_depth``, the *"permeable regolith over impermeable bedrock"* model. ``from_soil`` needs ``soilSPL`` (``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 − Lsoil`` is only the base of the thin weathering regolith; deposited sediment lives in the stratigraphy, not ``Lsoil``. 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 than ``lHbed``. The base is therefore taken as the **deeper** (lower) of ``lHbed − bedrock_depth`` and ``z − Σ sediment thickness`` — so uplands (thin pile) keep the regolith base while basins deepen to the fill base. Rank-local. """ base = self.gwAquiferBase if isinstance(base, str): # 'from_soil' lHbed = getattr(self, "lHbed", None) if lHbed is None: if MPIrank == 0 and self.verbose: print( "[gw] aquifer_base: from_soil needs soilSPL (lHbed) — " "falling back to the surface as the aquifer base.", flush=True, ) return z.copy() zbed = lHbed.getArray() - float(self.gwBedrockDepth) if self.stratNb > 0 and self.stratH is not None: lo = int(getattr(self, "bedrockLay", 0)) # skip bedrock sentinel top = self.stratStep + 1 if top > lo: sed = self.stratH[:, lo:top].sum(axis=1) # porous fill thickness zbed = np.minimum(zbed, z - sed) # basin fill = aquifer return zbed return z - base
def _syncHalo(self, arr): r""" Return ``arr`` with its halo/ghost entries overwritten by the **owning rank's** value, via a local→global→local round-trip (``localToGlobal`` INSERT takes the owner's value; ``globalToLocal`` broadcasts it back). For output fields computed on OWNED nodes only (e.g. the seepage export ``seep_sink·c·A``, or the area-weighted baseflow), the local array's halo stays 0 / stale, which renders as a **seam at partition boundaries** in the per-rank output. Syncing removes the seam. Serial runs have no halo, so this is a no-op there. Rank-local vectors; the round-trip is collective. """ self.tmpL.setArray(np.ascontiguousarray(arr, dtype=np.float64)) self.dm.localToGlobal(self.tmpL, self.tmp) self.dm.globalToLocal(self.tmp, self.tmpL) return self.tmpL.getArray().copy() def _subaerialMask(self): r""" Boolean per-node mask of **subaerial land** — not marine (``seaID``) and not under a ponded continental lake (``pitIDs>-1 & lFill>z``). The capillary-fringe duricrust forms, and Level-B solute precipitates, ONLY here — there is no capillary fringe under standing water. Rank-local. """ z = self.hLocal.getArray() sub = np.zeros(self.lpoints, dtype=bool) sub[self.seaID] = True pitIDs = getattr(self, "pitIDs", None) lFill = getattr(self, "lFill", None) if pitIDs is not None and lFill is not None: sub |= (pitIDs > -1) & (lFill > z) return ~sub
[docs] def _baseflowClosure(self, hold, hnew, seep): r""" 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: .. math:: 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 recharge`` in the steady limit (``ΔS→0``) — river discharge stays ``≈ rain − evap``. Stored in ``self.baseflowL`` (diagnostic/output); re-injection into the surface-flow source (making rivers physically baseflow-fed, which redistributes erosion) is the next increment. Collective (two ``Allreduce``); no rank-local collective gate. """ A = self.larea S = float(self.gwSpecificYield) owned = self.inIDs == 1 R = self.rechargeL.getArray() # Net discharge rate = recharge volume − storage-change volume (m³/yr). rvol = np.where(owned, R * A, 0.0).sum() svol = np.where(owned, S * (hnew - hold) * A, 0.0).sum() / self.dt Qtot = MPI.COMM_WORLD.allreduce(float(rvol - svol), op=MPI.SUM) bf = np.zeros(self.lpoints, dtype=np.float64) # Distribute the return flow over the SUBAERIAL seepage nodes only — the # springs / lakes / land outlets that actually feed the rivers. Marine # (`seaID`) nodes are seepage BCs too, but baseflow injected there is lost # to the sea (it never reaches a river), which would break the water # conservation this closure exists to enforce (and paints "baseflow" over # the ocean in the output). So exclude the marine seepage nodes. marine = np.zeros(self.lpoints, dtype=bool) marine[self.seaID] = True seep_owned = seep & owned & (~marine) wsum = MPI.COMM_WORLD.allreduce( float(np.where(seep_owned, A, 0.0).sum()), op=MPI.SUM ) if wsum > 0.0: bf[seep_owned] = Qtot * A[seep_owned] / wsum # `bf` is set on OWNED nodes only (0 on the halo). Sync the halo from the # owning rank so shared/ghost nodes carry the owner's value; otherwise the # local `baseflowL` has a 0 ring at partition boundaries — a visible seam # in the output. The re-injection is unaffected (localToGlobal INSERT # already takes the owner's value); this just fixes the halo/output. self.baseflowL.setArray(self._syncHalo(bf)) return
[docs] def _arrhenius(self): r""" Optional Arrhenius temperature factor for the weathering supply, ``exp(Ea/Rg · (1/T_ref − 1/T))`` (dimensionless, 1 at ``T = T_ref``). Returns ``1.0`` (no temperature dependence) when ``weather_Ea <= 0`` OR no temperature map is available — so duricrust runs soil-free by default. Reuses the soil ``tempMap`` (``self.tempFile``/``tempData``/``tempRef``) when present; loaded once and cached. Rank-local. """ if self.duriWeatherEa <= 0.0: return 1.0 if self._gwTempK is None: tempFile = getattr(self, "tempFile", None) if tempFile is None: if MPIrank == 0 and self.verbose: print( "[gw] duricrust weather_Ea > 0 but no soil tempMap — " "Arrhenius term disabled (factor 1).", flush=True, ) self.duriWeatherEa = 0.0 # don't retry every step return 1.0 data = np.load(tempFile) self._gwTempK = data[self.tempData][self.locIDs] + 273.15 Tref = getattr(self, "tempRef", 15.0) + 273.15 return np.exp(self.duriWeatherEa * (1.0 / Tref - 1.0 / self._gwTempK) / 8.314)
[docs] def _surfaceSourceClass(self): r""" Per-node **current surface lithology** class — the dominant provenance class of the **top non-empty stratigraphic layer** (from ``stratP``), falling back to the static bedrock ``source_class`` where 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 a ``surface_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 plain ``source_class`` (or ``None``) when the per-layer class record is absent. Rank-local; no collective. """ base = getattr(self, "source_class", None) stratP = getattr(self, "stratP", None) if base is None or stratP is None or self.stratNb == 0: return base top = self.stratStep + 1 H = self.stratH[:, :top] rev = (H > 0)[:, ::-1] valid = rev.any(axis=1) # columns with any sediment label = base.copy() if valid.any(): top_idx = (H.shape[1] - 1 - np.argmax(rev, axis=1))[valid] rows = np.arange(H.shape[0])[valid] label[rows] = stratP[rows, top_idx, :].argmax(axis=1) return label
[docs] def _resolveGeoWeather(self): r""" 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 like ``duriWeatherability`` / ``gwInfiltration``. - **(b) table by a standalone lithology map** — a species' ``weatherability_by_class: [...]`` is gathered by a per-vertex integer ``lithology: [file, key]`` map (independent of provenance). - **(c) table by the provenance label** — the same ``weatherability_by_class`` gathered by a per-vertex class label: ``weatherability_from: source_class`` uses the **static bedrock** class, while ``weatherability_from: surface_class`` uses 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 (static ``source_class`` or dynamic ``surface_class``). Resolved lazily (first ``_updateSolute``) so it runs after provenance seeds ``source_class``; the dynamic ``surface_class`` form is re-resolved every step. Rank-local, partition-exact, no collective. """ label = None lithomap = getattr(self, "_gwLithoMap", None) wfrom = getattr(self, "gwWeatherFrom", None) if lithomap is not None: # (b) standalone map d = np.load(lithomap[0] + ".npz") label = d[lithomap[1]][self.locIDs].astype(np.int64) elif wfrom == "surface_class": # (c) dynamic top layer label = self._surfaceSourceClass() if label is None and MPIrank == 0 and self.verbose: print( "[gw] geochem weatherability_from='surface_class' needs " "provenance on — falling back to scalar weatherability.", flush=True, ) elif wfrom == "source_class": # (c) static bedrock label = getattr(self, "source_class", None) if label is None and MPIrank == 0 and self.verbose: print( "[gw] geochem weatherability_from='source_class' needs " "provenance on — falling back to scalar weatherability.", flush=True, ) byclass = getattr(self, "gwGeoWeatherByClass", None) out = [] for k in range(int(self.gwNspecies)): wab = self.gwGeoWeather[k] tbl = byclass[k] if byclass is not None else None if isinstance(wab, (list, tuple)): # (a) per-vertex map d = np.load(wab[0] + ".npz") out.append(d[wab[1]][self.locIDs].astype(np.float64)) elif tbl is not None and label is not None: # (c) table by label tblA = np.asarray(tbl, dtype=np.float64) out.append(tblA[np.clip(label, 0, len(tblA) - 1)]) else: # scalar (unchanged) out.append(float(wab)) return out
[docs] def _weatheringSupply(self): r""" 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·weatherability`` driven by the recharge ``R`` the head solve already computes; ``L = Lsoil`` when ``soilSPL`` is on (capped by the regolith production supply), else the prescribed ``path_length``. - **prodsoil**: reuse the temperature-scaled ``soilSPL.prodSoil`` directly, scaled by water availability (the chemical∝physical congruency). Falls back to the proxy when soil is not tracked. """ rain = self.rainVal evap = getattr(self, "evapVal", None) net = np.maximum(0.0, rain if evap is None else (rain - evap)) mode = self.duriWeatherMode if mode == "rate": R = self.rechargeL.getArray() if getattr(self, "cptSoil", False) and getattr(self, "Lsoil", None) is not None: L = np.maximum(self.Lsoil.getArray(), 1.0e-3) # regolith residence length else: L = max(float(self.duriWeatherL), 1.0e-3) # weatherability: scalar OR a lazily-resolved per-vertex map. wab = self.duriWeatherability if isinstance(wab, (list, tuple)): if self._duriWeatherArr is None: d = np.load(wab[0] + ".npz") self._duriWeatherArr = d[wab[1]][self.locIDs].astype(np.float64) wab = self._duriWeatherArr else: wab = float(wab) # R→0 ⇒ Dw/(R·L)→∞ ⇒ frac→1 ⇒ W→0 (guard the divide). with np.errstate(divide="ignore", invalid="ignore"): frac = 1.0 - np.exp(-self.duriWeatherDw / (R * L)) W = np.where(R > 0.0, R * self.duriWeatherCeq * frac, 0.0) * wab W = W * self._arrhenius() prod = getattr(self, "prodSoil", None) if prod is not None: # regolith supply cap W = np.minimum(W, prod * rain) return W if mode == "prodsoil": prod = getattr(self, "prodSoil", None) if prod is None: if MPIrank == 0 and self.verbose: print( "[gw] duricrust weathering mode 'prodsoil' needs soil " "production — falling back to the climate proxy.", flush=True, ) self.duriWeatherMode = "proxy" else: return prod * net # proxy (default / fallback) return net ** float(self.duriSupplyExp) * self._arrhenius()
[docs] def _updateDuricrust(self): r""" Evolve the generic capillary-fringe duricrust over ``self.dt`` (DESIGN_WATERTABLE_DURICRUST.md §3, step 5). Per-node, rank-local — no collective; ``localToGlobal`` on ``duriH`` at the end for the halo. - **Fringe favourability** ``Φ = exp(−((wt − d0)/w)²)`` — a Gaussian band on the water-table depth ``wt = z − h`` centred on the mean fringe depth ``d0`` (half-width ``w``); →1 at the fringe, →0 far above/below. With the opt-in ``discharge_gate`` (``_dischargeWeight``) ``Φ`` is additionally restricted to groundwater discharge zones (absolute-accumulation crust). - **Formation** ``dduriH/dt = k_form·Φ·Ψ·(1 − duriH/duriH_max)`` (self-limiting to ``duriH_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 induration ``duriF = duriH/duriH_max`` and the armor multiplier ``duriKarmor = 1 − armor_max·duriF`` (consumed by the Phase-4 erodibility hook). """ z = self.hLocal.getArray() wt = self.wtDepth # z − h ≥ 0 Hmax = float(self.duriMaxThick) Phi = np.exp(-(((wt - self.duriFringeDepth) / self.duriFringeWidth) ** 2)) # No capillary fringe under standing water: zero the favourability off # subaerial land so the crust neither forms nor is held under the sea / # a ponded lake (a submerged crust then decays via the (1-Phi) term). Phi = np.where(self._subaerialMask(), Phi, 0.0) Psi = self._weatheringSupply() if getattr(self, "gwGeochemOn", False): # Level-B conservative geochemistry provides the crust source (the # transported, precipitated solute in `_updateSolute`), so the Level-A # proxy/rate supply is switched off here to avoid double-counting. supply = np.zeros(self.lpoints, dtype=np.float64) else: # Absolute-accumulation gate (opt-in): restrict the fringe supply to # groundwater discharge zones (lateral convergence of q = −T∇h) rather # than forming wherever the water table is shallow. if getattr(self, "duriDischargeGate", False): h = self.headL.getArray() T = self.gwKsat * np.maximum( h - self._gwZbed(z), float(self.gwMinSatThick) ) _, divq = self._soluteAdvecCoeffs(h, T) Phi = Phi * self._dischargeWeight(divq, self.rechargeL.getArray()) supply = self.duriFormRate * Phi * Psi if getattr(self, "cptSoil", False): # regolith-limited supply = np.minimum(supply, self._regolithSupplyRate()) duriH = self.duriHL.getArray().copy() duriH += self.dt * supply * (1.0 - duriH / Hmax) incision = np.maximum(0.0, self.gwZlast - z) # surface lowered duriH -= self.duriBreakRate * incision duriH -= self.dt * self.duriDecayRate * (1.0 - Phi) * duriH duriH = np.clip(duriH, 0.0, Hmax) self.duriHL.setArray(duriH) self.dm.localToGlobal(self.duriHL, self.duriHG) # Refresh the halo from the owners (localToGlobal INSERT took the owned # values; globalToLocal broadcasts them back) so the crust `duriH`, the # induration `duriF` and the armor `duriKarmor` are halo-consistent and the # `duricrust`/`induration`/`Karmor` outputs show no partition seam. self.dm.globalToLocal(self.duriHG, self.duriHL) duriH = self.duriHL.getArray() self.duriF = duriH / Hmax self.duriKarmor = 1.0 - self.duriArmorMax * self.duriF self.gwZlast = z.copy() return
[docs] def _regolithSupplyRate(self): r""" 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) when ``prodSoil`` is unavailable, so a soil-off run is unaffected. Rank-local. """ prod = getattr(self, "prodSoil", None) if prod is None: return np.inf return prod * self.rainVal
[docs] def _surfaceSlope(self): r""" Per-node steepest-descent slope (m/m, ≥ 0) from the flow-direction receivers built by ``flowAccumulation`` — ``(z − z_rcv)/dist`` to the primary receiver ``rcvID[:,0]``. Flats / sinks / outlets (``dist=0`` or a self-receiver) return 0. A cheap proxy for the optional slope-dependent infiltration (goSPL stores no explicit slope). Rank-local. """ hl = self.hLocal.getArray() rcv0 = self.rcvID[:, 0] d = self.distRcv[:, 0] valid = (rcv0 >= 0) & (d > 0.0) idx = np.where(valid, rcv0, np.arange(self.lpoints)) slope = np.where(valid, (hl - hl[idx]) / np.where(d > 0.0, d, 1.0), 0.0) return np.maximum(slope, 0.0)
[docs] def _recordInduration(self): r""" Sync the live per-node induration ``duriF`` with the per-layer stratigraphic archive ``stratDuri`` (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``), refreshing ``duriKarmor``. The stacked-duricrust / relief-inversion behaviour of cratonic laterites. - **Formation (write-down)** — the live crust has a real thickness ``duriH`` that 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 all ``k`` with ``depth_above(k) < duriH`` (``depth_above`` = the summed thickness of the layers above ``k``). A thick crust therefore indurates several thin layers; each is **preserved** when later buried (``deposeStrat`` fresh 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** into ``stratCrustSource``) 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 the ``stratDuri`` degree. These are archive-only (no exhumation read-up). No-op (surface-only ``duriF``, no archive) when ``stratDuri`` is unallocated (``stratNb == 0``). Composition-only — no geometry change. Rank-local (per-node); no collective. """ if getattr(self, "stratDuri", None) is None or self.stratNb == 0: return top = self.stratStep + 1 H = self.stratH[:, :top] rev = (H > 0)[:, ::-1] valid = rev.any(axis=1) # columns with any sediment if not valid.any(): return # Read-up: the exposed (top non-empty) layer re-arms the live crust. top_idx = (H.shape[1] - 1 - np.argmax(rev, axis=1))[valid] rows = np.arange(H.shape[0])[valid] self.duriF[valid] = np.maximum(self.duriF[valid], self.stratDuri[rows, top_idx]) # The read-up is rank-local and reads `stratDuri`, so sync `duriF` from the # owners (owner wins) before deriving the armor, keeping the induration / # Karmor outputs free of a partition seam even when a crust is exhumed. self.duriF = self._syncHalo(self.duriF) self.duriKarmor = 1.0 - self.duriArmorMax * self.duriF # Write-down: record duriF into every layer within `duriH` of the surface. # depth_above(k) = Σ_{j>k} H[j] (thickness overlying layer k, so the top # non-empty layer has depth_above 0 and is included whenever duriH > 0). duriH = self.duriHL.getArray() depth_above = np.cumsum(H[:, ::-1], axis=1)[:, ::-1] - H within = (H > 0) & (depth_above < duriH[:, None]) self.stratDuri[:, :top] = np.where( within, np.maximum(self.stratDuri[:, :top], self.duriF[:, None]), self.stratDuri[:, :top], ) # Level-B chemistry archive: stamp the crust's DOMINANT solute species # (and, with provenance, its dominant source region) into every layer # of the crust's depth range, wherever crust has actually precipitated # (gwCrustType >= 0). Categorical, so a later step overwrites rather than # accumulates — the layer carries the current dominant while it is within # the live crust, then freezes when buried below duriH. Archive-only (no # read-up); the live gwCrustType/gwCrustSource are recomputed each step. if getattr(self, "stratCrustType", None) is not None: paint = within & (self.gwCrustType[:, None] >= 0) self.stratCrustType[:, :top] = np.where( paint, self.gwCrustType[:, None].astype(np.float64), self.stratCrustType[:, :top], ) if getattr(self, "stratCrustSource", None) is not None: paint = within & (self.gwCrustSource[:, None] >= 0) self.stratCrustSource[:, :top] = np.where( paint, self.gwCrustSource[:, None].astype(np.float64), self.stratCrustSource[:, :top], ) return
# ------------------------------------------------------------------ # # Level-B geochemistry — solute transport (G1: operator + solver). # # Steady advection ∇·(q c) of a lumped conservative tracer along the # # groundwater flux q = −T∇h, reusing the head operator's face # # conductances. NOT wired into updateGroundwater yet (inert); the # # dissolution source, fringe precipitation and export land in G2/G3. # # ------------------------------------------------------------------ #
[docs] def _makeSoluteKSP(self): """ 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 + exported`` mass 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 the ``gw_solute_`` prefix (e.g. request an iterative solver for meshes too large to factorise). """ ksp = petsc4py.PETSc.KSP().create(petsc4py.PETSc.COMM_WORLD) ksp.setType("preonly") pc = ksp.getPC() pc.setType("lu") if MPIsize > 1: pc.setFactorSolverType("mumps") # parallel direct solve ksp.setOptionsPrefix("gw_solute_") ksp.setFromOptions() return ksp
[docs] def _soluteAdvecCoeffs(self, h, T): r""" Upwind finite-volume coefficients for the steady solute advection ``∇·(q c)`` by the groundwater flux ``q = −T∇h`` (area-normalised, per cell). Reuses ``jacobiancoeff`` — its off-diagonals are the area-normalised face conductances ``C_ik/A_i`` (correct for **flat and global** meshes alike), so the signed face flux ``i→k`` is ``f_ik = (C_ik/A_i)·(h_i − h_k)``. First-order upwinding puts the outflow (``f>0``) on the diagonal (carries ``c_i``) and the inflow (``f<0``) on the neighbour column (carries ``c_k``). Returns ``(adv, divq)``: ``adv`` is the ``(lpoints, 1+maxnb)`` array for ``_assembleDiffMatCSR`` (col 0 = diagonal), and ``divq = Σ_k f_ik`` is the lateral divergence (used for the seepage sink / export). """ zeroKp = np.zeros(self.lpoints, dtype=np.float64) lap = jacobiancoeff(h, T, zeroKp) # area-norm neg-Laplacian ncol = lap.shape[1] - 1 cond = -lap[:, 1:] # C_ik/A_i ≥ 0 (off-diag) flux = np.zeros((self.lpoints, ncol), dtype=np.float64) for k in range(ncol): flux[:, k] = cond[:, k] * (h - h[self.FVmesh_ngbID[:, k]]) adv = np.zeros((self.lpoints, 1 + ncol), dtype=np.float64) adv[:, 0] = np.maximum(flux, 0.0).sum(axis=1) # outflow → c_i (diagonal) adv[:, 1:] = np.minimum(flux, 0.0) # inflow → c_k (neighbour) # Vertical exchange closes the balance: the lateral divergence # div q = Σ_k f_ik equals recharge (source, >0) minus seepage (sink, <0). # At a DISCHARGE node (net lateral inflow, div q < 0) the solute leaves the # aquifer to the surface — a diagonal sink `−div q` that makes the operator # a well-posed, diagonally-dominant M-matrix (pure lateral advection has no # sink there and blows up). Recharge nodes carry their source in the RHS. divq = flux.sum(axis=1) adv[:, 0] += np.maximum(-divq, 0.0) return adv, divq
[docs] def _dischargeWeight(self, divq, R): r""" Absolute-accumulation gate (opt-in ``discharge_gate``): a per-node weight ``G ∈ [0,1]`` that restricts crust formation to groundwater **discharge** zones. The lateral groundwater flux ``q = −T∇h`` **converges** 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 recharge ``R``). Both terms are area-normalised rates (m/yr), so ``G`` is dimensionless and self-scaling (no new tuning constant): ``G→1`` at a strongly convergent valley floor, ``G→0`` at a recharge (divergent) rise. Returns all-ones when the gate is off (backwards-compatible). Rank-local. """ if not getattr(self, "duriDischargeGate", False): return np.ones(self.lpoints, dtype=np.float64) dis = np.maximum(-divq, 0.0) # lateral convergence return dis / (dis + np.maximum(R, 0.0) + 1.0e-12)
[docs] def _solveSoluteTransport(self, source, dmask, dval): r""" Solve one steady tracer transport ``M c = source`` with Dirichlet nodes ``dmask`` pinned to ``dval`` (``M`` = upwind advection of §``_soluteAdvecCoeffs`` at the current head). The seepage set is pinned (``c`` leaves 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. """ z = self.hLocal.getArray() h = self.headL.getArray() T = self.gwKsat * np.maximum(h - self._gwZbed(z), float(self.gwMinSatThick)) adv, _ = self._soluteAdvecCoeffs(h, T) M = self._assembleDiffMatCSR(adv) IntType = petsc4py.PETSc.IntType owned_d = np.where(dmask & (self.inIDs == 1))[0].astype(IntType) M.zeroRowsLocal(owned_d, diag=1.0) # c = dval on Dirichlet rows rhs = np.asarray(source, dtype=np.float64).copy() rhs[dmask] = dval[dmask] if self._ksp_solute is None: self._ksp_solute = self._makeSoluteKSP() ksp = self._ksp_solute self.soluteL.setArray(rhs) self.dm.localToGlobal(self.soluteL, self.tmp) # rhs (global) self.dm.localToGlobal(self.soluteL, self.soluteG) # nonzero guess = rhs ksp.setOperators(M, M) ksp.solve(self.tmp, self.soluteG) M.destroy() self.dm.globalToLocal(self.soluteG, self.soluteL) return self.soluteL.getArray().copy()
[docs] def _soluteSolveRHS(self, M, rhs, guess): r""" Solve ``M c = rhs`` for the (pre-assembled) transport operator ``M`` with a warm-start ``guess``, returning the local concentration (clipped ≥ 0). A thin wrapper so ``_updateSolute`` can reuse one assembled operator for several right-hand sides (the per-source-class provenance solves, G5). Collective (KSP). """ if self._ksp_solute is None: self._ksp_solute = self._makeSoluteKSP() ksp = self._ksp_solute self.soluteL.setArray(rhs) self.dm.localToGlobal(self.soluteL, self.tmp) # rhs (global) self.soluteL.setArray(guess) self.dm.localToGlobal(self.soluteL, self.soluteG) # nonzero guess ksp.setOperators(M, M) ksp.solve(self.tmp, self.soluteG) self.dm.globalToLocal(self.soluteG, self.soluteL) return np.maximum(self.soluteL.getArray().copy(), 0.0)
[docs] def _updateSolute(self): r""" Level-B per-step solute update (G2), per tracer: **dissolve → transport → precipitate → export**, conservatively accounted. 1. **Dissolution** — a chemical-weathering source (the Level-A ``_weatheringSupply`` scaled per tracer by ``weatherability``), on subaerial land only, **debiting the conserved source pool**. 2. **Transport** — the steady ``M c = D`` solve of ``_soluteAdvecCoeffs`` (upwind advection by ``q = −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-lagged ``c > c_sat`` gate). 3. **Precipitation** — the sink mass ``k_p·Φ·c`` feeds the duricrust ``duriH`` (Level-B thus **replaces** the Level-A proxy supply as the crust source; the induration/armoring then follow unchanged). 4. **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 in ``gwDissolved``/``gwPrecip``/``gwOceanFlux`` for the conservation guard. Rank-local accounting (KSP solve collective). """ if not getattr(self, "gwGeochemOn", False): return z = self.hLocal.getArray() h = self.headL.getArray() T = self.gwKsat * np.maximum(h - self._gwZbed(z), float(self.gwMinSatThick)) A = self.larea dt = self.dt owned = self.inIDs == 1 adv, divq = self._soluteAdvecCoeffs(h, T) # advection + seepage sink (G1) seep_sink = np.maximum(-divq, 0.0) # lateral-convergence discharge # Vertical discharge to the surface = recharge − lateral divergence, from the # quasi-steady balance ∇·q = R − seepage ⇒ seepage = R − ∇·q. Apply it at # EVERY node, not just the saturated ones: wherever the groundwater cannot # transmit the dissolved load it must leave with the recharge/runoff to the # surface network, and there are TWO such near-singular populations — # (i) a flat, fully SATURATED seepage node (∇·q ≈ 0, no lateral outflow); # (ii) a DRY node (water table at the aquifer base, so `T` is pinned at # `min_sat_thickness` and the face conductances — hence the transport # diagonal — collapse), common over arid interiors. # Both are left with a ~zero transport diagonal by the `−div q` sink alone, # so the steady concentration (and the ocean/marine flux) spikes. Adding the # recharge term floors the diagonal at ~R. At a well-drained node ∇·q ≈ R, so # the extra term is ~0 and the solute advects laterally exactly as before. R = self.rechargeL.getArray() extra = np.maximum(np.maximum(R - divq, 0.0) - seep_sink, 0.0) # only ADD discharge adv[:, 0] += extra # keep the operator diagonal in sync seep_sink = seep_sink + extra self.gwSoluteFlux[:] = 0.0 # per-node baseflow export (G3) self.gwSoluteFluxSp[:] = 0.0 # per-species export (output + routing) prov = getattr(self, "provOn", False) and getattr(self, "source_class", None) is not None W = self._weatheringSupply() # base weathering rate (Level A) # ext 1: per-species weatherability. Resolved once and cached, EXCEPT the # dynamic surface-lithology form, which is re-resolved every step so the # weatherability follows the top stratigraphic layer (exhumation/burial). if self._gwGeoWeatherArr is None or getattr(self, "gwWeatherFrom", None) == "surface_class": self._gwGeoWeatherArr = self._resolveGeoWeather() # Subaerial land only — no dissolution AND no fringe precipitation under # the sea / a ponded lake (there is no capillary fringe under standing # water, so a duricrust cannot form there). subaerial = self._subaerialMask() # Precipitation favourability = the capillary fringe (needs the # duricrust), zeroed off subaerial land. if getattr(self, "duriOn", False): Phi = np.exp( -(((self.wtDepth - self.duriFringeDepth) / self.duriFringeWidth) ** 2) ) Phi = np.where(subaerial, Phi, 0.0) # Absolute-accumulation gate: precipitate only where the groundwater # discharges (converges), not everywhere the fringe is shallow. Phi = Phi * self._dischargeWeight(divq, R) else: Phi = np.zeros(self.lpoints, dtype=np.float64) duriH = self.duriHL.getArray().copy() Hmax = float(self.duriMaxThick) for k in range(int(self.gwNspecies)): # 1. Dissolution — debit the source pool. # weatherability: scalar OR a per-vertex array (ext 1) — broadcasts. Drate = np.where(subaerial, self._gwGeoWeatherArr[k] * W, 0.0) want = Drate * A * dt diss = np.minimum(want, self.gwSourcePool[:, k]) self.gwSourcePool[:, k] -= diss Deff = diss / (A * dt) # One-time warning when the finite source pool starts to limit # dissolution (the tracer will go inert as it empties) — a silent # exhaustion otherwise looks like the geochemistry has stopped. Raise # `source_pool` for that species to keep weathering rate-limited. The # `not _solutePoolWarned` gate is uniform across ranks (the flag is set # from a reduced count), so the Allreduce below is reached collectively. if not self._solutePoolWarned: limited = MPI.COMM_WORLD.allreduce( int(((want > diss + 1.0e-30) & (self.inIDs == 1)).sum()), op=MPI.SUM, ) if limited > 0: self._solutePoolWarned = True if MPIrank == 0: print( "[gw] geochem: source pool exhausting for '%s' at %d " "node(s) — dissolution now supply-limited (raise " "`source_pool` to keep it rate-limited)." % (self.gwGeoName[k], limited), flush=True, ) # 2. Precipitation sink at the fringe — a LINEAR removal `k_p·Φ` # (proportional to the local concentration), **self-limiting** as the # crust fills: `·(1 − duriH/duriH_max)` (mirrors the Level-A ODE), so a # crust at `max_thickness` REJECTS further solute — precipitation → 0 # there and the solute is exported to the rivers instead of piling into # an already-full crust. Uses the running `duriH` (updated per species), # so it also shares the remaining room across tracers. Kept linear so # the operator is constant under constant forcing → the solute reaches # a true per-step steady state (a hard `c > c_sat` gate would # oscillate; `c_sat` is a documented nonlinear refinement, DESIGN §3). p = self.gwGeoPrecip[k] * Phi * np.maximum(0.0, 1.0 - duriH / Hmax) # 3. Transport: (advection + seepage sink + precip sink) c = Deff. coeffs = adv.copy() coeffs[:, 0] += p M = self._assembleDiffMatCSR(coeffs) if prov: # G5 solute-source provenance: by linearity of the (fixed) # operator, transport the solute dissolved in each source-rock # class separately (same M, class-restricted RHS), sum to the # total, and attribute the precipitated crust to each source. c = np.zeros(self.lpoints, dtype=np.float64) for r in range(int(self.provNb)): Deff_r = np.where(self.source_class == r, Deff, 0.0) c_r = self._soluteSolveRHS(M, Deff_r, self.gwSolute[:, k]) c += c_r self.gwCrustProv[:, r] += p * c_r * dt * self.gwGeoVsolid[k] else: c = self._soluteSolveRHS(M, Deff, self.gwSolute[:, k]) M.destroy() c = np.maximum(c, 0.0) # guard tiny negatives self.gwSolute[:, k] = c # 4. Sinks: precipitation feeds the crust; seepage exports to the # surface (baseflow). The steady operator is exactly conservative # (upwind internal faces cancel), so per step # dissolved = precipitated + exported to the solver tolerance — no # storage term (a steady solve maintains, not accumulates, the # standing concentration). precip_mass = p * c * A * dt # → crust export_mass = seep_sink * c * A * dt # → surface / ocean duriH += precip_mass / A * self.gwGeoVsolid[k] self.gwDissolved[k] += float(diss[owned].sum()) self.gwPrecip[k] += float(precip_mass[owned].sum()) self.gwOceanFlux[k] += float(export_mass[owned].sum()) # Per-node baseflow export rate (m³/yr): total (summed over tracers) # and the per-species split (output + the river-routing source). self.gwSoluteFlux += seep_sink * c * A self.gwSoluteFluxSp[:, k] = seep_sink * c * A # Per-node crust contributed by this tracer (G4 typing). self.gwCrustBySpecies[:, k] += precip_mass / A * self.gwGeoVsolid[k] # The seepage-export fields are `seep_sink·c·A`; `seep_sink` (from the # local FV advection stencil) is only correct on OWNED nodes, so sync the # halo from the owner (local -> global -> local) — otherwise the # `soluteflux` / `soluteflux_<name>` outputs show a partition seam (like # baseflow). The river routing is unaffected (it uses localToGlobal INSERT, # owner wins). `gwSolute` / `riverSolute` come from global solves and are # already halo-consistent. self.gwSoluteFlux = self._syncHalo(self.gwSoluteFlux) for k in range(int(self.gwNspecies)): self.gwSoluteFluxSp[:, k] = self._syncHalo(self.gwSoluteFluxSp[:, k]) duriH = np.clip(duriH, 0.0, Hmax) self.duriHL.setArray(duriH) self.dm.localToGlobal(self.duriHL, self.duriHG) # Refresh the halo from the owners so the crust / induration / armor are # halo-consistent (no partition seam in the outputs); see _updateDuricrust. self.dm.globalToLocal(self.duriHG, self.duriHL) if getattr(self, "duriOn", False): duriH = self.duriHL.getArray() self.duriF = duriH / Hmax self.duriKarmor = 1.0 - self.duriArmorMax * self.duriF # G4 typing: the dominant crust-forming tracer per node (−1 = no crust). tot = self.gwCrustBySpecies.sum(axis=1) self.gwCrustType = np.where( tot > 0.0, self.gwCrustBySpecies.argmax(axis=1), -1 ).astype(np.int32) # G5 provenance: the dominant SOURCE-ROCK class of the crust per node. if prov: ptot = self.gwCrustProv.sum(axis=1) self.gwCrustSource = np.where( ptot > 0.0, self.gwCrustProv.argmax(axis=1), -1 ).astype(np.int32) if self.verbose: tot = MPI.COMM_WORLD.allreduce( float(self.gwSoluteFlux[owned].sum()), op=MPI.SUM ) if MPIrank == 0: per = ", ".join( "%s=%0.3g" % (self.gwGeoName[k], self.gwOceanFlux[k]) for k in range(int(self.gwNspecies)) ) print( "[gw] dissolved solute flux to surface: %0.4g m3/yr " "(cumulative per tracer: %s)" % (tot, per), flush=True, ) return
[docs] def _routeRiverSolute(self): r""" 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 ``_getSedFlux`` pattern) with that species' per-node seepage export ``gwSoluteFluxSp[:, 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 as ``fMati.shift(κ)``); the lost mass ``κ·Σ L`` is a per-species diagnostic (in-channel precipitation / uptake). Per-species fields ``riverSoluteSp`` / ``riverSoluteToOceanSp`` / ``riverSoluteLost`` are summed into the totals ``riverSolute`` / ``riverSoluteToOcean``. With ``marine_coupling`` on, the delivered coastal flux accumulates into a per-species marine reservoir ``marineSolute`` (m³, integrated) and the per-node coastal input ``marineSoluteInput``. Called at the end of ``updateGroundwater`` — ``flowAccumulation`` (which caches ``fMati``) runs earlier in the step, so the matrix is fresh and no reordering is needed. No-op unless ``river_load`` is on; the flow matrix must exist (guarded for the first call). Collective (KSP per tracer). """ if not getattr(self, "gwRiverLoad", False): return if getattr(self, "fMati", None) is None: return # no flow matrix yet owned = self.inIDs == 1 exitm = np.zeros(self.lpoints, dtype=bool) # coast / outlet exits exitm[self.seaID] = True outl = getattr(self, "outletIDs", None) if outl is not None: exitm[outl] = True self.riverSolute[:] = 0.0 for k in range(int(self.gwNspecies)): # RHS = this species' per-node seepage export (m³/yr, extensive). self.soluteL.setArray(self.gwSoluteFluxSp[:, k]) self.dm.localToGlobal(self.soluteL, self.tmp) kappa = float(self.gwGeoRiverDecay[k]) if kappa > 0.0: # in-transit first-order loss M = self.fMati.copy() M.shift(kappa) # (I − Wᵀ) + κ I self._solve_KSP(False, M, self.tmp, self.riverSoluteG) M.destroy() else: # conservative accumulation self._solve_KSP(False, self.fMati, self.tmp, self.riverSoluteG) self.dm.globalToLocal(self.riverSoluteG, self.riverSoluteL) Lk = np.maximum(self.riverSoluteL.getArray().copy(), 0.0) self.riverSoluteSp[:, k] = Lk self.riverSolute += Lk self.riverSoluteToOceanSp[k] = MPI.COMM_WORLD.allreduce( float(Lk[owned & exitm].sum()), op=MPI.SUM ) # In-transit loss (mass balance: Σs = Σ_terminal L + κ·Σ L). self.riverSoluteLost[k] = ( MPI.COMM_WORLD.allreduce(float(kappa * Lk[owned].sum()), op=MPI.SUM) if kappa > 0.0 else 0.0 ) self.riverSoluteToOcean = float(self.riverSoluteToOceanSp.sum()) # Marine coupling: the delivered coastal flux feeds a per-species marine # reservoir (integrated over time) + a per-node coastal-input field. if getattr(self, "gwMarineCoupling", False): self.marineSoluteInput[:] = 0.0 self.marineSoluteInput[exitm] = self.riverSolute[exitm] self.marineSolute += self.riverSoluteToOceanSp * self.dt if MPIrank == 0 and self.verbose: per = ", ".join( "%s=%0.3g" % (self.gwGeoName[k], self.riverSoluteToOceanSp[k]) for k in range(int(self.gwNspecies)) ) print( "[gw] river dissolved load to ocean: %0.4g m3/yr (per tracer: %s)" % (self.riverSoluteToOcean, per), flush=True, ) return