diff --git a/examples/single_module.yml b/examples/single_module.yml index 40f8ede..f8cf5a2 100644 --- a/examples/single_module.yml +++ b/examples/single_module.yml @@ -20,8 +20,8 @@ connectors: J2: type: SIG_CONN pinlabels: - - SIGNAL_IN - VOUT + - SIGNAL_IN notes: 'SPICE ref: J2, nets: SIGNAL_IN, VOUT' TP1: type: TP @@ -63,5 +63,5 @@ connections: - 1 - 2 - J2: - - 2 - 1 + - 2 diff --git a/src/spice2wireviz/mapper/single_module.py b/src/spice2wireviz/mapper/single_module.py index ed5ed24..5b92a40 100644 --- a/src/spice2wireviz/mapper/single_module.py +++ b/src/spice2wireviz/mapper/single_module.py @@ -8,11 +8,18 @@ defined inside it) and port interface. Generates: - Cables connecting the module header to boundary components via shared nets This shows the external interface of a single board/module. + +Layout optimization for the star topology: + 1. Order boundary components by average header pin position + 2. Group header pins by boundary component (reduces inter-cable crossings) + 3. Reorder boundary component pins to be parallel with header (eliminates + within-cable crossings) """ from __future__ import annotations import sys +from collections import defaultdict from typing import Any from ..emitter.yaml_emitter import ( @@ -34,6 +41,257 @@ def _net_wire_color(net: str, netlist: ParsedNetlist) -> str: return "" +# --------------------------------------------------------------------------- +# Layout optimization helpers +# --------------------------------------------------------------------------- + + +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"mapping. Available: {sorted(mapping.keys())}, " + f"Requested: {pins if isinstance(pins, int) else list(pins)}" + ) from e + + +def _optimize_single_layout( + header_name: str, + 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 star-topology layout for single-module diagrams. + + Three-pass optimization: + 1. Order boundary components by average header pin position + 2. Regroup header pins so pins connecting to the same boundary + component are adjacent (reduces inter-cable crossings) + 3. Reorder boundary component pins to be parallel with header + (eliminates within-cable crossings) + """ + if len(connectors) < 2 or not connections: + return connectors, cables, connections + + header = connectors.get(header_name) + if not header or "pinlabels" not in header: + return connectors, cables, connections + + header_labels = header["pinlabels"] + n_header = len(header_labels) + + # --- Parse connections to extract (header_pin, comp_pin) pairs --- + comp_pin_pairs: dict[str, list[tuple[int, int]]] = defaultdict(list) + + for i, conn in enumerate(connections): + if len(conn) < 3: + raise ValueError( + f"Connection {i} has {len(conn)} elements, expected at least 3 " + f"(connector, cable, connector): {conn}" + ) + left_name = next(iter(conn[0])) + right_name = next(iter(conn[2])) + + if left_name == header_name: + comp_name = right_name + h_pins = conn[0][header_name] + c_pins = conn[2][comp_name] + elif right_name == header_name: + comp_name = left_name + h_pins = conn[2][header_name] + c_pins = conn[0][comp_name] + else: + continue + + h_list = [h_pins] if isinstance(h_pins, int) else list(h_pins) + c_list = [c_pins] if isinstance(c_pins, int) else list(c_pins) + + for h, c in zip(h_list, c_list, strict=True): + comp_pin_pairs[comp_name].append((h, c)) + + if not comp_pin_pairs: + return connectors, cables, connections + + # --- Step 1: Order boundary components by average header pin --- + comp_avg: dict[str, float] = {} + for comp_name, pairs in comp_pin_pairs.items(): + comp_avg[comp_name] = sum(h for h, _ in pairs) / len(pairs) + + boundary_order = sorted(comp_avg.keys(), key=lambda c: (comp_avg[c], c)) + + # --- Step 2: Regroup header pins by boundary component --- + # Pins connecting to the first boundary component come first, then + # the second, etc. Unconnected header pins go at the end. + # Duplicate detection: a header pin shared by multiple boundary + # components (e.g., GND routed to both J1 and J2) is assigned to + # the first component in boundary_order — subsequent duplicates + # are skipped to avoid corrupting the pin mapping. + new_header_order: list[int] = [] + seen_pins: set[int] = set() + for comp_name in boundary_order: + comp_h_pins = sorted(h for h, _ in comp_pin_pairs[comp_name]) + for pin in comp_h_pins: + if pin not in seen_pins: + seen_pins.add(pin) + new_header_order.append(pin) + + connected = set(new_header_order) + for p in range(1, n_header + 1): + if p not in connected: + new_header_order.append(p) + + # Build header pin mapping (old position -> new position) + header_mapping: dict[int, int] | None = None + if new_header_order != list(range(1, n_header + 1)): + header_mapping = { + old: new for new, old in enumerate(new_header_order, 1) + } + new_header_labels = [header_labels[old - 1] for old in new_header_order] + else: + new_header_labels = header_labels + + # --- Step 3: Reorder boundary component pins to parallel header --- + # For each component, sort its pins by the corresponding (remapped) + # header pin position so wires run parallel without crossing. + comp_mappings: dict[str, dict[int, int]] = {} + + for comp_name, pairs in comp_pin_pairs.items(): + comp_conn = connectors.get(comp_name, {}) + comp_labels = comp_conn.get("pinlabels", []) + n_comp = len(comp_labels) if comp_labels else comp_conn.get("pincount", 0) + if n_comp <= 1: + continue + + # Map each component pin to its effective header position. + # A pin may connect to multiple header pins (fan-out); use + # the average position rather than last-wins. + comp_pin_h_positions: dict[int, list[float]] = defaultdict(list) + for h, c in pairs: + h_pos = header_mapping[h] if header_mapping else h + comp_pin_h_positions[c].append(float(h_pos)) + + comp_pin_to_h_pos: dict[int, float] = { + pin: sum(positions) / len(positions) + for pin, positions in comp_pin_h_positions.items() + } + + # Sort: connected pins by header position, unconnected at end + all_comp_pins = list(range(1, n_comp + 1)) + sorted_comp = sorted( + all_comp_pins, + key=lambda p: (comp_pin_to_h_pos.get(p, float("inf")), p), + ) + + if sorted_comp != all_comp_pins: + mapping = {old: new for new, old in enumerate(sorted_comp, 1)} + comp_mappings[comp_name] = mapping + + # --- Build new ordered connectors dict (no in-place mutation) --- + ordered: dict[str, dict[str, Any]] = {} + + # Header first (with possible pinlabel update) + if header_mapping: + new_h: dict[str, Any] = {} + for k, v in header.items(): + new_h[k] = new_header_labels if k == "pinlabels" else v + ordered[header_name] = new_h + else: + ordered[header_name] = connectors[header_name] + + # Boundary components in order (with possible pinlabel update) + for comp_name in boundary_order: + if comp_name not in connectors: + continue + comp_conn = connectors[comp_name] + if comp_name in comp_mappings: + mapping = comp_mappings[comp_name] + comp_labels = comp_conn.get("pinlabels", []) + if comp_labels: + sorted_pins = sorted(mapping.keys(), key=lambda p: mapping[p]) + new_labels = [comp_labels[old - 1] for old in sorted_pins] + new_comp: dict[str, Any] = {} + for k, v in comp_conn.items(): + new_comp[k] = new_labels if k == "pinlabels" else v + ordered[comp_name] = new_comp + else: + ordered[comp_name] = comp_conn + else: + ordered[comp_name] = comp_conn + + # Remaining connectors (e.g., disconnected test points) + for name, conn in connectors.items(): + if name not in ordered: + ordered[name] = conn + + # --- Apply all pin mappings to connections --- + all_mappings: dict[str, dict[int, int]] = {} + if header_mapping: + all_mappings[header_name] = header_mapping + all_mappings.update(comp_mappings) + + if all_mappings: + updated: list[list[dict[str, Any]]] = [] + for conn in connections: + # Shallow copy: only conn[0] and conn[2] are replaced (new dicts). + # conn[1] (cable) is shared by reference — safe because cables + # are not remapped in this pass. + new_conn = list(conn) + + left_name = next(iter(conn[0])) + if left_name in all_mappings: + new_conn[0] = { + left_name: _remap_pins( + left_name, conn[0][left_name], all_mappings[left_name] + ) + } + + right_name = next(iter(conn[2])) + if right_name in all_mappings: + new_conn[2] = { + right_name: _remap_pins( + right_name, conn[2][right_name], all_mappings[right_name] + ) + } + + updated.append(new_conn) + connections = updated + + # --- Sort connections by minimum header pin (top-to-bottom order) --- + def _sort_key(conn: list[dict[str, Any]]) -> tuple[float, ...]: + left_name = next(iter(conn[0])) + right_name = next(iter(conn[2])) + + if left_name == header_name: + h_pins = conn[0][header_name] + elif right_name == header_name: + h_pins = conn[2][header_name] + else: + return (float("inf"),) + + pin_list = [h_pins] if isinstance(h_pins, int) else h_pins + return (min(pin_list),) + + connections.sort(key=_sort_key) + + return ordered, cables, connections + + +# --------------------------------------------------------------------------- +# Main mapper +# --------------------------------------------------------------------------- + + def map_single_module( netlist: ParsedNetlist, subcircuit_name: str, @@ -143,6 +401,11 @@ def map_single_module( comp_name, mapped_comp, )) + # --- Optimize layout for cleaner diagrams --- + connectors, cables, connections = _optimize_single_layout( + header_name, connectors, cables, connections + ) + return assemble_wireviz_doc(connectors, cables, connections, metadata) diff --git a/tests/test_single_module.py b/tests/test_single_module.py index 40e9292..21ff9cf 100644 --- a/tests/test_single_module.py +++ b/tests/test_single_module.py @@ -5,7 +5,11 @@ from pathlib import Path import pytest from spice2wireviz.filter import FilterConfig -from spice2wireviz.mapper.single_module import map_single_module +from spice2wireviz.mapper.single_module import ( + _optimize_single_layout, + _remap_pins, + map_single_module, +) from spice2wireviz.parser.netlist import parse_netlist FIXTURES = Path(__file__).parent / "fixtures" @@ -102,3 +106,215 @@ class TestSingleModuleFiltering: result = map_single_module(netlist, "amplifier_board", config) assert "TP1" not in result["connectors"] + + +class TestSingleModuleLayoutOptimization: + """Tests for the star-topology layout optimization.""" + + def test_boundary_component_order(self): + """Boundary components should appear in header pin order.""" + netlist = parse_netlist(FIXTURES / "simple_board.net") + result = map_single_module(netlist, "amplifier_board") + + names = list(result["connectors"].keys()) + # Header first, then J1 (avg pin 1.5), then J2 (avg pin 3.5), then TP1 + assert names[0] == "amplifier_board" + assert names.index("J1") < names.index("J2") + + def test_j2_pins_reordered_to_eliminate_crossing(self): + """J2 pins should be [VOUT, SIGNAL_IN] to match header order.""" + netlist = parse_netlist(FIXTURES / "simple_board.net") + result = map_single_module(netlist, "amplifier_board") + + j2 = result["connectors"]["J2"] + # Header has VOUT@3, SIGNAL_IN@4 — J2 must match: VOUT first + assert j2["pinlabels"] == ["VOUT", "SIGNAL_IN"] + + def test_connections_parallel_after_optimization(self): + """After optimization, cable wires should not cross (pins monotonic).""" + netlist = parse_netlist(FIXTURES / "simple_board.net") + result = map_single_module(netlist, "amplifier_board") + + for conn in result["connections"]: + left_pins = conn[0][next(iter(conn[0]))] + right_pins = conn[2][next(iter(conn[2]))] + l_list = [left_pins] if isinstance(left_pins, int) else left_pins + r_list = [right_pins] if isinstance(right_pins, int) else right_pins + assert l_list == sorted(l_list), f"Left pins not sorted: {l_list}" + assert r_list == sorted(r_list), f"Right pins not sorted: {r_list}" + + def test_connection_pins_valid(self): + """All pin indices must be within the connector's pin count.""" + netlist = parse_netlist(FIXTURES / "simple_board.net") + result = map_single_module(netlist, "amplifier_board") + + connectors = result["connectors"] + for conn in result["connections"]: + for entry in [conn[0], conn[2]]: + name = next(iter(entry)) + pins = entry[name] + pin_list = [pins] if isinstance(pins, int) else pins + n_pins = len(connectors[name].get("pinlabels", [])) + if n_pins == 0: + n_pins = connectors[name].get("pincount", 0) + for p in pin_list: + assert 1 <= p <= n_pins, ( + f"Pin {p} out of range for {name} ({n_pins} pins)" + ) + + def test_optimize_no_crash_single_connector(self): + """Single connector (no boundary) should pass through.""" + connectors = {"header": {"pinlabels": ["A", "B"]}} + result_c, _cb, _cn = _optimize_single_layout("header", connectors, {}, []) + assert result_c == connectors + + def test_optimize_no_crash_empty(self): + """Empty inputs should pass through.""" + result_c, _cb, _cn = _optimize_single_layout("header", {}, {}, []) + assert result_c == {} + + def test_header_pin_regrouping(self): + """Header pins connecting to the same component should be grouped.""" + # Synthetic: header has interleaved pins + # Pin 1 → compA, Pin 2 → compB, Pin 3 → compA, Pin 4 → compB + connectors = { + "H": {"pinlabels": ["A1", "B1", "A2", "B2"]}, + "CA": {"pinlabels": ["X", "Y"]}, + "CB": {"pinlabels": ["M", "N"]}, + } + connections = [ + [{"H": [1, 3]}, {"W1": [1, 2]}, {"CA": [1, 2]}], + [{"H": [2, 4]}, {"W2": [1, 2]}, {"CB": [1, 2]}], + ] + result_c, _, result_cn = _optimize_single_layout( + "H", connectors, {}, connections + ) + # Header should be regrouped: [A1, A2, B1, B2] + assert result_c["H"]["pinlabels"] == ["A1", "A2", "B1", "B2"] + # CA should come before CB + names = list(result_c.keys()) + assert names.index("CA") < names.index("CB") + + def test_header_regrouping_updates_connections(self): + """After header regrouping, connection pin indices must be updated.""" + connectors = { + "H": {"pinlabels": ["A1", "B1", "A2", "B2"]}, + "CA": {"pinlabels": ["X", "Y"]}, + "CB": {"pinlabels": ["M", "N"]}, + } + connections = [ + [{"H": [1, 3]}, {"W1": [1, 2]}, {"CA": [1, 2]}], + [{"H": [2, 4]}, {"W2": [1, 2]}, {"CB": [1, 2]}], + ] + _, _, result_cn = _optimize_single_layout("H", connectors, {}, connections) + + # After regrouping: H pins [A1,A2,B1,B2] → old 1→new 1, old 3→new 2, + # old 2→new 3, old 4→new 4 + # Connection 1: H:[1,3] should become H:[1,2] + h_pins_0 = result_cn[0][0]["H"] + assert h_pins_0 == [1, 2] + # Connection 2: H:[2,4] should become H:[3,4] + h_pins_1 = result_cn[1][0]["H"] + assert h_pins_1 == [3, 4] + + def test_comp_pin_reorder_eliminates_crossing(self): + """Component pins should be reordered to match header pin order.""" + # Header: [A, B], CompX: [Y, X] where A→Y and B→X + # This means header pin 1→comp pin 2, header pin 2→comp pin 1 (crossing!) + connectors = { + "H": {"pinlabels": ["A", "B"]}, + "CX": {"pinlabels": ["Y", "X"]}, + } + connections = [ + [{"H": [1, 2]}, {"W1": [1, 2]}, {"CX": [2, 1]}], + ] + result_c, _, result_cn = _optimize_single_layout( + "H", connectors, {}, connections + ) + # CX should be reordered to [X, Y] so pin 1→pin 1, pin 2→pin 2 + assert result_c["CX"]["pinlabels"] == ["X", "Y"] + # Connection should now be CX:[1,2] (no crossing) + assert result_cn[0][2]["CX"] == [1, 2] + + def test_disconnected_test_point_preserved(self): + """Test points with no header connection should appear last.""" + netlist = parse_netlist(FIXTURES / "simple_board.net") + result = map_single_module(netlist, "amplifier_board") + + names = list(result["connectors"].keys()) + # TP1 connects to internal net N001 (not a port), so it's disconnected + # from the header. It should appear after connected components. + assert names[-1] == "TP1" + + def test_connections_sorted_by_header_pin(self): + """Connections should be sorted by their minimum header pin.""" + netlist = parse_netlist(FIXTURES / "simple_board.net") + result = map_single_module(netlist, "amplifier_board") + + prev_min = 0 + for conn in result["connections"]: + # Header is always on the left in single-module connections + left_name = next(iter(conn[0])) + h_pins = conn[0][left_name] + pin_list = [h_pins] if isinstance(h_pins, int) else h_pins + min_pin = min(pin_list) + assert min_pin >= prev_min, ( + f"Connection order wrong: min pin {min_pin} < previous {prev_min}" + ) + prev_min = min_pin + + def test_shared_header_pin_deduplication(self): + """Header pin shared by two components should not corrupt mapping.""" + # Pin 2 of header connects to both CA and CB (e.g., GND fan-out) + connectors = { + "H": {"pinlabels": ["VIN", "GND", "VOUT"]}, + "CA": {"pinlabels": ["X", "Y"]}, + "CB": {"pinlabels": ["M", "N"]}, + } + connections = [ + [{"H": [1, 2]}, {"W1": [1, 2]}, {"CA": [1, 2]}], + [{"H": [2, 3]}, {"W2": [1, 2]}, {"CB": [1, 2]}], + ] + result_c, _, result_cn = _optimize_single_layout( + "H", connectors, {}, connections + ) + # Should not crash, and all pin indices must be valid + h_labels = result_c["H"]["pinlabels"] + assert len(h_labels) == 3 + # All connections should have valid pin references + for conn in result_cn: + h_pins = conn[0]["H"] + pin_list = [h_pins] if isinstance(h_pins, int) else h_pins + for p in pin_list: + assert 1 <= p <= 3, f"Header pin {p} out of range" + + def test_remap_pins_missing_key_raises(self): + """_remap_pins should raise ValueError with context on missing pin.""" + mapping = {1: 2, 2: 1} + with pytest.raises(ValueError, match="Pin remapping failed for J1"): + _remap_pins("J1", [1, 3], mapping) + + def test_remap_pins_scalar(self): + """_remap_pins should handle scalar pin input.""" + mapping = {1: 3, 2: 1, 3: 2} + assert _remap_pins("X", 2, mapping) == 1 + + def test_comp_pin_fanout_averaging(self): + """Component pin connecting to multiple header pins should use average.""" + # CX pin 1 connects to header pins 4 and 5 → remapped avg = 2.5 + # CX pin 2 connects to header pin 1 → remapped pos = 1.0 + # After header regrouping [1,4,5] → positions [1,2,3]: + # pin 1: avg(2,3) = 2.5, pin 2: pos(1) = 1.0 + # So pin 2 should come before pin 1 → [P2, P1] + connectors = { + "H": {"pinlabels": ["A", "B", "C", "D", "E"]}, + "CX": {"pinlabels": ["P1", "P2"]}, + } + connections = [ + [{"H": 4}, {"W1": 1}, {"CX": 1}], + [{"H": 5}, {"W2": 1}, {"CX": 1}], + [{"H": 1}, {"W3": 1}, {"CX": 2}], + ] + result_c, _, _ = _optimize_single_layout("H", connectors, {}, connections) + # Pin 2 (remapped h_pos=1.0) before Pin 1 (avg remapped h_pos=2.5) + assert result_c["CX"]["pinlabels"] == ["P2", "P1"]