|
| 1 | +"""Candidate graph for the Level-3 trunk-network MILP. |
| 2 | +
|
| 3 | +The MILP optimises over a **candidate graph**: nodes are the sources, the sinks, and a set of |
| 4 | +**junction candidates** (a coarse grid over the study area) where flows may combine into trunks; |
| 5 | +arcs are a pruned set of near-neighbour links. Each arc carries the COMET-cost of the least-cost |
| 6 | +path between its endpoints (``s_cost``, from ``r.cost``) and a length estimate — these become the |
| 7 | +per-arc coefficients of the MILP. |
| 8 | +
|
| 9 | +The graph-building helpers (:func:`junction_grid`, :func:`candidate_arcs`) are pure Python and |
| 10 | +unit-tested; :func:`build_candidate_graph` adds the GRASS ``r.cost`` step (one run per node gives |
| 11 | +that node's least-cost cost to every other node), validated in QGIS. |
| 12 | +""" |
| 13 | + |
| 14 | +from __future__ import annotations |
| 15 | + |
| 16 | +import os |
| 17 | +import shutil |
| 18 | +import tempfile |
| 19 | +from collections import defaultdict |
| 20 | +from dataclasses import dataclass |
| 21 | +from typing import Sequence |
| 22 | + |
| 23 | +from qgis.core import QgsPointXY, QgsRasterLayer |
| 24 | + |
| 25 | +from ...constants.lcp import DEFAULT_RCOST_MEMORY_MB |
| 26 | +from ..capex import get_raster_value_at_point |
| 27 | +from ..lcp import run_r_cost |
| 28 | +from .model import Node, distance |
| 29 | +from .routing import _coord, _slug |
| 30 | + |
| 31 | +JUNCTION = "junction" |
| 32 | + |
| 33 | + |
| 34 | +@dataclass |
| 35 | +class CandidateArc: |
| 36 | + """An undirected candidate link between two nodes. |
| 37 | +
|
| 38 | + ``s_cost`` is the COMET-cost summation of the least-cost path between the endpoints (from |
| 39 | + ``r.cost``); ``length`` is a planar length estimate (refined by ``r.drain`` only for the arcs |
| 40 | + the MILP selects). The MILP adds directional flow on top of these symmetric coefficients. |
| 41 | + """ |
| 42 | + |
| 43 | + u_id: str |
| 44 | + v_id: str |
| 45 | + s_cost: float |
| 46 | + length: float |
| 47 | + |
| 48 | + |
| 49 | +def junction_grid(anchors: Sequence[Node], spacing: float, margin: float = 0.0) -> list[Node]: |
| 50 | + """Regular grid of junction-candidate nodes over the ``anchors``' bounding box (+ ``margin``). |
| 51 | +
|
| 52 | + ``spacing`` (map units) is the density knob — smaller spacing = more candidates = a graph closer |
| 53 | + to the true optimum but a larger/slower MILP. Returns junction :class:`Node` objects (``kind`` = |
| 54 | + :data:`JUNCTION`, no flow/capacity). |
| 55 | + """ |
| 56 | + if spacing <= 0: |
| 57 | + raise ValueError("spacing must be positive.") |
| 58 | + xs = [n.x for n in anchors] |
| 59 | + ys = [n.y for n in anchors] |
| 60 | + minx, maxx = min(xs) - margin, max(xs) + margin |
| 61 | + miny, maxy = min(ys) - margin, max(ys) + margin |
| 62 | + |
| 63 | + nodes = [] |
| 64 | + idx = 0 |
| 65 | + x = minx |
| 66 | + while x <= maxx + 1e-9: |
| 67 | + y = miny |
| 68 | + while y <= maxy + 1e-9: |
| 69 | + nodes.append(Node(id=f"J{idx}", x=x, y=y, kind=JUNCTION)) |
| 70 | + idx += 1 |
| 71 | + y += spacing |
| 72 | + x += spacing |
| 73 | + return nodes |
| 74 | + |
| 75 | + |
| 76 | +def candidate_arcs(nodes: Sequence[Node], k: int = 6) -> list[tuple]: |
| 77 | + """Prune the O(n²) arc set to each node's ``k`` nearest neighbours (undirected, de-duplicated). |
| 78 | +
|
| 79 | + Returns a deterministic, sorted list of ``(u_id, v_id)`` unordered pairs (``u_id < v_id``). |
| 80 | + """ |
| 81 | + pairs = set() |
| 82 | + for a in nodes: |
| 83 | + nearest = sorted((b for b in nodes if b.id != a.id), key=lambda b: distance(a, b))[:k] |
| 84 | + for b in nearest: |
| 85 | + pairs.add(tuple(sorted((a.id, b.id)))) |
| 86 | + return sorted(pairs) |
| 87 | + |
| 88 | + |
| 89 | +def build_candidate_graph( |
| 90 | + combined_raster_path: str, |
| 91 | + sources: Sequence[Node], |
| 92 | + sinks: Sequence[Node], |
| 93 | + spacing: float, |
| 94 | + k: int = 6, |
| 95 | + memory: int = DEFAULT_RCOST_MEMORY_MB, |
| 96 | + log=lambda msg: None, |
| 97 | +) -> tuple: |
| 98 | + """Build the candidate graph and its per-arc COMET costs for the MILP. |
| 99 | +
|
| 100 | + Nodes = ``sources`` + ``sinks`` + a junction grid (:func:`junction_grid`); arcs = the pruned |
| 101 | + near-neighbour set (:func:`candidate_arcs`). One ``r.cost`` per node yields its least-cost cost |
| 102 | + to every other node (read at each neighbour's cell) → the per-arc ``s_cost``. |
| 103 | +
|
| 104 | + :returns: ``(nodes, arcs)`` — ``nodes`` the full node list, ``arcs`` a list of :class:`CandidateArc`. |
| 105 | + Does NOT touch the project. |
| 106 | + """ |
| 107 | + junctions = junction_grid(list(sources) + list(sinks), spacing) |
| 108 | + nodes = list(sources) + list(sinks) + junctions |
| 109 | + node_by_id = {n.id: n for n in nodes} |
| 110 | + arc_pairs = candidate_arcs(nodes, k) |
| 111 | + log(f"Candidate graph: {len(nodes)} nodes ({len(junctions)} junctions), {len(arc_pairs)} arcs.") |
| 112 | + |
| 113 | + neighbours = defaultdict(set) |
| 114 | + for u, v in arc_pairs: |
| 115 | + neighbours[u].add(v) |
| 116 | + neighbours[v].add(u) |
| 117 | + |
| 118 | + tmp = tempfile.mkdtemp() |
| 119 | + s_cost: dict = {} # frozenset(u_id, v_id) -> min COMET-cost seen (symmetrise both directions) |
| 120 | + try: |
| 121 | + for i, node in enumerate(nodes, start=1): |
| 122 | + nbrs = neighbours[node.id] |
| 123 | + if not nbrs: |
| 124 | + continue |
| 125 | + log(f" r.cost {i}/{len(nodes)} from '{node.id}'...") |
| 126 | + cost_out = os.path.join(tmp, f"cost_{_slug(node.id)}.tif") |
| 127 | + dir_out = os.path.join(tmp, f"dir_{_slug(node.id)}.tif") |
| 128 | + run_r_cost(combined_raster_path, _coord(node), cost_out, dir_out, memory=memory) |
| 129 | + |
| 130 | + layer = QgsRasterLayer(cost_out, "cost") |
| 131 | + if not layer.isValid(): |
| 132 | + continue |
| 133 | + for vid in nbrs: |
| 134 | + v = node_by_id[vid] |
| 135 | + val = get_raster_value_at_point(layer, QgsPointXY(v.x, v.y)) |
| 136 | + if val is None: |
| 137 | + continue |
| 138 | + key = frozenset((node.id, vid)) |
| 139 | + s_cost[key] = min(s_cost.get(key, val), float(val)) |
| 140 | + finally: |
| 141 | + shutil.rmtree(tmp, ignore_errors=True) |
| 142 | + |
| 143 | + arcs = [] |
| 144 | + for u, v in arc_pairs: |
| 145 | + key = frozenset((u, v)) |
| 146 | + if key not in s_cost: # endpoint unreachable from the other on the cost surface |
| 147 | + continue |
| 148 | + arcs.append(CandidateArc(u_id=u, v_id=v, s_cost=s_cost[key], length=distance(node_by_id[u], node_by_id[v]))) |
| 149 | + |
| 150 | + log(f"✓ Candidate graph: {len(arcs)} arcs with COMET costs.") |
| 151 | + return nodes, arcs |
0 commit comments