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
|
||||
}
|
||||
Reference in New Issue
Block a user