app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
John Lauer
source at 0.1.25: the whole crate workspace, the flow, the skills and the docs
f31cc25
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
#!/usr/bin/env python3
"""Claude Fable 5.1's copper-pour plan for the ESC G431, from the board's own pad geometry.
python3 demo/routing/esc/pours_plan.py BOARD.kicad_pcb --out pours.json [--vias vias-plan.json]
Reads the placed board, finds the power stage (the six MOSFET tabs and source pads, the three
phase contacts, the +VBAT contact, the bulk capacitors, the shunt and the GND_OUT contact) and
writes the pours as the JSON kicad_add_zone takes (tools/add_pours.py gates it offline):
* GND on F.Cu and B.Cu, whole outline, priority 0, thermal reliefs: the copper that stays
everywhere nothing else needs it (ablation), stitched to the In1.Cu plane by every through
pad and the existing stub vias.
* +VBAT, priority 2: the rail block (contact MC1, TVS, the bulk and ceramic capacitors, the
high-side tab of phase C) on F.Cu and B.Cu, plus a strip on B.Cu behind the MOSFET column
that reaches every high-side tab through thermal vias in the tabs. The gate driver and its
bootstrap nodes are left out of the rail pour on purpose (switching nodes beside gate drive).
* One pour per phase (/DRV_SHA, /DRV_SHB, /DRV_SHC), priority 3, solid connections: on F.Cu
from the high-side source pads over the low-side tab to the phase contact; on B.Cu only
under the low-side tab, so the +VBAT strip on B.Cu stays continuous. Compact by design:
these are the switching nodes.
* GND_OUT (the battery return through the shunt), priority 3, solid: from the return contact
MC3 to the shunt's GND_OUT pad, on both outer layers. The two Kelvin traces to the sense
amplifier stay outside every pour.
Not poured, deliberately: the bootstrap and gate-drive nets, the crystal and its capacitors, the
BEMF dividers and comparator inputs, /VBAT_SENSE and /IBAT_SENSE, +3V3 and /VDDA on the outer
layers (they have the In2.Cu plane), and anything under the MCU's escape bands.
--vias also writes the thermal/stitching vias as route_net entries (write_copper.py and
route_live.py replay them): two 0.8/0.4 vias in each high-side tab (F.Cu tab to the B.Cu +VBAT
strip) and a row along the B.Cu strip.
"""
import argparse, json, math, os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from escboard import Board # noqa: E402
PHASES = {"/DRV_SHA": "MC5", "/DRV_SHB": "MC9", "/DRV_SHC": "MC11"}
def box(pads):
xs = [q[0] for p in pads for poly in p.polys for q in poly]
ys = [q[1] for p in pads for poly in p.polys for q in poly]
return [min(xs), min(ys), max(xs), max(ys)]
def rect(x0, y0, x1, y1):
return [[round(x0, 2), round(y0, 2)], [round(x1, 2), round(y0, 2)], [round(x1, 2), round(y1, 2)], [round(x0, 2), round(y1, 2)]]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("board"); ap.add_argument("--out", required=True); ap.add_argument("--vias")
ap.add_argument("--avoid", help="routing plan JSON: a via candidate that would hit another net's copper is moved to the next spot in the tab; its Kelvin entries get pour keepouts")
ap.add_argument("--solid-at", action="append", default=[], help="REF.PAD that gets a small solid-connection patch (starved thermal relief)")
a = ap.parse_args()
b = Board(a.board)
pads = b.pads
x0, y0, x1, y1 = b.bbox
edge = 0.5
# the MOSFET column: high-side tab (+VBAT) and low-side tab (phase) per phase
q = {}
for p in pads:
if p.ref.startswith("Q") and p.name == "5":
q.setdefault(p.net, []).append(p)
hs_tabs = q.get("+VBAT", []) # three tabs, one per phase
zones = []
vias = []
# 1. GND everywhere, both outer layers, lowest priority
zones.append({"net": "GND", "layers": ["F.Cu", "B.Cu"], "outline": True, "inset": edge, "priority": 0, "connection": "thermal",
"clearance": 0.2, "minThickness": 0.25, "thermalGap": 0.3, "thermalBridge": 0.35, "name": "GND outer pour (ablation: keep the copper)"})
# pads where only one thermal spoke fits get a small solid patch (KiCad's rule wants two spokes or a solid joint)
for key in a.solid_at:
ref, name = key.split(".")
pd = next(p for p in pads if p.ref == ref and p.name == name)
zones.append({"net": pd.net, "layers": ["F.Cu"], "polygon": rect(pd.x - 0.9, pd.y - 0.9, pd.x + 0.9, pd.y + 0.9), "priority": 1, "connection": "solid",
"clearance": 0.2, "minThickness": 0.25, "name": "%s solid joint at %s (one spoke only)" % (pd.net, key)})
# 2. the rail: contact MC1, TVS D1, bulk caps C2/C3, the ceramic bank, down to the phase-C high-side tab
rail_parts = [p for p in pads if p.net == "+VBAT" and (p.ref.startswith("C") or p.ref in ("D1", "MC1"))]
rb = box(rail_parts)
col = box([p for p in pads if p.ref.startswith("Q")])
driver = box([p for p in pads if p.ref == "U4"])
rail_left = 127.5 # right of the buck (U2) and its SW node; the TVS is inside
rail_bottom = driver[1] - 1.0 # above the gate driver's band
strip_right = x1 - edge - 1.6
zones.append({"net": "+VBAT", "layers": ["F.Cu"], "polygon": rect(rail_left, y0 + edge, strip_right, rail_bottom), "priority": 2, "connection": "solid",
"clearance": 0.2, "minThickness": 0.25, "name": "+VBAT rail block F.Cu"})
# B.Cu: the rail block plus the strip behind the MOSFET column as ONE L-shaped zone (two zones of one
# net and one priority may not touch). The strip reaches every high-side tab through the thermal vias;
# the phase pours under the low-side tabs (priority 3) punch through it and the +VBAT copper flows around.
L = [[rail_left, y0 + edge], [strip_right, y0 + edge], [strip_right, col[3] + 1.0], [col[0] - 0.8, col[3] + 1.0], [col[0] - 0.8, rail_bottom], [rail_left, rail_bottom]]
zones.append({"net": "+VBAT", "layers": ["B.Cu"], "polygon": [[round(x, 2), round(y, 2)] for x, y in L], "priority": 2, "connection": "solid",
"clearance": 0.2, "minThickness": 0.25, "name": "+VBAT rail block and column strip B.Cu"})
# 3. one compact pour per phase
for net, contact in PHASES.items():
hs_src = [p for p in pads if p.ref.startswith("Q") and p.net == net and p.name in ("1", "2", "3")]
ls_tab = [p for p in pads if p.ref.startswith("Q") and p.net == net and p.name == "5"]
mc = [p for p in pads if p.ref == contact]
fb = box(hs_src + ls_tab + mc)
zones.append({"net": net, "layers": ["F.Cu"], "polygon": rect(fb[0] - 0.5, fb[1] - 0.6, x1 - edge, fb[3] + 0.6), "priority": 3, "connection": "solid",
"clearance": 0.2, "minThickness": 0.25, "name": "%s phase F.Cu" % net})
lb = box(ls_tab)
zones.append({"net": net, "layers": ["B.Cu"], "polygon": rect(lb[0] - 1.5, lb[1] - 0.3, lb[2] + 0.3, lb[3] + 0.6), "priority": 3, "connection": "solid",
"clearance": 0.2, "minThickness": 0.25, "name": "%s under the low-side tab B.Cu" % net})
# 4. the return through the shunt: contact MC3 to the shunt's GND_OUT pad
mc3 = [p for p in pads if p.ref == "MC3"]
r32 = [p for p in pads if p.ref == "R32" and p.net == "GND_OUT"]
gb = box(mc3 + r32)
zones.append({"net": "GND_OUT", "layers": ["F.Cu", "B.Cu"], "polygon": rect(gb[0] - 0.4, max(y0 + edge, gb[1] - 0.6), gb[2] + 0.4, gb[3] + 0.6), "priority": 3, "connection": "solid",
"clearance": 0.2, "minThickness": 0.25, "name": "GND_OUT return"})
# thermal vias in every high-side tab (two per tab, moved within the tab when the routed copper of
# another net passes under that spot on B.Cu), and two stitching vias along the B.Cu strip
blockers = []
if a.avoid:
for e in json.load(open(a.avoid))["nets"]:
if e["net"] == "+VBAT":
continue
for path in e["paths"]:
prev = None; layer = "F.Cu"
for w in path:
if isinstance(w, str):
prev = None; continue
if isinstance(w, dict):
if "x" not in w:
continue
layer = w.get("layer", layer); pt = (w["x"], w["y"])
else:
pt = (w[0], w[1])
if prev is not None:
blockers.append((prev, pt, e["width"] / 2.0 + 0.2 + 0.4))
prev = pt
def clear(x, y):
for (ax, ay), (bx, by), need in blockers:
dx, dy = bx - ax, by - ay; L = dx * dx + dy * dy
t = 0 if L == 0 else max(0.0, min(1.0, ((x - ax) * dx + (y - ay) * dy) / L))
if math.hypot(x - (ax + t * dx), y - (ay + t * dy)) < need:
return False
return True
for t in hs_tabs:
placed = 0
for dx, dy in ((-1.2, 0.0), (1.2, 0.0), (-1.2, -1.3), (1.2, -1.3), (-1.2, 1.3), (1.2, 1.3), (0.0, -1.3), (0.0, 1.3)):
if placed >= 2:
break
x, y = round(t.x + dx, 2), round(t.y + dy, 2)
if clear(x, y):
vias.append({"net": "+VBAT", "x": x, "y": y}); placed += 1
strip_x = round(col[2] + 1.9, 2)
for yy in (90.0, 118.0):
if clear(strip_x, yy):
vias.append({"net": "+VBAT", "x": strip_x, "y": yy})
# pour keepouts around every Kelvin tap in the routing plan (both outer layers, the whole trace, not the pads)
if a.avoid:
for e in json.load(open(a.avoid))["nets"]:
if not e.get("kelvin"):
continue
for path in e["paths"]:
pts = [(w["x"], w["y"]) if isinstance(w, dict) else (w[0], w[1]) for w in path if not isinstance(w, str)]
for (ax, ay), (bx, by) in zip(pts, pts[1:]):
dx, dy = bx - ax, by - ay
n = math.hypot(dx, dy)
if n < 0.05:
continue
ux, uy = dx / n, dy / n
w = 0.45 # half width of the keepout: trace half width 0.15 + clearance 0.2 + margin 0.1
poly = [[round(ax - uy * w, 3), round(ay + ux * w, 3)], [round(bx - uy * w, 3), round(by + ux * w, 3)],
[round(bx + uy * w, 3), round(by - ux * w, 3)], [round(ax + uy * w, 3), round(ay - ux * w, 3)]]
zones.append({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": poly, "keepout": {"copperPour": True},
"name": "Kelvin tap keepout %s" % e["net"]})
json.dump({"zones": zones}, open(a.out, "w"), indent=1)
print("%d zones -> %s" % (len(zones), a.out))
for z in zones:
print(" %-10s %-12s %s %s" % (z.get("net") or "keepout", "+".join(z["layers"]), "keepout" if z.get("keepout") else "prio %d %s" % (z["priority"], z["connection"]), z.get("polygon") or "outline"))
if a.vias:
entries = [{"net": v["net"], "width": 0.5, "viaSize": 0.8, "viaDrill": 0.4, "paths": [[[v["x"], v["y"]], {"x": v["x"], "y": v["y"], "layer": "B.Cu"}]]} for v in vias]
json.dump({"engine": "Claude Fable 5.1 pours plan (thermal and stitching vias)", "nets": entries}, open(a.vias, "w"), indent=1)
print("%d vias -> %s" % (len(entries), a.vias))
if __name__ == "__main__":
main()