Added: Initial world generation tool
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// 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))
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/uplift"
|
||||
)
|
||||
|
||||
// TestBorderIsAlwaysOcean is the boundary condition the whole solve rests on, asserted rather than assumed.
|
||||
//
|
||||
// A cell on the map border is an outlet: `fluvial.isOutlet` returns true for it whatever else is true, so it
|
||||
// takes no uplift and is never eroded, and the repose clamp will not lower it either. Land that reaches the
|
||||
// border therefore freezes at whatever the initial relief gave it while the interior erodes away beneath it,
|
||||
// and what that looks like is a rim of untouched terrain standing a hundred metres over its neighbour — it is
|
||||
// what once reported a 66 degree slope on a map whose angle of repose was 22. `continentMask` imposes a
|
||||
// margin to prevent it, and this test is what says the margin is still doing its job.
|
||||
//
|
||||
// It is worth a test rather than a comment because the margin is easy to break from a distance. The uplift
|
||||
// rate used to be multiplied by the continent mask, so the margin's taper was suppressing uplift at the border
|
||||
// as a side effect; decoupling the two (D-52) removed that, and nothing in the change was anywhere near this
|
||||
// file. The invariant is the border cells themselves, so that is what is asserted; the distances and rates are
|
||||
// reported alongside because they are what a person would want to see when it does fail.
|
||||
func TestBorderIsAlwaysOcean(t *testing.T) {
|
||||
const size = 1400
|
||||
const sideM = 14280.0
|
||||
cell := sideM / (size - 1)
|
||||
|
||||
for _, seed := range []int64{7, 9342, 67914} {
|
||||
m := manifest.Defaults()
|
||||
m.Source.Seed = seed
|
||||
up := uplift.Build(size, cell, m)
|
||||
|
||||
for x := 0; x < size; x++ {
|
||||
for _, i := range [2]int{x, (size-1)*size + x} {
|
||||
if !up.Base[i] {
|
||||
t.Errorf("seed %d: cell (%d,%d) on the top or bottom border is land", seed, i%size, i/size)
|
||||
}
|
||||
}
|
||||
}
|
||||
for y := 0; y < size; y++ {
|
||||
for _, i := range [2]int{y * size, y*size + size - 1} {
|
||||
if !up.Base[i] {
|
||||
t.Errorf("seed %d: cell (%d,%d) on the left or right border is land", seed, i%size, i/size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// How hard the margin is pressing. The margin does not only keep land off the border; where the
|
||||
// continent would have run past the edge it *cuts* it, and it cuts along a contour of distance-to-edge,
|
||||
// which is a straight line parallel to that edge. So a coastline inside the margin band is a coastline
|
||||
// the margin drew rather than one the noise did, and the count is the measure of how much of one there
|
||||
// is. It is logged, not asserted: some is unavoidable and the right threshold is a judgement.
|
||||
band := int(0.04 * size)
|
||||
nearest := size
|
||||
var peakRate, peakHeight float64
|
||||
landInBand, shoreInBand, shore := 0, 0, 0
|
||||
for i := range up.Base {
|
||||
x, y := i%size, i/size
|
||||
d := min(min(x, size-1-x), min(y, size-1-y))
|
||||
if up.Base[i] {
|
||||
// A waterline cell: ocean with land in the four-neighbourhood.
|
||||
for _, n := range [4]int{i - 1, i + 1, i - size, i + size} {
|
||||
if n >= 0 && n < len(up.Base) && !up.Base[n] {
|
||||
shore++
|
||||
if d < band {
|
||||
shoreInBand++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if d < nearest {
|
||||
nearest = d
|
||||
}
|
||||
if d < band {
|
||||
landInBand++
|
||||
if r := float64(up.Rate.Data[i]) * 1000; r > peakRate {
|
||||
peakRate = r
|
||||
}
|
||||
if h := float64(up.Height.Data[i]); h > peakHeight {
|
||||
peakHeight = h
|
||||
}
|
||||
}
|
||||
}
|
||||
pct := 0.0
|
||||
if shore > 0 {
|
||||
pct = float64(shoreInBand) / float64(shore) * 100
|
||||
}
|
||||
t.Logf("seed %6d: nearest land %d cells (%.0f m) from the border; inside the %d-cell margin, "+
|
||||
"%d land cells, peak uplift %.2f mm/yr, peak initial relief %.0f m; %.1f%% of the shoreline is "+
|
||||
"inside the margin band (a coast the margin drew, not the noise)",
|
||||
seed, nearest, float64(nearest)*cell, band, landInBand, peakRate, peakHeight, pct)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/fluvial"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/thermal"
|
||||
"salty/terrain/internal/uplift"
|
||||
)
|
||||
|
||||
// TestTalusHoldsInThePipeline is the gap between a unit test and a system. thermal.Apply demonstrably cuts a
|
||||
// cone to the angle of repose, and yet a full run with the angle set to 22 degrees reports a median land slope
|
||||
// of 37. One of those two things is wrong and the difference matters: the angle of repose is the only direct
|
||||
// lever on how steep hill country looks.
|
||||
func TestTalusHoldsInThePipeline(t *testing.T) {
|
||||
const (
|
||||
size = 300
|
||||
deg = 22.0
|
||||
steps = 300
|
||||
)
|
||||
passesUnderTest := 3
|
||||
if v := os.Getenv("TALUS_PASSES"); v != "" {
|
||||
passesUnderTest, _ = strconv.Atoi(v)
|
||||
}
|
||||
m := manifest.Defaults()
|
||||
m.Source.Seed = 7
|
||||
cellM := m.SideM() / float64(size-1)
|
||||
up := uplift.Build(size, cellM, m)
|
||||
h := up.Height.Clone()
|
||||
|
||||
g := fluvial.NewGrid(size, size, cellM, up.Base)
|
||||
g.SetElevationRange(-2000, 4000)
|
||||
talus := thermal.TalusFromDegrees(deg)
|
||||
g.Run(h.Data, up.Rate.Data, up.K.Data, fluvial.Params{
|
||||
K: m.Pipeline.Fluvial.K, M: 0.5, N: 1, DtYr: 1500, Steps: steps,
|
||||
Diffusion: 0.02, FillEvery: 1,
|
||||
TalusSlope: talus, ThermalEvery: 1, ThermalPasses: passesUnderTest,
|
||||
}, nil)
|
||||
|
||||
if os.Getenv("CLAMP_AFTER") != "" {
|
||||
t.Logf("removed %.2f m mean by a final clamp", g.ClampToRepose(h.Data, talus))
|
||||
}
|
||||
|
||||
// The steepest neighbour drop anywhere on land, in the same units the angle of repose is written in.
|
||||
worst, count := 0.0, 0
|
||||
wx, wy := 0, 0
|
||||
worstOther := float32(0)
|
||||
worstOtherBase := false
|
||||
for y := 1; y < size-1; y++ {
|
||||
for x := 1; x < size-1; x++ {
|
||||
i := y*size + x
|
||||
if up.Base[i] {
|
||||
continue
|
||||
}
|
||||
for _, d := range [][2]int{{1, 0}, {0, 1}, {1, 1}, {1, -1}} {
|
||||
ni := (y+d[1])*size + (x + d[0])
|
||||
if up.Base[ni] {
|
||||
continue
|
||||
}
|
||||
dist := cellM
|
||||
if d[0] != 0 && d[1] != 0 {
|
||||
dist = cellM * math.Sqrt2
|
||||
}
|
||||
if s := math.Abs(float64(h.Data[i]-h.Data[ni])) / dist; s > worst {
|
||||
worst = s
|
||||
wx, wy = x, y
|
||||
worstOther = h.Data[ni]
|
||||
worstOtherBase = up.Base[ni]
|
||||
}
|
||||
}
|
||||
if count++; false {
|
||||
_ = count
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("worst pair at (%d,%d): h=%.1f vs h=%.1f, base=%v/%v", wx, wy, h.Data[wy*size+wx], worstOther, up.Base[wy*size+wx], worstOtherBase)
|
||||
gotDeg := math.Atan(worst) * 180 / math.Pi
|
||||
t.Logf("steepest land slope after %d steps: %.1f deg (repose %.1f, cell %.0f m)", steps, gotDeg, deg, cellM)
|
||||
if gotDeg > deg+5 {
|
||||
t.Errorf("steepest land slope is %.1f deg with the angle of repose at %.1f: landsliding is not binding", gotDeg, deg)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user