@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.
Files changed (28) hide show
  1. package/lib/index.mjs +5 -0
  2. package/package.json +2 -2
  3. package/skills/hardware-design-brief/SKILL.md +65 -31
  4. package/skills/hardware-design-brief/agents/openai.yaml +3 -3
  5. package/skills/hardware-design-brief/references/design-brief-guide.md +84 -11
  6. package/skills/hardware-design-brief/references/examples/hardware-solution-example.md +442 -0
  7. package/skills/hardware-design-brief/references/hardware-prd-guide.md +53 -10
  8. package/skills/hardware-design-brief/references/mrd-guide.md +45 -10
  9. package/skills/hardware-design-brief/references/multi-board-guide.md +51 -0
  10. package/skills/hardware-design-brief/references/project-controls.md +45 -0
  11. package/skills/hardware-design-brief/references/solution-deliverable.md +55 -0
  12. package/skills/hardware-design-brief/references/stage-boundaries.md +32 -0
  13. package/skills/pcb-initial-placement/SKILL.md +343 -0
  14. package/skills/pcb-initial-placement/agents/openai.yaml +4 -0
  15. package/skills/pcb-initial-placement/references/algorithm.md +317 -0
  16. package/skills/pcb-initial-placement/scripts/apply_via_ipc.py +211 -0
  17. package/skills/pcb-initial-placement/scripts/board_geometry.py +368 -0
  18. package/skills/pcb-initial-placement/scripts/kipy_common.py +80 -0
  19. package/skills/pcb-initial-placement/scripts/pack.py +576 -0
  20. package/skills/pcb-initial-placement/scripts/placement_config.example.json +116 -0
  21. package/skills/pcb-initial-placement/scripts/placement_view.py +340 -0
  22. package/skills/pcb-initial-placement/scripts/pose_table.py +86 -0
  23. package/skills/pcb-initial-placement/scripts/projpaths.py +160 -0
  24. package/skills/pcb-initial-placement/scripts/sexpr.py +180 -0
  25. package/skills/pcb-initial-placement/scripts/suggest_modules.py +420 -0
  26. package/skills/pcb-initial-placement/scripts/verify_placement.py +226 -0
  27. package/skills/pcb-initial-placement/scripts/write_board_file.py +109 -0
  28. 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()