Tooling
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// Gathering a world's statistics one piece at a time.
|
||||
//
|
||||
// The geology is solved one landmass at a time (D-53) and a planet's regions never exist together, so a
|
||||
// planet-wide statistic has to be assembled rather than computed. Terrain.md's rule for that is "statistics
|
||||
// pool across regions rather than being computed per region and averaged", and until now it was a rule with
|
||||
// no implementation: the whole package took a grid and sorted it, so a planet bake printed its elevation
|
||||
// range and nothing else - no slope distribution, no per-uplift-class breakdown, no drainage density. The
|
||||
// block the documentation calls the one that matters most was the one that could not be afforded.
|
||||
//
|
||||
// An Accumulator is what makes the rule true. Every quantity in it is either a counter, an exact running
|
||||
// extreme, or a Histogram, and all three are **additive**: merging two regions and reading the result gives
|
||||
// exactly what one pass over both would have. See histogram.go for why that is the whole design and not an
|
||||
// implementation detail.
|
||||
//
|
||||
// Add takes a grid. It does not care whether that grid is one region of a planet or the whole square canvas,
|
||||
// which is the other half of the point: `generate` and `bake` now compute their statistics with the same
|
||||
// code, so a number measured on one is comparable with the same number measured on the other.
|
||||
|
||||
// Options are the constants a world is judged against. They have to be the same for every region of a planet,
|
||||
// which is why they live on the accumulator rather than being passed to each Add.
|
||||
type Options struct {
|
||||
// ElevMin and ElevMax bound the elevation histogram, and they are the manifest's encoding range on
|
||||
// purpose rather than the data's own extremes. A histogram's bounds have to be known before the first
|
||||
// value arrives, or two regions would bin against different scales and could not be merged - and the
|
||||
// encoding range is the one bound that is a property of the world rather than of whatever happens to be
|
||||
// in front of it. Anything outside is counted as out of range, which is also what the clip fraction is
|
||||
// about.
|
||||
ElevMin, ElevMax float64
|
||||
|
||||
TalusDeg float64 // the angle of repose, for the "pinned against the clamp" share
|
||||
ReliefWindowM float64 // the side of the square local relief is taken over
|
||||
ChannelM2 float64 // drainage area at which a cell counts as a channel
|
||||
K, M, N float64 // the stream-power constants, for the slope-area normalisation
|
||||
}
|
||||
|
||||
// slopeBins and elevBins are the resolutions. A twentieth of a degree and a metre or two of elevation are far
|
||||
// finer than any verdict in Summary turns on, and the whole structure is a few tens of kilobytes either way.
|
||||
const (
|
||||
slopeBins = 2048
|
||||
elevBins = 4096
|
||||
logSABins = 1024
|
||||
)
|
||||
|
||||
// bucketAcc is one uplift class's share of the accumulator.
|
||||
type bucketAcc struct {
|
||||
slope, relief, elev *Histogram
|
||||
near, total int64
|
||||
}
|
||||
|
||||
// saBin is one decade-fraction of drainage area in the slope-area plot.
|
||||
type saBin struct{ norm, raw *Histogram }
|
||||
|
||||
// Accumulator gathers one world's statistics, a grid at a time.
|
||||
type Accumulator struct {
|
||||
opt Options
|
||||
|
||||
Cells, Land, Clip int64
|
||||
MinM, MaxM float64 // the whole field, sea floor included: what the 16-bit encoding has to hold
|
||||
|
||||
elev *Histogram // land only
|
||||
slope *Histogram // land only, degrees
|
||||
|
||||
buckets []bucketAcc
|
||||
sa map[int]*saBin
|
||||
|
||||
saChannels int64
|
||||
channelCells int64
|
||||
leafCells int64
|
||||
}
|
||||
|
||||
// New returns an empty accumulator.
|
||||
func New(opt Options) *Accumulator {
|
||||
if opt.ElevMax <= opt.ElevMin {
|
||||
opt.ElevMin, opt.ElevMax = -1024, 2048
|
||||
}
|
||||
a := &Accumulator{
|
||||
opt: opt,
|
||||
MinM: math.Inf(1), MaxM: math.Inf(-1),
|
||||
elev: NewHistogram(opt.ElevMin, opt.ElevMax, elevBins),
|
||||
slope: NewHistogram(0, 90, slopeBins),
|
||||
sa: map[int]*saBin{},
|
||||
}
|
||||
span := opt.ElevMax - opt.ElevMin
|
||||
a.buckets = make([]bucketAcc, len(bucketDefs))
|
||||
for i := range a.buckets {
|
||||
a.buckets[i] = bucketAcc{
|
||||
slope: NewHistogram(0, 90, slopeBins),
|
||||
relief: NewHistogram(0, span, elevBins),
|
||||
elev: NewHistogram(opt.ElevMin, opt.ElevMax, elevBins),
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Input is one grid and everything known about it. Everything but H and Land is optional; a caller with no
|
||||
// flow topology gets the statistics that do not need one.
|
||||
type Input struct {
|
||||
H *field.Field
|
||||
Land []bool // nil means every cell is land
|
||||
|
||||
// WrapX says whether this grid's left and right edges are the same meridian. A region of a planet is a
|
||||
// rectangle cut out of the cylinder with water all round it, so it does *not* wrap; the whole square
|
||||
// canvas does not either. It is here because the local relief window is the one thing that reads
|
||||
// neighbours, and being wrong about it would put a seam in one column of the relief map.
|
||||
WrapX bool
|
||||
|
||||
UpliftMYr []float32 // per cell; without it there is no per-class breakdown
|
||||
KLocal []float32 // the lithology multiplier, for the slope-area normalisation
|
||||
|
||||
// The flow topology, for slope-area and drainage density. All three or none.
|
||||
Area []float32
|
||||
Receiver []int32
|
||||
Length []float32
|
||||
}
|
||||
|
||||
// AddExtent records what a *finished* grid covers: how many cells, how many of them are land, how many fall
|
||||
// outside the encoding range, and the extremes over everything including the sea floor.
|
||||
//
|
||||
// It is separate from Add because on a planet the two are measured in different places, and measuring them in
|
||||
// the wrong one is silently wrong rather than obviously so. A region is a rectangle cut out of the cylinder
|
||||
// with an ocean margin round it, and neighbouring regions' margins overlap - so pooling "cells" across regions
|
||||
// counts the same water more than once and reports a land fraction that means nothing. The extent is a
|
||||
// property of the composited planet and is measured once, on it. Land statistics are the opposite: they are
|
||||
// per landmass, disjoint by construction, and never see the finished cylinder at all.
|
||||
func (a *Accumulator) AddExtent(data []float32, land []bool, clipCells int64) {
|
||||
a.Clip += clipCells
|
||||
for i, v := range data {
|
||||
a.Cells++
|
||||
f := float64(v)
|
||||
if f < a.MinM {
|
||||
a.MinM = f
|
||||
}
|
||||
if f > a.MaxM {
|
||||
a.MaxM = f
|
||||
}
|
||||
if land == nil || land[i] {
|
||||
a.Land++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add folds one grid's land statistics in. It reads only the cells the mask calls land, and it deliberately
|
||||
// records nothing about the grid's extent - see AddExtent.
|
||||
func (a *Accumulator) Add(in Input) {
|
||||
h := in.H
|
||||
if h == nil || len(h.Data) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Local relief first, because it is the one quantity that needs a neighbourhood and therefore a whole
|
||||
// field of its own. Two sliding passes, O(1) a cell whatever the window: the loop this replaces was
|
||||
// 1.1e11 comparisons on a planet, which is why no planet bake has ever printed this block.
|
||||
var relief *field.Field
|
||||
if a.opt.ReliefWindowM > 0 && in.UpliftMYr != nil {
|
||||
r := int(math.Round(a.opt.ReliefWindowM / h.CellM / 2))
|
||||
if r < 1 {
|
||||
r = 1
|
||||
}
|
||||
relief = field.LocalRelief(h, r, in.WrapX)
|
||||
}
|
||||
|
||||
inv := 1.0 / (2.0 * h.CellM)
|
||||
cellArea := h.CellM * h.CellM
|
||||
for y := 0; y < h.H; y++ {
|
||||
for x := 0; x < h.W; x++ {
|
||||
i := y*h.W + x
|
||||
if in.Land != nil && !in.Land[i] {
|
||||
continue
|
||||
}
|
||||
v := float64(h.Data[i])
|
||||
a.elev.Add(v)
|
||||
|
||||
// The slope inline rather than through Field.Slope: that allocates a whole field, which at
|
||||
// planet scale is 300 MB per call and there would be two of them.
|
||||
gx := float64(h.AtClamped(x+1, y)-h.AtClamped(x-1, y)) * inv
|
||||
gy := float64(h.AtClamped(x, y+1)-h.AtClamped(x, y-1)) * inv
|
||||
deg := math.Atan(math.Hypot(gx, gy)) * 180 / math.Pi
|
||||
a.slope.Add(deg)
|
||||
|
||||
if in.UpliftMYr != nil {
|
||||
if b := bucketOf(float64(in.UpliftMYr[i]) * 1000); b >= 0 {
|
||||
acc := &a.buckets[b]
|
||||
acc.total++
|
||||
acc.slope.Add(deg)
|
||||
acc.elev.Add(v)
|
||||
if relief != nil {
|
||||
acc.relief.Add(float64(relief.Data[i]))
|
||||
}
|
||||
if deg >= a.opt.TalusDeg-2 { // pinned against the clamp rather than shaped by erosion
|
||||
acc.near++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if in.Area == nil {
|
||||
continue
|
||||
}
|
||||
if float64(in.Area[i]) >= a.opt.ChannelM2 {
|
||||
a.channelCells++
|
||||
}
|
||||
// A leaf is a cell that drains nothing but itself, and what it measures is the router when the
|
||||
// ground is smooth - not the landscape when it is finished. On a planar ramp with no erosion at
|
||||
// all, D8 leaves 29.5 % of the grid draining nothing, because a cell either sits on one of its
|
||||
// parallel flow lines or it does not; multiple-flow leaves 0.4 %, which is the strict local
|
||||
// maxima. After three hundred steps of solving the same ramp both come back near 8 %: the
|
||||
// terrain has dissected itself by then and its own divides dominate the count. So read this on
|
||||
// young ground, on a stage dump, or against another run of the same age, and do not read it as a
|
||||
// verdict on a mature one. It is a count, so it pools across regions exactly.
|
||||
if float64(in.Area[i]) <= cellArea*1.001 {
|
||||
a.leafCells++
|
||||
}
|
||||
a.addSlopeArea(in, i, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addSlopeArea records one channel cell in the slope-area plot.
|
||||
//
|
||||
// S is the gradient *along the flow path*, not the magnitude of the topographic gradient: on a valley floor
|
||||
// the central difference is dominated by the walls across the channel, which reads far steeper than the water
|
||||
// actually runs and bends the fitted exponent well past -m/n. And the slope is normalised by (U/K)^(1/n) with
|
||||
// the *local* K, because erodibility correlates with drainage area by construction - soft rock is cut down,
|
||||
// sits low and collects flow - so one global K mis-corrects the large-A end systematically.
|
||||
func (a *Accumulator) addSlopeArea(in Input, i int, h *field.Field) {
|
||||
if in.Receiver == nil || in.Length == nil || in.UpliftMYr == nil {
|
||||
return
|
||||
}
|
||||
r := in.Receiver[i]
|
||||
if int(r) == i { // a root drains to itself and has no gradient to measure
|
||||
return
|
||||
}
|
||||
area := float64(in.Area[i])
|
||||
s := float64(h.Data[i]-h.Data[r]) / float64(in.Length[i])
|
||||
if area < a.opt.ChannelM2 || s <= 1e-6 {
|
||||
return
|
||||
}
|
||||
u := float64(in.UpliftMYr[i])
|
||||
kk := a.opt.K
|
||||
if in.KLocal != nil {
|
||||
kk *= float64(in.KLocal[i])
|
||||
}
|
||||
if u <= 0 || kk <= 0 || a.opt.N <= 0 {
|
||||
return // no steady state to normalise against
|
||||
}
|
||||
a.saChannels++
|
||||
key := int(math.Floor(math.Log10(area) * binsPerDecade))
|
||||
b := a.sa[key]
|
||||
if b == nil {
|
||||
b = &saBin{norm: NewHistogram(-8, 4, logSABins), raw: NewHistogram(-8, 4, logSABins)}
|
||||
a.sa[key] = b
|
||||
}
|
||||
b.norm.Add(math.Log10(s / math.Pow(u/kk, 1/a.opt.N)))
|
||||
b.raw.Add(math.Log10(s))
|
||||
}
|
||||
|
||||
const binsPerDecade = 4
|
||||
|
||||
// Merge folds another accumulator in. Every field is additive by construction; see histogram.go.
|
||||
func (a *Accumulator) Merge(o *Accumulator) {
|
||||
if o == nil {
|
||||
return
|
||||
}
|
||||
a.Cells += o.Cells
|
||||
a.Land += o.Land
|
||||
a.Clip += o.Clip
|
||||
a.saChannels += o.saChannels
|
||||
a.channelCells += o.channelCells
|
||||
a.leafCells += o.leafCells
|
||||
a.MinM = math.Min(a.MinM, o.MinM)
|
||||
a.MaxM = math.Max(a.MaxM, o.MaxM)
|
||||
a.elev.Merge(o.elev)
|
||||
a.slope.Merge(o.slope)
|
||||
for i := range a.buckets {
|
||||
if i >= len(o.buckets) {
|
||||
break
|
||||
}
|
||||
a.buckets[i].slope.Merge(o.buckets[i].slope)
|
||||
a.buckets[i].relief.Merge(o.buckets[i].relief)
|
||||
a.buckets[i].elev.Merge(o.buckets[i].elev)
|
||||
a.buckets[i].near += o.buckets[i].near
|
||||
a.buckets[i].total += o.buckets[i].total
|
||||
}
|
||||
// Sorted, because Go randomises map iteration and cross-cutting rule 12 says the answer must not depend
|
||||
// on it. Here it would only change the order two float sums happen in, which is exactly the sort of "it
|
||||
// does not matter this time" the rule exists to refuse.
|
||||
keys := make([]int, 0, len(o.sa))
|
||||
for k := range o.sa {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
for _, k := range keys {
|
||||
b := a.sa[k]
|
||||
if b == nil {
|
||||
b = &saBin{norm: NewHistogram(-8, 4, logSABins), raw: NewHistogram(-8, 4, logSABins)}
|
||||
a.sa[k] = b
|
||||
}
|
||||
b.norm.Merge(o.sa[k].norm)
|
||||
b.raw.Merge(o.sa[k].raw)
|
||||
}
|
||||
}
|
||||
|
||||
// bucketDefs are the uplift classes the breakdown splits on.
|
||||
//
|
||||
// Absolute rather than percentiles of this map's own field: the point is to compare one run against the next,
|
||||
// and a percentile split would redefine "plain" every time the uplift field was retuned. They are reporting
|
||||
// buckets and not a description of terrain - 0.1 mm/yr is a fourteen-degree hillslope at an 8 m cell, which
|
||||
// is hill country wherever it is painted, and reading this axis as guidance is how a legend once ended up ten
|
||||
// times too hot (D-55).
|
||||
var bucketDefs = []struct {
|
||||
name string
|
||||
lo, hi float64
|
||||
}{
|
||||
{"plain", 0, 0.1},
|
||||
{"rolling", 0.1, 0.5},
|
||||
// The top bound is finite rather than +Inf only because the report is marshalled to meta.json and
|
||||
// encoding/json refuses an infinity. 100 mm/yr is an order of magnitude above anything on Earth.
|
||||
{"mountain", 0.5, 100},
|
||||
}
|
||||
|
||||
func bucketOf(mmYr float64) int {
|
||||
for i, d := range bucketDefs {
|
||||
if mmYr >= d.lo && mmYr < d.hi {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Report turns everything gathered into the numbers a run is judged by.
|
||||
func (a *Accumulator) Report(cellM float64) Report {
|
||||
r := Report{
|
||||
MinM: a.MinM, MaxM: a.MaxM, ReliefM: a.MaxM - a.MinM,
|
||||
}
|
||||
if a.Cells > 0 {
|
||||
r.LandFraction = float64(a.Land) / float64(a.Cells)
|
||||
r.ClipFraction = float64(a.Clip) / float64(a.Cells)
|
||||
}
|
||||
r.LandCells = a.Land
|
||||
r.MeasuredLandCells = a.elev.Count
|
||||
if a.elev.Count == 0 {
|
||||
return r
|
||||
}
|
||||
r.LandMinM, r.LandMaxM = a.elev.MinV, a.elev.MaxV
|
||||
r.LandReliefM = r.LandMaxM - r.LandMinM
|
||||
|
||||
r.Slopes = Slopes{
|
||||
Under15Deg: a.slope.FracBelow(15),
|
||||
Under30Deg: a.slope.FracBelow(30),
|
||||
Over50Deg: 1 - a.slope.FracBelow(50),
|
||||
MedianDeg: a.slope.Quantile(0.5),
|
||||
}
|
||||
|
||||
// The hypsometric integral is a *mean* of the normalised elevation, so it comes off the exact running sum
|
||||
// rather than out of the bins: (sum - n*lo) / (n*span). The curve is the binned part, which is what it
|
||||
// should be - it is eleven fractions and nobody reads the third decimal of one.
|
||||
if span := r.LandMaxM - r.LandMinM; span > 1e-6 {
|
||||
r.Hypsometry.Integral = (a.elev.Sum - float64(a.elev.Count)*r.LandMinM) /
|
||||
(float64(a.elev.Count) * span)
|
||||
curve := make([]float64, 11)
|
||||
for i := 0; i <= 10; i++ {
|
||||
curve[i] = 1 - a.elev.FracBelow(r.LandMinM+span*float64(i)/10)
|
||||
}
|
||||
r.Hypsometry.Curve = curve
|
||||
}
|
||||
|
||||
// Channel length over the area the channels were *counted* in, which is the land Add walked and not the
|
||||
// land the planet has. On a full bake the two are the same number. On a partial one - `bake --only` - the
|
||||
// extent is still the whole cylinder while the land statistics cover three islands, and dividing one by
|
||||
// the other would report a drainage density an order of magnitude low with nothing to say it had.
|
||||
if measured := a.elev.Count; measured > 0 && (a.channelCells > 0 || a.saChannels > 0) {
|
||||
lengthKm := float64(a.channelCells) * cellM / 1000
|
||||
areaKm2 := float64(measured) * cellM * cellM / 1e6
|
||||
if areaKm2 > 0 {
|
||||
r.DrainageDensity = lengthKm / areaKm2
|
||||
}
|
||||
}
|
||||
if measured := a.elev.Count; measured > 0 {
|
||||
r.LeafFraction = float64(a.leafCells) / float64(measured)
|
||||
}
|
||||
r.SlopeArea = a.slopeArea()
|
||||
r.Buckets = a.bucketReport(cellM)
|
||||
return r
|
||||
}
|
||||
|
||||
func (a *Accumulator) slopeArea() SlopeArea {
|
||||
out := SlopeArea{Expected: expectedGradient, Channels: int(a.saChannels),
|
||||
ThreshKm2: a.opt.ChannelM2 / 1e6}
|
||||
keys := make([]int, 0, len(a.sa))
|
||||
for k := range a.sa {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
|
||||
var xs, normYs, rawYs []float64
|
||||
for _, key := range keys {
|
||||
b := a.sa[key]
|
||||
if b.norm.Count < 8 { // a bin with a handful of cells is noise, not a data point
|
||||
continue
|
||||
}
|
||||
logA := (float64(key) + 0.5) / binsPerDecade
|
||||
med := b.norm.Quantile(0.5)
|
||||
out.Bins = append(out.Bins, Bin{LogA: logA, LogS: med, N: int(b.norm.Count)})
|
||||
xs = append(xs, logA)
|
||||
normYs = append(normYs, med)
|
||||
rawYs = append(rawYs, b.raw.Quantile(0.5))
|
||||
}
|
||||
out.Exponent, out.R2 = fitLine(xs, normYs)
|
||||
out.RawExponent, out.RawR2 = fitLine(xs, rawYs)
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *Accumulator) bucketReport(cellM float64) []UpliftBucket {
|
||||
total := int64(0)
|
||||
for i := range a.buckets {
|
||||
total += a.buckets[i].total
|
||||
}
|
||||
if total == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]UpliftBucket, 0, len(bucketDefs))
|
||||
for i, d := range bucketDefs {
|
||||
b := &a.buckets[i]
|
||||
if b.total == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, UpliftBucket{
|
||||
Name: d.name, LoMmYr: d.lo, HiMmYr: d.hi,
|
||||
LandFrac: float64(b.total) / float64(total),
|
||||
MedianDeg: b.slope.Quantile(0.5),
|
||||
P90Deg: b.slope.Quantile(0.9),
|
||||
MedianRelM: b.relief.Quantile(0.5),
|
||||
WindowM: a.opt.ReliefWindowM,
|
||||
NearTalus: float64(b.near) / float64(b.total),
|
||||
MedianElevM: b.elev.Quantile(0.5),
|
||||
Cells: int(b.total),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// The whole reason this package was rewritten: the sorts could not be afforded at planet scale. 3000 x 3000
|
||||
// is about what region 12 of the 100 km template is - 9 M cells, the largest single landmass - so this is the
|
||||
// cost of the biggest piece a bake ever hands over, and the planet is the sum of twenty of them.
|
||||
//
|
||||
// A benchmark rather than a test: it measures rather than asserts, and nothing here should fail a build.
|
||||
//
|
||||
// go test ./internal/stats/ -bench Region -benchtime 1x
|
||||
func BenchmarkRegionSizedAccumulate(b *testing.B) {
|
||||
const w, h, cellM = 3000, 3000, 8.0
|
||||
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
|
||||
land[i] = true
|
||||
t := float64(x) / float64(w)
|
||||
f.Data[i] = float32(40 + 300*t*t + 18*math.Sin(float64(x)/9)*math.Cos(float64(y)/7))
|
||||
up[i] = float32((0.02 + 0.9*t*t*t) / 1000)
|
||||
}
|
||||
}
|
||||
in := Input{H: f, Land: land, UpliftMYr: up}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
a := New(testOptions())
|
||||
a.Add(in)
|
||||
a.AddExtent(f.Data, land, 0)
|
||||
_ = a.Report(cellM)
|
||||
}
|
||||
b.ReportMetric(float64(w*h)/1e6, "Mcells")
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package stats
|
||||
|
||||
import "math"
|
||||
|
||||
// A fixed-bin histogram, which is what lets a planet be judged at all.
|
||||
//
|
||||
// Every statistic in this package used to be a sort: `ComputeHypsometry` copies every land cell into a
|
||||
// `[]float64` and sorts it, `ComputeSlopes` does the same with slopes, and `UpliftBuckets` does it three
|
||||
// times per bucket. On the square canvas that is a few megabytes and nobody noticed. On a planet it is
|
||||
// **28 million land cells**, so the copies alone are several gigabytes before a single number comes out, and
|
||||
// that is why a planet bake has never printed anything but its elevation range - the block that matters most
|
||||
// was the block that could not be afforded.
|
||||
//
|
||||
// A histogram replaces all of it. One pass, no allocation per cell, a quantile out of a running sum, and the
|
||||
// error is bounded by the bin width rather than by anything to do with the data.
|
||||
//
|
||||
// **And it pools, which is the property that actually matters here.** The geology is solved one landmass at a
|
||||
// time (D-53), so a planet-wide statistic has to be assembled from per-region pieces - and a histogram is
|
||||
// *additive*: summing two regions' bins and taking the quantile of the sum gives exactly the number a single
|
||||
// pass over both would have given. A median of medians would not; a mean of means weighted by area would be
|
||||
// right for a mean and wrong for everything else. This is the one structure that makes "statistics pool
|
||||
// across regions rather than being computed per region and averaged" true rather than aspirational.
|
||||
type Histogram struct {
|
||||
Lo, Hi float64 `json:"-"`
|
||||
Bins []int64 `json:"-"`
|
||||
|
||||
// Count, Sum, Min and Max are exact rather than binned. The mean and the extremes cost nothing to carry
|
||||
// and they are the numbers a bin width would spoil - the hypsometric integral is a mean, and reading it
|
||||
// off bin centres would make it a property of the bin count.
|
||||
Count int64 `json:"count"`
|
||||
Sum float64 `json:"sum"`
|
||||
MinV float64 `json:"min"`
|
||||
MaxV float64 `json:"max"`
|
||||
Under int64 `json:"under"` // values below Lo
|
||||
Over int64 `json:"over"` // values at or above Hi
|
||||
}
|
||||
|
||||
// NewHistogram covers lo..hi in n bins. Values outside are counted rather than clamped: a quantile that
|
||||
// silently piled everything on the end bin would be a quantile that lied about a field whose range had
|
||||
// been set wrong.
|
||||
func NewHistogram(lo, hi float64, n int) *Histogram {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if hi <= lo {
|
||||
hi = lo + 1
|
||||
}
|
||||
return &Histogram{Lo: lo, Hi: hi, Bins: make([]int64, n),
|
||||
MinV: math.Inf(1), MaxV: math.Inf(-1)}
|
||||
}
|
||||
|
||||
// Add records one value.
|
||||
func (h *Histogram) Add(v float64) {
|
||||
h.Count++
|
||||
h.Sum += v
|
||||
if v < h.MinV {
|
||||
h.MinV = v
|
||||
}
|
||||
if v > h.MaxV {
|
||||
h.MaxV = v
|
||||
}
|
||||
b := int((v - h.Lo) / (h.Hi - h.Lo) * float64(len(h.Bins)))
|
||||
switch {
|
||||
case b < 0:
|
||||
h.Under++
|
||||
case b >= len(h.Bins):
|
||||
h.Over++
|
||||
default:
|
||||
h.Bins[b]++
|
||||
}
|
||||
}
|
||||
|
||||
// Merge folds another histogram of the same shape into this one. Two histograms with different bounds cannot
|
||||
// be merged and the caller is the one place that knows it, so this refuses silently rather than inventing an
|
||||
// answer: every merge in this package is between histograms built by the same constructor.
|
||||
func (h *Histogram) Merge(o *Histogram) {
|
||||
if o == nil || o.Count == 0 || len(o.Bins) != len(h.Bins) || o.Lo != h.Lo || o.Hi != h.Hi {
|
||||
return
|
||||
}
|
||||
for i, n := range o.Bins {
|
||||
h.Bins[i] += n
|
||||
}
|
||||
h.Count += o.Count
|
||||
h.Sum += o.Sum
|
||||
h.Under += o.Under
|
||||
h.Over += o.Over
|
||||
if o.MinV < h.MinV {
|
||||
h.MinV = o.MinV
|
||||
}
|
||||
if o.MaxV > h.MaxV {
|
||||
h.MaxV = o.MaxV
|
||||
}
|
||||
}
|
||||
|
||||
// Mean is exact, not binned.
|
||||
func (h *Histogram) Mean() float64 {
|
||||
if h.Count == 0 {
|
||||
return 0
|
||||
}
|
||||
return h.Sum / float64(h.Count)
|
||||
}
|
||||
|
||||
// Quantile is the value below which p of the distribution sits, interpolated within the bin it lands in.
|
||||
//
|
||||
// The out-of-range counts are part of the walk rather than ignored: a quantile that fell among values below
|
||||
// Lo returns Lo, which is honest, where skipping them would shift every quantile above by however many there
|
||||
// were.
|
||||
func (h *Histogram) Quantile(p float64) float64 {
|
||||
if h.Count == 0 {
|
||||
return 0
|
||||
}
|
||||
if p <= 0 {
|
||||
return h.MinV
|
||||
}
|
||||
if p >= 1 {
|
||||
return h.MaxV
|
||||
}
|
||||
want := p * float64(h.Count)
|
||||
run := float64(h.Under)
|
||||
if run >= want {
|
||||
return h.Lo
|
||||
}
|
||||
width := (h.Hi - h.Lo) / float64(len(h.Bins))
|
||||
for i, n := range h.Bins {
|
||||
if run+float64(n) >= want {
|
||||
frac := 0.0
|
||||
if n > 0 {
|
||||
frac = (want - run) / float64(n)
|
||||
}
|
||||
return h.Lo + (float64(i)+frac)*width
|
||||
}
|
||||
run += float64(n)
|
||||
}
|
||||
return h.Hi
|
||||
}
|
||||
|
||||
// FracBelow is the share of the distribution strictly below x, which is what every "how much of the land is
|
||||
// under fifteen degrees" question is asking.
|
||||
func (h *Histogram) FracBelow(x float64) float64 {
|
||||
if h.Count == 0 {
|
||||
return 0
|
||||
}
|
||||
if x <= h.Lo {
|
||||
return float64(h.Under) / float64(h.Count)
|
||||
}
|
||||
if x >= h.Hi {
|
||||
return float64(h.Count-h.Over) / float64(h.Count)
|
||||
}
|
||||
width := (h.Hi - h.Lo) / float64(len(h.Bins))
|
||||
full := int((x - h.Lo) / width)
|
||||
run := h.Under
|
||||
for i := 0; i < full && i < len(h.Bins); i++ {
|
||||
run += h.Bins[i]
|
||||
}
|
||||
// The part-bin, spread evenly across its own width. Without it every threshold would snap to a bin edge,
|
||||
// which at a bin width of a twentieth of a degree does not matter and at a coarse one would.
|
||||
if full < len(h.Bins) {
|
||||
frac := (x - h.Lo - float64(full)*width) / width
|
||||
run += int64(float64(h.Bins[full]) * frac)
|
||||
}
|
||||
return float64(run) / float64(h.Count)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A histogram has to answer the same questions a sort did, closely enough that no verdict changes. The bound
|
||||
// is the bin width, so the test is against a real sort of the same data.
|
||||
func TestHistogramMatchesASort(t *testing.T) {
|
||||
r := rand.New(rand.NewPCG(7, 11))
|
||||
vals := make([]float64, 200000)
|
||||
h := NewHistogram(0, 90, 2048)
|
||||
for i := range vals {
|
||||
// A slope-like distribution: mostly gentle, with a tail.
|
||||
v := math.Abs(r.NormFloat64()) * 7
|
||||
if v > 89.9 {
|
||||
v = 89.9
|
||||
}
|
||||
vals[i] = v
|
||||
h.Add(v)
|
||||
}
|
||||
sort.Float64s(vals)
|
||||
q := func(p float64) float64 { return vals[int(p*float64(len(vals)-1))] }
|
||||
|
||||
width := 90.0 / 2048
|
||||
for _, p := range []float64{0.1, 0.25, 0.5, 0.75, 0.9, 0.99} {
|
||||
got, want := h.Quantile(p), q(p)
|
||||
if math.Abs(got-want) > width {
|
||||
t.Errorf("quantile %.2f: histogram %.4f, sort %.4f, wider than one %.4f bin", p, got, want, width)
|
||||
}
|
||||
}
|
||||
for _, x := range []float64{1, 5, 15, 30, 50} {
|
||||
want := float64(sort.SearchFloat64s(vals, x)) / float64(len(vals))
|
||||
if got := h.FracBelow(x); math.Abs(got-want) > 0.002 {
|
||||
t.Errorf("fraction below %.0f: histogram %.4f, sort %.4f", x, got, want)
|
||||
}
|
||||
}
|
||||
// The mean and the extremes are carried exactly, not read off bins.
|
||||
var sum float64
|
||||
for _, v := range vals {
|
||||
sum += v
|
||||
}
|
||||
if math.Abs(h.Mean()-sum/float64(len(vals))) > 1e-9 {
|
||||
t.Errorf("mean %v against %v", h.Mean(), sum/float64(len(vals)))
|
||||
}
|
||||
if h.MinV != vals[0] || h.MaxV != vals[len(vals)-1] {
|
||||
t.Errorf("extremes %v..%v against %v..%v", h.MinV, h.MaxV, vals[0], vals[len(vals)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// The property the whole per-region design rests on: summing two regions' bins and taking the quantile of the
|
||||
// sum is exactly the quantile of the two together. A median of medians would not be, which is why the
|
||||
// histogram is here and not a smaller summary.
|
||||
func TestMergingIsExactlyPooling(t *testing.T) {
|
||||
r := rand.New(rand.NewPCG(3, 5))
|
||||
a, b, both := NewHistogram(0, 90, 512), NewHistogram(0, 90, 512), NewHistogram(0, 90, 512)
|
||||
for i := 0; i < 30000; i++ {
|
||||
v := r.Float64() * 40
|
||||
a.Add(v)
|
||||
both.Add(v)
|
||||
}
|
||||
for i := 0; i < 70000; i++ {
|
||||
// A different distribution, so a mean of the two would not do.
|
||||
v := 50 + r.Float64()*30
|
||||
b.Add(v)
|
||||
both.Add(v)
|
||||
}
|
||||
a.Merge(b)
|
||||
if a.Count != both.Count {
|
||||
t.Fatalf("merged count %d against %d", a.Count, both.Count)
|
||||
}
|
||||
for i := range a.Bins {
|
||||
if a.Bins[i] != both.Bins[i] {
|
||||
t.Fatalf("bin %d: merged %d against %d", i, a.Bins[i], both.Bins[i])
|
||||
}
|
||||
}
|
||||
for _, p := range []float64{0.1, 0.5, 0.9} {
|
||||
if got, want := a.Quantile(p), both.Quantile(p); got != want {
|
||||
t.Errorf("quantile %.1f: merged %v, together %v", p, got, want)
|
||||
}
|
||||
}
|
||||
// The extremes pool exactly; the mean is a float sum and so is associativity-bound, which is a
|
||||
// 1e-16 effect and not a property worth asserting to the bit.
|
||||
if a.MinV != both.MinV || a.MaxV != both.MaxV {
|
||||
t.Errorf("the extremes did not pool: %v..%v against %v..%v", a.MinV, a.MaxV, both.MinV, both.MaxV)
|
||||
}
|
||||
if rel := math.Abs(a.Mean()-both.Mean()) / both.Mean(); rel > 1e-12 {
|
||||
t.Errorf("the mean did not pool: %v against %v", a.Mean(), both.Mean())
|
||||
}
|
||||
}
|
||||
|
||||
// Out of range is counted, not clamped: a range set wrong has to be visible rather than piling up on an end
|
||||
// bin and quietly moving every quantile.
|
||||
func TestOutOfRangeIsCountedRatherThanClamped(t *testing.T) {
|
||||
h := NewHistogram(0, 10, 10)
|
||||
for _, v := range []float64{-5, -1, 3, 3, 3, 12, 20} {
|
||||
h.Add(v)
|
||||
}
|
||||
if h.Under != 2 || h.Over != 2 {
|
||||
t.Fatalf("under %d over %d, want 2 and 2", h.Under, h.Over)
|
||||
}
|
||||
if h.Count != 7 {
|
||||
t.Fatalf("count %d", h.Count)
|
||||
}
|
||||
if h.MinV != -5 || h.MaxV != 20 {
|
||||
t.Errorf("extremes %v..%v", h.MinV, h.MaxV)
|
||||
}
|
||||
// Three of seven are below 4, plus the two under the bottom: five sevenths.
|
||||
if got := h.FracBelow(4); math.Abs(got-5.0/7) > 1e-9 {
|
||||
t.Errorf("FracBelow(4) = %v, want %v", got, 5.0/7)
|
||||
}
|
||||
// An empty histogram answers zero rather than dividing by nothing.
|
||||
e := NewHistogram(0, 1, 4)
|
||||
if e.Quantile(0.5) != 0 || e.Mean() != 0 || e.FracBelow(0.5) != 0 {
|
||||
t.Error("an empty histogram should answer zero everywhere")
|
||||
}
|
||||
}
|
||||
|
||||
// Merging refuses a mismatch rather than inventing an answer, because every real merge here is between
|
||||
// histograms one constructor made.
|
||||
func TestMergeRefusesADifferentShape(t *testing.T) {
|
||||
a := NewHistogram(0, 10, 10)
|
||||
a.Add(5)
|
||||
for _, b := range []*Histogram{NewHistogram(0, 10, 20), NewHistogram(0, 20, 10), nil} {
|
||||
if b != nil {
|
||||
b.Add(5)
|
||||
}
|
||||
a.Merge(b)
|
||||
}
|
||||
if a.Count != 1 {
|
||||
t.Errorf("a mismatched merge changed the histogram: count %d", a.Count)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,6 @@ package stats
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
type Bin struct {
|
||||
@@ -74,93 +71,22 @@ type Report struct {
|
||||
Hypsometry Hypsometry `json:"hypsometry"`
|
||||
DrainageDensity float64 `json:"drainage_density_per_km"`
|
||||
|
||||
// Buckets is the whole-map aggregates split by the uplift class that caused them; see UpliftBuckets.
|
||||
// LeafFraction is the share of land cells that drain nothing but themselves. See accumulate.go: it is
|
||||
// the one number that separates a drainage network from a comb of parallel non-converging flow lines.
|
||||
LeafFraction float64 `json:"leaf_fraction"`
|
||||
|
||||
// LandCells is how much land the world has and MeasuredLandCells how much of it the land statistics
|
||||
// below actually walked. They differ only on a partial run - `bake --only` leaves most of a planet at sea
|
||||
// level - and when they do, every distribution here describes the part that was solved while the extent
|
||||
// above describes the whole cylinder. Summary says so rather than leaving the two to be compared.
|
||||
LandCells int64 `json:"land_cells"`
|
||||
MeasuredLandCells int64 `json:"measured_land_cells"`
|
||||
|
||||
// Buckets is the whole-map aggregates split by the uplift class that caused them.
|
||||
// The map-wide median above cannot tell a mountain belt from a plain, and that is the question.
|
||||
Buckets []UpliftBucket `json:"uplift_buckets"`
|
||||
}
|
||||
|
||||
// ComputeSlopeArea bins channel cells by log10 drainage area and takes the median slope in each bin, which
|
||||
// is far more robust than the mean: one cliff cell in a bin drags a mean and leaves a median alone.
|
||||
//
|
||||
// S is the gradient *along the flow path*, (h - h_receiver) / L, not the magnitude of the topographic
|
||||
// gradient. The difference is not pedantic: for a cell on a valley floor the central difference is dominated
|
||||
// by the valley walls across the channel, which reads as a far steeper slope than the water actually runs
|
||||
// down, and it bends the fitted exponent well past -m/n. The receiver gradient is the quantity the
|
||||
// stream-power law is written in, so it is the quantity the plot has to use.
|
||||
// kLocal is the per-cell erodibility multiplier from the lithology pass, and passing it matters as much as
|
||||
// passing the uplift. Erodibility correlates with drainage area by construction: soft rock is cut down, so it
|
||||
// sits low and collects flow, while hard rock stands up as ridges and drains little. Normalising every cell by
|
||||
// one global K therefore mis-corrects the large-A end systematically and bends the fitted exponent — it read
|
||||
// -1.23 against a true -0.50 on a landscape the solver had built correctly. Steady state is written in the
|
||||
// local K, so the normalisation has to be too.
|
||||
func ComputeSlopeArea(h *field.Field, area []float32, receiver []int32, length []float32, land []bool,
|
||||
upliftMYr, kLocal []float32, k, n float64, thresholdM2 float64) SlopeArea {
|
||||
const binsPerDecade = 4
|
||||
type acc struct{ norm, raw []float64 }
|
||||
bins := map[int]*acc{}
|
||||
count := 0
|
||||
for i := range h.Data {
|
||||
if land != nil && !land[i] {
|
||||
continue
|
||||
}
|
||||
r := receiver[i]
|
||||
if int(r) == i { // a root drains to itself and has no gradient to measure
|
||||
continue
|
||||
}
|
||||
a := float64(area[i])
|
||||
s := float64(h.Data[i]-h.Data[r]) / float64(length[i])
|
||||
if a < thresholdM2 || s <= 1e-6 {
|
||||
continue
|
||||
}
|
||||
u := 0.0
|
||||
if upliftMYr != nil {
|
||||
u = float64(upliftMYr[i])
|
||||
}
|
||||
kk := k
|
||||
if kLocal != nil {
|
||||
kk *= float64(kLocal[i])
|
||||
}
|
||||
if u <= 0 || kk <= 0 || n <= 0 {
|
||||
continue // no steady state to normalise against
|
||||
}
|
||||
count++
|
||||
key := int(math.Floor(math.Log10(a) * binsPerDecade))
|
||||
b := bins[key]
|
||||
if b == nil {
|
||||
b = &acc{}
|
||||
bins[key] = b
|
||||
}
|
||||
b.norm = append(b.norm, math.Log10(s/math.Pow(u/kk, 1/n)))
|
||||
b.raw = append(b.raw, math.Log10(s))
|
||||
}
|
||||
// Map iteration is randomised in Go, so the keys are sorted before anything reads them. Determinism is
|
||||
// cross-cutting rule 12 and this is exactly where it would leak.
|
||||
keys := make([]int, 0, len(bins))
|
||||
for k := range bins {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
|
||||
out := SlopeArea{Expected: expectedGradient, Channels: count, ThreshKm2: thresholdM2 / 1e6}
|
||||
var xs, normYs, rawYs []float64
|
||||
for _, key := range keys {
|
||||
b := bins[key]
|
||||
if len(b.norm) < 8 { // a bin with a handful of cells is noise, not a data point
|
||||
continue
|
||||
}
|
||||
sort.Float64s(b.norm)
|
||||
sort.Float64s(b.raw)
|
||||
logA := (float64(key) + 0.5) / binsPerDecade
|
||||
out.Bins = append(out.Bins, Bin{LogA: logA, LogS: b.norm[len(b.norm)/2], N: len(b.norm)})
|
||||
xs = append(xs, logA)
|
||||
normYs = append(normYs, b.norm[len(b.norm)/2])
|
||||
rawYs = append(rawYs, b.raw[len(b.raw)/2])
|
||||
}
|
||||
out.Exponent, out.R2 = fitLine(xs, normYs)
|
||||
out.RawExponent, out.RawR2 = fitLine(xs, rawYs)
|
||||
return out
|
||||
}
|
||||
|
||||
// expectedGradient is the -m/n the theory predicts, kept in one place so the verdict compares the fit against
|
||||
// the exponents the run was actually configured with rather than against the defaults.
|
||||
var expectedGradient = -0.5
|
||||
@@ -204,85 +130,6 @@ func fitLine(x, y []float64) (float64, float64) {
|
||||
return grad, 1 - ssRes/ssTot
|
||||
}
|
||||
|
||||
func ComputeHypsometry(h *field.Field, land []bool) Hypsometry {
|
||||
vals := make([]float64, 0, len(h.Data))
|
||||
for i, v := range h.Data {
|
||||
if land != nil && !land[i] {
|
||||
continue
|
||||
}
|
||||
vals = append(vals, float64(v))
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return Hypsometry{}
|
||||
}
|
||||
sort.Float64s(vals)
|
||||
lo, hi := vals[0], vals[len(vals)-1]
|
||||
span := hi - lo
|
||||
if span < 1e-6 {
|
||||
return Hypsometry{Integral: 0}
|
||||
}
|
||||
var sum float64
|
||||
for _, v := range vals {
|
||||
sum += (v - lo) / span
|
||||
}
|
||||
curve := make([]float64, 11)
|
||||
for i := 0; i <= 10; i++ {
|
||||
target := lo + span*float64(i)/10
|
||||
// Fraction of land standing above this elevation.
|
||||
idx := sort.SearchFloat64s(vals, target)
|
||||
curve[i] = 1 - float64(idx)/float64(len(vals))
|
||||
}
|
||||
return Hypsometry{Integral: sum / float64(len(vals)), Curve: curve}
|
||||
}
|
||||
|
||||
func ComputeSlopes(h *field.Field, land []bool) Slopes {
|
||||
slope := h.Slope()
|
||||
degs := make([]float64, 0, len(slope.Data))
|
||||
for i, s := range slope.Data {
|
||||
if land != nil && !land[i] {
|
||||
continue
|
||||
}
|
||||
degs = append(degs, math.Atan(float64(s))*180/math.Pi)
|
||||
}
|
||||
if len(degs) == 0 {
|
||||
return Slopes{}
|
||||
}
|
||||
sort.Float64s(degs)
|
||||
frac := func(limit float64) float64 {
|
||||
return float64(sort.SearchFloat64s(degs, limit)) / float64(len(degs))
|
||||
}
|
||||
return Slopes{
|
||||
Under15Deg: frac(15),
|
||||
Under30Deg: frac(30),
|
||||
Over50Deg: 1 - frac(50),
|
||||
MedianDeg: degs[len(degs)/2],
|
||||
}
|
||||
}
|
||||
|
||||
// DrainageDensity is channel length over basin area, per kilometre. Real landscapes sit around 1 to 10 /km;
|
||||
// a value near zero means the solve never organised into channels at all.
|
||||
func DrainageDensity(area []float32, land []bool, cellM float64, thresholdM2 float64) float64 {
|
||||
var channels, total int
|
||||
for i, a := range area {
|
||||
if land != nil && !land[i] {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if float64(a) >= thresholdM2 {
|
||||
channels++
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
lengthKm := float64(channels) * cellM / 1000
|
||||
areaKm2 := float64(total) * cellM * cellM / 1e6
|
||||
if areaKm2 == 0 {
|
||||
return 0
|
||||
}
|
||||
return lengthKm / areaKm2
|
||||
}
|
||||
|
||||
// Summary is the one block a run prints. Written so the numbers that decide whether the run was any good are
|
||||
// the ones you see without asking.
|
||||
func (r Report) Summary() string {
|
||||
@@ -305,19 +152,31 @@ func (r Report) Summary() string {
|
||||
case r.Hypsometry.Integral < 0.35:
|
||||
hyp = "concave: over-eroded"
|
||||
}
|
||||
// A partial run measures the whole cylinder's extent and only the solved landmasses' ground, and the two
|
||||
// sitting next to each other invite exactly the wrong comparison. Say so, rather than leave somebody to
|
||||
// work out afterwards why the drainage density looked impossible.
|
||||
partial := ""
|
||||
if r.LandCells > 0 && r.MeasuredLandCells > 0 && r.MeasuredLandCells < r.LandCells {
|
||||
partial = fmt.Sprintf(
|
||||
" PARTIAL: the line above is the whole world; everything below is the %.0f%% of its land that\n"+
|
||||
" was actually solved (%d of %d cells). The two are not comparable.\n",
|
||||
100*float64(r.MeasuredLandCells)/float64(r.LandCells), r.MeasuredLandCells, r.LandCells)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
" field %.0f..%.0f m; land %.0f..%.0f m (relief %.0f m), %.0f%% land, %.2f%% clipped\n"+
|
||||
"%s"+
|
||||
" slopes: %.0f%% under 15 deg, %.0f%% under 30, %.1f%% over 50, median %.1f deg\n"+
|
||||
" slope-area: exponent %.3f (expect %.3f), R2 %.3f over %d bins, %d channel cells above %.2f km2\n"+
|
||||
" unnormalised %.3f, R2 %.3f (heterogeneous uplift, so this one is expected to be worse)\n"+
|
||||
" %s\n"+
|
||||
" hypsometric integral %.3f (%s); drainage density %.2f /km\n"+
|
||||
" hypsometric integral %.3f (%s); drainage density %.2f /km; %.1f%% of land drains nothing\n"+
|
||||
"%s",
|
||||
r.MinM, r.MaxM, r.LandMinM, r.LandMaxM, r.LandReliefM, r.LandFraction*100, r.ClipFraction*100,
|
||||
partial,
|
||||
r.Slopes.Under15Deg*100, r.Slopes.Under30Deg*100, r.Slopes.Over50Deg*100, r.Slopes.MedianDeg,
|
||||
sa.Exponent, sa.Expected, sa.R2, len(sa.Bins), sa.Channels, sa.ThreshKm2,
|
||||
sa.RawExponent, sa.RawR2,
|
||||
verdict, r.Hypsometry.Integral, hyp, r.DrainageDensity,
|
||||
verdict, r.Hypsometry.Integral, hyp, r.DrainageDensity, r.LeafFraction*100,
|
||||
BucketSummary(r.Buckets))
|
||||
}
|
||||
|
||||
@@ -349,110 +208,6 @@ type UpliftBucket struct {
|
||||
Cells int `json:"cells"`
|
||||
}
|
||||
|
||||
// UpliftBuckets splits the land by rock uplift rate and reports slope, local relief and how much of each
|
||||
// bucket is pinned against the repose clamp. The last of those is the diagnostic: a bucket where most cells
|
||||
// sit within two degrees of talus is not being shaped by erosion at all, it is being shaped by the clamp,
|
||||
// and no amount of tuning downstream of that will change what it looks like.
|
||||
//
|
||||
// reliefWindowM is the side of the square the local relief is taken over; 500 m is the usual choice and is
|
||||
// what the caller passes.
|
||||
func UpliftBuckets(h *field.Field, upliftMYr []float32, land []bool, talusDeg, reliefWindowM float64) []UpliftBucket {
|
||||
// The class boundaries are in mm/yr and are deliberately absolute rather than percentiles of this map's
|
||||
// own field: the point is to compare one run against the next, and a percentile split would redefine
|
||||
// "plain" every time the uplift field was retuned.
|
||||
defs := []struct {
|
||||
name string
|
||||
lo, hi float64
|
||||
}{
|
||||
{"plain", 0, 0.1},
|
||||
{"rolling", 0.1, 0.5},
|
||||
// The top bound is finite rather than +Inf only because the report is marshalled to meta.json and
|
||||
// encoding/json refuses an infinity. 100 mm/yr is an order of magnitude above anything on Earth.
|
||||
{"mountain", 0.5, 100},
|
||||
}
|
||||
if upliftMYr == nil {
|
||||
return nil
|
||||
}
|
||||
slope := h.Slope()
|
||||
radius := int(math.Round(reliefWindowM / h.CellM / 2))
|
||||
if radius < 1 {
|
||||
radius = 1
|
||||
}
|
||||
type acc struct {
|
||||
deg, rel, elev []float64
|
||||
near, total int
|
||||
}
|
||||
accs := make([]acc, len(defs))
|
||||
landCells := 0
|
||||
for i := range h.Data {
|
||||
if land != nil && !land[i] {
|
||||
continue
|
||||
}
|
||||
landCells++
|
||||
u := float64(upliftMYr[i]) * 1000 // mm/yr
|
||||
b := -1
|
||||
for j, d := range defs {
|
||||
if u >= d.lo && u < d.hi {
|
||||
b = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if b < 0 {
|
||||
continue
|
||||
}
|
||||
a := &accs[b]
|
||||
deg := math.Atan(float64(slope.Data[i])) * 180 / math.Pi
|
||||
a.deg = append(a.deg, deg)
|
||||
a.elev = append(a.elev, float64(h.Data[i]))
|
||||
a.rel = append(a.rel, localRelief(h, i%h.W, i/h.W, radius))
|
||||
a.total++
|
||||
if deg >= talusDeg-2 { // pinned against the clamp rather than shaped by erosion
|
||||
a.near++
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]UpliftBucket, 0, len(defs))
|
||||
for j, d := range defs {
|
||||
a := &accs[j]
|
||||
if a.total == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Float64s(a.deg)
|
||||
sort.Float64s(a.rel)
|
||||
sort.Float64s(a.elev)
|
||||
out = append(out, UpliftBucket{
|
||||
Name: d.name, LoMmYr: d.lo, HiMmYr: d.hi,
|
||||
LandFrac: float64(a.total) / float64(max(landCells, 1)),
|
||||
MedianDeg: a.deg[len(a.deg)/2],
|
||||
P90Deg: a.deg[min(len(a.deg)*9/10, len(a.deg)-1)],
|
||||
MedianRelM: a.rel[len(a.rel)/2],
|
||||
WindowM: float64(radius*2) * h.CellM,
|
||||
NearTalus: float64(a.near) / float64(a.total),
|
||||
MedianElevM: a.elev[len(a.elev)/2],
|
||||
Cells: a.total,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// localRelief is max minus min over a square window, the standard field measure of how rugged a place is.
|
||||
// Slope alone cannot tell a 5 m hummock from a 500 m mountainside, because both can stand at 30 degrees.
|
||||
func localRelief(h *field.Field, cx, cy, radius int) float64 {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for y := cy - radius; y <= cy+radius; y++ {
|
||||
for x := cx - radius; x <= cx+radius; x++ {
|
||||
v := float64(h.AtClamped(x, y))
|
||||
if v < lo {
|
||||
lo = v
|
||||
}
|
||||
if v > hi {
|
||||
hi = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return hi - lo
|
||||
}
|
||||
|
||||
// BucketSummary is the block the buckets print. Kept separate from Summary so a run that has no uplift field
|
||||
// to hand still prints the rest.
|
||||
func BucketSummary(bs []UpliftBucket) string {
|
||||
|
||||
Reference in New Issue
Block a user