Source code for sed.stratplex

import os
import gc
import sys
import petsc4py
import numpy as np

from mpi4py import MPI
from time import process_time

from gospl.tools.constants import BEDROCK_SENTINEL

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

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


[docs] class STRAMesh(object): """ This class encapsulates all the functions related to underlying stratigraphic information. .. note:: Sediment compaction in stratigraphic layers geometry and properties change are also considered. """ def __init__(self): """ The initialisation of `STRAMesh` class related to stratigraphic informations. """ self.stratH = None self.stratZ = None self.phiS = None # Dual-lithology (coarse/fine) layer fields. Allocated only when # self.stratLith is True (see _extraStrata / DESIGN_DUAL_LITHOLOGY.md). # stratHf = fine-fraction layer thickness (coarse = stratH - stratHf); # phiF = fine porosity per layer (phiS then holds the coarse porosity). self.stratHf = None self.phiF = None # Dual-lithology fine mass-balance diagnostics (m^3, owned-node running # totals over the run; reduced across ranks by the conservation test). # _fineEroded accumulates fine solid removed from the pile (erodeStrat); # _fineDeposited accumulates fine deposited back (deposeStrat, both the # continental and marine calls). On a closed sphere they must match to # within the floor/transit budget — the fine-specific analogue of the # total-cumED mass-conservation check. self._fineEroded = 0.0 self._fineDeposited = 0.0 # Per-layer erodibility multiplier (lpoints, stratNb). Effective K # at the surface = self.K * stratK[<top non-empty layer>]. Fresh # deposits and the bedrock sentinel default to 1.0 (no scaling). # An optional `stratK` key in the npstrata file lets the user # impose a non-uniform multiplier on the initial layers. self.stratK = None # In-model provenance tracers (opt-in `provenance:`; see # DESIGN_PROVENANCE.md §6). stratP[node, layer, class] = thickness of # each source-rock class in each layer (Σ over classes == stratH); # source_class = per-vertex bedrock class. Allocated only when provOn. self.stratP = None self.source_class = None return
[docs] def readStratLayers(self): """ When stratigraphic layers are turned on, this function reads any initial stratigraphic layers provided in the input file (key: `npstrata`). The following variables will be read from the file: - thickness of each stratigrapic layer `strataH` accounting for both erosion & deposition events. - elevation at time of deposition, considered to be to the current elevation for the top stratigraphic layer `strataZ`. - porosity of coarse sediment `phiS` in each stratigraphic layer computed at center of each layer. With dual lithology enabled (`strata: dual: True`), two optional keys let each initial layer carry its own coarse/fine composition: - `strataHf`: fine-fraction bulk thickness per layer (coarse = `strataH - strataHf`); absent -> all-coarse. - `phiF`: fine porosity per layer; absent -> defaults to `phi0f`. """ if self.strataFile is not None: fileData = np.load(self.strataFile) # Fail fast on a malformed file (missing required field / wrong # shape) and warn about an under-specified dual-lithology setup. nlay = self._checkStrataFile(fileData) has_hf = "strataHf" in fileData.files has_phiF = "phiF" in fileData.files has_K = "stratK" in fileData.files stratVal = fileData["strataH"] # Optionally reserve layer 0 as a dedicated infinite-bedrock # sentinel BELOW the file-provided layers (strata.bedrock_sentinel). # The file's `nlay` layers then occupy indices [off : off+nlay] and # layer 0 is the frozen 1e6 m reservoir. off=0 is the legacy # behaviour: the deepest file layer is itself the erosion floor. off = 1 if getattr(self, "bedrock_sentinel", False) else 0 self.initLay = nlay + off self.stratNb += self.initLay lo, hi = off, off + nlay # Create stratigraphic arrays self.stratH = np.zeros((self.lpoints, self.stratNb), dtype=np.float64) self.stratH[:, lo:hi] = stratVal[self.locIDs, 0:nlay] stratVal = fileData["strataZ"] self.stratZ = np.zeros((self.lpoints, self.stratNb), dtype=np.float64) self.stratZ[:, lo:hi] = stratVal[self.locIDs, 0:nlay] stratVal = fileData["phiS"] self.phiS = np.zeros((self.lpoints, self.stratNb), dtype=np.float64) self.phiS[:, lo:hi] = stratVal[self.locIDs, 0:nlay] # Per-layer K multiplier. Loaded from the optional `stratK` # key in the npstrata file (same (mpoints, nlay) shape as # the other layer fields); defaults to 1.0 (use self.K as-is). self.stratK = np.ones((self.lpoints, self.stratNb), dtype=np.float64) if "stratK" in fileData.files: stratVal = fileData["stratK"] self.stratK[:, lo:hi] = stratVal[self.locIDs, 0:nlay] # Dual-lithology fine-fraction layer fields. Optional in the # npstrata file: `strataHf` (per-layer fine bulk thickness, so each # initial layer carries its own coarse/fine composition; coarse = # strataH - strataHf) and `phiF` (per-layer fine porosity). Absent # `strataHf` -> all-coarse, so a single-fraction file still loads. if self.stratLith: self.stratHf = np.zeros((self.lpoints, self.stratNb), dtype=np.float64) if "strataHf" in fileData.files: stratVal = fileData["strataHf"] self.stratHf[:, lo:hi] = stratVal[self.locIDs, 0:nlay] # Keep the partition physical: 0 <= fine <= total thickness. np.clip( self.stratHf[:, lo:hi], 0.0, self.stratH[:, lo:hi], out=self.stratHf[:, lo:hi], ) self.phiF = np.zeros((self.lpoints, self.stratNb), dtype=np.float64) if "phiF" in fileData.files: stratVal = fileData["phiF"] self.phiF[:, lo:hi] = stratVal[self.locIDs, 0:nlay] else: # No fine porosity supplied: default to the fine surface # porosity so fine-bearing initial layers compact sensibly # (irrelevant where strataHf == 0). self.phiF[:, lo:hi] = self.phi0f elif "strataHf" in fileData.files and MPIrank == 0: print( "Warning: the npstrata file provides 'strataHf' (per-layer " "fine composition) but dual lithology is disabled. Set " "`strata: dual: True` to use it; the field is ignored.", flush=True, ) if off: # Dedicated infinite-bedrock sentinel beneath the file layers # (mirrors the no-file path): a 1e6 m reservoir at layer 0, # split into coarse/fine by bedrock_coarse_frac under dual # lithology. bedrockLay > 0 freezes it in compaction # (_depthPorosity), and erodeStrat's transient inflate/deflate # of layer 0 makes it an un-erodable floor with this defined # composition. stratZ/stratK keep their 0.0/1.0 init (frozen, # so the values are inert). Provenance is seeded for every # layer (incl. this one) by _initProvenance below. self.stratH[:, 0] = BEDROCK_SENTINEL self.phiS[:, 0] = self.phi0s if self.stratLith: self.stratHf[:, 0] = BEDROCK_SENTINEL * ( 1.0 - self.bedrock_coarse_frac ) self.phiF[:, 0] = self.phi0f self.bedrockLay = 1 else: # All layers in the file are real sediment; no bedrock sentinel. self.bedrockLay = 0 self._logStratInit( nfile_lay=nlay, has_hf=has_hf, has_phiF=has_phiF, has_K=has_K ) if self.memclear: del fileData, stratVal gc.collect() else: self.stratH = np.zeros((self.lpoints, self.stratNb), dtype=np.float64) self.phiS = np.zeros((self.lpoints, self.stratNb), dtype=np.float64) self.stratZ = np.zeros((self.lpoints, self.stratNb), dtype=np.float64) self.stratK = np.ones((self.lpoints, self.stratNb), dtype=np.float64) # Treat layer 0 as an effectively infinite bedrock reservoir when # no initial stratigraphy file is provided. The 1e6 m sentinel # cancels out in erodeStrat (cumThick / eroVal share the offset) # so it does not contaminate eroded volumes. self.stratH[:, 0] = BEDROCK_SENTINEL self.phiS[:, 0] = self.phi0s self.bedrockLay = 1 # layer 0 is the infinite-bedrock sentinel # Dual-lithology fine-fraction fields. The infinite-bedrock # sentinel layer is split into coarse/fine by bedrock_coarse_frac # so material eroded from bedrock inherits a defined composition. if self.stratLith: self.stratHf = np.zeros((self.lpoints, self.stratNb), dtype=np.float64) self.phiF = np.zeros((self.lpoints, self.stratNb), dtype=np.float64) self.stratHf[:, 0] = BEDROCK_SENTINEL * (1.0 - self.bedrock_coarse_frac) self.phiF[:, 0] = self.phi0f self._logStratInit() if getattr(self, "provOn", False): self._initProvenance() return
def _checkStrataFile(self, fileData): """ Validate an ``npstrata`` initial-stratigraphy file before it is loaded. Complains (raises ``ValueError``) when a required field is missing or an array shape is inconsistent, so a malformed file fails fast with a clear message instead of a cryptic ``KeyError`` / broadcast error deep in the loader. Under dual lithology it also warns (rank 0) when the per-layer coarse/fine composition (``strataHf``) is absent, since the initial pile then silently defaults to all-coarse. :arg fileData: the opened ``np.load`` archive. :return: the number of initial layers ``nlay`` (``strataH`` columns). """ avail = set(fileData.files) # 1) Fields required for ANY npstrata file. required = ("strataH", "strataZ", "phiS") missing = [k for k in required if k not in avail] if missing: raise ValueError( "npstrata file '%s' is missing required field(s): %s. An " "initial stratigraphy file must provide 'strataH' (per-layer " "thickness), 'strataZ' (deposition elevation) and 'phiS' " "(coarse porosity), each shaped (mesh_points, n_layers)." % (self.strataFile, ", ".join(missing)) ) # 2) Shape consistency: strataH is (mesh_points, n_layers); every other # layer field must match it (arrays are indexed by global vertex id). ref = fileData["strataH"].shape if len(ref) != 2 or ref[0] != self.mpoints: raise ValueError( "npstrata file '%s': 'strataH' has shape %s but must be " "(mesh_points=%d, n_layers); each row is one mesh vertex." % (self.strataFile, ref, self.mpoints) ) for k in ("strataZ", "phiS", "stratK", "strataHf", "phiF"): if k in avail and fileData[k].shape != ref: raise ValueError( "npstrata file '%s': field '%s' has shape %s but must " "match 'strataH' %s." % (self.strataFile, k, fileData[k].shape, ref) ) # 3) Dual-lithology composition: warn if the fine split is unspecified. if self.stratLith and "strataHf" not in avail and MPIrank == 0: print( "Warning: dual lithology is enabled but the npstrata file '%s' " "provides no 'strataHf' (per-layer fine bulk thickness); the " "initial layers will be treated as ALL-COARSE. Add a 'strataHf' " "array (and optionally 'phiF'), shaped like 'strataH', to set a " "per-vertex initial coarse/fine distribution." % self.strataFile, flush=True, ) return ref[1] def _logStratInit(self, nfile_lay=0, has_hf=False, has_phiF=False, has_K=False): """ Rank-0 verbose summary of the stratigraphy setup (run with ``-v``). Reports the single- vs dual-lithology mode and, under dual lithology, the per-fraction compaction curves and contrast knobs, the source of the initial layers (file vs bedrock-only), whether a per-vertex coarse/fine distribution was supplied, and the bedrock-floor model. """ if MPIrank != 0 or not getattr(self, "verbose", False): return if self.stratLith: print( "Dual-lithology stratigraphy ON — coarse phi0/z0 = %.3g/%.0f m, " "fine phi0/z0 = %.3g/%.0f m, fine K x%.3g, fine diffusivity x%.3g" % ( self.phi0c, self.z0c, self.phi0f, self.z0f, getattr(self, "fine_k_factor", 1.0), getattr(self, "fine_diff_factor", 1.0), ), flush=True, ) else: print("Single-lithology stratigraphy ON", flush=True) if self.strataFile is not None: if not self.stratLith: comp = "single-fraction" elif has_hf: comp = "per-vertex coarse/fine from 'strataHf'" + ( "" if has_phiF else " (phiF defaulted to phi0f)" ) else: comp = "all-coarse (no 'strataHf' in file)" print( " initial layers from '%s': %d layer(s); composition: %s%s" % ( self.strataFile, nfile_lay, comp, "; per-layer K from 'stratK'" if has_K else "", ), flush=True, ) if self.bedrockLay > 0: print( " + dedicated infinite-bedrock sentinel beneath them" + ( " (bedrock_coarse_frac = %.3g)" % self.bedrock_coarse_frac if self.stratLith else "" ), flush=True, ) else: print( " deepest file layer is the infinite-bedrock floor " "(set strata.bedrock_sentinel: True for a dedicated one)", flush=True, ) else: print( " infinite-bedrock sentinel only (no npstrata file)" + ( "; bedrock_coarse_frac = %.3g" % self.bedrock_coarse_frac if self.stratLith else "" ), flush=True, ) return def logDualState(self): """ Rank-0 per-step verbose line summarising the dual-lithology sediment state (run with ``-v``): the area-weighted mean surface fine fraction and the fine share of the whole recorded pile (excluding the bedrock sentinel). Called from the time loop so dual lithology stays visible throughout the run, not only in the one-off init summary (:meth:`_logStratInit`). No-op unless dual lithology is on and verbose. The reductions are COLLECTIVE, so the method runs on every rank (gated only by the uniform ``stratLith``/``verbose``); the line prints on rank 0. """ if not self.stratLith or not getattr(self, "verbose", False): return owned = self.inIDs == 1 a = self.larea ff = 1.0 - self._surfaceComposition() # surface fine fraction comm = MPI.COMM_WORLD surfNum = comm.allreduce(float(np.sum((ff * a)[owned])), op=MPI.SUM) area = comm.allreduce(float(np.sum(a[owned])), op=MPI.SUM) # Pile fine share over the recorded (non-sentinel) layers. b = self.bedrockLay H = self.stratH[:, b : self.stratStep + 1].sum(axis=1) Hf = self.stratHf[:, b : self.stratStep + 1].sum(axis=1) vH = comm.allreduce(float(np.sum((H * a)[owned])), op=MPI.SUM) vHf = comm.allreduce(float(np.sum((Hf * a)[owned])), op=MPI.SUM) if MPIrank == 0: surf = surfNum / area if area > 0.0 else 0.0 pile = vHf / vH if vH > 0.0 else 0.0 print( " [dual] surface fine fraction %.3f | recorded-pile fine " "share %.3f" % (surf, pile), flush=True, ) return def _logProvInit(self): """ Rank-0 ``-v`` setup summary for the in-model provenance tracers: the number of source classes and where the per-vertex bedrock class comes from (a uniform constant or a ``[file, key]`` map). Descriptive only (no reductions); the evolving per-class shares are reported each step by :meth:`logProvState`. """ if MPIrank != 0 or not getattr(self, "verbose", False): return if getattr(self, "_provSourceMap", None) is not None: src = "per-vertex map '%s':'%s'" % ( self._provSourceMap[0], self._provSourceMap[1] ) else: src = "uniform class %d (single source)" % int(self._provSourceUniform) print( "Provenance tracers ON — %d source class(es); bedrock source: %s" % (self.provNb, src), flush=True, ) return def logProvState(self): """ Rank-0 per-step verbose line: the source-class composition of the recorded stratigraphic pile (area-weighted volume share per class, excluding the bedrock sentinel) — i.e. where the deposited sediment has come from so far. No-op unless provenance is on and verbose. The reductions are COLLECTIVE (run on every rank, gated only by the uniform ``provOn``/``verbose``); the line prints on rank 0. """ if not getattr(self, "provOn", False) or not getattr(self, "verbose", False): return owned = self.inIDs == 1 a = self.larea b = self.bedrockLay # Per-class and total recorded-pile volume (area-weighted, owned nodes). P = self.stratP[:, b : self.stratStep + 1, :].sum(axis=1) # (lpoints, prov) H = self.stratH[:, b : self.stratStep + 1].sum(axis=1) # (lpoints,) comm = MPI.COMM_WORLD volP = np.ascontiguousarray( (P * a[:, None])[owned].sum(axis=0), dtype=np.float64 ) comm.Allreduce(MPI.IN_PLACE, volP, op=MPI.SUM) vH = comm.allreduce(float(np.sum((H * a)[owned])), op=MPI.SUM) if MPIrank == 0: if vH > 0.0: shares = " ".join( "c%d %.3f" % (c, volP[c] / vH) for c in range(self.provNb) ) else: shares = "(empty pile)" print(" [prov] recorded-pile source shares: %s" % shares, flush=True) return
[docs] def _initProvenance(self): """ Allocate and seed the provenance state (opt-in `provenance:`; Phase 0). ``source_class`` (per-vertex bedrock class) is read from the ``uniform`` scalar or the ``source`` ``[file, key]`` map; ``stratP[node, layer, class]`` is the per-class thickness in each layer, seeded so every initial layer (and the bedrock sentinel) carries the node's bedrock source class (Σ over classes == stratH). A passive tracer — no physics feedback — so a provenance-on run is identical to provenance-off until the erosion/transport/deposition hooks of later phases are added. """ if self._provSourceMap is not None: fname, key = self._provSourceMap[0], self._provSourceMap[1] data = np.load(fname + ".npz") self.source_class = data[key][self.locIDs].astype(np.int64) del data else: self.source_class = np.full( self.lpoints, int(self._provSourceUniform), dtype=np.int64 ) np.clip(self.source_class, 0, self.provNb - 1, out=self.source_class) # Per-layer per-class thickness; each initial layer's full thickness is # assigned to the node's bedrock source class. self.stratP = np.zeros( (self.lpoints, self.stratNb, self.provNb), dtype=np.float64 ) self.stratP[np.arange(self.lpoints), :, self.source_class] = self.stratH self._logProvInit() return
[docs] def _fillZeroPorosity(self, phiS): """ Where ``phiS == 0`` (layer has been emptied or never deposited), inherit the value from the nearest underlying layer with non-zero porosity. Vectorised forward-fill from low → high index along axis 1. Leading zeros at the column base stay zero (no valid layer below). """ mask = phiS > 0 idx = np.where(mask, np.arange(phiS.shape[1])[None, :], 0) np.maximum.accumulate(idx, axis=1, out=idx) return np.take_along_axis(phiS, idx, axis=1)
[docs] def _surfaceK(self): """ Return the per-node erodibility multiplier of the topmost non-empty stratigraphic layer. Used by the SPL flavours to scale the scalar ``self.K`` according to the bedrock currently exposed at the surface. Returns 1.0 everywhere when stratigraphy is disabled (``stratNb == 0``) or when the column is fully empty at a node, so the SPL evaluation falls back to the YAML-default K. """ if self.stratNb == 0 or self.stratK is None: return np.ones(self.lpoints, dtype=np.float64) sK = self.stratK[:, : self.stratStep + 1] mask = sK > 0 # For each row, locate the highest column index where the layer # has a non-zero multiplier. Reverse-then-argmax picks the first # non-zero starting from the top of the column. rev = mask[:, ::-1] any_valid = rev.any(axis=1) top_from_right = np.argmax(rev, axis=1) top_idx = sK.shape[1] - 1 - top_from_right out = np.ones(self.lpoints, dtype=np.float64) rows = np.arange(sK.shape[0]) out[any_valid] = sK[rows[any_valid], top_idx[any_valid]] return out
[docs] def _surfaceComposition(self): """ Return the per-node coarse fraction ``fc`` (by bulk thickness) of the topmost non-empty stratigraphic layer; the fine fraction is ``1 - fc``. Returns all-ones (all coarse) when dual lithology is disabled or the fine pile is unallocated, so single-fraction callers are unchanged. Shared hook for the SPL erodibility blend (`_surfaceLithoK`) and the hillslope diffusivity blend (see DESIGN_DUAL_LITHOLOGY.md). """ if not self.stratLith or self.stratHf is None or self.stratNb == 0: return np.ones(self.lpoints, dtype=np.float64) H = self.stratH[:, : self.stratStep + 1] Hf = self.stratHf[:, : self.stratStep + 1] mask = H > 0 # Topmost non-empty layer per node (reverse-then-argmax, as _surfaceK). rev = mask[:, ::-1] any_valid = rev.any(axis=1) top_from_right = np.argmax(rev, axis=1) top_idx = H.shape[1] - 1 - top_from_right fc = np.ones(self.lpoints, dtype=np.float64) rows = np.arange(H.shape[0]) Htop = H[rows, top_idx] Hftop = Hf[rows, top_idx] valid = any_valid & (Htop > 0) fc[valid] = 1.0 - (Hftop[valid] / Htop[valid]) np.clip(fc, 0.0, 1.0, out=fc) return fc
[docs] def _surfaceLithoK(self): """ Per-node erodibility multiplier from the exposed surface composition: ``fc + (1 - fc) * fine_k_factor``. Equals 1.0 everywhere when dual lithology is off (or ``fine_k_factor == 1``, i.e. no lithology contrast), so it composes multiplicatively with ``_surfaceK`` in the SPL flavours without altering single-fraction behaviour. """ if not self.stratLith: return np.ones(self.lpoints, dtype=np.float64) fc = self._surfaceComposition() return fc + (1.0 - fc) * self.fine_k_factor
[docs] def _surfaceLithoD(self): """ Per-node diffusivity multiplier from the exposed surface composition: ``fc + (1 - fc) * fine_diff_factor``. Equals 1.0 everywhere when dual lithology is off (or ``fine_diff_factor == 1``, i.e. no contrast), so it composes multiplicatively with the base hillslope coefficients (``Cda``/``Cdm``) without altering single-fraction behaviour. Fines diffuse faster when ``fine_diff_factor > 1`` (see DESIGN_DUAL_LITHOLOGY.md Section 7). """ if not self.stratLith: return np.ones(self.lpoints, dtype=np.float64) fc = self._surfaceComposition() return fc + (1.0 - fc) * self.fine_diff_factor
[docs] def deposeStrat(self): """ Add deposition on top of an existing stratigraphic layer. The following variables will be recorded: - thickness of each stratigrapic layer `stratH` accounting for both erosion & deposition events. - porosity of sediment `phiS` in each stratigraphic layer computed at center of each layer. In dual-lithology mode the deposit is split into a coarse and a fine fraction using the **per-node deposit fine fraction** ``self.depoFineFrac``. It starts each step as ``self.fineFrac`` (the composition of the routed sediment arriving at each node, from ``sedplex._getSedFlux``) and is refined inside continental depressions by ``_pitFineFraction`` (3b: fine biased to the depocenter, coarse to the inlet/margins). """ self.dm.globalToLocal(self.tmp, self.tmpL) depo = self.tmpL.getArray().copy() depo[depo < 1.0e-4] = 0.0 self.stratH[:, self.stratStep] += depo ids = np.where(depo > 0)[0] if self.stratLith: fineDepo = depo * self.depoFineFrac self.stratHf[:, self.stratStep] += fineDepo # Mass-balance diagnostic (owned nodes only, m^3). self._fineDeposited += float( np.sum((fineDepo * self.larea)[self.inIDs == 1]) ) # Fresh deposit carries each lithology's surface porosity. self.phiS[ids, self.stratStep] = self.phi0c self.phiF[ids, self.stratStep] = self.phi0f else: self.phiS[ids, self.stratStep] = self.phi0s if getattr(self, "provOn", False): # Lay the deposit into the layer's provenance composition (the # arriving routed composition; a passive tracer, so no sorting). # Keeps Σ over classes == stratH for the deposited thickness. frac = self.depoProvFrac.copy() fsum = frac.sum(axis=1) # Depositing nodes with no arriving composition (Σ_c ≈ 0) — e.g. # off-channel hillslope-creep deposits, which have no river # through-flux so `provFrac` is zero there. Without a fallback the # layer gets thickness but zero provenance (Σ_c stratP < stratH), so # the post-processed cell shows no source class (`dominant == -1`). # Attribute such locally-derived deposits to the in-situ bedrock # `source_class` (the best label available without threading # provenance through hillslope diffusion). Then renormalise every # depositing node so the layer is exactly partitioned (Σ_c == depo); # routed/pit/marine fractions already sum to 1, so this is a no-op # for them and only repairs the hillslope holes. holes = (depo > 0.0) & (fsum < 1.0e-6) if holes.any(): frac[holes, :] = 0.0 frac[holes, self.source_class[holes]] = 1.0 fsum = frac.sum(axis=1) good = fsum > 0.0 frac[good] /= fsum[good, None] provDepo = depo[:, None] * frac self.stratP[:, self.stratStep, :] += provDepo self._provDeposited += np.sum( (provDepo * self.larea[:, None])[self.inIDs == 1], axis=0 ) # Freshly deposited sediment carries the default erodibility (no # multiplier). If you want re-deposited sediment to keep its # source-layer K, this is the line to revisit. self.stratK[ids, self.stratStep] = 1.0 # Cleaning arrays if self.memclear: del depo, ids gc.collect() return
[docs] def erodeStrat(self): """ This function removes eroded sediment thicknesses from the stratigraphic pile. The function takes into account the porosity values of considered lithologies in each eroded stratigraphic layers. It follows the assumptions: - Eroded thicknesses from stream power law and hillslope diffusion are considered to encompass both the solid and void phase. - Only the solid phase will be moved dowstream by surface processes. - The corresponding deposit thicknesses for those freshly eroded sediments correspond to uncompacted thicknesses based on the porosity at surface given from the input file. """ self.dm.globalToLocal(self.tmp, self.tmpL) ero = self.tmpL.getArray().copy() ero[ero > 0] = 0.0 # Nodes experiencing erosion nids = np.where(ero < 0)[0] if len(nids) == 0: self.thCoarse = np.zeros(self.lpoints) if self.stratLith: self.thFine = np.zeros(self.lpoints) if getattr(self, "provOn", False): self.provEro = np.zeros((self.lpoints, self.provNb)) return # Cumulative thickness for each node if self.stratLith: # Inflate the fine pile in proportion to layer 0's CURRENT fine # fraction so the sentinel never alters that layer's composition # (for the infinite-bedrock case this fraction is 1-bedrock_coarse_frac; # for a real basal layer it is its own fine ratio, i.e. 0 if all coarse). H0 = self.stratH[nids, 0] sentFine = BEDROCK_SENTINEL * np.divide( self.stratHf[nids, 0], H0, out=np.zeros_like(H0), where=H0 > 0 ) self.stratH[nids, 0] += BEDROCK_SENTINEL if self.stratLith: self.stratHf[nids, 0] += sentFine provOn = getattr(self, "provOn", False) if provOn: # The infinite-bedrock sentinel is pure bedrock -> the node's source # class (keeps Σ over classes == stratH for layer 0). self.stratP[nids, 0, self.source_class[nids]] += BEDROCK_SENTINEL cumThick = np.cumsum(self.stratH[nids, self.stratStep :: -1], axis=1)[:, ::-1] boolMask = cumThick < -ero[nids].reshape((len(nids), 1)) mask = boolMask.astype(int) if provOn: # Per-class bulk eroded from the FULLY consumed layers (captured # before they are cleared below). provMaskedBulk = ( self.stratP[nids, : self.stratStep + 1, :] * mask[:, :, None] ).sum(axis=1) thickS = self.stratH[nids, 0 : self.stratStep + 1] if self.stratLith: # Each layer's bulk thickness splits into coarse and fine; each # fraction yields its own solid phase (1 - its own porosity). fineTh = self.stratHf[nids, 0 : self.stratStep + 1] coarseTh = thickS - fineTh thCoarse = coarseTh * (1.0 - self.phiS[nids, 0 : self.stratStep + 1]) thFine = fineTh * (1.0 - self.phiF[nids, 0 : self.stratStep + 1]) thCoarse = np.sum((thCoarse * mask), axis=1) thFine = np.sum((thFine * mask), axis=1) else: thCoarse = thickS * (1.0 - self.phiS[nids, 0 : self.stratStep + 1]) thCoarse = np.sum((thCoarse * mask), axis=1) # Clear all stratigraphy points which are eroded cumThick[boolMask] = 0.0 tmp = self.stratH[nids, : self.stratStep + 1] tmp[boolMask] = 0 self.stratH[nids, : self.stratStep + 1] = tmp if self.stratLith: tmpf = self.stratHf[nids, : self.stratStep + 1] tmpf[boolMask] = 0 self.stratHf[nids, : self.stratStep + 1] = tmpf if provOn: tmpp = self.stratP[nids, : self.stratStep + 1, :] tmpp[boolMask] = 0.0 self.stratP[nids, : self.stratStep + 1, :] = tmpp # Erode remaining stratal layers # Get non-zero top layer number. `minlength=len(nids)` is required: a # node whose entire column was consumed (every layer in `boolMask`, i.e. # the erosion demand exceeded the total pile *including* the # BEDROCK_SENTINEL infinite-bedrock floor) contributes no nonzero row to # `np.nonzero(cumThick)[0]`. Without minlength, `bincount` silently # truncates such an all-zero row when it is the LAST node, so `eroLayNb` # comes back one short and the fancy-index below raises a broadcast # IndexError (a middle all-zero row already gets a 0 count, so only # trailing ones crash). The sentinel makes a fully-consumed column # impossible for physical erosion, but a non-converged upstream solve # (e.g. glacial abrasion feeding `dz_ero`) can produce # a pathological demand that reaches here; padding keeps `erodeStrat` # crash-safe and treats those rows like any other fully-eroded node. eroLayNb = np.bincount(np.nonzero(cumThick)[0], minlength=len(nids)) - 1 eroVal = cumThick[np.arange(len(nids)), eroLayNb] + ero[nids] self.thCoarse = np.zeros(self.lpoints) # From the partially eroded top layer extract the solid phase removed. H_old = self.stratH[nids, eroLayNb] tmp = H_old - eroVal tmp[tmp < 1.0e-8] = 0.0 # TODO-REFACTOR: value matches DISCHARGE_FLOOR but distinct role (thickness numerical-noise floor); do not replace if self.stratLith: self.thFine = np.zeros(self.lpoints) Hf_old = self.stratHf[nids, eroLayNb] # Well-mixed layer: coarse/fine bulk are removed in proportion to # the layer composition; the remainder keeps the same ratio. frac = np.divide(tmp, H_old, out=np.zeros_like(tmp), where=H_old > 0) coarse_rm = (H_old - Hf_old) * frac fine_rm = Hf_old * frac thCoarse = thCoarse + coarse_rm * (1.0 - self.phiS[nids, eroLayNb]) thFine = thFine + fine_rm * (1.0 - self.phiF[nids, eroLayNb]) # Uncompacted thickness deposited downstream, per fraction, using # each lithology's surface porosity. self.thCoarse[nids] = thCoarse / (1.0 - self.phi0c) self.thFine[nids] = thFine / (1.0 - self.phi0f) self.thCoarse[self.thCoarse < 0.0] = 0.0 self.thFine[self.thFine < 0.0] = 0.0 # Remaining fine in the partial layer scales with remaining total. self.stratHf[nids, eroLayNb] = Hf_old * np.divide( eroVal, H_old, out=np.zeros_like(eroVal), where=H_old > 0 ) else: # Define the uncompacted sand thickness that will be deposited dowstream thCoarse += tmp * (1.0 - self.phiS[nids, eroLayNb]) self.thCoarse[nids] = thCoarse / (1.0 - self.phi0s) self.thCoarse[self.thCoarse < 0.0] = 0.0 if provOn: # Per-class bulk removed from the partial top layer (proportional to # the bulk removed), plus the fully-eroded layers captured earlier. fracP = np.divide( H_old - eroVal, H_old, out=np.zeros_like(H_old), where=H_old > 0 ) P_part = self.stratP[nids, eroLayNb, :].copy() provBulkEro = provMaskedBulk + P_part * fracP[:, None] # (n, classes) keepP = np.divide(eroVal, H_old, out=np.zeros_like(eroVal), where=H_old > 0) self.stratP[nids, eroLayNb, :] = P_part * keepP[:, None] # Update thickness of top stratigraphic layer self.stratH[nids, eroLayNb] = eroVal self.stratH[nids, 0] -= BEDROCK_SENTINEL if self.stratLith: self.stratHf[nids, 0] -= sentFine if provOn: self.stratP[nids, 0, self.source_class[nids]] -= BEDROCK_SENTINEL neg = self.stratH < 0 self.stratH[neg] = 0.0 self.phiS[neg] = 0.0 self.stratK[neg] = 0.0 self.phiS[:, : self.stratStep + 1] = self._fillZeroPorosity( self.phiS[:, : self.stratStep + 1] ) # Same forward-fill for the K multiplier so an emptied layer # inherits the bedrock K of the layer below it once exposed. self.stratK[:, : self.stratStep + 1] = self._fillZeroPorosity( self.stratK[:, : self.stratStep + 1] ) if self.stratLith: top = self.stratStep + 1 self.stratHf[neg] = 0.0 self.phiF[neg] = 0.0 np.clip(self.stratHf, 0.0, None, out=self.stratHf) # Fine bulk can never exceed the layer total thickness. np.minimum( self.stratHf[:, :top], self.stratH[:, :top], out=self.stratHf[:, :top], ) self.phiF[:, :top] = self._fillZeroPorosity(self.phiF[:, :top]) self.thCoarse /= self.dt if self.stratLith: self.thFine /= self.dt # Mass-balance diagnostic: fine solid removed this step (owned # nodes, m^3). thFine is the uncompacted deposit-equivalent rate, # the same basis transported and deposited downstream. self._fineEroded += float( np.sum((self.thFine * self.dt * self.larea)[self.inIDs == 1]) ) if provOn: top = self.stratStep + 1 np.clip(self.stratP[:, :top, :], 0.0, None, out=self.stratP[:, :top, :]) # Re-impose Σ over classes == stratH per layer (absorbs the sentinel # round-off and the neg-thickness clamp); empty layers stay zero. psum = self.stratP[:, :top, :].sum(axis=2) scale = np.divide( self.stratH[:, :top], psum, out=np.ones_like(psum), where=psum > 0 ) self.stratP[:, :top, :] *= scale[:, :, None] # Per-class eroded sediment as the uncompacted deposit-equivalent # RATE: the total (self.thCoarse[+thFine]) split by the eroded bulk's # provenance composition. Σ over classes == the total, so the class # sub-fluxes routed in transport sum to the total flux. thTot = self.thCoarse.copy() if self.stratLith: thTot = thTot + self.thFine totBulk = provBulkEro.sum(axis=1) provComp = np.divide( provBulkEro, totBulk[:, None], out=np.zeros_like(provBulkEro), where=totBulk[:, None] > 0, ) self.provEro = np.zeros((self.lpoints, self.provNb)) self.provEro[nids] = provComp * thTot[nids][:, None] self._provEroded += np.sum( (self.provEro * self.dt * self.larea[:, None])[self.inIDs == 1], axis=0 ) return
[docs] def elevStrat(self): """ This function updates the current stratigraphic layer elevation. """ self.stratZ[:, self.stratStep] = self.hLocal.getArray() return
[docs] def _depthPorosity(self, depth): """ This function uses the depth-porosity relationships to compute the porosities for each lithology and then the solid phase to get each layer thickness changes due to compaction. .. note:: We assume that porosity cannot increase after unloading. :arg depth: depth below basement for each sedimentary layer :return: newH updated sedimentary layer thicknesses after compaction """ if self.stratLith: return self._depthPorosityDual(depth) # Depth-porosity functions phiS = self.phi0s * np.exp(depth / self.z0s) phiS = np.minimum(phiS, self.phiS[:, : self.stratStep + 1]) # Compute the solid phase in each layers tmpS = self.stratH[:, : self.stratStep + 1].copy() tmpS *= 1.0 - self.phiS[:, : self.stratStep + 1] solidPhase = tmpS # Get new layer thickness after porosity change tot = 1.0 - phiS[:, : self.stratStep + 1] ids = np.where(tot > 0.0) newH = np.zeros(tot.shape) newH[ids] = solidPhase[ids] / tot[ids] newH[newH <= 0] = 0.0 phiS[newH <= 0] = 0.0 # Freeze the bedrock sentinel so it neither compacts nor drives surface drop if self.bedrockLay > 0: b = self.bedrockLay newH[:, :b] = self.stratH[:, :b] # thickness unchanged (1e6 sentinel) phiS[:, :b] = self.phiS[:, :b] # porosity unchanged # Update porosities in each sedimentary layer phiS = self._fillZeroPorosity(phiS) self.phiS[:, : self.stratStep + 1] = phiS if self.memclear: del phiS, solidPhase del ids, tmpS, tot gc.collect() return newH
[docs] def _depthPorosityDual(self, depth): """ Dual-lithology depth-porosity / compaction. Each fraction (coarse, fine) compacts on its own porosity-depth curve; the layer's new thickness is the sum of the two recompacted bulk thicknesses, and the fine pile ``stratHf`` is updated consistently. This is the headline physics of dual lithology: fine and coarse have different compaction, so the same solid load yields a different preserved thickness depending on the layer's grain mix. :arg depth: depth below basement for each sedimentary layer :return: newH updated layer thicknesses after compaction """ top = self.stratStep + 1 phiS_cur = self.phiS[:, :top] phiF_cur = self.phiF[:, :top] H = self.stratH[:, :top] Hf = self.stratHf[:, :top] Hc = H - Hf # Per-fraction depth-porosity, capped at the current value (porosity # cannot increase on unloading — same assumption as single-fraction). phiS_new = np.minimum(self.phi0c * np.exp(depth / self.z0c), phiS_cur) phiF_new = np.minimum(self.phi0f * np.exp(depth / self.z0f), phiF_cur) # Solid phase preserved per fraction. coarseSolid = Hc * (1.0 - phiS_cur) fineSolid = Hf * (1.0 - phiF_cur) # Recompacted bulk thickness per fraction. totC = 1.0 - phiS_new totF = 1.0 - phiF_new Hc_new = np.zeros_like(Hc) Hf_new = np.zeros_like(Hf) idc = totC > 0.0 idf = totF > 0.0 Hc_new[idc] = coarseSolid[idc] / totC[idc] Hf_new[idf] = fineSolid[idf] / totF[idf] Hc_new[Hc_new <= 0] = 0.0 Hf_new[Hf_new <= 0] = 0.0 newH = Hc_new + Hf_new # Emptied layers carry no porosity. empty = newH <= 0 phiS_new[empty] = 0.0 phiF_new[empty] = 0.0 # Freeze the bedrock sentinel layers (thickness + porosities unchanged). if self.bedrockLay > 0: b = self.bedrockLay newH[:, :b] = H[:, :b] Hf_new[:, :b] = Hf[:, :b] phiS_new[:, :b] = phiS_cur[:, :b] phiF_new[:, :b] = phiF_cur[:, :b] phiS_new = self._fillZeroPorosity(phiS_new) phiF_new = self._fillZeroPorosity(phiF_new) self.phiS[:, :top] = phiS_new self.phiF[:, :top] = phiF_new self.stratHf[:, :top] = Hf_new if self.memclear: del phiS_new, phiF_new, coarseSolid, fineSolid del Hc, Hf, Hc_new, Hf_new, totC, totF gc.collect() return newH
[docs] def getCompaction(self): """ This function computes the change in sedimentary layers porosities and thicknesses due to compaction. .. note:: We assume a simple depth-porosiy relationship. """ t0 = process_time() topZ = np.vstack(self.hLocal.getArray()) totH = np.sum(self.stratH[:, : self.stratStep + 1], axis=1) # Height of the sediment column above the center of each layer is given by cumZ = -np.cumsum(self.stratH[:, self.stratStep :: -1], axis=1) + topZ elev = np.append(topZ, cumZ[:, :-1], axis=1) zlay = np.fliplr(elev - np.fliplr(self.stratH[:, : self.stratStep + 1] / 2.0)) # Compute lithologies porosities for each depositional layers # Get depth below basement depth = zlay - topZ # Now using depth-porosity relationships we compute the porosities newH = self._depthPorosity(depth) # Get the total thickness changes induced by compaction and # update the elevation accordingly dz = totH - np.sum(newH, axis=1) dz[dz <= 0] = 0.0 self.hLocal.setArray(topZ.flatten() - dz.flatten()) self.dm.localToGlobal(self.hLocal, self.hGlobal) # Update each layer thicknesses self.stratH[:, : self.stratStep + 1] = newH # Provenance: compaction only expels pore water — it is composition- # neutral (every source class's solid grains are preserved in the same # proportion), but it shrinks the layer thickness. Rescale stratP by the # same per-layer ratio so Σ over classes == the compacted stratH stays # exact; without this stratP keeps the pre-compaction (larger) thickness # and the post-processed fraction stratP[c]/stratH exceeds 1. Same renorm # idiom as erodeStrat and the advection step. (stratHf is already # recompacted consistently inside _depthPorosityDual.) if getattr(self, "provOn", False): top = self.stratStep + 1 psum = self.stratP[:, :top, :].sum(axis=2) scale = np.divide( self.stratH[:, :top], psum, out=np.ones_like(psum), where=psum > 0 ) self.stratP[:, :top, :] *= scale[:, :, None] if self.memclear: del dz, newH, totH, topZ del depth, zlay, cumZ, elev gc.collect() if MPIrank == 0 and self.verbose: print( "Compute Lithology Porosity Values (%0.02f seconds)" % (process_time() - t0), flush=True, ) return
[docs] def stratalRecord(self, indices, weights, onIDs): """ Once the interpolation has been performed, the following function updates the stratigraphic records based on the advected mesh. The function relies on the fortran subroutine strataonesed. In dual-lithology mode the fine pile (`stratHf`, `phiF`) is advected with a second strataonesed call — `stratHf` is a bulk thickness, so the plain weighted interpolation applies directly (the unused `stratathreesed` kernel instead treats its extra fields as 0–1 fractions and renormalises them, which does not match the thickness representation here). :arg indices: indices of the closest nodes used for interpolation :arg weights: weights based on the distances to closest nodes :arg onIDs: index of nodes remaining at the same position. """ # Get local stratal dataset after displacements loc_stratH = self.stratH[:, : self.stratStep] loc_stratZ = self.stratZ[:, : self.stratStep] loc_phiS = self.phiS[:, : self.stratStep] nstratH, nstratZ, nphiS = strataonesed( self.lpoints, self.stratStep, indices, weights, loc_stratH, loc_stratZ, loc_phiS, ) if len(onIDs) > 0: nstratZ[onIDs, :] = loc_stratZ[indices[onIDs, 0], :] nstratH[onIDs, :] = loc_stratH[indices[onIDs, 0], :] nphiS[onIDs, :] = loc_phiS[indices[onIDs, 0], :] if self.stratLith: # Advect the fine pile with the same interpolation (stratHf as # thickness, phiF as porosity); the re-interpolated elevation is # identical to nstratZ above and discarded. loc_stratHf = self.stratHf[:, : self.stratStep] loc_phiF = self.phiF[:, : self.stratStep] nstratHf, _, nphiF = strataonesed( self.lpoints, self.stratStep, indices, weights, loc_stratHf, loc_stratZ, loc_phiF, ) if len(onIDs) > 0: nstratHf[onIDs, :] = loc_stratHf[indices[onIDs, 0], :] nphiF[onIDs, :] = loc_phiF[indices[onIDs, 0], :] provOn = getattr(self, "provOn", False) if provOn: # Advect each provenance class's per-layer thickness with the same # interpolation (a bulk thickness, like stratHf). Linear weights # preserve Σ over classes == stratH (re-imposed below after sync). nstratP = np.zeros((self.lpoints, self.stratStep, self.provNb)) for c in range(self.provNb): loc_P = np.ascontiguousarray(self.stratP[:, : self.stratStep, c]) nP, _, _ = strataonesed( self.lpoints, self.stratStep, indices, weights, loc_P, loc_stratZ, loc_phiS, ) if len(onIDs) > 0: nP[onIDs, :] = loc_P[indices[onIDs, 0], :] nstratP[:, :, c] = nP # Updates stratigraphic records after mesh advection on the edges of each partition # to ensure that all stratigraphic information on the adjacent nodes of the neighbouring # partition are equals on all processors sharing a common number of nodes. for k in range(self.stratStep): self.tmp.setArray(nstratZ[:, k]) self.dm.globalToLocal(self.tmp, self.tmpL) self.stratZ[:, k] = self.tmpL.getArray().copy() self.tmp.setArray(nstratH[:, k]) self.dm.globalToLocal(self.tmp, self.tmpL) self.stratH[:, k] = self.tmpL.getArray().copy() self.tmp.setArray(nphiS[:, k]) self.dm.globalToLocal(self.tmp, self.tmpL) self.phiS[:, k] = self.tmpL.getArray().copy() if self.stratLith: self.tmp.setArray(nstratHf[:, k]) self.dm.globalToLocal(self.tmp, self.tmpL) self.stratHf[:, k] = self.tmpL.getArray().copy() self.tmp.setArray(nphiF[:, k]) self.dm.globalToLocal(self.tmp, self.tmpL) self.phiF[:, k] = self.tmpL.getArray().copy() if provOn: for c in range(self.provNb): self.tmp.setArray(nstratP[:, k, c]) self.dm.globalToLocal(self.tmp, self.tmpL) self.stratP[:, k, c] = self.tmpL.getArray().copy() if provOn: # Re-impose Σ over classes == stratH after the interpolation drift. top = self.stratStep np.clip(self.stratP[:, :top, :], 0.0, None, out=self.stratP[:, :top, :]) psum = self.stratP[:, :top, :].sum(axis=2) scale = np.divide( self.stratH[:, :top], psum, out=np.ones_like(psum), where=psum > 0 ) self.stratP[:, :top, :] *= scale[:, :, None] return