import os
import gc
import petsc4py
import numpy as np
from mpi4py import MPI
from time import process_time
from gospl.tools.constants import (
BOUNDARY_FLOW_SENTINEL,
MISSING_DATA_SENTINEL,
)
if "READTHEDOCS" not in os.environ:
from gospl._fortran import ice_velocity
from gospl._fortran import ice_lateral_erosion
from gospl._fortran import mfdreceivers
from gospl._fortran import epsfill
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 accumulation :math:`\dot{m}^+` is routed downhill on the
(epsilon-filled) 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.
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, no `meltfac`): accumulation above the ELA, ablation below.
"""
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):
"""
Build the multiple-flow-direction (MFD) routing matrix for ice on the
(epsilon-filled) bed surface — used by the diagnostic ``mfd`` flow model
to accumulate the ELA mass balance into an ice discharge. Mirrors the
water flow-direction matrix; deterministic slope tie-break via ``gid``.
"""
hl = self.hLocal.getArray().copy()
minh = self.hGlobal.min()[1] + 0.1
minh = max(minh, self.sealevel)
if self.flatModel:
hl[self.idBorders] = BOUNDARY_FLOW_SENTINEL
fillz = np.zeros(self.mpoints, dtype=np.float64) + MISSING_DATA_SENTINEL
fillz[self.locIDs] = hl
MPI.COMM_WORLD.Allreduce(MPI.IN_PLACE, fillz, op=MPI.MAX)
if MPIrank == 0:
fillz = epsfill(minh, fillz)
fillEPS = MPI.COMM_WORLD.bcast(fillz, root=0)
fillz = fillEPS[self.locIDs]
rcv, _, wght = mfdreceivers(
dir_ice, 1.0, fillz, BOUNDARY_FLOW_SENTINEL, self.gid,
)
if self.flatModel:
rcv[self.idBorders, :] = np.tile(self.idBorders, (dir_ice, 1)).T
wght[self.idBorders, :] = 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, hl, fillz, fillEPS, 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 accumulation downhill through the MFD matrix into an **ice
discharge** ``iceFA`` (one linear solve; no time integration).
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 positive mass balance (accumulation) into a discharge.
# Source is a VOLUME rate (m^3/yr) = accumulation rate x cell area, so
# the routed `iceFA` is an ice discharge (m^3/yr).
self._matrixIceFlow(self.iceDir)
iceA = np.maximum(mdot, 0.0) * self.larea
self.tmpL.setArray(iceA)
self.dm.localToGlobal(self.tmpL, self.tmp)
self._solve_KSP(True, self.iceMat, self.tmp, self.iceFAG)
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.)
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)
if MPIrank == 0 and self.verbose:
print(
"Glaciers Accumulation (%0.02f seconds)" % (process_time() - ti),
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