main
TylerK Update 51 file(s) 805d738 1mo ago
#!/usr/bin/env python3
"""Post-route copper widening for the high-current nets, clearance-verified.

The problem this solves
-----------------------
The router picks one width per link and caps it at the narrowest pad the link
lands on:

    cap   = max(MIN_W, min(a["narrow"], b["narrow"]))
    eff_w = min(width_for(net), cap)

That cap is correct AT THE PAD -- a 1.2mm trace cannot land on a 0.4mm pin
without bridging its neighbours -- but the router then applies it to the whole
run. So `/PANEL_PROT`, a 5A net whose width policy asks for 1.2mm, came out at
0.4mm along its entire length because somewhere on the chain it touches a
small pad. At 35um copper that is 0.014mm^2 of section carrying 5A: ~357
A/mm^2, against the ~120 A/mm^2 the electrothermal model already flagged as
this board's worst case.

The fix
-------
Width is a per-segment property, not a per-net one. This pass walks each
segment of each high-current net and grows it as far as the surrounding copper
actually allows, verified against exact geometry (geomcheck, no raster), then
writes the new widths back. Segments in open board area reach full policy
width; the short segments at the pads stay necked down, which is exactly where
the narrow cap was legitimate in the first place.

Nothing moves. Only widths change, and only when the widened shape has been
proven to still clear every different-net object around it.
"""
import math, re, sys, os

import geomcheck as G

PCB = ("/mnt/c/Users/kirkt/Documents/mppt-power-system-mcu/"
       "mppt-power-system-mcu.kicad_pcb")

# the schematic's real per-net currents; width_for() is the ROUTER's target
from route2 import NET_CURRENT_A, width_for, CLEARANCE, MIN_W

# widths a fab will panelise happily; try widest first
LADDER = [2.5, 2.0, 1.6, 1.4, 1.2, 1.0, 0.8, 0.6, 0.5, 0.4, 0.3, 0.25, 0.2, 0.15]


def final_width(net):
    """The width this net's copper SHOULD be, once it is placed.

    Deliberately more generous than the router's `width_for()`. Those are two
    different jobs: the router's target has to be narrow enough that the link
    actually routes at all, because a link that fails to route is worth far
    less than a link that routes thin. This one is applied afterwards, when the
    copper is already down and the surrounding geometry can be measured, and
    every increase is individually verified -- so aiming high costs nothing
    where there is no room and buys real section where there is.

    Raising these targets changes no routing at all -- the router has already
    run by this point -- so the only question each increase has to answer is
    whether the copper around that one segment has room for it.
    """
    a = NET_CURRENT_A.get(net, 0.0)
    if a >= 5.0:  return 2.5
    if a >= 2.5:  return 2.0
    if a >= 2.0:  return 1.6
    if a >= 1.0:  return 1.0
    if a >= 0.5:  return 0.8
    return MIN_W
WIDEN_MARGIN = 0.1         # mm of headroom against checker-vs-DRC disagreement


