main
Ray Publish 0.2.7 db87ed5 1d ago
#!/usr/bin/env python3
"""Routing gates: layout rules as numbers that fail the build. Reads a .kicad_pcb with the standard library.

    python3 routing_gates.py <board.kicad_pcb> <spec.json> [--json out.json] [--only grid,necks,...]

Run it on a board whose zones have been REFILLED by KiCad (kicad-cli pcb drc --refill-zones --save-board):
stored fills can be stale, and two checks measure the filled copper.

The spec names the parts the rules are about (nothing is guessed from reference designators):
{
  "powerStage": {"ic": "U1", "vinPin": "3", "gndPin": "1", "inputCaps": ["C2", "C3"],
                 "outputCaps": ["C6", "C7", "C8"], "inductor": "L1", "inductorOutPin": "2",
                 "planeLayer": "B.Cu", "planeNet": "GND"},
  "powerPaths": [{"net": "SW", "from": "U1.2", "to": "L1.1", "amps": 1.0}],
  "decoupling": [{"pin": "U1.3", "cap": "C2"}],
  "thresholds": {...optional overrides of THRESH below...}
}
Exit 0 when every gate passes, 1 when any fails, 2 on a spec or board error.
"""
import json, math, sys
from collections import defaultdict, deque
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import kicad_board as K  # noqa: E402

THRESH = {
    'off_grid_segments': 0,          # 0/45/90 only; arcs are reported separately and allowed only if the spec says so
    'extra_bends': 0,                # collinear (redundant) vertices plus short jogs that return to their heading
    'jog_max_mm': 0.6,               # a bend pair closer than this that restores the heading is a jog
    'inline_vias': 0,                # a via where two same-layer track ends meet (the trace continues through it)
    'input_pad_to_pin_mm': 2.5,      # best input cap: VIN pad to the IC VIN pin, GND pad to the IC GND pin
    'input_loop_area_mm2': 6.0,      # IC VIN pin -> cap VIN pad -> cap GND pad -> IC GND pin
    'output_cluster_spread_mm': 5.0,
    'output_to_inductor_mm': 5.0,
    'plane_intrusions': 0,           # non-plane-net copper on the plane layer under the hot loop
    'hot_loop_margin_mm': 1.0,
    'neck_track_vs_pad': 1.0,        # power path: narrowest track / the narrow side of the pad it leaves
    'neck_copper_vs_pad': 1.0,       # power path: narrowest filled-copper width along the path / the same pad side
    'meander_ratio': 1.15,           # power path: routed track length / straight pad-to-pad distance
    'path_bends': 4,                 # power path: direction changes along its track route
    'raster_mm': 0.02,               # filled-copper width raster
}


def seg_angle_ok(a, b):
    ang = math.degrees(math.atan2(b[1] - a[1], b[0] - a[0])) % 45
    return min(ang, 45 - ang) < 0.05


def unit(a, b):
    dx, dy = b[0] - a[0], b[1] - a[1]; n = math.hypot(dx, dy) or 1
    return dx / n, dy / n


def key(p):
    return (round(p[0], 3), round(p[1], 3))


