Added: Initial world generation tool
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user