master
John Lauer ESC demo: Fable placement redone with the molecule interface fixed; Astra prompt updated 723663d 25d ago
#!/usr/bin/env python3
"""Placement planner for the ESC G431 demo (Claude Fable 5.1's own placement).

Reads the KiCad 10 board, ignores the human placement, and produces a plan:
ref -> (x, y, rotation) inside the outline, no courtyard overlaps.

Method (what a layout engineer does, made explicit):
1. Macro placement of the eleven multi-pin parts by function, from the netlist:
   the six MOSFETs form the 3-phase bridge on the right edge with the high side
   above the low side per phase; the gate driver sits just left of the bridge;
   the MCU left of the driver; the buck near the battery input at the top; the
   current-sense amplifier at the low-side sources near the bottom; the debug
   header on the far left edge.
2. Every two-pad part is attached to the multi-pin part it shares the most
   nets with (decoupling caps to their IC, gate resistors to the driver/MOSFET,
   bootstrap caps to the driver, sense resistors to the INA) and placed on an
   occupancy grid in expanding rings around that anchor, rotated to match the
   ring side, never overlapping a courtyard, never outside the outline.
3. Score: total MST ratsnest length over all nets, before and after.
"""
import json, math, re, sys, collections
from pathlib import Path

SRC = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).with_name("esc-g431.kicad_pcb")
OUT = Path(sys.argv[2]) if len(sys.argv) > 2 else SRC.with_name("esc-placement-plan.json")
s = SRC.read_text(errors="ignore")

# ---------- outline ----------
edge = []
for m in re.finditer(r'\((gr_line|gr_rect|gr_arc|gr_circle)\s(.*?)\n\t\)\n', s, re.S):
    blk = m.group(0)
    if 'Edge.Cuts' in blk:
        edge += [(float(x), float(y)) for x, y in re.findall(r'\((?:start|end|center|mid) ([-\d.]+) ([-\d.]+)\)', blk)]
X0, X1 = min(p[0] for p in edge), max(p[0] for p in edge)
Y0, Y1 = min(p[1] for p in edge), max(p[1] for p in edge)
MARGIN = 1.0  # keep courtyards this far inside the edge

# ---------- footprints ----------
def rot(px, py, deg):
    a = math.radians(deg); c, si = math.cos(a), math.sin(a)
    return px * c - py * si, px * si + py * c

class Part:
    def __init__(self, blk):
        self.lib = blk.split('"', 2)[1]
        self.ref = re.search(r'\(property "Reference" "([^"]*)"', blk).group(1)
        self.val = re.search(r'\(property "Value" "([^"]*)"', blk).group(1)
        at = re.search(r'\n\t\t\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)', blk)
        self.ox, self.oy, self.orot = float(at.group(1)), float(at.group(2)), float(at.group(3) or 0)
        # pads: local coords (before footprint rotation) and nets
        self.pads = []
        for pm in re.finditer(r'\(pad "([^"]*)"[^\n]*\n((?:\t\t\t[^\n]*\n)+?)\t\t\)', blk):
            body = pm.group(2)
            pa = re.search(r'\(at ([-\d.]+) ([-\d.]+)', body); pn = re.search(r'\(net (?:\d+ )?"([^"]*)"\)', body)
            if pa and pn and not pn.group(1).startswith("unconnected"):
                self.pads.append((float(pa.group(1)), float(pa.group(2)), pn.group(1)))
        # courtyard extent in local coords (rotation 0)
        xs, ys = [], []
        for cm in re.finditer(r'\(fp_(?:line|rect|poly|circle)[\s\S]*?\(layer "F\.CrtYd"\)[\s\S]*?\n\t\t\)', blk):
            cblk = cm.group(0)
            for x, y in re.findall(r'\((?:start|end|xy) ([-\d.]+) ([-\d.]+)\)', cblk):
                xs.append(float(x)); ys.append(float(y))
        if not xs:  # fall back to pads + 0.3 mm
            for pm in re.finditer(r'\(pad "[^"]*" \w+ \w+\s*\(at ([-\d.]+) ([-\d.]+)[^)]*\)\s*\(size ([-\d.]+) ([-\d.]+)\)', blk):
                px, py, sw, sh = map(float, pm.groups())
                xs += [px - sw / 2 - .3, px + sw / 2 + .3]; ys += [py - sh / 2 - .3, py + sh / 2 + .3]
        if not xs:
            xs, ys = [-1, 1], [-1, 1]
        self.cw, self.ch = max(xs) - min(xs), max(ys) - min(ys)
        self.ccx, self.ccy = (max(xs) + min(xs)) / 2, (max(ys) + min(ys)) / 2  # courtyard centre offset
        self.x = self.y = None; self.r = 0
    def box(self, x, y, r):
        """axis-aligned courtyard box at (x,y) with rotation r (0/90/180/270)."""
        cx, cy = rot(self.ccx, self.ccy, r)
        w, h = (self.cw, self.ch) if r % 180 == 0 else (self.ch, self.cw)
        return (x + cx - w / 2, y + cy - h / 2, x + cx + w / 2, y + cy + h / 2)
    def pad_world(self, x, y, r):
        return [(x + rot(px, py, r)[0], y + rot(px, py, r)[1], n) for px, py, n in self.pads]

