// 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" ) 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"` // 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"` } // 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 } // 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" } // 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; %.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, r.LeafFraction*100, 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"` } // 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 }