Skip to content

Commit 4116c37

Browse files
authored
feat(networks): candidate graph + all-pairs cost matrix for the Level-3 MILP (#83)
First phase of the Level-3 trunk-network MILP: the candidate graph it optimises over. - src/core/networks/candidate_graph.py: - junction_grid(anchors, spacing, margin) — a grid of junction-candidate nodes over the sources/sinks bounding box; spacing is the density↔tractability knob. Pure. - candidate_arcs(nodes, k) — prune the O(n²) arc set to each node's k nearest neighbours (undirected, de-duplicated, sorted). Pure. - build_candidate_graph(...) — nodes = sources + sinks + junction grid, arcs = the pruned set; one r.cost per node gives its least-cost COMET cost to every other node (read at each neighbour's cell with get_raster_value_at_point) → per-arc s_cost; length is a planar estimate (refined by r.drain only for selected arcs later). Reuses run_r_cost / _coord / _slug. - tests: junction grid coverage/margin/validation, k-NN arc pruning (undirected/deduped/sorted). 45 unit tests pass. Part of #79
1 parent e46b29e commit 4116c37

2 files changed

Lines changed: 196 additions & 0 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Unit tests for the pure candidate-graph helpers (``src/core/networks/candidate_graph.py``).
2+
3+
The junction grid and the k-nearest arc pruning are pure Python (no GRASS); the r.cost step in
4+
``build_candidate_graph`` is validated in QGIS, not here.
5+
"""
6+
7+
import pytest
8+
9+
from src.core.networks.candidate_graph import JUNCTION, candidate_arcs, junction_grid
10+
from src.core.networks.model import SINK, SOURCE, Node
11+
12+
13+
def test_junction_grid_covers_the_bounding_box():
14+
anchors = [Node("S1", 0.0, 0.0, SOURCE), Node("K1", 10.0, 10.0, SINK)]
15+
grid = junction_grid(anchors, spacing=5.0)
16+
# x, y each ∈ {0, 5, 10} → 3×3 nodes, all junctions.
17+
assert len(grid) == 9
18+
assert all(n.kind == JUNCTION for n in grid)
19+
coords = {(n.x, n.y) for n in grid}
20+
assert {(0.0, 0.0), (5.0, 5.0), (10.0, 10.0)} <= coords
21+
22+
23+
def test_junction_grid_applies_the_margin():
24+
grid = junction_grid([Node("S1", 0.0, 0.0, SOURCE)], spacing=5.0, margin=5.0)
25+
# single anchor, ±5 margin → x, y ∈ {-5, 0, 5} → 9 nodes.
26+
assert len(grid) == 9
27+
28+
29+
def test_junction_grid_rejects_non_positive_spacing():
30+
with pytest.raises(ValueError):
31+
junction_grid([Node("S1", 0.0, 0.0, SOURCE)], spacing=0.0)
32+
33+
34+
def test_candidate_arcs_are_knn_undirected_deduped_sorted():
35+
nodes = [Node("A", 0.0, 0.0, JUNCTION), Node("B", 1.0, 0.0, JUNCTION), Node("C", 5.0, 0.0, JUNCTION)]
36+
# nearest: A↔B, B↔A, C→B → the undirected set {A-B, B-C}, sorted with u_id < v_id.
37+
assert candidate_arcs(nodes, k=1) == [("A", "B"), ("B", "C")]
38+
39+
40+
def test_candidate_arcs_more_neighbours_include_all_close_pairs():
41+
nodes = [Node("A", 0.0, 0.0, JUNCTION), Node("B", 1.0, 0.0, JUNCTION), Node("C", 2.0, 0.0, JUNCTION)]
42+
arcs = candidate_arcs(nodes, k=2)
43+
# every pair is within the 2 nearest → the full undirected set.
44+
assert arcs == [("A", "B"), ("A", "C"), ("B", "C")]
45+
assert all(u < v for u, v in arcs) # unordered pairs, u_id < v_id

0 commit comments

Comments
 (0)