pixalynx/nrf9151-gps-tracker

A compact 44×32 mm, 4-layer nRF9151 LTE-M/NB-IoT/GNSS tracker PCB with nPM1300 LiPo/USB-C power, nano-SIM/eSIM switching, nRF7002 Wi‑Fi scanning, accelerometer, antennas, buzzer, LEDs, SOS button, and SWD/debug connectors.

Version
0.1.4
License
unset
Stars
0

scripts/check-clearance.py

#!/usr/bin/env python3
"""Independent copper clearance audit for a built board (tscircuit's checker does not flag every trace-to-pad case).
Reports, per layer: trace/via copper closer than MIN to a land, via or trace of a different net, and copper closer
than EDGE to the board outline. Usage: python3 scripts/check-clearance.py dist/tracker/circuit.json [--json out]
"""
import json, sys, math
MIN, EDGE = 0.10, 0.30
path = sys.argv[1]
c = json.load(open(path))
board = next(e for e in c if e['type'] == 'pcb_board')
bx0, by0 = board['center']['x'] - board['width'] / 2, board['center']['y'] - board['height'] / 2
bx1, by1 = board['center']['x'] + board['width'] / 2, board['center']['y'] + board['height'] / 2
LAYERS = ['top', 'inner1', 'inner2', 'bottom']
ports = {p['pcb_port_id']: p for p in c if p['type'] == 'pcb_port'}
sports = {p['source_port_id']: p for p in c if p['type'] == 'source_port'}
sc = {e['source_component_id']: e['name'] for e in c if e['type'] == 'source_component'}
st = {e['source_trace_id']: e for e in c if e['type'] == 'source_trace'}
def port_net(pid):
    p = ports.get(pid); sp = sports.get(p['source_port_id']) if p else None
    return sp.get('subcircuit_connectivity_map_key') if sp else None
def port_name(pid):
    p = ports.get(pid); sp = sports.get(p['source_port_id']) if p else None
    return f"{sc.get(sp['source_component_id'])}.{sp.get('name')}" if sp else pid
# lands: (layer set, geometry, net, name)
lands = []
for e in c:
    if e['type'] == 'pcb_smtpad':
        g = ('circle', e['x'], e['y'], e['radius']) if e['shape'] == 'circle' else ('rect', e['x'], e['y'], e['width'], e['height'], e.get('ccw_rotation') or 0)
        lands.append(({e['layer']}, g, port_net(e.get('pcb_port_id')), port_name(e.get('pcb_port_id'))))
    elif e['type'] == 'pcb_plated_hole':
        if e.get('outer_diameter'): g = ('circle', e['x'], e['y'], e['outer_diameter'] / 2)
        else: g = ('rect', e['x'], e['y'], e.get('outer_width') or e.get('hole_width'), e.get('outer_height') or e.get('hole_height'), e.get('ccw_rotation') or 0)
        lands.append((set(LAYERS), g, port_net(e.get('pcb_port_id')), port_name(e.get('pcb_port_id'))))
    elif e['type'] == 'pcb_hole':
        d = e.get('hole_diameter') or max(e.get('hole_width', 0), e.get('hole_height', 0))
        lands.append((set(LAYERS), ('circle', e['x'], e['y'], d / 2), 'HOLE', 'hole'))
def pt_land(px, py, g):
    if g[0] == 'circle': return max(0.0, math.hypot(px - g[1], py - g[2]) - g[3])
    _, x, y, w, h, rot = g; r = -math.radians(rot); dx, dy = px - x, py - y
    rx, ry = dx * math.cos(r) - dy * math.sin(r), dx * math.sin(r) + dy * math.cos(r)
    return math.hypot(max(abs(rx) - w / 2, 0), max(abs(ry) - h / 2, 0))
def seg_land(a, b, g, n=10):
    return min(pt_land(a[0] + (b[0] - a[0]) * i / n, a[1] + (b[1] - a[1]) * i / n, g) for i in range(n + 1))
def seg_seg(a, b, u, v):
    def sp(p, a, b):
        vx, vy = b[0] - a[0], b[1] - a[1]; l2 = vx * vx + vy * vy
        t = 0 if l2 == 0 else max(0, min(1, ((p[0] - a[0]) * vx + (p[1] - a[1]) * vy) / l2))
        return math.hypot(p[0] - a[0] - t * vx, p[1] - a[1] - t * vy)
    def cross(o, a, b): return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
    d1, d2, d3, d4 = cross(u, v, a), cross(u, v, b), cross(a, b, u), cross(a, b, v)
    if ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0)) and d1 != 0 and d2 != 0: return 0.0
    return min(sp(a, u, v), sp(b, u, v), sp(u, a, b), sp(v, a, b))
