package fluvial import ( "math" "salty/terrain/internal/field" ) // Multiple-flow-direction drainage area: Freeman, Quinn and Holmgren's partition, and the answer to the one // thing D8 cannot do. // // A planar hillslope is where D8 fails, and it fails in closed form. The specific catchment area on a plane // is the distance from the divide and it is the same at every point along a contour, because nothing about a // plane tells one flow line from its neighbour. D8 has to disagree: every cell picks the same steepest // neighbour, so the flow lines run exactly parallel and never converge, and a cell either sits on a line and // carries the whole tube or sits off one and carries a single cell for ever. Measured on a ramp at an aspect // of 22.5 degrees, the most-drained cell in a contour band carries 769 times the median and 30 % of the grid // drains nothing at all (flow_test.go). Stream power then reads A^m off that and cuts each line in, which is // what a bake's mountain flanks were: a comb of ruler-straight parallel grooves, one per surviving line, // spaced by the mean distance between the merges the router's tie-break jitter happened to allow - a spacing // in cells, which is why it measured the same eighteen cells at an 8 m cell and at a 32 m one. // // The partition below splits a cell's area among every downslope neighbour by (dh_k * q_k)^p, where q_k is // the share of the cell's perimeter facing direction k divided by the centre-to-centre distance: 0.5 for a // cardinal neighbour, 0.25 for a diagonal. The cell size cancels out of the ratio, so the weights are height // differences times a constant - no division and no transcendental in the inner loop at p = 1. // // It replaces Accumulate and it replaces only that. Receiver, Length and Stack stay D8, because // Braun-Willett's implicit update walks one receiver chain and there is no multi-receiver form of it that is // still unconditionally stable. Stream power therefore incises along the steepest path using the area that // actually converges there. That pairing is deliberate and it is the standard one; it is not an oversight. // mfdPow selects how the partition quantity is raised to p, once per call rather than once per cell. p is // almost always 1, where the whole thing is a multiply. type mfdPow uint8 const ( mfdP1 mfdPow = iota mfdP2 mfdP3 mfdP4 mfdGeneral ) func mfdModeFor(p float64) (mfdPow, float64) { switch { case math.Abs(p-1) < 1e-9: return mfdP1, 1 case math.Abs(p-2) < 1e-9: return mfdP2, 2 case math.Abs(p-3) < 1e-9: return mfdP3, 3 case math.Abs(p-4) < 1e-9: return mfdP4, 4 default: return mfdGeneral, p } } func (g *Grid) mfdRaise(v float32) float32 { switch g.mfdMode { case mfdP1: return v case mfdP2: return v * v case mfdP3: return v * v * v case mfdP4: v2 := v * v return v2 * v2 default: return float32(math.Pow(float64(v), g.mfdExp)) } } // mfdQ is the perimeter share facing each D8 direction divided by the distance to it, in the order of dx8 // and dy8: NW N NE W E SW S SE. Cardinal 0.5, diagonal 0.25. var mfdQ = [8]float32{0.25, 0.5, 0.25, 0.5, 0.5, 0.25, 0.5, 0.25} // AccumulateMFD fills Area with multiple-flow drainage area, in m². // // The order is Kahn's algorithm over the flow graph rather than a sort by elevation, and neither of the two // obvious alternatives works. The D8 stack cannot be reused: BuildStack is a depth-first walk of the donor // tree, so a deep node of one subtree precedes a shallow node of the next and the order is not descending in // elevation - a cell would send area to a neighbour that had already been processed, and the loss would fall // on the flanks, which is exactly where it cannot be afforded. A bucket sort cannot either: the queue // quantises to a centimetre while the flood's epsilon ladder across a filled flat is a millimetre a cell, so // ten cells of one descending chain share a bucket and a lake bed would leak its area. // // Kahn needs no elevation comparison at all. mfdPending[i] is how many strictly higher neighbours i still // owes; a cell is ready when the count reaches zero. Because "strictly lower" is a strict order the graph is // acyclic, so every cell is released exactly once - which is asserted, because the alternative is a drainage // area that is quietly too small in a two-hour bake. // // The counting pass is inside this function and not folded into ComputeReceivers, which already reads all // eight neighbours and could have produced it for nothing. It was, and it was wrong: the walk *consumes* the // counts, so a second call without an intervening ComputeReceivers seeded its whole queue at once and // returned a drainage area that was silently wrong rather than panicking. Run happens to call the two in // lockstep, so nothing would have caught it there. A pass that owns its own preconditions cannot be misused // that way, and this one is a pure gather, so it parallelises and costs almost nothing in wall clock. func (g *Grid) AccumulateMFD(h []float32, p float64) { n := g.W * g.H g.mfdMode, g.mfdExp = mfdModeFor(p) field.Rows(g.H, func(y0, y1 int) { for y := y0; y < y1; y++ { for x := 0; x < g.W; x++ { i := y*g.W + x pend := uint8(0) for k := 0; k < 8; k++ { nx, ny := x+dx8[k], y+dy8[k] if nx < 0 || ny < 0 || nx >= g.W || ny >= g.H { continue } // How many neighbours will hand this cell a share: the ones strictly above it. The // weights below skip a neighbour when hn >= hc, so c sends to n exactly when // h[c] > h[n] - the same predicate, and it has to stay the same one or the walk ends // short. if h[ny*g.W+nx] > h[i] { pend++ } } g.mfdPending[i] = pend } } }) cell := float32(g.CellM * g.CellM) for i := range g.Area { g.Area[i] = cell } if cap(g.mfdQueue) < n { g.mfdQueue = make([]int32, 0, n) } q := g.mfdQueue[:0] for i := 0; i < n; i++ { if g.mfdPending[i] == 0 { q = append(q, int32(i)) } } // p = 1 is the default and it is a multiply; hoisting the mode test out of the cell loop saves a call // and a switch on every one of the eight faces of every cell of every step. linear := g.mfdMode == mfdP1 var wgt [8]float32 for read := 0; read < len(q); read++ { c := q[read] cx, cy := int(c)%g.W, int(c)/g.W hc := h[c] var total float32 for k := 0; k < 8; k++ { nx, ny := cx+dx8[k], cy+dy8[k] if nx < 0 || ny < 0 || nx >= g.W || ny >= g.H { wgt[k] = 0 continue } // The same predicate the counting pass above used, written the same way round, because the // counts and these weights have to agree cell for cell or the walk ends short. hn := h[ny*g.W+nx] if hn >= hc { wgt[k] = 0 continue } dh := hc - hn w := dh * mfdQ[k] if !linear { w = g.mfdRaise(w) } wgt[k] = w total += w } // A fixed cell is base level: it absorbs what arrives and sends nothing on. It still has to release // the cells below it, or their counts would never reach zero and the walk would end short - which is // why the release // loop below is not inside the `total > 0` branch. share := float32(0) if total > 0 && !g.fixed[c] { share = g.Area[c] / total } for k := 0; k < 8; k++ { if wgt[k] == 0 { continue } ni := int32((cy+dy8[k])*g.W + cx + dx8[k]) if share > 0 { g.Area[ni] += share * wgt[k] } g.mfdPending[ni]-- if g.mfdPending[ni] == 0 { q = append(q, ni) } } } g.mfdQueue = q if len(q) != n { // Unreachable unless the pending counts and the weights disagree about which neighbours are lower, // which would mean area silently going missing. Loud is the only useful behaviour here. panic("fluvial: MFD released " + itoa(len(q)) + " of " + itoa(n) + " cells; the pending counts and " + "the downslope test disagree") } } func itoa(v int) string { if v == 0 { return "0" } var b [20]byte i := len(b) for v > 0 { i-- b[i] = byte('0' + v%10) v /= 10 } return string(b[i:]) }