parts = {}
for blk in re.split(r'\n\t\(footprint ', s)[1:]:
    p = Part(blk); parts[p.ref] = p
STATE = SRC.with_name("state-clean.json")
if STATE.exists():  # the bridge's own courtyard boxes (what kicad_move_footprint will check)
    for f in json.load(open(STATE))["footprints"]:
        q = parts.get(f["ref"]); c = f["courtyard"]
        if q and f.get("rotation", 0) == 0:
            q.cw, q.ch = c["maxX"] - c["minX"], c["maxY"] - c["minY"]
            q.ccx, q.ccy = (c["maxX"] + c["minX"]) / 2 - f["x"], (c["maxY"] + c["minY"]) / 2 - f["y"]
    print("courtyards taken from the bridge's placement_state")
nets = collections.defaultdict(set)
for p in parts.values():
    for _, _, n in p.pads:
        nets[n].add(p.ref)

# ---------- helpers ----------
def overlaps(a, b, gap=0.4):
    return not (a[2] + gap <= b[0] or b[2] + gap <= a[0] or a[3] + gap <= b[1] or b[3] + gap <= a[1])

def inside(b):
    return b[0] >= X0 + MARGIN and b[1] >= Y0 + MARGIN and b[2] <= X1 - MARGIN and b[3] <= Y1 - MARGIN

placed = {}  # ref -> box
def place_near(p, x, y, r, reach=12.0, step=0.5):
    """try_place at (x,y), then in widening squares until it fits; returns True/False."""
    if try_place(p, x, y, r): return True
    for ring in range(1, int(reach / step) + 1):
        for dx in range(-ring, ring + 1):
            for dy in (-ring, ring):
                for rr in (r, (r + 90) % 360):
                    if try_place(p, x + dx * step, y + dy * step, rr): return True
        for dy in range(-ring + 1, ring):
            for dx in (-ring, ring):
                for rr in (r, (r + 90) % 360):
                    if try_place(p, x + dx * step, y + dy * step, rr): return True
    return False

def try_place(p, x, y, r):
    b = p.box(x, y, r)
    if not inside(b):
        return False
    for ref, ob in placed.items():
        if overlaps(b, ob):
            return False
    p.x, p.y, p.r = round(x, 3), round(y, 3), r; placed[p.ref] = b
    return True

