Source code for eroder.soilSPL

import os
import gc
import sys
import petsc4py
from gospl.tools.petscgc import safe_garbage_cleanup
import numpy as np
import numpy_indexed as npi

from mpi4py import MPI
from time import process_time

from gospl.tools.constants import BEDROCK_EXPOSED, ICE_COVER_MIN

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

MPIrank = petsc4py.PETSc.COMM_WORLD.Get_rank()


[docs] class soilSPL(object): """ The class computes river incision expressed using a **stream power formulation** function of river discharge and slope also **accounting for soil production**. A non-linear diffusion of soil based on soil thickness is also implemented in this class. If the user has turned-on the sedimentation capability, this class will solve implicitly the **stream power formulation** accounting for a sediment transport/deposition term (`Yuan et al, 2019 <https://agupubs.onlinelibrary.wiley.com/doi/full/10.1029/2018JF004867>`_). """ def __init__(self, *args, **kwargs): """ Initialisation of `soilSPL` class. """ # Regolith soil mode is only fully meaningful with stratigraphy on: it # keeps deposited sediment OUT of Lsoil, expecting the stratigraphy # (stratK) to carry that sediment's (soft) erodibility. Stratigraphy is # enabled purely by the stratal time step `time: strat:` (which sets # stratNb>0) — NOT by a `strata:` block (that is only for initial layers). # Without it `_surfaceK` returns 1.0, so a fresh deposit erodes at raw # bedrock K (harder than lumped mode would make it). Warn rather than # hard-fail: erosional/low-deposition runs can still use regolith mode # sensibly. See DESIGN_SOIL_REGOLITH.md §5. if ( getattr(self, "regolithSoil", False) and getattr(self, "cptSoil", False) and getattr(self, "stratNb", 0) == 0 and MPIrank == 0 ): print( "[soil] WARNING: mode 'regolith' without stratigraphy — deposited " "sediment has no soft-erodibility home and will erode as bedrock. " "Define a stratal time step ('time: strat:', which sets stratNb>0) " "so deposits are recorded with soft stratK, or use mode 'lumped'.", flush=True, ) # Soil-SPL SNES controls. These are normally set from the YAML soil # block by the input parser; fall back to robust defaults when soilSPL # is built standalone (e.g. unit tests). self.soil_rtol = getattr(self, "soil_rtol", 1.0e-6) self.soil_atol = getattr(self, "soil_atol", 1.0e-6) self.soil_maxit = getattr(self, "soil_maxit", 500) self.soil_pc = getattr(self, "soil_pc", "hypre") self.soil_solver = getattr(self, "soil_solver", "qn") # Cached SNES + helper vectors for the soil-aware SPL solver; created # lazily on first call and reused across timesteps. self._snes_soil = None self._snes_soil_f = None self._snes_soil_x = None # Robustness fallback SNES (L-BFGS quasi-Newton); created lazily and # only used on timesteps where the primary solver fails to converge. self._snes_soil_fb = None self._snes_soil_fb_f = None # Cached TS for the non-linear soil diffusion (rosw scheme); created # lazily on first call and reused across timesteps. self._ts_soil = None self._ts_soil_x = None self._ts_soil_f = None # Maximum soil (regolith) thickness — the depth at which soil production # has decayed to a fraction `Sperc` (= soil.bedrockConv) of its surface # rate, i.e. `-ln(Sperc)*Hs`. Applied as a clip in the two Lsoil # write-backs. When `Sperc == 0` (bedrockConv: 0, meaning "no bedrock- # conversion depth") there is NO maximum: use +inf so the clips # (`nHsoil > soil_transition`) are no-ops — a true no-cap. (Was 100.0 m, # which silently imposed a large-but-finite cap on a `bedrockConv: 0` run.) if self.Sperc > 0: self.soil_transition = -np.log(self.Sperc) * self.Hs else: self.soil_transition = np.inf self.Gsoil = self.hGlobal.duplicate() self.Lsoil = self.hLocal.duplicate() self.lHbed = self.hLocal.duplicate() self.gHbed = self.hGlobal.duplicate() # Allocate initial soil thicknesses if self.soilFile is not None: loadData = np.load(self.soilFile) soilH = loadData[self.soilData] self.Lsoil.setArray(soilH[self.locIDs]) self.dm.localToGlobal(self.Lsoil, self.Gsoil) else: # Create an uniform soil thickness distribution self.Gsoil.set(self.cstSoilH) self.Lsoil.set(self.cstSoilH) # Initialise the bedrock elevation (base of the soil/regolith mantle, # lHbed = z − Lsoil) from the start. It is refreshed each soil step # (diffuseSoil), but the groundwater `aquifer_base: from_soil` coupling # reads it on the first step — before the first soil update — so it must # already hold a valid value here rather than an empty duplicate. self.lHbed.waxpy(-1.0, self.Lsoil, self.hLocal) self.gHbed.waxpy(-1.0, self.Gsoil, self.hGlobal) # If temperatures dataset is provided then compute the corresponding soil production rate if self.tempFile is not None: Tref = self.tempRef + 273.15 loadData = np.load(self.tempFile) # Subset the full-mesh temperature map to this rank's local nodes # (mirrors the soilFile branch above). Without [self.locIDs] the # derived prodSoil stays global (mpoints) and broadcasts against # the local hSoil/rainVal arrays only when MPIsize==1; in parallel # (lpoints < mpoints) it raises a ValueError shape mismatch. T = loadData[self.tempData][self.locIDs] + 273.15 # Conversion to Kelvin # Compute Arrhenius term, including Ea / R T0 term R = 8.314 # Gas constant (J/mol/K) Arr_terms = self.energyAct * (1./Tref - 1./T) / R self.prodSoil = self.P0 * np.exp(Arr_terms) else: self.prodSoil = self.P0 * np.ones(len(self.locIDs)) return
[docs] def _form_residual_soil(self, snes, h, F): """ The nonlinear system (SNES) at each time step is solved iteratively by assessing the residual of the SPL equation accounting for erosion, deposition (transport-limited) and soil production. Parameters: ----------- snes : PETSc.SNES: The snes object. h : PETSc.Vec: The current solution vector (h^{n+1}) at the new time step. F : PETSc.Vec: The residual vector to be filled. """ # Current state self.dm.globalToLocal(h, self.hl) h_array = self.hl.getArray() # Compute slope S = local_spl(self.flowDir, h_array, self.rcvIDi, self.distRcvi, self.wghtVali) S[S < 0.] = 0. # Compute upstream sediment flux if self.fDepa > 0: self.tmp.waxpy(-1.0, h, self.hOld) self.tmp1.pointwiseMult(self.tmp, self.areaGlobal) self.tmp1.scale(1. / self.dt) self._solve_KSP(True, self.fMati, self.tmp1, self.tmp) self.dm.globalToLocal(self.tmp, self.tmpL) Qt = self.tmpL.getArray() Qt[Qt < 0.] = 0. Qt[self.seaID] = 0. # Compute soil thickness based on changes in elevation and soil production rates hSoil = self.soilH + h_array - self.hOldArray hSoil[hSoil < 0] = 0. # Regolith mode: the fluvial transport-limited DEPOSITION part of the # elevation change is sediment (stratigraphy), not weathering regolith, # so record its growth to be removed from Lsoil at write-back (erosion # still strips soil). Recomputed each residual eval; the eval at the SNES # solution is the one used. See DESIGN_SOIL_REGOLITH.md §5. if self.regolithSoil: self._soilDepoGrowth = np.maximum(0.0, h_array - self.hOldArray) if self.tempFile is not None: # In this case, the soil production needs to be scaled with local rainfall as the soil production parameter refers here to the precipitation factor a0 in Norton et al. (2013) EQ. 8 # http://dx.doi.org/10.1016/j.geomorph.2013.08.030 hSoil += self.dt * self.prodSoil * self.rainVal * np.exp(-hSoil / self.Hs) else: hSoil += self.dt * self.prodSoil * np.exp(-hSoil / self.Hs) self.nsoilH = hSoil.copy() # Residuals based on the equation # h(t+dt) (1-G) - h(t) (1-G) + dt * K * A^m * S^n - dt * G * Qt / Area = 0 res = (h_array - self.hOldArray) * (1.0 - self.fDep) res += self.Kbr * np.exp(-hSoil / self.h_star) * S**self.spl_n res += self.K_soil * (1.0 - np.exp(-hSoil / self.h_star)) * S**self.spl_n if self.fDepa > 0: res -= self.fDep * self.dt * Qt / self.larea # Residual vector F.setArray(res[self.glIDs]) return
[docs] def _monitorsoil(self, snes, its, norm): """ Non-linear SPL with soil production solver convergence evaluation. """ if MPIrank == 0 and its % 10 == 0: print(f" --- Non-linear soil SPL solver iteration {its}, Residual norm: {norm}", flush=True)
[docs] def _build_soil_snes(self, primary=True): """ Construct (and return) a soil-SPL SNES solver and its residual vector. Two configurations are available: * **primary** (``primary=True``) -- a Nonlinear GMRES accelerator (``ngmres``) right-preconditioned by Nonlinear Richardson (``nrichardson``). The Richardson sweep is what actually applies the Krylov solve (``cg``) and the multigrid preconditioner (HYPRE BoomerAMG by default, or ``self.soil_pc``); a *bare* ``ngmres`` ignores the KSP/PC entirely, which is why the previous setup stalled to ``SNES_DIVERGED_MAX_IT`` on the stiff soil-production residual. * **fallback** (``primary=False``) -- a limited-memory quasi-Newton solver (``qn``, L-BFGS) with a matrix-free critical-point line search. It builds an approximate Jacobian from secant updates (no analytic Jacobian required) and is markedly more robust for stiff problems; it runs only when the primary solver fails to converge. Each SNES gets its own PETSc options prefix so per-solver options (e.g. the line-search type) do not leak into the model's other SNES/KSP objects. :arg primary: select the primary (True) or fallback (False) solver. :return: the configured ``(SNES, residual Vec)`` pair. """ opts = petsc4py.PETSc.Options() snes = petsc4py.PETSc.SNES().create(comm=petsc4py.PETSc.COMM_WORLD) f = self.hGlobal.duplicate() snes.setFunction(self._form_residual_soil, f) if self.verbose: snes.setMonitor(self._monitorsoil) if primary: prefix = "soilspl_" solver = self.soil_solver snes.setTolerances(rtol=self.soil_rtol, atol=self.soil_atol, max_it=self.soil_maxit) else: prefix = "soilsplfb_" # The fallback only runs when the primary has already stalled, so # use the *other* method (the two are complementary: a fast # quasi-Newton vs the globally-convergent multigrid-preconditioned # accelerator), with a relaxed relative tolerance and a larger # iteration budget as a last resort. solver = "ngmres" if self.soil_solver == "qn" else "qn" snes.setTolerances(rtol=max(self.soil_rtol * 100.0, 1.0e-4), atol=self.soil_atol, max_it=max(2 * self.soil_maxit, 200)) snes.setOptionsPrefix(prefix) if solver == "ngmres": snes.setType("ngmres") # Nonlinear right-preconditioner: one Richardson sweep per outer # iteration is what engages the Krylov solve + multigrid PC. npc = snes.getNPC() npc.setType("nrichardson") npc.setTolerances(max_it=1) ksp = npc.getKSP() ksp.setType("cg") pc = ksp.getPC() pc.setType(self.soil_pc) if self.soil_pc == "hypre": opts["pc_hypre_type"] = "boomeramg" pc.setFromOptions() ksp.setTolerances(rtol=1.0e-6) else: # Limited-memory quasi-Newton (L-BFGS): builds curvature from secant # updates (no analytic Jacobian) and typically converges in far # fewer iterations than the ngmres accelerator on this stiff # residual. Critical-point line search is matrix-free (the 'bt' # backtracking search requires a Jacobian, which qn does not form). snes.setType("qn") opts[prefix + "snes_qn_type"] = "lbfgs" opts[prefix + "snes_linesearch_type"] = "cp" snes.setFromOptions() return snes, f
[docs] def _soilErodibility(self, PA, surfK): """ (Re)build the dt-scaled stream-power erodibility coefficients ``Kbr`` (bedrock) and ``K_soil`` (soil layer) for the CURRENT ``self.dt`` and ``self.hOldArray``. The erosion limiter follows the current elevation (drop to the receiver), so this is recomputed per sub-step by the adaptive sub-stepper as well as once for the full step. """ dh = (self.hOldArray[:, None] - self.hOldArray[self.rcvIDi]).max(axis=1) elimiter = np.divide(dh, dh + 1.0e-2, out=np.zeros_like(dh), where=dh != 0) if self.sedfacVal is not None: self.Kbr = self.K * surfK * self.sedfacVal * (self.rainVal ** self.coeffd) else: self.Kbr = self.K * surfK * (self.rainVal ** self.coeffd) self.Kbr *= self.dt * (PA ** self.spl_m) * elimiter self.Kbr[self.seaID] = 0.0 self.K_soil = self.Ksoil * self.dt * (PA ** self.spl_m) * elimiter self.K_soil[self.seaID] = 0.0
[docs] def _soilSNESsolve(self, x, guess): """ Solve the soil-SPL SNES into ``x`` from initial guess ``guess``: the primary solver, then the complementary quasi-Newton / ngmres fallback on failure (same primary→fallback net as the flow KSP). Returns the final converged reason (``>= 0`` converged, ``< 0`` both failed). Prints only the informational "fallback recovered" line; the caller decides what to do on an outright failure (sub-step, then discard). """ guess.copy(result=x) self._snes_soil.solve(None, x) r = self._snes_soil.getConvergedReason() if r < 0: if self._snes_soil_fb is None: self._snes_soil_fb, self._snes_soil_fb_f = self._build_soil_snes( primary=False ) r0, it0 = r, self._snes_soil.getIterationNumber() guess.copy(result=x) self._snes_soil_fb.solve(None, x) r = self._snes_soil_fb.getConvergedReason() if MPIrank == 0 and r >= 0: pn = self.soil_solver fn = "ngmres" if self.soil_solver == "qn" else "qn" print( "Soil SPL: primary (%s) stalled (reason %d after %d its); " "%s fallback converged (reason %d, %d its)." % (pn, r0, it0, fn, r, self._snes_soil_fb.getIterationNumber()), flush=True, ) return r
[docs] def _adaptiveSubstepSoil(self, x, PA, surfK): """ Adaptive sub-stepping for the soil-SPL solve when the full-Δt SNES diverges. The divergence is a stiffness problem: a de-armoured node that has just captured a large discharge demands an enormous single-step incision (``Kbr·Sⁿ`` with ``Kbr ∝ Δt·A^m``), so the slope term overshoots and the residual grows. Because ``Kbr ∝ Δt``, splitting the step into ``N`` increments of ``Δt/N`` cuts the demanded per-solve incision by ``N`` until the residual is smooth enough to converge; summing the increments recovers the FULL step's erosion (unlike simply discarding). Escalates ``N`` over ``_soilSubstepN`` (default 4, 8, 16); accepts the first ``N`` whose every sub-step converges. If none does, the step is discarded (revert to the prior elevation) as a last resort. On success the step-start bookkeeping (``hOld``/``hOldArray``/``soilH``/``dt``) is restored and one final residual evaluation rebuilds ``nsoilH`` / ``_soilDepoGrowth`` consistently with the full step (used by the soil update). Returns the final reason (``0`` sub-stepped OK, ``-1`` discarded). """ dt_full = self.dt hstart = self.hOld.getArray().copy() # step-start (owned) elevation hOldArray0 = self.hOldArray.copy() soilH0 = self.soilH.copy() converged, used = False, 0 for N in getattr(self, "_soilSubstepN", (4, 8, 16)): x.setArray(hstart) # running elevation ← step start self.soilH = soilH0.copy() self.dt = dt_full / N ok = True for _ in range(N): x.copy(result=self.hOld) # sub-step start = current elevation self.dm.globalToLocal(self.hOld, self.hOldLocal) self.hOldArray = self.hOldLocal.getArray().copy() self._soilErodibility(PA, surfK) # Kbr/K_soil at Δt/N + current elev if self._soilSNESsolve(x, self.hOld) < 0: ok = False break self.soilH = self.nsoilH.copy() # soil carried across sub-steps if ok: converged, used = True, N break # Restore full-step bookkeeping (step-start state, full Δt) so the eroded # thickness `x − hOld` and the soil update reflect the whole step. self.dt = dt_full self.hOld.setArray(hstart) self.dm.globalToLocal(self.hOld, self.hOldLocal) self.hOldArray = hOldArray0 self.soilH = soilH0 if not converged: x.setArray(hstart) # last resort: no erosion this step # Rebuild nsoilH / _soilDepoGrowth (Kbr-independent) at the final # elevation with the full-Δt production convention. self._form_residual_soil(self._snes_soil, x, self.tmp) if MPIrank == 0: if converged: print( " Soil SPL: full-Δt solve diverged — converged via %d " "sub-steps (Δt/%d); fluvial erosion retained." % (used, used), flush=True, ) else: print( " Soil SPL: still diverged after sub-stepping — discarded " "(no fluvial erosion this step).", flush=True, ) return 0 if converged else -1
[docs] def _solveSoil(self): """ Solves the non-linear stream power law for the transport limited and soil case. This calls the following *private function*: - _form_residual_soil .. note:: PETSc SNES approach is used to solve the nonlinear equation without forming an analytic Jacobian. The primary solver is a Nonlinear GMRES accelerator (``ngmres``) right-preconditioned by Nonlinear Richardson (``nrichardson``), whose Krylov solve uses a Preconditioned Conjugate Gradient (``cg``) method with a multi-grid preconditioner (HYPRE BoomerAMG by default). If the primary solver stalls on the stiff soil-production residual, a limited-memory quasi-Newton fallback (``qn``, L-BFGS) with a critical-point line search is used. The iteration budget, tolerances and preconditioner are configurable through the YAML ``soil`` block (``maxIter``, ``rtol``, ``atol``, ``pcType``). """ self.hOldArray = self.hLocal.getArray().copy() # Get soil thickness from previous time step self.soilH = self.Lsoil.getArray().copy() # Consider bedrock exposed when soil thickness is below 10 cm self.soilH[self.soilH < BEDROCK_EXPOSED] = 0.0 # Upstream-averaged mean annual precipitation rate based on drainage area PA = self.FAL.getArray() # Per-node erodibility multiplier from the top of the local # stratigraphic column (1.0 = use self.K as-is). Only scales the # *bedrock* SPL coefficient; the soil-layer K is governed by # `self.Ksoil` and is left unchanged. # Fold in the dual-lithology erodibility blend (1.0 everywhere when # single-fraction, so behaviour is unchanged): K_eff = K*surfK*litK. surfK = self._surfaceK() * self._surfaceLithoK() # Dimensionless depositional coefficient fDep = fDepa*area / PA (step- # constant — depends on drainage area, not on Δt or the evolving # elevation). Floor the denominator at the cap value (num/0.99) so a # denormal-tiny PA — more likely with evaporation reducing the discharge — # does not overflow to inf before the 0.99 cap; bit-identical to dividing # then clamping. num = self.fDepa * self.larea self.fDep = np.divide( num, np.maximum(PA, num / 0.99), out=np.zeros_like(PA), where=PA != 0, ) self.fDep[self.seaID] = 0. self.fDep[self.fDep > 0.99] = 0.99 if self.flatModel: self.fDep[self.outletIDs] = 0. # dt-scaled erodibility (erosion limiter + Kbr / K_soil) for the full step. self._soilErodibility(PA, surfK) if self._snes_soil is None: self._snes_soil, self._snes_soil_f = self._build_soil_snes(primary=True) self._snes_soil_x = self.hGlobal.duplicate() x = self._snes_soil_x r = self._soilSNESsolve(x, self.hGlobal) # When the full-Δt solve diverges (both primary and fallback), retry with # adaptive sub-stepping — Δt/N increments make the demanded per-solve # incision small enough to converge, and summing them recovers the full # step's erosion. Only a still-diverging step (after sub-stepping) is # discarded. See `_adaptiveSubstepSoil`. if r < 0: self._adaptiveSubstepSoil(x, PA, surfK) # Get eroded sediment thicknesses self.tmp.waxpy(-1.0, self.hOld, x) # Update soil thicknesses nHsoil = self.nsoilH.copy() # Regolith mode: remove the fluvial transport-limited DEPOSITION growth # (post-solve, so the SNES residual — and its smoothness — is untouched). # Lsoil then reflects weathering production + erosion only; the deposit is # tracked by the stratigraphy. Erosion (negative elevation change) still # strips soil via nsoilH. See DESIGN_SOIL_REGOLITH.md §5. if self.regolithSoil: nHsoil = nHsoil - self._soilDepoGrowth # No subaerial soil under standing water (marine seaID OR a ponded # continental lake). Extends the former marine-only mask to lakes for a # coherent subaerial gate — the rainfall-scaled production term would # otherwise leave a spurious cover on submerged nodes. nHsoil[self._subaqueousMask(self.hOldArray)] = 0.0 nHsoil[nHsoil < BEDROCK_EXPOSED] = 0. # Limit soil thickness nHsoil[nHsoil > self.soil_transition] = self.soil_transition # Ice-covered land: freeze the regolith inert (preserve the prior column, # no production). Applied LAST so the preserved value is not re-clipped. ice = self._iceFrozenMask(self.hOldArray) nHsoil[ice] = self.Lsoil.getArray()[ice] self.Lsoil.setArray(nHsoil) self.dm.localToGlobal(self.Lsoil, self.Gsoil) safe_garbage_cleanup() return
[docs] def _getEroDepRateSoil(self): """ This function computes erosion deposition rates in metres per year and associated soil evolution. This is done on the filled elevation. The approach is based on **BasicHySa** governing equations from Terrainbento (as described in Appendix B20 from `Barnhart et al. (2019) <https://gmd.copernicus.org/articles/12/1267/2019/gmd-12-1267-2019.pdf>`_). .. note:: The approach uses a continuous layer of soil-alluvium, which influences both hillslope and river-induced erosion. It relies on the SPACE algorithm of `Shobe et al. (2017) <https://gmd.copernicus.org/articles/10/4577/2017/>`_. """ t0 = process_time() # Build the SPL erosion arrays. Snapshot the flexure load reference only # at the start of a flexure interval so the load accumulates across # skipped steps (flex_interval). if self.flexOn and self.flexCount % self.flex_interval == 0: self.hLocal.copy(result=self.hOldFlex) self._solveSoil() # Update erosion/deposition rate (thickness convention: positive # for deposition, negative for incision; same sign as cumED and # the on-disk EDrate field). See SPL.py for the convention note. E = self.tmp.getArray().copy() E = np.divide(E, self.dt) self.Eb.setArray(E) self.dm.globalToLocal(self.Eb, self.EbLocal) E = self.EbLocal.getArray().copy() if self.flatModel: E[self.outletIDs] = 0.0 E[self.lsink] = 0.0 self.EbLocal.setArray(E) self.dm.localToGlobal(self.EbLocal, self.Eb) if MPIrank == 0 and self.verbose: print( "Finalise erosion deposition rates (%0.02f seconds)" % (process_time() - t0), flush=True, ) return
def _subaqueousMask(self, hl): """ Boolean mask (``lpoints``) of **subaqueous** nodes — under standing water — where subaerial soil is suppressed. ``self.Lsoil`` is a *subaerial* regolith cover, so it is held at 0 on this mask; sediment deposited under water is tracked by the stratigraphy, not as soil. Two contributions: - **marine** — ``self.seaID`` (filled level at/below sea level); - **ponded continental lake** — a depression node (``pitIDs > -1``) whose fill/spill level sits above the bed (``lFill > hl``), i.e. its accommodation is still water-filled (a lake fills to its spillover before emerging; see ``DESIGN_SOIL_REGOLITH.md`` §3). Purely local (per-node) — no collective. Falls back to a marine-only mask if the pit fields are not yet populated (bare ``STRAMesh.__new__`` stubs). """ 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 > hl) return sub def _iceFrozenMask(self, hl): """ Boolean mask (``lpoints``) of ice-covered LAND where soil is held **frozen inert** — the pre-existing regolith is *preserved* (not zeroed) and no new pedogenic soil is produced, since subaerial weathering does not operate beneath ice (frozen, insulated from the atmosphere, no biota / rain infiltration). This differs from the subaqueous case (marine / ponded lake), where soil is held at 0: ice can preserve a buried regolith for a long time, so glaciation freezes the soil column rather than removing it. Glacial erosion / till are handled separately by the ice model. Ice-covered ⇔ ``iceOn`` and ``iceHL > ICE_COVER_MIN``, restricted to LAND (subaqueous cells are excluded — an ice shelf over sea/lake stays a subaqueous, soil-free cell). Purely local; empty when ice is off. """ ice = np.zeros(self.lpoints, dtype=bool) if not getattr(self, "iceOn", False): return ice iceHL = getattr(self, "iceHL", None) if iceHL is None: return ice ice = iceHL.getArray() > ICE_COVER_MIN ice &= ~self._subaqueousMask(hl) # subaqueous (zero) wins over ice return ice
[docs] def updateSoilThickness(self, deposition=True): """ Updates soil thickness through time from the increment in ``self.tmp``. ``Lsoil`` is a **subaerial** regolith cover, so the increment is not retained at subaqueous nodes (marine or ponded lake) — see ``_subaqueousMask`` — and is frozen under ice (``_iceFrozenMask``). These gates run in every mode, so callers invoke this unconditionally. ``deposition`` distinguishes the two kinds of increment: - **deposition=True** (lake/pit, marine): in **regolith mode** the deposit is stratigraphy, not soil, so the increment is **skipped** (the gates still run — a cell newly ponded by this step's deposition is re-zeroed consistently with lumped mode). In lumped mode it is added. - **deposition=False** (soil creep / hillslope transport): always added — creep moves the weathering regolith itself, in both modes. """ self.dm.globalToLocal(self.tmp, self.tmpL) prevL = self.Lsoil.getArray().copy() if deposition and getattr(self, "regolithSoil", False): nHsoil = prevL.copy() # regolith: deposit is strata, not soil else: nHsoil = prevL + self.tmpL.getArray() hl = self.hLocal.getArray() # No subaerial soil under standing water (marine or ponded lake). nHsoil[self._subaqueousMask(hl)] = 0.0 # Limit soil thickness nHsoil[nHsoil < 0.] = 0. nHsoil[nHsoil > self.soil_transition] = self.soil_transition # Ice-covered land: freeze the regolith inert (no deposition-into-soil # increment under ice — glacial till is tracked by the stratigraphy). ice = self._iceFrozenMask(hl) nHsoil[ice] = prevL[ice] self.Lsoil.setArray(nHsoil) self.dm.localToGlobal(self.Lsoil, self.Gsoil) return
[docs] def erodepSPLsoil(self): """ Modified **stream power law** model used to represent erosion by rivers also taking into account the role played by sediments in modulating erosion and deposition rate, considering **non-linear slope dependency** and accounting for soil production. It calls the private function `_getEroDepRateSoil` described above. Once erosion/deposition rates have been calculated, the function computes local thicknesses and soil evolution for the considered time step and update local elevation and cumulative erosion, deposition values. """ t0 = process_time() # Computes the erosion deposition rates based on flow accumulation self.Eb.set(0.0) self.hGlobal.copy(result=self.hOld) self.dm.globalToLocal(self.hOld, self.hOldLocal) self._getEroDepRateSoil() self._glacialAbrasion() # Get erosion / deposition thicknesses (Eb is in thickness rate # convention: positive deposition, negative incision). See SPL.py. Eb = self.Eb.getArray().copy() self.tmp.setArray(Eb * self.dt) self.cumED.axpy(1.0, self.tmp) self.dm.globalToLocal(self.cumED, self.cumEDLocal) self.hGlobal.axpy(1.0, self.tmp) self.dm.globalToLocal(self.hGlobal, self.hLocal) self.tmp1.pointwiseMult(self.tmp, self.areaGlobal) # Update stratigraphic layers if self.stratNb > 0: self.erodeStrat() self.deposeStrat() # Update erosion/deposition rates self.dm.globalToLocal(self.tmp, self.tmpL) add_rate = self.tmpL.getArray() / self.dt self.EbLocal.setArray(add_rate) # Destroy flow matrices self.fMati.destroy() self.fMat.destroy() if MPIrank == 0 and self.verbose: print( "Get Erosion Deposition values (%0.02f seconds)" % (process_time() - t0), flush=True, ) if self.memclear: del Eb gc.collect() return
[docs] def _evalFunctionSoil(self, ts, t, x, xdot, f): """ The non-linear system for soil diffusion is solved iteratively using PETSc time stepping and SNES solution and is based on Rosenbrock W-scheme (``rosw``). Here again, we evaluate the residual function on a DMPlex for an implicit time-stepping method. Parameters: ----------- ts : PETSc.TS: The time-stepper object. t : float: The current time. x : PETSc.Vec: The current solution vector (h^{n+1}) at the new time step. xdot : PETSc.Vec: The time derivative approximation (h^{n+1} - h^n) / dt. f : PETSc.Vec: The residual vector to be filled. """ self.dm.globalToLocal(x, self.hl) with self.hl as hl, self.lHbed as zb, xdot as hdot: dh = hl - zb dh[dh < 0.1] = 0.0 Cd = self.minDiff + np.multiply(self.Cd, (1.0 - np.exp(-dh / self.H0))) nlvec = fctcoeff(hl, Cd) f.setArray(hdot + nlvec[self.glIDs]) return
[docs] def _evalJacobianSoil(self, ts, t, x, xdot, a, A, B): """ The non-linear system for soil diffusion is solved iteratively using PETSc time stepping and SNES solution and is based on Rosenbrock W-scheme (``rosw``). Here, we define the Jacobian matrix A and the preconditioner matrix B on a DMPlex. Parameters: ----------- ts : PETSc.TS: The time-stepper object. t : float: The current time. x : PETSc.Vec: The current solution vector (h^{n+1}) at the new time step. xdot : PETSc.Vec: The time derivative approximation (h^{n+1} - h^n) / dt. a : float: The shift factor for implicit methods. A : PETSc.Mat: The Jacobian matrix to be filled. B : PETSc.Mat: The preconditioner matrix to be filled. """ self.dm.globalToLocal(x, self.hl) with self.hl as hl, self.lHbed as zb: dh = hl - zb dh[dh < 0.1] = 0.0 Cd = self.minDiff + np.multiply(self.Cd, (1.0 - np.exp(-dh / self.H0))) # Coefficient derivatives Cp = np.multiply(self.Cd, np.exp(-dh / self.H0) / self.H0) nlC = jacobiancoeff(hl, Cd, Cp) # Assemble ONLY the rows this rank OWNS (self.glIDs). Looping over # all self.lpoints rows would also set the GHOST rows, whose stencil # is computed here from an INCOMPLETE neighbour set (the ghost's full # neighbourhood is not all present on this rank). Those off-process # ghost-row values collide under INSERT_VALUES with the owning rank's # correct values, leaving the boundary rows of the Jacobian # partition-dependent -- which drove an isolated elevation spike at # sub-domain boundaries (np=1 clean, np>1 spiked). Owned-row-only # assembly (mirroring _evalJacobianMardDiff) makes the matrix # partition-invariant. The diagonal column for owned row r is r # itself; its off-diagonal columns are r's FV neighbours. ngb_cols = self.FVmesh_ngbID[self.glIDs, :] cols_2d = np.column_stack([self.glIDs[:, None], ngb_cols]).astype( petsc4py.PETSc.IntType ) vals_2d = np.column_stack( [(a + nlC[self.glIDs, 0])[:, None], nlC[self.glIDs, 1:]] ) for i, row in enumerate(self.glIDs): B.setValuesLocal(row, cols_2d[i], vals_2d[i]) B.assemble() if A != B: A.assemble() return True
[docs] def _evalSolutionSoil(self, t, x): """ Evaluate the initial solution of the SNES system. """ assert t == 0.0, "only for t=0.0" x.setArray(self.h.getArray()) return
[docs] def diffuseSoil(self): r""" For river-transported sediments reaching the marine realm, this function computes the related marine deposition diffusion. It is based on a non-linear diffusion approach. .. math:: \frac{\partial h}{\partial t}= \nabla \cdot \left( C_d \times (1.0 - e^{-h_s/H_0} \nabla h \right) It calls the following *private functions*: - _evalFunctionSoil - _evalJacobianSoil - _evalSolutionSoil .. note:: PETSc SNES and time stepping TS approaches are used to solve the non-linear equation above over the considered time step. """ t0 = process_time() # Get diffusion soil coefficient self.Cd = np.full(self.lpoints, self.Cda, dtype=np.float64) self.Cd[self.seaID] = self.Cdm # Dual-lithology (Phase 7): scale soil diffusivity by the surface # composition so fine-rich soil diffuses faster (neutral when single- # fraction / no contrast). if self.stratLith: self.Cd = self.Cd * self._surfaceLithoD() # Remove the soil thickness from the elevation self.hLocal.copy(result=self.hl) self.dm.localToGlobal(self.hl, self.h) self.gHbed.waxpy(-1.0, self.Gsoil, self.hGlobal) self.lHbed.waxpy(-1.0, self.Lsoil, self.hLocal) # Time stepping definition (cached across timesteps) if self._ts_soil is None: ts = petsc4py.PETSc.TS().create(comm=petsc4py.PETSc.COMM_WORLD) # arkimex: IMEX Runge-Kutta schemes | rosw: Rosenbrock W-schemes ts.setType("rosw") ts.setIFunction(self._evalFunctionSoil, self.tmp1) ts.setIJacobian(self._evalJacobianSoil, self.mat) ts.setExactFinalTime(petsc4py.PETSc.TS.ExactFinalTime.MATCHSTEP) # Allow an unlimited number of failures (step rejected and retried) ts.setMaxSNESFailures(-1) # SNES nonlinear solver snes = ts.getSNES() snes.setTolerances(max_it=10) # KSP linear solver ksp = snes.getKSP() ksp.setType("preonly") pc = ksp.getPC() pc.setType("gasm") ts.setFromOptions() self._ts_soil = ts self._ts_soil_x = self.tmp1.duplicate() self._ts_soil_f = self.tmp1.duplicate() ts = self._ts_soil x = self._ts_soil_x # Soil thicknesses are meters; mm-level absolute tolerance is plenty. ts.setTolerances(atol=1e-3, rtol=1e-3) ts.setTime(0.0) # Reset the step COUNTER (setTime only resets the clock). The cached TS # is reused and getStepNumber() is not reset by setTime, so without this # setMaxSteps below becomes a *cumulative* cap — after ~tsStep total # substeps it is exceeded on entry and TSSolve returns immediately, # leaving the soil column un-diffused (same bug as hillslope marine TS). ts.setStepNumber(0) # Larger initial step (was self.dt / 1000.0). ts.setTimeStep(self.dt / 100.0) ts.setMaxTime(self.dt) ts.setMaxSteps(self.tsStep) tstart = ts.getTime() self._evalSolutionSoil(tstart, x) # Solve nonlinear equation ts.solve(x) if MPIrank == 0 and self.verbose: print( "Nonlinear soil diffusion solution (%0.02f seconds)" % (process_time() - t0), flush=True, ) print( "steps %d (%d rejected, %d SNES fails), nonlinear its %d, linear its %d" % ( ts.getStepNumber(), ts.getStepRejections(), ts.getSNESFailures(), ts.getSNESIterations(), ts.getKSPIterations(), ), flush=True, ) # Get diffused sediment thicknesses self.dh.waxpy(-1.0, self.hGlobal, x) self.dm.globalToLocal(self.dh, self.tmpL) chgSoil = self.tmpL.getArray().copy() self.tmpL.setArray(chgSoil) self.dm.localToGlobal(self.tmpL, self.tmp) safe_garbage_cleanup() # Update cumulative erosion and deposition as well as elevation self.cumED.axpy(1.0, self.tmp) self.dm.globalToLocal(self.cumED, self.cumEDLocal) self.hGlobal.axpy(1.0, self.tmp) self.dm.globalToLocal(self.hGlobal, self.hLocal) # Update soil thickness. Soil creep TRANSPORTS the weathering regolith # itself (not a deposit), so it is applied in both modes — deposition=False. self.updateSoilThickness(deposition=False) # Update erosion/deposition rates self.dm.globalToLocal(self.tmp, self.tmpL) add_rate = self.tmpL.getArray() / self.dt self.tmpL.setArray(add_rate) self.EbLocal.axpy(1.0, self.tmpL) # Update stratigraphic layer parameters if self.stratNb > 0: self.deposeStrat() if MPIrank == 0 and self.verbose: print( "Diffuse Soil Sediments (%0.02f seconds)" % (process_time() - t0), flush=True, ) return