Add Sugiyama-lite layout optimization for inter-module diagrams

Layered graph drawing approach to minimize cable crossings:
- Layer assignment via BFS from external connectors (J*, TP*, P*)
- Barycenter ordering (forward + backward sweep) within each layer
- Connection orientation: earlier connector always on the left
- Pin reordering: weighted average neighbor position groups pins
  by destination, reducing within-connector crossings
- Connection sorting: shorter (adjacent-pair) connections first

Fixes from Apollo safety review:
- Explicit ValueError on pin remapping failures (was silent KeyError)
- Weighted pin averaging for star topology (GND shared across modules)
- Fully deterministic sort keys for reproducible output
- Documented closure capture pattern in loop sort keys
This commit is contained in:
Ryan Malloy 2026-02-13 02:01:06 -07:00
parent b9154e851b
commit b8ff2d19da
5 changed files with 886 additions and 1 deletions

160
examples/hierarchical.yml Normal file
View File

@ -0,0 +1,160 @@
metadata:
title: 'Wiring diagram: hierarchical'
source: tests/fixtures/hierarchical.net
generator: spice2wireviz 2026.2.13
connectors:
J_PWR:
type: BARREL_JACK
pinlabels:
- VIN_RAW
- GND
notes: 'SPICE ref: J_PWR'
TP_3V3:
type: TP
style: simple
pinlabels:
- V3V3
notes: 'SPICE ref: TP_3V3'
J_USB:
type: USB_B_CONN
pinlabels:
- GND
- USB_DP
- USB_DM
notes: 'SPICE ref: J_USB'
X_REG:
type: regulator
pinlabels:
- VIN
- GND
- VOUT
notes: 'SPICE instance: X_REG (regulator)'
X_MAIN:
type: main_board
pinlabels:
- USB_D+
- USB_D-
- VCC
- GND
- SDA
- SCL
- ALERT
notes: 'SPICE instance: X_MAIN (main_board)'
X_SENSOR:
type: sensor_module
pinlabels:
- VCC
- GND
- SDA
- SCL
- ALERT
notes: 'SPICE instance: X_SENSOR (sensor_module)'
cables:
W1:
category: bundle
colors:
- BK
- ''
wirelabels:
- GND
- VIN_RAW
notes: 'Nets: GND, VIN_RAW'
W2:
category: bundle
wirecount: 2
wirelabels:
- USB_DM
- USB_DP
notes: 'Nets: USB_DM, USB_DP'
W3:
colors:
- BK
wirelabels:
- GND
notes: 'Net: GND'
W5:
wirecount: 1
wirelabels:
- V3V3
notes: 'Net: V3V3'
W7:
category: bundle
colors:
- BK
- ''
wirelabels:
- GND
- V3V3
notes: 'Nets: GND, V3V3'
W8:
category: bundle
wirecount: 3
wirelabels:
- ALERT_SIG
- I2C_SCL
- I2C_SDA
notes: 'Nets: ALERT_SIG, I2C_SCL, I2C_SDA'
W9:
category: bundle
colors:
- BK
- ''
wirelabels:
- GND
- V3V3
notes: 'Nets: GND, V3V3'
connections:
- - J_USB: 1
- W3: 1
- X_REG: 2
- - X_REG:
- 2
- 3
- W7:
- 1
- 2
- X_MAIN:
- 4
- 3
- - X_MAIN:
- 7
- 6
- 5
- W8:
- 1
- 2
- 3
- X_SENSOR:
- 5
- 4
- 3
- - TP_3V3: 1
- W5: 1
- X_REG: 3
- - J_USB:
- 3
- 2
- W2:
- 1
- 2
- X_MAIN:
- 2
- 1
- - X_REG:
- 2
- 3
- W9:
- 1
- 2
- X_SENSOR:
- 2
- 1
- - J_PWR:
- 2
- 1
- W1:
- 1
- 2
- X_REG:
- 2
- 1

93
examples/inter_module.yml Normal file
View File

