139 lines
5.0 KiB
Go
139 lines
5.0 KiB
Go
// Package check holds the generator's integration tests: the ones that need more than one package and so
|
|
// cannot live in either. There are two, and they are the two that matter.
|
|
//
|
|
// Determinism is a promise the project makes (cross-cutting rule 12) and Go is the language most likely to
|
|
// break it quietly, so it is asserted rather than assumed. Steady state is the physics: if the solver does
|
|
// not reproduce the analytic stream-power answer on a case with a known answer, every prettier result it
|
|
// produces is a coincidence.
|
|
package check
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"math"
|
|
"runtime"
|
|
"sort"
|
|
"testing"
|
|
"unsafe"
|
|
|
|
"salty/terrain/internal/coast"
|
|
"salty/terrain/internal/fluvial"
|
|
"salty/terrain/internal/manifest"
|
|
"salty/terrain/internal/uplift"
|
|
)
|
|
|
|
func hash(v []float32) string {
|
|
b := unsafe.Slice((*byte)(unsafe.Pointer(&v[0])), len(v)*4)
|
|
sum := sha256.Sum256(b)
|
|
return hex.EncodeToString(sum[:8])
|
|
}
|
|
|
|
// run is one whole small pipeline: uplift, the solve, then the coast. All three are in it because all three
|
|
// are parallel, and the coast pass in particular has two places determinism could leak — the fetch rays run
|
|
// over a slice of the waterline, and the sediment scatter accumulates several land cells into one shore cell,
|
|
// which is why that scatter is deliberately serial.
|
|
func run(size, steps int) []float32 {
|
|
m := manifest.Defaults()
|
|
m.Source.Seed = 7
|
|
up := uplift.Build(size, 40, m)
|
|
h := up.Height.Clone()
|
|
g := fluvial.NewGrid(size, size, 40, up.Base)
|
|
g.SetElevationRange(-2000, 4000)
|
|
g.Run(h.Data, up.Rate.Data, nil, fluvial.Params{
|
|
K: 5e-5, M: 0.5, N: 1, DtYr: 1500, Steps: steps, Diffusion: 0.02, FillEvery: 1,
|
|
}, nil)
|
|
coast.Build(coast.Input{
|
|
Height: h, Sea: up.Base, SeaLevelM: m.SeaLevelM,
|
|
BreakM: -m.Pipeline.Continent.SeaFloorM.Hi(), AbyssM: -m.Pipeline.Continent.SeaFloorM.Lo(),
|
|
Flow: g.Area, Seed: m.Source.Seed, Cfg: m.Pipeline.Coast,
|
|
})
|
|
return h.Data
|
|
}
|
|
|
|
// TestDeterministicAcrossGOMAXPROCS is the assertion Docs/Terrain.md makes about the Go core: the result must
|
|
// be byte-identical however many cores it ran on. Go offers three good ways to break this - randomised map
|
|
// iteration, goroutine completion order, and a shared global RNG - so it is worth a test rather than a
|
|
// comment.
|
|
func TestDeterministicAcrossGOMAXPROCS(t *testing.T) {
|
|
was := runtime.GOMAXPROCS(1)
|
|
defer runtime.GOMAXPROCS(was)
|
|
|
|
single := run(128, 40)
|
|
hSingle := hash(single)
|
|
|
|
for _, procs := range []int{2, 4, 8, 16} {
|
|
if procs > runtime.NumCPU()*2 {
|
|
continue
|
|
}
|
|
runtime.GOMAXPROCS(procs)
|
|
got := hash(run(128, 40))
|
|
if got != hSingle {
|
|
t.Fatalf("GOMAXPROCS=%d gave %s, GOMAXPROCS=1 gave %s: the pipeline is not deterministic", procs, got, hSingle)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSameSeedSameResult guards the other half of rule 12: a seed names a world.
|
|
func TestSameSeedSameResult(t *testing.T) {
|
|
if a, b := hash(run(96, 20)), hash(run(96, 20)); a != b {
|
|
t.Fatalf("two runs of one seed differ: %s vs %s", a, b)
|
|
}
|
|
}
|
|
|
|
// TestSteadyStateMatchesStreamPower is the physics check. On a uniform uplift field with uniform erodibility,
|
|
// the analytic steady state of dh/dt = U - K*A^m*S^n is S = (U/K)^(1/n) * A^(-m/n), so K*A^m*S^n / U must be
|
|
// 1 at every channel cell. Anything systematically off means the implicit update, the drainage accumulation
|
|
// or the stack order is wrong, and no amount of tuning would fix it.
|
|
func TestSteadyStateMatchesStreamPower(t *testing.T) {
|
|
const (
|
|
size = 160
|
|
cellM = 50.0
|
|
k = 1e-4
|
|
mExp = 0.5
|
|
nExp = 1.0
|
|
u = 1e-3 // m/yr
|
|
)
|
|
base := make([]bool, size*size) // no ocean: the borders are the outlets
|
|
h := make([]float32, size*size)
|
|
rate := make([]float32, size*size)
|
|
// A little noise so the flow network has something to organise; the answer must not depend on it.
|
|
seed := uint32(12345)
|
|
for i := range h {
|
|
seed = seed*1664525 + 1013904223
|
|
h[i] = float32(seed>>8&0xffff) / 65535 * 5
|
|
rate[i] = u
|
|
}
|
|
|
|
g := fluvial.NewGrid(size, size, cellM, base)
|
|
g.SetElevationRange(-100, 5000)
|
|
g.Run(h, rate, nil, fluvial.Params{
|
|
K: k, M: mExp, N: nExp, DtYr: 2000, Steps: 4000, Diffusion: 0, FillEvery: 1,
|
|
}, nil)
|
|
|
|
// Only well-developed channels: headwaters are hillslopes, where stream power is not the whole story
|
|
// and where the discrete D8 grid quantises slope badly.
|
|
var ratios []float64
|
|
threshold := 200 * cellM * cellM
|
|
for i := range h {
|
|
r := g.Receiver[i]
|
|
if int(r) == i || g.Base[i] || float64(g.Area[i]) < threshold {
|
|
continue
|
|
}
|
|
s := float64(h[i]-h[r]) / float64(g.Length[i])
|
|
if s <= 0 {
|
|
continue
|
|
}
|
|
ratios = append(ratios, k*math.Pow(float64(g.Area[i]), mExp)*math.Pow(s, nExp)/u)
|
|
}
|
|
if len(ratios) < 100 {
|
|
t.Fatalf("only %d channel cells; the solve did not organise a network", len(ratios))
|
|
}
|
|
sort.Float64s(ratios)
|
|
median := ratios[len(ratios)/2]
|
|
if math.Abs(median-1) > 0.1 {
|
|
t.Errorf("median K*A^m*S^n/U = %.4f over %d channel cells, want 1.0 +/- 0.1: "+
|
|
"the solve is not reaching the analytic steady state", median, len(ratios))
|
|
}
|
|
t.Logf("steady state check: median ratio %.4f over %d channel cells", median, len(ratios))
|
|
}
|