Files
pcb/scripts/finish_signal_routes.py
T
onur 95609a6b6a Complete the routing: 0 DRC violations, 0 unconnected
The board now routes end to end. Every net carrying two or more pads is a
single connected copper group (216 nets, 1175 pads); kicad-cli reports 0 DRC
violations and 0 unconnected items with zones refilled.

Freerouting was never the blocker. The 14 remaining opens were three separate
defects:

1. Obstacles in route_local_power.py modelled every pad as its axis-aligned
   bounding box. A 0.8-mm BGA dogbone site sits diagonally between four lands:
   true clearance 0.1407 mm, but the box test measured 0.4 mm from the box edge
   and rejected it, so no via could be placed anywhere in the Ti375 escape
   field. Replaced with an exact rounded-rectangle model, validated against
   KiCad's own SHAPE::Collide to 0.00 um over 14850 samples. Same 0 DRC and one
   fewer unconnected at the route_local_power stage.

2. CLOCK's 0.15-mm clearance cannot physically escape the BGA: two adjacent
   0.45-mm dogbone vias on 0.8-mm pitch leave a 0.35-mm channel and a 0.10-mm
   trace needs 0.40 mm. HARD_TCK and MAX_SCLK could never leave the package.
   New rule "Ti375 escape field copper clearance" relaxes clearance to the
   0.10 mm this field already uses, only where both objects are inside U1's
   courtyard. The clock trunk keeps 0.15 mm outside it.

3. RF_NO_DIGITAL was a rectangle reaching 7 mm past the RF line, covering the
   MAX2771's whole digital pin field and forcing every U21 output onto F.Cu
   alone. Narrowed to the corridor generate_pcb.py already reserves as
   TEMP_RF_LINE (y <= 43.7), clearing RF copper by 1.1 mm. The RF return path
   is guaranteed by the unrouted L2/L7 ground planes, not by this area.

scripts/close_remaining.py replaces the ad-hoc residual scripts: a multi-layer
maze over (x, y, layer) at 0.1 mm on all five routable layers, seeded from all
copper of both groups, exact clearances, 0.10-mm necking inside the escape
field, and reversible rip-up that rolls back entirely if a displaced net cannot
be put back. ~150 s for the whole board.

Also removes the regenerable intermediate boards, one-off session logs and the
superseded residual scripts, and tracks board_routed.* as the deliverable.

The 402 schematic-parity warnings are pre-existing and unchanged: 199
auto-named unconnected pins, 199 footprint library-nickname mismatches, and 4
mounting holes with no symbol. None affects connectivity or fabrication.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KaTmCCxCEW4awsmXRNT1d
2026-09-01 11:58:41 +03:00

404 lines
16 KiB
Python

