main
TylerK Revision 4: first-build audit. Bypass caps assigned to their own supply pin (worst distance 62.5mm -> 10.0mm), signal v… daee549 1mo ago
#!/usr/bin/env python3
"""First-build audit: the checks that decide whether this board works when it
is actually assembled, rather than whether it passes DRC.

DRC answers "can this be manufactured without shorting". It says nothing about
whether every schematic net actually made it onto the copper, whether a
regulator has a decoupling capacitor near enough to matter, whether the drills
are inside the fab's capability, or whether an exposed thermal pad is tied to
the plane. Those are the things that stop a board working the first time, and
none of them are DRC rules.

Everything here is measured from the board file and the netlist. Nothing is
asserted.
"""
import json, math, os, re, sys
from collections import defaultdict

import geomcheck as G

HERE = os.path.dirname(os.path.abspath(__file__))
PCB = ("/mnt/c/Users/kirkt/Documents/mppt-power-system-mcu/"
       "mppt-power-system-mcu.kicad_pcb")

# Conservative prototype-fab capability. Deliberately the common denominator of
# JLCPCB / PCBWay / OSHPark 4-layer standard process rather than any one
# vendor's best case -- a board that clears this is makeable anywhere.
FAB = {
    "min_track_mm": 0.127,       # 5 mil
    "min_clearance_mm": 0.127,
    "min_drill_mm": 0.20,
    "min_annular_ring_mm": 0.125,   # a 0.45mm via on a 0.20mm drill
    "min_edge_clearance_mm": 0.25,
}

POWER_PIN_HINTS = ("VCC", "VDD", "VIN", "VI", "AVDD", "VBAT", "VS", "VP")


def load_netlist():
    """Netlist, with net names normalised the way the board file spells them.

    KiCad escapes '/' as '{slash}' inside footprint pads but not inside
    tracks; the netlist export carries the escaped spelling. Comparing the two
    raw makes every net with a '/' in its name look absent from the board.
    """
    n = json.load(open(os.path.join(HERE, "mppt_mcu_netlist.json")))
    pins = {ref: {p: G.netname(v) for p, v in d.items()}
            for ref, d in n["ref_pin_nets"].items()}
    return n["components"], pins


def board_objects(path):
    return G.load_board(path)


def board_edge(path):
    """The Edge.Cuts rectangle, as (x0, y0, x1, y1)."""
    txt = open(path, errors='replace').read()
    xs, ys = [], []
    for tag in ("gr_rect", "gr_line", "gr_poly"):
        for blk in G._blocks(txt, tag):
            if '"Edge.Cuts"' not in blk:
                continue
            for a, b in re.findall(r'\((?:start|end|xy) ([-\d.]+) ([-\d.]+)\)', blk):
                xs.append(float(a)); ys.append(float(b))
    if not xs:
        return None
    return min(xs), min(ys), max(xs), max(ys)


# --------------------------------------------------------------------------
def check_net_parity(objs, ref_pin_nets):
    """Every schematic net, with every pad it should have, present on copper."""
    want = defaultdict(set)
    for ref, pins in ref_pin_nets.items():
        for pin, net in pins.items():
            if net and not net.startswith("unconnected-"):
                want[net].add(f"{ref}.{pin}")
    have = defaultdict(set)
    for o in objs:
        if o.kind == "pad" and o.tag and o.net:
            have[o.net].add(o.tag)

    missing_nets, missing_pads = [], []
    for net, pads in sorted(want.items()):
        if net not in have:
            missing_nets.append(net); continue
        gone = pads - have[net]
        if gone:
            missing_pads.append((net, sorted(gone)))
    return missing_nets, missing_pads


def check_single_pad_nets(ref_pin_nets):
    """A net that reaches exactly one pin connects nothing."""
    count = defaultdict(list)
    for ref, pins in ref_pin_nets.items():
        for pin, net in pins.items():
            if net and not net.startswith("unconnected-"):
                count[net].append(f"{ref}.{pin}")
    return sorted((n, p) for n, p in count.items() if len(p) == 1)


