Files
UnrealPrototyping/Tools/Terrain/internal/stats/histogram.go
T
2026-09-25 17:02:24 +03:00

163 lines
5.3 KiB
Go

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)
}