Numerical core — siim._core¶
The fastscape-free numerical kernels shared by the 1D profile model and the 2D
landscape model. Most use NumPy/Numba; spectral flexure uses SciPy. They need no
fastscape, xsimlab, or Matplotlib, so the numerics remain testable without the
optional adapter or plotting layer. The 1D and 2D models build one
GlacialParams record and an integer law_code and call the step skeletons
directly.
Per-run parameter record — siim._core.params¶
Frozen per-run parameter record for the law_code skeleton kernels.
One GlacialParams bundles every per-run physical/law scalar the skeletons
consume, so a kernel call passes one record instead of ~16 positional floats
(and no per-law 0.0 padding). Built once per run by the model, unpacked at
the top of each skeleton; the per-law dispatchers still receive plain scalars.
IMPORTANT — this type MUST be defined here (a stable, importable module) and
imported everywhere; never reconstruct an equivalent namedtuple in __main__
or per-call. numba keys a @njit(cache=True) function’s on-disk cache on its
argument types, and a namedtuple’s type identity comes from its defining
module + qualname. A __main__- or locally-defined twin is a different
numba type, so the cache silently misses and recompiles every process (same
failure class as the closure/exec patterns the Step-0 spike ruled out;
verified directly with NUMBA_DEBUG_CACHE).
- class siim._core.params.GlacialParams(Ko, Co, ce, n, nu, m, mu, cg, alpha_g, lambda_p, lambda_c, tau_c, coulomb_clamp, rho_g_g, hc_over_H, D_H)¶
Bases:
tupleOrder is load-bearing: the skeletons tuple-unpack
ppositionally. Every field defaults to 0.0 so the production builders keyword-construct only their law’s real fields (the inactive-law padding stays implicit; audit N19).- Co¶
Alias for field number 1
- D_H¶
Alias for field number 15
- Ko¶
Alias for field number 0
- alpha_g¶
Alias for field number 8
- ce¶
Alias for field number 2
- cg¶
Alias for field number 7
- coulomb_clamp¶
Alias for field number 12
- hc_over_H¶
Alias for field number 14
- lambda_c¶
Alias for field number 10
- lambda_p¶
Alias for field number 9
- m¶
Alias for field number 5
- mu¶
Alias for field number 6
- n¶
Alias for field number 3
- nu¶
Alias for field number 4
- rho_g_g¶
Alias for field number 13
- tau_c¶
Alias for field number 11
Step skeletons (law_code switch) — siim._core.skeleton¶
Mode-A/B step skeletons (law_code switch) for the siim numerical core.
One @njit(cache=True) skeleton per (mode x routing) for 2D, plus the 1D
joint-walk skeleton. Each takes a small-int law_code and reaches the per-law
physics through the shared dispatchers in siim._core.solvers /
siim._core.eroders. The 2D model (siim.fastscape.processes)
and the 1D model (siim.siim1d) call these skeletons directly with a
GlacialParams record + an integer law_code. numpy/numba only – no
model/fastscape imports, so the numerical core stays importable without the
fastscape stack.
- siim._core.skeleton._flot_factor(zs, H, bl, hc_over_H, ramp)[source]¶
Waterline-flotation factor f in [0, 1] multiplying glacial erosion (the effective-pressure ramp described in
docs/guides/concepts.md):f = clip((zs - bl) / (ramp*hc_over_H*H), 0, 1)
with f = 0 EXACTLY for zs <= bl (the anti-runaway backstop: a fully afloat column does no glacial erosion and, via E_c <= 0, carves nothing).
ramp= gamma, the ramp width in ice-column heights (delta = gamma*hc*H). ramp <= 0 — or a degenerate delta (thin ice) — falls back to the hard binary gate: f = 1 for zs >= bl, 0 below, bit-for-bit the pre-ramp behavior. Callers apply it only where the gate is on and the cell is icy.
- siim._core.skeleton._implicit_border_step(zb0, U, E, dt, H, hc_over_H, bl, ramp)[source]¶
Closed-form backward-Euler step of the border-bed budget dzb/dt = U - f(zb)*E, with f the flotation ramp evaluated at the NEW bed:
g(z) = z - zb0 - (U - f(z)*E)*dt f(z) = clip((z + hc*H - bl) / (ramp*hc*H), 0, 1)
E (the arrival-slope border erosion rate), H and the slope are frozen per step, so f is piecewise linear in z and g is strictly increasing (g’ = 1 + f’E*dt >= 1): exactly one of three branches is consistent — fully grounded (f = 1), fully afloat (f = 0), or the linear ramp. The iterate approaches the flotation-draft equilibrium zb = bl - hc*H + delta*U/E (delta = ramp*hc*H) MONOTONICALLY — it cannot overshoot at any dt (the reason the border budget is dt-robust where the explicit form dug km/step). ramp <= 0 is the binary gate, whose implicit solution is the Filippov sliding mode: the bed sticks at the flotation manifold z = bl - hc*H (no chatter possible). Verified closed-form-exact: residual < 2e-11 over 20 000 random draws (probe + build-time check).
- siim._core.skeleton._lake_fill_sfr_2d(z_flat, stack, rec)[source]¶
In-place monotone fill: walk stack outlet-first; any cell below its receiver gets raised to receiver level. The 2D analog of lake_fill_1d — relies on the basin-corrected receivers from fastscape’s flowrouting (lake-interior cells point toward the spillway), so this single pass fills closed depressions to the spillway elevation. Boundary cells (rec == self) are untouched.
- siim._core.skeleton._lake_fill_1d(zb, didx_l, didx_r, nx)[source]¶
In-place monotone fill on the 1D bed: walk each flank from the divide outward and raise any node below its downstream (already-filled) receiver to it — the 1D analog of
_lake_fill_sfr_2d(). Every closed basin spills at its downstream rim.zbis MODIFIED IN PLACE. njit’d because it is on the default mode-B 1D hot path (~33% of the step; audit m55).
- siim._core.skeleton._glac_fast_solve_modeA_sfr(z_flat, ice_flux, water_flux, H_flat, law_code, p, dt, lengths, stack, rec)[source]¶
SFR + mode A (z-tracking), law-agnostic skeleton.
One
@njit(cache=True)kernel for all three sliding laws (see the mode-B skeleton_glac_fast_solve_modeB_sfr()for the dispatch design). Mode A tracks the ice surfacez_flatdirectly, so H is a per-node local solve from the surface slopeS = (z_i - z_r)/L— exactly thefrom_slopebranch of_modeb_closure()(hc_over_H is irrelevant when the surface is tracked, so 1.0 is passed) — and there is no lake-fill, border-bed budget,surface_outorhc_over_H. The law enters only at the H-closure and the erosion step, both dispatched onlaw_code; the thin wrappers pass theirLAW_*code and 0.0 for the inactive-law constants (which reach only the unused branch).z_flatandH_flatare MODIFIED IN PLACE.- Parameters:
z_flat (ndarray) – Ice surface elevation, flattened — MODIFIED IN PLACE.
ice_flux (ndarray) – Accumulated ice / water flux (m^3/yr), flattened.
water_flux (ndarray) – Accumulated ice / water flux (m^3/yr), flattened.
H_flat (ndarray) – Ice thickness (m), flattened — MODIFIED IN PLACE.
law_code (int) –
LAW_EFFEXP/LAW_POWER/LAW_COULOMB(see _core.solvers).p (GlacialParams) – Packed law/physics constants (see
siim._core.params). Notable field:cg=alpha_g * kt * (2*Ac/5) * (rho_g*g)^3[m^-3 yr^-1] (kt absorbed).lengths (ndarray) – Distance from each node to its receiver (m).
stack (ndarray) – Topological sort (upstream → downstream).
rec (ndarray) – Receiver indices.
- siim._core.skeleton._glac_fast_solve_modeA_dinf(z_flat, ice_flux, water_flux, H_flat, law_code, p, dt, stack, nb_receivers, receivers, weights, lengths)[source]¶
D-inf + mode A (z-tracking), law-agnostic skeleton.
The D-inf twin of
_glac_fast_solve_modeA_sfr(): H is solved per node from the weighted-mean per-cell slopeS = Σ_k w_k·max(0, (z_i-z_rk)/L_k)over the cell’s D-inf receivers — that single effective slope feeds the samefrom_slopebranch of_modeb_closure()(hc_over_H irrelevant, 1.0 passed). Erosion uses the D-inf eroders via_erode_modeb_dinf()on(z_flat, zo).z_flatandH_flatare MODIFIED IN PLACE.
- siim._core.skeleton._glac_fast_solve_modeB_sfr(zb_flat, ice_flux, water_flux, H_flat, surface_out, law_code, p, dt, lengths, stack, rec, ny, nx, dx_cell, dy_cell, border_bed_uplift, bl=0.0, gate=True, ramp=0.1, wrap_y=False, wrap_x=False, parallel_erode=False)[source]¶
SFR + mode B (zb-tracking joint walk + lake-fill), law-agnostic skeleton.
One
@njit(cache=True)kernel for all three sliding laws; the per-law physics is reached through thelaw_codedispatch at the two sites that vary — the joint-walk H-closure (_modeb_closure()) and the erosion step (_erode_modeb_sfr()). Everything else (the outflow base-level ice BC, the two-view waterline lake-fill and the interior flotation ramp) is law-agnostic. The thin public wrappers pass their law’sLAW_*code and 0.0 for the inactive-law constants, which reach only the unused dispatch branch.Base-level ice border = OUTFLOW: the domain edge is an arbitrary cut through a continuing glacier. A through-flowing border gets zero-gradient thickness
H_border = H_dominant_donor(the max-ice-flux interior donor), and its bed keeps eroding by the IMPLICIT BORDER BUDGETdzb/dt = U - f*E— E the glacial law on the interior ARRIVAL slope, f the flotation ramp — integrated by the closed-form backward-Euler step_implicit_border_step(), so the bed approaches the flotation-draft equilibriumzb* = bl - hc*H + delta*U/Emonotonically at any dt. The waterline-flotation gate (gate/ramp) is the rho_i = rho_w effective-pressure law, applied to the interior erosion delta via_flot_factor()and inside the border step (ramp = 0is the hard binary gate / its Filippov sliding mode).- Parameters:
zb_flat (ndarray) – Bedrock, flat — uplift already applied; MODIFIED IN PLACE.
ice_flux (ndarray) – Accumulated ice / water flux (m^3/yr), flattened. The interior flotation ramp, H-closure, erosion, the dominant-donor pick and the border icy/ice-free switch all read
ice_flux.water_flux (ndarray) – Accumulated ice / water flux (m^3/yr), flattened. The interior flotation ramp, H-closure, erosion, the dominant-donor pick and the border icy/ice-free switch all read
ice_flux.H_flat (ndarray) – Ice thickness; previous-step value on input, MODIFIED IN PLACE.
surface_out (ndarray) – Filled with zb + hc_over_H*H_new (the new ice surface) on return.
law_code (int) –
LAW_EFFEXP/LAW_POWER/LAW_COULOMB(see _core.solvers).p (GlacialParams) – Packed law/physics constants (see
siim._core.params). Notable fields:D_H(H diffusivity [m^2/yr]; 0 disables, sub-stepped CFL) andhc_over_H(centerline-to-mean depth ratio; zs = zb + hc_over_H*H).border_bed_uplift (ndarray) – Flat (nn,) uplift rate (m/yr); only border (self-receiving) cells are read — the U in the icy border budget and the rate of the ice-free post-glacial recovery toward bl.
bl (float or ndarray) – Base level: the per-step water-line (Dirichlet) datum. Replaces the literal 0 at every waterline site — the ice-free-border erosion-view floor + lake-fill seed, the border recovery threshold, and the flotation reference. A scalar is the single domain-wide datum (default 0.0, bit-for-bit with the historical hard-coded datum); a flat
(nn,)array carries each base-level BORDER node’s own side datum (per-sidebl), and every interior node then inherits the datum of the outlet its basin drains to (step 0a).gate (bool) – Waterline-flotation gate (default constants.FLOTATION_GATE = True): the rho_i = rho_w effective-pressure law. Interior: scales the erosion delta. Border: the physical bound inside the implicit budget. Off is for diagnostics only — it un-bounds the border (f == 1, measured runaway).
ramp (float) – Flotation-ramp width gamma (default constants.FLOTATION_RAMP = 0.1): glacial erosion is scaled by
f = clip((zs - bl)/(gamma*hc*H), 0, 1)instead of the hard on/off switch. 0 = the hard binary gate (interior bit-for-bit; at the border its implicit solution is the flotation sliding mode). Safe ceiling 0.2 (see constants.FLOTATION_RAMP). Only active whengateis on.parallel_erode (bool) – Run the erosion step level-scheduled in parallel (topological levels of the flow graph; _core.routing._levels_sfr + eroders._erode_modeb_sfr_levels). BIT-FOR-BIT with the serial eroder at any thread count (disjoint writes, identical per-node arithmetic; pinned by test_parallel_erode). Default False.
- siim._core.skeleton._dinf_modeB_recv(zb_flat, H_flat, inode, nb_receivers, receivers, weights, lengths, hc_over_H)[source]¶
Effective receiver for the joint walk: a = zb_i − Z̄ and L̄ from the weighted receiver surfaces (receivers already solved — stack order). Returns (a, L_eff, ok).
- siim._core.skeleton._dinf_modeB_filled_view(zb_flat, H_flat, receivers, hc_over_H, ny, nx, wrap_y, wrap_x, bl_node)[source]¶
Erosion working view: z’ = zb + hc_over_H*H with ICE-FREE borders floored at their own water line
bl_node[i], depression-filled by the flat (eps = 0) priority flood seeded at the borders — the 2D generalization of the SFR lake-fill stack walk. An ICY (outflow) border keeps its true surface (a free outflow, not still base water). Returns the filled view.
- siim._core.skeleton._glac_fast_solve_modeB_dinf(zb_flat, ice_flux, water_flux, H_flat, surface_out, law_code, p, dt, stack, nb_receivers, receivers, weights, lengths, ny, nx, dx_cell, dy_cell, border_bed_uplift, wrap_y, wrap_x, bl=0.0, gate=True, ramp=0.1, parallel_erode=False)[source]¶
D-inf + mode B, law-agnostic skeleton.
The D-inf twin of
_glac_fast_solve_modeB_sfr()and the routing twin of_glac_fast_solve_modeA_dinf(): same six-step structure (joint walk, diffuse, filled view, erode, implicit border budget, output surface) over the D-inf graph. The joint walk visits the donor-first stack in REVERSE (receivers-first) and collapses the weighted receiver surfaces to a single effective receiver (_dinf_modeB_recv(),a = zb_i - Z̄andL̄), so the per-law H-closure is the SAME_modeb_closure()used by SFR; erosion is the D-inf dispatch_erode_modeb_dinf(). See the D-inf mode-B block comment above. Thin wrappers pass theirLAW_*code and 0.0 for the inactive-law constants.zb_flat/H_flatare MODIFIED IN PLACE andsurface_outis filled.OUTFLOW base-level ice BC (see the SFR twin): a through-flowing border gets zero-gradient thickness
H_border = H_dominant_donor(max ice flux into it) and its bed erodes by the IMPLICIT BORDER BUDGET on the interior arrival slope (_implicit_border_step(), ramp-bounded at the flotation draft). The flotationgate/rampis the rho_i = rho_w effective-pressure law (ramp= gamma; 0 = the hard binary gate / sliding mode).blis the per-step water-line datum — a scalar (default 0.0 = bit-for-bit) or a flat(nn,)per-side border datum resolved to the interior by the dominant-receiver basin-outlet walk (see the SFR twin).parallel_erode: level-scheduled parallel erosion step, bit-for-bit with the serial eroder (see the SFR twin). Default False.
- siim._core.skeleton._diag_walk(zb, Qg, H_out, law_code, p, dx, didx_l, didx_r, nx)[source]¶
Mode-B 1D joint walk (H + surface), law-agnostic skeleton.
The 1D twin of the 2D mode-B skeletons. Walk both sides of the divide from the base-level outlets inward, solving H and z_s = zb + hc_over_H*H jointly per node. The per-law H-closure is the SAME
_modeb_closurethe 2D kernels use — the from-a branch with cell length L = dx at interior nodes. A through-flowing base-level outlet is an OUTFLOW border with zero-gradient thicknessH_outlet = H_interior_neighbour, resolved by a 2-pass walk (the neighbour is solved after the outlet, so pass 1 uses the lagged outlet H as a bounded provisional and pass 2 the corrected one). Qg = 0 keeps H = 0. Only the closure dispatch varies by law; the outflow outlet BC and the Qg<=0 terminus are law-agnostic and live here.H_outis MODIFIED IN PLACE (its incoming value is the previous-step provisional); the thin wrappers pass theirLAW_*code and 0.0 for the inactive-law constants.The 1D border bed (the implicit arrival-slope budget) lives in
siim.siim1d._erode_border_bed_1d— this walk solves only H.
Scalar Newton solvers & ice-thickness closures — siim._core.solvers¶
Shared scalar Newton solvers and ice-thickness closures for the siim numerical core.
Single canonical home for the per-law scalar kernels that the 1D profile model
(siim.siim1d) and the 2D step skeletons (siim._core.skeleton)
both consume. These were previously maintained as duplicate copies in the 1D
and 2D modules; they are verified identical in executable logic and unified
here so a fix lands once. numpy/numba only – no model, fastscape or matplotlib imports,
so the numerical core stays importable without the fastscape stack.
All public entry points are @numba.njit(cache=True) and topology-agnostic:
scalar (or small fixed-size) inputs, called per-node by the skeleton kernels.
The _*_func / _dz*_func helpers are the per-law residuals and their
derivatives used by the bracketed Newton iterations.
- siim._core.solvers._solve_ice_thickness_power_analytical(D, lambda_p)[source]¶
Closed-form root of x^3 + lambda_p^2 x^2 - lambda_p^2 D^4 = 0 with x = H^2. Equivalent to solving H*(1 + (H/lambda_p)^2)^(1/4) = D, but with no iteration. Picks the unique positive real root via Cardano (single-root regime) or trig form (three-real-roots regime).
- siim._core.solvers._solve_ice_thickness_coulomb(D, a, lambda_c, clamp, tol=1e-12, max_iter=100)[source]¶
Solve H*(H + lambda_c/(1 - a*H^3))^(1/5) = D for H in (0, H_max). a = (rho_g*g*S / tau_c)^3, pole at H = H_max = a^(-1/3) (tau = tau_c).
Uses Newton + line-search bisection with a residual-based exit criterion (
|f|< tol * D). A step-based criterion is unsafe here because near the pole fp diverges, so dH -> 0 even when f is still large, and Newton will exit prematurely with a spurious H. Residual-based guarantees we actually satisfy the equation.clamp: minimum relative gap from the pole (1e-12 default) that the line search maintains. Prevents 1-a*H^3 underflowing to 0 in double precision.
- siim._core.solvers._solver_nonlinear_dinf(zo_i, n_rec, receivers_i, weights_i, lengths_i, z_flat, A, p, dt, epsilon=0.001, max_iter=50)[source]¶
Multi-receiver D-inf counterpart of
_solver_fluvial: one bracketed implicit erosion Newton summed over a cell’s receivers.F(z) = z - zo_i + sum_k w_k * (dt / L_k^p) * A * max(0, z - z_rk)^pF is strictly increasing with its root in
[min_k z_rk, zo_i]; the step bisects whenever Newton leaves the bracket. Plain (unbracketed) Newton 2-cycles forp < 1and silently returnszo_i(zero erosion) – the same failure the scalar_solver_fluvialguards against, one receiver-dimension up. Shared by all three*_erode_2d_dinferoders:(A, p) = (Ko*Qf^m, n)for the fluvial branch of every law, and the eff-exp / power glacial branches pass their own(A, p). Returns the updated node elevationz_i.Allocation-free (audit N31): the D-inf pack carries at most 2 receivers per cell (
receiversis (n, 2)), so the per-receiver prefactors live in scalars instead of per-call heap arrays – same arithmetic, same order, bit-for-bit with the array form it replaced.
- siim._core.solvers._F_and_dF_coulomb(zik, zio, zr, Qg, A_pre, ell, t, cg, rho_g_g, tau_c, lambda_c, dx, clamp)[source]¶
Residual F(zik) and analytical total derivative dF/dz for the regularized Coulomb erosion law.
The rheology factor R*(H+R)^(-3/5) is evaluated via the H-eq substitution:
H + R = D^5/H^5 => R*(H+R)^(-3/5) = (D/H)^2 * (1 - H^6/D^5)
which bypasses the catastrophic cancellation in 1 - (rho_g*g*H*S/tau_c)^3. The H^6/D^5 intermediate also gives R = H*(1-H^6/D^5)/(H^6/D^5) without recomputing 1-y, so the derivative stays precision-clean.
Derivation: differentiate H^5(H+R) = D^5 implicitly, using dD/dS = -3D/(5S). The log-slope gamma = (S/H) dH/dS comes out as:
gamma = -3(q+1) / (6q + 5 - 2y), q = H*lambda_c/R^2, y = a*H^3
with gamma -> -1 at the pole (H -> H_max) and gamma -> -1/2 in the zero-sliding regime. Then dM/dS = (dM/dD)(dD/dS) + (dM/dH)(dH/dS) using the expanded form M = D^2/H^2 - H^4/D^3 for the partials.
- siim._core.solvers._solver_glacial_coulomb(zio, zr, Qg, A_pre, ell, t, cg, rho_g_g, tau_c, lambda_c, dx, clamp, epsilon=1e-08, max_iter=50)[source]¶
Scalar Newton on zik, using an analytical total derivative (see _F_and_dF_coulomb) plus Armijo
|F|-decrease backtracking. One H-solve per accepted Newton step; the Armijo call also returns dF, which is carried into the next iteration.
- siim._core.solvers._diag_solve_power_H_newton(a, K_p, lambda_p2, tol=1e-12, max_iter=80)[source]¶
Solve H^4 * (H^2 + lambda_p^2) * (H + a)^3 = K_p for H > max(0, -a). K_p = Q_g * dx^3 / cg, lambda_p2 = lambda_p^2. f is strictly increasing on the admissible interval, so Newton + bisect-on-bad-step converges.
- siim._core.solvers._diag_solve_coulomb_H_newton(a, K_c, lambda_c, beta, clamp, tol=1e-12, max_iter=80)[source]¶
Solve H^5 * (H+a)^3 * (H + lambda_c/(1-phi^3)) = K_c for H in admissible domain. K_c = Q_g * dx^3 / cg, beta = rho_g*g/(tau_c*dx), phi = beta*H*(H+a), pole at phi=1 (tau = tau_c). Domain: H > max(0, -a), phi < 1-clamp.
- siim._core.solvers._modeb_closure_effexp(from_slope, x, qi, L, hc_over_H, cg, lambda_p)[source]¶
Eff-exp H-closure. from_slope: x = surface slope (L unused); else x = a with cell length L.
- siim._core.solvers._modeb_closure_power(from_slope, x, qi, L, hc_over_H, cg, lambda_p)[source]¶
Power H-closure. from_slope: x = surface slope (L unused); else x = a with cell length L.
- siim._core.solvers._modeb_closure_coulomb(from_slope, x, qi, L, hc_over_H, cg, lambda_c, tau_c, rho_g_g, clamp)[source]¶
Coulomb H-closure. from_slope: x = surface slope (L unused); else x = a with cell length L.
- siim._core.solvers._modeb_closure(law_code, from_slope, x, qi, L, hc_over_H, cg, lambda_p, lambda_c, tau_c, rho_g_g, clamp)[source]¶
Dispatch the mode-B joint-walk H-closure on
law_code.The skeleton calls this at the three closure sub-sites (interior, border-normal, border-degenerate); the inactive law’s constants are passed as 0.0 by the thin wrappers and reach only the unused branch.
2D erosion loops — siim._core.eroders¶
Per-law 2D erosion loops (SFR + D-inf) for the siim numerical core.
Walk the flow graph and apply the implicit erosion step per node, delegating
the per-node closures to the shared solvers in siim._core.solvers.
Consolidated into the numerical core in the pre-v1.0 rewrite. numpy/numba
only – no model/fastscape imports.
- siim._core.eroders._linear_erode_2d(z, zo, Qf, Qg, Kf, Kg, m, mu, stack, rec)[source]¶
Linear (n=nu=1) implicit erosion on a directed graph.
- siim._core.eroders._nonlinear_erode_2d(z, zo, Qf, Qg, Kf, Kg, m, mu, n, nu, stack, rec)[source]¶
Nonlinear implicit erosion on a directed graph.
- siim._core.eroders._power_erode_2d(z, zo, Qf, Qg, H, Kf, Kg_prefactor, m, n, t, lambda_p, stack, rec)[source]¶
Power sliding law erosion on a directed graph.
- siim._core.eroders._linear_erode_2d_dinf(z, zo, Qf, Qg, dt, Ko, Co, m, mu, stack, nb_receivers, receivers, weights, lengths)[source]¶
Linear (n=nu=1) implicit erosion on a multi-flow graph.
For ν=n=1, the implicit equation collapses to:
z (1 + K) = zo + Σ_k w_k · Gi_k · z_rk
where K = Σ_k w_k · Gi_k. Closed-form, no Newton needed.
- siim._core.eroders._nonlinear_erode_2d_dinf(z, zo, Qf, Qg, dt, Ko, Co, m, mu, n, nu, stack, nb_receivers, receivers, weights, lengths, epsilon=0.001, max_iter=50)[source]¶
Multi-receiver implicit Newton on z for the nonlinear case.
F(z) = z - zo + Σ_k w_k · (dt/L_k^p) · A · max(0, z - z_rk)^p dF/dz = 1 + Σ_k w_k · p · (dt/L_k^p) · A · max(0, z - z_rk)^(p-1) where (p, A) = (ν, Co·Q_g^μ) when glacial, else (n, Ko·Q_f^m).
- siim._core.eroders._power_erode_2d_dinf(z, zo, Qf, Qg, H, dt, Ko, ce, m, n, t, lambda_p, cg, alpha_g, stack, nb_receivers, receivers, weights, lengths, epsilon=0.001, max_iter=50)[source]¶
Power sliding law D-inf erosion. Same structure as _nonlinear_erode_2d_dinf but with the per-cell H-dependent G_o prefactor for the glacial branch.
- siim._core.eroders._coulomb_erode_2d(z, zo, Qf, Qg, Kf, A_const_nodal, m, n, ell, t, cg, rho_g_g, tau_c, lambda_c, clamp, lengths, stack, rec)[source]¶
Regularized Coulomb sliding law erosion on a directed graph (with kt absorbed in cg). A_const_nodal[i] = (dt / lengths[i]^t) * ce * (cg^(2/5)/alpha_g)^ell; the per-node Qg^(3*ell/5) and (H, R)-dependent mass factor are applied inside.
- siim._core.eroders._F_coulomb_dinf_residual(zik, zo_i, n_rec, receivers_i, weights_i, lengths_i, z_flat, Qg, base_A, ell, t, exp_Q, cg, rho_g_g, tau_c, lambda_c, clamp)[source]¶
Compute the multi-receiver coulomb residual F(zik) and dF/dz.
Mass factor M and H are computed from the cell’s weighted-mean slope S_eff = Σ_k w_k · max(0, (zik - z_rk)/L_k); the derivative is lagged in S (we drop the dM/dS coupling and rely on Newton line-search for robustness). Returns (F, dF). If no downhill receiver exists, returns (zik-zo, 1).
- siim._core.eroders._solver_glacial_coulomb_dinf(zo_i, n_rec, receivers_i, weights_i, lengths_i, z_flat, Qg, base_A_with_dt, ell, t, exp_Q, cg, rho_g_g, tau_c, lambda_c, clamp, epsilon=1e-08, max_iter=50)[source]¶
Multi-receiver coulomb Newton with Armijo
|F|-decrease backtracking.
- siim._core.eroders._coulomb_erode_2d_dinf(z, zo, Qf, Qg, dt, Ko, ce, m, n, ell, t, cg, rho_g_g, tau_c, lambda_c, clamp, alpha_g, stack, nb_receivers, receivers, weights, lengths)[source]¶
Coulomb sliding law erosion on a multi-receiver graph.
Per-cell H is computed from the weighted-mean slope; erosion is solved by multi-receiver Newton with lagged-S Jacobian and line-search backtracking. Fluvial fallback shares the bracketed
_solver_nonlinear_dinfwith the eff-exp/power D-inf eroders.
- siim._core.eroders._modeb_border_erosion(law_code, qi, slope, Hi, Co, mu, nu, ce, cg, alpha_g, lambda_p)[source]¶
Dispatch the mode-B border-bed glacial erosion rate on
law_code.Inactive-law constants are passed as 0.0 by the thin wrappers and reach only the unused branch.
- siim._core.eroders._erode_modeb_sfr(law_code, z_filled, z_pre, water_flux, ice_flux, H_flat, Ko, Co, ce, n, nu, m, mu, cg, alpha_g, lambda_p, lambda_c, tau_c, coulomb_clamp, rho_g_g, dt, lengths, stack, rec)[source]¶
Dispatch the mode-B SFR erosion step on
law_code.
- siim._core.eroders._erode_modeb_dinf(law_code, z, zo, water_flux, ice_flux, H, Ko, Co, ce, n, nu, m, mu, cg, alpha_g, lambda_p, lambda_c, tau_c, coulomb_clamp, rho_g_g, dt, stack, nb_receivers, receivers, weights, lengths)[source]¶
Dispatch the mode-B D-inf erosion step on
law_code.
- siim._core.eroders._erode_modeb_sfr_levels(law_code, z, zo, water_flux, ice_flux, H_flat, Ko, Co, ce, n, nu, m, mu, cg, alpha_g, lambda_p, lambda_c, tau_c, coulomb_clamp, rho_g_g, dt, lengths, rec, order, offsets, nlev)[source]¶
Level-parallel twin of
_erode_modeb_sfr()(same dispatch, same per-node arithmetic;order/offsets/nlevfromsiim._core.routing._levels_sfr()).
- siim._core.eroders._erode_modeb_dinf_levels(law_code, z, zo, water_flux, ice_flux, H, Ko, Co, ce, n, nu, m, mu, cg, alpha_g, lambda_p, lambda_c, tau_c, coulomb_clamp, rho_g_g, dt, nb_receivers, receivers, weights, lengths, order, offsets, nlev)[source]¶
Level-parallel twin of
_erode_modeb_dinf()(same dispatch, same per-node arithmetic;order/offsets/nlevfromsiim._core.routing._levels_dinf()).
Sub-grid glacier-width carving — siim._core.carve¶
Sub-grid glacier-width carving (mode B only).
Consolidated into the numerical core in the pre-v1.0 rewrite. See
docs/guides/concepts.md for the public description of sub-grid width
carving. numpy/numba only – no model/fastscape imports.
- siim._core.carve._carve_offsets(H_flat, rec, alpha_g, offsets_flat, seed_mask)[source]¶
Seed array for the width-carve power transform: -R^2 with R = alpha_g*H/2 at icy, non-border, seed-allowed cells; PDT_NO_SOURCE elsewhere. Border (self-receiving) cells are left out of the seeds so interior sources inherit their attribution (see _carve_subgrid_width).
seed_mask(per-cell, nonzero = allowed) can de-seed cells that must not anchor a disc; the Mode-C carve passes an all-ones mask (every icy interior cell seeds a disc). Returns the seed count (0 = nothing this step).
- siim._core.carve._power_dt_1d(f, h2, d_out, i_out)[source]¶
1D lower envelope of parabolas:
d_out[p] = min_q((p-q)^2*h2 + f[q]),i_out[p] = argmin q. Algorithm 1 of Felzenszwalb and Huttenlocher [FH12] with a physical spacing-squared factor h2 (anisotropic grids) and +inf-aware seeding.
- siim._core.carve._power_dt_2d(offsets, dy, dx, D, SRC)[source]¶
Minimum power distance D[y,x] = min_src(dist^2 - R_src^2) over the grid (physical units, anisotropic spacing), and SRC = the flat index of the argmin source cell (-1 outside any line of sources). offsets holds -R^2 at source cells and >=1e30 elsewhere.
- siim._core.carve._power_dt_2d_periodic(offsets, dy, dx, D, SRC, wrap_y, wrap_x)[source]¶
Periodic-aware power transform: wrap-pad the seed array along the looped axes, run the ordinary FH transform on the padded grid, crop the central window, and remap the argmin labels to original flat indices (numpy wrapper; the njit kernel is untouched).
EXACT for the carve, by two arguments. (1) Only each seed’s NEAREST periodic image matters: farther images of the same seed have the same R and larger d, hence strictly worse power. (2) Footprint membership and in-footprint attribution only involve seeds with d < R <= R_max (a footprint cell’s winner satisfies it, and any competitor that could beat the winner there satisfies it too), so padding by ceil(R_max/spacing) cells covers every image that can win; the half-circumference cap covers the R_max > L/2 regime, where the nearest image always lies within half a wrap. Cells outside every footprint (D >= 0) may in principle miss a remote image, but the carve skips them — and D < 0 vs >= 0 itself is exact by (2).
- siim._core.carve._carve_subgrid_width(zb_flat, zb_kern, zb_pre, H_flat, surface_out, rec, D, SRC, offsets, widening_factor, hc_over_H)[source]¶
Apply the sub-grid width carve (see block comment above) to every footprint cell whose bed stands above its target — bare valley walls AND thin-ice cells inside a bigger glacier’s footprint (“nodes that were in a glacier and didn’t know it”: under broad ice cover nearly the whole footprint is icy, and skipping icy cells starves the carve).
- Parameters:
zb_flat (ndarray) – Bed, MODIFIED IN PLACE (post-kernel on entry).
zb_kern (ndarray) – Snapshot of zb_flat at carve entry (post-kernel, pre-carve) — source anchors are read here, so the result is independent of cell visit order even when sources are themselves carved.
zb_pre (ndarray) – Bed before this step’s erosion kernel — descent caps are measured from here, so the kernel’s own erosion at a cell and the carve never add (whichever is lower wins).
offsets (ndarray) – The -R^2 seed array the power transform consumed — R^2 is read back from it (R2 = -offsets[s]) so footprint definition and carve targets cannot disagree.
no-op (A self-attributed cell's target is its own bed — a structural)
as ((the skip is a shortcut); border (self-receiving) cells are skipped)
sources; (targets (border budget's business) and must not be seeded as)
carve (sources with no kernel erosion this step (floating / decoupled))
(bare (nothing. Updates surface_out in place for carved cells)
icy (bed + hc_over_H*H — the presented surface drops with the bed).)
Explicit H diffusion — siim._core.diffusion¶
Explicit H diffusion for the siim 2D model (CFL-substepped 5-point FD).
Consolidated into the numerical core in the pre-v1.0 rewrite. numpy/numba only – no model/fastscape imports.
- siim._core.diffusion._diffuse_H_2d(H_flat, ny, nx, dx, dy, D, dt, wrap_y=False, wrap_x=False)[source]¶
Explicit 5-point FD diffusion on H, sub-stepped for CFL: per sub-step ax + ay <= 0.4 with ax = D*dt_sub/dx², ay = D*dt_sub/dy² (the 2D FTCS stability limit is ax + ay <= 0.5). Reduces to the previous square-grid behaviour exactly when dx == dy.
A looped axis (
wrap_y/wrap_x) wraps the stencil across the seam so the seam cells diffuse as the interior cells they physically are (matching the fill / facet-scan / carve seam-awareness; audit m16); a non-looped axis holds its outer ring fixed. Bit-for-bit with the old kernel when both are False. No-op when D <= 0. Clamps H >= 0 after each sub-step.
Hillslope diffusion (ADI) — siim._core.hillslope¶
In-house alternating-direction-implicit hillslope diffuser (topography), the
standalone replacement for fastscapelib-fortran’s fs.diffusion. Distinct from
siim._core.diffusion (the ice-thickness FD diffuser above).
In-house hillslope diffusion (ADI), the standalone replacement for
fastscapelib-fortran’s fs.diffusion (stock LinearDiffusion).
Linear hillslope diffusion \(\partial z/\partial t = \nabla\cdot(k_d\nabla z)\)
advanced one step by the alternating-direction-implicit (ADI) scheme,
matching fastscapelib-fortran Diffusion.f90 (v2.8.4): two dt/2
half-steps (x-implicit then y-implicit), arithmetic-mean face diffusivities, a
Thomas tridiagonal solve per row/column, and the exact Dirichlet (fixed) /
one-sided no-flux (free) edge branches keyed to the same ibc digit map. The
Thomas solver is independently expressed from the standard row recurrence. The
scheme is unconditionally stable and, for spatially-uniform k_d (siim’s
scalar D), reproduces fs.diffusion bit-for-bit (twin-gated,
rtol ~1e-12).
numpy/numba only – no fastscape/xsimlab imports (framework-free core). This is
the HILLSLOPE diffuser (topography); distinct from siim._core.diffusion,
the ice-thickness FD diffuser, which is not a migration target.
Edge/ibc map (single source: fs.processes.boundary +
Diffusion.f90). cbc = f"{ibc:04d}" and digit '1' = fixed_value
(Dirichlet), '0' = free (no-flux):
row j=0 -> cbc[0] (top)
row j=ny-1 -> cbc[2] (bottom)
col i=0 -> cbc[3] (left)
col i=nx-1 -> cbc[1] (right)
The outer boundary COLUMNS (i=0, i=nx-1) are left unchanged by the scheme (the y-pass writes only interior columns) – a structural quirk of the fortran ADI that is reproduced exactly for the bit-for-bit gate.
- siim._core.hillslope._solve_tridiagonal(lower, diagonal, upper, rhs, solution, size)[source]¶
Solve a tridiagonal system with the standard Thomas recurrence.
lower[i],diagonal[i], andupper[i]multiply the unknowns at indicesi-1,i, andi+1. The result is written into the preallocatedsolutionarray. This implementation was independently derived from that row equation; it performs no pivoting.
- siim._core.hillslope._adi_step(z, kd, dt, dx, dy, fix_r0, fix_rN, fix_c0, fix_cN)[source]¶
One ADI diffusion step (two
dt/2half-steps) onz(ny, nx), face diffusivities from the arithmetic mean ofkd(ny, nx).fix_*are the fixed-value (Dirichlet) flags for the four edges (row 0 / row ny-1 / col 0 / col nx-1); a False flag is the one-sided no-flux branch. Serial (no prange) – grids are small and the tridiagonal solves are sequential; order-independent by construction. Returns the diffused (ny, nx) array. The x-boundary columns are frozen (never written by the y-pass), matching the fortran ADI.
- siim._core.hillslope._fixed_flags(ibc)[source]¶
Decode
ibc-> (fix_row0, fix_rowN, fix_col0, fix_colN) booleans.
- siim._core.hillslope.diffuse(elevation, diffusivity, dt, nx, ny, xl, yl, ibc)[source]¶
Diffuse
elevationone step (dt) by the ADI scheme; return the diffused surface, same 2D shape (ny, nx).elevation/diffusivitymay be flat or 2D;diffusivityis a scalarDor an (ny, nx) field.ibcis the fastscapelib boundary code. Mirrors the stockLinearDiffusionstep (fs.diffusiononfs_context['h']), the caller deriveserosion = elevation - diffuse(...).dx = xl/(nx-1),dy = yl/(ny-1).
Spectral flexure — siim._core.flexure¶
In-house scipy.fft thin-plate flexure solve on the native grid, the standalone
replacement for fastscapelib-fortran’s fs.flexure.
In-house spectral flexure, the standalone replacement for
fastscapelib-fortran’s fs.flexure (fastscape Flexure / siim
GlacialFlexure).
Thin elastic plate on an inviscid asthenosphere,
solved spectrally on the native (ny, nx) grid (no quarter-grid resample) in a
type-2 sine basis (DST-II) on the padded box: transfer
\(\hat w = \hat q / (\rho_a g + D|k|^4)\) with \(|k|^2 = k_x^2 + k_y^2\),
\(k_x = \pi j/(N_x\,d_x)\) and \(k_y = \pi i/(N_y\,d_y)\) for
modes \(i, j = 1 \ldots N\), evaluated with scipy.fft dstn/idstn
(type=2, workers=-1). Type 2 rather than type 1 because scipy computes
DST-I through a real FFT of length 2(N+1), near-prime for a box sized as a
fast FFT length, while DST-II runs at N itself: same fidelity, 2-3.5x
faster (measurements in the 2026-09-16 decision record). Hardcoded
E = 1e11 Pa, nu = 0.25, g = 9.81 – the 9.81 MATCHES fastscape’s
flexure (flexure2D.f90), deliberately NOT siim’s constants.GRAVITY = 9.8
(which the shared step’s ice-column term uses); this cross-constant seam is
intentional and documented.
The sine basis clamps w = 0 at the padded-box edge (half a sample outside
it in type 2, one sample in the fortran’s type 1; invisible at the gates’
resolution) – the flexure2D.f90 basis, here on the native grid – and
needs no regime switch
(decision, Eric 2026-09-11, superseding the 2026-07-13 domain-mean zeroing):
domains
L << alpha(siim’s valleys; flexural parameteralpha = (4D/(rho_a g))**(1/4), ~55 km at Te = 20 km vs ~20 km domains): a uniform load is RIGIDITY-SUPPORTED by the plate held flat at the padded-box edge, not Airy-compensated – 0.004 m per metre of uniform unloading on a 20 km all-free box, matching the fortran’s far-field-neutral behaviour (full Airy would be 0.875).domains
>> alpha: a localized load compensates LOCALLY at Airy and its deflection decays to zero well inside the box – the correct large-domain limit. The retired periodic (rfft2) solve zeroed the domain-mean[0, 0]bin, which removed the Airy mean of a localized load and re-emitted it as a uniform uplift of the WHOLE box (+3.5 m per 100 kyr wave step on a 2500 x 250 km domain, an untouched far plateau rising 290 -> 1460 m).
Key differences from the fortran (both by design):
native grid. The fortran resamples the domain onto the central quarter of a power-of-two grid (~4x coarser), a Nunn & Aires (1988) anti-wraparound device; the native-grid solve is finer and closer to the analytic Kelvin solution. So the two agree only to a tolerance, not bit-for-bit (twin-gated on square grids; the anisotropic gate is vs the Kelvin oracle).
anisotropy fixed (OQ-6). The fortran builds the y-wavenumber with the x-spacing (
pihy = pi/hx,flexure2D.f90:88,145), correct only for square cells. Herek_yuses the true y-spacingdy, so ondx != dygrids the in-house diverges from fortran BY DESIGN and instead tracks the closed-form point-load solution.
Same call signature as the fortran seam
flexure(elev_post, elev_eq, nx, ny, xl, yl, rhos, rhoa, Te, ibc) – mutates
elev_post in place (adds the deflection w) – so it drops into the S1
injection seam (siim._core.step.glacial_flexure_step()) unchanged.
numpy/scipy only – no fastscape/xsimlab imports (framework-free core).
- siim._core.flexure._pad_load(load, free_top, free_bottom, free_left, free_right)[source]¶
Embed
load(ny, nx) in a >=2x, fast-FFT-length grid: mirror-reflect the load across each FREE (no-reflection at fixed) edge – a symmetric extension that gives the zero-gradient (free) plate edge the fortranaddwreflection imposes – and zero-pad the fixed edges (clamped far field) plus the anti-wraparound buffer. Returns(padded, off_y, off_x)where the native block sits atpadded[off_y:off_y+ny, off_x:off_x+nx].WARNING – since the sine basis the pad RATIO is physics, not just a numerical buffer: the padded box’s outer edge is where the sine basis clamps
w = 0, so it sets how far from the load the plate is held flat, which IS the small-domain rigidity support. Measured on the 31x31 / 20 km all-free config (uniform 1 m unload),mean|w|/ Airy runs 0.005 at the current >=2x, 0.028 at 3x, 0.081 at 4x, 0.35 at 6x. Do not retune the RATIO for speed without re-pinningtest_flexure_mean_load_rigidity_supported().
- siim._core.flexure._transfer(Ny, Nx, dy, dx, D, rhoa)[source]¶
rho_a g + D |k|^4on the padded (Ny, Nx) box: DST-II mode j has wavenumberpi*j/(N*d), j = 1..N (module docstring). Cached because it is about a quarter of a solve and identical every step of a run; read-only so a cached array can never be corrupted in place.
- siim._core.flexure.flexure(elev_post, elev_eq, nx, ny, xl, yl, rhos, rhoa, Te, ibc)[source]¶
Flexural deflection of the elastic plate under the load implied by
elev_post - elev_eq(the per-step loading increment the caller stacked intoelev_post); the deflectionwis ADDED toelev_postin place (sorebound = elev_post - elev_eq_preon return). Signature matches the fortranfs.flexureseam.elev_post/elev_eq/rhosare flat length-nx*nyarrays (row- major, x fastest, as fastscape passes them);rhoaasthenospheric density;Teeffective elastic thickness;ibcthe fastscapelib boundary code.dx = xl/(nx-1),dy = yl/(ny-1).
Flow routing & accumulation primitives — siim._core.routing¶
Flow routing & accumulation primitives for the siim numerical core.
The fastscape-free numba kernels – SFR + D-inf accumulators, the eps-fill
priority flood, and the Tarboton (1997) D-inf routing primitives. Pulling them
out of the fastscape-importing model is what lets the solver kernels import
without the fastscape stack. The DinfFlowRouter xsimlab process that
consumes them lives in siim.fastscape.processes. numpy/numba only.
- siim._core.routing._flow_accumulate_dinf(field, stack, nb_receivers, receivers, weights)[source]¶
Single-field D-inf accumulator. Donor-first stack.
- siim._core.routing._heap_push(hz, hi, hn, z, i)[source]¶
Binary min-heap push on parallel (z, idx) arrays; returns new size.
- siim._core.routing._priority_flood_eps(z_flat, ny, nx, interior_flat, eps, wrap_y, wrap_x, z_fill)[source]¶
Priority-flood depression filling with an epsilon drainage gradient (Barnes, Lehman & Mulla 2014): flood inward from the outlet cells (interior_flat == 0, the SFR self-receiving set), each cell filled to max(z, spill_path + eps), so on the FILLED surface every interior cell has a strictly lower 8-neighbour and lakes drain toward their spill on eps-gradients. eps accumulates to ~eps * lake diameter — millimetres for any realistic basin. 8-connectivity matches the D-inf facet neighbourhood, so routing the filled surface leaves no interior pits. Looped axes wrap (wrap_y rows, wrap_x columns).
- siim._core.routing._dinf_route(z_flat, ny, nx, dx, dy, interior_flat, rec1, rec2, w1, w2, len1, len2, slope_out, e1_dj, e1_di, e2_dj, e2_di, wrap_y, wrap_x)[source]¶
D-infinity routing. Fills rec1, rec2, w1, w2, len1, len2, slope_out in place.
The Tarboton [Tar97] algorithm on a (possibly anisotropic) rectangular grid: per facet, the cardinal step d1 is dx or dy depending on whether e1 is an E/W or N/S neighbour, and the transverse step d2 is the other spacing:
s1 = (z_c - z_e1) / d1 s2 = (z_e1 - z_e2) / d2 r = atan2(s2, s1) # flow angle within facet if r < 0: r = 0, s = s1 (along the cardinal) if r > atan2(d2,d1): r = that, s = (z_c-z_e2)/sqrt(dx²+dy²) (diagonal) else: s = sqrt(s1² + s2²)
The facet with the largest s is selected; flow splits between e1 and e2 in proportion to the angle within the facet (w_e2 = r / facet_angle — the π/4 of the square-grid formula generalises to atan2(d2, d1)). Boundary cells self-receive. Looped axes wrap. Routed on the eps-filled surface (see _priority_flood_eps), no interior cell pits.
Row-parallel (
prangeover j): every cell writes only its own index and reads onlyz_flat, so the result is independent of thread count and bit-for-bit identical to the serial scan.
- siim._core.routing._dinf_topo_stack(rec1, rec2, w1, w2, n, stack)[source]¶
Topological sort (Kahn’s algorithm) for D-inf graph. Emits cells in receivers-first order (base cells first, donors last). Caller must reverse for fastscape’s donor-first convention.
- siim._core.routing._dinf_pack(rec1, rec2, w1, w2, len1, len2, n, receivers, weights, lengths, nb_receivers)[source]¶
Pack D-inf routing into (n, 2) receiver/weight arrays.
Single-receiver cases (pit/boundary, both-receivers-same-cell, only one weight nonzero) get nb_receivers[i]=1 with the receiver at index 0. Two-receiver cells get nb_receivers[i]=2.
- siim._core.routing._d8_receivers(z_flat, ny, nx, dx, dy, interior_flat, rec, lengths, wrap_y, wrap_x)[source]¶
Steepest-descent D8 receiver, replicating fortran
find_receiver(FlowRouting.f90:302-362) on the (eps-filled) surface: outerjj in {-1,0,1}x innerii in {-1,0,1}scan (skipping(0,0)),l = sqrt((dx*ii)**2 + (dy*jj)**2), keep on STRICTslope > smaxwithsmaxinitialised totiny— so steepest descent wins and, on a tie, the first neighbour in scan order does (byte-identical to fortran on tie-free surfaces). Non-interior cells self-receive (rec[i]=i, lengths[i]=0, thebounds_bcborder contract). On the eps-filled surface every interior cell has a strictly-lower 8-neighbour, so it always finds one (no interior self-receivers => acyclic graph). Looped axes wrap.Serial: each cell writes only its own
rec/lengthsand reads onlyz_flat, so the result is independent of iteration order (a prange over the outer loop would be bit-for-bit identical; kept serial per Map 4 §4).
- siim._core.routing._d8_stack(rec, n, stack)[source]¶
Outlet-first topological stack for the single-receiver graph: base cells (
rec[i]==i) first, then their donors (receiver-before-donor). Donor-CSR BFS from the base cells — a valid topological order, which is all the accumulators/eroders require (fortran’s exact DFS visitation order is NOT contracted, Map 3 §3(iii)). On the eps-filled surfacerecis a forest of trees rooted at the border/base cells, so the BFS emits every cell exactly once.
- siim._core.routing._d8_basin(rec, stack, n, basin)[source]¶
Basin id of each cell = the index of the base (outlet) cell its receiver chain terminates at. One forward pass over the outlet-first
stack(the receiver is always processed before its donors), sobasinpropagates from each outlet down its tributaries. Labeling by outlet INDEX makes the ids reproducible run-to-run (unlike fortran’s unseededrandom_numbercatch labels, Map 4 §4) — butbasinis a diagnostic and stays out of every equality gate regardless.
- siim._core.routing.d8_interior_mask(border_status, ny, nx)[source]¶
The
interiormask (1 = routable interior cell, 0 = self-receiving boundary) directly fromborder_status— the in-house replacement for the fortran-derivedsfr_rec != arangemask (Map 3 §4). Provably identical: after fortranLocalMinimathe only self-receiving cells arebounds_bc= exactly the ‘fixed_value’ border rings (FastScape_ctx.f90:589-592).‘core’ and ‘looped’ are both plain interior for EVERY router (OQ-1(b), ratified): ‘core’ is non-periodic interior (periodicity is the separate wrap axis, keyed on ‘looped’ only), aligning the SFR mask with the D-inf convention siim already shipped.
border_status= [left, right, top, bottom]; node index =j*nx + i.
- siim._core.routing._levels_sfr(stack, rec)[source]¶
Level index for the SFR graph. Returns (order, offsets, nlev):
order[offsets[l]:offsets[l+1]]are the nodes of levell.
- siim._core.routing._levels_dinf(stack, nb_receivers, receivers)[source]¶
Level index for the D-inf graph (level = 1 + max over real receivers). Same return contract as
_levels_sfr(); nodes are bucketed in receivers-first (reversed donor-first stack) order.
Composition-chain step functions — siim._core.step¶
Framework-free extractions of the 2D model’s per-step composition chain (one
self-free function per siim.fastscape process run_step body). The xsimlab
adapter shells and the standalone driver call the same functions.
Framework-free composition-chain step functions for the siim 2D model.
Each run_step body of siim’s own @xs.process classes
(siim.fastscape.processes) is extracted here as a self-free
module-level function taking state + params explicitly. The xs.process classes
become thin shells that unpack self.*, call the function, and assign the
outputs; the standalone in-house driver (added later in the migration) calls the
same functions. One implementation, two front ends — no divergence.
This module stays numpy/numba/scipy-only (like the rest of siim._core):
it imports NOTHING from xsimlab / fastscape / siim.fastscape. The single fortran
seam that survives S1 — the FFT flexure solve — is injected as a callable
(glacial_flexure_step()’s flexure_solve argument), so this module never
imports fastscapelib_fortran either.
The step-order and state-separation subtleties that must remain bit-for-bit stable are summarized below:
the
ice_thicknessone-step lag (router/accumulator seeH(t-1)— an ordering artifact reproduced by reading H into the routing surface before the kernel overwrites it);exactly ONE
routing_relaxEMA update per step (ema_thickness(); the EMA carry_H_effis cross-step state AND reused within-step by the trunk subclass — the shell owns that single update);size-1 clock-sliced inputs de-squeezed inside the extracted fns (
bl = float(asarray(bl).ravel()[-1]),bbu[0]on a(1, ny, nx)slice);the FIREWALL (measured twice): the mode-B kernel reconstructs its OWN
zs = zb + hc*Hfrom raw H; the relaxed / fabricated surfaces (ema_thickness()/_fabricate_trunk_surface()) feed ONLY the router graph + the mass-balance surface, never a flux closure, the carve, the flexure load, or the outputs.
- siim._core.step.build_glacial_params(*, sliding_law, Ko, ce, n, nu, m, mu, Ac, alpha_g, lambda_p, lambda_c, tau_c, coulomb_clamp, hc_over_H, H_diffusivity)[source]¶
(law_code, GlacialParams) for the given sliding law — the frozen per-run scalars the law_code step skeletons consume. Body of
GlacialLaw.initialize+_glacial_params_and_code. Validatessliding_lawandhc_over_H;mdefaults ton/2andmuto the per-lawconstants.derive_*.
- siim._core.step.initial_topography(elevation_init, shape, border_status, seed, noise_amplitude)[source]¶
Initial surface =
elevation_init+ uniform tie-breaking noise, zeroed on ‘fixed_value’ edges. RNG seeded here (init-only; no per-step RNG).
- siim._core.step.plateau_profile(x, plateau_zo, plateau_dz, plateau_frac, plateau_w)[source]¶
1-D arctan-smoothed plateau profile on the coordinate
x: exactly 0 atx[0], rising across an escarpment centred atx_esc = (1 - plateau_frac)*Lxwith transition widthplateau_w, then droppingplateau_dzlinearly to exactlyplateau_zo - plateau_dzatx[-1]. The raw arctan ramp is rescaled so the domain ends land on 0 / 1 (the raw ramp only reaches them asymptotically — with the default frac/w on a 50 km domain the low edge sat at 0.25*zo, a permanent sill above the border’s water datum).
- siim._core.step.plateau_surface(x, y, shape, border_status, seed, noise_amplitude, plateau_zo, plateau_dz, plateau_frac, plateau_w)[source]¶
Arctan-smoothed plateau initial topography (
plateau_profile()tiled along y) + tie-breaking noise (zeroed on ‘fixed_value’ edges, so a fixed x-border starts exactly on 0 /plateau_zo - plateau_dz). Body ofPlateauSurface.initialize.
- siim._core.step.wave_uplift(x, y, shape, mask, dt, t, delta_h, wave_width, wave_velocity, x_escarpment, wave_calibration, U_inf)[source]¶
Moving-Gaussian uplift wave, midpoint-sampled (
t + dt/2) with calibration 1.0 = exactdelta_hdeposition over the passage. Body ofWaveUplift.run_step; the border mask isuplift_mask().
- siim._core.step.SIDE_SLICES = ((slice(None, None, None), 0), (slice(None, None, None), -1), (0, slice(None, None, None)), (-1, slice(None, None, None)))¶
Border ring of each domain edge, indexed in
border_statusorder (siim._core.outputs.SIDES= left, right, bottom, top): index 2 is row 0 (y = 0), index 3 is row ny-1.
- siim._core.step.uplift_mask(border_status, shape)[source]¶
Binary uplift mask: 0 on ‘fixed_value’ border rings, 1 elsewhere. Body of fastscape
BlockUplift.initialize(the mask half).
- siim._core.step.block_uplift(rate, dt, mask, shape)[source]¶
uplift = rate * dt, zeroed on fixed borders bymask. Body ofGlacialBlockUplift.run_step; drops the leading size-1 tstep dim left by xsimlab on a(nt, y, x)slice before broadcasting.
- siim._core.step.ema_thickness(H_lag, H_eff_prev, r)[source]¶
The (optionally EMA-relaxed) lagged thickness feeding this step’s routing + mass-balance surface.
r == 0returns the raw lagged H unchanged (bit-for-bit); otherwiseH_eff = r*H_eff_prev + (1-r)*H_lag(seeded atH_lagon the first step,H_eff_prev is None).The caller owns the single per-step update: it stores the return as the EMA carry (cross-step) AND reuses the SAME value within-step (the trunk subclass). One update per model step — never call twice.
- siim._core.step.routing_surface(post_uplift_surface, hc_over_H, H_eff)[source]¶
The mode-B routing/mass-balance surface
zs = post_uplift_bed + hc*H_eff(the reconstructed ice column on the post-uplift bed).H_effis the relaxed lagged thickness (raw H whenrouting_relax == 0). FIREWALL: this surface reaches ONLY the router graph + the accumulator’s mass-balance surface — never a physics closure, the carve, the flexure load, or outputs.
- siim._core.step._fabricate_trunk_surface(zs_dyn, zb, H_lag, border, alpha_g, dx, dy, k_dip, floor, offsets, D, SRC, wrap_y, wrap_x)[source]¶
Build the fabricated trunk routing surface described in
docs/guides/concepts.md. Pure function (no process state) so the process and the channel-persistence test share one implementation.zs_dyn(ny, nx) is the dynamic ice surfacezb + hc*H_lag;zbthe matching bed;H_lagthe lagged thickness;border(ny, nx bool) the base-level edges (excluded as seeds + never fabricated).offsets/D/SRCare (ny, nx) scratch buffers. Returns a fresh (ny, nx) elevation: the V-dipped trunk surface at footprint cells (max(zs_geo, zb)),zs_dynelsewhere.
- siim._core.step.accumulate_glacial_flow(surface, surface_upward, zELA, beta, runoff, cell_area, width_hack_k, width_hack_p, shape, stack, receivers, nb_receivers, weights, lengths, basin)[source]¶
Accumulate water flux, ice flux, drainage area + the routing-topology outputs (basin_ids, receivers_2d, stack_2d). Body of
GlacialFlowAccumulator.run_step.surfaceis the post-uplift routing surface (zs_route); the ELA-relative mass balanceb(z)is evaluated on the PRE-uplift climate surfacez_clim = surface - surface_upward(no O(U*dt) bias). Returns(ice_flux, water_flux, area, basin_ids, receivers_2d, stack_2d)(caller also setsflowacc = water_flux).
- siim._core.step._H_from_QS_modeA(law_code, gp, Qg, S)[source]¶
Per-law point closure H(Qg, S): the from_slope branch of the shared
_modeb_closure()(single-sourced; audit m34).
- siim._core.step._solve_border_H_modeA(z_flat, H_flat, ice_flux, receivers, nb_receivers, lengths, law_code, gp)[source]¶
Mode A: a self-receiving border node with through-flowing ice gets its thickness from the per-law H(Q, S) closure with S the steepest upwind (donor-side) surface slope — matching siim1d’s outlet treatment. Mutates
H_flatin place at border cells; the interior is untouched.nb_receiversis read only on the D-inf branch (may be None under SFR).
- siim._core.step.run_modeA_step(surface, H, ice_flux, water_flux, law_code, gp, dt, stack, receivers, nb_receivers, weights, lengths, hc_over_H, shape)[source]¶
Mode A erosion step (ice-surface state). Body of
GlacialSPLModeA: dispatch to the routing-specific mode-A skeleton (mutating a fresh copy of the surface + H), solve the border-H closure, then commit. Returns(z_eroded, H_new, bedrock_surface, erosion, denudation)—erosion == denudation(mode A erodes the ice surface as its single hc-invariant state).
- siim._core.step.run_modeB_kernel(zb_flat, H_flat, ice_flux, water_flux, law_code, gp, dt, stack, receivers, nb_receivers, weights, lengths, shape, dx, dy, border_bed_uplift, bl, gate, ramp, parallel, wrap_y, wrap_x)[source]¶
No-carve mode-B kernel dispatch (SFR or D-inf). Mutates
zb_flatandH_flatin place; returns the kernel’ssurface_out(= zb + hc*H).FIREWALL: the kernel reconstructs its OWN
zs = zb + hc*Hfrom the raw H for every closure and erosion slope — it never sees the relaxed/fabricated routing surface. De-squeezes the size-1 clock slices ofborder_bed_upliftandblinside, so the shell and the driver share the treatment.blmay also be the driver’s flat(ny*nx,)per-side border datum (seesiim._core.driver._bl_field()).
- siim._core.step.carve_bed(zb_flat, H_flat, surface_out, zb_pre, receivers, alpha_g, hc_over_H, widening_factor, shape, dx, dy, wrap_y, wrap_x, offsets=None, D=None, SRC=None, zb_kern=None)[source]¶
Apply the sub-grid width carve (see
siim._core.carve) to the post-kernel bedzb_flatIN PLACE.zb_preis the pre-kernel bed (the denudation datum + descent-cap origin);surface_outthe kernel’s reconstructed ice surface (updated for carved cells). Routing-agnostic: receivers enter only through the border markerrec[i] == i(self-receiving = base-level border).offsets/D/SRC/zb_kernare optional reusable scratch buffers (an optimization, not semantics — allocated fresh if omitted).
- siim._core.step._filled_surface_and_mask(elevation, shape, border_status)[source]¶
The fill-then-route preamble shared by
route_d8()/route_dinf(): theborder_statusinterior mask (Map 3 §4 — the in-house replacement for the fortransfr_rec != imask), the looped-axis wrap flags, and the eps-filled surface (depression floors filled to spill + eps, so every interior cell has a strictly-lower 8-neighbour and lakes drain toward their spills). Returns(z_route, interior, wrap_y, wrap_x, ny, nx, nn). The kernels/physics keep consuming the TRUE elevation; only routing sees the fill.
- siim._core.step.route_d8(elevation, shape, dx, dy, border_status)[source]¶
In-house D8 single-flow router (fill-then-route) — the framework-free replacement for fastscape’s fortran
SingleFlowRouter(fs.flowroutingsingleflowdirection). Returns the SFR router bundle(receivers, weights, lengths, nb_receivers, stack, basin):receivers/lengths1D(n,),weights/nb_receiversall-ones (SFR is single-receiver),stackoutlet-first,basin(ny, nx)labeled by outlet index. The receiver scan replicates fortranfind_receiveron the eps-filled surface (Map 3 §3); the routing delta vs fortran is confined to depression/tie cells (behavioral gate).
- siim._core.step.route_dinf(elevation, shape, dx, dy, border_status)[source]¶
D-infinity flow directions on the eps-filled surface. Body of
DinfFlowRouter.run_step— fully fortran-free (S4): the interior/boundary mask comes fromborder_statusdirectly (Map 3 §4, provably identical to the old fortransfr_rec != imask), andbasinfrom the in-house outlet labeling. Returns(receivers, weights, lengths, nb_receivers, stack, basin)—receivers/weights/lengths(n, 2),basin(ny, nx).
- siim._core.step.accumulate_sediment(denudation, cell_area, stack, receivers, nb_receivers, weights, shape)[source]¶
Route this step’s denuded rock volume
max(denudation, 0) * cell_areadown the flow graph in one accumulation pass. Body ofSedimentTracker.run_step(the per-stepflux; the caller owns the cross-step running integral_cum).
- siim._core.step.edge_sediment(flux, border_status)[source]¶
Sum the routed per-node
flux(the(ny, nx)fieldaccumulate_sediment()returns) over each domain-edge outlet ring — the volume (m^3) leaving the domain across that edge this step.Returns a length-4 array in
border_statusorder (siim.siim2d.siim._BL_SIDES= left, right, bottom, top), NaN on any side that is not'fixed_value': only fixed-value rings are self-receiving outlets (siim._core.routing.d8_interior_mask()), so a ‘core’ or ‘looped’ edge has no sediment to deliver, which is not the same as delivering none. Each corner node is counted exactly ONCE, by the same rulesiim._core.driver._bl_fielduses for the water datum: an outlet x-side (left/right) owns it, else the y-side.Shared by the in-house driver and
SedimentTracker.run_stepso the two front ends report the identical sums.
- siim._core.step.glacial_flexure_step(elevation, denudation, surface_upward, ice_thickness, alpha_g, cell_area, lithos_density, asthen_density, e_thickness, ibc, shape, length, col_prev, ice_load, flexure_solve)[source]¶
Incremental flexural isostasy: rock unloading (
surface_upward - denudation) plus, whenice_load, the per-step glacial ice load(rho_ice/lithos)*d(col)withcol = alpha_g*H**2/L(hc-free, mass-conserving). Body ofGlacialFlexure.run_step; the biharmonic plate solve is INJECTED asflexure_solve(elev_post, elev_eq, nx, ny, xl, yl, lithos, asthen, Te, ibc)(fortranfs.flexureat S1, in-house FFT later — keeps this module framework-free). Returns(rebound, col_new); the caller keepscol_newas the cross-step_col_prev.The returned
reboundis exactly 0 on every'fixed_value'border row and column (read offibc, so the in-house driver and the xsimlab adapter share it) — those nodes already get no block uplift and no erosion, so letting them subside was the one place their value was not actually fixed.
- siim._core.step.sum_erosion(*erosion_terms)[source]¶
Reproduce fastscape
TotalErosion.height = sum(erosion group)=glacial_spl.erosion + diffusion.erosion. Sums from 0 like the stock builtinsum, so a single term returns a copy (0 + a == a).
- siim._core.step.compose_vertical_motion(surface_forcing, bedrock_forcing, rebound, erosion_total)[source]¶
Reproduce fastscape’s
TotalVerticalMotiongroup-sum composition:surface_up = sum(surface_upward group) - sum(surface_downward group) = (surface_forcing + rebound) - erosion_total bedrock_up = sum(bedrock_upward group) = bedrock_forcing + rebound
surface_forcing/bedrock_forcingare the tectonic forcings (TectonicForcing.{surface,bedrock}_upward= block uplift here);reboundthe flexural rebound in both upward groups (Nonewhen flexure is off);erosion_totaltheTotalErosion.height(sum_erosion()). The finalize committopo += surface_uplives in the driver.
In-house time loop — siim._core.driver¶
The standalone driver: siim’s own merged step loop + two-cadence snapshot,
calling the same step functions the adapter shells call. Selected via
run(driver='inhouse') (default constants.DRIVER_DEFAULT).
The in-house time loop for the standalone siim 2D model.
Owns the merged step chain and the two-cadence snapshot that xsimlab’s driver
performed for the siim.fastscape adapter path, calling the SAME
framework-free step functions (siim._core.step) the @xs.process shells
call — one implementation, two front ends. The public model and output
contracts are documented in docs/guides/concepts.md and
docs/guides/outputs_and_io.md.
Merged step order (a valid topological sort of the composition graph):
uplift -> tectonics -> surf2erode -> route -> accumulate -> kernel ->
diffusion -> flexure -> (erosion sum + vertical-motion compose) -> sediment
-> [SNAPSHOT] -> finalize (topo += surface_up)
Snapshot-timing invariant (load-bearing, Map 2 §2): intermediate frames are
captured AFTER the step but BEFORE the uplift-committing finalize; the LAST frame
(out_idx[-1] == nt-1) is captured AFTER the loop (post the final finalize).
Frame 0’s topography__elevation is thus the initial topo while H / flux /
area / erosion already reflect one solved step.
Cross-step state OWNED by the driver: the ice_thickness one-step lag (the
router/accumulator read H(t-1) — an ordering artifact reproduced by reading
H into the routing surface before the kernel overwrites it), the _H_eff EMA
carry (routing_relax), the flexure _col_prev, the sediment _cum (and
its per-domain-edge twin), and the topography state (committed only at
finalize).
FIREWALL (Map 1 §2): the relaxed / fabricated routing surface (zs_route)
reaches ONLY the router graph, the mass-balance surface, and the ice-surface
hillslope diffusion — the mode-B kernel reconstructs its OWN zs = zb + hc*H
from raw H for every closure/erosion slope.
Routing is INJECTED as a callable (cfg.route) — the fortran backend at S3,
the in-house D8 producer at S4 — so this module stays framework-free (no
xsimlab / fastscape / fastscapelib import). The flexure plate solve is injected
too (cfg.flexure_solve); the in-house hillslope diffuser
(siim._core.hillslope.diffuse()) is called directly.
- siim._core.driver._edge_border_mask(border_status, shape)[source]¶
Non-looped domain edges (base-level borders), as the trunk-surface fabrication excludes them (TrunkSurfaceToErode.initialize).
- siim._core.driver._slice_forcing(series, scalar, k)[source]¶
Per-step forcing value:
series[k]when a clock series is present, the static scalar/field otherwise (the driver indexesarr[k]directly, replacing xsimlab’s(('tstep',), arr)groupby slice, Map 2 §4).
- siim._core.driver._bl_field(bl_sides, shape, k)[source]¶
Per-node water datum for step
k(PER-SIDEbl): eachfixed_valueborder node carries its own side’s datum; interior entries are placeholders (0) that the mode-B kernel overwrites with the datum of the outlet its basin drains to. Corner nodes touched by two fixed sides take the x-side (left/right) value — the x-sides are written last.bl_sidesis the 4-list of(scalar, series)entries parsed bysiim.siim2d.siim.set_and_check_parameters(Noneoff fixed sides).
- siim._core.driver.run_loop(cfg)[source]¶
Run the merged in-house time loop; return the packed
ds_outstep buffers (a{name: ndarray}dict, one array per output-spec row at that row’s dims).cfgis the resolved-parameter bundle assembled bysiim.siim2d.siim._run_inhouse()(see that method for the field contract).
Output packing — siim._core.outputs¶
Packs the driver’s step buffers into the exact ds_out contract
(variable names, (time, y, x) dims, dtypes, coords) that
siim.siim2d.siim._unpack_outputs reads.
Pack the in-house driver’s step buffers into the exact ds_out contract.
The standalone driver (siim._core.driver) fills per-step numpy buffers;
this module allocates them to the exact dtypes and wraps them in the
xarray.Dataset siim.siim2d.siim._unpack_outputs() reads — same
variable names, dims ((time, y, x) for every per-node field, (time,
side) for the per-domain-edge sediment totals), coords
(time/x/y/tstep, plus side),
dtypes (float64 fields; int32 for basin_ids/receivers_2d/stack_2d)
and the mode/flag-conditional rows. _unpack_outputs / save / load
consume the exact keys constructed here; see
docs/guides/outputs_and_io.md for the public output contract.
xarray is the only heavy import; it stays function-local so
import siim._core.outputs costs nothing until a dataset is built.
- siim._core.outputs.SIDES = ('left', 'right', 'bottom', 'top')¶
domain edges in
border_statusorder. Index 2 (‘bottom’) is row 0, i.e. y = 0, and index 3 (‘top’) is row ny-1 — the fastscape ibc digit naming calls those same two edges the other way round.- Type:
The
sidecoordinate
- siim._core.outputs.output_spec(mode, flexure, sediment, sediment_edge)[source]¶
The active
(name, dtype, dims)rows for this run.bedrock_surfaceiff mode A (keys the_unpackmode-A branch, siim2d.py:965),sediment__*iffsediment,sediment__edge_*(the per-edge totals, dims(time, side)) iffsediment_edge,flexure__reboundiffflexure.
- siim._core.outputs.allocate_buffers(spec, nt_out, ny, nx)[source]¶
One output buffer per spec row, at the row’s dtype and dims (the raster rows
(nt_out, ny, nx), the per-edge rows(nt_out, 4)).
- siim._core.outputs.build_dataset(buffers, spec, t_out, x, y, tstep)[source]¶
Wrap the filled step buffers into the
ds_outxarray Dataset: each row at its spec dims; coordstime=t_out,x,y,tstep, plussidewhen a per-edge row is active. Reads only variable values by name downstream (_unpack_outputs), so this in-memory build is interchangeable with the retired zarr path.