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