diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index db2f5fd..7f80a2a 100644 --- a/src/freeroute/cli.py +++ b/src/freeroute/cli.py @@ -198,6 +198,20 @@ def main(argv: list[str] | None = None) -> int: ) ses_text = write_ses(dsn, result) + missing = [u for u in result.unrouted if not u[3]] + in_zone = [u for u in result.unrouted if u[3]] + if missing: + # the SES is still written; say plainly what it is missing + print(f"freeroute: {len(missing)} connection(s) not routed:", file=sys.stderr) + for net, a, b, _zone in missing: + print(f" {net}: {a} to {b}", file=sys.stderr) + if in_zone: + print( + f"freeroute: {len(in_zone)} more connection(s) left to a zone fill that may join them:", + file=sys.stderr, + ) + for net, a, b, zone in in_zone: + print(f" {net}: {a} to {b} (both in zone {zone})", file=sys.stderr) if args.output: with open(args.output, "w", encoding="utf-8") as f: diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index d16663f..e377a2b 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -159,7 +159,11 @@ def route_board_exact( # Verify connection by connection, so one conflicting segment of a # 25-pin supply net costs that one connection, not the whole net. - for net_no, wires, vias in sorted(result.connections, key=lambda c: c[0]): + rejected: list[tuple[int, str, str]] = [] + ends_list = result.connection_ends or [("?", "?", "")] * len(result.connections) + for (net_no, wires, vias), ends in sorted( + zip(result.connections, ends_list, strict=False), key=lambda c: c[0][0] + ): boxes = _copper_boxes(wires, vias, half_width, via_layers, via_half) wire_count = len(_copper_boxes(wires, [], half_width, via_layers, via_half)) if any( @@ -169,6 +173,7 @@ def route_board_exact( for i, (box, box_layers) in enumerate(boxes) for layer in box_layers ) or (outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes)): + rejected.append((net_no, *ends)) continue # exact-clearance conflict -> drop (may be recovered by shove) acc = accepted.get(net_no) if acc is None: @@ -180,6 +185,7 @@ def route_board_exact( tree.insert(TreeShape(oid, net_no, box_layers, box, routed=True)) acc.owners.append(oid) + accepted_before_recovery = set(accepted) # a net is dropped if it has a real ratsnest (>= 2 pins on a signal layer) # but was not accepted — whether the grid failed to route it or its exact # copper conflicted. @@ -214,6 +220,12 @@ def route_board_exact( ) clean = RouteResult(half_width) + # what is still missing: what the grid could not route, plus what the + # exact check rejected, less any net shove or the diagonal pass recovered + clean.failed = [ + *result.failed, + *(r for r in rejected if not (r[0] in accepted and r[0] not in accepted_before_recovery)), + ] for net_no, acc in accepted.items(): clean.wires[net_no] = acc.wires clean.vias[net_no] = acc.vias diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 7361a6d..23b963a 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -59,7 +59,7 @@ class RouteResult: ``routed_net_numbers`` are the nets that got any wire or via. """ - __slots__ = ("wires", "vias", "half_width", "unrouted", "connections") + __slots__ = ("wires", "vias", "half_width", "unrouted", "connections", "connection_ends", "failed") def __init__(self, half_width: int) -> None: self.wires: dict[int, list[tuple[int, list[IntPoint]]]] = {} @@ -69,6 +69,11 @@ class RouteResult: self.unrouted = 0 #: the same copper per routed connection: (net_no, wires, vias) self.connections: list[tuple[int, list[tuple[int, list[IntPoint]]], list[IntPoint]]] = [] + #: what each routed connection joins, and the zone both ends sit in + #: (or ""), parallel to ``connections`` + self.connection_ends: list[tuple[str, str, str]] = [] + #: connections left unrouted: (net_no, one end, the other end) + self.failed: list[tuple[int, str, str]] = [] def add_wire(self, net_no: int, layer: int, path: list[IntPoint]) -> None: self.wires.setdefault(net_no, []).append((layer, path)) @@ -523,6 +528,16 @@ class GridRouter: covered = sum((*cell, zone.layer) in self._keepout for cell in cells) return covered >= coverage * len(cells) + def _left_to_zone(self, conn) -> str: + """The zone both ends of an unrouted connection sit in, if any: its + fill may well join them, even when it is not trusted for routing.""" + for zone in self._every_zone_by_net.get(conn.net_no, []): + if any(_pin_touches_zone(p, zone) for p in conn.a_group[0]) and any( + _pin_touches_zone(p, zone) for p in conn.b_group[0] + ): + return zone.name or "zone" + return "" + def _group_ends(self, group, net_no: int, conns) -> dict[Node, Pin | None]: """End nodes of a group, each mapped to its pin (None for a zone cell).""" ends: dict[Node, Pin | None] = {} @@ -668,6 +683,7 @@ class GridRouter: def route(self) -> RouteResult: """Route all ratsnest connections, using rip-up-and-retry across passes.""" conns = self._build_connections() + self._conns = conns if not conns: return RouteResult(self.half_width) @@ -709,6 +725,10 @@ class GridRouter: def _build_connections(self) -> list[_Connection]: pins_by_net = self._pins_by_net() zones_by_net: dict[int, list] = {} + self._every_zone_by_net: dict[int, list] = {} + for zone in self.board.get_conduction_areas(): + if zone.layer in self.layers and zone.net_nos: + self._every_zone_by_net.setdefault(zone.net_nos[0], []).append(zone) for zone in self.board.get_conduction_areas(): if zone.layer in self.layers and zone.net_nos and self._zone_is_solid(zone): zones_by_net.setdefault(zone.net_nos[0], []).append(zone) @@ -732,9 +752,10 @@ class GridRouter: by_net.setdefault(net_no, []).append(pin) return by_net - def _snapshot(self, conns: list[_Connection]) -> list[tuple[int, list, list]]: + def _snapshot(self, conns: list[_Connection]) -> list[tuple]: return [ - (c.net_no, [(layer, list(pts)) for layer, pts in c.wires], list(c.vias)) + (c.net_no, [(layer, list(pts)) for layer, pts in c.wires], list(c.vias), + (*_ends_of(c), self._left_to_zone(c))) for c in conns if c.routed ] @@ -742,12 +763,19 @@ class GridRouter: def _result_from(self, snapshot, unrouted: int) -> RouteResult: result = RouteResult(self.half_width) result.unrouted = unrouted - for net_no, wires, vias in snapshot: + for net_no, wires, vias, ends in snapshot: for layer, points in wires: result.add_wire(net_no, layer, points) for location in vias: result.add_via(net_no, location) result.connections.append((net_no, wires, vias)) + result.connection_ends.append(ends) + routed = {ends[:2] + (net_no,) for net_no, _w, _v, ends in snapshot} + result.failed = [ + (c.net_no, *_ends_of(c), self._left_to_zone(c)) + for c in self._conns + if _ends_of(c) + (c.net_no,) not in routed + ] return result @@ -755,6 +783,20 @@ def _first(items): return items[0] if items else None +def _group_label(group) -> str: + pins, zones = group + parts = [f"{p.component_ref}-{p.name}" for p in pins[:3]] + if len(pins) > 3: + parts.append(f"+{len(pins) - 3} pins") + parts += [f"zone {z.name}" for z in zones[:1]] + return "/".join(parts) or "?" + + +def _ends_of(conn) -> tuple[str, str]: + """A readable description of what a connection joins.""" + return _group_label(conn.a_group), _group_label(conn.b_group) + + def _pin_touches_zone(pin: Pin, zone) -> bool: """A pad on the zone's layer whose centre or a corner lies in the zone.""" if zone.layer not in pin.layers or zone.polygon is None: diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index f5cf203..1b24a66 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -126,6 +126,9 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str]) name = numbers_to_names.get(net_no, str(net_no)) for loc in locations: result.add_via(name, RoutedVia(padstack=via_name, x=loc.x / scale, y=loc.y / scale)) + result.unrouted = [ + (numbers_to_names.get(n, str(n)), a, b, zone) for n, a, b, zone in getattr(route, "failed", []) + ] return result diff --git a/src/freeroute/ses/model.py b/src/freeroute/ses/model.py index 8e104b1..760b3e2 100644 --- a/src/freeroute/ses/model.py +++ b/src/freeroute/ses/model.py @@ -57,6 +57,9 @@ class RoutingResult: """The complete routing output for a board, grouped by net.""" nets: list[RoutedNet] = field(default_factory=list) + #: connections the router could not make: (net name, one end, other end, + #: the zone both ends sit in or "") -- a zone's fill may join the latter + unrouted: list[tuple[str, str, str, str]] = field(default_factory=list) def net(self, name: str, *, create: bool = False) -> RoutedNet | None: """Return the :class:`RoutedNet` with ``name`` (creating it if asked).""" diff --git a/tests/route/test_real_board_geometry.py b/tests/route/test_real_board_geometry.py index 7ce7b55..6e20e0b 100644 --- a/tests/route/test_real_board_geometry.py +++ b/tests/route/test_real_board_geometry.py @@ -148,3 +148,19 @@ def test_a_pour_other_nets_can_cross_is_not_trusted_to_connect(): result, _, _ = route_dsn_board_exact(dsn) net = next(iter(result.routed_net_numbers)) assert result.result.wires.get(net) # P1 and P2 got a track between them + + +def test_cli_names_the_connections_it_could_not_route(tmp_path, capsys): + # a keepout wall across the board on both layers: A and B cannot be joined + from freeroute.cli import main + + wall = " ".join( + f"(keepout \"wall\" (polygon {layer} 0 9000 1000 11000 1000 11000 -21000 9000 -21000))" + for layer in ("F.Cu", "B.Cu") + ) + dsn = tmp_path / "wall.dsn" + dsn.write_text(_dsn("(component P (place A 4000 -10000 front 0) (place B 16000 -10000 front 0))", + "(net N (pins A-1 B-1))", wall)) + assert main([str(dsn), "--engine", "exact", "-do", str(tmp_path / "out.ses")]) == 0 + err = capsys.readouterr().err + assert "1 connection(s) not routed" in err and "N: A-1 to B-1" in err