← Commit history

Delete kicad/generator-scripts/route.py

TylerK ·f0574d0280 ·1mo ago ·parent 12b7cf1
1 file changed −487
kicad/generator-scripts/route.pydeleted−487
@@ -1,487 +0,0 @@-#!/usr/bin/env python3-"""Autorouter for the MPPT MCU board: parses the freshly-generated .kicad_pcb-(ground truth -- footprint placement + numbered nets already baked in by-pcbgen.py), builds per-net world-frame pad lists, and grid-routes every net-except GND (which is carried by the existing In1.Cu/B.Cu plane zones -- SMD-GND pads get a stitching via down to those planes instead of a track).--Two routing layers: F.Cu (0) and In2.Cu (1). In1.Cu/B.Cu are GND-only planes.-Grid-based multi-layer A* per net, local bounding-box scoped for speed, with-via cost penalty and a shared occupancy map so nets don't cross each other.-"""-import re, os, math, heapq, uuid as U-from collections import defaultdict--def uid(): return str(U.uuid4())--HERE = os.path.dirname(__file__)-PCB_PATH = "/mnt/c/Users/kirkt/Documents/mppt-power-system-mcu/mppt-power-system-mcu.kicad_pcb"-BOARD_W, BOARD_H = 195.0, 135.0--GRID = 0.3                      # mm per grid cell-LAYERS = ["F.Cu", "In2.Cu"]-VIA_PENALTY = 40                # in grid-cell units of extra cost-PAD_INFLATE_MM = 0.25           # keepout halo around any pad, for track-vs-pad clearance-SIGNAL_WIDTH = 0.25-POWER_WIDTH = 0.6-VIA_SIZE, VIA_DRILL = 0.4, 0.2   # near fab-minimum (0.25mm pad/0.15mm drill) ---                                  # smaller keepaway means fewer hole-clearance-                                  # violations against nearby unrelated pads--POWER_HINTS = ("VBATT", "VIN", "PANEL", "V5V", "V3V3", "SW", "VOUT", "VOS", "BATT")---def is_power_net(name):-    u = name.upper()-    return any(h in u for h in POWER_HINTS)---def split_blocks(text, tag):-    out = []-    i = 0-    n = len(text)-    needle = '(' + tag-    while True:-        i = text.find(needle, i)-        if i == -1:-            break-        j = i + len(needle)-        if j < n and (text[j].isalnum() or text[j] == '_'):-            i = j-            continue-        depth = 0-        k = i-        ins = False-        esc = False-        while k < n:-            ch = text[k]-            if esc:-                esc = False-            elif ch == '\\':-                esc = True-            elif ch == '"':-                ins = not ins-            elif not ins:-                if ch == '(':-                    depth += 1-                elif ch == ')':-                    depth -= 1-                    if depth == 0:-                        k += 1-                        break-            k += 1-        out.append(text[i:k])-        i = k-    return out---text = open(PCB_PATH, errors='replace').read()--# ============================================================================-# Parse every placed footprint's pads into world-frame coordinates + net-# ============================================================================-pads = []-fp_blocks = split_blocks(text, 'footprint')-for blk in fp_blocks:-    m_ref = re.search(r'\(property "Reference" "([^"]*)"', blk)-    ref = m_ref.group(1) if m_ref else "?"-    m_at = re.search(-        r'\(layer "[^"]*"\)\s*\n\s*\(uuid "[^"]*"\)\s*\n\s*\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)', blk)-    if not m_at:-        continue-    fx, fy, frot = float(m_at.group(1)), float(m_at.group(2)), float(m_at.group(3) or 0)-    th = math.radians(frot)-    cos_t, sin_t = math.cos(th), math.sin(th)-    for pblk in split_blocks(blk, 'pad'):-        mp = re.match(r'\(pad "(\w*)"\s+(\w+)\s+(\w+)', pblk)-        if not mp:-            continue-        pnum, ptype, pshape = mp.groups()-        if not pnum:-            continue-        m_lat = re.search(r'\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)', pblk)-        if not m_lat:-            continue-        lx, ly = float(m_lat.group(1)), float(m_lat.group(2))-        m_layers = re.search(r'\(layers ([^)]*)\)', pblk)-        layers = m_layers.group(1) if m_layers else ''-        any_layer = '*.Cu' in layers-        m_net = re.search(r'\(net (\d+) "([^"]*)"\)', pblk)-        netnum, netname = (int(m_net.group(1)), m_net.group(2)) if m_net else (0, "")-        m_size = re.search(r'\(size ([-\d.]+) ([-\d.]+)\)', pblk)-        pw, ph = (float(m_size.group(1)), float(m_size.group(2))) if m_size else (0.5, 0.5)-        wx = fx + lx * cos_t + ly * sin_t-        wy = fy + ly * cos_t - lx * sin_t-        pads.append({"ref": ref, "num": pnum, "net": netname, "netnum": netnum,-                      "x": round(wx, 4), "y": round(wy, 4), "any_layer": any_layer,-                      "radius": max(pw, ph) / 2})--print(f"parsed {len(pads)} pads (netted + unnetted) across {len(fp_blocks)} footprints")--# de-dupe exact (ref, num, x, y) repeats (can happen if a footprint's own pad-# regex matches twice for an unusual pad block shape)-seen_pad_keys = set()-dedup_pads = []-for p in pads:-    k = (p["ref"], p["num"])   # a given physical pin can only be one point ---    if k in seen_pad_keys:     # coordinate-only dedup missed near-duplicate-        continue               # float renderings of the same parsed pin-    seen_pad_keys.add(k)-    dedup_pads.append(p)-pads = dedup_pads--pins_per_ref = defaultdict(int)-for p in pads:-    pins_per_ref[p["ref"]] += 1-BUSY_REF_THRESHOLD = 10--by_net = defaultdict(list)-for p in pads:-    if p["net"]:-        by_net[p["net"]].append(p)--# ============================================================================-# GND stitching vias are computed FIRST, before any signal routing, because a-# through via drills through EVERY copper layer (F.Cu, In2.Cu, B.Cu) -- if-# signal nets were routed first without knowing where these vias will land,-# a via placed afterwards can drop right on top of an already-routed In2.Cu-# track and short it (this was found the hard way: it was the single largest-# source of real DRC shorting/clearance violations in the first full pass).-# ============================================================================-GND_VIA_SIZE, GND_VIA_DRILL = 0.45, 0.2   # >=0.1mm annular width at 0.2mm drill-net_num_by_name_early = {}-for p in pads:-    if p["net"]:-        net_num_by_name_early[p["net"]] = p["netnum"]-gnd_num = net_num_by_name_early.get("GND")-gnd_vias = []-gnd_via_xy = []-if gnd_num is not None:-    for p in by_net.get("GND", []):-        if p["any_layer"]:-            continue-        x, y = p["x"], p["y"]-        if any(math.hypot(x - ox, y - oy) < 0.25 for ox, oy in gnd_via_xy):-            continue-        gnd_via_xy.append((x, y))-        gnd_vias.append(-            f'\t(via\n\t\t(at {x:.6f} {y:.6f})\n\t\t(size {GND_VIA_SIZE})\n\t\t(drill {GND_VIA_DRILL})\n'-            f'\t\t(layers "F.Cu" "B.Cu")\n\t\t(net {gnd_num})\n\t\t(uuid "{uid()}")\n\t)')--# ============================================================================-# Obstacle map: every pad blocks its own footprint area (inflated) on the-# layers it physically exists on, regardless of net (routes must not cross-# unrelated copper). A cell records which net (if any) legitimately owns it.-# GND stitching via sites block BOTH routing layers (they're through-vias).-# ============================================================================-def to_grid(x, y):-    return (round(x / GRID), round(y / GRID))--def build_pad_obstacle(clearance_mm):-    """clearance_mm: extra clearance added on top of each pad's OWN real half--    size (not a flat halo) -- a small 0805 pad and a big THT connector pad get-    proportionate keepouts instead of the same fixed-radius blob, which badly-    under-blocked large pads (letting tracks graze real copper) in earlier-    passes."""-    obstacle = {}-    for p in pads:-        gx, gy = to_grid(p["x"], p["y"])-        layer_idxs = [0, 1] if p["any_layer"] else [0]-        inflate_cells = max(1, round((p["radius"] + clearance_mm) / GRID))-        for li in layer_idxs:-            for dx in range(-inflate_cells, inflate_cells + 1):-                for dy in range(-inflate_cells, inflate_cells + 1):-                    obstacle[(li, gx + dx, gy + dy)] = p["net"] or "?"-    via_inflate = max(1, round((GND_VIA_SIZE / 2 + 0.2) / GRID))-    for (vx, vy) in gnd_via_xy:-        gx, gy = to_grid(vx, vy)-        for li in (0, 1):-            for dx in range(-via_inflate, via_inflate + 1):-                for dy in range(-via_inflate, via_inflate + 1):-                    obstacle[(li, gx + dx, gy + dy)] = "GND"-    # force each pad's own EXACT cell back to its correct net -- on fine-pitch-    # parts (e.g. U4's QFN), neighboring pins' inflation halos can overlap and-    # overwrite a pin's own cell with the wrong net, making that pin unreachable.-    for p in pads:-        gx, gy = to_grid(p["x"], p["y"])-        layer_idxs = [0, 1] if p["any_layer"] else [0]-        for li in layer_idxs:-            obstacle[(li, gx, gy)] = p["net"] or "?"-    return obstacle--# normal obstacle map (real clearance halo) for the main pass; a near-zero--# inflation fallback map for a second attempt on links stuck behind fine-pitch-# IC pin fields where the halo has no room to breathe (e.g. U4's 0.65mm-pitch-# HTSSOP-28 -- neighbouring pins' halos abut and wall off the only approach).-pad_obstacle = build_pad_obstacle(PAD_INFLATE_MM)-pad_obstacle_tight = build_pad_obstacle(0.05)--# occupancy from already-routed track/via cells (shared across nets as we route)-occ = {}   # (layer_idx, gx, gy) -> net name--def mark_occ(li, gx, gy, net, half):-    for dx in range(-half, half + 1):-        for dy in range(-half, half + 1):-            occ[(li, gx + dx, gy + dy)] = net--def cell_free(li, gx, gy, net, obstacle_map):-    o = obstacle_map.get((li, gx, gy))-    if o is not None and o != net:-        return False-    o2 = occ.get((li, gx, gy))-    if o2 is not None and o2 != net:-        return False-    return True--# ============================================================================-# A* pathfinder, local bounding-box scoped, 2 layers + via transitions-# ============================================================================-MOVES = [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)]--def astar(src, dst, net, half, margin_cells, obstacle_map=None, src_layers=(0,), dst_layers=(0,)):-    obstacle_map = pad_obstacle if obstacle_map is None else obstacle_map-    sx, sy = to_grid(*src)-    tx, ty = to_grid(*dst)-    x0, x1 = min(sx, tx) - margin_cells, max(sx, tx) + margin_cells-    y0, y1 = min(sy, ty) - margin_cells, max(sy, ty) + margin_cells--    def h(node):-        li, gx, gy = node-        return math.hypot(gx - tx, gy - ty)--    # a path may only START/END on a layer the pad actually exists on (F.Cu-    # for a plain SMD pad) -- treating "either layer, free" here was a real-    # bug: it let a route finish on In2.Cu exactly at an SMD pad's (x,y) with-    # no via, which looks fine on paper but isn't electrically connected at-    # all (this was the dominant cause of "unconnected"/"dangling" findings).-    starts = [(li, sx, sy) for li in src_layers]-    goals = {(li, tx, ty) for li in dst_layers}--    openq = []-    for s in starts:-        heapq.heappush(openq, (h(s), 0, s, None))-    came = {}-    gscore = {s: 0 for s in starts}-    visited = set()--    while openq:-        f, g, node, parent = heapq.heappop(openq)-        if node in visited:-            continue-        visited.add(node)-        came[node] = parent-        if node in goals:-            path = []-            cur = node-            while cur is not None:-                path.append(cur)-                cur = came[cur]-            path.reverse()-            return path-        li, gx, gy = node-        if not (x0 <= gx <= x1 and y0 <= gy <= y1):-            continue-        for dxn, dyn in MOVES:-            ngx, ngy = gx + dxn, gy + dyn-            if not (x0 <= ngx <= x1 and y0 <= ngy <= y1):-                continue-            if not cell_free(li, ngx, ngy, net, obstacle_map):-                continue-            step = math.hypot(dxn, dyn)-            nnode = (li, ngx, ngy)-            ng = g + step-            if nnode not in gscore or ng < gscore[nnode]:-                gscore[nnode] = ng-                heapq.heappush(openq, (ng + h(nnode), ng, nnode, node))-        # via move (switch layer, same cell)-        oli = 1 - li-        if cell_free(oli, gx, gy, net, obstacle_map):-            nnode = (oli, gx, gy)-            ng = g + VIA_PENALTY-            if nnode not in gscore or ng < gscore[nnode]:-                gscore[nnode] = ng-                heapq.heappush(openq, (ng + h(nnode), ng, nnode, node))-    return None---def route_connection(src, dst, net, width_class, src_layers=(0,), dst_layers=(0,)):-    half = max(1, round((width_class / 2 + 0.2) / GRID))-    span = math.hypot(dst[0] - src[0], dst[1] - src[1]) / GRID-    margins = sorted(set(int(m) for m in (25, 60, 120, span * 0.6 + 40, 400)))-    for obstacle_map in (pad_obstacle, pad_obstacle_tight):-        for margin in margins:-            path = astar(src, dst, net, half, margin, obstacle_map, src_layers, dst_layers)-            if path:-                for (li, gx, gy) in path:-                    mark_occ(li, gx, gy, net, half)-                return path-    return None---# ============================================================================-# Route every non-GND net as a nearest-neighbor chain over its pads-# ============================================================================-def chain_order(plist):-    remaining = plist[1:]-    order = [plist[0]]-    while remaining:-        last = order[-1]-        remaining.sort(key=lambda p: (p["x"] - last["x"]) ** 2 + (p["y"] - last["y"]) ** 2)-        order.append(remaining.pop(0))-    return order---routed_paths = []   # (net, width, [(li,gx,gy), ...])-via_points = []      # (x, y, net) -- explicit vias from layer transitions in paths-failed = []--def touches_busy_ref(plist):-    return any(pins_per_ref[p["ref"]] >= BUSY_REF_THRESHOLD for p in plist)--# These specific nets sit right against U4's 0.65mm-pitch HTSSOP-28 pin field-# (the single tightest escape point on the board) -- verified in isolation-# (fresh occupancy) to route cleanly, but they lose the race for the few-# narrow corridors out of that pin field if ANYTHING else routes first and-# claims them. They get an empty-occupancy head start, before all other nets.-HAND_FIRST_NETS = {-    "Net-(U4-COMP)", "Net-(U4-VOSNS)", "Net-(U4-FB)",-    "Net-(Q2-D)", "Net-(U3-VI)", "Net-(U9-SW)",-}--nets_to_route = [(name, plist) for name, plist in by_net.items()-                  if name != "GND" and len(plist) >= 2]-nets_to_route.sort(key=lambda kv: (-    0 if kv[0] in HAND_FIRST_NETS else (1 if touches_busy_ref(kv[1]) else 2),-    len(kv[1])))--missing_links = []-for name, plist in nets_to_route:-    width = POWER_WIDTH if is_power_net(name) else SIGNAL_WIDTH-    order = chain_order(plist)-    net_path_cells = []-    net_failed_links = []-    for a, b in zip(order, order[1:]):-        a_layers = (0, 1) if a["any_layer"] else (0,)-        b_layers = (0, 1) if b["any_layer"] else (0,)-        path = route_connection((a["x"], a["y"]), (b["x"], b["y"]), name, width, a_layers, b_layers)-        if path is None:-            net_failed_links.append((a["ref"], a["num"], b["ref"], b["num"]))-        else:-            net_path_cells.append((path, (a["x"], a["y"]), (b["x"], b["y"])))-    if net_path_cells:-        routed_paths.append((name, width, net_path_cells))-    if net_failed_links:-        failed.append(name)-        missing_links.append((name, net_failed_links))--fully_ok = len(nets_to_route) - len(failed)-print(f"routed {fully_ok}/{len(nets_to_route)} non-GND multi-pad nets fully "-      f"({len(failed)} with at least one missing link: {failed[:15]}{'...' if len(failed) > 15 else ''})")--import json as _json-_json.dump(-    [[name, a_ref, a_num, b_ref, b_num] for name, links in missing_links-     for a_ref, a_num, b_ref, b_num in links],-    open(os.path.join(HERE, "missing_links.json"), "w"))--# ============================================================================-# Emit segments/vias for routed paths (merge collinear runs into fewer segs)-# ============================================================================-def cell_xy(gx, gy):-    return gx * GRID, gy * GRID--def merge_collinear(pts):-    """[(x,y), ...] grid-adjacent points -> fewer (x,y) waypoints by dropping-    interior points that continue the same direction as the previous step."""-    if len(pts) < 3:-        return pts-    out = [pts[0]]-    prev_dir = None-    for i in range(1, len(pts)):-        dx = pts[i][0] - out[-1][0]-        dy = pts[i][1] - out[-1][1]-        norm = math.hypot(dx, dy) or 1-        d = (dx / norm, dy / norm)-        if prev_dir is not None and abs(d[0] - prev_dir[0]) < 1e-6 and abs(d[1] - prev_dir[1]) < 1e-6:-            out[-1] = pts[i]-        else:-            out.append(pts[i])-            prev_dir = d-    return out---def emit_path(net_num, net_name, width, path_cells):-    segs = []-    vias = []-    for cells, exact_src, exact_dst in path_cells:-        # collapse consecutive same-layer points into polylines, splitting at via transitions-        i = 0-        n = len(cells)-        while i < n - 1:-            li0, gx0, gy0 = cells[i]-            j = i-            while j + 1 < n and cells[j + 1][0] == li0:-                j += 1-            # points i..j are same layer -- merge collinear runs, then emit segments-            pts = [cell_xy(cells[k][1], cells[k][2]) for k in range(i, j + 1)]-            # snap the path's true endpoints to the EXACT pad centers -- grid-            # rounding otherwise leaves the track ending up to half a grid-            # cell (0.15mm) short of the pad, which DRC sees as a dangling/-            # unconnected end even though it visually looks connected-            if i == 0:-                pts[0] = exact_src-            if j == n - 1:-                pts[-1] = exact_dst-            pts = merge_collinear(pts)-            for k in range(len(pts) - 1):-                x1, y1 = pts[k]-                x2, y2 = pts[k + 1]-                if (x1, y1) == (x2, y2):-                    continue-                segs.append(-                    f'\t(segment\n\t\t(start {x1:.6f} {y1:.6f})\n\t\t(end {x2:.6f} {y2:.6f})\n'-                    f'\t\t(width {width})\n\t\t(layer "{LAYERS[li0]}")\n\t\t(net {net_num})\n'-                    f'\t\t(uuid "{uid()}")\n\t)')-            if j + 1 < n:-                # via at cells[j] (layer transition point)-                _, vx, vy = cells[j]-                x, y = cell_xy(vx, vy)-                vias.append(-                    f'\t(via\n\t\t(at {x:.6f} {y:.6f})\n\t\t(size {VIA_SIZE})\n\t\t(drill {VIA_DRILL})\n'-                    f'\t\t(layers "F.Cu" "In2.Cu")\n\t\t(net {net_num})\n\t\t(uuid "{uid()}")\n\t)')-            i = j + 1-    return segs, vias--net_num_by_name = {}-for p in pads:-    if p["net"]:-        net_num_by_name[p["net"]] = p["netnum"]--all_segs, all_vias = [], []-for name, width, path_cells in routed_paths:-    s, v = emit_path(net_num_by_name[name], name, width, path_cells)-    all_segs.extend(s)-    all_vias.extend(v)--# GND stitching vias were already computed up-front (see above, before any-# signal routing) so their sites could be reserved as obstacles; nothing left-# to do here but report.-print(f"emitted {len(all_segs)} segments, {len(all_vias)} signal vias, "-      f"{len(gnd_vias)} GND stitching vias")--# ============================================================================-# Splice into the .kicad_pcb: insert before the final closing paren-# ============================================================================-insertion = '\n'.join(all_segs + all_vias + gnd_vias) + '\n'-assert text.rstrip().endswith(')')-idx = text.rstrip().rfind('\n')-new_text = text.rstrip()[:idx + 1] + insertion + text.rstrip()[idx + 1:] + '\n'-open(PCB_PATH, "w").write(new_text)-print(f"wrote routed board back to {PCB_PATH}")--if missing_links:-    print("MISSING LINKS (need manual attention):")-    for name, links in missing_links:-        print(" ", name)-        for a_ref, a_num, b_ref, b_num in links:-            print(f"    {a_ref}.{a_num} <-> {b_ref}.{b_num}")