Tooling
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user