@tachikomagundam/abathur 0.2.7 → 0.2.8

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.
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python3
2
+ """r17 board gate: ERC-clean + netlist-exact roundtrip for an authored sheet.
3
+ Exit 0 only when: ERC 0 errors, 0 unconnected, and EVERY spec net appears in the
4
+ export with EXACTLY its spec membership (and no extra nets). Closes the r13 D1
5
+ defect class deterministically (no model in the byte path)."""
6
+ import json, re, subprocess, sys, tempfile, os
7
+ from collections import defaultdict
8
+
9
+ def main(sch, netlist_json):
10
+ spec = json.load(open(netlist_json))
11
+ got = defaultdict(set)
12
+ for c in spec["components"]:
13
+ for p, net in c["pins"].items():
14
+ got[net].add(f"{c['ref']}.{p}")
15
+ with tempfile.TemporaryDirectory() as td:
16
+ erc = os.path.join(td, "erc.json"); net = os.path.join(td, "netlist.net")
17
+ r1 = subprocess.run(["kicad-cli", "sch", "erc", sch, "--format", "json",
18
+ "--severity-error", "--output", erc], capture_output=True, text=True)
19
+ r2 = subprocess.run(["kicad-cli", "sch", "export", "netlist", sch, "-o", net],
20
+ capture_output=True, text=True)
21
+ if r1.returncode or not os.path.exists(erc):
22
+ print(f"FAIL ERC_RUN rc={r1.returncode} {r1.stderr[:200]}"); return 1
23
+ if r2.returncode or not os.path.exists(net):
24
+ print(f"FAIL EXPORT rc={r2.returncode} {r2.stderr[:200]}"); return 1
25
+ d = json.load(open(erc))
26
+ sh = d["sheets"][0].get("erc", {})
27
+ v, u = sh.get("violations", []), sh.get("unconnected", [])
28
+ if v or u:
29
+ print(f"FAIL ERC violations={len(v)} unconnected={len(u)}")
30
+ for x in v[:6]: print(" ", json.dumps(x)[:180])
31
+ return 1
32
+ txt = open(net).read()
33
+ # parenthesis-balanced extraction of each (net ...) block (regex cannot
34
+ # nest; truncation risk seen in dev round — never parse s-exprs with a
35
+ # lazy match across closers).
36
+ exp = defaultdict(set)
37
+ i = 0
38
+ while True:
39
+ i = txt.find("(net\n", i)
40
+ if i < 0: break
41
+ depth = 0; j = i; instr = False
42
+ while j < len(txt):
43
+ ch = txt[j]
44
+ if ch == '"' and txt[j-1] != "\\": instr = not instr
45
+ elif not instr:
46
+ if ch == "(": depth += 1
47
+ elif ch == ")":
48
+ depth -= 1
49
+ if depth == 0: break
50
+ j += 1
51
+ block = txt[i:j+1]
52
+ m = re.search(r'\(name "([^"]+)"\)', block)
53
+ if m:
54
+ for ref, pin in re.findall(r'ref "([^"]+)"\)\s*\(\s*pin "([^"]+)"', block):
55
+ exp[m.group(1)].add(f"{ref}.{pin}")
56
+ i = j + 1
57
+ miss = {n for n in got if exp.get(n) != got[n]}
58
+ extra = set(exp) - set(got)
59
+ if miss or extra:
60
+ print(f"FAIL ROUNDTRIP {len(got)-len(miss)}/{len(got)} exact extra_nets={sorted(extra)[:5]}")
61
+ for n in sorted(miss)[:6]:
62
+ print(" DIFF", n, "spec-only", sorted(got[n]-exp.get(n, set()))[:3],
63
+ "exp-only", sorted(exp.get(n, set())-got[n])[:3])
64
+ return 1
65
+ print(f"PASS erc=0/0 nets={len(got)}/{len(got)} exact (members {sum(len(x) for x in got.values())} pins)")
66
+ return 0
67
+
68
+ if __name__ == "__main__":
69
+ sys.exit(main(sys.argv[1], sys.argv[2]))
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env python3
2
+ """r17 pcb placement gate: kicad-cli DRC must report 0 error-level violations.
3
+ unconnected_items are EXPECTED pre-routing (counted, not failing)."""
4
+ import json, subprocess, sys, os, tempfile
5
+ board = sys.argv[1]
6
+ assert board.endswith(".kicad_pcb")
7
+ pro = board[:-10] + ".kicad_pro"
8
+ if not os.path.exists(pro):
9
+ print("FAIL no sibling .kicad_pro (design constraints live there)"); sys.exit(1)
10
+ with tempfile.TemporaryDirectory() as td:
11
+ out = os.path.join(td, "drc.json")
12
+ r = subprocess.run(["kicad-cli", "pcb", "drc", board, "--format", "json",
13
+ "--severity-error", "--output", out], capture_output=True, text=True)
14
+ if r.returncode or not os.path.exists(out):
15
+ print(f"FAIL load/drc rc={r.returncode} {r.stderr[:200]}"); sys.exit(1)
16
+ d = json.load(open(out))
17
+ v = d.get("violations", [])
18
+ u = d.get("unconnected_items", [])
19
+ if v:
20
+ print(f"FAIL DRC err={len(v)}")
21
+ for x in v[:8]: print(" ", x.get("description", "")[:100])
22
+ sys.exit(1)
23
+ print(f"PASS placement-DRC 0 errors; pending-route unconnected={len(u)}")
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env python3
2
+ """r17 deterministic 2-layer router (RM-56 doctrine; scripted A* lane).
3
+ Reads placed .kicad_pcb (board_author_pcb output), routes a nearest-neighbour
4
+ spanning tree per net on F.Cu/B.Cu, appends (segment)/(via), writes routed board.
5
+ Grid 0.1mm; octile A*; per-layer flat occupancy dict (cell -> set(nets)).
6
+ A cell is blocked for net N if occupied by nets other than N or 0.
7
+ Gates afterwards: kicad DRC 0 errors + unconnected_items == 0 (board_gate_routed)."""
8
+ import os
9
+ import re, math, heapq, sys
10
+ from collections import defaultdict
11
+
12
+ GRID = float(os.environ.get('BR_GRID', '0.05'))
13
+ CLEAR = 0.26
14
+ POWER = {"VBUS", "5V_SERVO", "5V_LOGIC", "3V3", "GND", "SW_NODE", "+5V_IN"}
15
+ W_POWER, W_SIG = 0.4, 0.25
16
+ VIA_COST = 40
17
+ MAX_PUSH = int(os.environ.get('BR_MAX_PUSH', '900000'))
18
+
19
+
20
+ def footprint_blocks(txt):
21
+ return sub_blocks(txt, "footprint")
22
+
23
+
24
+ def sub_blocks(s, token):
25
+ """full (token ...) blocks, balanced-paren + string-aware scan."""
26
+ out = []
27
+ i = 0
28
+ pat = "(" + token + " "
29
+ while True:
30
+ i = s.find(pat, i)
31
+ if i < 0:
32
+ return out
33
+ depth = 0
34
+ j = i
35
+ instr = False
36
+ while j < len(s):
37
+ ch = s[j]
38
+ if ch == '"' and (j == 0 or s[j - 1] != "\\"):
39
+ instr = not instr
40
+ elif not instr:
41
+ if ch == "(":
42
+ depth += 1
43
+ elif ch == ")":
44
+ depth -= 1
45
+ if depth == 0:
46
+ break
47
+ j += 1
48
+ out.append(s[i:j + 1])
49
+ i = j + 1
50
+
51
+
52
+ def pads_of(txt):
53
+ pads = []
54
+ for block in footprint_blocks(txt):
55
+ fm = re.search(r'\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)', block)
56
+ fx, fy, frot = (float(fm.group(1)), float(fm.group(2)), float(fm.group(3) or 0)) if fm else (0, 0, 0)
57
+ refm = re.search(r'\(property "Reference" "([^"]+)"', block)
58
+ ref = refm.group(1) if refm else "?"
59
+ for pb in sub_blocks(block, "pad"):
60
+ nm = re.search(r'\(net (\d+) "((?:[^"\\]|\\.)*)"\)', pb)
61
+ if not nm:
62
+ continue
63
+ pm2 = re.match(r'\(pad "([^"]*)"', pb)
64
+ at = re.search(r'\(at ([-\d.]+)(?: ([-\d.]+))?(?: ([-\d.]+))?\)', pb)
65
+ sz = re.search(r'\(size ([-\d.]+) ([-\d.]+)\)', pb)
66
+ px, py = (float(at.group(1)), float(at.group(2) or 0)) if at else (0, 0)
67
+ pr = float(at.group(3) or 0) if at else 0.0
68
+ w, h = (float(sz.group(1)), float(sz.group(2))) if sz else (1.0, 1.0)
69
+ a = math.radians(pr + frot)
70
+ gx = fx + px * math.cos(a) - py * math.sin(a)
71
+ gy = fy + px * math.sin(a) + py * math.cos(a)
72
+ pads.append(dict(ref=ref, pad=pm2.group(1) if pm2 else "?", net=int(nm.group(1)),
73
+ netname=nm.group(2), x=gx, y=gy, w=w, h=h))
74
+ return pads
75
+
76
+
77
+ class Occ:
78
+ def __init__(self):
79
+ self.m = [{}, {}]
80
+
81
+ def add_rect(self, x0, y0, x1, y1, l, net):
82
+ for cx in range(int(math.floor(x0 / GRID)), int(math.ceil(x1 / GRID)) + 1):
83
+ row = self.m[l]
84
+ for cy in range(int(math.floor(y0 / GRID)), int(math.ceil(y1 / GRID)) + 1):
85
+ s = row.get((cx, cy))
86
+ if s is None:
87
+ row[(cx, cy)] = {net}
88
+ else:
89
+ s.add(net)
90
+
91
+ def passable(self, l, cx, cy, net):
92
+ s = self.m[l].get((cx, cy))
93
+ return s is None or s == {0} or net in s
94
+
95
+
96
+ def build_occ(pads):
97
+ occ = Occ()
98
+ for p in pads:
99
+ if str(p.get("num", p.get("pad", "1"))) == "":
100
+ # NPTH retention holes/screws: universal obstacle (no net may own
101
+ # copper in a hole's clearance annulus), sentinel -2 matches nothing.
102
+ hw, hh = p["w"] / 2 + 0.35, p["h"] / 2 + 0.35
103
+ for l in (0, 1):
104
+ occ.add_rect(p["x"] - hw, p["y"] - hh, p["x"] + hw, p["y"] + hh, l, -2)
105
+ continue
106
+ hw, hh = p["w"] / 2 + CLEAR, p["h"] / 2 + CLEAR
107
+ # cross-layer: KiCad enforces Cu-to-Cu clearance between tracks and pads
108
+ # on the OTHER copper layer too -> register every pad ring on BOTH layers.
109
+ for l in (0, 1):
110
+ occ.add_rect(p["x"] - hw, p["y"] - hh, p["x"] + hw, p["y"] + hh, l, p["net"])
111
+ # unmark pad interiors of the net's own footprint pads as free-to-stand target cells
112
+ return occ
113
+
114
+
115
+ def cell(x, y):
116
+ return int(round(x / GRID)), int(round(y / GRID))
117
+
118
+
119
+ def astar(occ, start, goal, net, via_cost=VIA_COST):
120
+ sx, sy = start[0]
121
+ sl = start[1]
122
+ gx, gy = goal[0]
123
+ gl = goal[1]
124
+ h0 = abs(sx - gx) + abs(sy - gy)
125
+ heap = [(h0, 0, sx, sy, sl)]
126
+ gsc = {(sx, sy, sl): 0}
127
+ came = {}
128
+ pushes = 0
129
+ while heap:
130
+ _, g, x, y, l = heapq.heappop(heap)
131
+ if x == gx and y == gy and l == gl:
132
+ path = [(x, y, l)]
133
+ while (x, y, l) in came:
134
+ x, y, l = came[(x, y, l)]
135
+ path.append((x, y, l))
136
+ return path[::-1]
137
+ pushes += 1
138
+ if pushes > MAX_PUSH:
139
+ return None
140
+ for dx, dy, dl, dc in ((1, 0, 0, 10), (-1, 0, 0, 10), (0, 1, 0, 10), (0, -1, 0, 10),
141
+ (0, 0, 1, VIA_COST), (0, 0, -1, VIA_COST)):
142
+ nx, ny, nl = x + dx, y + dy, l + dl
143
+ if nl < 0 or nl > 1:
144
+ continue
145
+ if dx and dy: # no diagonal corner cutting: both flanks must be open
146
+ if not (occ.passable(l, x + dx, y, net) and occ.passable(l, x, y + dy, net)):
147
+ continue
148
+ if not occ.passable(nl, nx, ny, net):
149
+ continue
150
+ if dl: # via may stand on its OWN pad (pinning) but never touch a
151
+ # foreign pad/box: cells whose net-set excludes us (or incl. the
152
+ # -7 gauntlet sentinel) are landing-banned.
153
+ bad = False
154
+ for ll in (0, 1):
155
+ for m in (occ.m[ll], occ.m[1 - ll]):
156
+ for ox in range(-3, 4):
157
+ for oy in range(-3, 4):
158
+ ss = m.get((nx + ox, ny + oy))
159
+ if ss is None:
160
+ continue
161
+ if -7 in ss or (net not in ss and ss != {0}):
162
+ bad = True
163
+ break
164
+ if bad: break
165
+ if bad: break
166
+ if bad:
167
+ break
168
+ if bad:
169
+ continue
170
+ ng = g + (via_cost if dl else dc)
171
+ if ng < gsc.get((nx, ny, nl), 1e18):
172
+ gsc[(nx, ny, nl)] = ng
173
+ came[(nx, ny, nl)] = (x, y, l)
174
+ heapq.heappush(heap, (ng + abs(nx - gx) + abs(ny - gy) + (0 if nl == gl else VIA_COST), ng, nx, ny, nl))
175
+ return None
176
+
177
+
178
+ def simplify(path):
179
+ if len(path) <= 2:
180
+ return path
181
+ out = [path[0]]
182
+ for i in range(1, len(path) - 1):
183
+ x0, y0, l0 = path[i - 1]
184
+ x1, y1, l1 = path[i]
185
+ x2, y2, l2 = path[i + 1]
186
+ if (x1 - x0, y1 - y0, l1 - l0) != (x2 - x1, y2 - y1, l2 - l1):
187
+ out.append(path[i])
188
+ out.append(path[-1])
189
+ return out
190
+
191
+
192
+ def occ_track(occ, path, net, width):
193
+ """register a laid path (cells + clearance ring) into occupancy for OTHER nets."""
194
+ r = 2 if width < 0.35 else 3
195
+ for (cx, cy, l) in path:
196
+ for dx in range(-r, r + 1):
197
+ for dy in range(-r, r + 1):
198
+ occ.add_rect((cx + dx) * GRID, (cy + dy) * GRID,
199
+ (cx + dx) * GRID, (cy + dy) * GRID, l, net)
200
+
201
+
202
+ def route(board_path, out_path, only_nets=None):
203
+ txt = open(board_path).read()
204
+ pads = pads_of(txt)
205
+ nets = {int(m.group(1)): m.group(2) for m in re.finditer(r'\(net (\d+) "([^"]*)"\)', txt)}
206
+ occ = build_occ(pads)
207
+ # r17: J1 USB-C gauntlet — F.Cu inside the pad rows is forbidden (0.85mm pad
208
+ # pitch admits no 0.2mm pair); D+/D-, VBUS, CC and shield stubs are forced
209
+ # to B.Cu / the south channel. Sentinel net -7 closes the box to every net.
210
+ # (gauntlet experiment retired: foreign SMD pads only exist on their own
211
+ # layer's map — B.Cu threads between the J1 rows are clearance-legal.)
212
+ by_net = defaultdict(list)
213
+ for p in pads:
214
+ if p["net"]:
215
+ by_net[p["net"]].append(p)
216
+ # order: power/GND first, prefer bottom layer (B=1); signals then on F=0
217
+ def layer_pref(nid):
218
+ return 1 if nets.get(nid, "") in POWER else 0
219
+ ordered = sorted(by_net.items(), key=lambda kv: (nets.get(kv[0], "") not in POWER, -len(kv[1])))
220
+ segs, vias, fails = [], [], []
221
+ kept_segs = []
222
+ if only_nets:
223
+ fp_cut = txt.index("(footprint")
224
+ root_end = len(txt) - 1
225
+ tail_blocks = [m.group(0) for m in re.finditer(r'\(segment [^\n]*\)|\(via [^\n]*\)', txt[fp_cut:])]
226
+ newblocks = []
227
+ for blk in tail_blocks:
228
+ mn2 = re.search(r'\(net (\d+)\)', blk)
229
+ name2 = nets.get(int(mn2.group(1)), "") if mn2 else ""
230
+ if name2 in only_nets:
231
+ continue
232
+ newblocks.append(blk)
233
+ mm = re.search(r'\(segment \(start ([-\d.]+) ([-\d.]+)\) \(end ([-\d.]+) ([-\d.]+)\) \(width ([.\d]+)\) \(layer "([FB])\.Cu"\)', blk)
234
+ mv = re.search(r'\(via \(at ([-\d.]+) ([-\d.]+)', blk)
235
+ if mm:
236
+ x1_, y1_, x2_, y2_ = (float(mm.group(i)) for i in (2, 3, 4, 5))
237
+ w_ = float(mm.group(5)); l_ = 0 if mm.group(6) == "F" else 1
238
+ nn_ = max(1, int(max(abs(x2_ - x1_), abs(y2_ - y1_)) / GRID))
239
+ pts = [(cell(x1_ + (x2_ - x1_) * k / nn_, y1_ + (y2_ - y1_) * k / nn_)[0], cell(x1_ + (x2_ - x1_) * k / nn_, y1_ + (y2_ - y1_) * k / nn_)[1], l_) for k in range(nn_ + 1)]
240
+ occ_track(occ, pts, -1, w_)
241
+ elif mv:
242
+ vx, vy = float(mv.group(1)), float(mv.group(2))
243
+ for l_ in (0, 1):
244
+ occ.add_rect(vx - 0.4, vy - 0.4, vx + 0.4, vy + 0.4, l_, -1)
245
+ kept_segs = newblocks
246
+ ordered = [kv for kv in ordered if nets.get(kv[0], "") in only_nets]
247
+ txt = txt[:fp_cut] + "\n".join(newblocks) + "\n)\n"
248
+ for nid, plist in ordered:
249
+ if len(plist) < 2:
250
+ continue
251
+ if nets.get(nid, "").startswith("NC"):
252
+ continue # NC nets are floating-on-purpose groups: copper would
253
+ # only weld foreign pads together inside connector gauntlets
254
+ width = W_POWER if nets.get(nid, "") in POWER else W_SIG
255
+ root = min(plist, key=lambda q: (q["x"], q["y"]))
256
+ remaining = [q for q in plist if q is not root]
257
+ tree = [root]
258
+ while remaining:
259
+ best = None
260
+ for a in tree:
261
+ for q in remaining:
262
+ if a.get("ref") and q.get("ref") == a["ref"]:
263
+ continue # same-part pins bond internally: no copper
264
+ dist = abs(q["x"] - a["x"]) + abs(q["y"] - a["y"])
265
+ if best is None or dist < best[0]:
266
+ best = (dist, q)
267
+ if best is None:
268
+ break
269
+ tree.append(best[1])
270
+ remaining.remove(best[1])
271
+ lp = layer_pref(nid)
272
+ for a, c in zip(tree, tree[1:]):
273
+ ca = cell(a["x"], a["y"])
274
+ cc = cell(c["x"], c["y"])
275
+ def ok_via_ends(pp):
276
+ if not pp:
277
+ return False
278
+ a_ok = True if pp[0][2] == lp else (len(pp) > 1 and pp[1][2] != pp[0][2])
279
+ b_ok = True if pp[-1][2] == lp else (len(pp) > 1 and pp[-2][2] != pp[-1][2])
280
+ return a_ok and b_ok
281
+ cands = [astar(occ, (ca, lp), (cc, lp), nid),
282
+ astar(occ, (ca, lp), (cc, 1 - lp), nid),
283
+ astar(occ, (ca, 1 - lp), (cc, lp), nid)]
284
+ path = next((c for c in cands if ok_via_ends(c)), None)
285
+ if not path:
286
+ cands = [astar(occ, (ca, lp), (cc, lp), nid, via_cost=12),
287
+ astar(occ, (ca, lp), (cc, 1 - lp), nid, via_cost=12)]
288
+ path = next((c for c in cands if ok_via_ends(c)), None)
289
+ if not path:
290
+ fails.append((nets.get(nid), a["ref"] + "." + a["pad"], c["ref"] + "." + c["pad"]))
291
+ continue
292
+ sp = simplify(path)
293
+ occ_track(occ, path, nid, width)
294
+ for n1, n2 in zip(sp, sp[1:]):
295
+ if n1[2] != n2[2]:
296
+ vias.append((n1[0] * GRID, n1[1] * GRID, nid))
297
+ else:
298
+ segs.append((n1[0] * GRID, n1[1] * GRID, n2[0] * GRID, n2[1] * GRID, n1[2], width, nid))
299
+ u = [0x5A000000]
300
+ def U():
301
+ u[0] += 1
302
+ x = f"{u[0]:032x}"
303
+ return f"{x[:8]}-{x[8:12]}-{x[12:16]}-{x[16:20]}-{x[20:32]}"
304
+ emit = []
305
+ for (x1, y1, x2, y2, l, w, n) in segs:
306
+ emit.append(f' (segment (start {x1:.4f} {y1:.4f}) (end {x2:.4f} {y2:.4f}) (width {w})'
307
+ f' (layer "{"F" if l==0 else "B"}.Cu") (net {n}) (uuid "{U()}"))')
308
+ for (x, y, n) in vias:
309
+ emit.append(f' (via (at {x:.4f} {y:.4f}) (size 0.6) (drill 0.3)'
310
+ f' (layers "F.Cu" "B.Cu") (net {n}) (uuid "{U()}"))')
311
+ head = txt[:txt.rindex(")")].rstrip("\n")
312
+ mid = ("\n" + "\n".join(kept_segs)) if (only_nets and kept_segs) else ""
313
+ open(out_path, "w").write(head + mid + "\n".join(emit) + "\n)\n")
314
+ print(f"routed: segments={len(segs)} vias={len(vias)} failed_edges={len(fails)}")
315
+ for f in fails[:12]:
316
+ print(" FAIL-EDGE", f)
317
+ return len(fails)
318
+
319
+
320
+ if __name__ == "__main__":
321
+ only = None
322
+ if "--only-nets" in sys.argv:
323
+ only = set(sys.argv[sys.argv.index("--only-nets") + 1].split(","))
324
+ sys.exit(1 if route(sys.argv[1], sys.argv[2], only) else 0)