Add star-topology layout optimization for single-module diagrams
Three-pass optimization eliminates cable crossings: 1. Order boundary components by average header pin position 2. Regroup header pins by boundary component (reduces inter-cable crossings) 3. Reorder boundary component pins to parallel header (eliminates within-cable crossings) Safety hardening from Apollo review: - Duplicate header pin deduplication (prevents silent mapping corruption) - Connection structure validation at entry - Fan-out averaging for component pins connected to multiple header pins - Explicit ValueError on pin remapping failures with diagnostic context 145 tests passing (was 130).
This commit is contained in:
parent
b8ff2d19da
commit
c2197f6fe6
@ -20,8 +20,8 @@ connectors:
|
|||||||
J2:
|
J2:
|
||||||
type: SIG_CONN
|
type: SIG_CONN
|
||||||
pinlabels:
|
pinlabels:
|
||||||
- SIGNAL_IN
|
|
||||||
- VOUT
|
- VOUT
|
||||||
|
- SIGNAL_IN
|
||||||
notes: 'SPICE ref: J2, nets: SIGNAL_IN, VOUT'
|
notes: 'SPICE ref: J2, nets: SIGNAL_IN, VOUT'
|
||||||
TP1:
|
TP1:
|
||||||
type: TP
|
type: TP
|
||||||
@ -63,5 +63,5 @@ connections:
|
|||||||
- 1
|
- 1
|
||||||
- 2
|
- 2
|
||||||
- J2:
|
- J2:
|
||||||
- 2
|
|
||||||
- 1
|
- 1
|
||||||
|
- 2
|
||||||
|
|||||||
@ -8,11 +8,18 @@ defined inside it) and port interface. Generates:
|
|||||||
- Cables connecting the module header to boundary components via shared nets
|
- Cables connecting the module header to boundary components via shared nets
|
||||||
|
|
||||||
This shows the external interface of a single board/module.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..emitter.yaml_emitter import (
|
from ..emitter.yaml_emitter import (
|
||||||
@ -34,6 +41,257 @@ def _net_wire_color(net: str, netlist: ParsedNetlist) -> str:
|
|||||||
return ""
|
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(
|
def map_single_module(
|
||||||
netlist: ParsedNetlist,
|
netlist: ParsedNetlist,
|
||||||
subcircuit_name: str,
|
subcircuit_name: str,
|
||||||
@ -143,6 +401,11 @@ def map_single_module(
|
|||||||
comp_name, mapped_comp,
|
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)
|
return assemble_wireviz_doc(connectors, cables, connections, metadata)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,11 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from spice2wireviz.filter import FilterConfig
|
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
|
from spice2wireviz.parser.netlist import parse_netlist
|
||||||
|
|
||||||
FIXTURES = Path(__file__).parent / "fixtures"
|
FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
@ -102,3 +106,215 @@ class TestSingleModuleFiltering:
|
|||||||
result = map_single_module(netlist, "amplifier_board", config)
|
result = map_single_module(netlist, "amplifier_board", config)
|
||||||
|
|
||||||
assert "TP1" not in result["connectors"]
|
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"]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user