@ -0,0 +1,93 @@
metadata:
title: 'Wiring diagram: multi_board'
source: tests/fixtures/multi_board.net
generator: spice2wireviz 2026.2.13
connectors:
J_CHASSIS:
type: chassis_gnd
pinlabels:
- EARTH
- GND
notes: 'SPICE ref: J_CHASSIS'
TP_VCC:
type: TP
style: simple
pinlabels:
- VCC
notes: 'SPICE ref: TP_VCC'
X1:
type: power_supply
pinlabels:
- VCC
- GND
notes: 'SPICE instance: X1 (power_supply)'
X2:
type: amplifier
pinlabels:
- VIN
- GND
- VOUT
notes: 'SPICE instance: X2 (amplifier)'
X3:
type: io_board
pinlabels:
- GND
- SIG_IN
- SIG_OUT
- CTRL
notes: 'SPICE instance: X3 (io_board)'
cables:
W1:
colors:
- BK
wirelabels:
- GND
notes: 'Net: GND'
W3:
colors:
- RD
wirelabels:
- VCC
notes: 'Net: VCC'
W5:
category: bundle
colors:
- BK
- RD
wirelabels:
- GND
- VCC
notes: 'Nets: GND, VCC'
W6:
colors:
- BK
wirelabels:
- GND
notes: 'Net: GND'
W8:
wirecount: 1
wirelabels:
- AUDIO_OUT
notes: 'Net: AUDIO_OUT'
connections:
- - TP_VCC: 1
- W3: 1
- X1: 1
- - X1:
- 2
- 1
- W5:
- 1
- 2
- X2:
- 2
- 1
- - X2: 3
- W8: 1
- X3: 2
- - J_CHASSIS: 2
- W1: 1
- X1: 2
- - X1: 2
- W6: 1
- X3: 1

View File

@ -0,0 +1,67 @@
metadata:
title: 'Wiring diagram: simple_board'
source: tests/fixtures/simple_board.net
generator: spice2wireviz 2026.2.13
connectors:
amplifier_board:
type: Module Interface
pinlabels:
- VIN
- GND
- VOUT
- SIGNAL_IN
notes: 'SPICE subcircuit: .subckt amplifier_board'
J1:
type: PWR_CONN
pinlabels:
- VIN
- GND
notes: 'SPICE ref: J1, nets: VIN, GND'
J2:
type: SIG_CONN
pinlabels:
- SIGNAL_IN
- VOUT
notes: 'SPICE ref: J2, nets: SIGNAL_IN, VOUT'
TP1:
type: TP
style: simple
pinlabels:
- N001
notes: 'SPICE ref: TP1, nets: N001'
cables:
W_J1:
category: bundle
colors:
- ''
- BK
wirelabels:
- VIN
- GND
notes: 'Nets: VIN, GND'
W_J2:
category: bundle
wirecount: 2
wirelabels:
- VOUT
- SIGNAL_IN
notes: 'Nets: VOUT, SIGNAL_IN'
connections:
- - amplifier_board:
- 1
- 2
- W_J1:
- 1
- 2
- J1:
- 1
- 2
- - amplifier_board:
- 3
- 4
- W_J2:
- 1
- 2
- J2:
- 2
- 1

View File

