Scope: gospl/ (~10.3k LOC across 22 Python files). I read every Python file end-to-end. The report cites files relative to the repo root.
The two are not literally the same object even though they wrap the same underlying communicator — collectives invoked on each go through different code paths, and mixing them in the same module masks where MPI is actually being driven.
- Pattern A —
MPIcomm = petsc4py.PETSc.COMM_WORLD(10 files):gospl/flow/flowplex.py:16gospl/flow/iceplex.py:18gospl/flow/pitfilling.py:30gospl/sed/sedplex.py:16gospl/sed/seaplex.py:23gospl/sed/hillslope.py:18gospl/sed/stratplex.py:16gospl/eroder/SPL.py:16gospl/eroder/nlSPL.py:17gospl/eroder/soilSPL.py:18
- Pattern B —
MPIcomm = MPI.COMM_WORLD(4 files):gospl/tools/addprocess.py:20gospl/tools/outmesh.py:16gospl/mesher/unstructuredmesh.py:28gospl/mesher/tectonics.py:22
gospl/tools/inputparser.py:14definesMPIrankonly — noMPIcommat all (yet collectives are called elsewhere).gospl/model.py:90doesMPIrank = MPI.COMM_WORLD.Get_rank()(no PETSc).
Three of the files (pitfilling.py, seaplex.py, stratplex.py) also define MPIsize while most others don't, even though they use the same MPI patterns.
Repeated calls happen at import time in 13 files (gospl/tools/inputparser.py:13, gospl/tools/outmesh.py:13, gospl/tools/addprocess.py:17, gospl/mesher/unstructuredmesh.py:25, gospl/mesher/tectonics.py:19, gospl/flow/flowplex.py:14, gospl/flow/iceplex.py:16, gospl/flow/pitfilling.py:28, gospl/sed/*.py (all four), gospl/eroder/*.py (all three)). PETSc tolerates repeats but the boilerplate hides where state actually gets created, and sys.argv is inspected by every module — switching to a single init in gospl/__init__.py would be safer.
There are 130 except KeyError blocks in gospl/tools/inputparser.py alone. Three different styles:
- Pattern A —
try/exceptper key with default (most common, e.g._readSPL383-414):
try:
self.K = splDict["K"]
except KeyError:
print("...", flush=True)
raise ValueError("Surface Process Model: K coefficient not found.")- Pattern B —
try/exceptreturning a silent default (e.g._extraDomain196-216):
try:
self.seaDepo = domainDict["seadepo"]
except KeyError:
self.seaDepo = True- Pattern C — outer
try/exceptwrapping the whole section with parallel defaults inside the except (e.g._readHillslope423-470,_readSoilInfo546-644). The defaults are written twice (once inside the innerexcept KeyError, once inside the outer one), so changing a default means editing two places. This is where_readSoilInfocurrently has a real divergence: line 564 defaultsself.Hsto0.0in the populated branch but never sets it in the outerexcept(line 634 setsself.Hs = 0.0too — same here, butH0is0.7in one branch and1.0in the other: lines 578 vs 636).
- Pattern A — module-cached and reused (
gospl/flow/flowplex.py:169-186_ksp_main,:123-149_ksp_fallback;gospl/eroder/nlSPL.py:214-232;gospl/sed/hillslope.py:215-233):
if self._ksp_main is None:
ksp = petsc4py.PETSc.KSP().create(...)
...
self._ksp_main = ksp- Pattern B — created+destroyed per call (
gospl/eroder/SPL.py:192-243_coupledEDSystem;gospl/sed/seaplex.py:278-329_depMarineSystem):
ksp = petsc4py.PETSc.KSP().create(petsc4py.PETSc.COMM_WORLD)
...
ksp.destroy()Both patterns assemble a fresh Mat().createNest(...) per call. The cached pattern is newer; the per-call pattern in the nested-matrix solves is a real performance asymmetry, not just a style difference.
The tecdata / raindata / sedfacdata / tedata time-window advance logic is duplicated. Examples that should be a single helper:
gospl/mesher/tectonics.py:62-76(tecNbadvance + loadiloccolumns):
nb = self.tecNb
if nb < len(self.tecdata) - 1:
if self.tecdata.iloc[nb + 1, 0] < self.tNow + self.dt:
nb += 1
if nb > self.tecNb or nb == -1:
if nb == -1: nb = 0
self.tecNb = nb
...gospl/mesher/unstructuredmesh.py:665-693(rainNbadvance, same shape):
nb = self.rainNb
if nb < len(self.raindata) - 1:
if self.raindata.iloc[nb + 1, 0] <= self.tNow:
nb += 1
if nb > self.rainNb or nb == -1: ...gospl/mesher/unstructuredmesh.py:715-732(sedfactNb, same shape).gospl/tools/addprocess.py:150-176(teNb, same shape).
Four copies × ~25 lines each. They differ subtly: tectonics.py:64 uses < self.tNow + self.dt, the rain version (unstructuredmesh.py:667) uses <= self.tNow, and the Te version (addprocess.py:152) uses <= self.tNow. The drift in comparison operator could shift the timing of forcing events by one dt between subsystems.
In gospl/flow/flowplex.py:421-426, flowAccumulation snapshots six arrays after the first _buildFlowDirection call so they remain available even after downstream routing rebuilds the matrix:
self._buildFlowDirection(hl, False)
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 is the only thing telling consumers (SPL.py:79, SPL.py:88, nlSPL.py:64, soilSPL.py:101, sedplex.py:67) that this is "initial / unfilled-elevation" and not the current matrix. There's no docstring or constant for it.
Several functions construct a per-pit float array, gather it via Allreduce, and inverse-index it back:
gospl/sed/sedplex.py:264-283(_spillCoords, SUM reduce).gospl/sed/sedplex.py:295-347(_addPitMicroTilt, two SUM reduces for sum and count).gospl/sed/sedplex.py:382-561(_diffuseLargePit, multiple SUM/MAX reduces, including the two-step trick at:442-471).gospl/flow/pitfilling.py:299-333(_getPitParams, three Allreduce calls SUM/MAX/MAX).gospl/flow/flowplex.py:330andgospl/sed/sedplex.py:112(both Allreduce per-pit volume).
Same shape, hand-coded variants. Worth a _perPitReduce(values, op) helper.
Three different magic numbers used as the "no data" floor for a MAX reduction:
-1.0e8(most common):gospl/tools/addprocess.py:509,:588,gospl/mesher/tectonics.py:430,:436(mixed with-1.0e10).-1.0e6(boundary marker):gospl/sed/seaplex.py:153,gospl/flow/iceplex.py:76.-1.0e10:gospl/mesher/tectonics.py:436(gED),:442(gFI),:449(gSL).
The three pre-fill values are NOT interchangeable — _distribute* paths sometimes treat -1e8 as "data is missing" — but the script-by-script choice is opaque.
gospl/sed/sedplex.py:184max_iters = 5000, threshold =1.0e-3m³.gospl/sed/seaplex.py:360-363max_iters = 5000, threshold =max(1.0, 1e-6 * initial_total).gospl/flow/flowplex.py:363threshold => 1.0e-3andgospl/flow/flowplex.py:375skip-condition isself.maxarea[0].gospl/flow/pitfilling.py:217-223stp > 1000fall-through.
Each value is a comment-explained heuristic; none are exposed in the YAML.
gospl/model.py:93-110 declares Model as a multi-inheritance of 16 mixin classes. gospl/model.py:126-198 __init__ calls each parent's __init__ by name, in a specific order:
_ReadYaml → _STRAMesh → _VoroBuild → _UnstMesh → _WriteMesh
→ _FAMesh → _IceMesh → _SPL → _nlSPL → _soilSPL → _PITFill
→ _SEDMesh → _hillSLP → _SEAMesh → _GridProcess → _Tectonics
Convention: each class's __init__ only allocates Vec/Mat from self.hGlobal/hLocal and assumes anything earlier in the list has populated self.*. What goes wrong if you don't know: re-ordering mixins in the class declaration looks safe (Python's MRO is consistent), but the __init__ calls are explicit-by-name; permuting one line silently uses an unallocated attribute (e.g. _FAMesh.__init__ reads self.hLocal set by _UnstMesh._buildMesh; if _UnstMesh ran after, you'd AttributeError).
Every mixin's __init__ does self.foo = self.hGlobal.duplicate(). Names like tmp, tmpL, tmp1, dh, h, hl, newH are scratch vectors reused by every module. gospl/sed/sedplex.py:30-35 allocates tmp/tmpL/tmp1/Qs/QsL/nQs. Once that runs, everyone (eroder, sed, flow) reuses them. What goes wrong if you don't know: if you add a new method that uses self.tmpL and call it between two consumers that also use self.tmpL, you silently overwrite their state. There is no convention for "your code may zero this" vs "this is sacred".
gospl/flow/flowplex.py:252(every flow accumulation).gospl/sed/hillslope.py:274(every hillslope step).gospl/sed/seaplex.py:431(self.sinkIDs = self.lFill <= self.sealevel— close kin but different array).
What goes wrong: a function that runs between an erosion step and a deposition step and looks at self.seaID will see stale values relative to the current self.hLocal. The class fields read like global state but are actually written cooperatively.
Every numerical kernel checks if self.flatModel: before forcing border indices to zero or -1e8. unstructuredmesh.py:423 sets flatModel = True only when there are boundary points anywhere across all ranks. What goes wrong if you don't know: a kernel written for the spherical case will quietly produce wrong values at the domain edges on 2D grids because the flatness clamp wasn't applied. There's no decorator or hook — just convention.
gospl/mesher/tectonics.py:73-117 does self.tecdata.iloc[nb, 1], .iloc[nb, 2], .iloc[nb, 3], .iloc[nb, -1]. Column names exist ("start", "end", "tMap", "zMap", "hMap") and are set in inputparser._storeTectonics (gospl/tools/inputparser.py:788), but the consumer uses integer positions. What goes wrong: add a column in _storeTectonics and every consumer silently picks up the wrong column. Same issue in unstructuredmesh._updateRain:677-688 (.iloc[nb, 4] is rMap, .iloc[nb, 5] is rKey — but only if _defineRain appends columns in that order, which it does because line 1239 hard-codes it).
Recurring magic numbers with no shared constant:
-1e8— missing-data sentinel forAllreduce(MAX).1.0e-8— discharge floor for output (outmesh.py:288, :299, :328, :357).1.0e-3(1 mm) — "drop sub-mm deposit" rule (seaplex.py:465,sedplex.py:134).1.0e-1(10 cm) — "bedrock exposed" threshold (soilSPL.py:166).1.0e6— bedrock-thickness sentinel (stratplex.py:99,stratplex.py:194-225).
The bedrock sentinel 1e6 in particular is documented in stratplex.py:96-101 but used as a literal 1.0e6 four times in erodeStrat (stratplex.py:194, 225) with no constant. Renaming it requires grepping for 1.0e6 everywhere.
This item is obsolete. gFlex and its regular-grid flat-model flexure were removed; the flat model now uses the parallel FV biharmonic solver (method='fem'), which maps the boundary sides geographically (N=ymax, E=xmax, S=ymin, W=xmin) with no swap. There is no longer a simflex.BC_S/BC_N assignment in addprocess.py.
gospl/flow/flowplex.py:144-147returnsvector2.set(0.0)after divergence.gospl/sed/seaplex.py:296-313"Marine deposition may be inaccurate; operators should monitor this warning".gospl/eroder/SPL.py:210-228same: "downstream newH may be inaccurate".
What goes wrong if you don't know: a long run silently produces zero-discharge or stale-deposit fields after a divergence; downstream stratigraphy/flexure compounds the error.
Every module wraps its Fortran/heavy-deps imports in this guard, and gospl/__init__.py does it for the entire model class. What goes wrong: code that imports gospl in a docs CI job gets a no-op Model class. A test runner that sets that env var by mistake will silently fail to test anything. There is no warning.
Mat.setValuesLocal(row, …) / setValuesLocalCSR map the local row index through the row local-to-global map. A ghost local row (a halo node owned by another rank) maps to that owner's global row, so setting it ships the value off-process. Whether that is correct depends on the stencil and the insert mode — and getting it wrong does not hang: it silently produces a partition-dependent operator (serial always passes; np>1 gives a wrong-but-finite result). There are three safe patterns in the codebase and one trap:
- Diagonal-only (
flowplex._matrix_build_diag, theseaplexcreateNestsub-blocks): rowihas only entry(i,i)with a per-node value; ghost rows carry the same synced value, so it is consistent under any mode. - Receiver-based (
flowplex.matrixFlow,eroder/SPL.py,eroder/nlSPL._form_jacobian,flow/iceplex,seaplexoff-matrix): rowihas entries atiand its flow receivers, with values built from per-node-iquantities (rcvIDi/wghtVali/distRcvi/slope) that are partition-invariant (the validated drainage arrays — see AGENTS.md "Mechanism #2"). The diagonal is not a sum over the full neighbour ring, so a ghost row computes the same values as the owner; assembling over alllpointsis therefore safe (andmatrixFlowis the most-validated example). - Additive FV-Laplacian (
hillslope._assembleDiffMatvia per-directionaxpy,_assembleDiffMatCSRwithaddv=ADD_VALUES,seaplexoff-matrixADD_VALUES): the diffusion stencil's diagonal is−Σneighbour terms, butADD_VALUESmakes assembly additive — each rank contributes the edges incident on the nodes it can see and PETSc sums the off-process partial rows into the complete row atMatAssemblyEnd. Safe over alllpoints. - The trap — FV-Laplacian over all
lpointswithINSERT_VALUES. A neighbour-summing row needs the node's complete neighbour ring; a ghost node's ring is incomplete on the ghosting rank (some neighbours are 2-ring, absent), so its locally-computed row is wrong, and underINSERTit races with the owner's correct row → partition-dependent boundary rows. This was the 2026-06-24 soil-diffusion spike:_evalJacobianSoilloopedrange(self.lpoints)andINSERTed every row including ghosts, producing an isolated elevation spike at sub-domain boundaries (np=1 clean — a single partition has no ghost rows — np>1 spiked by the 2nd output). The diagnostic tell was that an exact MUMPS solve still spiked, proving the matrix not the solver was partition-dependent. Fix: restrict to owned rows (for i, row in enumerate(self.glIDs)), which the marine counterpart_evalJacobianMardDiffalready did. The owner always has the complete ring, so owned-row-onlyINSERTis correct.
Rule: a new matrix assembly must use one of the three safe patterns. If the row is a neighbour-summing (Laplacian) stencil, either assemble it owned-rows-only (self.glIDs) with INSERT, or additively (ADD_VALUES) over all lpoints — never INSERT a Laplacian row over all lpoints. A full setValues* sweep on 2026-06-24 confirmed the soil Jacobian was the only violation.
Six different conventions in one package:
| Class | File | Convention |
|---|---|---|
FAMesh |
flow/flowplex.py | PascalCase + Mesh suffix |
IceMesh |
flow/iceplex.py | PascalCase + Mesh suffix |
SEDMesh |
sed/sedplex.py | ALL-CAPS + Mesh suffix |
SEAMesh |
sed/seaplex.py | ALL-CAPS + Mesh suffix |
STRAMesh |
sed/stratplex.py | ALL-CAPS + Mesh suffix |
UnstMesh |
mesher/unstructuredmesh.py | Camel + Mesh |
SPL |
eroder/SPL.py | ALL-CAPS |
nlSPL |
eroder/nlSPL.py | mixedCase |
soilSPL |
eroder/soilSPL.py | mixedCase |
hillSLP |
sed/hillslope.py | mixedCase (note: SLP not SLOPE) |
PITFill |
flow/pitfilling.py | ALL-CAPS + Pascal |
VoroBuild |
mesher/meshfunc.py | PascalCase verb |
Tectonics |
mesher/tectonics.py | PascalCase |
GridProcess |
tools/addprocess.py | PascalCase |
WriteMesh |
tools/outmesh.py | Pascal verb-noun |
ReadYaml |
tools/inputparser.py | Pascal verb-noun |
The "do one step of X for the runProcesses loop" verb varies:
| Method | File | Verb form |
|---|---|---|
flowAccumulation |
flow/flowplex.py:394 | noun |
iceAccumulation |
flow/iceplex.py:129 | noun |
fillElevation |
flow/pitfilling.py:549 | verb-noun |
erodepSPL |
eroder/SPL.py:334 | verb (no get) |
erodepSPLnl |
eroder/nlSPL.py:387 | verb + suffix |
erodepSPLsoil |
eroder/soilSPL.py:309 | verb + suffix |
sedChange |
sed/sedplex.py:628 | noun |
seaChange |
sed/seaplex.py:419 | noun |
getHillslope |
sed/hillslope.py:268 | get-noun |
getTectonics |
mesher/tectonics.py:49 | get-noun |
applyTectonics |
mesher/unstructuredmesh.py:634 | apply-noun |
applyForces |
mesher/unstructuredmesh.py:577 | apply-noun |
applyFlexure |
tools/addprocess.py:492 | apply-noun |
cptOrography |
tools/addprocess.py:561 | cpt-noun (abbrev) |
updatePaleoZ |
mesher/tectonics.py:607 | update-noun |
updateSoilThickness |
eroder/soilSPL.py:292 | update-noun |
getCompaction |
sed/stratplex.py:299 | get-noun |
diffuseSoil |
eroder/soilSPL.py:441 | verb-noun |
getTectonics (computes the current advection/uplift) and applyTectonics (adds upsub * dt) sound like aliases but do different things; getHillslope is closer in spirit to applyFlexure than to getCompaction.
The same kind of physical quantity gets different prefixes by module:
| Coefficient | YAML key | Python attribute | Used in |
|---|---|---|---|
| Hillslope diffusion (aerial) | hillslopeKa |
self.Cda |
hillslope.py |
| Hillslope diffusion (marine) | hillslopeKm |
self.Cdm |
hillslope.py |
| Non-linear hillslope strength | hillslopenl |
self.K_nl |
hillslope.py |
| Critical slope | hillslopeSc |
self.K_sc |
hillslope.py |
| Iteration count | hillslopeNb |
self.K_nb |
hillslope.py |
| Non-linear coef (lake/marine) | nonlinKm |
self.nlK |
hillslope.py |
| SPL erodibility | K |
self.K |
SPL/nlSPL/soilSPL |
| Soil SPL | soilK |
self.Ksoil |
soilSPL.py |
| Ice erodibility | Ki |
self.Kice |
SPL/nlSPL |
Cd* vs K_* vs nlK vs Ksoil vs Kice — five conventions for "an erodibility constant".
from gospl._fortran import mfdreceivers—flow/flowplex.py:12,flow/iceplex.py:13,eroder/SPL.py:12.from gospl._fortran import mfdrcvrs—sed/seaplex.py:18only.
The latter is the 12-direction marine variant. Calling the wrong one silently changes the flow-direction count from flowDir (default 8) to a hard-coded 12. No comment in either module explains the split.
| Flag | Type | Meaning |
|---|---|---|
self.flexOn |
bool | flexural isostasy on |
self.iceOn |
bool | glacial dynamics on |
self.oroOn |
bool | orographic rain on |
self.cptSoil |
bool | soil production on |
self.fast |
bool | skip flow accumulation |
self.nodep |
bool | skip deposition |
self.seaDepo |
bool | enable marine deposition |
self.flatModel |
bool | 2D flat (not spherical) |
self.memclear |
bool | aggressive GC |
*On (3), cpt* (1), *Depo (1), plus four naked flags. Trivial to forget which form to test for.
gospl/tools/inputparser.py:565-578 defines, in order: Hs (production decay), h_star (roughness length), H0 (transport decay), Sperc (bedrock conversion). Three different decay-depth-like quantities (Hs, h_star, H0) with three different naming conventions in 13 lines.
gospl/mesher/tectonics.py:78:
if self.tecdata.iloc[nb, -1] != "empty":The -1 index relies on hMap being the last column written by _storeTectonics in inputparser.py:788. The same dataframe is also accessed by name in inputparser.py:869 (tecdata["start"]). Two access patterns coexist in the same dataframe — risky.
Ranked by likelihood that an edit there breaks something elsewhere.
Owns the PETSc DMPlex (self.dm), the local/global mapping (self.locIDs, self.glbIDs, self.lpoints, self.mpoints, self.lcoords, self.mCoords), boundary IDs (self.idBorders, self.northPts, etc.), the FV connectivity (self.FVmesh_ngbID, self.larea, self.maxnb), and the forcing dispatch (applyForces calls cptOrography or _updateRain, plus _updateEroFactor and applyTectonics). Every other module reads self.dm, self.locIDs, self.lpoints, self.idBorders, self.sealevel, self.bL/self.bG. Plus destroy_DMPlex lines 738-799 names every Vec/Mat in the project by hand — adding a new Vec elsewhere requires editing this list or the cleanup is incomplete.
Owns _solve_KSP and _solve_KSP2, used by nlSPL, soilSPL, SPL, seaplex, tectonics, sedplex, hillslope. Also owns _matrix_build and _matrix_build_diag — every PETSc-Mat construction in the project. Also runs _buildFlowDirection, which writes to self.rcvID, self.distRcv, self.wghtVal, self.lsink, self.fMat, self.seaID — consumed by everyone. Changing the KSP type or the matrix-building convention silently changes solver behaviour for ten downstream modules.
Single source of every model parameter. The _readErofactor/_readTeMap/_readRain/_readTectonics set of methods build the forcing dataframes whose column order is consumed by iloc[nb, k] calls in unstructuredmesh.py:677-688, tectonics.py:78-117, addprocess.py:160-174. Adding a column anywhere breaks consumers silently.
Also contains the rUni/sUni mismatch bug at line 915: in _defineErofactor the dict is written with key "rUni" (line 915) but the DataFrame is constructed with column "sUni" (line 929), so the sUni column ends up NaN when sMap is None. This propagates to _updateEroFactor at unstructuredmesh.py:725:
if pd.isnull(self.sedfacdata["sUni"][nb]): # always True when sMap is None
loadData = np.load(self.sedfacdata.iloc[nb, 2]) # tries to load Nonei.e. uniform-erodibility events crash. Worth confirming with a regression test before any refactor.
Three deposition paths inside _updateSinks (full-fill vs bottom-up vs diffuse-large-pit) with shared per-pit reductions. _diffuseLargePit calls _diffuseImplicit (from hillslope.py) and mutates self.Cd/self.minDiff_vec in-place. The bottom-up vs diffuse-large-pit path classifier (gospl/sed/sedplex.py:585-593) controls which method runs per-pit; subtle errors here are basin-specific and not caught by simple smoke tests.
Twelve Fortran kernels, an MPI graph algorithm, and a per-iteration pit-ID merge loop (_transferIDs 1000-iteration fall-through at line 217-223). All other modules consume self.pitIDs, self.pitInfo, self.pitParams, self.lFill, self.flatDirs. The _performFilling algorithm is hard to test in isolation because it relies on inter-rank halo exchange.
getTectonics mutates self.hdisp, self.upsub, self.paleoZ, self.plateStep, self.tec_IDs, etc. The advection chooses among IIOE1/IIOE2/upwind/plate-interp using self.advscheme integer codes — a quiet way to break the integration is to advect elevation but forget to advect cumED (lines 274-300) or Lsoil (329-358) consistently.
stratK/stratH/stratZ/phiS arrays use a per-node "column" scheme with a 1.0e6 sentinel for layer 0 representing infinite bedrock. The arithmetic in erodeStrat:194-225 adds and subtracts 1.0e6 to make the cumsum work. Refactoring this requires understanding why the sentinel cancels out (because cumThick and eroVal share the offset). Stratigraphy is also touched from advection (tectonics._advectStrati), compaction (getCompaction), and three SPL flavours (erodeStrat/deposeStrat).
_outputMesh writes ~12 HDF5 datasets and the XMF schema mirroring them. readData reads them back. Adding a new field requires touching at least 3 places (write, read, XMF schema). Restart compatibility lives entirely here — silent breakage means existing checkpoint files become un-restartable.
Holds the cached PETSc TS (_ts_marine) shared by every "non-linear diffuse on a sub-domain" caller (marine, lake, soil). _diffuseImplicit mutates self.Cd/self.minDiff_vec in-place, called from _diffuseOcean (this file) and _diffuseLargePit (sed/sedplex.py:535). Concurrent calls would race — fine today because runProcesses is serial, but easy to forget if you parallelise.
These are the things I'd genuinely get wrong if you handed me the codebase and a ticket.
gospl/model.py:126-198 manually invokes _ReadYaml.__init__(self, filename), _STRAMesh.__init__(self), etc. by class name. Coming from typical Python I'd reach for super().__init__() and break the chain instantly. The clue that this is intentional (not just legacy) is that each parent's __init__ takes *args, **kwargs but only some use them. I would also assume the inheritance order in the class declaration (line 93-110) matched the call order in __init__ (line 136-184). It does not — _VoroBuild is third in inheritance but called 4th; _Tectonics is 6th in inheritance but called 16th. The MRO matters for method resolution, the init order matters for attribute population, and they are deliberately different.
Every module borrows these. Reading any one of seaplex.py:267-271:
self.tmpL.setArray(sedflux / self.dt)
self.dm.localToGlobal(self.tmpL, self.tmp1)
self.h.pointwiseMult(self.hGlobal, self.areaGlobal)
self.h.scale(1. / self.dt)
self.tmp1.axpy(1., self.h)I'd assume self.h is the persistent state vector (it's named like hLocal/hGlobal). Actually self.h is allocated in hillslope.py:39 as a scratch vector. Misuse risk: read self.h for the elevation, get whatever the last hillslope step wrote.
I would parse rcvIDi[:, k] as rcvIDi (indexed by something) at column k, with i being a placeholder. Actually i means "initial" — see flowplex.py:421-426. The k is the flow-direction index. So the array is 2-D: [node, direction], snapshotted before pit filling. Used 9 times in SPL.py/nlSPL.py/soilSPL.py/sedplex.py. The same i-suffix means "initial" everywhere (wghtVali, distRcvi, lsinki, fMati), which is a real convention — but only learnable by reading flowAccumulation.
tectonics.py:78:
if self.tecdata.iloc[nb, -1] != "empty":Reading this cold I'd assume iloc[nb, -1] is "the last column whatever it is" and break the moment I add a new column anywhere. The convention is that column order in _storeTectonics is part of the API contract — adding columns must happen at the END, never in the middle. Not stated anywhere.
gospl/tools/inputparser.py has _readDomain that calls _extraDomain which calls _extraDomain2. _readHillslope calls _extraHillslope. _readFlex calls _extraFlex. _readOrography calls _extraOrography(oroDict). _readIce calls _extraIce(...).
Reading the name I'd assume _extraDomain parses some optional keys. It does — but it ALSO sets self.advscheme (line 252), which is required everywhere. Renaming or deleting _extraDomain would silently set advscheme to whatever was there before (or AttributeError). The "extra" prefix is misleading; they are continuations of _readDomain/_readHillslope/_readFlex/_readIce/_readOrography.
_outputMesh (outmesh.py:202-398) writes datasets with names like elev, erodep, EDrate, waterFill, fillFA, FA, iceFA, iceH, flexIso, soilH, sedLoad, uplift, rain. The XMF generator _save_DMPlex_XMF (line 494-683) hand-codes each <Attribute> block. The names there are aliased: HDF5 elev becomes XMF Z, HDF5 erodep becomes XMF ED, HDF5 sedLoad becomes XMF SL, HDF5 uplift becomes XMF vTec, HDF5 rain becomes XMF Rain. The renaming is silent and visible only in Paraview. Adding a field means: write HDF5, write XMF block, and choose whether to rename. I'd miss the rename half the time.
5.7 _buildFlowDirection is called from BOTH flow accumulation and downstream-sediment distribution and mutates self.lsink, self.rcvID, etc.
flowplex.py:239-297 writes to six self.* attributes. sedplex.py:147-149 calls _buildFlowDirection(self.sedFilled) (with the post-deposit topography) — overwriting self.rcvID/self.lsink mid-iteration. Then _updateSinks reads self.lsink and the SPL kernels read self.rcvIDi (the cached pre-fill version, separately preserved). So the current state of self.rcvID is undefined depending on what just ran. Reading any SPL code I'd assume self.rcvID was current; the convention is "always use the cached rcvIDi for erosion, the live rcvID only inside _distributeSediment/_moveDownstream".
SPL.py:311-321:
E = -self.tmp.getArray().copy()
E = np.divide(E, self.dt)
self.Eb.setArray(E)And later tmp.setArray(-Eb * self.dt). So self.Eb is positive for incision, negative for deposition, and the conversion to thickness flips the sign again. I would track this wrong on first read; it took me 3 passes through erodepSPL (lines 334-383) to be sure. Compounding: EbLocal.axpy(1.0, self.tmpL) accumulates contributions from SEAMesh and hillSLP and soilSPL within a single timestep, so its sign is "net erosion rate this step including marine/hillslope deposits".
SPL.py:298, nlSPL.py:210, soilSPL.py:202: all cap self.fDep[self.fDep > 0.99] = 0.99 because fDep ≥ 1 makes the coupled (I - W^T)Q + (1-fDep)h block singular. Three places, three identical lines. Refactoring one and missing the others would produce three different convergence behaviours.
flowplex.py:438-444 comment:
meltfacis deliberately NOT applied here because it is a numerical sink-amplifier inside the implicit ice solver, not a physical melt multiplier.
Yet the YAML key is melt (inputparser.py:1599) and the docstring there says "Melting factor adjustment". Reading the YAML doc I would set melt: 10 thinking it controls ablation; it actually controls the solver's sink-pull strength. The right place to extract melt is the new iceMeltL field (iceplex.py:50-209), wired into rainA separately.
hillslope.py:471-487 caches self._ts_marine. It is reused for marine deposition AND lake (large-pit) deposition — the label argument is print-only. soilSPL.diffuseSoil uses a separate cached TS self._ts_soil. So three different non-linear diffusions, two caches. I would assume "one TS per use case" and either over-cache (breaking lake by sharing with marine) or under-cache (creating TSes per call).
addprocess.py:151:
nb = self.teNb
if nb < len(self.tedata) - 1:
if self.tedata.iloc[nb + 1, 0] <= self.tNow:
nb += 1
if nb > self.teNb or nb == -1:
if nb == -1: nb = 0
self.teNb = nbInitialised to -1 in unstructuredmesh.py:483, and the check nb > self.teNb or nb == -1 is the trigger for the load. The first iteration: self.teNb == -1, so nb < len(...) - 1 is true; if the start time is in the past, nb += 1 → nb = 0, and the loader runs. If the start time is in the future, nb stays at -1, and the next branch if nb > self.teNb or nb == -1 fires anyway with nb == -1, setting nb = 0 and reading self.tedata.iloc[0, ...]. So the first load always happens at index 0 regardless of timing — which is fine, but the code reads like "load when the new event becomes current". Coupled with the per-block sentinel-prepending logic in _readTeMap:1165-1178, the actual time-zero behavior depends on whether the input data started after tStart. I'd misread the loop control and not realise the sentinel row exists.
One concrete thing I'd fix first if you wanted to derisk a refactor: extract the tecdata/raindata/sedfacdata/tedata "advance + load" pattern into a single helper that takes a name-keyed dataframe (instead of iloc-positional access). That collapses §1.5, §2.5, §3.7, §5.12 into one location; it surfaces the rUni/sUni bug in §4.3 as a typed key mismatch instead of a silent NaN; and it makes the existing intentional patterns (the teNb/rainNb first-load contract, the i-suffix snapshot convention) explicit rather than implicit.