# ---------- 1b. machine pins (MP) and machine contacts (MC): FIXED by the scaffold pattern ----------
# A molecule's pins and contacts are its plug-in interface (the scaffold pattern: pins 16 mm
# in from the left and right edges, contacts on a 2 mm pitch). They keep the positions and
# rotations the board came with; the plan only occupies their courtyards and never moves them.
big = {}
contacts = sorted([r for r in parts if re.fullmatch(r"MC\d+", r)], key=lambda r: int(r[2:]))
pins = sorted([r for r in parts if re.fullmatch(r"MP\d+", r)], key=lambda r: int(r[2:]))
fixed = {}
for r in contacts + pins:
    q = parts[r]; q.x, q.y, q.r = q.ox, q.oy, q.orot
    placed[r] = q.box(q.x, q.y, q.r); fixed[r] = (q.x, q.y, q.r)
    big[r] = q
# ---------- 1. macro placement by function ----------
cx, cy = (X0 + X1) / 2, (Y0 + Y1) / 2
def nets_of(r): return {n for _, _, n in parts[r].pads}
def find(pred):
    return [r for r, p in parts.items() if pred(p)]
mosfets = sorted(find(lambda p: "BSC016" in p.val))
mcu = find(lambda p: "STM32" in p.val)[0]; drv = find(lambda p: "DRV83" in p.val)[0]; buck = find(lambda p: "TPSM" in p.val)[0]
ina = find(lambda p: "INA" in p.val)[0]; hdr = find(lambda p: "STDC14" in p.val)[0]
bulk = sorted(find(lambda p: p.val == "330u")); tvs = find(lambda p: "SMBJ" in p.val)[0]; shunt = find(lambda p: p.val == "R0005")[0]
reg12 = find(lambda p: "ZXTR" in p.val)[0]; reg33 = find(lambda p: "AP2112" in p.val)[0]; sw = find(lambda p: "SKRPACE" in p.val)[0]
xtal = find(lambda p: p.val == "8MHz")[0]; schottky = find(lambda p: p.val == "SS14")[0]
# 3-phase bridge on the right edge: one column, phase A at the bottom, C at the top, high side above low side
def is_high(q): return "+VBAT" in nets_of(q)
phase = collections.defaultdict(list)
for q in mosfets:
    for n in nets_of(q):
        if n.startswith("/DRV_SH"): phase[n].append(q)
bridge_x = X1 - MARGIN - 3.7 - 0.6 - 8.0   # left of the MC5/MC9/MC11 contact column at x=164
macro = {}
for k, yy in zip(sorted(phase.keys()), (Y1 - 20, cy + 4, Y0 + 22)):   # A bottom, B middle, C top
    hs, ls = sorted(phase[k], key=lambda q: not is_high(q))
    macro[hs] = (bridge_x, yy - 3.5, 0); macro[ls] = (bridge_x, yy + 3.5, 0)
macro[bulk[1]] = (X1 - MARGIN - 12.0, Y0 + 15.5, 0)      # bulk caps at the +VBAT rail, under the top contacts, above the bridge
macro[bulk[0]] = (X1 - MARGIN - 12.0 - 14.2, Y0 + 15.5, 0)
macro[tvs] = (X1 - MARGIN - 12.0 - 14.2 - 11.5, Y0 + 15.5, 0)  # TVS on the same rail
macro[shunt] = (X1 - MARGIN - 4.2, Y1 - MARGIN - 2.5, 0)           # shunt in the low-side return, below the bridge
macro[ina] = (X1 - MARGIN - 4.2 - 9.5, Y1 - MARGIN - 2.2, 0)         # sense amp right at the shunt
macro[drv] = (bridge_x - 12.0, cy + 4, 0)                          # gate driver beside the bridge, mid height
macro[reg12] = (bridge_x - 12.0, cy - 8.5, 0)                       # 12 V gate supply above the driver
macro[mcu] = (bridge_x - 30.0, cy + 6, 0)                           # MCU left of the driver
macro[xtal] = (bridge_x - 30.0, cy + 16.5, 0)                        # crystal under the MCU
macro[reg33] = (bridge_x - 30.0, cy - 5.5, 0)                        # 3V3 LDO above the MCU
macro[buck] = (X0 + 22, Y0 + 17, 0)                                 # buck near the top-left, fed from the rail
macro[schottky] = (X0 + 22, Y0 + 26, 0)
macro[hdr] = (X0 + 10.5, cy + 2, 0)                          # debug header on the left edge
macro[sw] = (X0 + 10.5, cy + 13.5, 0)                          # reset button under it
for r in macro: big[r] = parts[r]
for r, (x, y, rr) in macro.items():
    assert place_near(parts[r], x, y, rr, reach=20.0), r