@ -6,6 +6,12 @@ Parallel wires between the same pair of modules are grouped into
multi-wire cables for cleaner diagrams.
Also includes top-level J*/TP*/P* connectors directly.
Layout optimization uses a Sugiyama-lite approach:
1. Layer assignment (externals at layer 0, BFS outward for modules)
2. Barycenter ordering within each layer
3. Connection orientation (left = earlier in ordering)
4. Pin reordering (group by neighbor, order by neighbor position)
"""
from __future__ import annotations
@ -36,6 +42,343 @@ def _make_connector_name(ref: str) -> str:
return ref.replace(" ", "_")
# ---------------------------------------------------------------------------
# Layout optimization helpers
# ---------------------------------------------------------------------------
def _build_adjacency(
connections: list[list[dict[str, Any]]],
) -> dict[str, dict[str, int]]:
"""Build weighted adjacency graph from connection list.
Weight = number of wires between each connector pair.
"""
adj: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
for conn in connections:
left_name = next(iter(conn[0]))
right_name = next(iter(conn[2]))
pins = conn[0][left_name]
wire_count = 1 if isinstance(pins, int) else len(pins)
adj[left_name][right_name] += wire_count
adj[right_name][left_name] += wire_count
return adj
def _compute_layer_ordering(
names: list[str],
adj: dict[str, dict[str, int]],
) -> list[str]:
"""Compute optimal left-to-right ordering using layered barycenter method.
External connectors (J*, TP*, P*) are placed in the leftmost layer,
then modules are assigned to deeper layers by BFS distance.
Within each layer, nodes are ordered by the barycenter of their
connections to adjacent layers, minimizing edge crossings.
"""
if len(names) <= 1:
return list(names)
name_set = set(names)
# --- Phase 1: Assign layers via BFS from external connectors ---
external = sorted(n for n in names if not n.startswith("X"))
layers: dict[str, int] = {}
if external:
queue = list(external)
for n in queue:
layers[n] = 0
visited = set(queue)
else:
# No external connectors; start from lowest-degree node
start = min(names, key=lambda n: sum(adj.get(n, {}).values()) if adj.get(n) else 0)
queue = [start]
layers[start] = 0
visited = {start}
while queue:
next_queue = []
for node in queue:
for neighbor in sorted(adj.get(node, {})):
if neighbor in name_set and neighbor not in visited:
visited.add(neighbor)
layers[neighbor] = layers[node] + 1
next_queue.append(neighbor)
queue = next_queue
# Handle disconnected nodes
max_layer = max(layers.values()) if layers else 0
for n in names:
if n not in layers:
layers[n] = max_layer + 1
# --- Phase 2: Group by layer ---
max_layer = max(layers.values())
layer_groups: list[list[str]] = [[] for _ in range(max_layer + 1)]
for n in names:
layer_groups[layers[n]].append(n)
# Initial sort: alphabetical within each layer (stable base)
for group in layer_groups:
group.sort()
# --- Phase 3: Forward sweep — order layers 1+ by connections to previous ---
for layer_idx in range(1, len(layer_groups)):
prev_positions = {n: i for i, n in enumerate(layer_groups[layer_idx - 1])}
# Default param _pp captures THIS iteration's prev_positions, not the
# final value after the loop completes (classic Python closure gotcha).
def _bc_fwd(node: str, _pp: dict[str, int] = prev_positions) -> float:
"""Weighted average position of neighbors in previous layer.
Returns inf for nodes with no previous-layer neighbors (sorts last)."""
neighbors = {n: w for n, w in adj.get(node, {}).items() if n in _pp}
if not neighbors:
return float("inf")
total_w = sum(neighbors.values())
return sum(_pp[n] * w for n, w in neighbors.items()) / total_w
layer_groups[layer_idx].sort(key=_bc_fwd)
# --- Phase 4: Backward sweep — refine earlier layers by next layer ---
for layer_idx in range(len(layer_groups) - 2, -1, -1):
next_positions = {n: i for i, n in enumerate(layer_groups[layer_idx + 1])}
# Same default-param capture pattern as _bc_fwd above.
def _bc_bwd(node: str, _np: dict[str, int] = next_positions) -> float:
"""Weighted average position of neighbors in next layer.
Returns inf for nodes with no next-layer neighbors (sorts last)."""
neighbors = {n: w for n, w in adj.get(node, {}).items() if n in _np}
if not neighbors:
return float("inf")
total_w = sum(neighbors.values())
return sum(_np[n] * w for n, w in neighbors.items()) / total_w
layer_groups[layer_idx].sort(key=_bc_bwd)
# Flatten layers into single linear order
return [n for group in layer_groups for n in group]
def _orient_connections(
connections: list[list[dict[str, Any]]],
positions: dict[str, int],
) -> list[list[dict[str, Any]]]:
"""Orient each connection so the earlier connector (in ordering) is on the left."""
oriented = []
for conn in connections:
left_name = next(iter(conn[0]))
right_name = next(iter(conn[2]))
left_pos = positions.get(left_name, 0)
right_pos = positions.get(right_name, 0)
if left_pos > right_pos:
# Swap: put the earlier connector on the left
oriented.append([
{right_name: conn[2][right_name]},
conn[1], # cable is symmetric
{left_name: conn[0][left_name]},
])
else:
oriented.append(conn)
return oriented
def _optimize_pin_order(
connector_name: str,
pinlabels: list[str],
connections: list[list[dict[str, Any]]],
positions: dict[str, int],
) -> tuple[list[str], dict[int, int] | None]:
"""Reorder pins so connections to the same neighbor are grouped,
and groups are ordered by neighbor position (left neighbors at top,
right neighbors at bottom).
Returns (new_pinlabels, old_to_new_mapping) or (pinlabels, None) if no change.
"""
my_pos = positions.get(connector_name, 0)
# Collect ALL neighbor (position, wire_count) per pin. A pin may appear in
# multiple connections when it participates in a star topology (e.g. GND
# shared across many modules). We weight by wire count so heavier
# connections pull the pin's sort position more strongly.
pin_neighbor_data: dict[int, list[tuple[float, int]]] = defaultdict(list)
for conn in connections:
left_name = next(iter(conn[0]))
right_name = next(iter(conn[2]))
if left_name == connector_name:
neighbor_pos = positions.get(right_name, my_pos)
pins = conn[0][connector_name]
pin_list = pins if isinstance(pins, list) else [pins]
wire_count = len(pin_list)
for p in pin_list:
pin_neighbor_data[p].append((neighbor_pos, wire_count))
elif right_name == connector_name:
neighbor_pos = positions.get(left_name, my_pos)
pins = conn[2][connector_name]
pin_list = pins if isinstance(pins, list) else [pins]
wire_count = len(pin_list)
for p in pin_list:
pin_neighbor_data[p].append((neighbor_pos, wire_count))
if not pin_neighbor_data:
return pinlabels, None
# Weighted average neighbor position per pin
pin_avg_pos: dict[int, float] = {}
for p, data_list in pin_neighbor_data.items():
total_w = sum(w for _, w in data_list)
pin_avg_pos[p] = sum(pos * w for pos, w in data_list) / total_w
pin_indices = list(range(1, len(pinlabels) + 1))
# Sort by average neighbor position (ascending), then by original index as tiebreaker
sorted_pins = sorted(
pin_indices,
key=lambda p: (pin_avg_pos.get(p, my_pos), p),
)
if sorted_pins == pin_indices:
return pinlabels, None
# Build mapping and new labels
old_to_new: dict[int, int] = {}
new_labels: list[str] = []
for new_idx, old_idx in enumerate(sorted_pins, 1):
old_to_new[old_idx] = new_idx
new_labels.append(pinlabels[old_idx - 1])
return new_labels, old_to_new
def _remap_pins(
name: str, pins: int | list[int], mapping: dict[int, int]
) -> int | list[int]:
"""Apply pin index remapping, failing explicitly if a pin is missing."""
try:
if isinstance(pins, int):
return mapping[pins]
return [mapping[p] for p in pins]
except KeyError as e:
raise ValueError(
f"Pin remapping failed for {name}: pin {e.args[0]} not in "
f"connector pinlabels. Available: {sorted(mapping.keys())}, "
f"Requested: {pins if isinstance(pins, int) else list(pins)}"
) from e
def _apply_pin_mappings(
connections: list[list[dict[str, Any]]],
pin_mappings: dict[str, dict[int, int]],
) -> list[list[dict[str, Any]]]:
"""Update connection pin indices after pin reordering."""
if not pin_mappings:
return connections
updated = []
for conn in connections:
new_conn = list(conn) # shallow copy
# Left side (index 0)
left_name = next(iter(conn[0]))
if left_name in pin_mappings:
new_conn[0] = {
left_name: _remap_pins(left_name, conn[0][left_name], pin_mappings[left_name])
}
# Right side (index 2)
right_name = next(iter(conn[2]))
if right_name in pin_mappings:
new_conn[2] = {
right_name: _remap_pins(right_name, conn[2][right_name], pin_mappings[right_name])
}
updated.append(new_conn)
return updated
def _optimize_layout(
connectors: dict[str, dict[str, Any]],
cables: dict[str, dict[str, Any]],
connections: list[list[dict[str, Any]]],
) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]], list[list[dict[str, Any]]]]:
"""Optimize connector ordering and pin sequencing for cleaner diagrams.
Uses a Sugiyama-lite layered graph drawing approach to minimize
cable crossings in the rendered WireViz diagram.
"""
if len(connectors) < 2 or not connections:
return connectors, cables, connections
# Step 1: Build adjacency and compute ordering
adj = _build_adjacency(connections)
order = _compute_layer_ordering(list(connectors.keys()), adj)
positions = {name: i for i, name in enumerate(order)}
# Step 2: Reorder connectors dict (insertion order drives GraphViz placement)
ordered_connectors: dict[str, dict[str, Any]] = {}
for name in order:
if name in connectors:
ordered_connectors[name] = connectors[name]
# Append any disconnected connectors not in the order
for name, conn in connectors.items():
if name not in ordered_connectors:
ordered_connectors[name] = conn
# Step 3: Orient connections (earlier connector on the left)
oriented = _orient_connections(connections, positions)
# Step 4: Optimize pin ordering within each connector
pin_mappings: dict[str, dict[int, int]] = {}
for name in order:
if name not in ordered_connectors:
continue
conn = ordered_connectors[name]
pinlabels = conn.get("pinlabels")
if not pinlabels or len(pinlabels) <= 1:
continue
new_labels, mapping = _optimize_pin_order(name, pinlabels, oriented, positions)
if mapping:
# Rebuild connector dict with new pinlabels (preserve key order)
new_conn: dict[str, Any] = {}
for k, v in conn.items():
if k == "pinlabels":
new_conn[k] = new_labels
else:
new_conn[k] = v
ordered_connectors[name] = new_conn
pin_mappings[name] = mapping
# Step 5: Apply pin index remapping to all connections
if pin_mappings:
oriented = _apply_pin_mappings(oriented, pin_mappings)
# Step 6: Sort connections by distance (shorter connections first gives
# GraphViz better routing hints — adjacent pairs get priority).
# Fully deterministic key: (distance, left_pos, right_pos).
def _conn_distance(conn: list[dict[str, Any]]) -> tuple[int, int, int]:
left = next(iter(conn[0]))
right = next(iter(conn[2]))
dist = abs(positions.get(right, 0) - positions.get(left, 0))
return (dist, positions.get(left, 0), positions.get(right, 0))
oriented.sort(key=_conn_distance)
return ordered_connectors, cables, oriented
# ---------------------------------------------------------------------------
# Main mapper
# ---------------------------------------------------------------------------
def map_inter_module(
netlist: ParsedNetlist,
config: FilterConfig | None = None,
@ -199,4 +542,7 @@ def map_inter_module(
)
cable_counter += 1
# --- Optimize layout for cleaner diagrams ---
connectors, cables, connections = _optimize_layout(connectors, cables, connections)
return assemble_wireviz_doc(connectors, cables, connections, metadata)

View File

@ -3,7 +3,14 @@
from pathlib import Path
from spice2wireviz.filter import FilterConfig
from spice2wireviz.mapper.inter_module import map_inter_module
from spice2wireviz.mapper.inter_module import (
_build_adjacency,
_compute_layer_ordering,
_optimize_layout,
_optimize_pin_order,
_orient_connections,
map_inter_module,
)
from spice2wireviz.parser.netlist import parse_netlist
FIXTURES = Path(__file__).parent / "fixtures"
@ -143,3 +150,215 @@ class TestHierarchicalInterModule:
# Each cable should have wirecount=1
for cable in result["cables"].values():
assert cable.get("wirecount", 1) == 1 or len(cable.get("colors", [])) == 1
class TestLayoutOptimization:
"""Tests for the Sugiyama-lite layout optimization."""
def test_build_adjacency_weights(self):
"""Adjacency weights count wires between connector pairs."""
connections = [
[{"A": [1, 2]}, {"W1": [1, 2]}, {"B": [1, 2]}],
[{"A": 3}, {"W2": 1}, {"C": 1}],
]
adj = _build_adjacency(connections)
assert adj["A"]["B"] == 2 # 2-wire cable
assert adj["A"]["C"] == 1 # 1-wire cable
assert adj["B"]["A"] == 2 # symmetric
def test_layer_ordering_externals_first(self):
"""External connectors (J*, TP*) should be placed at layer 0 (leftmost)."""
adj = {
"J1": {"X1": 2},
"TP1": {"X1": 1},
"X1": {"J1": 2, "TP1": 1, "X2": 3},
"X2": {"X1": 3},
}
order = _compute_layer_ordering(["X1", "X2", "J1", "TP1"], adj)
# Externals before modules
j1_pos = order.index("J1")
tp1_pos = order.index("TP1")
x1_pos = order.index("X1")
x2_pos = order.index("X2")
assert j1_pos < x1_pos
assert tp1_pos < x1_pos
assert x1_pos < x2_pos
def test_layer_ordering_hierarchical(self):
"""Hierarchical fixture: externals -> X_REG -> X_MAIN -> X_SENSOR."""
netlist = parse_netlist(FIXTURES / "hierarchical.net")
result = map_inter_module(netlist)
conn_names = list(result["connectors"].keys())
# External connectors should come before all X* instances
x_reg_pos = conn_names.index("X_REG")
x_main_pos = conn_names.index("X_MAIN")
x_sensor_pos = conn_names.index("X_SENSOR")
for ext in ("J_PWR", "J_USB", "TP_3V3"):
assert conn_names.index(ext) < x_reg_pos, f"{ext} should be before X_REG"
# Module ordering by depth
assert x_reg_pos < x_main_pos, "X_REG should be before X_MAIN"
assert x_main_pos < x_sensor_pos, "X_MAIN should be before X_SENSOR"
def test_layer_ordering_multi_board(self):
"""Multi-board fixture: externals -> X1 -> X2 -> X3."""
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
conn_names = list(result["connectors"].keys())
x1_pos = conn_names.index("X1")
x2_pos = conn_names.index("X2")
x3_pos = conn_names.index("X3")
for ext in ("J_CHASSIS", "TP_VCC"):
assert conn_names.index(ext) < x1_pos, f"{ext} should be before X1"
assert x1_pos < x2_pos, "X1 should be before X2"
assert x2_pos < x3_pos, "X2 should be before X3"
def test_orient_connections_left_to_right(self):
"""Connections should be oriented so the earlier connector is on the left."""
connections = [
[{"B": 1}, {"W1": 1}, {"A": 1}], # B is after A — should swap
]
positions = {"A": 0, "B": 1}
oriented = _orient_connections(connections, positions)
left_name = next(iter(oriented[0][0]))
right_name = next(iter(oriented[0][2]))
assert left_name == "A"
assert right_name == "B"
def test_orient_preserves_correct_direction(self):
"""Already-correct orientation should be preserved."""
connections = [
[{"A": 1}, {"W1": 1}, {"B": 1}],
]
positions = {"A": 0, "B": 1}
oriented = _orient_connections(connections, positions)
assert next(iter(oriented[0][0])) == "A"
def test_pin_reorder_groups_by_neighbor(self):
"""Pins connecting to earlier neighbors should come before later ones."""
# Connector C at position 2 connects to A(0) via pin 3 and B(3) via pin 1
connections = [
[{"C": 3}, {"W1": 1}, {"A": 1}], # pin 3 → A(pos 0)
[{"C": 1}, {"W2": 1}, {"B": 1}], # pin 1 → B(pos 3)
]
positions = {"A": 0, "C": 2, "B": 3}
new_labels, mapping = _optimize_pin_order(
"C", ["P1", "P2", "P3"], connections, positions
)
assert mapping is not None
# Pin 3 (connecting to A at pos 0) should come first
assert new_labels[0] == "P3"
# Pin 1 (connecting to B at pos 3) should come after
assert new_labels[-1] == "P1" or new_labels.index("P1") > new_labels.index("P3")
def test_pin_reorder_averaging_star_topology(self):
"""A pin connected to multiple neighbors should use average position."""
# Pin 1 of C connects to A(pos 0) AND D(pos 4) — avg = 2
# Pin 2 of C connects to B(pos 1)
connections = [
[{"C": 1}, {"W1": 1}, {"A": 1}], # pin 1 → A(pos 0)
[{"C": 1}, {"W2": 1}, {"D": 1}], # pin 1 → D(pos 4)
[{"C": 2}, {"W3": 1}, {"B": 1}], # pin 2 → B(pos 1)
]
positions = {"A": 0, "B": 1, "C": 2, "D": 4}
new_labels, mapping = _optimize_pin_order(
"C", ["P1", "P2"], connections, positions
)
# Pin 2 (avg neighbor pos = 1) should come before Pin 1 (avg = (0+4)/2 = 2)
assert mapping is not None
assert new_labels == ["P2", "P1"]
def test_optimize_layout_no_crash_on_single_connector(self):
"""Single connector should pass through unchanged."""
connectors = {"X1": {"pinlabels": ["A", "B"]}}
cables: dict = {}
connections: list = []
result_c, _result_cb, _result_cn = _optimize_layout(connectors, cables, connections)
assert result_c == connectors
def test_optimize_layout_no_crash_on_empty(self):
"""Empty inputs should pass through."""
result_c, _result_cb, _result_cn = _optimize_layout({}, {}, [])
assert result_c == {}
def test_all_connections_reference_valid_pins(self):
"""After layout optimization, all connection pin indices must be valid."""
netlist = parse_netlist(FIXTURES / "hierarchical.net")
result = map_inter_module(netlist)
connectors = result["connectors"]
for conn_set in result["connections"]:
for entry in [conn_set[0], conn_set[2]]: # left and right connectors
name = next(iter(entry))
pins = entry[name]
if isinstance(pins, int):
pins = [pins]
n_pins = len(connectors[name].get("pinlabels", []))
if n_pins == 0:
n_pins = connectors[name].get("pincount", 0)
for p in pins:
assert 1 <= p <= n_pins, (
f"Pin {p} out of range for {name} ({n_pins} pins)"
)
def test_connections_sorted_by_distance(self):
"""Shorter connections (adjacent pairs) should come before longer ones."""
netlist = parse_netlist(FIXTURES / "hierarchical.net")
result = map_inter_module(netlist)
conn_names = list(result["connectors"].keys())
positions = {name: i for i, name in enumerate(conn_names)}
prev_dist = 0
for conn_set in result["connections"]:
left = next(iter(conn_set[0]))
right = next(iter(conn_set[2]))
dist = abs(positions.get(right, 0) - positions.get(left, 0))
assert dist >= prev_dist or dist == prev_dist, (
f"Connection {left}->{right} (dist={dist}) comes after dist={prev_dist}"
)
prev_dist = dist
def test_layer_ordering_all_disconnected(self):
"""All connectors disconnected — all assigned to the same layer."""
adj: dict[str, dict[str, int]] = {}
order = _compute_layer_ordering(["X3", "X1", "X2"], adj)
assert set(order) == {"X1", "X2", "X3"}
def test_layer_ordering_cycle(self):
"""Cycle in connectivity — BFS should not infinite loop."""
adj = {
"J1": {"X1": 1},
"X1": {"J1": 1, "X2": 1},
"X2": {"X1": 1, "X3": 1},
"X3": {"X2": 1, "J1": 1},
}
order = _compute_layer_ordering(["J1", "X1", "X2", "X3"], adj)
assert len(order) == 4
# J1 is external, should be at layer 0 (first)
assert order[0] == "J1"
def test_pin_reorder_weighted_star(self):
"""In star topology, heavier connections should pull pin position more."""
# Pin 1 connects to A(pos 0, 3-wire cable) and D(pos 6, 1-wire cable)
# Weighted avg = (0*3 + 6*1) / (3+1) = 1.5
# Pin 2 connects to B(pos 3, 1-wire cable)
# Avg = 3.0
# So pin 1 (1.5) should come before pin 2 (3.0)
connections = [
[{"C": [1, 1, 1]}, {"W1": [1, 2, 3]}, {"A": [1, 2, 3]}], # 3-wire to A
[{"C": 1}, {"W2": 1}, {"D": 1}], # 1-wire to D
[{"C": 2}, {"W3": 1}, {"B": 1}], # 1-wire to B
]
positions = {"A": 0, "B": 3, "C": 2, "D": 6}
new_labels, _mapping = _optimize_pin_order(
"C", ["P1", "P2"], connections, positions
)
# Pin 1 weighted avg ≈ 1.5, pin 2 avg = 3.0 → pin 1 first (no reorder needed)
assert new_labels[0] == "P1"