// Package stats is how a run is judged. "It reads as real geology" is not a screenshot; it is a straight // slope-area plot and an S-shaped hypsometric curve, and this package produces both. // // The project's habit already is to measure rather than eyeball — a slope histogram settled the noise tuning // and attributed rill damage to a specific pass — and these are the two standard checks the incoming spec // added on top. The slope-area exponent in particular is the direct test of whether the fluvial pass did the // thing it exists to do, so it is the proof that closes build-order step 4. package stats import ( "fmt" "math" "sort" "salty/terrain/internal/field" ) type Bin struct { LogA float64 `json:"log_area"` LogS float64 `json:"log_slope"` N int `json:"n"` } // SlopeArea is the stream-power signature. At steady state S = (U/K)^(1/n) * A^(-m/n), so log S against // log A is a straight line of gradient -m/n: -0.5 at the defaults. A curved or scattered plot means K, m, n // or the run length is wrong, and no amount of detail noise will hide it. // // The catch, and it took a bad R2 to notice: that relation has the same gradient but a *different intercept* // for every uplift rate. This map's uplift spans 0.2 to 5 mm/yr, so regressing every channel together stacks // twenty-five-fold-separated parallel lines into a cloud and fits nonsense to it. Slope is therefore // normalised by (U/K)^(1/n) first, which collapses every regime onto one line through the origin and tests // the exponent rather than the uplift field's heterogeneity. RawExponent keeps the unnormalised fit, which is // what a single-uplift map would report and is worth seeing next to it. type SlopeArea struct { Bins []Bin `json:"bins"` Exponent float64 `json:"exponent"` RawExponent float64 `json:"raw_exponent"` Expected float64 `json:"expected"` R2 float64 `json:"r2"` RawR2 float64 `json:"raw_r2"` Channels int `json:"channel_cells"` ThreshKm2 float64 `json:"threshold_km2"` } // Hypsometry is the second check: cumulative area against normalised elevation should be S-shaped. The // integral is the single number - convex and high means too young or too much uplift, concave and low means // over-eroded. Mature landscapes sit near 0.4 to 0.6. type Hypsometry struct { Integral float64 `json:"integral"` Curve []float64 `json:"curve"` // area fraction at 11 elevation fractions, 0.0 to 1.0 } type Slopes struct { Under15Deg float64 `json:"under_15_deg"` Under30Deg float64 `json:"under_30_deg"` Over50Deg float64 `json:"over_50_deg"` MedianDeg float64 `json:"median_deg"` } type Report struct { LandFraction float64 `json:"land_fraction"` ClipFraction float64 `json:"clip_fraction"` // Min and Max span the whole field, sea floor included, because that is what the 16-bit encoding has to // fit. Land relief is the number that says anything about the terrain, and they are not the same: a // -180 m sea floor flatters the relief by 180 m for free. ReliefM float64 `json:"relief_m"` MinM float64 `json:"min_m"` MaxM float64 `json:"max_m"` LandMinM float64 `json:"land_min_m"` LandMaxM float64 `json:"land_max_m"` LandReliefM float64 `json:"land_relief_m"` Slopes Slopes `json:"slopes"` SlopeArea SlopeArea `json:"slope_area"` 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. // 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 // SetExpected is called once from the command before any report is computed. func SetExpected(m, n float64) { if n != 0 { expectedGradient = -m / n } } // fitLine is an ordinary least-squares fit returning the gradient and R². func fitLine(x, y []float64) (float64, float64) { n := float64(len(x)) if n < 3 { return 0, 0 } var sx, sy, sxx, sxy float64 for i := range x { sx += x[i] sy += y[i] sxx += x[i] * x[i] sxy += x[i] * y[i] } den := n*sxx - sx*sx if math.Abs(den) < 1e-12 { return 0, 0 } grad := (n*sxy - sx*sy) / den intercept := (sy - grad*sx) / n mean := sy / n var ssRes, ssTot float64 for i := range x { pred := grad*x[i] + intercept ssRes += (y[i] - pred) * (y[i] - pred) ssTot += (y[i] - mean) * (y[i] - mean) } if ssTot < 1e-12 { return grad, 0 } 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 { sa := r.SlopeArea verdict := "no channels: the solve did not organise" if sa.Channels > 0 && len(sa.Bins) >= 3 { switch { case sa.R2 >= 0.9 && math.Abs(sa.Exponent-sa.Expected) < 0.15: verdict = "straight and at the expected gradient: stream power is doing its job" case sa.R2 >= 0.9: verdict = "straight but off gradient: K, m or n is wrong, or the run is too short" default: verdict = "scattered: not at steady state, or pits are routing badly" } } hyp := "mature" switch { case r.Hypsometry.Integral > 0.6: hyp = "convex: too young, or too much uplift" case r.Hypsometry.Integral < 0.35: hyp = "concave: over-eroded" } return fmt.Sprintf( " field %.0f..%.0f m; land %.0f..%.0f m (relief %.0f m), %.0f%% land, %.2f%% clipped\n"+ " 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"+ "%s", r.MinM, r.MaxM, r.LandMinM, r.LandMaxM, r.LandReliefM, r.LandFraction*100, r.ClipFraction*100, 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, BucketSummary(r.Buckets)) } // Uplift buckets: the measurement that decides whether a plain is a plain. // // Every aggregate above is taken over the whole land mask, and that is exactly what hid the problem this // bucketing was added to find. A continent whose mountains are at 35 degrees and whose plains are at 32 // reports a median of 31 and looks, from the summary, like a mountainous map — which it is, but not for the // reason anyone assumed. Splitting by the uplift rate that *caused* the slope separates the two questions: // "are the mountains right" and "are the plains plains". // // Uplift is the right axis rather than elevation. Elevation is the output of the solve, so bucketing by it // mixes a low mountain valley in with a plain and moves the boundary every time a constant changes; uplift // is an input, fixed before the first step, and it is the term that sets steady-state slope through // S = U/(K*A^m). A cell's bucket therefore does not move when the run does. // UpliftBucket is one class of the uplift field and what the landscape did with it. type UpliftBucket struct { Name string `json:"name"` LoMmYr float64 `json:"lo_mm_yr"` HiMmYr float64 `json:"hi_mm_yr"` LandFrac float64 `json:"land_fraction"` // share of land in this bucket MedianDeg float64 `json:"median_deg"` P90Deg float64 `json:"p90_deg"` MedianRelM float64 `json:"median_relief_m"` // local relief, max-min over the window below WindowM float64 `json:"relief_window_m"` NearTalus float64 `json:"near_talus_fraction"` // within 2 degrees of the angle of repose MedianElevM float64 `json:"median_elev_m"` 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 { if len(bs) == 0 { return "" } s := " by uplift class:\n" for _, b := range bs { hi := fmt.Sprintf("%.2f", b.HiMmYr) if b.HiMmYr >= 100 { hi = " up" } s += fmt.Sprintf(" %-9s %.2f..%s mm/yr %4.0f%% of land slope %4.1f deg median, %4.1f P90 "+ "relief %5.0f m/%.0f m at talus %4.0f%% median %.0f m\n", b.Name, b.LoMmYr, hi, b.LandFrac*100, b.MedianDeg, b.P90Deg, b.MedianRelM, b.WindowM, b.NearTalus*100, b.MedianElevM) } return s }