segs, vias = [], []
for t in c:
    if t['type'] != 'pcb_trace': continue
    s = st.get(t.get('source_trace_id'), {}); net = s.get('subcircuit_connectivity_map_key'); name = s.get('name') or t['pcb_trace_id']
    for p in t['route']:
        if p['route_type'] == 'via': vias.append((p['x'], p['y'], p.get('via_diameter') or 0.4, net, name))
    # same rule as circuit-to-svg getPcbTraceSegments: every consecutive pair is copper, including wire->via and
    # via->wire (layer from the wire end), so a pad-to-via stub is checked even without a wire point on the via
    for a, b in zip(t['route'], t['route'][1:]):
        layer = a.get('layer') if a['route_type'] == 'wire' else b.get('layer') if b['route_type'] == 'wire' else None
        if not layer or (abs(a['x'] - b['x']) < 1e-9 and abs(a['y'] - b['y']) < 1e-9): continue
        w = (a.get('width') if a['route_type'] == 'wire' else None) or b.get('width') or 0.15
        segs.append((layer, (a['x'], a['y']), (b['x'], b['y']), w, net, name))
for v in c:
    if v['type'] == 'pcb_via':
        if not any(abs(v['x'] - x) < 1e-6 and abs(v['y'] - y) < 1e-6 for x, y, *_ in vias): vias.append((v['x'], v['y'], v.get('outer_diameter') or 0.4, None, v['pcb_via_id']))
viol = []
for layer, a, b, w, net, name in segs:
    for ls, g, lnet, lname in lands:
        if layer not in ls or (net and lnet and net == lnet): continue
        d = seg_land(a, b, g) - w / 2
        if d < MIN - 1e-6: viol.append((round(d, 3), layer, 'trace-land', name, lname))
    for x, y, dia, vnet, vname in vias:
        if net and vnet and net == vnet: continue
        d = seg_seg(a, b, (x, y), (x, y)) - w / 2 - dia / 2
        if d < MIN - 1e-6: viol.append((round(d, 3), layer, 'trace-via', name, vname))
    e = min(a[0] - bx0, bx1 - a[0], a[1] - by0, by1 - a[1], b[0] - bx0, bx1 - b[0], b[1] - by0, by1 - b[1]) - w / 2
    if e < EDGE - 1e-6: viol.append((round(e, 3), layer, 'trace-edge', name, 'board edge'))
for i, (layer, a, b, w, net, name) in enumerate(segs):
    for layer2, u, v, w2, net2, name2 in segs[i + 1:]:
        if layer != layer2 or (net and net2 and net == net2) or name == name2: continue
        if max(abs(a[0] - u[0]), abs(a[1] - u[1])) > 6: continue
        d = seg_seg(a, b, u, v) - w / 2 - w2 / 2
        if d < MIN - 1e-6: viol.append((round(d, 3), layer, 'trace-trace', name, name2))
for x, y, dia, vnet, vname in vias:
    for ls, g, lnet, lname in lands:
        if vnet and lnet and vnet == lnet: continue
        d = pt_land(x, y, g) - dia / 2
        if d < MIN - 1e-6: viol.append((round(d, 3), 'all', 'via-land', vname, lname))
    e = min(x - bx0, bx1 - x, y - by0, by1 - y) - dia / 2
    if e < EDGE - 1e-6: viol.append((round(e, 3), 'all', 'via-edge', vname, 'board edge'))
for i, (x, y, dia, vnet, vname) in enumerate(vias):
    for x2, y2, dia2, vnet2, vname2 in vias[i + 1:]:
        if vnet and vnet2 and vnet == vnet2: continue
        d = math.hypot(x - x2, y - y2) - dia / 2 - dia2 / 2
        if d < MIN - 1e-6: viol.append((round(d, 3), 'all', 'via-via', vname, vname2))
viol.sort()
# copper inside another net's pour: the pour solver only clears what it knows about (a via spanning fewer layers than
# the drill, copper added after the pour) -- a via centre, land centre or trace sample inside foreign pour copper is a short
def _inside(poly, x, y):
    cin = False; n = len(poly)
    for i in range(n):
        x1, y1 = poly[i]['x'], poly[i]['y']; x2, y2 = poly[(i + 1) % n]['x'], poly[(i + 1) % n]['y']
        if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1: cin = not cin
    return cin
