Files
UnrealPrototyping/Tools/Terrain/internal/fluvial/hillslope_test.go
T

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
}