diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index bed9c76..272991a 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -141,31 +141,42 @@ def build_exact_routing_result( def route_dsn_board_rooms( - dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False + dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False, pack: bool = False ) -> tuple[ExactRouteResult, int, list[str]]: """Route a parsed :class:`DsnBoard` with the continuous expansion-room track; return the result, the scale, and layer names. ``shove`` enables occupancy- - aware gate placement and shove-in-rooms recovery of otherwise-dropped nets.""" + aware gate placement and shove-in-rooms recovery of otherwise-dropped nets. + ``pack`` enables the coordinated multi-trace channel packer.""" board = build_board(dsn) scale = max(dsn.resolution.value, 1) width_board = round(_rule_width_dsn(dsn) * scale) clearance_board = round(_rule_clearance_dsn(dsn) * scale) rooms = route_board_rooms( - board, trace_width=width_board, clearance=clearance_board, layers=layers, shove=shove + board, + trace_width=width_board, + clearance=clearance_board, + layers=layers, + shove=shove, + pack=pack, ) return rooms, scale, [layer.name for layer in dsn.layers] def build_rooms_routing_result( - dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False + dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False, pack: bool = False ) -> RoutingResult: """Route ``dsn`` (room track) and convert to a DSN-unit RoutingResult.""" - rooms, scale, layer_names = route_dsn_board_rooms(dsn, layers=layers, shove=shove) + rooms, scale, layer_names = route_dsn_board_rooms(dsn, layers=layers, shove=shove, pack=pack) return _to_routing_result(rooms.result, dsn, scale, layer_names) def route( - dsn_text: str, *, layers: list[int] | None = None, engine: str = "grid", shove: bool = False + dsn_text: str, + *, + layers: list[int] | None = None, + engine: str = "grid", + shove: bool = False, + pack: bool = False, ) -> str: """Route a Specctra DSN string and return the routed SES string. @@ -181,7 +192,7 @@ def route( if engine == "exact": result = build_exact_routing_result(dsn, layers=layers, shove=shove) elif engine == "room": - result = build_rooms_routing_result(dsn, layers=layers, shove=shove) + result = build_rooms_routing_result(dsn, layers=layers, shove=shove, pack=pack) else: result = build_routing_result(dsn, layers=layers) return write_ses(dsn, result) diff --git a/src/freeroute/route/room_router.py b/src/freeroute/route/room_router.py index 2d57b3c..1aa0581 100644 --- a/src/freeroute/route/room_router.py +++ b/src/freeroute/route/room_router.py @@ -468,6 +468,7 @@ def route_board_rooms( layers: list[int] | None = None, via_cost: float = 50000.0, shove: bool = False, + pack: bool = False, max_shove_depth: int = 5, shove_cap: int = 3, ) -> ExactRouteResult: @@ -481,6 +482,13 @@ def route_board_rooms( ``shove=False`` every new branch is skipped and the output is byte-for-byte the foundation router. ``max_shove_depth`` bounds the distinct blocker nets per recovery; ``shove_cap`` bounds how often one trace may be moved. + + ``pack`` (opt-in, independent of ``shove``) runs the coordinated channel + packer first: groups of 2-pin nets that must cross a common obstacle gap are + assigned consistent parallel lanes across the gap and routed together, so a + channel that is physically wide enough for all of them does not drop nets to + greedy per-net gate collisions. With ``pack=False`` the pre-pass is skipped + and the output is unchanged. """ signal_layers = layers or [ i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal @@ -513,7 +521,26 @@ def route_board_rooms( via_layers = frozenset(signal_layers) accepted: dict[int, _Accepted] = {} shoves = 0 + packed: set[int] = set() + if pack: + packed = _pack_channels( + board, + tree, + result, + owner, + outline, + signal_layers, + half_width, + clearance, + margin, + trace_width, + via_layers, + accepted, + shove, + ) for net_no in sorted(_routable_nets(board, signal_layers)): + if net_no in packed: + continue pins = _net_pins(board, net_no, signal_layers) for a, b in zip(pins, pins[1:], strict=False): shoves += _route_connection( @@ -586,7 +613,9 @@ def _route_connection( for box, box_layers in boxes for layer in box_layers ): - _commit(net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted) + _commit( + net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted + ) return 0 # the net would be dropped: try a transactional shove-in-rooms recovery @@ -672,6 +701,337 @@ def _commit(net_no, wires, vias, tree, result, owner, half_width, via_layers, sh acc.via_owners.append(oid) +# --- coordinated channel packing --------------------------------------------- + + +def _long(p: IntPoint, axis: str) -> int: + """Coordinate along a channel's length (the axis nets travel).""" + return p.y if axis == "v" else p.x + + +def _trans(p: IntPoint, axis: str) -> int: + """Coordinate across a channel (the axis lanes spread over).""" + return p.x if axis == "v" else p.y + + +def _box_long(b: IntBox, axis: str) -> tuple[int, int]: + return (b.ll.y, b.ur.y) if axis == "v" else (b.ll.x, b.ur.x) + + +def _box_trans(b: IntBox, axis: str) -> tuple[int, int]: + return (b.ll.x, b.ur.x) if axis == "v" else (b.ll.y, b.ur.y) + + +def _outline_trans(outline: IntBox, axis: str) -> tuple[int, int]: + return (outline.min_x, outline.max_x) if axis == "v" else (outline.min_y, outline.max_y) + + +def _axis_point(long_val: int, trans_val: int, axis: str) -> IntPoint: + """Build a point from (length-coord, transverse-coord) for the given axis.""" + return IntPoint(trans_val, long_val) if axis == "v" else IntPoint(long_val, trans_val) + + +def _lane_polyline( + top: IntPoint, bot: IntPoint, lane: int, trunk_top: int, trunk_bot: int, axis: str +) -> list[IntPoint]: + """A pad -> trunk -> lane -> trunk -> pad orthogonal trace. + + Each net converges to its lane on a *staggered* trunk line (``trunk_top`` / + ``trunk_bot``, a distinct length-coordinate per net just outside the gap), runs + straight through the channel in its lane, then diverges to the far pad on the + other trunk. Distinct trunk lines keep the fan-in horizontals from overlapping + when pads spread wider than the gap. ``top`` is the pin with the larger + length-coordinate. Collinear/duplicate points collapse.""" + tt = _trans(top, axis) + tb = _trans(bot, axis) + pts = [ + top, + _axis_point(trunk_top, tt, axis), + _axis_point(trunk_top, lane, axis), + _axis_point(trunk_bot, lane, axis), + _axis_point(trunk_bot, tb, axis), + bot, + ] + return _dedupe(pts) + + +def _wall_rows(obstacles: list[IntBox], axis: str) -> list[list[IntBox]]: + """Cluster obstacle boxes into walls whose blocked length-ranges overlap; each + cluster is one barrier that nets cross transversely through its gaps.""" + boxes = sorted(obstacles, key=lambda b: (_box_long(b, axis), _box_trans(b, axis))) + used = [False] * len(boxes) + rows: list[list[IntBox]] = [] + for i, b in enumerate(boxes): + if used[i]: + continue + used[i] = True + cluster = [b] + lo, hi = _box_long(b, axis) + changed = True + while changed: + changed = False + for j, c in enumerate(boxes): + if used[j]: + continue + clo, chi = _box_long(c, axis) + if clo < hi and chi > lo: + used[j] = True + cluster.append(c) + lo, hi = min(lo, clo), max(hi, chi) + changed = True + rows.append(cluster) + return rows + + +def _channel_groups(obstacles, cands, axis, trace_width): + """Find (gap_lo, gap_hi, [net_no, ...]) channels on one axis: a gap between two + obstacles in a wall row, and the >= 2 candidate nets that must cross that wall + and are nearest this gap. Only gaps bounded by an obstacle on both sides count + (an open board edge is not a channel).""" + groups = [] + for cluster in _wall_rows(obstacles, axis): + blo = max(_box_long(b, axis)[0] for b in cluster) + bhi = min(_box_long(b, axis)[1] for b in cluster) + if bhi <= blo: + continue # no length-band the whole cluster blocks in common + occ = sorted(_box_trans(b, axis) for b in cluster) + merged: list[tuple[int, int]] = [] + for lo, hi in occ: + if merged and lo <= merged[-1][1]: + merged[-1] = (merged[-1][0], max(merged[-1][1], hi)) + else: + merged.append((lo, hi)) + gaps = [ + (merged[k][1], merged[k + 1][0]) + for k in range(len(merged) - 1) + if merged[k + 1][0] - merged[k][1] >= trace_width + ] + if not gaps: + continue + crossing = [] + for net_no, (pa, pb) in cands.items(): + la, lb = _long(pa, axis), _long(pb, axis) + if (la <= blo and lb >= bhi) or (lb <= blo and la >= bhi): + crossing.append(net_no) + for gap_lo, gap_hi in gaps: + members = [] + for net_no in crossing: + pa, pb = cands[net_no] + center = (_trans(pa, axis) + _trans(pb, axis)) / 2 + nearest = min(gaps, key=lambda g: abs((g[0] + g[1]) / 2 - center)) + if nearest == (gap_lo, gap_hi): + members.append(net_no) + if len(members) >= 2: + groups.append((gap_lo, gap_hi, blo, bhi, sorted(members))) + return groups + + +def _boxes_clear(boxes, net_no, placed, clearance) -> bool: + """True if none of ``boxes`` come within ``clearance`` of a different-net box in + ``placed`` (list of ``(net_no, layer, box)``) -- the in-group lane-vs-lane check + that the search tree cannot do until copper is committed.""" + for box, box_layers in boxes: + expanded = box.offset(clearance) + for other_net, other_layer, other_box in placed: + if other_net == net_no or other_layer not in box_layers: + continue + if other_box.overlaps(expanded): + return False + return True + + +def _pack_channels( + board, + tree, + result, + owner, + outline, + signal_layers, + half_width, + clearance, + margin, + trace_width, + via_layers, + accepted, + shove, +) -> set[int]: + """Coordinated multi-trace channel packing (opt-in pre-pass). + + Greedy per-net routing sends each net through a shared constriction on its own + independently chosen path; the paths collide and some nets drop even when the + gap is physically wide enough for all of them. This pass groups 2-pin nets that + must cross a common obstacle gap, assigns each a parallel lane across the gap's + usable width -- sorted by pad position, spacing >= trace_width + clearance so + lanes never conflict and, being pad-ordered, never cross -- and realizes each as + an orthogonal converge/straight/diverge trace verified exactly DRC-clean against + the search tree. A net whose lane trace is not clean (e.g. it clips a pad) is + left for the normal loop. Returns the fully-routed net numbers so the caller + skips them. Scope: single-layer 2-pin nets through one axis-aligned gap.""" + routed: set[int] = set() + spacing_min = trace_width + clearance + cands: dict[int, tuple[int, IntPoint, IntPoint]] = {} + for net_no in sorted(_routable_nets(board, signal_layers)): + pins = _net_pins(board, net_no, signal_layers) + if len(pins) != 2: + continue + shared = [ + layer for layer in pins[0].layers if layer in pins[1].layers and layer in signal_layers + ] + if not shared: + continue + cands[net_no] = (min(shared), pins[0].location, pins[1].location) + if len(cands) < 2: + return routed + + obstacles_by_layer: dict[int, list[IntBox]] = {layer: [] for layer in signal_layers} + for obstacle in board.get_obstacle_areas(): + if obstacle.layer not in signal_layers: + continue + for tile in obstacle.tiles: + box = tile.bounding_box() + if not box.is_empty(): + obstacles_by_layer[obstacle.layer].append(box) + + for layer in sorted(signal_layers): + obstacles = obstacles_by_layer.get(layer, []) + if not obstacles: + continue + layer_cands = {net_no: (pa, pb) for net_no, (lyr, pa, pb) in cands.items() if lyr == layer} + if len(layer_cands) < 2: + continue + for axis in ("v", "h"): + for gap_lo, gap_hi, blo, bhi, members in _channel_groups( + obstacles, layer_cands, axis, trace_width + ): + members = [n for n in members if n not in routed] + if len(members) < 2: + continue + placed = _pack_group( + members, + layer_cands, + axis, + layer, + gap_lo, + gap_hi, + blo, + bhi, + tree, + half_width, + clearance, + margin, + spacing_min, + via_layers, + outline, + ) + if placed is None: + continue # could not pack every member cleanly -> leave to greedy + for net_no, wires in placed: + _commit( + net_no, + wires, + [], + tree, + result, + owner, + half_width, + via_layers, + shove, + accepted, + ) + routed.add(net_no) + return routed + + +def _pack_group( + members, + cands, + axis, + layer, + gap_lo, + gap_hi, + blo, + bhi, + tree, + half_width, + clearance, + margin, + spacing_min, + via_layers, + outline, +): + """Assign parallel lanes + staggered fan-in trunks to every net crossing one + gap, and return ``[(net_no, wires), ...]`` if ALL of them realize exactly + DRC-clean, else ``None``. All-or-nothing: a partial pack could leave the + channel worse than greedy, so the group is committed only if it packs whole.""" + count = len(members) + lo = gap_lo + margin + hi = gap_hi - margin + band = hi - lo + if band < (count - 1) * spacing_min: + return None # gap not physically wide enough for this many lanes + + # per net: (top pin, bottom pin) by length-coordinate; top has the larger value + ends = {} + for net_no in members: + pa, pb = cands[net_no] + ends[net_no] = (pa, pb) if _long(pa, axis) >= _long(pb, axis) else (pb, pa) + + # lanes in gap order, preserving pad order so lanes never cross + lane_order = sorted(members, key=lambda n: (_trans(ends[n][0], axis), n)) + lane_of = { + net_no: (lo if count == 1 else lo + round(band * i / (count - 1))) + for i, net_no in enumerate(lane_order) + } + + # staggered trunks: the widest fan-in horizontal sits nearest the gap mouth so + # it passes under the shorter nets' descents (a monotone, crossing-free fan) + top_rank = { + n: r + for r, n in enumerate( + sorted(members, key=lambda n: (-abs(_trans(ends[n][0], axis) - lane_of[n]), n)) + ) + } + bot_rank = { + n: r + for r, n in enumerate( + sorted(members, key=lambda n: (-abs(_trans(ends[n][1], axis) - lane_of[n]), n)) + ) + } + top_pad = min(_long(ends[n][0], axis) for n in members) + bot_pad = max(_long(ends[n][1], axis) for n in members) + if bhi + margin + (count - 1) * spacing_min > top_pad - margin: + return None # not enough room above the gap to stack the fan-in trunks + if blo - margin - (count - 1) * spacing_min < bot_pad + margin: + return None # not enough room below the gap + + placed_boxes: list[tuple[int, int, IntBox]] = [] + to_commit = [] + for net_no in sorted(members): + top_pt, bot_pt = ends[net_no] + trunk_top = bhi + margin + top_rank[net_no] * spacing_min + trunk_bot = blo - margin - bot_rank[net_no] * spacing_min + corners = _lane_polyline(top_pt, bot_pt, lane_of[net_no], trunk_top, trunk_bot, axis) + if len(corners) < 2: + return None + wires = [(layer, corners)] + boxes = _collect_boxes(wires, [], half_width, via_layers, outline) + if boxes is None: + return None + if any( + tree.clearance_conflict(box, net_no, box_layer, clearance) + for box, box_layers in boxes + for box_layer in box_layers + ): + return None + if not _boxes_clear(boxes, net_no, placed_boxes, clearance): + return None + for box, box_layers in boxes: + for box_layer in box_layers: + placed_boxes.append((net_no, box_layer, box)) + to_commit.append((net_no, wires)) + return to_commit + + # --- shove-in-rooms recovery -------------------------------------------------