pcb-crossing-optimizer 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crossing_analyzer.py +1333 -0
- pcb_crossing_optimizer-0.4.0.dist-info/METADATA +122 -0
- pcb_crossing_optimizer-0.4.0.dist-info/RECORD +6 -0
- pcb_crossing_optimizer-0.4.0.dist-info/WHEEL +5 -0
- pcb_crossing_optimizer-0.4.0.dist-info/entry_points.txt +2 -0
- pcb_crossing_optimizer-0.4.0.dist-info/top_level.txt +1 -0
crossing_analyzer.py
ADDED
|
@@ -0,0 +1,1333 @@
|
|
|
1
|
+
"""Crossing analyzer for SKiDL-generated netlists.
|
|
2
|
+
|
|
3
|
+
Detects trace crossings across component layers and computes optimal
|
|
4
|
+
pin orderings for reorderable connectors to minimize or eliminate
|
|
5
|
+
crossings for single-layer routing.
|
|
6
|
+
|
|
7
|
+
Algorithm: Sugiyama-style barycenter sweep with virtual node insertion
|
|
8
|
+
for long edges spanning non-adjacent layers.
|
|
9
|
+
|
|
10
|
+
Usage (CLI):
|
|
11
|
+
python crossing_analyzer.py <netlist.net> --layers "J1 | R1,C1 | J2" --reorderable J2
|
|
12
|
+
|
|
13
|
+
Programmatic usage from SKiDL scripts:
|
|
14
|
+
from crossing_analyzer import PinColumn, sweep_optimize, format_multilayer_report
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from itertools import combinations
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# =========================================================================
|
|
29
|
+
# Data model
|
|
30
|
+
# =========================================================================
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class PinColumn:
|
|
34
|
+
"""A connector with pins in physical order (top-to-bottom or left-to-right).
|
|
35
|
+
|
|
36
|
+
pin_order is a list of pin IDs (strings) in physical position order.
|
|
37
|
+
Position 0 is the first physical pin.
|
|
38
|
+
"""
|
|
39
|
+
ref: str
|
|
40
|
+
pin_order: list[str]
|
|
41
|
+
|
|
42
|
+
def position_of(self, pin_id: str) -> int:
|
|
43
|
+
return self.pin_order.index(pin_id)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class LayerEdge:
|
|
48
|
+
"""An edge between pins on components in adjacent layers."""
|
|
49
|
+
net_name: str
|
|
50
|
+
source_ref: str
|
|
51
|
+
source_pin: str
|
|
52
|
+
target_ref: str
|
|
53
|
+
target_pin: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class LayerPairCrossing:
|
|
58
|
+
"""Two edges between adjacent layers that cross."""
|
|
59
|
+
edge_a: LayerEdge
|
|
60
|
+
edge_b: LayerEdge
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class LayerPairReport:
|
|
65
|
+
"""Crossing report for one pair of adjacent layers."""
|
|
66
|
+
source_layer_idx: int
|
|
67
|
+
target_layer_idx: int
|
|
68
|
+
source_refs: list[str]
|
|
69
|
+
target_refs: list[str]
|
|
70
|
+
crossing_count: int
|
|
71
|
+
crossings: list[LayerPairCrossing]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class MultilayerReport:
|
|
76
|
+
"""Full crossing analysis across all layer pairs."""
|
|
77
|
+
total_crossings: int
|
|
78
|
+
total_crossings_after: int
|
|
79
|
+
layer_pair_reports: list[LayerPairReport]
|
|
80
|
+
original_orders: dict[str, list[str]]
|
|
81
|
+
optimized_orders: dict[str, list[str]]
|
|
82
|
+
iterations: int
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class PinAssignment:
|
|
87
|
+
"""A single pin's assignment in a footprint plan."""
|
|
88
|
+
pin: str
|
|
89
|
+
net: Optional[str] # None = NC
|
|
90
|
+
status: str # "locked" | "optimized" | "unmatched"
|
|
91
|
+
routes_to: Optional[str] = None # e.g. "J2.1" for optimized pins
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class FootprintPlan:
|
|
96
|
+
"""Result of a plan-footprint analysis."""
|
|
97
|
+
target_ref: str
|
|
98
|
+
pin_map: list[PinAssignment]
|
|
99
|
+
crossings_before: int
|
|
100
|
+
crossings_after: int
|
|
101
|
+
iterations: int
|
|
102
|
+
passive_reorderings: dict[str, list[str]]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# =========================================================================
|
|
106
|
+
# Formatting helpers
|
|
107
|
+
# =========================================================================
|
|
108
|
+
|
|
109
|
+
def _format_pin_ref(ref: str, pin: str) -> str:
|
|
110
|
+
"""Format a pin reference for display, hiding virtual node internals."""
|
|
111
|
+
if ref.startswith("_virt_"):
|
|
112
|
+
return "[pass-through]"
|
|
113
|
+
return f"{ref}.{pin}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# =========================================================================
|
|
117
|
+
# Core analysis
|
|
118
|
+
# =========================================================================
|
|
119
|
+
|
|
120
|
+
def layer_global_positions(
|
|
121
|
+
components: list[PinColumn],
|
|
122
|
+
) -> dict[tuple[str, str], int]:
|
|
123
|
+
"""Map (ref, pin) to a global position index across a layer.
|
|
124
|
+
|
|
125
|
+
Components are laid out sequentially: if component A has 3 pins and
|
|
126
|
+
component B has 2 pins, B's first pin is at position 3.
|
|
127
|
+
"""
|
|
128
|
+
pos = 0
|
|
129
|
+
result: dict[tuple[str, str], int] = {}
|
|
130
|
+
for comp in components:
|
|
131
|
+
for pin in comp.pin_order:
|
|
132
|
+
result[(comp.ref, pin)] = pos
|
|
133
|
+
pos += 1
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def count_layer_pair_crossings(
|
|
138
|
+
source_layer: list[PinColumn],
|
|
139
|
+
target_layer: list[PinColumn],
|
|
140
|
+
edges: list[LayerEdge],
|
|
141
|
+
) -> list[LayerPairCrossing]:
|
|
142
|
+
"""Count crossings between edges connecting two adjacent layers.
|
|
143
|
+
|
|
144
|
+
Two edges (i->j) and (k->l) cross iff (i < k and j > l) or
|
|
145
|
+
(i > k and j < l), where i,k are positions on the source layer
|
|
146
|
+
and j,l are positions on the target layer.
|
|
147
|
+
"""
|
|
148
|
+
source_pos = layer_global_positions(source_layer)
|
|
149
|
+
target_pos = layer_global_positions(target_layer)
|
|
150
|
+
|
|
151
|
+
# Filter to edges that have valid positions in both layers
|
|
152
|
+
valid_edges: list[LayerEdge] = []
|
|
153
|
+
for e in edges:
|
|
154
|
+
sk = (e.source_ref, e.source_pin)
|
|
155
|
+
tk = (e.target_ref, e.target_pin)
|
|
156
|
+
if sk in source_pos and tk in target_pos:
|
|
157
|
+
valid_edges.append(e)
|
|
158
|
+
|
|
159
|
+
crossings: list[LayerPairCrossing] = []
|
|
160
|
+
for a, b in combinations(valid_edges, 2):
|
|
161
|
+
si = source_pos[(a.source_ref, a.source_pin)]
|
|
162
|
+
sk = source_pos[(b.source_ref, b.source_pin)]
|
|
163
|
+
tj = target_pos[(a.target_ref, a.target_pin)]
|
|
164
|
+
tl = target_pos[(b.target_ref, b.target_pin)]
|
|
165
|
+
if (si < sk and tj > tl) or (si > sk and tj < tl):
|
|
166
|
+
crossings.append(LayerPairCrossing(a, b))
|
|
167
|
+
return crossings
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def extract_layer_pair_edges(
|
|
171
|
+
nets: dict[str, list[tuple[str, str]]],
|
|
172
|
+
source_refs: set[str],
|
|
173
|
+
target_refs: set[str],
|
|
174
|
+
) -> list[LayerEdge]:
|
|
175
|
+
"""Extract edges between two sets of components from parsed net data.
|
|
176
|
+
|
|
177
|
+
A net produces edges if it has pins on components in both the source
|
|
178
|
+
and target sets.
|
|
179
|
+
"""
|
|
180
|
+
edges: list[LayerEdge] = []
|
|
181
|
+
for net_name, nodes in nets.items():
|
|
182
|
+
source_pins = [(ref, pin) for ref, pin in nodes if ref in source_refs]
|
|
183
|
+
target_pins = [(ref, pin) for ref, pin in nodes if ref in target_refs]
|
|
184
|
+
for sref, spin in source_pins:
|
|
185
|
+
for tref, tpin in target_pins:
|
|
186
|
+
edges.append(LayerEdge(
|
|
187
|
+
net_name=net_name,
|
|
188
|
+
source_ref=sref, source_pin=spin,
|
|
189
|
+
target_ref=tref, target_pin=tpin,
|
|
190
|
+
))
|
|
191
|
+
return edges
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _compute_multilayer_barycenters(
|
|
195
|
+
component_ref: str,
|
|
196
|
+
adjacent_layer: list[PinColumn],
|
|
197
|
+
edges: list[LayerEdge],
|
|
198
|
+
) -> dict[str, float]:
|
|
199
|
+
"""Compute barycenter for each pin of a component based on its
|
|
200
|
+
connections to an adjacent layer.
|
|
201
|
+
|
|
202
|
+
Works regardless of whether the component is on the source or target
|
|
203
|
+
side of the edges.
|
|
204
|
+
"""
|
|
205
|
+
adj_pos = layer_global_positions(adjacent_layer)
|
|
206
|
+
|
|
207
|
+
pin_positions: dict[str, list[int]] = {}
|
|
208
|
+
for edge in edges:
|
|
209
|
+
if edge.source_ref == component_ref:
|
|
210
|
+
pin = edge.source_pin
|
|
211
|
+
adj_key = (edge.target_ref, edge.target_pin)
|
|
212
|
+
elif edge.target_ref == component_ref:
|
|
213
|
+
pin = edge.target_pin
|
|
214
|
+
adj_key = (edge.source_ref, edge.source_pin)
|
|
215
|
+
else:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
if adj_key in adj_pos:
|
|
219
|
+
pin_positions.setdefault(pin, []).append(adj_pos[adj_key])
|
|
220
|
+
|
|
221
|
+
barycenters: dict[str, float] = {}
|
|
222
|
+
for pin, positions in pin_positions.items():
|
|
223
|
+
barycenters[pin] = sum(positions) / len(positions)
|
|
224
|
+
|
|
225
|
+
return barycenters
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _reorder_pins_by_barycenter(
|
|
229
|
+
component: PinColumn,
|
|
230
|
+
barycenters: dict[str, float],
|
|
231
|
+
) -> PinColumn:
|
|
232
|
+
"""Reorder a component's pins by their barycenter values.
|
|
233
|
+
|
|
234
|
+
Pins without a barycenter (unconnected to the adjacent layer)
|
|
235
|
+
keep their relative order and are placed at the end.
|
|
236
|
+
"""
|
|
237
|
+
connected = [p for p in component.pin_order if p in barycenters]
|
|
238
|
+
unconnected = [p for p in component.pin_order if p not in barycenters]
|
|
239
|
+
connected.sort(key=lambda p: barycenters[p])
|
|
240
|
+
return PinColumn(ref=component.ref, pin_order=connected + unconnected)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _reorder_components_in_layer(
|
|
244
|
+
layer: list[PinColumn],
|
|
245
|
+
adjacent_layer: list[PinColumn],
|
|
246
|
+
edges: list[LayerEdge],
|
|
247
|
+
) -> list[PinColumn]:
|
|
248
|
+
"""Reorder components within a layer by their aggregate barycenter.
|
|
249
|
+
|
|
250
|
+
Each component's aggregate barycenter is the average of all its
|
|
251
|
+
pin barycenters relative to the adjacent layer.
|
|
252
|
+
"""
|
|
253
|
+
adj_pos = layer_global_positions(adjacent_layer)
|
|
254
|
+
|
|
255
|
+
comp_barycenters: dict[str, float] = {}
|
|
256
|
+
for comp in layer:
|
|
257
|
+
positions: list[int] = []
|
|
258
|
+
for edge in edges:
|
|
259
|
+
if edge.source_ref == comp.ref:
|
|
260
|
+
adj_key = (edge.target_ref, edge.target_pin)
|
|
261
|
+
elif edge.target_ref == comp.ref:
|
|
262
|
+
adj_key = (edge.source_ref, edge.source_pin)
|
|
263
|
+
else:
|
|
264
|
+
continue
|
|
265
|
+
if adj_key in adj_pos:
|
|
266
|
+
positions.append(adj_pos[adj_key])
|
|
267
|
+
|
|
268
|
+
if positions:
|
|
269
|
+
comp_barycenters[comp.ref] = sum(positions) / len(positions)
|
|
270
|
+
else:
|
|
271
|
+
comp_barycenters[comp.ref] = float("inf")
|
|
272
|
+
|
|
273
|
+
return sorted(layer, key=lambda c: comp_barycenters[c.ref])
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _build_component_layer_map(
|
|
277
|
+
layers: list[list[PinColumn]],
|
|
278
|
+
) -> dict[str, int]:
|
|
279
|
+
"""Map component ref -> layer index."""
|
|
280
|
+
result: dict[str, int] = {}
|
|
281
|
+
for i, layer in enumerate(layers):
|
|
282
|
+
for comp in layer:
|
|
283
|
+
result[comp.ref] = i
|
|
284
|
+
return result
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _expand_with_virtual_nodes(
|
|
288
|
+
layers: list[list[PinColumn]],
|
|
289
|
+
nets: dict[str, list[tuple[str, str]]],
|
|
290
|
+
) -> tuple[list[list[PinColumn]], list[list[LayerEdge]], set[str]]:
|
|
291
|
+
"""Insert virtual nodes for long edges spanning non-adjacent layers.
|
|
292
|
+
|
|
293
|
+
For a net connecting components in layers 0 and 2, a virtual pin is
|
|
294
|
+
inserted in layer 1 so the sweep can account for all routing paths.
|
|
295
|
+
|
|
296
|
+
Only pins present in the layers' pin_order lists are considered,
|
|
297
|
+
so caller exclusions are automatically respected.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
expanded_layers: layers with virtual PinColumn added where needed.
|
|
301
|
+
layer_pair_edges: pre-computed edge list for each adjacent pair.
|
|
302
|
+
virtual_refs: set of virtual component refs (always reorderable).
|
|
303
|
+
"""
|
|
304
|
+
comp_layer = _build_component_layer_map(layers)
|
|
305
|
+
n_layers = len(layers)
|
|
306
|
+
|
|
307
|
+
# Build set of active (ref, pin) from the layer data
|
|
308
|
+
active_pins: set[tuple[str, str]] = set()
|
|
309
|
+
for layer in layers:
|
|
310
|
+
for comp in layer:
|
|
311
|
+
for pin in comp.pin_order:
|
|
312
|
+
active_pins.add((comp.ref, pin))
|
|
313
|
+
|
|
314
|
+
# Collect virtual pins needed per intermediate layer
|
|
315
|
+
virt_pins_per_layer: dict[int, list[str]] = {}
|
|
316
|
+
virt_counter = 0
|
|
317
|
+
|
|
318
|
+
# Build edge lists for each layer pair
|
|
319
|
+
pair_edges: list[list[LayerEdge]] = [[] for _ in range(n_layers - 1)]
|
|
320
|
+
|
|
321
|
+
for net_name, nodes in nets.items():
|
|
322
|
+
# Group nodes by layer, filtering to active pins only
|
|
323
|
+
nodes_by_layer: dict[int, list[tuple[str, str]]] = {}
|
|
324
|
+
for ref, pin in nodes:
|
|
325
|
+
if ref not in comp_layer:
|
|
326
|
+
continue
|
|
327
|
+
if (ref, pin) not in active_pins:
|
|
328
|
+
continue
|
|
329
|
+
li = comp_layer[ref]
|
|
330
|
+
nodes_by_layer.setdefault(li, []).append((ref, pin))
|
|
331
|
+
|
|
332
|
+
# For each pair of layer groups, create edges
|
|
333
|
+
sorted_layers = sorted(nodes_by_layer.keys())
|
|
334
|
+
for a_idx in range(len(sorted_layers)):
|
|
335
|
+
for b_idx in range(a_idx + 1, len(sorted_layers)):
|
|
336
|
+
src_li = sorted_layers[a_idx]
|
|
337
|
+
tgt_li = sorted_layers[b_idx]
|
|
338
|
+
gap = tgt_li - src_li
|
|
339
|
+
|
|
340
|
+
for sref, spin in nodes_by_layer[src_li]:
|
|
341
|
+
for tref, tpin in nodes_by_layer[tgt_li]:
|
|
342
|
+
if gap == 1:
|
|
343
|
+
# Direct edge: no virtual nodes needed
|
|
344
|
+
pair_edges[src_li].append(
|
|
345
|
+
LayerEdge(net_name, sref, spin, tref, tpin)
|
|
346
|
+
)
|
|
347
|
+
else:
|
|
348
|
+
# Long edge: create virtual pins in intermediate layers
|
|
349
|
+
chain: list[tuple[str, str]] = [(sref, spin)]
|
|
350
|
+
for mid_li in range(src_li + 1, tgt_li):
|
|
351
|
+
vref = f"_virt_L{mid_li}"
|
|
352
|
+
vid = f"_v{virt_counter}"
|
|
353
|
+
virt_counter += 1
|
|
354
|
+
virt_pins_per_layer.setdefault(mid_li, []).append(vid)
|
|
355
|
+
chain.append((vref, vid))
|
|
356
|
+
chain.append((tref, tpin))
|
|
357
|
+
|
|
358
|
+
# Create edge segments along the chain
|
|
359
|
+
for seg in range(len(chain) - 1):
|
|
360
|
+
sr, sp = chain[seg]
|
|
361
|
+
tr, tp = chain[seg + 1]
|
|
362
|
+
edge_li = src_li + seg
|
|
363
|
+
pair_edges[edge_li].append(
|
|
364
|
+
LayerEdge(net_name, sr, sp, tr, tp)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
# Build expanded layers with virtual PinColumns
|
|
368
|
+
expanded: list[list[PinColumn]] = []
|
|
369
|
+
virtual_refs: set[str] = set()
|
|
370
|
+
for i, layer in enumerate(layers):
|
|
371
|
+
new_layer = [
|
|
372
|
+
PinColumn(ref=c.ref, pin_order=list(c.pin_order)) for c in layer
|
|
373
|
+
]
|
|
374
|
+
if i in virt_pins_per_layer:
|
|
375
|
+
vref = f"_virt_L{i}"
|
|
376
|
+
virtual_refs.add(vref)
|
|
377
|
+
new_layer.append(PinColumn(ref=vref, pin_order=virt_pins_per_layer[i]))
|
|
378
|
+
expanded.append(new_layer)
|
|
379
|
+
|
|
380
|
+
return expanded, pair_edges, virtual_refs
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def sweep_optimize(
|
|
384
|
+
layers: list[list[PinColumn]],
|
|
385
|
+
reorderable_refs: set[str],
|
|
386
|
+
nets: dict[str, list[tuple[str, str]]],
|
|
387
|
+
max_iterations: int = 10,
|
|
388
|
+
) -> MultilayerReport:
|
|
389
|
+
"""Minimize crossings across all layer pairs using Sugiyama-style
|
|
390
|
+
barycenter sweep.
|
|
391
|
+
|
|
392
|
+
Handles long edges (nets spanning non-adjacent layers) by inserting
|
|
393
|
+
virtual nodes in intermediate layers so the sweep accounts for all
|
|
394
|
+
routing paths.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
layers: List of layers, each layer is a list of PinColumn objects.
|
|
398
|
+
layers[0] is the leftmost/topmost layer.
|
|
399
|
+
reorderable_refs: Set of component refs whose pin order can change.
|
|
400
|
+
nets: Parsed netlist data (net_name -> [(ref, pin), ...]).
|
|
401
|
+
max_iterations: Maximum forward+backward sweep iterations.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
MultilayerReport with crossing counts before/after and optimized orders.
|
|
405
|
+
"""
|
|
406
|
+
# Save original orders before any modification
|
|
407
|
+
original_orders: dict[str, list[str]] = {}
|
|
408
|
+
for layer in layers:
|
|
409
|
+
for comp in layer:
|
|
410
|
+
original_orders[comp.ref] = list(comp.pin_order)
|
|
411
|
+
|
|
412
|
+
# Expand layers with virtual nodes for long edges
|
|
413
|
+
layers, layer_pair_edges, virtual_refs = _expand_with_virtual_nodes(layers, nets)
|
|
414
|
+
|
|
415
|
+
# Virtual nodes are always reorderable
|
|
416
|
+
all_reorderable = reorderable_refs | virtual_refs
|
|
417
|
+
|
|
418
|
+
# Count initial crossings
|
|
419
|
+
def total_crossing_count() -> int:
|
|
420
|
+
total = 0
|
|
421
|
+
for i, edges in enumerate(layer_pair_edges):
|
|
422
|
+
crossings = count_layer_pair_crossings(
|
|
423
|
+
layers[i], layers[i + 1], edges,
|
|
424
|
+
)
|
|
425
|
+
total += len(crossings)
|
|
426
|
+
return total
|
|
427
|
+
|
|
428
|
+
initial_total = total_crossing_count()
|
|
429
|
+
best_total = initial_total
|
|
430
|
+
iterations = 0
|
|
431
|
+
|
|
432
|
+
for iteration in range(max_iterations):
|
|
433
|
+
improved = False
|
|
434
|
+
|
|
435
|
+
# Forward sweep: fix layer i, optimize layer i+1
|
|
436
|
+
for i in range(len(layers) - 1):
|
|
437
|
+
edges = layer_pair_edges[i]
|
|
438
|
+
new_layer: list[PinColumn] = []
|
|
439
|
+
for comp in layers[i + 1]:
|
|
440
|
+
if comp.ref in all_reorderable:
|
|
441
|
+
bc = _compute_multilayer_barycenters(
|
|
442
|
+
comp.ref, layers[i], edges,
|
|
443
|
+
)
|
|
444
|
+
new_layer.append(_reorder_pins_by_barycenter(comp, bc))
|
|
445
|
+
else:
|
|
446
|
+
new_layer.append(comp)
|
|
447
|
+
layers[i + 1] = _reorder_components_in_layer(
|
|
448
|
+
new_layer, layers[i], edges,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# Backward sweep: fix layer i+1, optimize layer i
|
|
452
|
+
for i in range(len(layers) - 2, -1, -1):
|
|
453
|
+
edges = layer_pair_edges[i]
|
|
454
|
+
new_layer = []
|
|
455
|
+
for comp in layers[i]:
|
|
456
|
+
if comp.ref in all_reorderable:
|
|
457
|
+
bc = _compute_multilayer_barycenters(
|
|
458
|
+
comp.ref, layers[i + 1], edges,
|
|
459
|
+
)
|
|
460
|
+
new_layer.append(_reorder_pins_by_barycenter(comp, bc))
|
|
461
|
+
else:
|
|
462
|
+
new_layer.append(comp)
|
|
463
|
+
layers[i] = _reorder_components_in_layer(
|
|
464
|
+
new_layer, layers[i + 1], edges,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
current_total = total_crossing_count()
|
|
468
|
+
iterations = iteration + 1
|
|
469
|
+
|
|
470
|
+
if current_total < best_total:
|
|
471
|
+
best_total = current_total
|
|
472
|
+
improved = True
|
|
473
|
+
|
|
474
|
+
if not improved:
|
|
475
|
+
break
|
|
476
|
+
|
|
477
|
+
# Build layer pair reports for final state (filter out virtual refs from display)
|
|
478
|
+
pair_reports: list[LayerPairReport] = []
|
|
479
|
+
for i, edges in enumerate(layer_pair_edges):
|
|
480
|
+
crossings = count_layer_pair_crossings(
|
|
481
|
+
layers[i], layers[i + 1], edges,
|
|
482
|
+
)
|
|
483
|
+
pair_reports.append(LayerPairReport(
|
|
484
|
+
source_layer_idx=i,
|
|
485
|
+
target_layer_idx=i + 1,
|
|
486
|
+
source_refs=[c.ref for c in layers[i] if c.ref not in virtual_refs],
|
|
487
|
+
target_refs=[c.ref for c in layers[i + 1] if c.ref not in virtual_refs],
|
|
488
|
+
crossing_count=len(crossings),
|
|
489
|
+
crossings=crossings,
|
|
490
|
+
))
|
|
491
|
+
|
|
492
|
+
# Collect optimized orders (real components only)
|
|
493
|
+
optimized_orders: dict[str, list[str]] = {}
|
|
494
|
+
for layer in layers:
|
|
495
|
+
for comp in layer:
|
|
496
|
+
if comp.ref not in virtual_refs:
|
|
497
|
+
optimized_orders[comp.ref] = list(comp.pin_order)
|
|
498
|
+
|
|
499
|
+
return MultilayerReport(
|
|
500
|
+
total_crossings=initial_total,
|
|
501
|
+
total_crossings_after=best_total,
|
|
502
|
+
layer_pair_reports=pair_reports,
|
|
503
|
+
original_orders=original_orders,
|
|
504
|
+
optimized_orders=optimized_orders,
|
|
505
|
+
iterations=iterations,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# =========================================================================
|
|
510
|
+
# KiCad netlist parser (.net S-expression format)
|
|
511
|
+
# =========================================================================
|
|
512
|
+
|
|
513
|
+
def _tokenize_sexp(text: str) -> list[str]:
|
|
514
|
+
"""Tokenize an S-expression into a flat list of tokens."""
|
|
515
|
+
tokens: list[str] = []
|
|
516
|
+
i = 0
|
|
517
|
+
while i < len(text):
|
|
518
|
+
c = text[i]
|
|
519
|
+
if c in " \t\n\r":
|
|
520
|
+
i += 1
|
|
521
|
+
elif c == "(":
|
|
522
|
+
tokens.append("(")
|
|
523
|
+
i += 1
|
|
524
|
+
elif c == ")":
|
|
525
|
+
tokens.append(")")
|
|
526
|
+
i += 1
|
|
527
|
+
elif c == '"':
|
|
528
|
+
# Quoted string
|
|
529
|
+
j = i + 1
|
|
530
|
+
while j < len(text) and text[j] != '"':
|
|
531
|
+
if text[j] == "\\":
|
|
532
|
+
j += 1 # skip escaped char
|
|
533
|
+
j += 1
|
|
534
|
+
tokens.append(text[i + 1 : j])
|
|
535
|
+
i = j + 1
|
|
536
|
+
else:
|
|
537
|
+
# Unquoted atom
|
|
538
|
+
j = i
|
|
539
|
+
while j < len(text) and text[j] not in " \t\n\r()\"":
|
|
540
|
+
j += 1
|
|
541
|
+
tokens.append(text[i:j])
|
|
542
|
+
i = j
|
|
543
|
+
return tokens
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _parse_sexp(tokens: list[str], pos: int = 0) -> tuple:
|
|
547
|
+
"""Parse tokenized S-expression into nested tuples."""
|
|
548
|
+
if tokens[pos] == "(":
|
|
549
|
+
items = []
|
|
550
|
+
pos += 1
|
|
551
|
+
while tokens[pos] != ")":
|
|
552
|
+
item, pos = _parse_sexp(tokens, pos)
|
|
553
|
+
items.append(item)
|
|
554
|
+
return tuple(items), pos + 1
|
|
555
|
+
else:
|
|
556
|
+
return tokens[pos], pos + 1
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _find_nodes(sexp, tag: str):
|
|
560
|
+
"""Recursively find all sub-expressions starting with the given tag."""
|
|
561
|
+
results = []
|
|
562
|
+
if isinstance(sexp, tuple):
|
|
563
|
+
if len(sexp) > 0 and sexp[0] == tag:
|
|
564
|
+
results.append(sexp)
|
|
565
|
+
for child in sexp:
|
|
566
|
+
results.extend(_find_nodes(child, tag))
|
|
567
|
+
return results
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def parse_netlist(filepath: str) -> dict:
|
|
571
|
+
"""Parse a KiCad .net file, returning components and net connectivity.
|
|
572
|
+
|
|
573
|
+
Returns:
|
|
574
|
+
{
|
|
575
|
+
"components": {ref: {"value": str, "part": str}},
|
|
576
|
+
"nets": {net_name: [(ref, pin_id), ...]},
|
|
577
|
+
}
|
|
578
|
+
"""
|
|
579
|
+
text = Path(filepath).read_text(encoding="utf-8")
|
|
580
|
+
tokens = _tokenize_sexp(text)
|
|
581
|
+
tree, _ = _parse_sexp(tokens)
|
|
582
|
+
|
|
583
|
+
components: dict[str, dict] = {}
|
|
584
|
+
for comp in _find_nodes(tree, "comp"):
|
|
585
|
+
ref = value = part = None
|
|
586
|
+
for item in comp:
|
|
587
|
+
if isinstance(item, tuple):
|
|
588
|
+
if item[0] == "ref" and len(item) > 1:
|
|
589
|
+
ref = item[1]
|
|
590
|
+
elif item[0] == "value" and len(item) > 1:
|
|
591
|
+
value = item[1]
|
|
592
|
+
elif item[0] == "libsource":
|
|
593
|
+
for sub in item:
|
|
594
|
+
if isinstance(sub, tuple) and sub[0] == "part" and len(sub) > 1:
|
|
595
|
+
part = sub[1]
|
|
596
|
+
if ref:
|
|
597
|
+
components[ref] = {"value": value, "part": part}
|
|
598
|
+
|
|
599
|
+
nets: dict[str, list[tuple[str, str]]] = {}
|
|
600
|
+
for net in _find_nodes(tree, "net"):
|
|
601
|
+
net_name = None
|
|
602
|
+
nodes = []
|
|
603
|
+
for item in net:
|
|
604
|
+
if isinstance(item, tuple):
|
|
605
|
+
if item[0] == "name" and len(item) > 1:
|
|
606
|
+
net_name = item[1]
|
|
607
|
+
elif item[0] == "node":
|
|
608
|
+
ref = pin = None
|
|
609
|
+
for sub in item:
|
|
610
|
+
if isinstance(sub, tuple):
|
|
611
|
+
if sub[0] == "ref" and len(sub) > 1:
|
|
612
|
+
ref = sub[1]
|
|
613
|
+
elif sub[0] == "pin" and len(sub) > 1:
|
|
614
|
+
pin = sub[1]
|
|
615
|
+
if ref and pin:
|
|
616
|
+
nodes.append((ref, pin))
|
|
617
|
+
if net_name:
|
|
618
|
+
nets[net_name] = nodes
|
|
619
|
+
|
|
620
|
+
return {"components": components, "nets": nets}
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def infer_pin_order(ref: str, nets: dict[str, list[tuple[str, str]]]) -> list[str]:
|
|
624
|
+
"""Infer pin order for a component by collecting all pin IDs seen in the
|
|
625
|
+
netlist and sorting them numerically (with non-numeric pins like 'SH' last)."""
|
|
626
|
+
pins: set[str] = set()
|
|
627
|
+
for nodes in nets.values():
|
|
628
|
+
for node_ref, pin in nodes:
|
|
629
|
+
if node_ref == ref:
|
|
630
|
+
pins.add(pin)
|
|
631
|
+
|
|
632
|
+
def sort_key(p: str):
|
|
633
|
+
try:
|
|
634
|
+
return (0, int(p))
|
|
635
|
+
except ValueError:
|
|
636
|
+
return (1, p)
|
|
637
|
+
|
|
638
|
+
return sorted(pins, key=sort_key)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
# =========================================================================
|
|
642
|
+
# Report formatting
|
|
643
|
+
# =========================================================================
|
|
644
|
+
|
|
645
|
+
def format_multilayer_report(
|
|
646
|
+
report: MultilayerReport,
|
|
647
|
+
nets: dict[str, list[tuple[str, str]]],
|
|
648
|
+
) -> str:
|
|
649
|
+
"""Format a MultilayerReport as human-readable text."""
|
|
650
|
+
lines: list[str] = []
|
|
651
|
+
lines.append("Multi-Layer Crossing Analysis")
|
|
652
|
+
lines.append("=" * 60)
|
|
653
|
+
lines.append("")
|
|
654
|
+
lines.append(f"Total crossings (before): {report.total_crossings}")
|
|
655
|
+
lines.append(f"Total crossings (after): {report.total_crossings_after}")
|
|
656
|
+
lines.append(f"Sweep iterations: {report.iterations}")
|
|
657
|
+
lines.append("")
|
|
658
|
+
|
|
659
|
+
for pair_report in report.layer_pair_reports:
|
|
660
|
+
src = ", ".join(r for r in pair_report.source_refs if not r.startswith("_virt_"))
|
|
661
|
+
tgt = ", ".join(r for r in pair_report.target_refs if not r.startswith("_virt_"))
|
|
662
|
+
src_label = src or "[pass-through]"
|
|
663
|
+
tgt_label = tgt or "[pass-through]"
|
|
664
|
+
lines.append(
|
|
665
|
+
f"Layer {pair_report.source_layer_idx} [{src_label}] -> "
|
|
666
|
+
f"Layer {pair_report.target_layer_idx} [{tgt_label}]"
|
|
667
|
+
)
|
|
668
|
+
lines.append(f" Crossings: {pair_report.crossing_count}")
|
|
669
|
+
for i, cp in enumerate(pair_report.crossings, 1):
|
|
670
|
+
a_src = _format_pin_ref(cp.edge_a.source_ref, cp.edge_a.source_pin)
|
|
671
|
+
a_tgt = _format_pin_ref(cp.edge_a.target_ref, cp.edge_a.target_pin)
|
|
672
|
+
b_src = _format_pin_ref(cp.edge_b.source_ref, cp.edge_b.source_pin)
|
|
673
|
+
b_tgt = _format_pin_ref(cp.edge_b.target_ref, cp.edge_b.target_pin)
|
|
674
|
+
lines.append(
|
|
675
|
+
f" {i}. {cp.edge_a.net_name} "
|
|
676
|
+
f"({a_src} -> {a_tgt}) X "
|
|
677
|
+
f"{cp.edge_b.net_name} "
|
|
678
|
+
f"({b_src} -> {b_tgt})"
|
|
679
|
+
)
|
|
680
|
+
lines.append("")
|
|
681
|
+
|
|
682
|
+
# Show reordering recommendations
|
|
683
|
+
changed = {
|
|
684
|
+
ref for ref in report.optimized_orders
|
|
685
|
+
if report.optimized_orders[ref] != report.original_orders.get(ref)
|
|
686
|
+
}
|
|
687
|
+
if changed:
|
|
688
|
+
lines.append("Recommended pin reorderings:")
|
|
689
|
+
for ref in sorted(changed):
|
|
690
|
+
original = ", ".join(report.original_orders[ref])
|
|
691
|
+
optimized = ", ".join(report.optimized_orders[ref])
|
|
692
|
+
lines.append(f" {ref}: [{original}] -> [{optimized}]")
|
|
693
|
+
else:
|
|
694
|
+
lines.append("No reordering needed.")
|
|
695
|
+
|
|
696
|
+
if report.total_crossings_after > 0:
|
|
697
|
+
lines.append("")
|
|
698
|
+
lines.append(
|
|
699
|
+
"WARNING: Not all crossings can be eliminated by reordering alone."
|
|
700
|
+
)
|
|
701
|
+
lines.append(
|
|
702
|
+
"Remaining crossings will require vias or a second routing layer."
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
return "\n".join(lines)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def report_to_dict(
|
|
709
|
+
report: MultilayerReport,
|
|
710
|
+
nets: dict[str, list[tuple[str, str]]],
|
|
711
|
+
) -> dict:
|
|
712
|
+
"""Convert a MultilayerReport to a JSON-serializable dict."""
|
|
713
|
+
layer_pairs = []
|
|
714
|
+
for pr in report.layer_pair_reports:
|
|
715
|
+
crossings = []
|
|
716
|
+
for cp in pr.crossings:
|
|
717
|
+
crossings.append({
|
|
718
|
+
"edge_a": {
|
|
719
|
+
"net": cp.edge_a.net_name,
|
|
720
|
+
"source": _format_pin_ref(cp.edge_a.source_ref, cp.edge_a.source_pin),
|
|
721
|
+
"target": _format_pin_ref(cp.edge_a.target_ref, cp.edge_a.target_pin),
|
|
722
|
+
},
|
|
723
|
+
"edge_b": {
|
|
724
|
+
"net": cp.edge_b.net_name,
|
|
725
|
+
"source": _format_pin_ref(cp.edge_b.source_ref, cp.edge_b.source_pin),
|
|
726
|
+
"target": _format_pin_ref(cp.edge_b.target_ref, cp.edge_b.target_pin),
|
|
727
|
+
},
|
|
728
|
+
})
|
|
729
|
+
layer_pairs.append({
|
|
730
|
+
"source_layer": pr.source_layer_idx,
|
|
731
|
+
"target_layer": pr.target_layer_idx,
|
|
732
|
+
"source_refs": [r for r in pr.source_refs if not r.startswith("_virt_")],
|
|
733
|
+
"target_refs": [r for r in pr.target_refs if not r.startswith("_virt_")],
|
|
734
|
+
"crossing_count": pr.crossing_count,
|
|
735
|
+
"crossings": crossings,
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
reorderings = {}
|
|
739
|
+
for ref in sorted(report.optimized_orders):
|
|
740
|
+
orig = report.original_orders.get(ref, [])
|
|
741
|
+
opt = report.optimized_orders[ref]
|
|
742
|
+
if orig != opt:
|
|
743
|
+
reorderings[ref] = {"original": orig, "optimized": opt}
|
|
744
|
+
|
|
745
|
+
return {
|
|
746
|
+
"total_crossings_before": report.total_crossings,
|
|
747
|
+
"total_crossings_after": report.total_crossings_after,
|
|
748
|
+
"iterations": report.iterations,
|
|
749
|
+
"layer_pairs": layer_pairs,
|
|
750
|
+
"reorderings": reorderings,
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
# =========================================================================
|
|
755
|
+
# Footprint planning
|
|
756
|
+
# =========================================================================
|
|
757
|
+
|
|
758
|
+
def parse_pin_locks(lock_args: list[str]) -> dict[str, str | None]:
|
|
759
|
+
"""Parse --lock arguments into a pin-to-net mapping.
|
|
760
|
+
|
|
761
|
+
Each arg is 'PIN=NET' or 'PIN=NC'.
|
|
762
|
+
Returns dict mapping pin ID to net name (or None for NC).
|
|
763
|
+
"""
|
|
764
|
+
locks: dict[str, str | None] = {}
|
|
765
|
+
for arg in lock_args:
|
|
766
|
+
if "=" not in arg:
|
|
767
|
+
raise ValueError(
|
|
768
|
+
f"Invalid --lock format: '{arg}'. Expected PIN=NET or PIN=NC."
|
|
769
|
+
)
|
|
770
|
+
pin, net = arg.split("=", 1)
|
|
771
|
+
pin = pin.strip()
|
|
772
|
+
net = net.strip()
|
|
773
|
+
if not pin:
|
|
774
|
+
raise ValueError(f"Empty pin in --lock: '{arg}'")
|
|
775
|
+
if net.upper() == "NC":
|
|
776
|
+
locks[pin] = None
|
|
777
|
+
else:
|
|
778
|
+
locks[pin] = net
|
|
779
|
+
return locks
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _find_primary_route(
|
|
783
|
+
target_ref: str,
|
|
784
|
+
target_pin: str,
|
|
785
|
+
net_name: str,
|
|
786
|
+
nets: dict[str, list[tuple[str, str]]],
|
|
787
|
+
) -> str | None:
|
|
788
|
+
"""Find the primary non-target endpoint for a net (for 'routes to' display)."""
|
|
789
|
+
nodes = nets.get(net_name, [])
|
|
790
|
+
for ref, pin in nodes:
|
|
791
|
+
if ref != target_ref and not ref.startswith("TP"):
|
|
792
|
+
return f"{ref}.{pin}"
|
|
793
|
+
return None
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def build_pin_map(
|
|
797
|
+
target_ref: str,
|
|
798
|
+
all_pins: list[str],
|
|
799
|
+
locks: dict[str, str | None],
|
|
800
|
+
optimized_signal_pins: list[str],
|
|
801
|
+
pin_to_net: dict[str, str],
|
|
802
|
+
nets: dict[str, list[tuple[str, str]]],
|
|
803
|
+
unmatched_mode: str,
|
|
804
|
+
) -> list[PinAssignment]:
|
|
805
|
+
"""Build the final pin map by merging locked, optimized, and unmatched pins.
|
|
806
|
+
|
|
807
|
+
Args:
|
|
808
|
+
target_ref: Component ref (for routes_to lookup).
|
|
809
|
+
all_pins: All pin IDs in physical position order.
|
|
810
|
+
locks: Pin-to-net locks from --lock (None = NC).
|
|
811
|
+
optimized_signal_pins: Pin IDs in sweep-optimized order (signal pins only).
|
|
812
|
+
pin_to_net: Mapping of pin ID to net name (from netlist).
|
|
813
|
+
nets: Full net connectivity for routes_to lookup.
|
|
814
|
+
unmatched_mode: 'start', 'end', or 'split'.
|
|
815
|
+
|
|
816
|
+
Returns:
|
|
817
|
+
List of PinAssignment in physical position order.
|
|
818
|
+
"""
|
|
819
|
+
# Categorize pins
|
|
820
|
+
locked_pins = set(locks.keys())
|
|
821
|
+
signal_pins = set(optimized_signal_pins)
|
|
822
|
+
unmatched_pins = [
|
|
823
|
+
p for p in all_pins
|
|
824
|
+
if p not in locked_pins and p not in signal_pins
|
|
825
|
+
]
|
|
826
|
+
|
|
827
|
+
# Build the assignment slots
|
|
828
|
+
n = len(all_pins)
|
|
829
|
+
result: list[PinAssignment | None] = [None] * n
|
|
830
|
+
pin_to_idx = {p: i for i, p in enumerate(all_pins)}
|
|
831
|
+
|
|
832
|
+
# 1. Place locked pins at their positions
|
|
833
|
+
for pin, net in locks.items():
|
|
834
|
+
if pin in pin_to_idx:
|
|
835
|
+
idx = pin_to_idx[pin]
|
|
836
|
+
result[idx] = PinAssignment(
|
|
837
|
+
pin=pin, net=net, status="locked",
|
|
838
|
+
routes_to=_find_primary_route(target_ref, pin, net, nets) if net else None,
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
# 2. Collect open slots (not locked)
|
|
842
|
+
open_slots = [i for i in range(n) if result[i] is None]
|
|
843
|
+
|
|
844
|
+
# 3. Place unmatched pins per mode, filling from the open slots
|
|
845
|
+
if unmatched_mode == "start":
|
|
846
|
+
unmatched_slots = open_slots[:len(unmatched_pins)]
|
|
847
|
+
signal_slots = open_slots[len(unmatched_pins):]
|
|
848
|
+
elif unmatched_mode == "split":
|
|
849
|
+
half = len(unmatched_pins) // 2
|
|
850
|
+
unmatched_start = unmatched_pins[:half]
|
|
851
|
+
unmatched_end = unmatched_pins[half:]
|
|
852
|
+
signal_slot_count = len(open_slots) - len(unmatched_pins)
|
|
853
|
+
unmatched_slots = open_slots[:len(unmatched_start)] + open_slots[len(unmatched_start) + signal_slot_count:]
|
|
854
|
+
signal_slots = open_slots[len(unmatched_start):len(unmatched_start) + signal_slot_count]
|
|
855
|
+
# Rebuild unmatched_pins to match the split order
|
|
856
|
+
unmatched_pins = unmatched_start + unmatched_end
|
|
857
|
+
else: # "end" (default)
|
|
858
|
+
signal_slots = open_slots[:len(optimized_signal_pins)]
|
|
859
|
+
unmatched_slots = open_slots[len(optimized_signal_pins):]
|
|
860
|
+
|
|
861
|
+
# 4. Place optimized signal pins in sweep order
|
|
862
|
+
for sig_pin, slot_idx in zip(optimized_signal_pins, signal_slots):
|
|
863
|
+
net = pin_to_net.get(sig_pin)
|
|
864
|
+
result[slot_idx] = PinAssignment(
|
|
865
|
+
pin=all_pins[slot_idx], net=net, status="optimized",
|
|
866
|
+
routes_to=_find_primary_route(target_ref, sig_pin, net, nets) if net else None,
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
# 5. Place unmatched pins
|
|
870
|
+
for um_pin, slot_idx in zip(unmatched_pins, unmatched_slots):
|
|
871
|
+
net = pin_to_net.get(um_pin)
|
|
872
|
+
result[slot_idx] = PinAssignment(
|
|
873
|
+
pin=all_pins[slot_idx], net=net, status="unmatched",
|
|
874
|
+
routes_to=None,
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
# Fill any remaining Nones (shouldn't happen but defensive)
|
|
878
|
+
for i in range(n):
|
|
879
|
+
if result[i] is None:
|
|
880
|
+
result[i] = PinAssignment(pin=all_pins[i], net=None, status="unmatched")
|
|
881
|
+
|
|
882
|
+
return result
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def plan_footprint(
|
|
886
|
+
target_ref: str,
|
|
887
|
+
target_pins: list[str],
|
|
888
|
+
anchor_layers: list[list[PinColumn]],
|
|
889
|
+
nets: dict[str, list[tuple[str, str]]],
|
|
890
|
+
locks: dict[str, str | None],
|
|
891
|
+
unmatched: str = "end",
|
|
892
|
+
exclude_nets: set[str] | None = None,
|
|
893
|
+
) -> FootprintPlan:
|
|
894
|
+
"""Compute an optimal pin map for a custom footprint.
|
|
895
|
+
|
|
896
|
+
Args:
|
|
897
|
+
target_ref: Ref of the component being designed.
|
|
898
|
+
target_pins: All pin IDs in physical position order.
|
|
899
|
+
anchor_layers: Fixed components organized in layers (outermost first).
|
|
900
|
+
nets: Parsed netlist connectivity.
|
|
901
|
+
locks: Pin-to-net locks (from parse_pin_locks).
|
|
902
|
+
unmatched: Placement mode for unconnected pins.
|
|
903
|
+
exclude_nets: Net names to exclude from analysis.
|
|
904
|
+
|
|
905
|
+
Returns:
|
|
906
|
+
FootprintPlan with complete pin map proposal.
|
|
907
|
+
"""
|
|
908
|
+
exclude = exclude_nets or set()
|
|
909
|
+
|
|
910
|
+
# Filter nets
|
|
911
|
+
filtered_nets = {
|
|
912
|
+
name: nodes for name, nodes in nets.items()
|
|
913
|
+
if name not in exclude
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
# Build pin-to-net mapping for the target component
|
|
917
|
+
pin_to_net: dict[str, str] = {}
|
|
918
|
+
for net_name, nodes in filtered_nets.items():
|
|
919
|
+
for ref, pin in nodes:
|
|
920
|
+
if ref == target_ref:
|
|
921
|
+
pin_to_net[pin] = net_name
|
|
922
|
+
|
|
923
|
+
# Identify locked, signal, and unmatched pins
|
|
924
|
+
locked_pins = set(locks.keys())
|
|
925
|
+
signal_pins = [
|
|
926
|
+
p for p in target_pins
|
|
927
|
+
if p not in locked_pins and p in pin_to_net
|
|
928
|
+
]
|
|
929
|
+
# Unmatched: not locked and not connected to any analyzed net
|
|
930
|
+
# (will be placed by build_pin_map)
|
|
931
|
+
|
|
932
|
+
# Build layers for sweep: anchor_layers + [target with signal pins only]
|
|
933
|
+
# Detect passives: 2-pin components in anchor layers are reorderable
|
|
934
|
+
reorderable_refs = {target_ref}
|
|
935
|
+
for layer in anchor_layers:
|
|
936
|
+
for comp in layer:
|
|
937
|
+
if len(comp.pin_order) == 2:
|
|
938
|
+
reorderable_refs.add(comp.ref)
|
|
939
|
+
|
|
940
|
+
target_column = PinColumn(ref=target_ref, pin_order=list(signal_pins))
|
|
941
|
+
all_layers = list(anchor_layers) + [[target_column]]
|
|
942
|
+
|
|
943
|
+
# Run sweep
|
|
944
|
+
report = sweep_optimize(all_layers, reorderable_refs, filtered_nets)
|
|
945
|
+
|
|
946
|
+
# Extract optimized signal pin order from sweep result
|
|
947
|
+
optimized_signal_order = report.optimized_orders.get(target_ref, signal_pins)
|
|
948
|
+
|
|
949
|
+
# Collect passive reorderings
|
|
950
|
+
passive_reorderings: dict[str, list[str]] = {}
|
|
951
|
+
for ref, order in report.optimized_orders.items():
|
|
952
|
+
if ref != target_ref and order != report.original_orders.get(ref):
|
|
953
|
+
passive_reorderings[ref] = order
|
|
954
|
+
|
|
955
|
+
# Build final pin map
|
|
956
|
+
pin_map = build_pin_map(
|
|
957
|
+
target_ref=target_ref,
|
|
958
|
+
all_pins=target_pins,
|
|
959
|
+
locks=locks,
|
|
960
|
+
optimized_signal_pins=optimized_signal_order,
|
|
961
|
+
pin_to_net=pin_to_net,
|
|
962
|
+
nets=filtered_nets,
|
|
963
|
+
unmatched_mode=unmatched,
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
return FootprintPlan(
|
|
967
|
+
target_ref=target_ref,
|
|
968
|
+
pin_map=pin_map,
|
|
969
|
+
crossings_before=report.total_crossings,
|
|
970
|
+
crossings_after=report.total_crossings_after,
|
|
971
|
+
iterations=report.iterations,
|
|
972
|
+
passive_reorderings=passive_reorderings,
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def format_footprint_plan(plan: FootprintPlan) -> str:
|
|
977
|
+
"""Format a FootprintPlan as human-readable text."""
|
|
978
|
+
lines: list[str] = []
|
|
979
|
+
total = len(plan.pin_map)
|
|
980
|
+
lines.append(f"Footprint Pin Map Proposal for {plan.target_ref} ({total} positions)")
|
|
981
|
+
lines.append("=" * 60)
|
|
982
|
+
lines.append("")
|
|
983
|
+
|
|
984
|
+
# Group by status
|
|
985
|
+
locked = [a for a in plan.pin_map if a.status == "locked"]
|
|
986
|
+
optimized = [a for a in plan.pin_map if a.status == "optimized"]
|
|
987
|
+
unmatched = [a for a in plan.pin_map if a.status == "unmatched"]
|
|
988
|
+
|
|
989
|
+
if locked:
|
|
990
|
+
lines.append("Locked pins:")
|
|
991
|
+
for a in locked:
|
|
992
|
+
net_str = a.net if a.net else "NC"
|
|
993
|
+
lines.append(f" {a.pin:>3}: {net_str}")
|
|
994
|
+
lines.append("")
|
|
995
|
+
|
|
996
|
+
if optimized:
|
|
997
|
+
lines.append("Optimized signal assignment:")
|
|
998
|
+
for a in optimized:
|
|
999
|
+
net_str = a.net if a.net else "NC"
|
|
1000
|
+
route = f" (routes to {a.routes_to})" if a.routes_to else ""
|
|
1001
|
+
lines.append(f" {a.pin:>3}: {net_str:<20}{route}")
|
|
1002
|
+
lines.append("")
|
|
1003
|
+
|
|
1004
|
+
if unmatched:
|
|
1005
|
+
lines.append("Unmatched pins:")
|
|
1006
|
+
for a in unmatched:
|
|
1007
|
+
net_str = a.net if a.net else "NC"
|
|
1008
|
+
lines.append(f" {a.pin:>3}: {net_str}")
|
|
1009
|
+
lines.append("")
|
|
1010
|
+
|
|
1011
|
+
if plan.passive_reorderings:
|
|
1012
|
+
lines.append("Passive reorderings:")
|
|
1013
|
+
for ref in sorted(plan.passive_reorderings):
|
|
1014
|
+
order = ", ".join(plan.passive_reorderings[ref])
|
|
1015
|
+
lines.append(f" {ref}: [{order}]")
|
|
1016
|
+
lines.append("")
|
|
1017
|
+
|
|
1018
|
+
lines.append(
|
|
1019
|
+
f"Crossings: {plan.crossings_before} before -> "
|
|
1020
|
+
f"{plan.crossings_after} after ({plan.iterations} iterations)"
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
if plan.crossings_after > 0:
|
|
1024
|
+
lines.append("")
|
|
1025
|
+
lines.append(
|
|
1026
|
+
"WARNING: Not all crossings can be eliminated by reordering alone."
|
|
1027
|
+
)
|
|
1028
|
+
lines.append(
|
|
1029
|
+
"Remaining crossings will require vias or a second routing layer."
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
return "\n".join(lines)
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def plan_to_dict(plan: FootprintPlan) -> dict:
|
|
1036
|
+
"""Convert a FootprintPlan to a JSON-serializable dict."""
|
|
1037
|
+
pin_map = []
|
|
1038
|
+
for a in plan.pin_map:
|
|
1039
|
+
entry: dict = {
|
|
1040
|
+
"pin": a.pin,
|
|
1041
|
+
"net": a.net,
|
|
1042
|
+
"status": a.status,
|
|
1043
|
+
}
|
|
1044
|
+
if a.routes_to:
|
|
1045
|
+
entry["routes_to"] = a.routes_to
|
|
1046
|
+
pin_map.append(entry)
|
|
1047
|
+
|
|
1048
|
+
return {
|
|
1049
|
+
"target": plan.target_ref,
|
|
1050
|
+
"total_pins": len(plan.pin_map),
|
|
1051
|
+
"crossings_before": plan.crossings_before,
|
|
1052
|
+
"crossings_after": plan.crossings_after,
|
|
1053
|
+
"iterations": plan.iterations,
|
|
1054
|
+
"pin_map": pin_map,
|
|
1055
|
+
"passive_reorderings": plan.passive_reorderings,
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
# =========================================================================
|
|
1060
|
+
# CLI entry point
|
|
1061
|
+
# =========================================================================
|
|
1062
|
+
|
|
1063
|
+
def _cmd_analyze(args):
|
|
1064
|
+
"""Handle the 'analyze' subcommand (original behavior)."""
|
|
1065
|
+
if not Path(args.netlist).exists():
|
|
1066
|
+
print(f"Error: file not found: {args.netlist}")
|
|
1067
|
+
sys.exit(1)
|
|
1068
|
+
|
|
1069
|
+
# Parse exclusions into a set of (ref, pin) tuples
|
|
1070
|
+
exclude_set: set[tuple[str, str]] = set()
|
|
1071
|
+
for exc in args.exclude:
|
|
1072
|
+
if ":" not in exc:
|
|
1073
|
+
print(f"Error: exclusion must be REF:PIN format, got '{exc}'")
|
|
1074
|
+
sys.exit(1)
|
|
1075
|
+
ref, pin = exc.split(":", 1)
|
|
1076
|
+
exclude_set.add((ref, pin))
|
|
1077
|
+
|
|
1078
|
+
# Parse netlist
|
|
1079
|
+
data = parse_netlist(args.netlist)
|
|
1080
|
+
|
|
1081
|
+
verbose = not args.quiet and not args.json_output
|
|
1082
|
+
|
|
1083
|
+
if exclude_set and verbose:
|
|
1084
|
+
excluded_str = ", ".join(f"{r}:{p}" for r, p in sorted(exclude_set))
|
|
1085
|
+
print(f"(Excluding pins: {excluded_str})")
|
|
1086
|
+
print()
|
|
1087
|
+
|
|
1088
|
+
# Parse layer specification: "J1 | R1,R2,C1 | J2"
|
|
1089
|
+
layer_specs = [
|
|
1090
|
+
[ref.strip() for ref in group.split(",")]
|
|
1091
|
+
for group in args.layers.split("|")
|
|
1092
|
+
]
|
|
1093
|
+
|
|
1094
|
+
# Validate all refs exist
|
|
1095
|
+
all_refs = [ref for group in layer_specs for ref in group]
|
|
1096
|
+
for ref in all_refs:
|
|
1097
|
+
if ref not in data["components"]:
|
|
1098
|
+
print(f"Error: component '{ref}' not found in netlist.")
|
|
1099
|
+
print(f"Available: {', '.join(sorted(data['components'].keys()))}")
|
|
1100
|
+
sys.exit(1)
|
|
1101
|
+
|
|
1102
|
+
# Build reorderable set
|
|
1103
|
+
reorderable_refs = set(args.reorderable)
|
|
1104
|
+
if not reorderable_refs and verbose:
|
|
1105
|
+
print("Warning: no --reorderable refs specified; no optimization possible.")
|
|
1106
|
+
|
|
1107
|
+
# Build layer PinColumn lists, applying exclusions
|
|
1108
|
+
layers: list[list[PinColumn]] = []
|
|
1109
|
+
for group in layer_specs:
|
|
1110
|
+
layer: list[PinColumn] = []
|
|
1111
|
+
for ref in group:
|
|
1112
|
+
pins = [
|
|
1113
|
+
p for p in infer_pin_order(ref, data["nets"])
|
|
1114
|
+
if (ref, p) not in exclude_set
|
|
1115
|
+
]
|
|
1116
|
+
layer.append(PinColumn(ref=ref, pin_order=pins))
|
|
1117
|
+
layers.append(layer)
|
|
1118
|
+
|
|
1119
|
+
# Print layer summary
|
|
1120
|
+
if verbose:
|
|
1121
|
+
print("Layer assignment:")
|
|
1122
|
+
for i, layer in enumerate(layers):
|
|
1123
|
+
refs = ", ".join(c.ref for c in layer)
|
|
1124
|
+
pins = sum(len(c.pin_order) for c in layer)
|
|
1125
|
+
print(f" Layer {i}: [{refs}] ({pins} pins)")
|
|
1126
|
+
print()
|
|
1127
|
+
|
|
1128
|
+
# Run sweep
|
|
1129
|
+
report = sweep_optimize(layers, reorderable_refs, data["nets"])
|
|
1130
|
+
|
|
1131
|
+
if args.json_output:
|
|
1132
|
+
print(json.dumps(report_to_dict(report, data["nets"]), indent=2))
|
|
1133
|
+
elif not args.quiet:
|
|
1134
|
+
print(format_multilayer_report(report, data["nets"]))
|
|
1135
|
+
|
|
1136
|
+
sys.exit(0 if report.total_crossings_after == 0 else 1)
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def _cmd_plan_footprint(args):
|
|
1140
|
+
"""Handle the 'plan-footprint' subcommand."""
|
|
1141
|
+
if not Path(args.netlist).exists():
|
|
1142
|
+
print(f"Error: file not found: {args.netlist}")
|
|
1143
|
+
sys.exit(1)
|
|
1144
|
+
|
|
1145
|
+
data = parse_netlist(args.netlist)
|
|
1146
|
+
|
|
1147
|
+
# Validate target component
|
|
1148
|
+
if args.target not in data["components"]:
|
|
1149
|
+
print(f"Error: target component '{args.target}' not found in netlist.")
|
|
1150
|
+
print(f"Available: {', '.join(sorted(data['components'].keys()))}")
|
|
1151
|
+
sys.exit(1)
|
|
1152
|
+
|
|
1153
|
+
# Parse --anchors: "J2,U1 | R1,R2,C1"
|
|
1154
|
+
anchor_layers: list[list[PinColumn]] = []
|
|
1155
|
+
for group_str in args.anchors.split("|"):
|
|
1156
|
+
layer: list[PinColumn] = []
|
|
1157
|
+
for ref in group_str.split(","):
|
|
1158
|
+
ref = ref.strip()
|
|
1159
|
+
if not ref:
|
|
1160
|
+
continue
|
|
1161
|
+
if ref not in data["components"]:
|
|
1162
|
+
print(f"Error: anchor component '{ref}' not found in netlist.")
|
|
1163
|
+
print(f"Available: {', '.join(sorted(data['components'].keys()))}")
|
|
1164
|
+
sys.exit(1)
|
|
1165
|
+
pins = infer_pin_order(ref, data["nets"])
|
|
1166
|
+
layer.append(PinColumn(ref=ref, pin_order=pins))
|
|
1167
|
+
if layer:
|
|
1168
|
+
anchor_layers.append(layer)
|
|
1169
|
+
|
|
1170
|
+
# Parse locks
|
|
1171
|
+
try:
|
|
1172
|
+
locks = parse_pin_locks(args.lock or [])
|
|
1173
|
+
except ValueError as e:
|
|
1174
|
+
print(f"Error: {e}")
|
|
1175
|
+
sys.exit(1)
|
|
1176
|
+
|
|
1177
|
+
# Infer target pin count from component or default to max pin in netlist
|
|
1178
|
+
target_pins = infer_pin_order(args.target, data["nets"])
|
|
1179
|
+
# Include locked pins that may not appear in the netlist (e.g. NC pins)
|
|
1180
|
+
for pin in locks:
|
|
1181
|
+
if pin not in target_pins:
|
|
1182
|
+
target_pins.append(pin)
|
|
1183
|
+
# Re-sort
|
|
1184
|
+
def _sort_key(p: str):
|
|
1185
|
+
try:
|
|
1186
|
+
return (0, int(p))
|
|
1187
|
+
except ValueError:
|
|
1188
|
+
return (1, p)
|
|
1189
|
+
target_pins.sort(key=_sort_key)
|
|
1190
|
+
|
|
1191
|
+
exclude_nets = set(args.exclude_nets or [])
|
|
1192
|
+
verbose = not args.quiet and not args.json_output
|
|
1193
|
+
|
|
1194
|
+
if verbose:
|
|
1195
|
+
print(f"Planning footprint for {args.target} ({len(target_pins)} pins)")
|
|
1196
|
+
print(f"Anchor layers: {args.anchors}")
|
|
1197
|
+
if locks:
|
|
1198
|
+
locked_str = ", ".join(
|
|
1199
|
+
f"{p}={'NC' if n is None else n}"
|
|
1200
|
+
for p, n in sorted(locks.items(), key=lambda x: _sort_key(x[0]))
|
|
1201
|
+
)
|
|
1202
|
+
print(f"Locked pins: {locked_str}")
|
|
1203
|
+
if exclude_nets:
|
|
1204
|
+
print(f"Excluded nets: {', '.join(sorted(exclude_nets))}")
|
|
1205
|
+
print()
|
|
1206
|
+
|
|
1207
|
+
plan = plan_footprint(
|
|
1208
|
+
target_ref=args.target,
|
|
1209
|
+
target_pins=target_pins,
|
|
1210
|
+
anchor_layers=anchor_layers,
|
|
1211
|
+
nets=data["nets"],
|
|
1212
|
+
locks=locks,
|
|
1213
|
+
unmatched=args.unmatched,
|
|
1214
|
+
exclude_nets=exclude_nets,
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
if args.json_output:
|
|
1218
|
+
print(json.dumps(plan_to_dict(plan), indent=2))
|
|
1219
|
+
elif not args.quiet:
|
|
1220
|
+
print(format_footprint_plan(plan))
|
|
1221
|
+
|
|
1222
|
+
sys.exit(0 if plan.crossings_after == 0 else 1)
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def main():
|
|
1226
|
+
import argparse
|
|
1227
|
+
|
|
1228
|
+
parser = argparse.ArgumentParser(
|
|
1229
|
+
description="Analyze trace crossings in a KiCad netlist.",
|
|
1230
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1231
|
+
)
|
|
1232
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
1233
|
+
|
|
1234
|
+
# --- analyze subcommand (default) ---
|
|
1235
|
+
analyze = subparsers.add_parser(
|
|
1236
|
+
"analyze",
|
|
1237
|
+
help="Analyze crossings between component layers.",
|
|
1238
|
+
epilog=(
|
|
1239
|
+
"Example: crossing-analyzer analyze net.net "
|
|
1240
|
+
"--layers 'J1 | R1,C1 | J2' --reorderable J2"
|
|
1241
|
+
),
|
|
1242
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1243
|
+
)
|
|
1244
|
+
analyze.add_argument("netlist", help="Path to a KiCad .net file generated by SKiDL")
|
|
1245
|
+
analyze.add_argument(
|
|
1246
|
+
"--layers", type=str, required=True,
|
|
1247
|
+
help=(
|
|
1248
|
+
"Layer specification: components per layer separated by |. "
|
|
1249
|
+
"Multiple components in a layer separated by commas. "
|
|
1250
|
+
"Example: 'J1 | R1,R2,C1 | J2'"
|
|
1251
|
+
),
|
|
1252
|
+
)
|
|
1253
|
+
analyze.add_argument(
|
|
1254
|
+
"--reorderable", nargs="*", default=[], metavar="REF",
|
|
1255
|
+
help="Components whose pin order can be changed.",
|
|
1256
|
+
)
|
|
1257
|
+
analyze.add_argument(
|
|
1258
|
+
"--exclude", nargs="*", default=[], metavar="REF:PIN",
|
|
1259
|
+
help="Exclude pins from analysis (e.g. J1:SH).",
|
|
1260
|
+
)
|
|
1261
|
+
analyze.add_argument(
|
|
1262
|
+
"--json", action="store_true", dest="json_output",
|
|
1263
|
+
help="Output results as JSON instead of human-readable text.",
|
|
1264
|
+
)
|
|
1265
|
+
analyze.add_argument(
|
|
1266
|
+
"--quiet", "-q", action="store_true",
|
|
1267
|
+
help="Suppress all output; exit code 0 = no crossings, 1 = crossings remain.",
|
|
1268
|
+
)
|
|
1269
|
+
analyze.set_defaults(func=_cmd_analyze)
|
|
1270
|
+
|
|
1271
|
+
# --- plan-footprint subcommand ---
|
|
1272
|
+
plan = subparsers.add_parser(
|
|
1273
|
+
"plan-footprint",
|
|
1274
|
+
help="Compute an optimal pin map for a custom footprint.",
|
|
1275
|
+
epilog=(
|
|
1276
|
+
"Example: crossing-analyzer plan-footprint net.net "
|
|
1277
|
+
"--target J1 --anchors 'J2,U1 | R1,R2,C1' "
|
|
1278
|
+
"--lock 1=NC 2=NC 3=GND_EARLY_A"
|
|
1279
|
+
),
|
|
1280
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1281
|
+
)
|
|
1282
|
+
plan.add_argument("netlist", help="Path to a KiCad .net file generated by SKiDL")
|
|
1283
|
+
plan.add_argument(
|
|
1284
|
+
"--target", required=True, metavar="REF",
|
|
1285
|
+
help="Component ref whose footprint is being designed.",
|
|
1286
|
+
)
|
|
1287
|
+
plan.add_argument(
|
|
1288
|
+
"--anchors", required=True,
|
|
1289
|
+
help=(
|
|
1290
|
+
"Fixed components organized in layers separated by |. "
|
|
1291
|
+
"Example: 'J2,U1 | R1,R2,R3,C1'"
|
|
1292
|
+
),
|
|
1293
|
+
)
|
|
1294
|
+
plan.add_argument(
|
|
1295
|
+
"--lock", nargs="*", default=[], metavar="PIN=NET",
|
|
1296
|
+
help="Lock pins to specific nets. Use PIN=NC for no-connect. Example: 1=NC 3=GND_EARLY_A",
|
|
1297
|
+
)
|
|
1298
|
+
plan.add_argument(
|
|
1299
|
+
"--unmatched", choices=["start", "end", "split"], default="end",
|
|
1300
|
+
help="Where to place unmatched pins: start, end (default), or split.",
|
|
1301
|
+
)
|
|
1302
|
+
plan.add_argument(
|
|
1303
|
+
"--exclude-nets", nargs="*", default=[], metavar="NET",
|
|
1304
|
+
help="Net names to exclude from analysis (e.g. GND).",
|
|
1305
|
+
)
|
|
1306
|
+
plan.add_argument(
|
|
1307
|
+
"--json", action="store_true", dest="json_output",
|
|
1308
|
+
help="Output results as JSON instead of human-readable text.",
|
|
1309
|
+
)
|
|
1310
|
+
plan.add_argument(
|
|
1311
|
+
"--quiet", "-q", action="store_true",
|
|
1312
|
+
help="Suppress all output; exit code 0 = no crossings, 1 = crossings remain.",
|
|
1313
|
+
)
|
|
1314
|
+
plan.set_defaults(func=_cmd_plan_footprint)
|
|
1315
|
+
|
|
1316
|
+
# Backward compatibility: if first arg is not a known subcommand,
|
|
1317
|
+
# assume "analyze" mode (legacy CLI: crossing-analyzer file.net --layers ...)
|
|
1318
|
+
known_commands = {"analyze", "plan-footprint", "-h", "--help"}
|
|
1319
|
+
argv = sys.argv[1:]
|
|
1320
|
+
if argv and argv[0] not in known_commands:
|
|
1321
|
+
argv = ["analyze"] + argv
|
|
1322
|
+
|
|
1323
|
+
args = parser.parse_args(argv)
|
|
1324
|
+
|
|
1325
|
+
if args.command is None:
|
|
1326
|
+
parser.print_help()
|
|
1327
|
+
sys.exit(0)
|
|
1328
|
+
|
|
1329
|
+
args.func(args)
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
if __name__ == "__main__":
|
|
1333
|
+
main()
|