Files
UnrealPrototyping/Tools/Terrain/internal/fluvial/flow_test.go
T
2026-09-25 17:02:24 +03:00

171 lines
5.7 KiB
Go

package fluvial
import (
"math"
"sort"
"testing"
)
// The diagnosis on a surface with no history.
//
// A planar hillslope is the one case where the right answer is known in closed form: the specific catchment
// area - the upslope area per unit contour length - is the distance from the divide, and it is the same at
// every point along a contour. Nothing about a plane distinguishes one flow line from its neighbour, so a
// router that says otherwise is inventing the difference.
//
// D8 cannot say otherwise quietly. Every cell on the plane picks the same steepest neighbour, so the flow
// lines run exactly parallel and never converge: a cell either sits on a line and carries the whole tube, or
// sits off one and carries a single cell for ever. The ratio between them is the statistic below, and it is
// why the flanks of a bake come out combed - stream power reads A^m off the lie and cuts each line in.
//
// There is no erosion in here. One fill, one receiver pass, one stack, one accumulate, and whatever comes
// out belongs to the router and to nothing else.
// planarRamp is a plane tilted by aspectDeg from the x axis, with a whisper of noise to break exact ties.
// The aspect matters: at 0 or 45 degrees the plane is aligned with a D8 direction and the answer is
// degenerate in the other direction, so the test asks at 22.5, which is the worst case and the honest one.
func planarRamp(n int, cellM, slope, aspectDeg float64) []float32 {
t := aspectDeg * math.Pi / 180
cs, sn := math.Cos(t), math.Sin(t)
h := make([]float32, n*n)
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
d := (float64(x)*cs + float64(y)*sn) * cellM
// A millimetre of hash noise: enough that no two neighbours are bit-identical, far below
// anything the router could read as structure.
j := float64(hashXY(1, int32(x), int32(y), 99)) * 1e-3
h[y*n+x] = float32(4000 - d*slope + j)
}
}
return h
}
// concentration is max over median of the drainage area in a band of cells all the same distance from the
// divide. On a plane the true value is 1: every cell in the band drains the same strip above it.
func concentration(t *testing.T, area []float32, n int, cellM, aspectDeg, lo, hi float64) float64 {
th := aspectDeg * math.Pi / 180
cs, sn := math.Cos(th), math.Sin(th)
dmax := (float64(n-1)*cs + float64(n-1)*sn) * cellM
var band []float64
for y := 2; y < n-2; y++ {
for x := 2; x < n-2; x++ {
d := (float64(x)*cs + float64(y)*sn) * cellM
if d >= lo*dmax && d <= hi*dmax {
band = append(band, float64(area[y*n+x]))
}
}
}
if len(band) < 100 {
t.Fatalf("contour band has only %d cells", len(band))
}
sort.Float64s(band)
med := band[len(band)/2]
if med <= 0 {
t.Fatalf("median area in the band is %g", med)
}
return band[len(band)-1] / med
}
const (
flowN = 256
flowCellM = 10.0
flowSlope = 0.1
flowAspect = 22.5
)
func TestD8ConcentratesFlowOnAPlanarSlope(t *testing.T) {
h := planarRamp(flowN, flowCellM, flowSlope, flowAspect)
g := NewGrid(flowN, flowN, flowCellM, nil)
g.SetSeed(37125)
g.FillDepressions(h, 1e-3)
g.ComputeReceivers(h)
g.BuildStack()
g.Accumulate()
c := concentration(t, g.Area, flowN, flowCellM, flowAspect, 0.6, 0.7)
leaves := 0
for i := range g.Area {
if g.Area[i] <= float32(flowCellM*flowCellM)*1.001 {
leaves++
}
}
t.Logf("D8: concentration max/median = %.1f, leaf cells = %.1f%%",
c, 100*float64(leaves)/float64(flowN*flowN))
if c < 5 {
t.Errorf("D8 concentration is %.1f; this test exists because it is large, so either the router "+
"changed or the measurement is wrong", c)
}
}
func TestMFDDoesNotConcentrateFlowOnAPlanarSlope(t *testing.T) {
h := planarRamp(flowN, flowCellM, flowSlope, flowAspect)
g := NewGrid(flowN, flowN, flowCellM, nil)
g.SetSeed(37125)
g.FillDepressions(h, 1e-3)
g.ComputeReceivers(h)
g.AccumulateMFD(h, 1)
c := concentration(t, g.Area, flowN, flowCellM, flowAspect, 0.6, 0.7)
leaves := 0
for i := range g.Area {
if g.Area[i] <= float32(flowCellM*flowCellM)*1.001 {
leaves++
}
}
t.Logf("MFD: concentration max/median = %.2f, leaf cells = %.1f%%",
c, 100*float64(leaves)/float64(flowN*flowN))
if c > 2 {
t.Errorf("MFD concentration is %.2f on a plane, where the true answer is 1; the partition is not "+
"spreading flow across the contour", c)
}
}
// TestMFDConservesArea is the test the panic in AccumulateMFD cannot be: the walk releasing every cell says
// nothing about how much area arrived. Over a closed basin the total that reaches the outlets has to be the
// whole grid, because there is nowhere else for it to go.
func TestMFDConservesArea(t *testing.T) {
const n = 128
const cellM = 10.0
// A bowl, so every flow path ends at the one interior minimum rather than at the border.
h := make([]float32, n*n)
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
dx, dy := float64(x)-n/2, float64(y)-n/2
j := float64(hashXY(7, int32(x), int32(y), 99)) * 1e-3
h[y*n+x] = float32(100 + 0.02*(dx*dx+dy*dy) + j)
}
}
g := NewGrid(n, n, cellM, nil)
g.SetSeed(9342)
g.ComputeReceivers(h)
g.AccumulateMFD(h, 1)
// Every cell that sends nothing on is a sink: the bowl's floor and the fixed border. What rests in them
// is the whole grid's area.
var rest float64
for i := 0; i < n*n; i++ {
x, y := i%n, i/n
lower := false
for k := 0; k < 8; k++ {
nx, ny := x+dx8[k], y+dy8[k]
if nx < 0 || ny < 0 || nx >= n || ny >= n {
continue
}
if h[ny*n+nx] < h[i] {
lower = true
break
}
}
if !lower || g.fixed[i] {
rest += float64(g.Area[i])
}
}
want := float64(n*n) * cellM * cellM
if rel := math.Abs(rest-want) / want; rel > 1e-4 {
t.Errorf("area resting in sinks is %.0f m2, the grid is %.0f m2: %.2e relative, float32 is not enough",
rest, want, rel)
} else {
t.Logf("area conserved to %.2e relative in float32", rel)
}
}