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:
parent
b9154e851b
commit
b8ff2d19da
160
examples/hierarchical.yml
Normal file
160
examples/hierarchical.yml
Normal 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
93
examples/inter_module.yml
Normal 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
|
||||||
67
examples/single_module.yml
Normal file
67
examples/single_module.yml
Normal 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
|
||||||
@ -6,6 +6,12 @@ Parallel wires between the same pair of modules are grouped into
|
|||||||
multi-wire cables for cleaner diagrams.
|
multi-wire cables for cleaner diagrams.
|
||||||
|
|
||||||
Also includes top-level J*/TP*/P* connectors directly.
|
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
|
from __future__ import annotations
|
||||||
@ -36,6 +42,343 @@ def _make_connector_name(ref: str) -> str:
|
|||||||
return ref.replace(" ", "_")
|
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(
|
def map_inter_module(
|
||||||
netlist: ParsedNetlist,
|
netlist: ParsedNetlist,
|
||||||
config: FilterConfig | None = None,
|
config: FilterConfig | None = None,
|
||||||
@ -199,4 +542,7 @@ def map_inter_module(
|
|||||||
)
|
)
|
||||||
cable_counter += 1
|
cable_counter += 1
|
||||||
|
|
||||||
|
# --- Optimize layout for cleaner diagrams ---
|
||||||
|
connectors, cables, connections = _optimize_layout(connectors, cables, connections)
|
||||||
|
|
||||||
return assemble_wireviz_doc(connectors, cables, connections, metadata)
|
return assemble_wireviz_doc(connectors, cables, connections, metadata)
|
||||||
|
|||||||
@ -3,7 +3,14 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from spice2wireviz.filter import FilterConfig
|
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
|
from spice2wireviz.parser.netlist import parse_netlist
|
||||||
|
|
||||||
FIXTURES = Path(__file__).parent / "fixtures"
|
FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
@ -143,3 +150,215 @@ class TestHierarchicalInterModule:
|
|||||||
# Each cable should have wirecount=1
|
# Each cable should have wirecount=1
|
||||||
for cable in result["cables"].values():
|
for cable in result["cables"].values():
|
||||||
assert cable.get("wirecount", 1) == 1 or len(cable.get("colors", [])) == 1
|
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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user