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
+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 {