diff --git a/examples/hierarchical.yml b/examples/hierarchical.yml new file mode 100644 index 0000000..2ddddfe --- /dev/null +++ b/examples/hierarchical.yml @@ -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 diff --git a/examples/inter_module.yml b/examples/inter_module.yml new file mode 100644 index 0000000..ccca328 --- /dev/null +++ b/examples/inter_module.yml @@ -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 diff --git a/examples/single_module.yml b/examples/single_module.yml new file mode 100644 index 0000000..40f8ede --- /dev/null +++ b/examples/single_module.yml @@ -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 diff --git a/src/spice2wireviz/mapper/inter_module.py b/src/spice2wireviz/mapper/inter_module.py index aad1df9..39725c3 100644 --- a/src/spice2wireviz/mapper/inter_module.py +++ b/src/spice2wireviz/mapper/inter_module.py @@ -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) diff --git a/tests/test_inter_module.py b/tests/test_inter_module.py index a25a63c..7a89293 100644 --- a/tests/test_inter_module.py +++ b/tests/test_inter_module.py @@ -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"