Add coordinated multi-trace channel packing to the room track

Greedy per-net room routing sends each net through a shared obstacle gap 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. shove cannot fix this -- it
relocates blockers into existing space, it does not pack lanes.

Add an opt-in pre-pass (pack=True, independent of shove) that groups 2-pin nets
which must cross a common gap, assigns each a parallel lane across the gap's
usable width (pad-ordered so lanes never cross, spaced >= width + clearance) with
staggered fan-in trunks so the converge/diverge jogs never overlap, and realizes
each as an orthogonal trace verified exactly DRC-clean. Packing is all-or-nothing
per group, so it never leaves a channel worse than greedy. With pack=False the
pre-pass is skipped and the output is unchanged.

Scope: single-layer 2-pin nets through one axis-aligned gap, with enough fan
depth to stack the trunks. Threaded through route_dsn_board_rooms /
build_rooms_routing_result / route(engine="room").
This commit is contained in:
Ryan Malloy 2026-07-13 12:27:23 -06:00
parent 99c3d20de5
commit c28223ea9c
2 changed files with 379 additions and 8 deletions

View File

@ -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)

View File

@ -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 -------------------------------------------------