#!/usr/bin/env python3
"""Close a small, explicit set of residual PCB connections.
This is deliberately a *finisher*, not a full autorouter. It operates on the
real KiCad board after the global Specctra route has been imported. For each
requested net it finds the disconnected copper groups, chooses anchors from
pads, existing track endpoints and vias, and runs a fine 3-D A* search through
the board's actual copper obstacle model. Every committed segment and via is
locked; KiCad DRC remains the acceptance test.
Unlike the older residual helpers, an endpoint does not need room for a new
via. A route may leave a fine-pitch pad on its own copper layer, travel to a
legal via site, and only then change layers. Existing same-net vias and track
ends are also valid start/goal anchors.
"""
from __future__ import annotations
import argparse
import heapq
import math
import shutil
import time
from dataclasses import dataclass
from pathlib import Path
import pcbnew
import route_local_power as rlp
# A 0.20-mm grid lands on both halves of the Ti375's 0.8-mm dogbone lattice
# while keeping the Python obstacle search tractable. Endpoint stubs retain
# their exact coordinates, so off-grid package pads are not displaced.
STEP = 0.20
VIA_DIAMETER = 0.45
VIA_DRILL = 0.225
CLASS_RULES = {
"Default": (0.12, 0.10),
"BGA_ESCAPE": (0.10, 0.10),
"CLOCK": (0.15, 0.15),
# Only low-current local rails may be passed to this finisher. Main 5-V
# and converter paths need deterministic wide copper instead.
"POWER": (0.25, 0.10),
}
def mm(value: int) -> float:
return round(pcbnew.ToMM(value), 4)
def MM(value: float) -> int:
return pcbnew.FromMM(value)
@dataclass(frozen=True)
class Anchor:
point: tuple[float, float]
layers: tuple[int, ...]
label: str
class FineMaze:
def __init__(self, board: pcbnew.BOARD) -> None:
self.board = board
self.layers = tuple(
layer for layer in board.GetEnabledLayers().CuStack()
if board.GetLayerType(layer) == pcbnew.LT_SIGNAL
)
self.layer_rank = {layer: index for index, layer in enumerate(self.layers)}
self.obstacles = rlp.Obstacles(board)
def track_clear(self, net: str, a: tuple[float, float], b: tuple[float, float],
layer: int, width: float, clearance: float) -> bool:
return self.obstacles.track_clear(
net, a, b, width, layer, clearance_floor=clearance
)
def via_clear(self, net: str, p: tuple[float, float], clearance: float) -> bool:
return self.obstacles.via_clear(net, p, clearance_floor=clearance)
@staticmethod
def _collapse(points: list[tuple[float, float, int]]) -> list[tuple[float, float, int]]:
if len(points) < 3:
return points
out = [points[0]]
for index in range(1, len(points) - 1):
a = out[-1]
b = points[index]
c = points[index + 1]
if a[2] == b[2] == c[2]:
cross = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0])
if abs(cross) < 1e-8:
continue
out.append(b)
out.append(points[-1])
return out
def search(self, net: str, start: Anchor, goal: Anchor, width: float,
clearance: float, margin: float, deadline_s: float = 90.0,
node_cap: int = 700_000) -> list[tuple[float, float, int]] | None:
sx, sy = start.point
gx, gy = goal.point
x0 = min(sx, gx) - margin
y0 = min(sy, gy) - margin
x1 = max(sx, gx) + margin
y1 = max(sy, gy) + margin
def snap(value: float) -> int:
return round(value / STEP)
ix0, iy0 = snap(x0), snap(y0)
ix1, iy1 = snap(x1), snap(y1)
def point(ix: int, iy: int) -> tuple[float, float]:
return round(ix * STEP, 4), round(iy * STEP, 4)
def heuristic(ix: int, iy: int) -> float:
px, py = point(ix, iy)
return math.hypot(px - gx, py - gy)
start_layers = tuple(layer for layer in start.layers if layer in self.layer_rank)
goal_layers = frozenset(layer for layer in goal.layers if layer in self.layer_rank)
if not start_layers or not goal_layers:
return None
queue: list[tuple[float, float, tuple[int, int, int]]] = []
cost: dict[tuple[int, int, int], float] = {}
parent: dict[tuple[int, int, int], tuple[int, int, int] | None] = {}
# Seed several nearby grid points. The exact pad/via/track endpoint to
# grid stub is checked just like every other segment; no unchecked snap
# segment can leak into the output.
base_x, base_y = snap(sx), snap(sy)
for layer in start_layers:
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
ix, iy = base_x + dx, base_y + dy
if not (ix0 <= ix <= ix1 and iy0 <= iy <= iy1):
continue
p = point(ix, iy)
if p != start.point and not self.track_clear(
net, start.point, p, layer, width, clearance):
continue
state = (ix, iy, layer)
initial = math.hypot(p[0] - sx, p[1] - sy)
if initial < cost.get(state, math.inf):
cost[state] = initial
parent[state] = None
heapq.heappush(queue, (initial + heuristic(ix, iy), initial, state))
if not queue:
return None
stop = time.monotonic() + deadline_s
hit: tuple[int, int, int] | None = None
visited = 0
directions = ((-1, -1), (-1, 0), (-1, 1), (0, -1),
(0, 1), (1, -1), (1, 0), (1, 1))
while queue and visited < node_cap and time.monotonic() < stop:
_score, current_cost, state = heapq.heappop(queue)
if current_cost > cost.get(state, math.inf) + 1e-9:
continue
visited += 1
ix, iy, layer = state
here = point(ix, iy)
if layer in goal_layers and math.hypot(here[0] - gx, here[1] - gy) <= 0.30:
if here == goal.point or self.track_clear(
net, here, goal.point, layer, width, clearance):
hit = state
break
for dx, dy in directions:
nx, ny = ix + dx, iy + dy
if not (ix0 <= nx <= ix1 and iy0 <= ny <= iy1):
continue
nxt_point = point(nx, ny)
if not self.track_clear(net, here, nxt_point, layer, width, clearance):
continue
nxt = (nx, ny, layer)
move = STEP * (math.sqrt(2.0) if dx and dy else 1.0)
# Prefer inner signal layers slightly, but do not ban F/B.
outer_penalty = 0.015 if layer in (pcbnew.F_Cu, pcbnew.B_Cu) else 0.0
new_cost = current_cost + move + outer_penalty
if new_cost + 1e-9 < cost.get(nxt, math.inf):
cost[nxt] = new_cost
parent[nxt] = state
heapq.heappush(queue, (new_cost + heuristic(nx, ny), new_cost, nxt))
if self.via_clear(net, here, clearance):
for next_layer in self.layers:
if next_layer == layer:
continue
nxt = (ix, iy, next_layer)
new_cost = current_cost + 2.0
if new_cost + 1e-9 < cost.get(nxt, math.inf):
cost[nxt] = new_cost
parent[nxt] = state
heapq.heappush(queue, (new_cost + heuristic(ix, iy), new_cost, nxt))
if hit is None:
return None
states = []
cursor: tuple[int, int, int] | None = hit
while cursor is not None:
states.append(cursor)
cursor = parent[cursor]
states.reverse()
result = [(sx, sy, states[0][2])]
result.extend((*point(ix, iy), layer) for ix, iy, layer in states)
result.append((gx, gy, states[-1][2]))
return self._collapse(result)
def _same_net_via_at(self, net: str, p: tuple[float, float]) -> bool:
for item in self.board.GetTracks():
if not isinstance(item, pcbnew.PCB_VIA) or item.GetNetname() != net:
continue
q = item.GetPosition()
if math.hypot(mm(q.x) - p[0], mm(q.y) - p[1]) < 0.02:
return True
return False
def add_track(self, net: str, a: tuple[float, float], b: tuple[float, float],
layer: int, width: float) -> None:
if math.hypot(a[0] - b[0], a[1] - b[1]) < 1e-6:
return
track = pcbnew.PCB_TRACK(self.board)
track.SetStart(pcbnew.VECTOR2I_MM(*a))
track.SetEnd(pcbnew.VECTOR2I_MM(*b))
track.SetWidth(MM(width))
track.SetLayer(layer)
track.SetNet(self.board.FindNet(net))
track.SetLocked(True)
self.board.Add(track)
track.thisown = False
self.obstacles.add_track(net, a, b, width, layer)
def add_via(self, net: str, p: tuple[float, float]) -> None:
if self._same_net_via_at(net, p):
return
via = pcbnew.PCB_VIA(self.board)
via.SetPosition(pcbnew.VECTOR2I_MM(*p))
via.SetWidth(MM(VIA_DIAMETER))
via.SetDrill(MM(VIA_DRILL))
via.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)
via.SetNet(self.board.FindNet(net))
via.SetLocked(True)
self.board.Add(via)
via.thisown = False
self.obstacles.add_via(net, p, VIA_DIAMETER / 2, VIA_DRILL / 2)
def commit(self, net: str, path: list[tuple[float, float, int]], width: float) -> None:
for a, b in zip(path, path[1:]):
if a[2] == b[2]:
self.add_track(net, a[:2], b[:2], a[2], width)
else:
if math.hypot(a[0] - b[0], a[1] - b[1]) > 0.02:
raise RuntimeError(f"Non-vertical layer change in {net}: {a} -> {b}")
self.add_via(net, a[:2])
def item_anchor(item, routing_layers: tuple[int, ...], label: str) -> list[Anchor]:
if isinstance(item, pcbnew.PAD):
p = item.GetPosition()
layers = tuple(layer for layer in item.GetLayerSet().Seq() if layer in routing_layers)
return [Anchor((mm(p.x), mm(p.y)), layers, label)] if layers else []
if isinstance(item, pcbnew.PCB_VIA):
p = item.GetPosition()
return [Anchor((mm(p.x), mm(p.y)), routing_layers, label)]
if isinstance(item, pcbnew.PCB_TRACK):
layer = item.GetLayer()
if layer not in routing_layers:
return []
a, b = item.GetStart(), item.GetEnd()
return [Anchor((mm(a.x), mm(a.y)), (layer,), label + ":start"),
Anchor((mm(b.x), mm(b.y)), (layer,), label + ":end")]
return []
def copper_groups(board: pcbnew.BOARD, net: str,
routing_layers: tuple[int, ...]) -> list[tuple[list[pcbnew.PAD], list[Anchor]]]:
connectivity = board.GetConnectivity()
pads = [pad for fp in board.GetFootprints() for pad in fp.Pads()
if pad.GetNetname() == net]
seen: set[str] = set()
groups = []
for pad in pads:
uid = pad.m_Uuid.AsString()
if uid in seen:
continue
connected = list(connectivity.GetConnectedItems(pad, 0))
if all(item.m_Uuid.AsString() != uid for item in connected):
connected.append(pad)
group_pads = [item for item in connected if isinstance(item, pcbnew.PAD)]
if not group_pads:
group_pads = [pad]
seen.update(item.m_Uuid.AsString() for item in group_pads)
anchors: list[Anchor] = []
anchor_seen = set()
for index, item in enumerate(connected):
for anchor in item_anchor(item, routing_layers, f"{item.GetClass()}#{index}"):
key = (anchor.point, anchor.layers)
if key not in anchor_seen:
anchors.append(anchor)
anchor_seen.add(key)
if not anchors:
anchors.extend(item_anchor(pad, routing_layers, "pad"))
groups.append((group_pads, anchors))
return groups
def class_rule(board: pcbnew.BOARD, net: str) -> tuple[float, float, str]:
for fp in board.GetFootprints():
for pad in fp.Pads():
if pad.GetNetname() == net:
name = pad.GetNetClassName()
width, clearance = CLASS_RULES.get(name, CLASS_RULES["Default"])
return width, clearance, name
raise RuntimeError(f"Net {net} has no pads")
def finish_net(maze: FineMaze, net: str) -> int:
board = maze.board
joined = 0
while True:
board.BuildConnectivity()
groups = copper_groups(board, net, maze.layers)
if len(groups) < 2:
return joined
groups.sort(key=lambda group: len(group[0]), reverse=True)
main = groups[0]
branch = min(groups[1:], key=lambda group: len(group[0]))
width, clearance, class_name = class_rule(board, net)
pairs = []
for source in branch[1]:
for target in main[1]:
distance = math.hypot(source.point[0] - target.point[0],
source.point[1] - target.point[1])
pairs.append((distance, source, target))
pairs.sort(key=lambda entry: entry[0])
routed = False
for distance, source, target in pairs[:10]:
for margin in (5.0, 12.0):
path = maze.search(net, source, target, width, clearance, margin,
deadline_s=20.0, node_cap=240_000)
if path is None:
continue
maze.commit(net, path, width)
board.BuildConnectivity()
print(f"{net}: joined {source.label} -> {target.label}; "
f"class={class_name} width={width:.3f} mm span={distance:.1f} mm")
joined += 1
routed = True
break
if routed:
break
if not routed:
print(f"{net}: FAILED ({len(groups)} copper groups, {len(pairs)} anchor pairs)")
return joined
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("board", type=Path)
parser.add_argument("--output", type=Path)
parser.add_argument("--nets", required=True,
help="Comma- or semicolon-separated residual net names")
args = parser.parse_args()
output = args.output or args.board
nets = [name.strip() for name in args.nets.replace(";", ",").split(",")
if name.strip()]
board = pcbnew.LoadBoard(str(args.board))
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Zone refill failed before residual routing")
board.BuildConnectivity()
maze = FineMaze(board)
for net in nets:
if board.FindNet(net) is None:
raise RuntimeError(f"Unknown net: {net}")
finish_net(maze, net)
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
raise RuntimeError("Zone refill failed after residual routing")
board.BuildConnectivity()
pcbnew.SaveBoard(str(output), board)
if output != args.board:
for suffix in (".kicad_pro", ".kicad_dru"):
source = args.board.with_suffix(suffix)
if source.exists():
shutil.copyfile(source, output.with_suffix(suffix))
print(f"Unconnected endpoints: {board.GetConnectivity().GetUnconnectedCount(True)}")
print(f"Saved {output}")
if __name__ == "__main__":
main()