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-connectivity.py

#!/usr/bin/env python3
"""Physical connectivity audit of the built board: are all lands of every net joined by copper?

tscircuit does not report unconnected ports when routing is disabled, so this rebuilds connectivity from the copper
itself: traces (each pcb_trace joins its start and end land, and all of its route points), through vias, and the
copper-pour islands (a pour island joins every same-net land, via and trace point that lies inside it). Lands are
grouped per net; any net whose lands fall into more than one island of copper is reported with the stray lands.
Usage: python3 scripts/check-connectivity.py [dist/index/circuit.json]
"""
import json, sys

path = sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith('--') else 'dist/index/circuit.json'
d = json.load(open(path))
src = {c['source_component_id']: c['name'] for c in d if c['type'] == 'source_component'}
pcomp = {c['pcb_component_id']: src.get(c['source_component_id']) for c in d if c['type'] == 'pcb_component'}
sports = {p['source_port_id']: p for p in d if p['type'] == 'source_port'}
pports = {p['pcb_port_id']: p for p in d if p['type'] == 'pcb_port'}
nets = {n['source_net_id']: n['name'] for n in d if n['type'] == 'source_net'}
key_name = {}
for t in d:
    if t['type'] == 'source_trace':
        names = [nets[n] for n in t.get('connected_source_net_ids', []) if n in nets]
        for pid in t.get('connected_source_port_ids', []):
            k = sports[pid].get('subcircuit_connectivity_map_key')
            if names and k and k not in key_name: key_name[k] = names[0]
connected_ports = {pid for t in d if t['type'] == 'source_trace' for pid in t.get('connected_source_port_ids', [])}

parent = {}
def find(a):
    parent.setdefault(a, a)
    while parent[a] != a:
        parent[a] = parent[parent[a]]; a = parent[a]
    return a
def union(a, b): parent[find(a)] = find(b)

LAYERS = ['top', 'inner1', 'inner2', 'bottom']
lands = []  # (node, net, name, layers, x, y, halfw, halfh)
for e in d:
    if e['type'] not in ('pcb_smtpad', 'pcb_plated_hole'): continue
    pp = pports.get(e.get('pcb_port_id')); sp = sports.get(pp['source_port_id']) if pp else None
    if not sp or sp['source_port_id'] not in connected_ports: continue
    key = sp.get('subcircuit_connectivity_map_key')
    node = ('land', e.get('pcb_smtpad_id') or e.get('pcb_plated_hole_id'))
    layers = [e['layer']] if e['type'] == 'pcb_smtpad' else LAYERS
    if e['type'] == 'pcb_smtpad' and e.get('shape') == 'circle': hw = hh = e['radius']
    elif e['type'] == 'pcb_smtpad': hw, hh = e['width'] / 2, e['height'] / 2
    else: hw = hh = (e.get('outer_diameter') or min(e.get('outer_width', 0), e.get('outer_height', 0))) / 2
    lands.append((node, key, f"{pcomp.get(e.get('pcb_component_id'))}.{sp.get('name')}", layers, e['x'], e['y'], hw, hh))
    union(node, ('port', e.get('pcb_port_id')))  # all lands of one port (paddle + its holes) are one conductor

# traces: one node per route point, joined only through copper that is actually drawn -- consecutive points joined
# by a segment (circuit-to-svg rule: the layer comes from a wire end; two via points in a row draw nothing unless
# they are stacked on the same spot), end points joined to their lands
points = []  # (node, key, layers, x, y)
port_layers, layer_mismatch = {}, []
for e in d:
    if e['type'] in ('pcb_smtpad', 'pcb_plated_hole') and e.get('pcb_port_id'):
        port_layers.setdefault(e['pcb_port_id'], set()).update([e['layer']] if e['type'] == 'pcb_smtpad' else LAYERS)
