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
+448
View File
@@ -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")
}
+162
View File
@@ -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)
}
}
+26 -271
View File
@@ -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 {