@huaqiu/dsh-kicad 0.4.13 → 0.5.2
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.
- package/lib/index.mjs +5 -0
- package/package.json +2 -2
- package/skills/hardware-design-brief/SKILL.md +65 -31
- package/skills/hardware-design-brief/agents/openai.yaml +3 -3
- package/skills/hardware-design-brief/references/design-brief-guide.md +84 -11
- package/skills/hardware-design-brief/references/examples/hardware-solution-example.md +442 -0
- package/skills/hardware-design-brief/references/hardware-prd-guide.md +53 -10
- package/skills/hardware-design-brief/references/mrd-guide.md +45 -10
- package/skills/hardware-design-brief/references/multi-board-guide.md +51 -0
- package/skills/hardware-design-brief/references/project-controls.md +45 -0
- package/skills/hardware-design-brief/references/solution-deliverable.md +55 -0
- package/skills/hardware-design-brief/references/stage-boundaries.md +32 -0
- package/skills/pcb-initial-placement/SKILL.md +343 -0
- package/skills/pcb-initial-placement/agents/openai.yaml +4 -0
- package/skills/pcb-initial-placement/references/algorithm.md +317 -0
- package/skills/pcb-initial-placement/scripts/apply_via_ipc.py +211 -0
- package/skills/pcb-initial-placement/scripts/board_geometry.py +368 -0
- package/skills/pcb-initial-placement/scripts/kipy_common.py +80 -0
- package/skills/pcb-initial-placement/scripts/pack.py +576 -0
- package/skills/pcb-initial-placement/scripts/placement_config.example.json +116 -0
- package/skills/pcb-initial-placement/scripts/placement_view.py +340 -0
- package/skills/pcb-initial-placement/scripts/pose_table.py +86 -0
- package/skills/pcb-initial-placement/scripts/projpaths.py +160 -0
- package/skills/pcb-initial-placement/scripts/sexpr.py +180 -0
- package/skills/pcb-initial-placement/scripts/suggest_modules.py +420 -0
- package/skills/pcb-initial-placement/scripts/verify_placement.py +226 -0
- package/skills/pcb-initial-placement/scripts/write_board_file.py +109 -0
- package/src/skills.ts +11 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Draft the placement config from the board's own connectivity - step 2 + 4 helpers.
|
|
3
|
+
|
|
4
|
+
Step 2 ("every electrical device belongs to exactly one module") and step 4 ("give each
|
|
5
|
+
module a rough zone") are the two steps a human otherwise has to do by reading a netlist
|
|
6
|
+
and inventing anchors. Both are largely mechanical, and getting them wrong is the most
|
|
7
|
+
expensive kind of wrong: a bad partition survives all the way to the delivered board.
|
|
8
|
+
|
|
9
|
+
This script does the mechanical part and writes a config you review and correct:
|
|
10
|
+
|
|
11
|
+
* clusters devices by shared SIGNAL nets (union-find), with power/ground
|
|
12
|
+
down-weighted the way the skill requires - a net touching half the board carries no
|
|
13
|
+
grouping information, so it must not merge everything into one blob,
|
|
14
|
+
* attaches the power-only parts (decoupling, bulk caps) to the cluster they belong to,
|
|
15
|
+
* names a core per module (highest pin-count / highest intra-module degree),
|
|
16
|
+
* lays the modules out as area-proportional horizontal bands and puts one anchor at
|
|
17
|
+
each band's centre, so the packer has a legal starting region per module,
|
|
18
|
+
* asserts the partition is a true partition (no duplicates, nothing missing) - the
|
|
19
|
+
same assertion the skill tells you to hand-write.
|
|
20
|
+
|
|
21
|
+
What it deliberately does NOT do: decide which parts are mechanically fixed, or draw
|
|
22
|
+
keep-outs. Those are physical facts about the enclosure and must come from the user.
|
|
23
|
+
|
|
24
|
+
python suggest_modules.py --board board.kicad_pcb
|
|
25
|
+
python suggest_modules.py --board board.kicad_pcb --out placement/placement_config.json
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import math
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
from collections import defaultdict
|
|
35
|
+
|
|
36
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
37
|
+
import projpaths # noqa: E402
|
|
38
|
+
import sexpr # noqa: E402
|
|
39
|
+
|
|
40
|
+
#: Nets whose name marks them as a supply/return rail.
|
|
41
|
+
POWER_NAMES = {"GND", "VCC", "VDD", "VSS", "VPP", "VEE",
|
|
42
|
+
"+5V", "+3V3", "+3.3V", "+12V", "-12V", "VBUS", "VBAT"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_power_name(name, power_extra):
|
|
46
|
+
base = name.strip("/").split("/")[-1]
|
|
47
|
+
return name in POWER_NAMES or base in POWER_NAMES or name in power_extra
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load(board):
|
|
51
|
+
text = open(board, encoding="utf-8").read()
|
|
52
|
+
devs, net2pins = {}, defaultdict(set)
|
|
53
|
+
for fp in sexpr.footprints(text):
|
|
54
|
+
ref = sexpr.property_value(fp, "Reference")
|
|
55
|
+
if ref is None:
|
|
56
|
+
continue
|
|
57
|
+
pins = []
|
|
58
|
+
for pad in fp.kids("pad"):
|
|
59
|
+
num = pad.atoms()[1].strip('"') if len(pad.atoms()) > 1 else ""
|
|
60
|
+
pins.append((num, sexpr.net_name(pad)))
|
|
61
|
+
devs[ref] = {"value": sexpr.property_value(fp, "Value"),
|
|
62
|
+
"lib": fp.atoms()[1].strip('"'),
|
|
63
|
+
"pins": pins,
|
|
64
|
+
"nets": sorted({n for _, n in pins if n})}
|
|
65
|
+
for num, name in pins:
|
|
66
|
+
if name:
|
|
67
|
+
net2pins[name].add((ref, num))
|
|
68
|
+
return devs, net2pins
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def ranks(devs, lib):
|
|
72
|
+
"""'SOIC-8' / 'R_0805' -> ('sop', 4) so blocks can be laid out sensibly."""
|
|
73
|
+
up = lib.upper()
|
|
74
|
+
if "DIP" in up:
|
|
75
|
+
return "dip", up
|
|
76
|
+
if up.startswith("R_") or up.startswith("C_") or "CHIP" in up:
|
|
77
|
+
return "passive", up
|
|
78
|
+
return "other", up
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def bridging_nets(net2pins, candidates, min_pins=4, max_frac=0.5):
|
|
82
|
+
"""Nets that act as a *bus* rather than as local wiring.
|
|
83
|
+
|
|
84
|
+
A net that really groups parts has its pins already tied together by other nets -
|
|
85
|
+
cut it and they stay connected. A bus does not: ``/DATA-RB7`` runs from the DB9
|
|
86
|
+
across the buffers to the ZIF socket, and its pins are otherwise in unrelated
|
|
87
|
+
blocks. Treating such a net as grouping evidence merges half the board into one
|
|
88
|
+
useless module.
|
|
89
|
+
|
|
90
|
+
Both guards matter, and the defaults are deliberately conservative:
|
|
91
|
+
|
|
92
|
+
* ``min_pins`` - a 3-pin local net (buffer output -> series R -> indicator, a very
|
|
93
|
+
common sub-circuit) is real grouping evidence, not a bus. Only nets that reach
|
|
94
|
+
across several pins *and* fail the reconnect test are cut.
|
|
95
|
+
* ``max_frac`` - hesitate unless the net genuinely falls apart. Cutting a net that
|
|
96
|
+
still holds half its pins together shreds a coherent module into single parts,
|
|
97
|
+
which is just as useless as lumping everything into one.
|
|
98
|
+
|
|
99
|
+
Returns {net: (largest_component_fraction, n_pins)} for every bridging net found.
|
|
100
|
+
"""
|
|
101
|
+
incident = defaultdict(list)
|
|
102
|
+
for n in candidates:
|
|
103
|
+
for r, _ in net2pins[n]:
|
|
104
|
+
incident[r].append(n)
|
|
105
|
+
|
|
106
|
+
found = {}
|
|
107
|
+
for n in candidates:
|
|
108
|
+
pins = net2pins[n]
|
|
109
|
+
refs = sorted({r for r, _ in pins})
|
|
110
|
+
if len(refs) < min_pins:
|
|
111
|
+
continue
|
|
112
|
+
start = refs[0]
|
|
113
|
+
seen, stack = {start}, [start]
|
|
114
|
+
while stack:
|
|
115
|
+
cur = stack.pop()
|
|
116
|
+
for other_net in incident[cur]:
|
|
117
|
+
if other_net == n:
|
|
118
|
+
continue
|
|
119
|
+
for r, _ in net2pins[other_net]:
|
|
120
|
+
if r not in seen:
|
|
121
|
+
seen.add(r)
|
|
122
|
+
stack.append(r)
|
|
123
|
+
frac = len(seen) / float(len(refs))
|
|
124
|
+
if frac <= max_frac:
|
|
125
|
+
found[n] = (round(frac, 3), len(refs))
|
|
126
|
+
return found
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cluster(devs, net2pins, power_extra, fanout_limit):
|
|
130
|
+
"""Union-find over non-power, non-single-pin, non-bridging nets."""
|
|
131
|
+
parent = {r: r for r in devs}
|
|
132
|
+
|
|
133
|
+
def find(x):
|
|
134
|
+
while parent[x] != x:
|
|
135
|
+
parent[x] = parent[parent[x]]
|
|
136
|
+
x = parent[x]
|
|
137
|
+
return x
|
|
138
|
+
|
|
139
|
+
def union(a, b):
|
|
140
|
+
ra, rb = find(a), find(b)
|
|
141
|
+
if ra != rb:
|
|
142
|
+
parent[rb] = ra
|
|
143
|
+
|
|
144
|
+
candidates = []
|
|
145
|
+
for n, pins in net2pins.items():
|
|
146
|
+
if len(pins) < 2:
|
|
147
|
+
continue # a net nothing else touches groups nothing
|
|
148
|
+
if is_power_name(n, power_extra):
|
|
149
|
+
continue
|
|
150
|
+
if len(pins) > fanout_limit:
|
|
151
|
+
continue # a rail by another name: too wide to group
|
|
152
|
+
candidates.append(n)
|
|
153
|
+
|
|
154
|
+
bridges = bridging_nets(net2pins, candidates)
|
|
155
|
+
signal_nets = {}
|
|
156
|
+
for n in candidates:
|
|
157
|
+
if n in bridges:
|
|
158
|
+
continue
|
|
159
|
+
signal_nets[n] = net2pins[n]
|
|
160
|
+
refs = sorted({r for r, _ in net2pins[n]})
|
|
161
|
+
for r in refs[1:]:
|
|
162
|
+
union(refs[0], r)
|
|
163
|
+
|
|
164
|
+
groups = defaultdict(list)
|
|
165
|
+
for r in devs:
|
|
166
|
+
groups[find(r)].append(r)
|
|
167
|
+
return {k: sorted(v) for k, v in groups.items()}, signal_nets, bridges
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def attach_power_only(devs, net2pins, groups, power_extra, fanout_limit):
|
|
171
|
+
"""Move devices that only touch power/singletons into their best-host cluster.
|
|
172
|
+
|
|
173
|
+
A decoupling cap must sit next to the chip it decouples, and the only evidence is
|
|
174
|
+
which power net it is on - so it goes to the cluster holding the most other devices
|
|
175
|
+
on that same net.
|
|
176
|
+
"""
|
|
177
|
+
host_of = {}
|
|
178
|
+
for gid, refs in groups.items():
|
|
179
|
+
for r in refs:
|
|
180
|
+
host_of[r] = gid
|
|
181
|
+
|
|
182
|
+
moved = {}
|
|
183
|
+
for gid, refs in list(groups.items()):
|
|
184
|
+
keep = []
|
|
185
|
+
for r in refs:
|
|
186
|
+
functional = [n for n in devs[r]["nets"]
|
|
187
|
+
if len(net2pins[n]) >= 2 and not is_power_name(n, power_extra)]
|
|
188
|
+
if functional or not devs[r]["nets"]:
|
|
189
|
+
keep.append(r)
|
|
190
|
+
continue
|
|
191
|
+
# power-only: find the cluster sharing the most power-net pins with it
|
|
192
|
+
votes = defaultdict(int)
|
|
193
|
+
for n in devs[r]["nets"]:
|
|
194
|
+
if len(net2pins[n]) > fanout_limit and not is_power_name(n, power_extra):
|
|
195
|
+
continue
|
|
196
|
+
for other, _ in net2pins[n]:
|
|
197
|
+
if other != r and host_of.get(other) != gid:
|
|
198
|
+
votes[host_of[other]] += 1
|
|
199
|
+
best = max(votes.items(), key=lambda kv: (kv[1], -len(groups[kv[0]])))[0] \
|
|
200
|
+
if votes else None
|
|
201
|
+
if best is None:
|
|
202
|
+
keep.append(r)
|
|
203
|
+
else:
|
|
204
|
+
moved.setdefault(best, []).append(r)
|
|
205
|
+
groups[gid] = keep
|
|
206
|
+
|
|
207
|
+
for gid, refs in moved.items():
|
|
208
|
+
groups.setdefault(gid, []).extend(refs)
|
|
209
|
+
return {gid: sorted(set(refs)) for gid, refs in groups.items() if refs}, moved
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def attach_singletons(devs, net2pins, groups, power_extra, fanout_limit):
|
|
213
|
+
"""Fold one-part modules into the block they are actually wired to.
|
|
214
|
+
|
|
215
|
+
Single-part modules are an artefact, not a partition: an indicator LED whose series
|
|
216
|
+
resistor was cut off by the bus test becomes its own "module", which then gets its
|
|
217
|
+
own anchor and its own share of the floorplan. That fragments the layout for no
|
|
218
|
+
reason, so a lone part is merged into whichever module holds most of its
|
|
219
|
+
neighbours. Ties leave the part alone.
|
|
220
|
+
"""
|
|
221
|
+
host_of = {}
|
|
222
|
+
for gid, refs in groups.items():
|
|
223
|
+
for r in refs:
|
|
224
|
+
host_of[r] = gid
|
|
225
|
+
|
|
226
|
+
merged = {}
|
|
227
|
+
for gid, refs in list(groups.items()):
|
|
228
|
+
if len(refs) != 1:
|
|
229
|
+
continue
|
|
230
|
+
r = refs[0]
|
|
231
|
+
votes = defaultdict(int)
|
|
232
|
+
nets = [n for n in devs[r]["nets"] if len(net2pins[n]) <= fanout_limit]
|
|
233
|
+
for n in nets:
|
|
234
|
+
for other, _ in net2pins[n]:
|
|
235
|
+
if other == r:
|
|
236
|
+
continue
|
|
237
|
+
h = host_of.get(other)
|
|
238
|
+
if h is None or h == gid:
|
|
239
|
+
continue
|
|
240
|
+
votes[h] += 1 if not is_power_name(n, power_extra) else 0
|
|
241
|
+
# power pins vote too, but only as a weak tie-breaker
|
|
242
|
+
for n in devs[r]["nets"]:
|
|
243
|
+
if is_power_name(n, power_extra):
|
|
244
|
+
for other, _ in net2pins[n]:
|
|
245
|
+
h = host_of.get(other)
|
|
246
|
+
if h is not None and h != gid:
|
|
247
|
+
votes[h] += 0.25
|
|
248
|
+
if not votes:
|
|
249
|
+
continue
|
|
250
|
+
best, score = max(votes.items(), key=lambda kv: kv[1])
|
|
251
|
+
if score <= 0:
|
|
252
|
+
continue
|
|
253
|
+
merged.setdefault(best, []).append(r)
|
|
254
|
+
groups[gid] = []
|
|
255
|
+
|
|
256
|
+
for gid, refs in merged.items():
|
|
257
|
+
groups.setdefault(gid, []).extend(refs)
|
|
258
|
+
return {gid: sorted(set(refs)) for gid, refs in groups.items() if refs}, len(merged)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def name_module(refs, devs):
|
|
262
|
+
"""A short, stable label from the dominant package family / largest part."""
|
|
263
|
+
biggest = max(refs, key=lambda r: len(devs[r]["pins"]))
|
|
264
|
+
lib = devs[biggest]["lib"].split(":")[-1]
|
|
265
|
+
kind = lib.split("_")[0]
|
|
266
|
+
return ("%s_%s" % (kind, refs[0])).lower().replace("-", "_"), biggest
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def main():
|
|
270
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
271
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
272
|
+
ap.add_argument("--board", required=True)
|
|
273
|
+
ap.add_argument("--out", help="config to write "
|
|
274
|
+
"(default <board dir>/placement/placement_config.json)")
|
|
275
|
+
ap.add_argument("--power", action="append", default=[],
|
|
276
|
+
help="extra net name to treat as a supply rail (repeatable)")
|
|
277
|
+
ap.add_argument("--fanout", type=int, default=8,
|
|
278
|
+
help="a non-power net touching more than this many devices is "
|
|
279
|
+
"treated as a rail and ignored for grouping (default 8)")
|
|
280
|
+
ap.add_argument("--grid-mm", type=float, default=0.635)
|
|
281
|
+
ap.add_argument("--gap-mm", type=float, default=0.3)
|
|
282
|
+
ap.add_argument("--rot-modes", type=int, default=4, choices=(2, 3, 4))
|
|
283
|
+
projpaths.add_outdir(ap)
|
|
284
|
+
args = ap.parse_args()
|
|
285
|
+
|
|
286
|
+
projpaths.make_console_safe()
|
|
287
|
+
devs, net2pins = load(args.board)
|
|
288
|
+
if not devs:
|
|
289
|
+
raise SystemExit("no footprints found in %s" % args.board)
|
|
290
|
+
|
|
291
|
+
groups, signal_nets, bridges = cluster(devs, net2pins, set(args.power), args.fanout)
|
|
292
|
+
groups, moved = attach_power_only(devs, net2pins, groups, set(args.power),
|
|
293
|
+
args.fanout)
|
|
294
|
+
groups, folded = attach_singletons(devs, net2pins, groups, set(args.power),
|
|
295
|
+
args.fanout)
|
|
296
|
+
|
|
297
|
+
# ---- partition assertion: this is the thing the skill says must be enforced ----
|
|
298
|
+
all_members = [r for refs in groups.values() for r in refs]
|
|
299
|
+
dupes = sorted({r for r in all_members if all_members.count(r) > 1})
|
|
300
|
+
missing = sorted(set(devs) - set(all_members))
|
|
301
|
+
if dupes or missing:
|
|
302
|
+
raise SystemExit("internal error: partition is not exact (dupes=%s missing=%s)"
|
|
303
|
+
% (dupes, missing))
|
|
304
|
+
|
|
305
|
+
# ---- names, cores, areas ----
|
|
306
|
+
mods = {}
|
|
307
|
+
for gid, refs in groups.items():
|
|
308
|
+
name, core = name_module(refs, devs)
|
|
309
|
+
while name in mods:
|
|
310
|
+
name += "x"
|
|
311
|
+
mods[name] = {"refs": refs, "core": core}
|
|
312
|
+
|
|
313
|
+
net_deg = defaultdict(int)
|
|
314
|
+
for n, pins in signal_nets.items():
|
|
315
|
+
for r, _ in pins:
|
|
316
|
+
net_deg[r] += 1
|
|
317
|
+
for m in mods.values():
|
|
318
|
+
m["area"] = sum(len(devs[r]["pins"]) * 12.0 + 40.0 for r in m["refs"])
|
|
319
|
+
m["pins"] = sum(len(devs[r]["pins"]) for r in m["refs"])
|
|
320
|
+
|
|
321
|
+
# ---- order: biggest block first (it has the hardest time finding space) ----
|
|
322
|
+
order = sorted(mods, key=lambda m: (-mods[m]["area"], m))
|
|
323
|
+
|
|
324
|
+
# ---- band floorplan over the true board rectangle ----
|
|
325
|
+
geo_path = os.path.join(
|
|
326
|
+
projpaths.placement_dir(board=args.board, outdir=args.outdir, create=False),
|
|
327
|
+
"geometry.json")
|
|
328
|
+
if os.path.isfile(geo_path):
|
|
329
|
+
geo = json.load(open(geo_path, encoding="utf-8-sig"))
|
|
330
|
+
bx0, by0, bx1, by1 = geo["outline_bbox"]
|
|
331
|
+
else:
|
|
332
|
+
bx0 = by0 = 0.0
|
|
333
|
+
bx1 = by1 = math.sqrt(sum(mods[m]["area"] for m in mods)) * 1.6
|
|
334
|
+
bw, bh = bx1 - bx0, by1 - by0
|
|
335
|
+
total_area = sum(mods[m]["area"] for m in mods)
|
|
336
|
+
scale = (bw * bh) / total_area if total_area else 1.0
|
|
337
|
+
target_w = bw * 0.85
|
|
338
|
+
rows, cur, cur_w = [], [], 0.0
|
|
339
|
+
for m in order:
|
|
340
|
+
w = math.sqrt(mods[m]["area"] * scale)
|
|
341
|
+
if cur and cur_w + w > target_w:
|
|
342
|
+
rows.append(cur)
|
|
343
|
+
cur, cur_w = [], 0.0
|
|
344
|
+
cur.append((m, w))
|
|
345
|
+
cur_w += w
|
|
346
|
+
if cur:
|
|
347
|
+
rows.append(cur)
|
|
348
|
+
|
|
349
|
+
anchors = {}
|
|
350
|
+
y = by0
|
|
351
|
+
row_h = max(1, len(rows))
|
|
352
|
+
for r in rows:
|
|
353
|
+
tot = sum(w for _, w in r) or 1.0
|
|
354
|
+
h = min((bh - (y - by0)) / max(1, row_h - rows.index(r)), bh)
|
|
355
|
+
x = bx0
|
|
356
|
+
for m, w in r:
|
|
357
|
+
anchors.setdefault(m, []).append(
|
|
358
|
+
[round(x + (w / tot) * bw / 2, 2), round(y + h / 2, 2)])
|
|
359
|
+
x += (w / tot) * bw
|
|
360
|
+
y += h
|
|
361
|
+
|
|
362
|
+
# ---- emit ----
|
|
363
|
+
cfg = {
|
|
364
|
+
"board": "../" + os.path.basename(args.board),
|
|
365
|
+
"grid_mm": args.grid_mm,
|
|
366
|
+
"gap_mm": args.gap_mm,
|
|
367
|
+
"big_part_area_mm2": 40.0,
|
|
368
|
+
"radius_schedule_mm": [20, 32, 45, 65, 90, 130],
|
|
369
|
+
"rot_modes": args.rot_modes,
|
|
370
|
+
"_generated_by": "suggest_modules.py - REVIEW before use: anchors are a "
|
|
371
|
+
"mechanical first guess, and fixed parts / keep-outs are NOT "
|
|
372
|
+
"filled in because they are physical facts only you know",
|
|
373
|
+
"module_order": order,
|
|
374
|
+
"priority": sorted((m["core"] for m in mods.values()),
|
|
375
|
+
key=lambda r: -len(devs[r]["pins"])),
|
|
376
|
+
"modules": {m: {"title": "%s (draft)" % m,
|
|
377
|
+
"core": [mods[m]["core"]],
|
|
378
|
+
"anchors": anchors.get(m, [[(bx0 + bx1) / 2, (by0 + by1) / 2]]),
|
|
379
|
+
"members": mods[m]["refs"]}
|
|
380
|
+
for m in order},
|
|
381
|
+
"fixed": {},
|
|
382
|
+
"keepouts": [],
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
out = projpaths.resolve_out(args.out, "placement_config.json",
|
|
386
|
+
board=args.board, outdir=args.outdir)
|
|
387
|
+
json.dump(cfg, open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
388
|
+
|
|
389
|
+
print("devices : %d" % len(devs))
|
|
390
|
+
print("nets total : %d" % len(net2pins))
|
|
391
|
+
print("grouping nets used : %d" % len(signal_nets))
|
|
392
|
+
print("bus/bridging nets cut : %d %s"
|
|
393
|
+
% (len(bridges), sorted(bridges)[:6]))
|
|
394
|
+
print("modules suggested : %d" % len(mods))
|
|
395
|
+
print("power-only parts attached: %d" % sum(len(v) for v in moved.values()))
|
|
396
|
+
print("lone parts folded in : %d" % folded)
|
|
397
|
+
print()
|
|
398
|
+
for m in order:
|
|
399
|
+
d = mods[m]
|
|
400
|
+
flag = ""
|
|
401
|
+
if len(d["refs"]) >= max(8, len(devs) // 4):
|
|
402
|
+
flag = " <- too big: split by hand, it will otherwise eat the floorplan"
|
|
403
|
+
print(" %-22s core %-5s %2d parts %3d pins anchor %s%s"
|
|
404
|
+
% (m, d["core"], len(d["refs"]), d["pins"], anchors.get(m, ["-"])[0], flag))
|
|
405
|
+
print()
|
|
406
|
+
print("partition exact : no duplicates, no missing (%d/%d)"
|
|
407
|
+
% (len(all_members), len(devs)))
|
|
408
|
+
print("wrote %s" % out)
|
|
409
|
+
print()
|
|
410
|
+
print("NEXT: open that file and")
|
|
411
|
+
print(" 1. rename modules to what they actually are,")
|
|
412
|
+
print(" 2. move each anchor into the zone you want that block in,")
|
|
413
|
+
print(" 3. add \"fixed\" entries for every mechanically constrained part, and")
|
|
414
|
+
print(" 4. add \"keepouts\" for anything that must not be occupied (tall sockets,")
|
|
415
|
+
print(" connectors that tilt, antenna areas).")
|
|
416
|
+
return 0
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
if __name__ == "__main__":
|
|
420
|
+
sys.exit(main())
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Independent check of a placement plan against the real board.
|
|
3
|
+
|
|
4
|
+
python verify_placement.py --geometry geometry.json --plan placement_plan.json
|
|
5
|
+
# also writes <placement dir>/verify_placement.txt (disable with --no-report)
|
|
6
|
+
|
|
7
|
+
This shares no code with pack.py beyond the raw outline reader, so it is a genuine
|
|
8
|
+
cross-check rather than a re-run of the same arithmetic. It answers the questions
|
|
9
|
+
a reviewer actually asks:
|
|
10
|
+
|
|
11
|
+
1 completeness - every footprint placed, none invented
|
|
12
|
+
2 overlap - courtyard pairs that intersect (the hard gate: must be 0)
|
|
13
|
+
3 clearance - minimum courtyard-to-courtyard distance
|
|
14
|
+
4 outline - which courts hang off the board edge, and by how much
|
|
15
|
+
5 grouping - per module: centroid, bbox, spread, and stray parts that sit
|
|
16
|
+
nearer another module's anchor than their own
|
|
17
|
+
6 area budget - courtyard area vs true board area (density sanity)
|
|
18
|
+
7 fixed anchors - fixed parts still exactly where the config put them
|
|
19
|
+
|
|
20
|
+
Exit code is non-zero when a hard gate fails, so it can gate a pipeline.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
import projpaths # noqa: E402
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main() -> None:
|
|
35
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
36
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
37
|
+
ap.add_argument("--geometry", required=True)
|
|
38
|
+
ap.add_argument("--plan", required=True)
|
|
39
|
+
ap.add_argument("--min-clearance-mm", type=float, default=0.2,
|
|
40
|
+
help="fail if any two courtyards are closer than this")
|
|
41
|
+
ap.add_argument("--grid-mm", type=float, default=0.635)
|
|
42
|
+
ap.add_argument("--report", help="text report to write "
|
|
43
|
+
"(default <placement dir>/verify_placement.txt)")
|
|
44
|
+
ap.add_argument("--no-report", action="store_true",
|
|
45
|
+
help="print only; leave no report file behind")
|
|
46
|
+
projpaths.add_outdir(ap)
|
|
47
|
+
args = ap.parse_args()
|
|
48
|
+
|
|
49
|
+
tee = None
|
|
50
|
+
if not args.no_report:
|
|
51
|
+
report = projpaths.resolve_out(args.report, "verify_placement.txt",
|
|
52
|
+
refs=(args.geometry, args.plan),
|
|
53
|
+
outdir=args.outdir)
|
|
54
|
+
tee = projpaths.start_report(report)
|
|
55
|
+
print("report: %s\n" % report)
|
|
56
|
+
|
|
57
|
+
geom = json.load(open(args.geometry, encoding="utf-8-sig"))
|
|
58
|
+
geo = {g["ref"]: g for g in geom["footprints"]}
|
|
59
|
+
plan = json.load(open(args.plan, encoding="utf-8-sig"))
|
|
60
|
+
pos = plan["positions"]
|
|
61
|
+
anchors = plan.get("anchors", {})
|
|
62
|
+
poly = [tuple(p) for p in geom["outline"]]
|
|
63
|
+
hard_fail = 0
|
|
64
|
+
|
|
65
|
+
def inside(x, y):
|
|
66
|
+
r, n = False, len(poly)
|
|
67
|
+
for i in range(n):
|
|
68
|
+
x0, y0 = poly[i]
|
|
69
|
+
x1, y1 = poly[(i + 1) % n]
|
|
70
|
+
if (y0 > y) != (y1 > y) and x < x0 + (y - y0) * (x1 - x0) / (y1 - y0):
|
|
71
|
+
r = not r
|
|
72
|
+
return r
|
|
73
|
+
|
|
74
|
+
def rect(ref):
|
|
75
|
+
x0, y0, x1, y1 = geo[ref]["bbox_local"]
|
|
76
|
+
ox, oy, rot = pos[ref]["x"], pos[ref]["y"], pos[ref]["rot"]
|
|
77
|
+
t = math.radians(rot)
|
|
78
|
+
c, s = math.cos(t), math.sin(t)
|
|
79
|
+
pts = [(lx * c + ly * s, -lx * s + ly * c) for lx in (x0, x1) for ly in (y0, y1)]
|
|
80
|
+
return (ox + min(p[0] for p in pts), oy + min(p[1] for p in pts),
|
|
81
|
+
ox + max(p[0] for p in pts), oy + max(p[1] for p in pts))
|
|
82
|
+
|
|
83
|
+
print("=== 1. completeness ===")
|
|
84
|
+
print("footprints on board :", len(geo))
|
|
85
|
+
print("placed by the plan :", len(pos))
|
|
86
|
+
missing = sorted(set(geo) - set(pos))
|
|
87
|
+
extra = sorted(set(pos) - set(geo))
|
|
88
|
+
print("missing / unknown refs :", missing, extra)
|
|
89
|
+
print("unplaced reported by packer :", plan.get("unplaced"))
|
|
90
|
+
if missing or extra:
|
|
91
|
+
hard_fail += 1
|
|
92
|
+
|
|
93
|
+
R = {r: rect(r) for r in pos}
|
|
94
|
+
refs = sorted(R)
|
|
95
|
+
|
|
96
|
+
print("\n=== 2. courtyard overlap check ===")
|
|
97
|
+
overlaps = []
|
|
98
|
+
for i in range(len(refs)):
|
|
99
|
+
a = refs[i]
|
|
100
|
+
ax0, ay0, ax1, ay1 = R[a]
|
|
101
|
+
for j in range(i + 1, len(refs)):
|
|
102
|
+
b = refs[j]
|
|
103
|
+
bx0, by0, bx1, by1 = R[b]
|
|
104
|
+
if ax0 < bx1 and bx0 < ax1 and ay0 < by1 and by0 < ay1:
|
|
105
|
+
ov = (min(ax1, bx1) - max(ax0, bx0)) * (min(ay1, by1) - max(ay0, by0))
|
|
106
|
+
overlaps.append((a, b, ov))
|
|
107
|
+
print("overlapping courtyard pairs :", len(overlaps))
|
|
108
|
+
for a, b, ov in overlaps[:20]:
|
|
109
|
+
print(" %-6s %-6s overlap %.3f mm2" % (a, b, ov))
|
|
110
|
+
if overlaps:
|
|
111
|
+
hard_fail += 1
|
|
112
|
+
|
|
113
|
+
print("\n=== 3. minimum courtyard clearance ===")
|
|
114
|
+
worst = []
|
|
115
|
+
for i in range(len(refs)):
|
|
116
|
+
a = refs[i]
|
|
117
|
+
ax0, ay0, ax1, ay1 = R[a]
|
|
118
|
+
best, who = 1e9, None
|
|
119
|
+
for j in range(len(refs)):
|
|
120
|
+
if i == j:
|
|
121
|
+
continue
|
|
122
|
+
b = refs[j]
|
|
123
|
+
bx0, by0, bx1, by1 = R[b]
|
|
124
|
+
d = math.hypot(max(bx0 - ax1, ax0 - bx1, 0.0),
|
|
125
|
+
max(by0 - ay1, ay0 - by1, 0.0))
|
|
126
|
+
if d < best:
|
|
127
|
+
best, who = d, b
|
|
128
|
+
worst.append((best, a, who))
|
|
129
|
+
worst.sort()
|
|
130
|
+
for d, a, b in worst[:8]:
|
|
131
|
+
print(" %-6s -> %-6s %.3f mm" % (a, b, d))
|
|
132
|
+
if worst and worst[0][0] < args.min_clearance_mm:
|
|
133
|
+
print(" ! below the %.2f mm floor" % args.min_clearance_mm)
|
|
134
|
+
hard_fail += 1
|
|
135
|
+
|
|
136
|
+
print("\n=== 4. parts off the board outline ===")
|
|
137
|
+
off = []
|
|
138
|
+
for r in refs:
|
|
139
|
+
x0, y0, x1, y1 = R[r]
|
|
140
|
+
tot = out = 0
|
|
141
|
+
for k in range(12):
|
|
142
|
+
px = x0 + (x1 - x0) * ((k % 4) / 3.0)
|
|
143
|
+
py = y0 + (y1 - y0) * ((k // 4) / 2.0)
|
|
144
|
+
tot += 1
|
|
145
|
+
if not inside(px, py):
|
|
146
|
+
out += 1
|
|
147
|
+
if out:
|
|
148
|
+
off.append((out / tot, r, R[r]))
|
|
149
|
+
off.sort(reverse=True)
|
|
150
|
+
for frac, r, rr in off:
|
|
151
|
+
print(" %-6s %4.0f%% of courtyard outside rect=(%.1f,%.1f)-(%.1f,%.1f)"
|
|
152
|
+
% (r, frac * 100, rr[0], rr[1], rr[2], rr[3]))
|
|
153
|
+
if not off:
|
|
154
|
+
print(" none - every courtyard is fully on the board")
|
|
155
|
+
print(" (connectors whose body is meant to overhang the edge belong here;")
|
|
156
|
+
print(" anything else is a placement bug - see the half-cell erosion note)")
|
|
157
|
+
|
|
158
|
+
print("\n=== 5. module grouping ===")
|
|
159
|
+
titles = plan.get("module_titles", {})
|
|
160
|
+
mods = plan["modules"]
|
|
161
|
+
# A module whose members are all FIXED (mounting holes, board-edge terminals)
|
|
162
|
+
# legitimately declares no anchors. Only modules that actually have anchors may
|
|
163
|
+
# take part in the distance/stray maths - indexing anchors[k][0] unconditionally
|
|
164
|
+
# is an IndexError waiting for the first all-fixed module.
|
|
165
|
+
with_anchors = sorted(k for k in anchors if anchors.get(k))
|
|
166
|
+
if not with_anchors:
|
|
167
|
+
print(" no module declares anchors - distance/stray metrics skipped")
|
|
168
|
+
for m in sorted(mods):
|
|
169
|
+
rs = [r for r in mods[m] if r in R]
|
|
170
|
+
if not rs:
|
|
171
|
+
continue
|
|
172
|
+
xs = [(R[r][0] + R[r][2]) / 2 for r in rs]
|
|
173
|
+
ys = [(R[r][1] + R[r][3]) / 2 for r in rs]
|
|
174
|
+
cx, cy = sum(xs) / len(xs), sum(ys) / len(ys)
|
|
175
|
+
allx = [R[r][0] for r in rs] + [R[r][2] for r in rs]
|
|
176
|
+
ally = [R[r][1] for r in rs] + [R[r][3] for r in rs]
|
|
177
|
+
print(" %s %-32s n=%2d centroid=(%6.1f,%6.1f) bbox %5.1fx%5.1f"
|
|
178
|
+
% (m, titles.get(m, m), len(rs), cx, cy,
|
|
179
|
+
max(allx) - min(allx), max(ally) - min(ally)))
|
|
180
|
+
if m in with_anchors:
|
|
181
|
+
ds = sorted(min(math.hypot(x - ax, y - ay) for ax, ay in anchors[m])
|
|
182
|
+
for x, y in zip(xs, ys))
|
|
183
|
+
print(" distance to nearest own core: median %.1f mm, max %.1f mm"
|
|
184
|
+
% (ds[len(ds) // 2], ds[-1]))
|
|
185
|
+
else:
|
|
186
|
+
print(" no anchors declared - spread not scored")
|
|
187
|
+
if with_anchors:
|
|
188
|
+
stray = [r for r in rs
|
|
189
|
+
if min(with_anchors, key=lambda k: math.hypot(
|
|
190
|
+
(R[r][0] + R[r][2]) / 2 - anchors[k][0][0],
|
|
191
|
+
(R[r][1] + R[r][3]) / 2 - anchors[k][0][1])) != m]
|
|
192
|
+
if stray:
|
|
193
|
+
print(" %d part(s) sit nearer another module anchor: %s"
|
|
194
|
+
% (len(stray), sorted(stray)[:16]))
|
|
195
|
+
|
|
196
|
+
print("\n=== 6. area budget ===")
|
|
197
|
+
g = args.grid_mm
|
|
198
|
+
bx0 = min(p[0] for p in poly)
|
|
199
|
+
bx1 = max(p[0] for p in poly)
|
|
200
|
+
by0 = min(p[1] for p in poly)
|
|
201
|
+
by1 = max(p[1] for p in poly)
|
|
202
|
+
board = sum(g * g
|
|
203
|
+
for i in range(int((bx1 - bx0) / g) + 1)
|
|
204
|
+
for j in range(int((by1 - by0) / g) + 1)
|
|
205
|
+
if inside(bx0 + i * g, by0 + j * g))
|
|
206
|
+
court = sum((R[r][2] - R[r][0]) * (R[r][3] - R[r][1]) for r in refs)
|
|
207
|
+
print(" board area : %8.0f mm2" % board)
|
|
208
|
+
print(" sum of courtyards : %8.0f mm2 (%.1f%%)" % (court, 100 * court / board))
|
|
209
|
+
print(" above ~75% the board is dense: expect spills into the inter-bank gaps")
|
|
210
|
+
|
|
211
|
+
print("\n=== 7. fixed parts did not move ===")
|
|
212
|
+
for ref in plan.get("fixed", []):
|
|
213
|
+
if ref in pos:
|
|
214
|
+
p = pos[ref]
|
|
215
|
+
print(" %-6s (%8.3f, %8.3f) rot %6.1f" % (ref, p["x"], p["y"], p["rot"]))
|
|
216
|
+
|
|
217
|
+
print("\n%s" % ("PASS - all hard gates clear" if hard_fail == 0
|
|
218
|
+
else "FAIL - %d hard gate(s) tripped" % hard_fail))
|
|
219
|
+
if tee is not None:
|
|
220
|
+
tee.close()
|
|
221
|
+
print("report written to %s" % tee.path)
|
|
222
|
+
raise SystemExit(1 if hard_fail else 0)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
main()
|