# ---------- 2. attach passives to anchors ----------
small = [p for r, p in parts.items() if r not in big]
def anchor_for(p):
    score = collections.Counter()
    for _, _, n in p.pads:
        if n in ("GND",):
            continue
        for r in nets[n]:
            if r in big and r != p.ref:
                score[r] += 3 if not n.startswith("+") else 1
    if score:
        return score.most_common(1)[0][0]
    # power-only or GND-only passive: the rail owner
    pn = {n for _, _, n in p.pads}
    if "+VBAT" in pn: return bulk[0]
    if "+12V" in pn: return reg12
    if "+5V" in pn: return buck
    return mcu
order = sorted(small, key=lambda p: (-(p.cw * p.ch), p.ref))  # big passives first
unplaced = []
for p in order:
    a = parts[anchor_for(p)]
    ax, ay = a.x, a.y
    # ideal side: towards the anchor pad this part connects to
    target = None
    for px, py, n in p.pads:
        for qx, qy, qn in a.pad_world(a.x, a.y, a.r):
            if qn == n and n != "GND":
                target = (qx, qy); break
        if target: break
    tx, ty = target if target else (ax, ay)
    step = 0.5; ok = False
    for ring in range(1, 140):
        cands = []
        rad = ring * step
        for k in range(max(8, int(2 * math.pi * rad / step))):
            ang = 2 * math.pi * k / max(8, int(2 * math.pi * rad / step))
            x, y = tx + rad * math.cos(ang), ty + rad * math.sin(ang)
            cands.append((abs(x - tx) + abs(y - ty), x, y))
        cands.sort()
        for _, x, y in cands:
            for r in (0, 90):
                if try_place(p, x, y, r): ok = True; break
            if ok: break
        if ok: break
    if not ok:
        unplaced.append(p.ref)

# ---------- 3. score ----------
def mst_len(points):
    if len(points) < 2: return 0.0
    left = list(points); tree = [left.pop()]; total = 0.0
    while left:
        best = None
        for i, q in enumerate(left):
            d = min(math.hypot(q[0] - t[0], q[1] - t[1]) for t in tree)
            if best is None or d < best[0]: best = (d, i)
        total += best[0]; tree.append(left.pop(best[1]))
    return total
def ratsnest(use_plan):
    tot = 0.0
    for n, refs in nets.items():
        pts = []
        for r in refs:
            p = parts[r]
            pos = (p.x, p.y, p.r) if (use_plan and p.x is not None) else (p.ox, p.oy, p.orot)
            pts += [(x, y) for x, y, pn in p.pad_world(*pos) if pn == n]
        tot += mst_len(pts)
    return tot
plan = {r: {"x": p.x, "y": p.y, "rotation": p.r, "side": "F.Cu", "value": p.val, "anchor": (anchor_for(p) if r not in big else None)} for r, p in parts.items() if p.x is not None and r not in fixed}
report = {"outline": [X0, Y0, X1, Y1], "placed": len(plan), "fixedInterface": fixed, "unplaced": unplaced,
          "ratsnest_original_mm": round(ratsnest(False), 1), "ratsnest_plan_mm": round(ratsnest(True), 1),
          "macro": {k: (parts[k].x, parts[k].y, parts[k].r) for k in list(big)}}
json.dump({"report": report, "plan": plan}, open(OUT, "w"), indent=1)
print(json.dumps(report, indent=1))