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) } }