240 lines
8.4 KiB
Go
240 lines
8.4 KiB
Go
package fluvial
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
)
|
|
|
|
// The three properties the nonlinear law is being trusted for. Each one is a thing the pair it replaces got
|
|
// wrong, so each is worth a test rather than an assurance.
|
|
|
|
// TestDiffuseNonlinearConservesMass is the property ClampToRepose does not have: material shed from a divide
|
|
// has to arrive somewhere, not be deleted.
|
|
//
|
|
// The border is always an outlet, so the sum over the whole grid cannot be conserved by construction — base
|
|
// level is a sink and is meant to be. The check is therefore over an interior that the disturbance never
|
|
// reaches: a bump in the middle of a grid big enough that nothing has diffused to the edge by the time the
|
|
// run ends.
|
|
func TestDiffuseNonlinearConservesMass(t *testing.T) {
|
|
const (
|
|
w, h = 101, 101
|
|
cellM = 10.0
|
|
sc = 0.7
|
|
)
|
|
base := make([]bool, w*h)
|
|
g := NewGrid(w, h, cellM, base)
|
|
g.SetElevationRange(-100, 2000)
|
|
|
|
field := make([]float32, w*h)
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
d := math.Hypot(float64(x-w/2), float64(y-h/2)) * cellM
|
|
field[y*w+x] = float32(math.Max(0, 300-1.5*d)) // a cone well past Sc
|
|
}
|
|
}
|
|
|
|
sum := func() float64 {
|
|
var s float64
|
|
for y := 2; y < h-2; y++ {
|
|
for x := 2; x < w-2; x++ {
|
|
s += float64(field[y*w+x])
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
before := sum()
|
|
for i := 0; i < 40; i++ {
|
|
g.DiffuseNonlinear(field, 0.02, sc, 0.9, 1500, 24)
|
|
}
|
|
after := sum()
|
|
|
|
// The cone is 300 m tall and the interior holds millions of cubic metres; a tenth of a percent is a very
|
|
// tight bound on forty steps of an explicit scheme in float32.
|
|
rel := math.Abs(after-before) / before
|
|
t.Logf("interior mass %.1f -> %.1f, relative change %.2e", before, after, rel)
|
|
if rel > 1e-3 {
|
|
t.Errorf("interior mass changed by %.3f%%; the flux is not antisymmetric", rel*100)
|
|
}
|
|
}
|
|
|
|
// TestDiffuseNonlinearLimitsSlope is the self-limiting property, and the only honest way to test it is under
|
|
// uplift. Without uplift every diffusion law flattens everything eventually, nonlinear included, so a
|
|
// relaxing cone proves nothing. What distinguishes the two laws is where they come to rest against a forcing:
|
|
// linear diffusion has no limiting angle at all and lets relief grow to U*L^2/(2D), which at these numbers is
|
|
// a kilometre and slopes many times Sc, while the nonlinear law's flux diverges as the slope approaches Sc so
|
|
// the landscape settles near it whatever U is. That is the entire reason for the change, so it is the test.
|
|
func TestDiffuseNonlinearLimitsSlope(t *testing.T) {
|
|
// The forcing is chosen so the question is about the law and not about the sub-step budget. A hillslope of
|
|
// half-width L under uplift U comes to rest, under the linear law, at a maximum slope of U*L/D; here that is
|
|
// 0.8, twice Sc, so linear diffusion visibly fails to limit. The nonlinear law can hold Sc only while its
|
|
// flux at the cap, D*Sc/(1-uCap^2), still exceeds U*L, and at these numbers it does with room to spare — so
|
|
// a failure here is the law's, not the budget's. Push U much higher and no bounded-flux law holds Sc; that
|
|
// is the regime ClampToRepose exists for, and Run keeps it for exactly that reason.
|
|
const (
|
|
w, h = 41, 41
|
|
cellM = 10.0
|
|
sc = 0.4
|
|
upliftM = 2e-4 // 0.2 mm/yr
|
|
dt = 1000.0
|
|
steps = 5000
|
|
)
|
|
grow := func(nonlinear bool) float64 {
|
|
base := make([]bool, w*h)
|
|
g := NewGrid(w, h, cellM, base)
|
|
g.SetElevationRange(-100, 8000)
|
|
f := make([]float32, w*h)
|
|
for i := 0; i < steps; i++ {
|
|
for j := range f {
|
|
if !g.fixed[j] {
|
|
f[j] += float32(upliftM * dt)
|
|
}
|
|
}
|
|
if nonlinear {
|
|
g.DiffuseNonlinear(f, 0.05, sc, 0.9, dt, 24)
|
|
} else {
|
|
g.Diffuse(f, 0.05, dt)
|
|
}
|
|
}
|
|
return maxCardinalSlope(f, w, h, cellM)
|
|
}
|
|
lin := grow(false)
|
|
non := grow(true)
|
|
t.Logf("after %.1f Myr at %.1f mm/yr: linear reaches slope %.3f, nonlinear %.3f (Sc %.3f)",
|
|
steps*dt/1e6, upliftM*1000, lin, non, sc)
|
|
|
|
if non > sc {
|
|
t.Errorf("nonlinear settled at %.3f, above Sc %.3f: the flux is not stiffening", non, sc)
|
|
}
|
|
if non < sc*0.4 {
|
|
t.Errorf("nonlinear settled at %.3f, far below Sc %.3f: it is over-transporting", non, sc)
|
|
}
|
|
// The discriminating statement: under one forcing, the linear law overshoots the critical slope and the
|
|
// nonlinear law does not. If linear stays under it too, the forcing was too gentle to test anything.
|
|
if lin <= sc {
|
|
t.Errorf("linear only reached %.3f against Sc %.3f; the forcing is too weak to tell the laws apart", lin, sc)
|
|
}
|
|
}
|
|
|
|
// TestDiffuseNonlinearIsStable catches the failure that does not announce itself. An explicit scheme run past
|
|
// its stability limit does not blow up on the first step; it grows a checkerboard over hundreds of them, and
|
|
// by then the run is finished and the artefact looks like texture. A checkerboard is the mode a five-point
|
|
// stencil goes unstable in, so it is what the test starts from: a stable scheme damps it towards flat.
|
|
//
|
|
// The settings put the sub-step logic where it has to choose. The initial field is far past Sc, so the cap
|
|
// engages; the budget is well below what u = 0.95 would want, so the cap has to be lowered rather than the
|
|
// sub-step count truncated. Truncating is the tempting, wrong branch and is what this is here to catch.
|
|
//
|
|
// Only the interior is measured. The border is an outlet and is held fixed by design, so it keeps its initial
|
|
// values for ever and reading it back tells you nothing about the scheme.
|
|
func TestDiffuseNonlinearIsStable(t *testing.T) {
|
|
const (
|
|
w, h = 64, 64
|
|
cellM = 8.0
|
|
sc = 0.7
|
|
)
|
|
base := make([]bool, w*h)
|
|
g := NewGrid(w, h, cellM, base)
|
|
g.SetElevationRange(-1000, 4000)
|
|
|
|
// The checkerboard goes in the interior only. The border is an outlet and is held fixed, so a
|
|
// checkerboard written across it is a permanent forcing that keeps re-injecting the mode into the first
|
|
// interior ring — the scheme would then be blamed for a boundary condition.
|
|
field := make([]float32, w*h)
|
|
for y := 1; y < h-1; y++ {
|
|
for x := 1; x < w-1; x++ {
|
|
if (x+y)%2 == 0 {
|
|
field[y*w+x] = 200
|
|
}
|
|
}
|
|
}
|
|
for i := range field {
|
|
if g.fixed[i] {
|
|
field[i] = 100 // flat base level, the mean of the checkerboard
|
|
}
|
|
}
|
|
for i := 0; i < 500; i++ {
|
|
g.DiffuseNonlinear(field, 0.02, sc, 0.95, 1500, 24)
|
|
}
|
|
lo, hi := float32(math.Inf(1)), float32(math.Inf(-1))
|
|
for y := 1; y < h-1; y++ {
|
|
for x := 1; x < w-1; x++ {
|
|
v := field[y*w+x]
|
|
if math.IsNaN(float64(v)) || math.IsInf(float64(v), 0) {
|
|
t.Fatalf("hillslope diffusion produced %v at %d,%d", v, x, y)
|
|
}
|
|
if v < lo {
|
|
lo = v
|
|
}
|
|
if v > hi {
|
|
hi = v
|
|
}
|
|
}
|
|
}
|
|
t.Logf("after 500 steps the interior spans %.3f..%.3f m, from a 200 m checkerboard", lo, hi)
|
|
if hi-lo > 1 {
|
|
t.Errorf("the checkerboard is still %.1f m after 500 steps: it is not being damped", hi-lo)
|
|
}
|
|
}
|
|
|
|
// TestDiffuseNonlinearMatchesLinearWhenGentle pins the other end of the law. Well below Sc the two must agree
|
|
// closely, because that is the claim that lets this replace linear diffusion outright rather than sit beside
|
|
// it: the lowlands must not change when the switch is thrown.
|
|
func TestDiffuseNonlinearMatchesLinearWhenGentle(t *testing.T) {
|
|
const (
|
|
w, h = 64, 64
|
|
cellM = 10.0
|
|
sc = 1.0 // Sc far above anything in the field, so u stays near zero
|
|
)
|
|
base := make([]bool, w*h)
|
|
a := make([]float32, w*h)
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
// A gentle bump: peak slope about 0.01, one percent of Sc.
|
|
d := math.Hypot(float64(x-w/2), float64(y-h/2)) * cellM
|
|
a[y*w+x] = float32(3 * math.Exp(-d*d/(2*100*100)))
|
|
}
|
|
}
|
|
b := make([]float32, w*h)
|
|
copy(b, a)
|
|
|
|
ga := NewGrid(w, h, cellM, base)
|
|
ga.SetElevationRange(-100, 100)
|
|
gb := NewGrid(w, h, cellM, base)
|
|
gb.SetElevationRange(-100, 100)
|
|
for i := 0; i < 20; i++ {
|
|
ga.DiffuseNonlinear(a, 0.02, sc, 0.9, 1500, 24)
|
|
gb.Diffuse(b, 0.02, 1500)
|
|
}
|
|
|
|
var worst float64
|
|
for i := range a {
|
|
if d := math.Abs(float64(a[i] - b[i])); d > worst {
|
|
worst = d
|
|
}
|
|
}
|
|
t.Logf("worst divergence from linear diffusion over 20 steps: %.4f m", worst)
|
|
if worst > 0.01 {
|
|
t.Errorf("nonlinear and linear diffusion differ by %.4f m at u ~ 0.01; they should agree", worst)
|
|
}
|
|
}
|
|
|
|
func maxCardinalSlope(h []float32, w, hgt int, cellM float64) float64 {
|
|
var worst float64
|
|
for y := 0; y < hgt; y++ {
|
|
for x := 0; x < w; x++ {
|
|
i := y*w + x
|
|
if x+1 < w {
|
|
if s := math.Abs(float64(h[i+1]-h[i])) / cellM; s > worst {
|
|
worst = s
|
|
}
|
|
}
|
|
if y+1 < hgt {
|
|
if s := math.Abs(float64(h[i+w]-h[i])) / cellM; s > worst {
|
|
worst = s
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return worst
|
|
}
|