class Gates:
    def __init__(self, board, spec):
        self.B, self.S = board, spec
        self.T = {**THRESH, **spec.get('thresholds', {})}
        self.res, self.fails = {}, []
        self.straight = [t for t in board.tracks if not t['arc']]

    def fail(self, msg):
        self.fails.append(msg)

    # ---- appearance ---------------------------------------------------------------------------------
    def grid(self):
        off = [(t['net'], self.B.design(t['a']), self.B.design(t['b'])) for t in self.straight if not seg_angle_ok(t['a'], t['b'])]
        arcs = sum(t['arc'] for t in self.B.tracks)
        self.res['grid'] = {'segments': len(self.straight), 'off_grid': len(off), 'examples': off[:5], 'arcs': arcs}
        if len(off) > self.T['off_grid_segments']: self.fail(f'{len(off)} of {len(self.straight)} segments off the 0/45/90 grid')
        if arcs and not self.S.get('allowArcs'): self.fail(f'{arcs} arc track(s); set "allowArcs" if they are intended')

    def _chains(self):
        """Per net and layer: vertices where exactly two segments of equal width meet and nothing else touches."""
        touch = defaultdict(list)
        for i, t in enumerate(self.straight):
            for e in ('a', 'b'):
                touch[(t['net'], t['layer'], key(t[e]))].append(i)
        pads = [(p, K.pad_polygon(p)) for p in self.B.pads]
        vias = {key((v['x'], v['y'])) for v in self.B.vias}
        out = []
        for (net, layer, k), ids in touch.items():
            if len(ids) != 2 or k in vias: continue
            if any(p['net'] == net and K.inside(k, poly) for p, poly in pads): continue
            out.append((net, layer, k, [self.straight[i] for i in ids]))
        return out

    def bends(self):
        redundant, jogs = [], []
        chains = self._chains()
        for net, layer, k, (s1, s2) in chains:
            o1 = s1['a'] if key(s1['b']) == k else s1['b']; o2 = s2['a'] if key(s2['b']) == k else s2['b']
            u1, u2 = unit(o1, k), unit(k, o2)
            if abs(u1[0] * u2[1] - u1[1] * u2[0]) < 1e-6 and u1[0] * u2[0] + u1[1] * u2[1] > 0 and s1['w'] == s2['w']:
                redundant.append((net, self.B.design(k)))
        # jogs: a short segment between two bends whose outer headings agree (a sidestep that adds two bends)
        mids = {}
        for net, layer, k, segs in chains:
            mids[(net, layer, k)] = segs
        for t in self.straight:
            ln = math.dist(t['a'], t['b'])
            if ln >= self.T['jog_max_mm']: continue
            ends = []
            for e in ('a', 'b'):
                segs = mids.get((t['net'], t['layer'], key(t[e])))
                if not segs: break
                other = segs[0] if segs[1] is t else segs[1]
                far = other['a'] if key(other['b']) == key(t[e]) else other['b']
                ends.append((e, far))
            if len(ends) != 2: continue
            ua = unit(ends[0][1], t['a']); ub = unit(t['b'], ends[1][1])
            if abs(ua[0] * ub[1] - ua[1] * ub[0]) < 1e-3 and ua[0] * ub[0] + ua[1] * ub[1] > 0 and not (abs(ua[0] * unit(t['a'], t['b'])[1] - ua[1] * unit(t['a'], t['b'])[0]) < 1e-3):
                jogs.append((t['net'], self.B.design(t['a']), round(ln, 3)))
        self.res['bends'] = {'redundant_vertices': len(redundant), 'jogs': len(jogs), 'examples': (redundant + jogs)[:6]}
        n = len(redundant) + len(jogs)
        if n > self.T['extra_bends']: self.fail(f'{n} extra bend(s): {len(redundant)} redundant vertex(es), {len(jogs)} short jog(s)')

    def inline_vias(self):
        ends = defaultdict(int)
        for t in self.B.tracks:
            for e in ('a', 'b'):
                ends[(key(t[e]), t['layer'])] += 1
        bad = [(v['net'], self.B.design((v['x'], v['y']))) for v in self.B.vias
               if any(ends[(key((v['x'], v['y'])), l)] >= 2 for l in ('F.Cu', 'B.Cu'))]
        self.res['inline_vias'] = {'count': len(bad), 'examples': bad[:5]}
        if len(bad) > self.T['inline_vias']: self.fail(f'{len(bad)} via(s) inside a trace (two same-layer track ends meet at the via)')

    # ---- connectivity graph (tracks, vias, pads, filled zones) -------------------------------------------------
    def graph(self, net):
        """Nodes: track ends, pads and vias of `net`. Edges carry (length, kind): 'track' (its length), 'join' (a track
        end inside a pad or via), and 'fill': members of one filled polygon are joined pairwise at their straight-line
        distance, so crossing a pour costs the distance across it, never zero."""
        g = defaultdict(list)
        def edge(a, b, mm, kind):
            g[a].append((b, mm, kind)); g[b].append((a, mm, kind))
        trs = [t for t in self.B.tracks if t['net'] == net]
        pads = [p for p in self.B.pads if p['net'] == net]
        vias = [v for v in self.B.vias if v['net'] == net]
        for t in trs:
            edge(('pt', key(t['a']), t['layer']), ('pt', key(t['b']), t['layer']), math.dist(t['a'], t['b']), 'track')
        on_layer = lambda p, layer: layer in p['layers'] or '*.Cu' in p['layers']
        for t in trs:
            for e in ('a', 'b'):
                n = ('pt', key(t[e]), t['layer'])
                for p in pads:
                    if on_layer(p, t['layer']) and K.inside(t[e], K.pad_polygon(p)):
                        edge(n, ('pad', p['ref'], p['num']), math.dist(t[e], (p['x'], p['y'])), 'join')
                for v in vias:
                    if math.dist(t[e], (v['x'], v['y'])) <= v['d'] / 2:
                        edge(n, ('via', key((v['x'], v['y']))), math.dist(t[e], (v['x'], v['y'])), 'join')
        for z in self.B.zones:
            if z['net'] != net: continue
            for f in z['fills']:
                members = [(('pad', p['ref'], p['num']), (p['x'], p['y'])) for p in pads if on_layer(p, f['layer']) and K.inside((p['x'], p['y']), f['pts'])]
                members += [(('via', key((v['x'], v['y']))), (v['x'], v['y'])) for v in vias if K.inside((v['x'], v['y']), f['pts'])]
                members += [(('pt', key(t[e]), t['layer']), t[e]) for t in trs if t['layer'] == f['layer'] for e in ('a', 'b') if K.inside(t[e], f['pts'])]
                for i in range(len(members)):
                    for j in range(i + 1, len(members)):
                        edge(members[i][0], members[j][0], math.dist(members[i][1], members[j][1]), 'fill')
        return g

    @staticmethod
    def shortest(g, src, dst_pred):
        dist, prev, todo, seen = {src: 0.0}, {}, [src], set()
        while todo:
            u = min(todo, key=dist.get); todo.remove(u)
            if u in seen: continue
            seen.add(u)
            if dst_pred(u):
                path, kinds = [u], []
                while path[-1] in prev:
                    q, kind = prev[path[-1]]; kinds.append(kind); path.append(q)
                return dist[u], (path[::-1], kinds[::-1])
            for v, w, kind in g[u]:
                if dist[u] + w < dist.get(v, 1e18):
                    dist[v] = dist[u] + w; prev[v] = (u, kind); todo.append(v)
        return None, None

    # ---- decoupling: cap first, loops measured, never a silent None ---------------------------------------------
    def decoupling(self):
        rows = []
        for d in self.S.get('decoupling', []):
            ref, num = d['pin'].split('.')
            pin = self.B.pad(ref, num); net = pin['net']
            cap_pads = self.B.pads_of(d['cap']); cap = next((p for p in cap_pads if p['net'] == net), None)
            gnd = next((p for p in cap_pads if p['net'] != net), None)
            row = {'pin': d['pin'], 'cap': d['cap'], 'net': net}
            if cap is None:
                row['error'] = f"{d['cap']} has no pad on {net}"; rows.append(row); self.fail(row['error']); continue
            g = self.graph(net)
            length, pk = self.shortest(g, ('pad', ref, num), lambda u: u == ('pad', d['cap'], cap['num']))
            row['supplyPathMm'] = None if length is None else round(length, 3)
            # cap first: the pin reaches its cap over its own copper. A pour on the way, or a node where a third
            # route joins (a track end, pad or via with other routes), is a junction before the cap.
            junction = None
            if pk:
                path, kinds = pk
                if 'fill' in kinds:
                    junction = 'a pour'
                for u in path[1:-1]:
                    if junction: break
                    if len([1 for _, _, kd in g[u] if kd != 'fill']) >= 3:
                        junction = u[0] if u[0] != 'pt' else self.B.design(u[1])
            row['capFirst'] = pk is not None and junction is None
            row['junction'] = junction
            gg = self.graph(gnd['net'])
            sink, spk = self.shortest(gg, ('pad', d['cap'], gnd['num']), lambda u: u[0] == 'via' or (u[0] == 'pad' and u[1] in self.S.get('groundPins', [])))
            row['gndSinkMm'] = None if sink is None else round(sink, 3)
            row['gndSink'] = None if not spk else (spk[0][-1][0] if spk[0][-1][0] == 'via' else '.'.join(spk[0][-1][1:]))
            rows.append(row)
            if row['supplyPathMm'] is None: self.fail(f"decoupling {d['pin']} -> {d['cap']}: supply path not traced")
            elif not row['capFirst']: self.fail(f"decoupling {d['pin']} -> {d['cap']}: a junction ({row['junction']}) before the cap")
            if row['gndSinkMm'] is None: self.fail(f"decoupling {d['cap']}: ground sink not traced")
        self.res['decoupling'] = rows

    # ---- power stage ------------------------------------------------------------------------------------------
    def power_stage(self):
        ps = self.S.get('powerStage')
        if not ps: return
        vin, gndp = self.B.pad(ps['ic'], ps['vinPin']), self.B.pad(ps['ic'], ps['gndPin'])
        caps = {}
        for r in ps['inputCaps']:
            v = next(p for p in self.B.pads_of(r) if p['net'] == vin['net']); g = next(p for p in self.B.pads_of(r) if p['net'] == gndp['net'])
            P = lambda p: (p['x'], p['y'])
            caps[r] = {'vin_mm': round(math.dist(P(v), P(vin)), 3), 'gnd_mm': round(math.dist(P(g), P(gndp)), 3),
                       'loop_mm2': round(K.area([P(vin), P(v), P(g), P(gndp)]), 2)}
        best = min(caps, key=lambda r: max(caps[r]['vin_mm'], caps[r]['gnd_mm']))
        b = caps[best]
        lv = self.B.pad(ps['inductor'], ps['inductorOutPin'])
        outs = {r: next(p for p in self.B.pads_of(r) if p['net'] == lv['net']) for r in ps['outputCaps']}
        pts = {r: (p['x'], p['y']) for r, p in outs.items()}
        spread = max(math.dist(pts[a], pts[c]) for a in pts for c in pts)
        far = max(math.dist(p, (lv['x'], lv['y'])) for p in pts.values())
        self.res['power_stage'] = {'input': caps, 'best_input': best, 'output_spread_mm': round(spread, 3), 'output_to_inductor_mm': round(far, 3)}
        if max(b['vin_mm'], b['gnd_mm']) > self.T['input_pad_to_pin_mm']:
            self.fail(f"input loop: {best} pads {b['vin_mm']} / {b['gnd_mm']} mm from the VIN / GND pins (> {self.T['input_pad_to_pin_mm']})")
        if b['loop_mm2'] > self.T['input_loop_area_mm2']: self.fail(f"input loop area {b['loop_mm2']} mm2 (> {self.T['input_loop_area_mm2']})")
        if spread > self.T['output_cluster_spread_mm']: self.fail(f'output caps spread {spread:.2f} mm (> {self.T["output_cluster_spread_mm"]})')
        if far > self.T['output_to_inductor_mm']: self.fail(f'an output cap is {far:.2f} mm from the inductor (> {self.T["output_to_inductor_mm"]})')
        # plane under the hot loop
        hot = [vin, gndp] + [p for r in ps['inputCaps'] for p in self.B.pads_of(r)] + [self.B.pad(ps['ic'], pp['from'].split('.')[1])
                for pp in self.S.get('powerPaths', []) if pp['from'].startswith(ps['ic'] + '.')]
        m = self.T['hot_loop_margin_mm']
        xs = [p['x'] for p in hot]; ys = [p['y'] for p in hot]
        box = (min(xs) - m, min(ys) - m, max(xs) + m, max(ys) + m)
        ins = lambda q: box[0] <= q[0] <= box[2] and box[1] <= q[1] <= box[3]
        layer, pnet = ps['planeLayer'], ps['planeNet']
        intr = [(t['net'], 'track') for t in self.B.tracks if t['layer'] == layer and t['net'] != pnet and (ins(t['a']) or ins(t['b']))]
        intr += [(v['net'], 'via') for v in self.B.vias if v['net'] != pnet and ins((v['x'], v['y']))]
        intr += [(z['net'], 'fill') for z in self.B.zones if z['net'] != pnet for f in z['fills'] if f['layer'] == layer and any(ins(q) for q in f['pts'])]
        self.res['plane_under_hot_loop'] = {'layer': layer, 'intrusions': intr}
        if len(intr) > self.T['plane_intrusions']: self.fail(f'{len(intr)} non-{pnet} item(s) on {layer} under the hot loop')

    # ---- power paths: necks and meanders -----------------------------------------------------------------------
    def power_paths(self):
        rows = []
        for pp in self.S.get('powerPaths', []):
            fr, to = pp['from'].split('.'), pp['to'].split('.')
            a, b = self.B.pad(*fr), self.B.pad(*to)
            pad_side = min(a['w'], a['h'])
            net = pp['net']
            # the track route: shortest track-only path between the two pads (fills excluded, so a trace is judged as drawn)
            full = self.graph(net)
            g = defaultdict(list, {u: [(v, w, k) for v, w, k in es if k != 'fill'] for u, es in full.items()})
            length, pk = self.shortest(g, ('pad', *fr), lambda u: u == ('pad', *to))
            path = pk[0] if pk else None
            row = {'net': net, 'from': pp['from'], 'to': pp['to'], 'padNarrowSideMm': round(pad_side, 3),
                   'straightMm': round(math.dist((a['x'], a['y']), (b['x'], b['y'])), 3)}
            if path:
                pts = [u[1] for u in path if u[0] == 'pt']
                widths = []
                for t in self.B.tracks:
                    if t['net'] == net and key(t['a']) in pts and key(t['b']) in pts: widths.append(t['w'])
                heads = [unit(p, q) for p, q in zip(pts, pts[1:]) if math.dist(p, q) > 1e-6]
                turns = sum(1 for u, v in zip(heads, heads[1:]) if abs(u[0] * v[1] - u[1] * v[0]) > 1e-3 or u[0] * v[0] + u[1] * v[1] < 0)
                row.update(trackRouteMm=round(length, 3), minTrackMm=min(widths) if widths else None, bends=turns,
                           meanderRatio=round(length / row['straightMm'], 3) if row['straightMm'] else None)
            else:
                row.update(trackRouteMm=None, minTrackMm=None, bends=None, meanderRatio=None)
            row['copperNeckMm'] = self.copper_neck(net, a, b)
            rows.append(row)
            if row['minTrackMm'] is not None and row['minTrackMm'] < self.T['neck_track_vs_pad'] * pad_side:
                self.fail(f"{net}: the trace necks to {row['minTrackMm']} mm, narrower than the {pad_side:.2f} mm pin it leaves ({pp['from']})")
            if row['meanderRatio'] is not None and row['meanderRatio'] > self.T['meander_ratio']:
                self.fail(f"{net}: the trace wanders, {row['trackRouteMm']} mm routed for {row['straightMm']} mm straight (x{row['meanderRatio']})")
            if row['bends'] is not None and row['bends'] > self.T['path_bends']:
                self.fail(f"{net}: {row['bends']} bends between {pp['from']} and {pp['to']} (> {self.T['path_bends']})")
            if row['copperNeckMm'] is None:
                self.fail(f"{net}: no filled-copper path between {pp['from']} and {pp['to']} (refill the zones first)")
            elif row['copperNeckMm'] < self.T['neck_copper_vs_pad'] * pad_side:
                self.fail(f"{net}: the filled copper necks to {row['copperNeckMm']} mm, narrower than the {pad_side:.2f} mm pin ({pp['from']})")
        self.res['power_paths'] = rows

    def copper_neck(self, net, a, b):
        """Widest-path bottleneck of the net's copper on the pads' layer, outside the two terminal pads: the widest
        conductor you can push from pad a to pad b. Copper = tracks (capsules), pads and filled polygons of the net."""
        layer = 'F.Cu' if 'F.Cu' in a['layers'] or '*.Cu' in a['layers'] else 'B.Cu'
        r = self.T['raster_mm']
        trs = [t for t in self.straight if t['net'] == net and t['layer'] == layer]
        pads = [p for p in self.B.pads if p['net'] == net and (layer in p['layers'] or '*.Cu' in p['layers'])]
        fills = [f['pts'] for z in self.B.zones if z['net'] == net for f in z['fills'] if f['layer'] == layer]
        xs = [a['x'], b['x']] + [q[0] for t in trs for q in (t['a'], t['b'])] + [q[0] for f in fills for q in f]
        ys = [a['y'], b['y']] + [q[1] for t in trs for q in (t['a'], t['b'])] + [q[1] for f in fills for q in f]
        x0, y0 = min(xs) - 2, min(ys) - 2; nx, ny = int((max(xs) + 2 - x0) / r) + 1, int((max(ys) + 2 - y0) / r) + 1
        if nx * ny > 4_000_000: return None
        cu = bytearray(nx * ny); term = bytearray(nx * ny)
        def fill_poly(poly, mark):
            py0 = max(0, int((min(q[1] for q in poly) - y0) / r)); py1 = min(ny - 1, int((max(q[1] for q in poly) - y0) / r) + 1)
            for j in range(py0, py1 + 1):
                y = y0 + (j + 0.5) * r; xs_ = []
                for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1]):
                    if (y1 > y) != (y2 > y): xs_.append(x1 + (y - y1) * (x2 - x1) / (y2 - y1))
                xs_.sort()
                for k in range(0, len(xs_) - 1, 2):
                    i0 = max(0, int((xs_[k] - x0) / r)); i1 = min(nx - 1, int((xs_[k + 1] - x0) / r))
                    for i in range(i0, i1 + 1): mark[j * nx + i] = 1
        for f in fills: fill_poly(f, cu)
        for p in pads: fill_poly(K.pad_polygon(p), cu)
        for t in trs:
            hw = t['w'] / 2; (ax, ay), (bx, by) = t['a'], t['b']
            i0 = max(0, int((min(ax, bx) - hw - x0) / r)); i1 = min(nx - 1, int((max(ax, bx) + hw - x0) / r) + 1)
            j0 = max(0, int((min(ay, by) - hw - y0) / r)); j1 = min(ny - 1, int((max(ay, by) + hw - y0) / r) + 1)
            L = (bx - ax) ** 2 + (by - ay) ** 2
            for j in range(j0, j1 + 1):
                y = y0 + (j + 0.5) * r
                for i in range(i0, i1 + 1):
                    x = x0 + (i + 0.5) * r
                    s = 0 if L == 0 else max(0, min(1, ((x - ax) * (bx - ax) + (y - ay) * (by - ay)) / L))
                    if (x - ax - s * (bx - ax)) ** 2 + (y - ay - s * (by - ay)) ** 2 <= hw * hw: cu[j * nx + i] = 1
        fill_poly(K.pad_polygon(a), term); fill_poly(K.pad_polygon(b), term)
        # distance to the nearest non-copper cell (chamfer 5-7-11, about 2 % error), in mm
        INF = 1 << 30; d = [0 if not c else INF for c in cu]
        W = ((-1, -1, 7), (0, -1, 5), (1, -1, 7), (-1, 0, 5), (-2, -1, 11), (-1, -2, 11), (1, -2, 11), (2, -1, 11))
        for j in range(ny):
            for i in range(nx):
                k = j * nx + i
                if d[k]:
                    best = d[k]
                    for di, dj, w in W:
                        ii, jj = i + di, j + dj
                        v = d[jj * nx + ii] + w if 0 <= ii < nx and 0 <= jj < ny else w
                        if v < best: best = v
                    d[k] = best
        for j in range(ny - 1, -1, -1):
            for i in range(nx - 1, -1, -1):
                k = j * nx + i
                if d[k]:
                    best = d[k]
                    for di, dj, w in W:
                        ii, jj = i - di, j - dj
                        v = d[jj * nx + ii] + w if 0 <= ii < nx and 0 <= jj < ny else w
                        if v < best: best = v
                    d[k] = best
        width = [2 * v * r / 5 if v < INF else 0 for v in d]        # local conductor width ~ twice the distance
        # widest path: maximise the minimum width over cells outside the terminal pads
        import heapq
        starts = [k for k in range(nx * ny) if term[k] and cu[k] and K.inside((x0 + (k % nx + 0.5) * r, y0 + (k // nx + 0.5) * r), K.pad_polygon(a))]
        goal = set(k for k in range(nx * ny) if term[k] and K.inside((x0 + (k % nx + 0.5) * r, y0 + (k // nx + 0.5) * r), K.pad_polygon(b)))
        best = {}; h = []
        for k in starts: best[k] = 1e9; heapq.heappush(h, (-1e9, k))
        while h:
            negw, k = heapq.heappop(h); w = -negw
            if k in goal: return round(w if w < 1e8 else min(a['w'], a['h']), 3)
            if best.get(k, -1) > w: continue
            i, j = k % nx, k // nx
            for di, dj in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                ii, jj = i + di, j + dj
                if not (0 <= ii < nx and 0 <= jj < ny): continue
                kk = jj * nx + ii
                if not cu[kk]: continue
                nw = w if term[kk] else min(w, width[kk])
                if nw > best.get(kk, -1):
                    best[kk] = nw; heapq.heappush(h, (-nw, kk))
        return None

    def run(self, only=None):
        for name in ('grid', 'bends', 'inline_vias', 'decoupling', 'power_stage', 'power_paths'):
            if only and name not in only: continue
            getattr(self, name)()
        self.res['thresholds'] = self.T; self.res['failures'] = self.fails; self.res['passed'] = not self.fails
        return self.res


def main(argv):
    if len(argv) < 2:
        print(__doc__); return 2
    try:
        board, spec = K.Board(argv[0]), json.load(open(argv[1]))
    except Exception as e:
        print(f'ERROR: {e}'); return 2
    only = set(argv[argv.index('--only') + 1].split(',')) if '--only' in argv else None
    res = Gates(board, spec).run(only)
    if '--json' in argv:
        json.dump(res, open(argv[argv.index('--json') + 1], 'w'), indent=1, default=str)
    for k in ('grid', 'bends', 'inline_vias', 'power_stage', 'power_paths', 'decoupling', 'plane_under_hot_loop'):
        if k in res: print(k, json.dumps(res[k], default=str)[:400])
    print('PASS' if res['passed'] else 'FAIL:\n  ' + '\n  '.join(res['failures']))
    return 0 if res['passed'] else 1


if __name__ == '__main__':
    raise SystemExit(main(sys.argv[1:]))