st = {t['source_trace_id']: t for t in d if t['type'] == 'source_trace'}
def trace_key(t):
    s = st.get(t.get('source_trace_id'))
    for pid in (s or {}).get('connected_source_port_ids', []):
        return sports[pid].get('subcircuit_connectivity_map_key')
    return None
for t in d:
    if t['type'] != 'pcb_trace': continue
    key = trace_key(t); r = t['route']
    for i, p in enumerate(r):
        node = ('tp', t['pcb_trace_id'], i)
        for k in ('start_pcb_port_id', 'end_pcb_port_id'):
            if not p.get(k): continue
            if p['route_type'] == 'wire' and port_layers.get(p[k]) and p['layer'] not in port_layers[p[k]]:
                layer_mismatch.append(f"{t['pcb_trace_id']} ends on {p['layer']} at a {'/'.join(sorted(port_layers[p[k]]))} land"); continue
            union(node, ('port', p[k]))
        if p['route_type'] == 'via': points.append((node, key, LAYERS, p['x'], p['y']))
        else: points.append((node, key, [p['layer']], p['x'], p['y']))
    for i, (a, b) in enumerate(zip(r, r[1:])):
        drawn = a['route_type'] == 'wire' or b['route_type'] == 'wire'
        stacked = abs(a['x'] - b['x']) < 1e-6 and abs(a['y'] - b['y']) < 1e-6
        if drawn or stacked: union(('tp', t['pcb_trace_id'], i), ('tp', t['pcb_trace_id'], i + 1))
for v in d:
    if v['type'] == 'pcb_via':  # a trace's via carries the trace's net key; a pour must never adopt a via of another net
        points.append((('via', v['pcb_via_id']), v.get('subcircuit_connectivity_map_key'), LAYERS, v['x'], v['y']))

def inside(poly, x, y):
    c = 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: c = not c
    return c
pour_key = {}
for p in d:
    if p['type'] != 'pcb_copper_pour': continue
    net = nets.get(p.get('source_net_id'))
    key = next((k for k, n in key_name.items() if n == net), None)
    node = ('pour', p['pcb_copper_pour_id'])
    b = p['brep_shape']
    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 (ln, lk, name, layers, x, y, hw, hh) in lands:
        if lk == key and p['layer'] in layers and in_pour(x, y): union(node, ln)
    for (pn, pk, layers, x, y) in points:
        if pk == key and p['layer'] in layers and in_pour(x, y): union(node, pn)

# vias (standalone) join the trace that created them: a trace via point and a pcb_via at the same spot are one barrel
via_at = {}
for (pn, pk, layers, x, y) in points:
    if layers == LAYERS: via_at.setdefault((round(x, 3), round(y, 3)), []).append(pn)
for group in via_at.values():
    for n in group[1:]: union(group[0], n)
# a via barrel also joins any same-net trace whose copper passes over it (a route point within the via pad)
for (vx, vy), group in via_at.items():
    for (pn, pk, layers, x, y) in points:
        if (x - vx) ** 2 + (y - vy) ** 2 <= 0.2 ** 2:
            vk = next((k for (n2, k, l2, x2, y2) in points if n2 == group[0] and k), None)
            if pk is None or vk is None or pk == vk: union(group[0], pn)

by_net = {}
for (ln, lk, name, *_rest) in lands: by_net.setdefault(lk, []).append((find(ln), name))
bad = 0
for key, items in sorted(by_net.items(), key=lambda kv: key_name.get(kv[0], str(kv[0]))):
    groups = {}
    for root, name in items: groups.setdefault(root, []).append(name)
    if len(groups) > 1:
        bad += 1
        main = max(groups.values(), key=len)
        strays = [n for g in groups.values() if g is not main for n in g]
        print(f"{key_name.get(key, key)}: {len(groups)} islands; stray lands: {strays[:12]}{' ...' if len(strays) > 12 else ''}")
for m in layer_mismatch: print('layer mismatch:', m)
print(f"connectivity: {len(by_net) - bad}/{len(by_net)} nets fully joined")
sys.exit(1 if bad else 0)