Tooling
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/fluvial"
|
||||
"salty/terrain/internal/uplift"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// The claim the whole fault feature rests on, end to end: a difference in uplift rate across a line survives
|
||||
// the solve as an escarpment, on the side the fault raises.
|
||||
//
|
||||
// It is here rather than in internal/uplift because everything up there tests the *rate* field - that it is
|
||||
// asymmetric, that two frames agree about it, that it tapers at the tips - and none of that says the solve
|
||||
// leaves anything behind. A fault is applied as a rate precisely so that erosion cannot remove it, and
|
||||
// "erosion cannot remove it" is a statement about a thousand steps of stream power, not about a weight
|
||||
// function. Measured on the real planet it comes out at 2.7 to 50 m of scarp for throws of 139 to 399 m, all
|
||||
// five facing the right way; this is that in miniature and fast enough to run every time.
|
||||
func TestAFaultLeavesAScarpAfterTheSolve(t *testing.T) {
|
||||
const w, h = 400, 400
|
||||
const cellM = 8.0
|
||||
const steps = 400
|
||||
const dtYr = 1500.0
|
||||
const runYears = steps * dtYr
|
||||
|
||||
p := world.Planet{CellM: cellM, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * cellM}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := world.Whole(p)
|
||||
|
||||
// One straight east-west trace across the middle of the grid. Straight on purpose: the question is what
|
||||
// the solve does to the step, and a curve would only make the measurement harder to read.
|
||||
midM := float64(h) * cellM / 2
|
||||
pts := make([][2]float64, 17)
|
||||
for i := range pts {
|
||||
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 16, midM}
|
||||
}
|
||||
trace := uplift.FaultTrace{PointsM: pts, ThrowM: 300, LengthM: float64(w) * cellM}
|
||||
delta := uplift.FaultDelta(f, []uplift.FaultTrace{trace}, runYears)
|
||||
if delta == nil {
|
||||
t.Fatal("the trace reached nothing")
|
||||
}
|
||||
|
||||
// A quiet landscape to put it in: the sea along the left edge as base level, and a low uniform rate
|
||||
// everywhere else so that anything standing up is the fault's doing and not the background's.
|
||||
base := make([]bool, w*h)
|
||||
rate := make([]float32, w*h)
|
||||
height := make([]float32, w*h)
|
||||
const backgroundMYr = 4.5e-5 // 0.045 mm/yr, the shipped highland foreland
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
if x < 12 {
|
||||
base[i] = true
|
||||
continue
|
||||
}
|
||||
r := backgroundMYr + float64(delta[i])
|
||||
if r < 0 {
|
||||
r = 0
|
||||
}
|
||||
rate[i] = float32(r)
|
||||
height[i] = float32(20 + 4*math.Sin(float64(x)/23)*math.Cos(float64(y)/31))
|
||||
}
|
||||
}
|
||||
|
||||
g := fluvial.NewGrid(w, h, cellM, base)
|
||||
g.SetElevationRange(-2000, 4000)
|
||||
g.Run(height, rate, nil, fluvial.Params{
|
||||
K: 5e-5, M: 0.5, N: 1, DtYr: dtYr, Steps: steps, Diffusion: 0.02, FillEvery: 1,
|
||||
TalusSlope: math.Tan(35 * math.Pi / 180), ThermalEvery: 4, ThermalPasses: 24,
|
||||
CriticalSlope: math.Tan(35 * math.Pi / 180), SlopeCap: 0.9, MaxHillslopeSub: 24,
|
||||
}, nil)
|
||||
|
||||
// The trace runs east-west, so the two sides are north and south of it. nearestOnTrace signs a point by
|
||||
// the cross product, which for a west-to-east trace puts the *north* side at d > 0 - the steep, upthrown
|
||||
// side of a fault that is not reversed.
|
||||
const offCells = 75 // 600 m either side, the same offset the planet-scale measurement used
|
||||
midCell := h / 2
|
||||
mean := func(row int) float64 {
|
||||
sum, n := 0.0, 0
|
||||
for x := 40; x < w-40; x++ {
|
||||
sum += float64(height[row*w+x])
|
||||
n++
|
||||
}
|
||||
return sum / float64(n)
|
||||
}
|
||||
up := mean(midCell - offCells)
|
||||
down := mean(midCell + offCells)
|
||||
|
||||
if up <= down {
|
||||
t.Fatalf("no scarp: the upthrown side averages %.1f m and the downthrown side %.1f m", up, down)
|
||||
}
|
||||
// Big enough to be terrain rather than noise, and well under the throw, because erosion takes most of a
|
||||
// fault's displacement away - which is the whole reason a fault has to be applied as a rate and not as a
|
||||
// shape. The planet-scale measurement puts the survivor at a few per cent to a fifth of the throw.
|
||||
if step := up - down; step < 5 {
|
||||
t.Errorf("the scarp is only %.1f m across a 300 m throw; that is not an escarpment", step)
|
||||
} else if step > trace.ThrowM {
|
||||
t.Errorf("the scarp is %.1f m against a %.0f m throw; nothing should exceed its own displacement",
|
||||
step, trace.ThrowM)
|
||||
}
|
||||
|
||||
// And it is *at the fault*, not a general tilt of the map: the step across the trace has to be far
|
||||
// sharper than the same distance measured entirely on one side of it.
|
||||
across := up - down
|
||||
within := math.Abs(mean(midCell-offCells) - mean(midCell-2*offCells))
|
||||
if across <= within {
|
||||
t.Errorf("the step across the trace is %.1f m and a step of the same span on one side of it is "+
|
||||
"%.1f m; that is a tilted map, not a fault", across, within)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/fluvial"
|
||||
"salty/terrain/internal/manifest"
|
||||
"salty/terrain/internal/region"
|
||||
"salty/terrain/internal/template"
|
||||
"salty/terrain/internal/thermal"
|
||||
"salty/terrain/internal/uplift"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
const planetLegend = `{"classes":[
|
||||
{"name":"sea","rgb":[0,0,255],"sea":true,"depth_m":400},
|
||||
{"name":"plain","rgb":[150,200,100],"uplift_mm_yr":0.08,"k_mult":1.0},
|
||||
{"name":"range","rgb":[60,160,100],"uplift_mm_yr":0.9,"k_mult":0.6}
|
||||
]}`
|
||||
|
||||
// syntheticPlanet paints a small world with three landmasses, one of them across the seam, and returns it
|
||||
// classified and projected. It is the smallest thing that exercises everything a real bake does: a cylinder,
|
||||
// several regions, a seam, and two uplift classes.
|
||||
func syntheticPlanet(t *testing.T, seed int64) (*manifest.Manifest, *template.Map, *region.Partition) {
|
||||
t.Helper()
|
||||
lg, err := template.Parse([]byte(planetLegend))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const w, paintH, pad = 128, 64, 6
|
||||
p := world.Planet{CellM: 40, W: w, H: paintH + 2*pad, PadY: pad, NoisePeriodM: w * 40}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sea := uint8(lg.Index("sea"))
|
||||
plain := uint8(lg.Index("plain"))
|
||||
rng := uint8(lg.Index("range"))
|
||||
|
||||
m := &template.Map{P: p, L: lg, Class: make([]uint8, p.W*p.H), Sea: make([]bool, p.W*p.H)}
|
||||
for i := range m.Class {
|
||||
m.Class[i], m.Sea[i] = sea, true
|
||||
}
|
||||
put := func(x0, y0, w0, h0 int, c uint8) {
|
||||
for y := y0; y < y0+h0; y++ {
|
||||
for x := x0; x < x0+w0; x++ {
|
||||
i := (y+pad)*p.W + p.WrapX(x)
|
||||
m.Class[i], m.Sea[i] = c, false
|
||||
}
|
||||
}
|
||||
}
|
||||
put(20, 10, 30, 24, plain) // a plain
|
||||
put(30, 16, 12, 10, rng) // with a range in it
|
||||
put(70, 30, 22, 20, rng) // a mountainous island
|
||||
put(-4, 44, 10, 12, plain) // and one across the seam
|
||||
|
||||
part, err := region.Build(m, 4, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(part.Regions) < 3 {
|
||||
t.Fatalf("got %d regions, want at least 3", len(part.Regions))
|
||||
}
|
||||
seam := false
|
||||
for _, r := range part.Regions {
|
||||
seam = seam || r.Seam
|
||||
}
|
||||
if !seam {
|
||||
t.Fatal("no region straddles the seam; the test is not testing what it claims")
|
||||
}
|
||||
|
||||
man := manifest.Defaults()
|
||||
man.Source.Seed = seed
|
||||
man.Planet = &manifest.Planet{UpliftVariation: 0.3}
|
||||
return man, m, part
|
||||
}
|
||||
|
||||
// solvePlanet runs the whole painted path: cut each region, build its painted geology, solve it, composite
|
||||
// the land back. It is deliberately the same sequence internal/planet uses.
|
||||
func solvePlanet(t *testing.T, seed int64, steps int) []float32 {
|
||||
t.Helper()
|
||||
man, m, part := syntheticPlanet(t, seed)
|
||||
rates, ks := m.L.Rates(), m.L.Erodibilities()
|
||||
out := make([]float32, m.P.W*m.P.H)
|
||||
|
||||
params := fluvial.Params{
|
||||
K: 5e-5, M: 0.5, N: 1, DtYr: 1500, Steps: steps, Diffusion: 0.02, FillEvery: 1,
|
||||
TalusSlope: thermal.TalusFromDegrees(35), ThermalEvery: 4, ThermalPasses: 2,
|
||||
CriticalSlope: thermal.TalusFromDegrees(35), SlopeCap: 0.9, MaxHillslopeSub: 24,
|
||||
}
|
||||
for _, rg := range part.Regions {
|
||||
class, land := part.Cut(m, rg)
|
||||
up := uplift.FromTemplate(uplift.Paint{
|
||||
Frame: rg.Frame, Class: class, Land: land,
|
||||
Rates: rates, Ks: ks, Variation: man.Planet.UpliftVariation,
|
||||
}, man)
|
||||
h := up.Height.Clone()
|
||||
g := fluvial.NewGrid(rg.Frame.W, rg.Frame.H, rg.Frame.P.CellM, up.Base)
|
||||
g.SetSeed(man.Source.Seed)
|
||||
g.SetFrame(rg.Frame)
|
||||
g.SetElevationRange(-2000, 4000)
|
||||
g.Run(h.Data, up.Rate.Data, up.K.Data, params, nil)
|
||||
part.Composite(out, m, rg, h.Data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The painted path's half of cross-cutting rule 12. The square canvas already has this assertion; a planet
|
||||
// adds three ways to break it that the square canvas cannot reach - the classifier's parallel reduction, the
|
||||
// region flood, and regions solved several at a time - so it gets its own.
|
||||
func TestPaintedPlanetIsDeterministicAcrossGOMAXPROCS(t *testing.T) {
|
||||
was := runtime.GOMAXPROCS(1)
|
||||
defer runtime.GOMAXPROCS(was)
|
||||
|
||||
var want string
|
||||
for _, procs := range []int{1, 2, 4, 8, 16} {
|
||||
runtime.GOMAXPROCS(procs)
|
||||
got := hash(solvePlanet(t, 7, 60))
|
||||
if want == "" {
|
||||
want = got
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("GOMAXPROCS %d gives %s, GOMAXPROCS 1 gives %s", procs, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameSeedSamePlanet(t *testing.T) {
|
||||
a := hash(solvePlanet(t, 11, 40))
|
||||
b := hash(solvePlanet(t, 11, 40))
|
||||
if a != b {
|
||||
t.Fatalf("two runs of the same seed differ: %s and %s", a, b)
|
||||
}
|
||||
if c := hash(solvePlanet(t, 12, 40)); c == a {
|
||||
t.Fatal("two different seeds give the same planet")
|
||||
}
|
||||
}
|
||||
|
||||
// The invariant the whole per-landmass decomposition rests on, asserted directly.
|
||||
//
|
||||
// Solving a landmass in a box of its own is only the same answer as solving the planet whole because ocean
|
||||
// cells are held fixed at sea level and nothing in the solve can move them: ComputeReceivers makes every
|
||||
// outlet its own receiver, so no flow path crosses water, and StreamPower, both diffusions, the repose clamp
|
||||
// and thermal all skip a fixed cell. If that ever stopped being true, regions would start lying to each
|
||||
// other and nothing else in the suite would say so.
|
||||
func TestOceanCellsAreUntouchedByTheSolve(t *testing.T) {
|
||||
man, m, part := syntheticPlanet(t, 7)
|
||||
rates, ks := m.L.Rates(), m.L.Erodibilities()
|
||||
params := fluvial.Params{
|
||||
K: 5e-5, M: 0.5, N: 1, DtYr: 1500, Steps: 80, Diffusion: 0.02, FillEvery: 1,
|
||||
TalusSlope: thermal.TalusFromDegrees(35), ThermalEvery: 4, ThermalPasses: 2,
|
||||
CriticalSlope: thermal.TalusFromDegrees(35), SlopeCap: 0.9, MaxHillslopeSub: 24,
|
||||
}
|
||||
checked := 0
|
||||
for _, rg := range part.Regions {
|
||||
class, land := part.Cut(m, rg)
|
||||
up := uplift.FromTemplate(uplift.Paint{
|
||||
Frame: rg.Frame, Class: class, Land: land,
|
||||
Rates: rates, Ks: ks, Variation: 0.3,
|
||||
}, man)
|
||||
h := up.Height.Clone()
|
||||
g := fluvial.NewGrid(rg.Frame.W, rg.Frame.H, rg.Frame.P.CellM, up.Base)
|
||||
g.SetSeed(man.Source.Seed)
|
||||
g.SetFrame(rg.Frame)
|
||||
g.SetElevationRange(-2000, 4000)
|
||||
g.Run(h.Data, up.Rate.Data, up.K.Data, params, nil)
|
||||
|
||||
for i, isBase := range up.Base {
|
||||
if !isBase {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
if h.Data[i] != float32(man.SeaLevelM) {
|
||||
t.Fatalf("region %d: ocean cell %d came out at %g m, not sea level. The composite writes "+
|
||||
"only land for exactly this reason, and it is now unsafe", rg.ID, i, h.Data[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("no ocean cells were checked")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user