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
365 lines
14 KiB
Python
365 lines
14 KiB
Python
"""Tests for the inter-module mapper."""
|
|
|
|
from pathlib import Path
|
|
|
|
from spice2wireviz.filter import FilterConfig
|
|
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"
|
|
|
|
|
|
class TestInterModuleMapping:
|
|
def test_multi_board_connectors(self):
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
result = map_inter_module(netlist)
|
|
|
|
connectors = result["connectors"]
|
|
# Should have connectors for each X instance and top-level components
|
|
assert "X1" in connectors
|
|
assert "X2" in connectors
|
|
assert "X3" in connectors
|
|
assert "J_CHASSIS" in connectors
|
|
assert "TP_VCC" in connectors
|
|
|
|
def test_instance_connector_type(self):
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
result = map_inter_module(netlist)
|
|
|
|
x1 = result["connectors"]["X1"]
|
|
assert x1["type"] == "power_supply"
|
|
|
|
def test_instance_pinlabels(self):
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
result = map_inter_module(netlist)
|
|
|
|
x2 = result["connectors"]["X2"]
|
|
assert "VIN" in x2["pinlabels"]
|
|
assert "GND" in x2["pinlabels"]
|
|
assert "VOUT" in x2["pinlabels"]
|
|
|
|
def test_cables_for_shared_nets(self):
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
result = map_inter_module(netlist)
|
|
|
|
# Should have cables connecting modules via shared nets
|
|
cables = result["cables"]
|
|
assert len(cables) >= 1
|
|
|
|
def test_connections_exist(self):
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
result = map_inter_module(netlist)
|
|
|
|
connections = result["connections"]
|
|
assert len(connections) >= 1
|
|
|
|
def test_traceability_notes(self):
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
result = map_inter_module(netlist)
|
|
|
|
x1 = result["connectors"]["X1"]
|
|
assert "SPICE instance" in x1["notes"]
|
|
|
|
def test_top_level_test_point_style(self):
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
result = map_inter_module(netlist)
|
|
|
|
tp = result["connectors"]["TP_VCC"]
|
|
assert tp.get("style") == "simple"
|
|
|
|
|
|
class TestInterModuleFiltering:
|
|
def test_exclude_instance(self):
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
config = FilterConfig(exclude_refs=["X1"])
|
|
result = map_inter_module(netlist, config)
|
|
|
|
assert "X1" not in result["connectors"]
|
|
assert "X2" in result["connectors"]
|
|
|
|
def test_exclude_subcircuit(self):
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
config = FilterConfig(exclude_subcircuits=["power_supply"])
|
|
result = map_inter_module(netlist, config)
|
|
|
|
assert "X1" not in result["connectors"]
|
|
|
|
def test_no_ground_filter(self):
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
config = FilterConfig(show_ground=False)
|
|
result = map_inter_module(netlist, config)
|
|
|
|
# GND should not appear in any connector's pinlabels
|
|
for name, conn in result["connectors"].items():
|
|
if "pinlabels" in conn:
|
|
assert "GND" not in conn["pinlabels"], f"GND found in {name}"
|
|
|
|
def test_no_power_filter(self):
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
config = FilterConfig(show_power=False)
|
|
result = map_inter_module(netlist, config)
|
|
|
|
for name, conn in result["connectors"].items():
|
|
if "pinlabels" in conn:
|
|
assert "VCC" not in conn["pinlabels"], f"VCC found in {name}"
|
|
|
|
|
|
class TestHierarchicalInterModule:
|
|
def test_hierarchical_instances(self):
|
|
netlist = parse_netlist(FIXTURES / "hierarchical.net")
|
|
result = map_inter_module(netlist)
|
|
|
|
connectors = result["connectors"]
|
|
assert "X_REG" in connectors
|
|
assert "X_SENSOR" in connectors
|
|
assert "X_MAIN" in connectors
|
|
|
|
def test_external_connectors(self):
|
|
netlist = parse_netlist(FIXTURES / "hierarchical.net")
|
|
result = map_inter_module(netlist)
|
|
|
|
connectors = result["connectors"]
|
|
assert "J_PWR" in connectors
|
|
assert "J_USB" in connectors
|
|
assert "TP_3V3" in connectors
|
|
|
|
def test_grouped_parallel_wires(self):
|
|
"""Parallel wires between same modules should be grouped."""
|
|
netlist = parse_netlist(FIXTURES / "hierarchical.net")
|
|
config = FilterConfig(group_parallel_wires=True)
|
|
result = map_inter_module(netlist, config)
|
|
|
|
# With grouping, fewer cables than total net connections
|
|
cables = result["cables"]
|
|
connections = result["connections"]
|
|
assert len(cables) == len(connections)
|
|
|
|
def test_ungrouped_wires(self):
|
|
"""Without grouping, each wire gets its own cable."""
|
|
netlist = parse_netlist(FIXTURES / "hierarchical.net")
|
|
config = FilterConfig(group_parallel_wires=False)
|
|
result = map_inter_module(netlist, config)
|
|
|
|
# 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"
|