def main(path=PCB, dry=False):
    text = open(path, errors='replace').read()
    objs = G.load_board(path)
    idx = G.Index(objs)

    targets = {n: final_width(n) for n in NET_CURRENT_A}
    tracks = [o for o in objs if o.kind == "track"]
    print(f"{len(tracks)} tracks, {len(targets)} current-carrying nets")

    changes = {}          # (x0,y0,x1,y1,layer,old_w) -> new_w
    wid = {}              # id(track object) -> its key in `changes`
    grown = widened_mm = 0
    per_net = {}
    for o in tracks:
        tgt = targets.get(o.net)
        if tgt is None: continue
        cur_w = o.r * 2
        if cur_w >= tgt - 1e-9:
            per_net.setdefault(o.net, []).append(cur_w)
            continue
        # how much room is there, really?
        room = idx.min_gap(o, reach=max(2.0, tgt))
        # Room is measured from the CURRENT edge, so the half-width may grow by
        # (room - CLEARANCE) before the rule is violated -- plus a margin,
        # because this measurement is a model and models are wrong somewhere.
        # Widening right up to the limit put two pad-vs-track pairs 0.03-0.05mm
        # inside KiCad's rule while this checker still read them as legal (it
        # disagreed on an oval TO-220 pad by 0.0475mm). Spending 0.1mm of
        # copper width to stay clear of the disagreement is a good trade.
        allow = cur_w + 2 * (room - CLEARANCE - WIDEN_MARGIN)
        best = cur_w
        for w in LADDER:
            if w > tgt or w <= cur_w: continue
            if w <= allow + 1e-9:
                best = w; break
        if best > cur_w:
            key = (o.ax, o.ay, o.bx, o.by, o.layers[0], cur_w)
            changes[key] = best
            wid[id(o)] = key
            widened_mm += math.hypot(o.bx - o.ax, o.by - o.ay)
            grown += 1
            o.r = best / 2          # later segments see the new width
        per_net.setdefault(o.net, []).append(best)

    # ---- settle ----------------------------------------------------------
    # Widening is order-dependent: segment A measures its room against B at
    # B's CURRENT width, and if B is widened afterwards the pair ends up
    # tighter than either measurement predicted. One pass duly produced two
    # violations. Rather than try to predict every neighbour's final width,
    # widen, look, and step the offenders back down until the board settles.
    for settle in range(6):
        bad = G.violations(objs)
        hit = [(gap, o) for gap, rule, why, a, b in bad
               for o in (a, b) if id(o) in wid]
        if not hit:
            if settle:
                print(f"   settled after {settle} shrink pass(es)")
            break
        for _gap, o in hit:
            key = wid[id(o)]
            cur = changes.get(key, o.r * 2)
            lower = [w for w in LADDER if w < cur - 1e-9]
            new_w = lower[0] if lower else None
            if new_w is None or new_w <= key[5]:
                changes.pop(key, None)          # back to how it was routed
                o.r = key[5] / 2
            else:
                changes[key] = new_w
                o.r = new_w / 2
        grown = len(changes)
    else:
        print("   WARNING: widening did not settle; leaving widths as routed")
        changes = {}

    print(f"\nwidened {grown} segments ({widened_mm:.1f}mm of copper)")
    print(f"{'net':22s} {'target':>7s} {'min':>6s} {'median':>7s} {'max':>6s}")
    for n in sorted(per_net, key=lambda k: -NET_CURRENT_A.get(k, 0)):
        ws = sorted(per_net[n])
        if not ws: continue
        print(f"{n:22s} {targets[n]:7.2f} {ws[0]:6.2f} "
              f"{ws[len(ws)//2]:7.2f} {ws[-1]:6.2f}")

    if dry:
        return 0

    # ---- rewrite widths in place -----------------------------------------
    pat = re.compile(
        r'(\(segment\s+\(start ([-\d.]+) ([-\d.]+)\)\s+\(end ([-\d.]+) ([-\d.]+)\)'
        r'\s+\(width )([\d.]+)(\)\s+\(layer "([^"]+)"\))')

    hit = [0]

    def sub(m):
        key = (float(m.group(2)), float(m.group(3)),
               float(m.group(4)), float(m.group(5)), m.group(8), float(m.group(6)))
        if key in changes:
            hit[0] += 1
            return f"{m.group(1)}{changes[key]:g}{m.group(7)}"
        return m.group(0)

    out = pat.sub(sub, text)
    print(f"\nrewrote {hit[0]} segment widths in the board file")
    # hit[0] counts SUBSTITUTIONS, and several segments can share one key
    # (same endpoints, layer and old width), so it legitimately exceeds the
    # number of planned changes. Only a shortfall is a problem; the old test
    # printed "-675 planned changes did not match -- widths left alone" on a
    # run where every width had in fact been written.
    if hit[0] < len(changes):
        print(f"   WARNING: {len(changes) - hit[0]} planned change(s) matched "
              f"no segment text -- those widths were left alone")
    open(path, "w").write(out)

    # ---- prove it ---------------------------------------------------------
    v = G.violations(G.load_board(path))
    print(f"exact different-net violations after widening: {len(v)}")
    return len(v)


if __name__ == "__main__":
    sys.exit(main(dry="--dry" in sys.argv))