_src_net = {n['source_net_id']: n['name'] for n in c if n['type'] == 'source_net'}
_net_key = {}
for t in c:
    if t['type'] == 'source_trace':
        for nid in t.get('connected_source_net_ids', []):
            for pid in t.get('connected_source_port_ids', []):
                k = sports[pid].get('subcircuit_connectivity_map_key') if pid in sports else None
                if k: _net_key.setdefault(_src_net.get(nid), k)
for pour in (e for e in c if e['type'] == 'pcb_copper_pour'):
    pkey = _net_key.get(_src_net.get(pour.get('source_net_id'))); b = pour['brep_shape']; L = pour['layer']
    def in_pour(x, y): return _inside(b['outer_ring']['vertices'], x, y) and not any(_inside(r['vertices'], x, y) for r in b.get('inner_rings', []))
    for x, y, dia, vnet, vname in vias:  # vias are drilled through every layer
        if vnet != pkey and in_pour(x, y): viol.append((-dia / 2, L, 'via-in-pour', vname, f'{_src_net.get(pour.get("source_net_id"))} pour'))
    for layer, a, b_, w, net, name in segs:
        if layer != L or net == pkey: continue
        n = max(1, int(math.hypot(b_[0] - a[0], b_[1] - a[1]) / 0.2))
        if any(in_pour(a[0] + (b_[0] - a[0]) * i / n, a[1] + (b_[1] - a[1]) * i / n) for i in range(n + 1)):
            viol.append((-w / 2, L, 'trace-in-pour', name, f'{_src_net.get(pour.get("source_net_id"))} pour'))
# Deliberate via-in-pad lands (board edge / 0201 density leaves no other spot). They need epoxy-filled, capped vias
# (JLC "via in pad" / POFV) at fabrication; see docs/design.md. Reported as notes, not findings.
ACCEPTED_VIA_IN_PAD = {'C20.pin2', 'C22.pin2', 'R6.pin2', 'X1.X1', 'C52.pin1'}
accepted = []
# via drilled into an SMD land of any net (solder wicks down an untented via-in-pad); footprint thermal vias are
# plated holes, not pcb_vias, so only routed / stub vias are tested here
for v in (e for e in c if e['type'] == 'pcb_via'):
    for e in c:
        if e['type'] != 'pcb_smtpad': continue
        if e.get('shape') == 'circle': inside_pad = math.hypot(v['x'] - e['x'], v['y'] - e['y']) < e['radius']
        else:
            r_ = -math.radians(e.get('ccw_rotation') or 0); dx, dy = v['x'] - e['x'], v['y'] - e['y']
            rx, ry = dx * math.cos(r_) - dy * math.sin(r_), dx * math.sin(r_) + dy * math.cos(r_)
            inside_pad = abs(rx) < e['width'] / 2 and abs(ry) < e['height'] / 2
        if inside_pad:
            land = port_name(e.get('pcb_port_id')) or e['pcb_smtpad_id']
            (accepted if land in ACCEPTED_VIA_IN_PAD else viol).append((0.0, e['layer'], 'via-in-pad', v['pcb_via_id'], land))
# drill to drill, any net (JLC: >= 0.2 mm hole edge to hole edge); stacked vias on one spot are one barrel
drills = [(v['x'], v['y'], v.get('hole_diameter') or 0.2, v['pcb_via_id']) for v in c if v['type'] == 'pcb_via']
drills += [(h['x'], h['y'], h.get('hole_diameter') or min(h.get('hole_width', 0), h.get('hole_height', 0)), h.get('pcb_plated_hole_id') or h.get('pcb_hole_id'))
           for h in c if h['type'] in ('pcb_plated_hole', 'pcb_hole')]
for i, (x, y, dh, a) in enumerate(drills):
    for x2, y2, dh2, b in drills[i + 1:]:
        dd = math.hypot(x - x2, y - y2)
        if 1e-3 < dd and dd - dh / 2 - dh2 / 2 < 0.2 - 1e-6: viol.append((round(dd - dh / 2 - dh2 / 2, 3), 'all', 'drill-drill', a, b))
print(f"{len(segs)} segments, {len(vias)} vias, {len(lands)} lands; {len(viol)} clearance findings below {MIN} mm (edge {EDGE} mm)")
for v in viol[:40]: print('  ', v)
if accepted: print(f"note: {len({a[4] for a in accepted})} accepted via-in-pad lands (fill + cap at fab): {sorted({a[4] for a in accepted})}")
if '--json' in sys.argv: json.dump(viol, open(sys.argv[sys.argv.index('--json') + 1], 'w'), indent=1)
sys.exit(1 if viol else 0)