Rules for anyone (human or AI agent) changing goSPL. Read this file at the start of
every session, and the subsystem file in the Map below before you edit that part
of the tree. Update the relevant file when an invariant changes. Long-form rationale:
REFACTOR_AUDIT.md; past bugs and their guards: docs/dev/FIXED_BUGS.md; per-change
history: docs/dev/CHANGELOG_DEV.md.
Last reviewed 2026-10-03 against dev (after v2026.7.14). In October 2026 this file
was split: it keeps only the rules that apply everywhere, and the subsystem detail
moved verbatim into the files below.
| Before you edit… | Read |
|---|---|
gospl/flow/ (flow KSP, pits, cascade, ice, groundwater) |
gospl/flow/AGENTS.md |
gospl/sed/ (sediment, marine, hillslope, strata, dual lithology, provenance tracers) |
gospl/sed/AGENTS.md |
gospl/eroder/ (SPL, nlSPL, soilSPL) |
gospl/eroder/AGENTS.md |
gospl/tools/ (inputparser, forcing DataFrames, zprobe, outputs) |
gospl/tools/AGENTS.md |
gospl/analyse/ (post-processing tools) |
gospl/analyse/AGENTS.md |
fortran/ (kernels, neighbour tables, memory safety) |
fortran/AGENTS.md |
tests/ |
tests/README.md |
benchmarks/ |
benchmarks/AGENTS.md |
.github/, environment.yml |
.github/AGENTS.md |
conda/ |
conda/AGENTS.md |
docker/ |
docker/AGENTS.md |
| a version bump / release | docs/dev/RELEASE.md and the release skill |
| a new process, output or forcing | docs/HOW_TO_ADD_FORCING.md, docs/HOW_TO_ADD_OUTPUT.md, the add-feature skill |
Recurring procedures are written up as skills in .claude/skills/ (release,
debug-spike, parallel-check, add-feature); they are plain Markdown, usable by any
agent.
| Task | Command |
|---|---|
| Quick check (187 of 196 tests, ~25 s) | pytest tests/ -n 4 -m "not mpi" |
| One subsystem | pytest tests/ -m flow (markers: tests/README.md) |
| Multi-rank guards (~70 s) | pytest tests/ -m mpi |
| Full suite before a commit | pytest tests/ -n 4 (CI runs it serially) |
| Analytical benchmarks (minutes) | pytest benchmarks/ -m benchmark |
| Collective-under-rank-guard lint | python scripts/lint_mpi_collectives.py (CI runs it) |
| np=1 vs np=N field comparison | python scripts/ab_partition.py <input.yml> -n 4 |
| Mass budget of a run | python scripts/budget.py <output-dir> |
| Machine-readable per-step summary | gospl -i input.yml --summary run.jsonl |
| Inspect / compare output | gospl-inspect <output-dir> [--step N] [--compare <other-dir>] [--json] (or python -m gospl.analyse.runinspect before a pip install -e . registers the command) |
| Fortran bounds-checked suite | scripts/fcheck.sh |
| goSPL-examples smoke run | python scripts/examples_smoke.py <examples-dir> --only Local-examples |
| CI runs / logs | gh run list, gh run view <id> --log-failed (gh is authenticated) |
goSPL is a parallel landscape-evolution model that integrates the stream-power law (river incision), linear and non-linear hillslope diffusion, marine sediment transport, glacial accumulation, flexural isostasy, and horizontal/vertical tectonics on an unstructured Voronoi/Delaunay finite-volume mesh. The mesh is either a 2D flat plane (self.flatModel == True) or a global sphere; partitioning, halo exchange, and all linear/non-linear solves run on PETSc DMPlex via petsc4py. Time integration is an explicit outer Euler loop in Model.runProcesses with implicit KSP/SNES/TS inner solves for diffusion, flow accumulation, and sediment routing.
Every state field exists in two parallel representations.
Numpy land (raw arrays indexed by local node ID, dimensional, no halo): self.lcoords (m), self.mCoords (m), self.larea (m²), self.rainVal (m/yr), self.upsub (m/yr), self.stratH/stratZ/phiS/stratK, self.stratHf/phiF (dual-lithology fine pile — see ## Dual lithology), self.fineFrac/depoFineFrac (dual), self.pitParams, self.pitIDs, self.lFill, self.localFlex, plus any vec.getArray().copy() view.
PETSc land (parallel Vec with halo, mutated via setArray/getArray/localToGlobal): self.hLocal/self.hGlobal, self.cumED/self.cumEDLocal, self.FAL/self.FAG, self.fillFAL, self.Eb/self.EbLocal, self.bL/self.bG, self.areaLocal/self.areaGlobal, self.iceHL/self.iceMeltL/self.iceUbL/self.iceAbrL/self.iceFlex (diagnostic glacial model — see ## Ice sheet), self.Lsoil/self.Gsoil, self.lHbed/self.gHbed, self.vSed/self.vSedLocal, self.vSedF/self.vSedFLocal (dual-lithology fine flux), self.fiso.
Both sides hold physical units; the boundary is about who owns halo synchronisation, not units. Cross only via self.dm.localToGlobal(local, global), self.dm.globalToLocal(global, local), vec.getArray(), vec.setArray(arr). After mutating a *Local array view, you MUST localToGlobal before the next collective solve, or ranks see stale halos.
Collective (every rank must call, in the same order): self.dm.localToGlobal, self.dm.globalToLocal, MPI.COMM_WORLD.Allreduce/Bcast/bcast/Allgatherv/Reduce/Barrier, ksp.solve, snes.solve, ts.solve, vec.sum/max/min/norm/dot, mat.norm, vec.assemblyBegin/End, mat.assemblyBegin/End, vec.duplicate/destroy, mat.destroy, dm.distribute, petsc4py.PETSc.garbage_cleanup. Rank-local: vec.getArray(), vec.setArray(), all numpy ops, anything inside if MPIrank == 0:.
THE #1 parallel deadlock class — a collective MUST NOT be gated on a rank-local condition. If a collective (anything in the list above) is reached on some ranks but not others, the ranks that called it block forever in the underlying MPI_Allreduce/scatter waiting for the ranks that skipped — the run hangs at np>1 (and serial ALWAYS passes, because there is no partner to wait for: the classic serial-passes/parallel-hangs signature). The trap is subtle because the gating condition often looks fine:
- Rank-local guards that desync:
if MPIrank == 0:/!= 0,if local_mask.any(),if len(self.xPts) > 0,if seaID..., an earlyreturn/continue/breakon a per-rank value, or afor/whilewhose trip-count differs per rank. Avec.sum()/vec.norm()/mat.norm()looks innocent but is collective — putting one insideif MPIrank == 0deadlocks (this was the 2026-06-24flowplex._solve_KSP2hang). - Global guards that are SAFE: a YAML/config scalar (
stratNb,fDepa,flexOn, …), a global per-pit array (pitParams-derivedpitVol/eV; noteeV=inV−pitVolis safe only becauseinVisAllreduced first), an already-Allreduced value, or a quantity that is itself collective (vec.sum()returns the same global number on every rank, soif vec.sum() > x:is consistent). The same.any()/.size/foris safe iff its operand is one of these.
The fix recipe: compute the collective on every rank, then gate only the rank-local part (the print/IO). For a genuine rank-local decision that must gate a collective, reduce it first: do_it = MPI.COMM_WORLD.allreduce(local_flag, op=MPI.LOR) (or LAND), then branch on do_it. The bcast(..., root=0) pattern (rank 0 sends, the else branch receives) is fine — both sides call bcast.
THE #2 partition-dependence class — matrix/vector assembly MUST set OWNED rows only, never ghost rows. Mat.setValuesLocal(row, …) maps a local row through the lgmap; a GHOST local row maps to a global row owned by another rank, and its stencil computed on the ghosting rank uses an incomplete neighbour set (the ghost's full neighbourhood is not all present locally). Setting it ships that wrong value off-process, where under INSERT_VALUES it collides with the owner's correct value — the result is undefined / partition-dependent (owner vs ghoster race). So loop only over owned rows (self.glIDs), exactly as _evalJacobianMardDiff and every residual (F.setArray(res[self.glIDs])) do. This does NOT hang — it silently corrupts the operator's boundary rows, so it passes serial (one partition → no ghost rows) and produces a wrong-but-finite result at np>1 (the 2026-06-24 soil-diffusion _evalJacobianSoil spike: it looped range(self.lpoints), incl. ghost rows → partition-dependent Jacobian → an isolated elevation spike at sub-domain boundaries by the 2nd output; np=1 clean). Diagnostic tell: an exact solve (swap the PC for lu/MUMPS) still spikes ⇒ the matrix, not the solver, is partition-dependent. Don't chase the linear solver (KSP/PC/tolerance) until you've confirmed assembly is owned-rows-only.
Both classes were audited clean on 2026-06-24 (every setValues* site, and every collective reached under a condition or inside a loop; full records in docs/dev/FIXED_BUGS.md > MPI audits). Safe assembly patterns: (a) diagonal-only matrices; (b) receiver-based stencils built from the partition-invariant drainage arrays (rcvIDi/wghtVali); (c) additive FV-Laplacian assembly with ADD_VALUES. Never INSERT a neighbour-summing row over all lpoints. The live reductions that make gated collectives safe: the coastline rebuild allreduce(rebuild, MPI.LOR) (hillslope.py), the closed-sink deposit allreduce(has_closed, MPI.LOR) (sedplex.py), the per-pit inV/inlet_count Allreduces. Run python scripts/lint_mpi_collectives.py whenever you add or move a collective; end-to-end guards: pytest -m mpi.
PETSc initialisation happens exactly once, in gospl/__init__.py (petsc4py.init(sys.argv), line 25). Python guarantees the package __init__ runs before any submodule, so module-level code in submodules (e.g. MPIrank = petsc4py.PETSc.COMM_WORLD.Get_rank() at import time) can rely on PETSc being live. Do NOT re-introduce petsc4py.init in any submodule — until 2026-06 every submodule called it at import time (15 sites); the call is idempotent so duplicates were harmless but obscured where state was created. Submodules still import petsc4py to access petsc4py.PETSc.X symbols; that's a separate concern from init().
Versions live in four places that move together: meson.build:4, conda/meta.yaml:2, docs/conf.py and docs/_static/version_switch.json. CalVer with no leading zeros (2026.6.13, not 2026.06.13). gospl.__version__ derives from meson.build via importlib.metadata; never hardcode it. Background: docs/dev/RELEASE.md.
MPIcomm is defined locally in 5 active files. 9 dead-code assignments were removed 2026-06. Active sites already follow the rule below.
| File | MPIcomm = |
Used for |
|---|---|---|
flow/flowplex.py |
petsc4py.PETSc.COMM_WORLD |
Mat().create(comm=MPIcomm) |
sed/seaplex.py |
petsc4py.PETSc.COMM_WORLD |
Mat/Vec().createNest(comm=MPIcomm) |
eroder/SPL.py |
petsc4py.PETSc.COMM_WORLD |
Mat/Vec().createNest(comm=MPIcomm) |
mesher/unstructuredmesh.py |
MPI.COMM_WORLD |
bcast, Barrier |
tools/outmesh.py |
MPI.COMM_WORLD |
bcast, gather, Barrier |
Rule: use MPI.COMM_WORLD for raw collectives (Allreduce/bcast/Allgatherv); use petsc4py.PETSc.COMM_WORLD only when creating PETSc objects (KSP().create(comm=...), Mat().create(comm=...)). They wrap the same handle but go through different paths inside PETSc.
PETSc solvers follow two intentional patterns. Use the right one for new code. Full per-solver notes (prefixes, why each PC, failure modes) are in the subsystem files.
Created lazily on first use, stored as self._X, reused for the whole run (avoids ~5–10 ms create/destroy per call).
| File | Method | Cached attribute | Solver |
|---|---|---|---|
flow/flowplex.py |
_solve_KSP |
_ksp_main |
KSP fgmres+bjacobi (flowacc_); fatal solve full budget, cascade capped |
flow/flowplex.py |
_solveIDAExact |
_ksp_exact |
KSP fgmres+bjacobi/exact lu (flowaccx_); routing-solve rescue, sticky |
flow/flowplex.py |
_solve_KSP2 |
_ksp_fallback |
KSP richardson+none bounded fallback (flowaccfb_) |
flow/gwplex.py |
_solveHead |
_ksp_gw |
KSP fgmres+hypre BoomerAMG (gw_); AMG is required |
eroder/nlSPL.py |
_solveNL_ed |
_snes_ed (+_fb) |
SNES qn, ngmres fallback |
eroder/nlSPL.py |
_solveNL |
_snes_nl |
SNES nrichardson + analytic Jacobian |
eroder/soilSPL.py |
_solveSoil |
_snes_soil (+_fb) |
SNES qn, ngmres fallback, adaptive sub-stepping |
eroder/soilSPL.py |
diffuseSoil |
_ts_soil |
TS rosw |
sed/hillslope.py |
_hillSlopeNL |
_snes_hill |
SNES |
sed/hillslope.py |
_diffuseImplicit |
_ts_marine |
TS rosw (marine + lake); stage solve gmres+gasm (NOT preonly: rosw has no Newton correction) |
sed/hillslope.py |
_solveSmooth |
_ksp_smooth (+_smoothMat) |
KSP, operator rebuilt on coastline move |
sed/hillslope.py |
_hillSlope(smooth=0) |
_ksp_hill_lin (+_hillMat) |
KSP, operator rebuilt on coastline move |
sed/hillslope.py |
_diffuseImplicitPicard |
_ksp_picard |
KSP (opt-in marineSolver: picard) |
sed/hillslope.py |
_diffuseProvTracers |
_ksp_provdiff |
KSP (only when provOn) |
CRITICAL — collective rebuild decision. Both coastline-gated caches above (_smoothMat/_hillMat) rebuild the operator (and redo PCSetUp) only when seaID moves. seaID is rank-local, but _buildDiffMat assembly and PCSetUp are collective — so the "did the coastline move?" test MUST be reduced across ranks (rebuild = MPI.COMM_WORLD.allreduce(local_changed, op=MPI.LOR)) before it gates those calls. Without the reduce, one rank rebuilds while another reuses, the two take different collective paths, and the run deadlocks at np>1 (it ran fine until the per-partition coastlines drifted apart, then hung). A rank forced to rebuild with an unchanged mask reproduces its own cached matrix, so the reduce stays bit-faithful. Any future cached operator gated on a rank-local condition needs the same reduce.
Lifecycle:
if self._snes_x is None:
snes = petsc4py.PETSc.SNES().create(comm=petsc4py.PETSc.COMM_WORLD)
# ...configure...
self._snes_x = snes
snes = self._snes_x
# ...solve...
# Do NOT call snes.destroy() — destroy_DMPlex handles it at simulation end.CRITICAL: any new cached solver MUST be added to the destroy_DMPlex loop in mesher/unstructuredmesh.py (def destroy_DMPlex, runs to EOF). The loop iterates over a hardcoded list of attribute names; forgetting to add yours leaks the PETSc object at simulation end. Same applies to cached helper Vecs (self._snes_X_f, self._snes_X_x, self._snes_X_J, etc.).
Solvers that build a Mat().createNest(...) whose sub-matrices change every call AND configure pc.setType("fieldsplit") with IS sets derived from the nested-mat structure. The fieldsplit PC's IS configuration is tied to the specific sysMat instance, so re-using a cached KSP via setOperators(new_sysMat) does NOT automatically re-derive the splits. Caching is theoretically possible but requires careful experimentation with pc.reset() and the nested-mat lifecycle.
| File | Method | Condition |
|---|---|---|
eroder/SPL.py |
_coupledEDSystem |
self.fDepa != 0 (transport-limited branch with non-zero G) |
sed/seaplex.py |
_depMarineSystem |
not flatModel AND self.Gmar > 0, AND only inside the second _distOcean pass |
Lifecycle: create at the top of the method, configure, solve, then explicitly destroy everything (KSP, sub-KSPs, sub-ISes, PC, sysMat, RHS/solution vectors) at the end. See SPL.py:116-259 (_coupledEDSystem) for the canonical pattern. Both sites are COLD-path (conditional, not every step), so the cumulative create+destroy overhead is small.
If a future contributor wants to convert one of these to CACHED, the obstacle is the fieldsplit-PC + nested-mat IS lifecycle, not the KSP object itself. Do it on a focused branch with full regression run.
- All comm arguments use
petsc4py.PETSc.COMM_WORLD(neverMPI.COMM_WORLD). Matches the MPI contract above. - Positional (
KSP().create(PETSc.COMM_WORLD)) vs keyword (SNES().create(comm=PETSc.COMM_WORLD)) is purely cosmetic; both work identically.
The IDA system (I − Wᵀ) q = b drives discharge and every water/sediment cascade. Its non-convergence is the most common symptom of trouble at scale. Know before touching it: the primary is fgmres (stationary Richardson diverges partition-dependently); a failed routing solve is first retried with exact block factors (_solveIDAExact, sticky once it works: long drainage chains are well posed, just beyond ILU/Richardson); the main discharge solve is fatal=True but ponds a small finite un-drained region instead of aborting (cap GOSPL_UNDRAINED_CAP); a failed iterate is never accepted; the outer cascade loop is bounded (stagnation break, relative floor, _cascade_max_steps); a reachability pin was tried and rejected. All of it, with the reasons: gospl/flow/AGENTS.md.
gospl/model.py declares Model (class Model() as multi-inheritance of 17 mixins. Model.__init__ calls each parent's __init__ by name, not via super(). The init order is load-bearing and differs from the MRO declaration order — adding super().__init__() will break the chain. Line numbers below are as of 2026-10-03; search for the call if they have drifted.
| # | Init call (model.py line) | Assumes already populated | Allocates / sets |
|---|---|---|---|
| 1 | _ReadYaml(filename) :247 |
— | self.input, every YAML attr, self.tNow |
| 2 | _STRAMesh() :256 |
ReadYaml (strataFile) |
stratH/stratZ/phiS/stratK = None |
| 3 | _VoroBuild() :259 |
— | Voronoi cache attrs reset |
| 4 | _UnstMesh() :262 |
ReadYaml + STRAMesh | dm, hLocal, hGlobal, locIDs, glbIDs, lpoints, mpoints, lcoords, mCoords, larea, FVmesh_ngbID, lgmap_*, idBorders/idLBounds/ghostIDs, bL/bG, calls readStratLayers |
| 5 | _WriteMesh() :265 |
UnstMesh | step, outputDir, upsG/upsL |
| 6 | _FAMesh() :268 |
UnstMesh | iMat, fillFAL, FAG, FAL, rtol; defines _matrix_build, _matrix_build_diag, _solve_KSP* for every downstream class |
| 7 | _IceMesh() :271 |
FAMesh | iceHL/iceMeltL/iceMeltRiverL/iceUbL/iceAbrL/iceFAL/iceFAG/iceFlex + cached iceMat (only if iceOn) |
| 8 | _GWMesh() :276 |
FAMesh | groundwater head/recharge/duricrust/solute state + cached _ksp_gw/_gwMat (only if gwOn); runs before _soilSPL, so lHbed is initialised in soilSPL.__init__ |
| 9 | _SPL() :279 |
FAMesh | hOld, hOldLocal, hOldFlex, Eb, EbLocal, stepED, newH |
| 10 | _nlSPL() :282 |
SPL | snes_rtol/atol/maxit, lazy _snes_ed/_snes_nl |
| 11 | _soilSPL() :285 |
nlSPL | Gsoil, Lsoil, lHbed, gHbed, prodSoil, soil_transition |
| 12 | _PITFill() :288 |
UnstMesh | borders, outEdges |
| 13 | _SEDMesh() :291 |
FAMesh | tmp, tmpL, tmp1, Qs, QsL, nQs, vSed, vSedLocal, maxnb |
| 14 | _hillSLP() :294 |
SEDMesh | h, hl, dh, mat, Dlimit/dexp/minDiff, lazy _snes_hill, _ts_marine |
| 15 | _SEAMesh() :297 |
FAMesh | zMat |
| 16 | _GridProcess() :300 |
ReadYaml + UnstMesh | localFlex, cached FEM operator/KSP (flat fem) or DH grid (global); cached orography advection operators if orography on |
| 17 | _UnstMesh.applyForces(self) :303 |
everything above | rainVal, upsub, sealevel |
| 18 | _Tectonics() :306 |
UnstMesh (hGlobal) |
fiso, tecNb=-1 |
Permuting any line silently uses an unallocated attribute or overwrites one another mixin has already set.
The following PETSc Vecs are scratch. Any kernel may overwrite them at any time. They are NOT persistent state — they exist only so collective allocations are paid once at init.
| Vec | Allocated at | Local/Global |
|---|---|---|
self.tmp |
sedplex.py:29 | global |
self.tmpL |
sedplex.py:30 | local |
self.tmp1 |
sedplex.py:31 | global |
self.Qs, self.QsL, self.nQs |
sedplex.py:32-34 | g, l, l |
self.h |
hillslope.py:40 | global |
self.hl |
hillslope.py:41 | local |
self.dh |
hillslope.py:42 | global |
self.newH |
SPL.py:40 | global (also held by SNES) |
self.stepED |
SPL.py:38 | global |
self.upsG, self.upsL |
outmesh.py:64-65 | global, local |
Reading self.h for elevation gives you whatever the last hillslope step wrote. Use self.hLocal/hGlobal for elevation, self.cumED/cumEDLocal for cumulative ED, self.FAL/FAG for flow accumulation, self.Eb/EbLocal for erosion rate, self.vSed/vSedLocal for sediment volume.
Rule: any new method that uses a scratch Vec MUST document which ones in its docstring and MUST leave them in a defined state on exit (typically set(0.0) or a freshly-written array, never mid-computation).
flowAccumulation (flowplex.py:787-791) snapshots five arrays after the first _buildFlowDirection on the unfilled topography, before pit filling and before downstream-routing rebuilds the flow matrix:
self.wghtVali = self.wghtVal.copy()
self.rcvIDi = self.rcvID.copy()
self.distRcvi = self.distRcv.copy()
self.fMati = self.fMat.copy()
self.lsinki = self.lsink.copy()
The i suffix means initial (pre-fill). All SPL kernels (eroder/SPL.py, eroder/nlSPL.py, eroder/soilSPL.py) and sedplex._getSedFlux MUST use the i-suffix versions. The live self.rcvID/wghtVal/distRcv/fMat/lsink are valid only inside flowplex._distributeDownstream and sedplex._moveDownstream, where they are rebuilt against the current filled/sediment-filled topography. Outside those two functions their state is undefined.
Both self.Eb (global) and self.EbLocal (local) are in the thickness-rate convention: positive for deposition, negative for incision. Same sign as cumED, same sign as the on-disk EDrate output field, same sign as the restart loader (outmesh.py:439-440). Unified 2026-06.
What each field contains by end-of-step:
self.Eb— river-only thickness rate from the most recent SPL flavour (SPL.py:_getEroDepRate/nlSPL.py:_getEroDepRateNL/soilSPL.py:_getEroDepRateSoil). Not re-synced after marine/hillslope contributions, so it reflects ONLY the river step.self.EbLocal— net thickness rate including all axpy contributions from later kernels (seaplex.py:486,hillslope.py:297,soilSPL.py:549). This is whatoutmesh.py:272writes to disk asEDrate.
Same convention, different content. self.Eb and self.EbLocal are NOT local/global views of the same field — the wrapper at the end of each erodepSPL* overwrites EbLocal with add_rate = tmp/dt = Eb, then downstream kernels mutate EbLocal only.
Thickness conversion (in case of future refactors):
- Inside
_getEroDepRate*:tmp = stepED - hOldis the elevation change → divide bydtdirectly to get the thickness-rateEb(no inversion). - Inside
erodepSPL*wrapper:tmp = Eb * dtis the signed thickness change (negative at incising, positive at depositing cells). cumED.axpy(1.0, tmp)→ cumED in thickness convention.hGlobal.axpy(1.0, tmp)→ at incision, h drops correctly.
One quirk worth knowing: sedplex._getSedFlux (sedplex.py:63) negates self.Eb before the upstream-integration solve, because that solve needs an erosion-positive source for vSed (m³/yr) to accumulate as positive downstream flux. The stratNb > 0 branch uses self.thCoarse which is already erosion-positive (from stratplex.erodeStrat), so no negation there.
Each is gated by a flag and must stay byte-identical when off (guard that with a test when you touch it).
- Dual lithology (
strata: dual,stratLith): coarse/fine sediment; deposition is composition-only, so it never changes elevation. Detail:gospl/sed/AGENTS.md,docs/DESIGN_DUAL_LITHOLOGY.md. - In-model provenance tracers (
provenance:,provOn): a passive label carried through erosion, transport, deposition and stratigraphy; conservation is machine-exact. Detail:gospl/sed/AGENTS.md,docs/DESIGN_PROVENANCE.md. - Diagnostic glacial model (
ice:,iceOn): routed discharge → Bahr thickness → sliding → abrasion/till/meltwater. There is no SIA solver; do not reintroduce a serial epsfill for the ice routing surface. Detail:gospl/flow/AGENTS.md,docs/DESIGN_ICE_SHEET.md. - Groundwater, duricrust, Level-B geochemistry (
groundwater:,gwOn/duriOn/gwGeochemOn): implicit Dupuit head (hypre), duricrust armoring via_surfaceLithoK, conservative multi-species solutes (direct LU). Detail:docs/DESIGN_WATERTABLE_DURICRUST.md,docs/DESIGN_WATERTABLE_GEOCHEM.md,docs/tech_guide/groundwater.rst. - Soil / regolith (
soil:):mode: regolithkeeps deposits in the stratigraphy. Detail:docs/DESIGN_SOIL_REGOLITH.md.
The _extra* parser methods are mandatory continuations, not optional parsers. Use self._get_param(...) / dict.get for defaults (do not reintroduce try/except KeyError boilerplate; the 33 remaining ones are deliberate and marked # TODO-REFACTOR). Forcing DataFrames are read with named df.at[nb, col], never iloc[nb, k]. Detail: gospl/tools/AGENTS.md.
Maximum vertex degree is 12 (fixed-width Fortran neighbour tables). epsfill seeds only on elev < cut-off and its 1-ULP increment does not survive float32. Detail: fortran/AGENTS.md.
All sentinels and threshold values are defined in gospl/tools/constants.py and imported by name. These literal values MUST NOT be reintroduced inline. If you add a new constant, add an entry to both this section AND gospl/tools/constants.py so the two stay in sync.
| Constant | Value | Role |
|---|---|---|
MISSING_DATA_SENTINEL |
-1.0e8 |
Pre-fill before Allreduce(MAX) + boundary marker before fitedges. |
MISSING_LARGE_SENTINEL |
-1.0e10 |
Same as above but for fields whose magnitude can exceed 1e8 (cumED, flexure, soil thickness — used in tectonics._advectPlates). Two orders below MISSING_DATA_SENTINEL so they never collide. |
DISCHARGE_FLOOR |
1.0e-8 |
Minimum FA/discharge/sedLoad value written to HDF5 outputs (avoids -inf in log10 viz). |
DEPOSIT_FLOOR |
1.0e-3 (1 mm) |
Drop sub-mm marine sediment as numerical noise. |
BEDROCK_EXPOSED |
1.0e-1 (10 cm) |
Soil-vs-bedrock threshold in soil-aware SPL. |
BEDROCK_SENTINEL |
1.0e6 |
Infinite-bedrock layer-0 sentinel thickness; offset cancels in cumsum arithmetic. |
BOUNDARY_FLOW_SENTINEL |
-1.0e6 |
Mfd no-data marker passed to mfdreceivers/mfdrcvrs; distinct from MISSING_DATA_SENTINEL — kept two orders apart so a mix-up is obvious in diagnostics. |
GRAPH_OUTLIER_CAP |
1.0e7 |
Upper-bound clamp in pitfilling._performFilling; entries above this are rewritten to MISSING_DATA_SENTINEL. |
MARINE_SMOOTH_N_LAND |
1.0 |
Dimensionless smoothing strength for emergent land in the marine flow-direction smoother (hillslope._hillSlope(smooth=2)). |
MARINE_SMOOTH_N_SEA |
5.0 |
Dimensionless smoothing strength for the seafloor in the same smoother (heavier than land). Per-node Kd = N·cell_area, so the smoothing is timestep- and resolution-independent (replaces the old Cd·dt, Cd∈{1e5,5e6} m²/yr, whose effective strength v≈Cd·dt/Δx² silently varied with both dt and mesh resolution — ~1e-3 on the coarse fixtures, ~55 on a 30 km/dt=1e4 run). Used only to derive marine flow directions in seaplex._matOcean; never alters elevation. |
ICE_COVER_MIN |
1.0e-2 (1 cm) |
Ice thickness above which a land cell is treated as ice-covered for the subaerial soil gate (soilSPL._iceFrozenMask): pedogenic soil production is suppressed and the regolith is held frozen inert (preserved, NOT zeroed — unlike the subaqueous marine/lake case). Matches the ice-presence threshold used inline in flow/iceplex.py (_glacialMeltwater/_routeTill), which is a distinct role (kept inline there with a # TODO-REFACTOR note). See DESIGN_SOIL_REGOLITH.md §3. |
ICE_DISCHARGE_REL_FLOOR |
1.0e-6 |
Fraction of the maximum smoothed ice discharge below which _iceFlowMFD sets the discharge to zero before the Bahr thickness H ∝ Q^0.3. The implicit smoothing leaves an unresolved tail on every cell (below ~100× the solve's rtol of 1e-8); Q^0.3 turned it into a thin ice fringe set by solver noise, so by the partition. Removes only cells with zero routed discharge (0.7% of ice volume on glacial_erosion). |
Same value, different role — DO NOT replace these with the listed constants:
1.0e-8atsed/stratplex.py:219(thickness numerical-noise floor).1.0e-3atflow/flowplex.py:363(water-routing convergence),sed/sedplex.py:139(sediment-routing convergence),flow/pitfilling.py:568,622(minh epsilon nudges),sed/seaplex.py:507,509(clinoH 1mm offset).1.0e-2inflow/iceplex.py(_glacialMeltwater,_routeTill) is the ice-presence threshold (m): a distinct physical role, not a tolerance.
Each of these is marked with a permanent # TODO-REFACTOR: value matches X but distinct role; do not replace comment so future readers know the coincidence is intentional.
mesher/unstructuredmesh.py— ownsdm, every shared mesh attribute, forcing dispatch (applyForces→_updateRain/_updateIce/…), anddestroy_DMPlex(~line 1096, runs to EOF) which names every Vec/Mat by hand. Adding a new persistent Vec elsewhere requires adding it to that destroy list or it leaks (e.g. the dual-lithologyself.vSedF/self.vSedFLocaland the diagnostic-glacialself.iceMeltL/self.iceUbL/self.iceAbrLare registered there).flow/flowplex.py— owns_solve_KSP,_solve_KSP2,_matrix_build,_matrix_build_diag,_buildFlowDirection. Consumed by every downstream module.tools/inputparser.py— owns all parameter parsing; forcing DataFrame column order is API; the_extra*chain is mandatory.
- An elevation spike: follow the
debug-spikeskill.gospl/tools/zprobe.py(-v) names the stage that raised the maximum; check thefDep0.99 cap first (keepspl: G ≲ rain/2). Detail:gospl/tools/AGENTS.md. - A result that changes with rank count, or a hang at np>1: follow the
parallel-checkskill (scripts/lint_mpi_collectives.py,scripts/ab_partition.py). - A wandering
SIGABRTon Linux that macOS does not reproduce: a Fortran out-of-bounds write. Runscripts/fcheck.sh. Detail:fortran/AGENTS.md. - A mass imbalance:
scripts/budget.py <output-dir>, or--summary run.jsonlfor per-step budgets. - The model aborted on a large un-drained region: first run
scripts/fill_mesh_pits.py --checkon the input mesh. A topography problem is not a solver problem;GOSPL_FLOW_KSP=richardsondoes not fix a singular block.
All issues found by the 2026-10 analytical benchmarks and partition checks are fixed (see docs/dev/FIXED_BUGS.md). When you find one, record it here with how to reproduce it, and pin it with a benchmark or test at the current behaviour.
- (none currently open)
- Any
Vec/Matreduction, scatter orAllreduceunderif MPIrank == 0or a rank-local.any()deadlocks at np>1, and serial always passes. - A scratch Vec that holds a result you keep must not be reused for an intermediate reduction on the skip path (IIOE2 zeroed fields this way). The converse holds too: a scratch Vec passed as a KSP solution with
guess=Trueis a STALE starting guess;_solve_KSPnow resets one that is worse than zero, but seed routing solves explicitly (b,seed=True). - A diffusion on absolute elevation that clamps its increment to one sign is not conservative on relief: rescale the increment to the input volume.
- One YAML key must have one meaning at every site that reads it (
oFill). - A fatal solve distinguishes a genuinely broken state (large or non-finite: abort) from a knife-edge local singularity (small and finite: pond and continue).
- A serial rank-0 step inside
Model.__init__presents as a hang at np>1 if it is slow (thedomain: radiusDH-grid query). - A linearly-implicit integrator (Rosenbrock
rosw) needs each stage SOLVED;ksp preonlyturns it into an inexact, partition-dependent scheme its error estimator cannot see. - A ghost node's Fortran
FVarea(and any stencil quantity computed over its truncated local neighbourhood: range, outflow count) is NOT the owner's. Harmless while ghost rows are dropped at assembly; wrong as soon as an owned row reads a ghost's derived value (the IIOE2thetain = 1 - thetaout(ghost)bug). Pass halo-synced inputs. idBorders,outletIDsandadvectBorderslist OWNED nodes only (ghost copies of edge nodes are not in them), whilenorthPts/southPts/eastPts/westPtsare geometric and include ghosts. Used as an exclusion mask for a neighbour average/min, an owned-only set lets a ghost edge neighbour's raw value in, which is partition-dependent (the advection edge-reset bug). Sync the flag to the ghosts first (seetectonics._advEdgeHalo).- Test the parameter regimes the fixtures do not: every stratigraphy fixture had
G: 0, which hid a transport-limited double count for months. When a code path branches on a parameter, a guard needs a fixture on each side. - A regression guard must fail without its fix. Verify that before committing.
flowAccumulationruns twice per step, so it must not overwrite the per-step forcing it reads:bL/bGare read-only there and the net source (evaporation, ice split) goes to_srcL/_srcG. Writing it back evaporated the runoff twice (2026-10-27).UnstMesh._setRunoffSourceis the only writer ofbL(rain and orographic paths alike).
gidargument onmfdreceivers/mfdrcvrs. The Fortran kernels take an extragid(nb)argument (per-local-node vertex IDself.gid, set inmesher/unstructuredmesh.py; since 2026-06-21 it isself.locIDs, the partition-INVARIANT input-mesh id, no longer PETSc's per-partition numbering; seegospl/flow/AGENTS.md). Inside the kernels, the stored slope is perturbed byval * (1 - 1.0e-15 * gid(n))before the quicksort tie-break. This makes EXACT slope ties resolve deterministically across MPI decompositions (lower global ID wins). The perturbation is well below KSP-solver precision and does not affect physical results; near-ties driven by KSP floating-point noise are a separate problem and are NOT fixed by this pattern. See the relaxation comment ontest_parallel_correctnessintests/test_parallel.pyandfortran/functions.F90:mfdreceiversfor the full rationale. Do NOT remove thegidargument — without it,mfdreceiversordering depends on local iteration order and the parallel-correctness test diverges by ~10x from current baseline.- Loose tolerances in
test_parallel_correctness(rel_sum_fa5e-2,rel_mean_h5e-9): the platform-dependent KSP floating-point floor, not a regression. Tightening them requires bitwise-identical halo state across decompositions. Detail:docs/dev/FIXED_BUGS.md> Test tolerance notes. filllabelis uncalled but kept on purpose; do not remove it in a dead-code sweep (fortran/AGENTS.md).- Fortran
meshparamsallocatables must be freed per mesh indefinetin; seefortran/AGENTS.md.
tests-pr.yml:pytest tests/+ the MPI lint on PRs and pushes tomaster/release-candidate, and on manual dispatch (gh workflow run tests-pr.yml --ref dev), the only way to run the FULL suite ondevin CI: the nightlytests-slowruns just theslowsubset (40 of 196 tests) and never the benchmarks. Pushes todevrun no CI; the nightly cron (tests-slow.yml,examples-smoke.yml) runs from the default branch, which isdev. Sodevis tested nightly, not per push: run the suite locally before pushing.tests-slow.yml: slow tier nightly/tags/dispatch, benchmarks onmaster/release-candidatepushes.examples-smoke.yml: every local goSPL-examples input for 2 steps at np=2 (scripts/examples_smoke.py), nightly + dispatch. The Global-examples inputs are notebook products, gitignored in goSPL-examples, so CI cannot run them (a dispatch skips them all, and the script exits 2 when nothing ran): runpython scripts/examples_smoke.py <goSPL-examples> --only Global-exampleslocally.- On a
v*tag,conda-build,pypi-publishanddocker-buildeachneeds:the_release-gate.ymltests job, so nothing publishes unless the suite passes. - Matrix:
ubuntu-latest + macos-15 × Python 3.11 + 3.12. macOS is pinned (notmacos-latest) because the osx-arm64 OpenMPI 4.x / petsc4py 3.21 stack is validated per image. environment.ymlconventions, cache-path pitfalls, MPI pins:.github/AGENTS.md.
The API reference (docs/api_ref/*.rst) is Sphinx autodoc — it imports each module to extract docstrings, so the modules MUST import under the docs build. Two invariants keep the API pages from rendering empty (both regressed once and produced blank pages for most classes):
gosplmust be importable. It is NOT pip-installed on Read the Docs, sodocs/conf.pyputs BOTH the repo root (.., sofrom gospl.tools.constants import …and otherfrom gospl.X importresolve) AND../gospl/(so the legacy top-level.. autoclass:: sed.hillslope.hillSLPpaths resolve) onsys.path. Don't remove either.- Mock only the genuinely-absent compiled/MPI deps via
autodoc_mock_imports(h5py,mpi4py,petsc4py,vtk,pyshtools,gospl._fortran). Do NOT mock packagesdocs/requirements.txtinstalls (numpy,scipy,pandas,numpy-indexed,ruamel.yaml) — the old manualsys.modules[m] = Mock()shadowed the realscipyand brokefrom scipy.special import …(and didn't cover submodules), blanking the pages.autodoc_mock_importsmocks submodules automatically; theREADTHEDOCSenv-guard aroundfrom gospl._fortran import …is the belt-and-braces for the compiled extension. - When you add a public/private method that should appear in the API, add it to BOTH the
.. autosummary::and.. automethod::lists in the relevantdocs/api_ref/*_ref.rst(they are hand-maintained, not auto-generated).
benchmarks/ validates physics against exact solutions (pytest benchmarks/ -m benchmark; scipy/matplotlib, skipped when absent). Mark new ones @pytest.mark.benchmark @pytest.mark.slow, build the mesh at runtime in tmp_path (see benchmarks/test_dupuit.py), and keep each under ~2 minutes. Conventions and per-benchmark notes: benchmarks/AGENTS.md.
| Test file | Process | Analytical basis | Pass criteria |
|---|---|---|---|
test_spl.py |
SPL steady state | Braun & Willett 2013; Perron & Royden 2013 | Cases 150 & 200: 5/5 each (basin test skipped — no case meets >80%); case 100 excluded (finest-mesh basin/R² artifacts + ~34 min cost) |
test_hillslope.py |
Hillslope diffusion | z=(U/2κ)x(L-x); Roering, Kirchner & Dietrich 1999 |
All TOL_* constants met |
test_knickpoint.py |
Knickpoint propagation | c=K·A^m; Royden & Perron 2013; Tucker & Whipple 2002 |
4/4 sub-tests |
test_dupuit.py |
Groundwater head (Dupuit-Boussinesq) | s²=s_d²+(R/K)(2Wx−x²) |
RMSE 0.00 %, R²=1 |
test_flexure.py |
Flat FEM + global flexure | cosine modes w=q/(Dk⁴+Δρg) (exact for the 5-point stencil); infinite-plate line load with image loads; degree-l spherical-harmonic response q/(Δρg+D·P_l) + flat-plate limit |
FEM: discrete round-off, continuum <1%, 2nd-order convergence; line load RMSE <0.5%; global spectral 1e-9, mesh pipeline RMSE <1% |
test_orography.py |
Orographic rain (Smith-Barstad, no mountain wave) | P̂ = Cw·iku·ĥ/((1+ikuτc)(1+ikuτf)) on a 1-D ridge, with the background/floor clip reproduced |
central-row RMSE <1.5% of peak, wind-parallel edge rows <1.5% (inflow-only BC since 2026-10); first-order convergence |
test_advection.py |
Horizontal advection (upwind / iioe1 / iioe2) | translated Gaussian; uniform field (iioe2 zero-excess path) | mass < 1e-4 for every scheme (iioe2 mass-neutral since 2026-10), centroid, peak, RMSE; uniform field preserved |
test_marine_diffusion.py |
Marine diffusion (ts and picard) |
2-D heat kernel on a constant-Cd deposit |
volume 1e-12; D err, peak law, RMSE < 2e-3 for both solvers (the ts stage-solve fix, 2026-10) |
test_geochem_balance.py |
Level-B geochemistry | per-step dissolved = precipitated + exported; 1-D strip c=D0/(R+p)·(Ls/u)^(1+p/R) |
closure ~1e-15; strip vs exact discrete 2e-5 |
- Did you read this file? If invariants here changed, update them.
- Did you run the full regression suite (
pytest tests/ -n 4, or at least the markers you touched plus-m mpiif you touched a collective, a solver or an assembly)? Did a new guard fail without your fix? - If you added a column to a forcing DataFrame, did you use named access (df.at[nb, col]) not iloc?
- If you used a scratch Vec, did you document which ones in the method's docstring?
- If you changed a method called by
Model.runProcesses(model.py,def runProcesses), did you check every caller AND the mixin init order (Model.__init__)? - If you added a new KSP/SNES/TS solver, did you pick the right lifecycle (CACHED for hot-path solvers, AD-HOC for nested-fieldsplit) AND, if cached, add the attribute to the
destroy_DMPlexlist inunstructuredmesh.py? 6b. If you added or moved a collective call (localToGlobal/Allreduce/ksp.solve/vec.norm/vec.sum/garbage_cleanup/…), is it reached by ALL ranks — NOT insideif MPIrank==0, a rank-local.any()/.size/early-return, or a loop with a per-rank trip count? If a rank-local decision must gate it, reduce the flag first (allreduce(..., op=MPI.LOR)). Runpython scripts/lint_mpi_collectives.pyandpytest tests/ -m mpi. - If you added a new forcing type, did you follow
docs/HOW_TO_ADD_FORCING.mdincluding thedestroy_DMPlexregistration? - If you added a new output field, did you follow
docs/HOW_TO_ADD_OUTPUT.mdincluding thedestroy_DMPlexregistration and XDMF entry? - If you added a benchmark test, did you apply
pytest.importorskipfor scipy/matplotlib AND mark@pytest.mark.benchmark @pytest.mark.slow? - If you added a goSPL
Modelinit in a test (regression OR benchmark), ismodel.destroy()in atry/finallyblock per AGENTS.md > KSP/SNES/TS lifecycle contract? - If you bumped the version, did you change all four locations (
meson.build:4,conda/meta.yaml:2,docs/conf.py,docs/_static/version_switch.json) in the same commit, with the no-leading-zero spelling (2026.6.13)? Seedocs/dev/RELEASE.md. - If you changed a Fortran kernel, did you edit
fortran/functions.F90ANDfortran/functions.pyftogether, bound-check every write into a fixed-width(npoints, 12)table, and runscripts/fcheck.sh? An out-of-bounds write passes on macOS and surfaces as a wanderingSIGABRTon glibc. Seefortran/AGENTS.md. - Did you add a row to
docs/dev/CHANGELOG_DEV.mdfor a feature or fix (and an entry indocs/dev/FIXED_BUGS.mdfor a bug fix, naming its guard test)?