def check_net_continuity(objs, bucket=2.0):
    """Is every net one continuous piece of copper?

    This is the first-build question, and it is asked here with exact geometry
    rather than by trusting any one tool: two objects are joined when their
    copper actually overlaps (touching, gap <= 0), a via joins every layer it
    passes, and every pad of a net must end up in the same component.

    KiCad's own connectivity says the same thing, but it is worth having an
    independent answer -- the electrothermal model rasterises at 0.2mm and has
    reported a net as split that both KiCad and this check call continuous.
    """
    by_net = defaultdict(list)
    for o in objs:
        if o.net and o.net != "GND":      # GND is planes; not a trace question
            by_net[o.net].append(o)

    split = []
    for net, items in sorted(by_net.items()):
        n = len(items)
        parent = list(range(n))

        def find(i):
            while parent[i] != i:
                parent[i] = parent[parent[i]]; i = parent[i]
            return i

        def union(i, j):
            a, b = find(i), find(j)
            if a != b: parent[a] = b

        grid = defaultdict(list)
        for i, o in enumerate(items):
            x0, y0, x1, y1 = o.bbox()
            for gx in range(int(x0 // bucket), int(x1 // bucket) + 1):
                for gy in range(int(y0 // bucket), int(y1 // bucket) + 1):
                    grid[(gx, gy)].append(i)
        for cell in grid.values():
            for ii in range(len(cell)):
                for jj in range(ii + 1, len(cell)):
                    a, b = items[cell[ii]], items[cell[jj]]
                    if find(cell[ii]) == find(cell[jj]):
                        continue
                    if not set(a.layers) & set(b.layers):
                        continue
                    if a.copper_gap(b) <= 1e-6:
                        union(cell[ii], cell[jj])

        pads = [(i, o) for i, o in enumerate(items) if o.kind == "pad"]
        if len(pads) < 2:
            continue
        roots = defaultdict(list)
        for i, o in pads:
            roots[find(i)].append(o.tag or "?")
        if len(roots) > 1:
            split.append((net, [sorted(v) for v in roots.values()]))
    return split


def check_fab(objs, path):
    """Track width, drill, annular ring and edge clearance against FAB."""
    issues = defaultdict(list)
    for o in objs:
        if o.kind == "track":
            w = o.r * 2
            if w < FAB["min_track_mm"] - 1e-9:
                issues["track too narrow"].append(f"{w:.3f}mm on {o.net}")
        if o.hole:
            d = o.hole * 2
            if d < FAB["min_drill_mm"] - 1e-9:
                issues["drill too small"].append(
                    f"{d:.3f}mm {o.kind} {o.tag or o.net}")
            if o.kind == "via":
                ring = o.r - o.hole
                if ring < FAB["min_annular_ring_mm"] - 1e-9:
                    issues["annular ring too thin"].append(
                        f"{ring:.3f}mm via on {o.net}")

    edge = board_edge(path)
    if edge:
        x0, y0, x1, y1 = edge
        m = FAB["min_edge_clearance_mm"]
        for o in objs:
            if not o.net:
                continue
            bx0, by0, bx1, by1 = o.bbox()
            if (bx0 < x0 + m or by0 < y0 + m or bx1 > x1 - m or by1 > y1 - m):
                issues["copper near board edge"].append(
                    f"{o.kind} {o.tag or o.net} at "
                    f"({bx0:.1f},{by0:.1f})-({bx1:.1f},{by1:.1f})")
    return issues, edge


def check_decoupling(objs, comps, ref_pin_nets, max_mm=10.0):
    """Does every IC have a bypass capacitor physically near it?

    Measured per IC, not per pin, and deliberately so. The netlist carries pin
    NUMBERS, not pin names, so there is no reliable way to tell a supply pin
    from a current-sense input from the netlist alone -- an earlier version of
    this check treated the INA180s' shunt inputs as supply pins and reported
    problems that were not there. What can be measured honestly is whether a
    GND-referenced capacitor that shares a net with the part is close to it,
    which is the question that actually matters: an IC with no local capacitor
    is the failure mode.
    """
    pad_xy = defaultdict(list)
    for o in objs:
        if o.kind == "pad" and o.tag:
            pad_xy[o.tag.split(".")[0]].append((o.ax, o.ay))
    cen = {r: (sum(p[0] for p in v) / len(v), sum(p[1] for p in v) / len(v))
           for r, v in pad_xy.items()}

    bypass = {r for r, pins in ref_pin_nets.items()
              if r.startswith("C") and len(pins) == 2
              and "GND" in pins.values()}
    out = []
    for ic, pins in sorted(ref_pin_nets.items()):
        if not ic.startswith("U") or len(pins) < 3 or ic not in cen:
            continue
        nets = set(pins.values())
        best = None
        for c in bypass:
            if c not in cen:
                continue
            if not (nets & (set(ref_pin_nets[c].values()) - {"GND"})):
                continue
            d = math.hypot(cen[ic][0] - cen[c][0], cen[ic][1] - cen[c][1])
            if best is None or d < best[0]:
                best = (d, c)
        if best is None:
            out.append((ic, None, None, float("inf")))
        else:
            out.append((ic, best[1], comps.get(best[1], {}).get("value", "?"),
                        best[0]))
    return [r for r in out if r[3] > max_mm], out


def check_thermal_pads(objs, ref_pin_nets):
    """An exposed pad must be tied to its net by vias, not just by its own face."""
    eps = defaultdict(list)
    for o in objs:
        if o.kind == "pad" and o.tag:
            ref, _, num = o.tag.partition(".")
            # an exposed pad is a large pad; collect the big ones per footprint
            if o.rect and (o.rect.hw * 2) > 1.6 and (o.rect.hh * 2) > 1.6:
                eps[(ref, num)].append(o)
    vias = [o for o in objs if o.kind == "via"]
    out = []
    for (ref, num), pads in sorted(eps.items()):
        if not ref.startswith("U"):
            continue
        net = pads[0].net
        if net != "GND":
            # A non-GND tab (a SOT-223 regulator's output, say) has no plane
            # of its own net to via down to, so "no vias" is correct for it,
            # not a finding. It needs copper AREA, which is a pour question.
            continue
        n_via = 0
        for p in pads:
            x0, y0, x1, y1 = p.bbox()
            for v in vias:
                if v.net == net and x0 <= v.ax <= x1 and y0 <= v.ay <= y1:
                    n_via += 1
        # a thru-hole pad in the same group is itself a thermal via
        n_pth = sum(1 for p in pads if p.hole)
        out.append((f"{ref}.{num}", net, len(pads), n_via, n_pth))
    return out


def check_courtyard_to_edge(objs, edge):
    """Nothing should overhang the board outline."""
    if not edge:
        return []
    x0, y0, x1, y1 = edge
    bad = []
    for o in objs:
        if o.kind != "pad":
            continue
        bx0, by0, bx1, by1 = o.bbox()
        if bx0 < x0 or by0 < y0 or bx1 > x1 or by1 > y1:
            bad.append(o.tag)
    return bad


def main(path=PCB):
    comps, ref_pin_nets = load_netlist()
    objs = board_objects(path)
    print(f"board: {len(objs)} copper objects, {len(comps)} components\n")
    problems = 0

    print("== schematic -> PCB net parity ==")
    mn, mp = check_net_parity(objs, ref_pin_nets)
    if mn:
        problems += len(mn)
        print(f"  MISSING NETS ({len(mn)}): {mn[:8]}")
    if mp:
        problems += len(mp)
        for net, pads in mp[:10]:
            print(f"  MISSING PADS on {net}: {pads}")
    if not mn and not mp:
        print("  every schematic net and pad is present on the board")

    print("\n== net continuity (exact geometry, independent of KiCad) ==")
    split = check_net_continuity(objs)
    if split:
        problems += len(split)
        for net, groups in split[:10]:
            print(f"  SPLIT {net}: " + "  |  ".join(",".join(g[:4]) for g in groups))
    else:
        print("  every non-GND net is one continuous piece of copper")

    print("\n== nets that reach only one pin ==")
    single = check_single_pad_nets(ref_pin_nets)
    if single:
        problems += len(single)
        for n, p in single[:10]:
            print(f"  {n} -> {p}")
    else:
        print("  none")

    print("\n== fab capability ==")
    issues, edge = check_fab(objs, path)
    if issues:
        for k, v in issues.items():
            problems += len(v)
            print(f"  {k}: {len(v)}")
            for x in v[:5]:
                print(f"      {x}")
    else:
        print(f"  clears the conservative prototype process "
              f"(track>={FAB['min_track_mm']}mm, drill>={FAB['min_drill_mm']}mm, "
              f"ring>={FAB['min_annular_ring_mm']}mm)")
    if edge:
        print(f"  board outline: {edge[2]-edge[0]:.1f} x {edge[3]-edge[1]:.1f} mm")

    print("\n== decoupling: nearest bypass cap to each IC ==")
    far, allrows = check_decoupling(objs, comps, ref_pin_nets)
    for ic, cap, val, d in sorted(allrows, key=lambda t: -t[3]):
        if cap is None:
            print(f"  {ic:<4s} NO GND-referenced capacitor shares a net with it")
        else:
            print(f"  {ic:<4s} -> {cap:>4s} ({val:>5s}) at {d:5.1f}mm")
    problems += len(far)
    if not far:
        print("  every IC has a bypass capacitor within 10mm")

    print("\n== exposed / thermal pads ==")
    for tag, net, npads, nvia, npth in check_thermal_pads(objs, ref_pin_nets):
        flag = "" if (nvia + npth) else "   <-- NO VIAS"
        print(f"  {tag:<8s} {net:<8s} {npads} pad(s), {nvia} via(s), "
              f"{npth} thru-hole thermal pad(s){flag}")

    print("\n== pads overhanging the board outline ==")
    over = check_courtyard_to_edge(objs, edge)
    if over:
        problems += len(over)
        print(f"  {len(over)}: {over[:10]}")
    else:
        print("  none")

    print(f"\n>>> audit problems needing attention: {problems}")
    return problems


if __name__ == "__main__":
    sys.exit(0 if main() == 0 else 1)