171 lines
5.7 KiB
Go
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)
|
|
}
|
|
}
|