Source code for flow.iceplex

import os
import gc
import petsc4py
import numpy as np

from mpi4py import MPI
from time import process_time

if "READTHEDOCS" not in os.environ:
    from gospl._fortran import ice_velocity
    from gospl._fortran import ice_lateral_erosion
    from gospl._fortran import mfdreceivers

MPIrank = petsc4py.PETSc.COMM_WORLD.Get_rank()


[docs] class IceMesh(object): r""" Diagnostic glacial-erosion model. Rather than time-integrating an ice-dynamics PDE, this class derives a cheap, stable diagnostic of the glacial state each step and uses it to drive glacial erosion, sediment transport and ice loading. For the equilibrium-line-altitude (ELA) surface mass balance :math:`\dot{m}`: 1. The net mass balance :math:`\dot{m}^+ - \mathrm{melt}\,\dot{m}^-` is routed downhill on the (drainage-conditioned) bed using a multiple-flow-direction algorithm — the same machinery as the river flow accumulation — into an **ice discharge** :math:`Q` (m\ :sup:`3`/yr); one linear solve, no time integration. ``melt`` (default 0 = accumulation-only) sets how much ablation eats the discharge, hence the glacier extent. 2. The **ice thickness** follows a Bahr width–area scaling of the discharge, :math:`H = e_h\, f_w\, Q^{0.3}`. 3. The **basal sliding velocity** comes from Glen's sliding law on that thickness and the bed-surface slope (the ``ice_velocity`` kernel, :math:`u_b \propto H^{n-1}|\nabla s|^{n-1}\nabla s`) — physically bounded, so erosion does not spike at flow-convergence cells. From these the model computes velocity-based glacial abrasion (:math:`E_g = K_g |u_b|^l`, with an optional lateral valley-wall term for U-shaping), ice-transported till deposited as moraine where the ice melts out (conserving rock volume), the discharge-conserving glacial meltwater re-injected into the river flow accumulation, and the ice load feeding the flexural isostasy. The diagnostic is robust and physical at any resolution and over goSPL's long (:math:`10^2`–:math:`10^4` yr) timesteps, where a true ice-dynamics solve is stiff and over-thickens km-scale continental ice (see ``docs/DESIGN_ICE_SHEET.md`` for the history). Useful links: - `Braun et al. (1999) <https://doi.org/10.3189/172756499781821797>`_ - `Herman & Braun (2008) <https://doi.org/10.1029/2007JF000807>`_ - `Egholm (2012) <https://doi.org/10.1016/j.geomorph.2011.12.019>`_ - `Deal & Prasicek (2020) <https://agupubs.onlinelibrary.wiley.com/doi/10.1029/2020GL089263>`_ - `Hergarten (2021) <http://dx.doi.org/10.5194/esurf-2021-1>`_ - `Liebl et al. (2023) <https://gmd.copernicus.org/articles/16/1315/2023/>`_ """ def __init__(self, *args, **kwargs): """ Initialisation of the `IceMesh` class. This method initializes the ice-related fields (ice thickness, basal velocity, meltwater and flexural response) based on the configuration flags `iceOn` and `flexOn`. Memory is allocated for these fields. """ if self.iceOn: # Diagnostic ice thickness (Bahr width-area scaling of the discharge). self.iceHL = self.hLocal.duplicate() # Precip-scaled ablation pattern used as the till melt-out / moraine # deposition weight. self.iceMeltL = self.hLocal.duplicate() # Glacial meltwater delivered to the rivers (discharge-conserving), # re-injected into the river FA source in flowplex so glacial # discharge is not lost downstream. Distinct from iceMeltL. self.iceMeltRiverL = self.hLocal.duplicate() if self.flexOn: self.iceFlex = self.hLocal.duplicate() # Basal sliding speed (m/yr); the driver for the velocity-based # glacial abrasion law. Registered in destroy_DMPlex. self.iceUbL = self.hLocal.duplicate() # Glacial abrasion rate E_g = Kg|u_b|^l (m/yr); a diagnostic output # field, populated every step from the basal velocity (zero where # abrasion is off, i.e. Kg = 0). self.iceAbrL = self.hLocal.duplicate() # Ice discharge (m^3/yr): the ELA accumulation routed downhill; # drives the Bahr thickness (and hence the sliding velocity). self.iceFAL = self.hLocal.duplicate() self.iceFAG = self.hGlobal.duplicate() self.iceHL.set(0.0) self.iceMeltL.set(0.0) self.iceMeltRiverL.set(0.0) self.iceUbL.set(0.0) self.iceAbrL.set(0.0) self.iceFAL.set(0.0) self.iceFAG.set(0.0) # Glacial-till mass-balance diagnostics (m^3, owned-node running # totals reduced by the till-conservation test). self._tillEroded = 0.0 self._tillDeposited = 0.0 return
[docs] def _iceMassBalance(self, elaH, iceH): """ Bed elevation `zbed` and the ELA surface mass balance `mdot` (m ice/yr): accumulation above the ELA, ablation below. The accumulation side is scaled/capped by `accum_factor`/`accum_max`. The RAW ablation is returned here (used as-is for the till melt-out weight and meltwater); the `melt` amplifier is applied only to the ablation that feeds the ice discharge (see `_iceFlowMFD`). """ zbed = self.hLocal.getArray().copy() # bed elevation (fixed this step) # ELA mass-balance ramp (1 above the ice cap, 0 at the ELA, negative # below). elaH/iceH may be scalars (uniform) or per-vertex arrays # (spatial maps). Where iceH <= elaH (degenerate / no-ice-band cell) the # ramp is zeroed so the division never blows up; this is exact for the # uniform case, where the caller's guard already ensures iceH > elaH. denom = iceH - elaH safe = np.where(denom > 0.0, denom, 1.0) ramp = np.where(denom > 0.0, (zbed - elaH) / safe, 0.0) ramp = np.minimum(ramp, 1.0) mdot = self.rainVal * ramp # surface mass balance (m ice/yr) # Scale / cap the ACCUMULATION (positive mdot) to realistic ice rates: # full precipitation is rarely all snow/ice, so `accum_factor` converts # precipitation to ice accumulation and `accum_max` caps it. Ablation # (negative mdot) is left untouched. Defaults (1.0, None) are a no-op. if self.ice_accum_factor != 1.0 or self.ice_accum_max is not None: acc = np.maximum(mdot, 0.0) * self.ice_accum_factor if self.ice_accum_max is not None: acc = np.minimum(acc, self.ice_accum_max) mdot = np.where(mdot > 0.0, acc, mdot) return zbed, mdot
[docs] def _matrixIceFlow(self, dir_ice=1, terminus=None): """ Build the multiple-flow-direction (MFD) routing matrix for ice on the bed surface — used by the diagnostic glacial driver to accumulate the ELA mass balance into an ice discharge. The ice discharge is a single linear solve of ``(I − Wᵀ)``, so the routing surface must have NO closed depressions and NO flats above the glacier terminus — every above-terminus cell must strictly drain, or the operator is singular and the KSP diverges (zeroing the discharge: no ice). The previous serial ``epsfill`` (gather the whole mesh to rank 0, fill, broadcast) gave that property but does NOT scale. Here the same property is obtained in PARALLEL by reusing the flow pit machinery, seeded at the **terminus** (the physical ice outlet — ice melts out at/below it): * ``_performFilling`` — the partition-invariant parallel Barnes flood — removes depressions so the above-terminus surface drains to the mesh edges (no pits); * ``_pitInformation`` (→ ``_dirFlats``) — the partition-invariant flat router — gives every filled-flat cell a downhill direction to its spill, replacing the epsilon gradient (no flats). Cells at/below the terminus (the ice-free melt-out region) and the domain borders are made absorbing sinks, so the above-terminus ice region drains to a well-posed outlet. ``self.lFill`` / ``pitIDs`` / ``flatDirs`` overwritten here are all recomputed by the flow accumulation that runs immediately after the ice step, so there is no cross-contamination (nothing reads them in between). """ bed = self.hLocal.getArray().copy() if terminus is None: terminus = max(self.hGlobal.min()[1] + 0.1, self.sealevel) # Parallel terminus-anchored fill + flat directions (no serial epsfill). self._performFilling(bed - terminus, terminus, sed=True) self.lFill += terminus self._pitInformation(bed, terminus, sed=True) fillz = self.lFill # MFD receivers on the filled ice surface. rcv, _, wght = mfdreceivers(dir_ice, 1.0, fillz, self.sealevel, self.gid) # Flats (filled-depression cells left with no MFD receiver) drain along # the spill-ward flat direction — the parallel replacement for epsilon. sum_weight = np.sum(wght, axis=1) flat = ( (self.pitIDs > -1) & (self.flatDirs > -1) & (sum_weight == 0.0) ).nonzero()[0] rcv[flat, :] = np.tile(flat, (dir_ice, 1)).T rcv[flat, 0] = self.flatDirs[flat] wght[flat, :] = 0.0 wght[flat, 0] = 1.0 # Absorbing outlet: ice-free melt-out region (bed at/below the terminus, # matching the downstream `fa[zbed < terminus] = 0` mask) + the borders. sinks = bed <= terminus if self.flatModel: sinks[self.idBorders] = True sink_ids = np.where(sinks)[0] rcv[sink_ids, :] = sink_ids[:, None] wght[sink_ids, :] = 0.0 self.iceMat = self.iMat.copy() indptr = np.arange(0, self.lpoints + 1, dtype=petsc4py.PETSc.IntType) nodes = indptr[:-1] for k in range(dir_ice): tmpMat = self._matrix_build() data = wght[:, k].copy() data[rcv[:, k].astype(petsc4py.PETSc.IntType) == nodes] = 0.0 tmpMat.assemblyBegin() tmpMat.setValuesLocalCSR( indptr, rcv[:, k].astype(petsc4py.PETSc.IntType), data, ) tmpMat.assemblyEnd() self.iceMat.axpy(-1.0, tmpMat) tmpMat.destroy() if self.memclear: del data, indptr, nodes, bed, fillz, rcv, wght gc.collect() self.iceMat.transpose() return
[docs] def _iceFlowMFD(self, elaH, iceH, iceT): r""" Diagnostic glacial driver (no ice-dynamics solve). 1. ELA surface mass balance (``_iceMassBalance``: accumulation above the ELA, ablation below — including the ``accum_factor``/``accum_max`` controls). 2. Route the **net mass balance** (accumulation minus `melt`-scaled ablation) downhill through the MFD matrix into an **ice discharge** ``iceFA`` (one linear solve; no time integration). The tongue ends where downstream ablation has consumed the upstream accumulation. 3. **Thickness** ``iceHL`` from a Bahr width–area scaling of the discharge. 4. **Basal sliding velocity** ``iceUbL`` from Glen's sliding law on that thickness + bed-surface slope (``ice_velocity``), the abrasion driver ``E_g = K_g |u_b|^l``. Physically bounded (``∝ H^{n-1}|∇s|^{n-1}∇s``), unlike a raw balance velocity ``Q/(H·W)`` which blows up with catchment size and spikes at flow-convergence cells. No stiffness, no CFL — one routing solve per step. """ zbed, mdot = self._iceMassBalance(elaH, iceH) terminus = max(iceT, self.sealevel) # (2) Route the NET mass balance into a discharge. The source is a volume # rate (m^3/yr) = net balance x cell area: accumulation above the ELA # ADDS, ablation below SUBTRACTS, so the routed `iceFA` is the cumulative # net ice flux through each cell. Where downstream ablation has eaten all # the upstream accumulation the flux goes <= 0 (clamped to 0 below) — that # is the glacier snout, emerging from the mass balance rather than a hard # cut-off. `melt` (in _iceMassBalance) scales the ablation, so it directly # controls how far/thick the tongue reaches. self._matrixIceFlow(self.iceDir, terminus) # Net-balance source for the discharge: accumulation minus the # `melt`-scaled ablation. `melt` (ice_meltfac) controls ONLY how much # the below-ELA ablation eats the routed discharge -> glacier extent: # melt = 0 -> accumulation-only (ablation ignored; the legacy default) # melt = 1 -> true net balance # melt > 1 -> amplified ablation -> shorter, thinner tongues # The raw ablation in `mdot` is untouched, so the till melt-out weight # (iceMeltL) and the conserving meltwater below still use the true rate. iceA = ( np.maximum(mdot, 0.0) - self.ice_meltfac * np.maximum(-mdot, 0.0) ) * self.larea self.tmpL.setArray(iceA) self.dm.localToGlobal(self.tmpL, self.tmp) # seed=True warm-starts the discharge from the net-balance source (the # accumulation zone, where it is positive, dominates the warm start); # the parallel terminus-anchored fill makes (I - W^T) well-posed so the # solve converges, and the bounded fallback is the safety net. self._solve_KSP(True, self.iceMat, self.tmp, self.iceFAG, seed=True) self.dm.globalToLocal(self.iceFAG, self.iceFAL) fa = self.iceFAL.getArray() fa[fa < 0.0] = 0.0 fa[zbed < terminus] = 0.0 self.iceFAL.setArray(fa) self.dm.localToGlobal(self.iceFAL, self.iceFAG) # Ablation pattern for the till melt-out / deposition weight (precip- # scaled, gated to ice presence — what the till machinery expects). self.iceMeltL.setArray(np.maximum(-mdot, 0.0) * self.larea * (fa > 1.0e-8)) # Glacial meltwater delivered to the rivers (discharge-conserving by # default: the routed accumulation released where the ice melts out, so # Σ river-melt == Σ accumulation; closes the glacial water budget). self.iceMeltRiverL.setArray(self._glacialMeltwater(zbed, mdot)) # Smooth the discharge (robustness / morphology), then Bahr thickness. self.iceFAG.copy(result=self.tmp1) smth = self._hillSlope(smooth=1) smth[smth < 0.0] = 0.0 self.iceFAL.setArray(smth) self.dm.localToGlobal(self.iceFAL, self.iceFAG) # (3) Ice thickness: Bahr width–area scaling of the discharge. H = self.icewe * self.icewf * np.power(smth, 0.3) H[zbed < terminus] = 0.0 H[H < 1.0e-1] = 0.0 self.iceHL.setArray(H) # (4) Basal sliding velocity from the diagnostic thickness + bed-surface # slope, via Glen's sliding law (``ice_velocity``: u_s ∝ # H^(n-1)|∇s|^(n-1)∇s). This is PHYSICALLY BOUNDED by thickness and slope # — unlike a raw balance velocity Q/(H·W), which blows up with catchment # size (Q grows with the upstream area while the cell width does not) and # spikes at MFD flow-convergence cells, driving runaway abrasion and # tens-of-km erosion/deposition spikes in downstream sinks. ub = ice_velocity(self.lpoints, H, zbed, self.ice_slide, self.ice_glen) ub[H <= 1.0] = 0.0 self.iceUbL.setArray(ub) abr = self.ice_Kg * np.power(np.maximum(ub, 0.0), self.ice_abr_l) abr[self.seaID] = 0.0 self.iceAbrL.setArray(abr) return
[docs] def iceAccumulation(self): """ Main glacial update. Computes the diagnostic glacial state (:meth:`_iceFlowMFD`) from the ice-cap altitude, the equilibrium-line altitude (ELA) and the glacier terminus. Each of these may be a uniform scalar (time-varying) or a per-vertex map — the latter for global models where the ELA varies strongly with latitude. It also snapshots the ice load for the flexural isostasy when enabled. """ ti = process_time() # Snapshot the current ice load for flexure (applyFlexure loads the # increment iceHL - iceFlex), taken BEFORE the thickness is updated. if self.flexOn: self.iceHL.copy(result=self.iceFlex) # Resolve the glacier-geometry altitudes: per-vertex map where supplied # (spatial ELA for global tropical-vs-polar runs), otherwise the uniform # time-varying scalar. `spatial` flags that any field is a map. iceH = self.iceMesh[self.locIDs] if self.iceMesh is not None else self.iceH(self.tNow) elaH = self.elaMesh[self.locIDs] if self.elaMesh is not None else self.elaH(self.tNow) iceT = self.termMesh[self.locIDs] if self.termMesh is not None else self.iceT(self.tNow) spatial = ( self.iceMesh is not None or self.elaMesh is not None or self.termMesh is not None ) # Uniform case keeps the cheap global short-circuit (byte-identical): # no ice when the whole surface is below the ELA, or for a degenerate # config where the ice-cap altitude is not above the ELA. With spatial # maps these are per-node and handled robustly in _iceMassBalance. if not spatial: max_elev = self.hGlobal.max()[1] if max_elev < elaH or iceH <= elaH: self.iceHL.set(0.) self.iceUbL.set(0.) self.iceAbrL.set(0.) self.iceMeltL.set(0.) self.iceMeltRiverL.set(0.) self.iceFAL.set(0.) self.iceFAG.set(0.) if self.flexOn: self.iceFlex.set(0.) if MPIrank == 0 and self.verbose: why = ("max elevation %.0f m below ELA %.0f m" % (max_elev, elaH) if max_elev < elaH else "ice-cap altitude %.0f m not above ELA %.0f m" % (iceH, elaH)) print("Glaciers Accumulation (%0.02f seconds) — no ice (%s)" % (process_time() - ti, why), flush=True) return # Diagnostic glacial driver (routing proxy — glacial erosion morphology # without an ice-dynamics solve). self._iceFlowMFD(elaH, iceH, iceT) # At the first step, seed the flexure reference with the ice just # diagnosed so the initial load is not applied as a transient. if self.flexOn and self.tNow == self.tStart: self.iceHL.copy(result=self.iceFlex) # Instructive diagnostics (only with -v / verbose). The reductions are # COLLECTIVE so they run on every rank (gated only by the uniform # `self.verbose`, never by MPIrank); the formatted line prints on rank 0. if self.verbose: comm = MPI.COMM_WORLD own = self.inIDs == 1 H = self.iceHL.getArray() ub = self.iceUbL.getArray() abr = self.iceAbrL.getArray() vol = comm.allreduce(float(np.sum(H[own] * self.larea[own])), op=MPI.SUM) hmax = comm.allreduce(float(H[own].max()) if own.any() else 0.0, op=MPI.MAX) ncov = comm.allreduce(int((H[own] > 0.1).sum()), op=MPI.SUM) ntot = comm.allreduce(int(own.sum()), op=MPI.SUM) ubmax = comm.allreduce(float(ub[own].max()) if own.any() else 0.0, op=MPI.MAX) abrmax = comm.allreduce(float(abr[own].max()) if own.any() else 0.0, op=MPI.MAX) if MPIrank == 0: cov = 100.0 * ncov / max(ntot, 1) geom = ( "ELA %.0f m, terminus %.0f m, ice-cap %.0f m" % (elaH, max(iceT, self.sealevel), iceH) if not spatial else "spatial ELA/terminus/ice-cap maps" ) print( "Glaciers Accumulation (%0.02f seconds) — %s" % (process_time() - ti, geom), flush=True, ) print( " ice volume %.4g km3 | max thickness %.1f m | cover %.1f%% " "(%d cells) | max sliding %.3g m/yr | max abrasion %.3g m/yr" % (vol / 1.0e9, hmax, cov, ncov, ubmax, abrmax), flush=True, ) return
[docs] def _glacialLateralErosion(self): r""" Lateral glacial erosion rate (m/yr) — valley-wall abrasion by adjacent fast ice, the explicit term that widens glaciated valleys toward a U-profile (vertical abrasion alone only deepens the trough). Returns a per-cell rate (≥0), nonzero only on subaerial 'wall' cells (little ice of their own) standing within the ice column of a faster-sliding neighbour — see the ``ice_lateral_erosion`` kernel. No-op (zeros) unless ``ice.abrasion.Kl > 0``. """ if self.ice_Kl <= 0.0: return np.zeros(self.lpoints, dtype=np.float64) H = self.iceHL.getArray() ub = self.iceUbL.getArray() zbed = self.hLocal.getArray() elat = ice_lateral_erosion( self.lpoints, H, zbed, np.maximum(ub, 0.0), self.ice_Kl, self.ice_lat_l, 1.0, # heps = 1 m ice-presence threshold ) elat[self.seaID] = 0.0 # no marine erosion return elat
[docs] def glacialTill(self): r""" Glacial till — production, transport and deposition. Glacial abrasion (:math:`E_g = K_g |u_b|^l`) erodes rock under sliding ice; the abraded material becomes **till** carried by the ice and deposited where the ice **melts out** — the ablation zone / terminal moraine. The bed is therefore **lowered** under fast ice and **raised** in the ablation zone: a transport, not a source/sink. Two modes, selected by whether stratigraphy is on: - **Bulk bed** (no stratigraphy): the abraded volume ``Vtot`` is redistributed to the ablation cells weighted by the meltwater rate (``iceMeltL``) as a bed-to-bed transport, so ``Σ deposited == Vtot`` and the net bed-volume change is zero by construction. - **Stratigraphic** (:meth:`_glacialTillStrata`): the till is removed from the layers it was abraded from and re-deposited as a fresh moraine layer in the ablation zone — split into the coarse/fine lithology fractions under dual lithology. Conservation is on the *solid* phase (per fraction), so the bed bulks up by the porosity contrast (uncompacted till vs compacted source rock). Both modes are guarded by a dedicated till-conservation test (the dual-lithology lesson: a volume the total budget can't see needs its own guard). Active only with ``till.on`` and ``Kg > 0``; a no-op otherwise. **Deposition distribution.** By default the till is spread across the whole ablation zone weighted by the meltwater rate. With ``till.route: True`` it is instead **routed down the ice-surface flow network** and melts out toward each catchment's terminus (:meth:`_routeTill`) — appropriate for high-resolution regional runs where individual glacier catchments and termini are resolved. Both produce a per-cell deposition weight (summing to one) consumed identically by the bulk and stratigraphic paths, so conservation is unchanged. """ if not (self.iceOn and self.ice_till_on) or ( self.ice_Kg <= 0.0 and self.ice_Kl <= 0.0 ): return ti = process_time() ub = self.iceUbL.getArray() # Vertical abrasion under sliding ice + lateral wall abrasion (U-shaping); # both are bed-lowering routed into the same conserved till -> moraine. Eg = self.ice_Kg * np.power(np.maximum(ub, 0.0), self.ice_abr_l) # m/yr Eg = Eg + self._glacialLateralErosion() Eg[self.seaID] = 0.0 Vero = Eg * self.dt * self.larea # abraded volume (m^3) per cell owned = self.inIDs == 1 Vtot = MPI.COMM_WORLD.allreduce(float(np.sum(Vero[owned])), op=MPI.SUM) # No abrasion -> nothing happens (skipping the erosion too keeps it # conservative: no orphaned till). if Vtot <= 0.0: return # Per-cell deposition weight (local array, Σ over owned nodes = 1): # melt-weighted spreading, or catchment-routed melt-out to the termini. if self.ice_till_route: dep_w = self._routeTill(Vero, Vtot, owned) else: melt = self.iceMeltL.getArray() # ablation volume (m^3/yr) Wtot = MPI.COMM_WORLD.allreduce( float(np.sum(melt[owned])), op=MPI.SUM ) # No ablation zone to receive the melt-spread till -> no-op. if Wtot <= 0.0: return dep_w = melt / Wtot # Routing can leave nothing depositable (e.g. no resolved outlet); guard. Wdep = MPI.COMM_WORLD.allreduce(float(np.sum(dep_w[owned])), op=MPI.SUM) if Wdep <= 0.0: return dz_ero = -Eg * self.dt # bed lowering (m, ≤0) if self.stratNb > 0: # Stratigraphy on: route the till through the stratigraphic pile so # the abraded material leaves the layers it came from and the # moraine is recorded as a fresh deposit (split coarse/fine under # dual lithology). See _glacialTillStrata. self._glacialTillStrata(dz_ero, dep_w, owned) else: # Bulk bed transport: erode at abrasion cells, deposit the till per # the deposition weight. Σ(dz·area) == 0 (rock moved, not created). dz_dep = (Vtot * dep_w) / self.larea dz = dz_ero + dz_dep self.tmpL.setArray(dz) self.dm.localToGlobal(self.tmpL, self.tmp) self.cumED.axpy(1.0, self.tmp) self.hGlobal.axpy(1.0, self.tmp) self.dm.globalToLocal(self.cumED, self.cumEDLocal) self.dm.globalToLocal(self.hGlobal, self.hLocal) self._tillEroded += Vtot self._tillDeposited += MPI.COMM_WORLD.allreduce( float(np.sum((dz_dep * self.larea)[owned])), op=MPI.SUM ) if MPIrank == 0 and self.verbose: print( "Glacial Till (%0.02f seconds)" % (process_time() - ti), flush=True, ) return
[docs] def _glacialTillStrata(self, dz_ero, dep_w, owned): r""" Stratigraphic / dual-lithology coupling for glacial till. The abraded bed lowering ``dz_ero`` (m, ≤0) is removed from the stratigraphic pile with :meth:`erodeStrat`, which returns the uncompacted solid removed per fraction (``thCoarse`` / ``thFine``, m/yr). That solid is the till: its total uncompacted volume is redistributed to the ablation cells weighted by the meltwater rate and laid down as a fresh moraine layer with :meth:`deposeStrat`, carrying the **abraded fine fraction** (a single, ice-mixed composition). Solid mass is conserved per fraction by construction: the fine volume deposited equals the fine volume eroded (so the dual-lithology ``_fineEroded`` / ``_fineDeposited`` budget stays balanced), and the coarse volume likewise. The bed bulks up by the porosity contrast between the uncompacted till and the compacted source rock. ``erodeStrat`` / ``deposeStrat`` overwrite the transient routing arrays (``thCoarse`` / ``thFine`` / ``depoFineFrac``) that the *fluvial* ``sedChange`` consumes later this step, so they are saved and restored around the till calls. """ # Preserve the fluvial routing state (set by the eroder's erodeStrat, # consumed by the later sedChange/_getSedFlux). glacialTill runs # between the two. saved_thCoarse = self.thCoarse.copy() if hasattr(self, "thCoarse") else None saved_thFine = ( self.thFine.copy() if self.stratLith and hasattr(self, "thFine") else None ) saved_depoFineFrac = self.depoFineFrac.copy() # --- Erosion: remove the abraded bulk from the stratigraphic pile. --- self.tmpL.setArray(dz_ero) self.dm.localToGlobal(self.tmpL, self.tmp) self.erodeStrat() # sets thCoarse/thFine # Apply the bed lowering to the elevation / cumulative-erosion fields. self.cumED.axpy(1.0, self.tmp) self.hGlobal.axpy(1.0, self.tmp) # Uncompacted solid abraded (m^3): the till volume to redeposit. coarseV = MPI.COMM_WORLD.allreduce( float(np.sum((self.thCoarse * self.dt * self.larea)[owned])), op=MPI.SUM ) if self.stratLith: fineV = MPI.COMM_WORLD.allreduce( float(np.sum((self.thFine * self.dt * self.larea)[owned])), op=MPI.SUM ) else: fineV = 0.0 Vsolid = coarseV + fineV if Vsolid <= 0.0: # Nothing actually came out of the pile (e.g. emptied to bedrock # floor): no till to deposit. Restore and return. self.thCoarse = saved_thCoarse if saved_thFine is not None: self.thFine = saved_thFine self.depoFineFrac = saved_depoFineFrac self.dm.globalToLocal(self.cumED, self.cumEDLocal) self.dm.globalToLocal(self.hGlobal, self.hLocal) self._tillEroded += 0.0 return # Ice-mixed till composition (single fine fraction for the moraine). ffrac = fineV / Vsolid if self.stratLith else 0.0 # --- Deposition: lay the till down per the deposition weight. --- dz_dep = (Vsolid * dep_w) / self.larea # bulk till thickness (m) self.depoFineFrac = np.zeros(self.lpoints, dtype=np.float64) self.depoFineFrac[dz_dep > 0.0] = ffrac # Snapshot the current layer so the bed/cumED update uses the thickness # deposeStrat ACTUALLY laid down (it floors sub-1e-4 m deposits), keeping # elevation, the stratigraphic pile and the diagnostics consistent. h_before = self.stratH[:, self.stratStep].copy() self.tmpL.setArray(dz_dep) self.dm.localToGlobal(self.tmpL, self.tmp) self.deposeStrat() # adds to stratH/stratHf dep = self.stratH[:, self.stratStep] - h_before # actual deposit (m) self.tmpL.setArray(dep) self.dm.localToGlobal(self.tmpL, self.tmp) self.cumED.axpy(1.0, self.tmp) self.hGlobal.axpy(1.0, self.tmp) self.dm.globalToLocal(self.cumED, self.cumEDLocal) self.dm.globalToLocal(self.hGlobal, self.hLocal) # Diagnostics: solid abraded vs solid actually re-deposited (the two # match to the deposeStrat thickness floor; any sub-floor remainder is # dropped, as for every other deposit in the model). self._tillEroded += Vsolid self._tillDeposited += MPI.COMM_WORLD.allreduce( float(np.sum((dep * self.larea)[owned])), op=MPI.SUM ) # Restore the fluvial routing state for sedChange. if saved_thCoarse is not None: self.thCoarse = saved_thCoarse if saved_thFine is not None: self.thFine = saved_thFine self.depoFineFrac = saved_depoFineFrac return
[docs] def _glacialMeltwater(self, zbed, mdot): r""" Glacial meltwater volume (m^3/yr) delivered to the rivers. Two models, selected by ``ice.melt_conserve``: - **Discharge-conserving** (default): the accumulation (the water that fell as ice above the ELA, :math:`\dot{m}^+\,A`) is routed down the ice-surface flow network and **released as meltwater where the ice melts out** (fraction :math:`f`, forced to 1 at the margin/terminus), so :math:`\sum \mathrm{melt} = \sum \mathrm{accumulation}`. Over goSPL's long timesteps a land-terminating glacier is ~steady, so all the ice that accumulates leaves as meltwater at the snout — this closes the glacial water budget (the precipitation removed from runoff above the ELA returns downstream). Transport-with-loss on the ice network, same MPI-correct flow-matrix / KSP machinery as ``_routeTill``. - **Precip-scaled** (``melt_conserve: False``): the local ablation rate :math:`\max(-\dot{m},0)\,A` where ice is present — cheaper, but it loses water (the ablation generally under-returns the accumulation). """ H = self.iceHL.getArray() if not self.ice_melt_conserve: return np.maximum(-mdot, 0.0) * self.larea * (H > 1.0e-2) # Halo-sync the thickness (ghosts needed for receivers / margin test). self.dm.localToGlobal(self.iceHL, self.tmp) self.dm.globalToLocal(self.tmp, self.tmpL) H = self.tmpL.getArray().copy() s = zbed + H # ice surface rcv, _, wght = mfdreceivers(1, self.flowExp, s, self.sealevel, self.gid) rcv0 = rcv[:, 0].astype(petsc4py.PETSc.IntType) w0 = wght[:, 0].copy() thr = 1.0e-2 ice = H > thr A = np.maximum(mdot, 0.0) * self.larea # accumulation volume (m^3/yr) # Melt-out fraction: ablation share of the ice column this step, =1 off-ice # and at the margin (receiver ice-free) so all remaining ice melts out and # the transport is exactly conservative (Σ melt = Σ accumulation). abl = np.maximum(-mdot, 0.0) * self.dt f = np.clip(abl / np.maximum(H, thr), 0.0, 1.0) f[~ice] = 1.0 f[ice & (H[rcv0] <= thr)] = 1.0 outw = w0 * (1.0 - f) outw[self.ghostIDs] = 0.0 if self.flatModel: outw[self.idBorders] = 0.0 mat = self.iMat.copy() indptr = np.arange(0, self.lpoints + 1, dtype=petsc4py.PETSc.IntType) nodes = indptr[:-1] data = -outw data[rcv0 == nodes] = 0.0 tmpMat = self._matrix_build() tmpMat.assemblyBegin() tmpMat.setValuesLocalCSR(indptr, rcv0, data) tmpMat.assemblyEnd() mat.axpy(1.0, tmpMat) tmpMat.destroy() mat.transpose() self.tmpL.setArray(A) self.dm.localToGlobal(self.tmpL, self.tmp) self._solve_KSP(True, mat, self.tmp, self.tmp1) mat.destroy() self.dm.globalToLocal(self.tmp1, self.tmpL) L = self.tmpL.getArray().copy() # ice flux through each cell melt = f * L # melt-out (m^3/yr) melt[melt < 0.0] = 0.0 # The transport conserves analytically; the KSP solve is only accurate to # its tolerance, so renormalise so the released meltwater equals the # accumulation exactly (closing the water budget bit-for-bit). owned = self.inIDs == 1 Mtot = MPI.COMM_WORLD.allreduce(float(np.sum(melt[owned])), op=MPI.SUM) Atot = MPI.COMM_WORLD.allreduce(float(np.sum(A[owned])), op=MPI.SUM) if Mtot > 0.0: melt *= Atot / Mtot return melt
[docs] def _routeTill(self, Vero, Vtot, owned): r""" Catchment-aware till routing on the ice-surface flow network (``till.route: True``). The abraded till is transported down the **ice surface** :math:`s = z_\mathrm{bed} + H` along steepest descent (the same direction the ice flux follows) and **melts out** progressively toward each catchment's terminus, building moraine where the ice actually ends rather than smearing it across the whole ablation zone. This matters at high (sub-km) resolution where individual glacier catchments and termini are resolved. It is a transport-with-loss on the ice network, solved with the same MPI-correct flow-matrix / KSP machinery as the river accumulation: .. math:: L_i = A_i + \sum_{u \to i} w_{ui}\,(1 - f_u)\,L_u, \qquad D_i = f_i\,L_i where :math:`A_i` is the local abraded volume, :math:`L_i` the till load passing through cell :math:`i`, and :math:`f_i \in [0,1]` the melt-out fraction — the share of the local ice column lost to ablation this step, :math:`f_i = \min(1, \dot{a}_i\,\Delta t / H_i)`. At the ice margin (a cell whose steepest-descent receiver is ice-free) :math:`f` is forced to 1 so no till leaks onto bare ground, which makes the transport exactly conservative: :math:`\sum_i D_i = \sum_i A_i`. :arg Vero: per-cell abraded volume (m^3, local array). :arg Vtot: total abraded volume over owned nodes (m^3). :arg owned: boolean mask of owned (non-ghost) nodes. :return: per-cell deposition weight (local array, Σ over owned = 1). """ # Halo-synced ice thickness and meltwater (ghost values are needed for # the steepest-descent receivers and the melt-out fraction). self.dm.localToGlobal(self.iceHL, self.tmp) self.dm.globalToLocal(self.tmp, self.tmpL) H = self.tmpL.getArray().copy() self.dm.localToGlobal(self.iceMeltL, self.tmp) self.dm.globalToLocal(self.tmp, self.tmpL) melt = self.tmpL.getArray().copy() zbed = self.hLocal.getArray() s = zbed + H # ice surface # Steepest-descent (single-flow-direction) receivers on the ice surface. rcv, _, wght = mfdreceivers(1, self.flowExp, s, self.sealevel, self.gid) rcv0 = rcv[:, 0].astype(petsc4py.PETSc.IntType) w0 = wght[:, 0].copy() thr = 1.0e-2 # ice-presence threshold (m) ice = H > thr # Melt-out fraction f = (ablation thickness this step) / H, clamped. meltThick = (melt * self.dt) / np.maximum(self.larea, 1.0e-12) f = np.clip(meltThick / np.maximum(H, thr), 0.0, 1.0) # Deposit fully (no downstream carry) off-ice and at the ice margin # (receiver ice-free), so nothing leaks onto bare ground -> conservative. f[~ice] = 1.0 f[ice & (H[rcv0] <= thr)] = 1.0 # Downstream-carried weight (1 - f); zero on ghosts/borders. outw = w0 * (1.0 - f) outw[self.ghostIDs] = 0.0 if self.flatModel: outw[self.idBorders] = 0.0 # Accumulation matrix (I - W^T) on the ice network, then L = solve(·, A). mat = self.iMat.copy() indptr = np.arange(0, self.lpoints + 1, dtype=petsc4py.PETSc.IntType) nodes = indptr[:-1] data = -outw data[rcv0 == nodes] = 0.0 tmpMat = self._matrix_build() tmpMat.assemblyBegin() tmpMat.setValuesLocalCSR(indptr, rcv0, data) tmpMat.assemblyEnd() mat.axpy(1.0, tmpMat) tmpMat.destroy() mat.transpose() self.tmpL.setArray(Vero) self.dm.localToGlobal(self.tmpL, self.tmp) self._solve_KSP(True, mat, self.tmp, self.tmp1) mat.destroy() self.dm.globalToLocal(self.tmp1, self.tmpL) L = self.tmpL.getArray().copy() # Deposited volume per cell. The transport conserves analytically, but # the KSP solve is only accurate to its tolerance, so normalise by the # ACTUAL routed total (not Vtot) to make the weight sum to one exactly — # mass conservation then matches the melt-weighted path bit-for-bit. # The analytical load is non-negative; clamp away sub-tolerance KSP # residual noise so the deposition weight can never go slightly negative. dep_vol = np.maximum(f * L, 0.0) total = MPI.COMM_WORLD.allreduce( float(np.sum(dep_vol[owned])), op=MPI.SUM ) if total <= 0.0: return np.zeros(self.lpoints, dtype=np.float64) return dep_vol / total