This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
@@ -0,0 +1,248 @@
package stats
import (
"math"
"math/rand/v2"
"sort"
"testing"
"salty/terrain/internal/field"
)
// A small world with real structure in it: a coast, a range, a plain, and a sea the statistics have to leave
// out. Deterministic, so both halves of every comparison see the same ground.
func testWorld(t *testing.T, w, h int, cellM float64) (*field.Field, []bool, []float32) {
t.Helper()
r := rand.New(rand.NewPCG(11, 13))
f := field.New(w, h, cellM)
land := make([]bool, w*h)
up := make([]float32, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if x < w/8 {
f.Data[i] = -40 // the sea, which must not appear in any land statistic
continue
}
land[i] = true
t := float64(x) / float64(w)
// A range towards the east, a plain in the middle, and enough noise to give the slopes a spread.
// The 40 m base keeps every land cell above sea level, so "the land minimum is positive" is a
// statement about the mask rather than about this formula.
f.Data[i] = float32(40 + 300*t*t + 18*math.Sin(float64(x)/9)*math.Cos(float64(y)/7) +
r.NormFloat64()*3)
up[i] = float32((0.02 + 0.9*t*t*t) / 1000)
}
}
return f, land, up
}
func testOptions() Options {
return Options{ElevMin: -1024, ElevMax: 2048, TalusDeg: 35, ReliefWindowM: 500,
ChannelM2: 1e6, K: 5e-5, M: 0.5, N: 1}
}
// The histograms replaced sorts, and the whole point is that nothing a run is judged by moved. This is the
// same data through both: the old implementation is reproduced here as the reference, so that a future change
// to the fast path has something to be wrong against.
func TestTheHistogramsAgreeWithSorting(t *testing.T) {
const w, h, cellM = 220, 160, 8.0
f, land, up := testWorld(t, w, h, cellM)
acc := New(testOptions())
acc.Add(Input{H: f, Land: land, UpliftMYr: up})
acc.AddExtent(f.Data, land, 0)
got := acc.Report(cellM)
// --- the reference, by sorting, exactly as the package used to do it -----------------------------
slope := f.Slope()
var degs, elevs []float64
for i := range f.Data {
if !land[i] {
continue
}
degs = append(degs, math.Atan(float64(slope.Data[i]))*180/math.Pi)
elevs = append(elevs, float64(f.Data[i]))
}
sort.Float64s(degs)
sort.Float64s(elevs)
frac := func(v []float64, limit float64) float64 {
return float64(sort.SearchFloat64s(v, limit)) / float64(len(v))
}
const slopeTol = 90.0 / slopeBins // one bin: the whole error budget of a histogram quantile
if d := math.Abs(got.Slopes.MedianDeg - degs[len(degs)/2]); d > slopeTol {
t.Errorf("median slope %.4f against %.4f", got.Slopes.MedianDeg, degs[len(degs)/2])
}
for _, c := range []struct {
name string
got float64
want float64
}{
{"under 15", got.Slopes.Under15Deg, frac(degs, 15)},
{"under 30", got.Slopes.Under30Deg, frac(degs, 30)},
{"over 50", got.Slopes.Over50Deg, 1 - frac(degs, 50)},
} {
if math.Abs(c.got-c.want) > 0.002 {
t.Errorf("slopes %s: %.4f against %.4f", c.name, c.got, c.want)
}
}
// The hypsometric integral is a mean and is carried exactly, so it has to match to the bit of a float sum.
lo, hi := elevs[0], elevs[len(elevs)-1]
var sum float64
for _, v := range elevs {
sum += (v - lo) / (hi - lo)
}
if d := math.Abs(got.Hypsometry.Integral - sum/float64(len(elevs))); d > 1e-9 {
t.Errorf("hypsometric integral %.6f against %.6f", got.Hypsometry.Integral, sum/float64(len(elevs)))
}
if got.LandMinM != lo || got.LandMaxM != hi {
t.Errorf("land range %.3f..%.3f against %.3f..%.3f", got.LandMinM, got.LandMaxM, lo, hi)
}
// And the per-class breakdown, which is the block that matters most.
for _, b := range got.Buckets {
var bdeg []float64
for i := range f.Data {
if !land[i] {
continue
}
mm := float64(up[i]) * 1000
if mm < b.LoMmYr || mm >= b.HiMmYr {
continue
}
bdeg = append(bdeg, math.Atan(float64(slope.Data[i]))*180/math.Pi)
}
if len(bdeg) != b.Cells {
t.Errorf("bucket %s holds %d cells, the reference found %d", b.Name, b.Cells, len(bdeg))
}
sort.Float64s(bdeg)
if d := math.Abs(b.MedianDeg - bdeg[len(bdeg)/2]); d > slopeTol {
t.Errorf("bucket %s median %.4f against %.4f", b.Name, b.MedianDeg, bdeg[len(bdeg)/2])
}
p90 := bdeg[min(len(bdeg)*9/10, len(bdeg)-1)]
if d := math.Abs(b.P90Deg - p90); d > slopeTol {
t.Errorf("bucket %s P90 %.4f against %.4f", b.Name, b.P90Deg, p90)
}
}
if len(got.Buckets) < 2 {
t.Fatalf("only %d buckets came out; this test measured almost nothing", len(got.Buckets))
}
}
// The property the planet depends on: a world cut into pieces and accumulated piece by piece has to report
// what one pass over the whole thing would. Everything here is additive by construction, and this is the
// assertion that says so end to end rather than one histogram at a time.
func TestPoolingPiecesMatchesOnePass(t *testing.T) {
const w, h, cellM = 240, 120, 8.0
f, land, up := testWorld(t, w, h, cellM)
whole := New(testOptions())
whole.Add(Input{H: f, Land: land, UpliftMYr: up})
whole.AddExtent(f.Data, land, 0)
// The same ground in three horizontal strips. Slope and relief read neighbours, so a strip's own edge
// rows differ from the whole - which is exactly the seam a region has, and the reason the comparison
// below is on the *distributions* rather than cell by cell.
pooled := New(testOptions())
for _, band := range [][2]int{{0, 40}, {40, 80}, {80, 120}} {
sub := field.New(w, band[1]-band[0], cellM)
subLand := make([]bool, w*(band[1]-band[0]))
subUp := make([]float32, len(subLand))
copy(sub.Data, f.Data[band[0]*w:band[1]*w])
copy(subLand, land[band[0]*w:band[1]*w])
copy(subUp, up[band[0]*w:band[1]*w])
pooled.Add(Input{H: sub, Land: subLand, UpliftMYr: subUp})
pooled.AddExtent(sub.Data, subLand, 0)
}
a, b := whole.Report(cellM), pooled.Report(cellM)
if a.LandFraction != b.LandFraction {
t.Errorf("land fraction %.6f pooled against %.6f whole", b.LandFraction, a.LandFraction)
}
if a.LandMinM != b.LandMinM || a.LandMaxM != b.LandMaxM {
t.Errorf("land range %.3f..%.3f pooled against %.3f..%.3f",
b.LandMinM, b.LandMaxM, a.LandMinM, a.LandMaxM)
}
// Elevation does not read neighbours at all, so it has to pool to the bit.
if math.Abs(a.Hypsometry.Integral-b.Hypsometry.Integral) > 1e-12 {
t.Errorf("hypsometric integral %.9f pooled against %.9f", b.Hypsometry.Integral, a.Hypsometry.Integral)
}
// Slope reads one cell either side, so six rows of a 120-row world are clamped differently. The
// distribution has to survive that; a tenth of a degree is far inside anything Summary turns on.
if d := math.Abs(a.Slopes.MedianDeg - b.Slopes.MedianDeg); d > 0.1 {
t.Errorf("median slope %.3f pooled against %.3f", b.Slopes.MedianDeg, a.Slopes.MedianDeg)
}
for i := range a.Buckets {
if i >= len(b.Buckets) {
t.Fatalf("pooling lost a bucket: %d against %d", len(b.Buckets), len(a.Buckets))
}
if a.Buckets[i].Cells != b.Buckets[i].Cells {
t.Errorf("bucket %s: %d cells pooled against %d", a.Buckets[i].Name,
b.Buckets[i].Cells, a.Buckets[i].Cells)
}
if d := math.Abs(a.Buckets[i].MedianDeg - b.Buckets[i].MedianDeg); d > 0.2 {
t.Errorf("bucket %s median %.3f pooled against %.3f", a.Buckets[i].Name,
b.Buckets[i].MedianDeg, a.Buckets[i].MedianDeg)
}
}
}
// Merge is the other way pieces arrive - a planet's regions are accumulated separately and folded together -
// and it has to be the same as adding them to one accumulator.
func TestMergeMatchesAddingToOne(t *testing.T) {
const w, h, cellM = 160, 60, 8.0
f, land, up := testWorld(t, w, h, cellM)
one := New(testOptions())
one.Add(Input{H: f, Land: land, UpliftMYr: up})
one.AddExtent(f.Data, land, 0)
one.Add(Input{H: f, Land: land, UpliftMYr: up})
one.AddExtent(f.Data, land, 0)
a, b := New(testOptions()), New(testOptions())
a.Add(Input{H: f, Land: land, UpliftMYr: up})
a.AddExtent(f.Data, land, 0)
b.Add(Input{H: f, Land: land, UpliftMYr: up})
b.AddExtent(f.Data, land, 0)
a.Merge(b)
x, y := one.Report(cellM), a.Report(cellM)
if x.LandFraction != y.LandFraction || x.Slopes.MedianDeg != y.Slopes.MedianDeg ||
x.LandMinM != y.LandMinM || x.LandMaxM != y.LandMaxM {
t.Errorf("merged report differs from one built by adding twice:\n %+v\n %+v", x.Slopes, y.Slopes)
}
for i := range x.Buckets {
if x.Buckets[i].Cells != y.Buckets[i].Cells || x.Buckets[i].MedianDeg != y.Buckets[i].MedianDeg {
t.Errorf("bucket %s differs after a merge", x.Buckets[i].Name)
}
}
}
// Sea cells are counted for the land fraction and for the encoding range, and are in nothing else. A single
// -40 m sea floor in a land statistic would flatter every relief number by forty metres for free.
func TestTheSeaIsNotLand(t *testing.T) {
const w, h, cellM = 120, 80, 8.0
f, land, up := testWorld(t, w, h, cellM)
acc := New(testOptions())
acc.Add(Input{H: f, Land: land, UpliftMYr: up})
acc.AddExtent(f.Data, land, 0)
r := acc.Report(cellM)
if r.LandMinM < 0 {
t.Errorf("land minimum is %.1f m; the sea got into the land statistics", r.LandMinM)
}
if r.MinM > -39 {
t.Errorf("whole-field minimum is %.1f m; the sea should still bound the encoding range", r.MinM)
}
wantLand := 0
for _, v := range land {
if v {
wantLand++
}
}
if got := int(r.LandFraction*float64(w*h) + 0.5); got != wantLand {
t.Errorf("land fraction says %d cells, the mask has %d", got, wantLand)
}
}