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/import-router-session.py

#!/usr/bin/env python3
"""Import a Freerouting Specctra session (SES) as explicit tscircuit copper legs.

The router returns loose wire fragments and vias per net. tscircuit renders copper as `<trace from to pcbPath>`
legs that must start and end on lands, so the fragments are stitched into a graph (T-junctions split, vias join
layers, the inner1 ground plane is a node) and decomposed into land-to-land legs (shortest graph path from every
land to the nearest already-served land) or, for ground, land-to-plane stubs ending in a via. Shared trunk copper
is repeated in each leg (same-net overlap, electrically identical).

Usage: python3 scripts/import-router-session.py tracker [--ses .tmp/router/tracker.ses]
Reads dist/<board>/circuit.json (the export build), .tmp/router/<board>-pad-map.json, the SES; writes
routing/<board>-routes.json rendered by lib/routed-traces.tsx.
"""
import json, sys, math, heapq, hashlib, re
board_key = sys.argv[1] if len(sys.argv) > 1 else 'tracker'
ses_path = sys.argv[sys.argv.index('--ses') + 1] if '--ses' in sys.argv else f'.tmp/router/{board_key}.ses'
LAYERS = ['top', 'inner1', 'inner2', 'bottom']
PLANE = 'inner1'
GND = 'GND'

# ---------------------------------------------------------------- S-expression parser
def parse(text):
    tokens = re.findall(r'\(|\)|"[^"]*"|[^\s()"]+', text)
    pos = 0
    def node():
        nonlocal pos
        tok = tokens[pos]; pos += 1
        if tok == '(':
            out = []
            while tokens[pos] != ')': out.append(node())
            pos += 1
            return out
        return tok.strip('"')
    return node()
def find(node, head):
    return [n for n in node if isinstance(n, list) and n and n[0] == head]
ses = parse(open(ses_path).read())
routes = find(ses, 'routes')[0]
res = find(routes, 'resolution')[0]
assert res[1] == 'um', res
scale = 1 / (1000 * float(res[2]))                     # SES units -> mm
placement = find(ses, 'placement')[0]
pres = find(placement, 'resolution')[0]; pscale = 1 / (1000 * float(pres[2]))

pad_map = json.load(open(f'.tmp/router/{board_key}-pad-map.json'))
raw = open(f'dist/{board_key}/circuit.json', 'rb').read()
if hashlib.sha256(raw).hexdigest() != pad_map['source_sha256']:
    raise SystemExit('dist/%s/circuit.json changed since the DSN export; re-export and re-route' % board_key)
c = json.loads(raw)
lands = pad_map['lands']
# the session must echo every land exactly where it was exported
placed = {}
for comp in find(placement, 'component'):
    for pl in find(comp, 'place'): placed[pl[1]] = (float(pl[2]) * pscale, float(pl[3]) * pscale, pl[4], pl[5])
for l in lands:
    p = placed.get(l['pin'])
    assert p and abs(p[0] - l['x']) < 1e-3 and abs(p[1] - l['y']) < 1e-3 and p[2] == 'front', ('land moved', l['pin'], p)

# ---------------------------------------------------------------- land geometry / component frames
def inside(l, x, y, tol=0.002):
    g = l['geo']; dx, dy = x - l['x'], y - l['y']
    if g['shape'] == 'circle': return math.hypot(dx, dy) <= g['d'] / 2 + tol
    r = -math.radians(g['rot']); rx, ry = dx * math.cos(r) - dy * math.sin(r), dx * math.sin(r) + dy * math.cos(r)
    return abs(rx) <= g['w'] / 2 + tol and abs(ry) <= g['h'] / 2 + tol
pcb_comps = {e['pcb_component_id']: e for e in c if e['type'] == 'pcb_component'}
land_comp = {}
for e in c:
    if e['type'] in ('pcb_smtpad', 'pcb_plated_hole'):
        land_comp[e.get('pcb_smtpad_id') or e.get('pcb_plated_hole_id')] = e.get('pcb_component_id')
# fixed copper that the router may echo
fixed_segs, fixed_vias = [], []
for t in c:
    if t['type'] != 'pcb_trace': continue
    for pt in t['route']:
        if pt['route_type'] == 'via': fixed_vias.append((pt['x'], pt['y']))
    for a, b in zip(t['route'], t['route'][1:]):  # wire->via and via->wire pairs are copper too
        layer = a.get('layer') if a['route_type'] == 'wire' else b.get('layer') if b['route_type'] == 'wire' else None
        if layer and math.hypot(a['x'] - b['x'], a['y'] - b['y']) > 1e-6: fixed_segs.append((layer, (a['x'], a['y']), (b['x'], b['y'])))
# anchors: a via of a fixed (hand) trace that is joined by a straight segment to the trace's end land. The router may
# finish a connection on such a via instead of on a land; the leg then ends there with a via onto the land's layer and
# tscircuit draws the last straight hop to the land, on top of the fixed trace's own copper.
land_by_port = {l['port']: l for l in lands}
land_by_port_ref = {l['ref']: l for l in lands}
anchors = []  # {net, x, y, layer, ref, trace_ports}
for t in c:
    if t['type'] != 'pcb_trace': continue
    r = t['route']
    ends = [(r[0].get('start_pcb_port_id'), r[0], r[1:]), (r[-1].get('end_pcb_port_id'), r[-1], r[-2::-1])]
    trace_ports = {pid for pid, _, _ in ends if pid in land_by_port}
    for pid, end, rest in ends:
        l = land_by_port.get(pid)
        if not l or not l['net'] or end.get('route_type') != 'wire': continue
        for j, q in enumerate(rest):  # the first via, reached straight from the land (wire points on the land or via skipped)
            if q['route_type'] == 'via':
                anchors.append({'net': l['net'], 'x': q['x'], 'y': q['y'], 'layer': end['layer'], 'ref': l['ref'], 'trace_ports': trace_ports})
                break
            nxt = rest[j + 1] if j + 1 < len(rest) else None
            on_land = abs(q['x'] - end['x']) < 1e-6 and abs(q['y'] - end['y']) < 1e-6
            on_via = nxt and nxt['route_type'] == 'via' and abs(nxt['x'] - q['x']) < 1e-6 and abs(nxt['y'] - q['y']) < 1e-6
            if not (on_land or on_via): break
# fixed copper per net: the router may T-join a fixed (hand) trace anywhere along it, so its drawn segments and vias
# go into the stitching graph (width 0 = never sets a leg width). Lands joined by one fixed trace form a cluster that
# counts as connected already.
fixed_by_net = {}
for t in c:
    if t['type'] != 'pcb_trace': continue
    r = t['route']
    tports = [p_.get(k) for p_ in r for k in ('start_pcb_port_id', 'end_pcb_port_id') if p_.get(k) in land_by_port]
    if not tports or not land_by_port[tports[0]]['net']: continue
    f = fixed_by_net.setdefault(land_by_port[tports[0]]['net'], {'segs': [], 'vias': [], 'traces': []})
    f['traces'].append(set(tports))
    for a, b in zip(r, r[1:]):
        layer = a.get('layer') if a['route_type'] == 'wire' else b.get('layer') if b['route_type'] == 'wire' else None
        if layer and math.hypot(a['x'] - b['x'], a['y'] - b['y']) > 1e-6: f['segs'].append((layer, (a['x'], a['y']), (b['x'], b['y'])))
    for p_ in r:
        if p_['route_type'] == 'via' and (p_['x'], p_['y']) not in f['vias']: f['vias'].append((p_['x'], p_['y']))
def on_seg(p, a, b, tol=0.003):
    vx, vy = b[0] - a[0], b[1] - a[1]; L = math.hypot(vx, vy) or 1e-9
    t = ((p[0] - a[0]) * vx + (p[1] - a[1]) * vy) / (L * L)
    if t < -tol / L or t > 1 + tol / L: return False
    return abs((p[0] - a[0]) * vy - (p[1] - a[1]) * vx) / L <= tol

# ---------------------------------------------------------------- per-net stitching
legs, report = [], {'nets': {}, 'dropped_fixed_wires': 0, 'dropped_fixed_vias': 0, 'floating_fragments': 0, 'uncovered_lands': []}
net_lands = {}
for l in lands:
    if l['net']: net_lands.setdefault(l['net'], []).append(l)
network = find(routes, 'network_out')[0]
for net_node in find(network, 'net'):
    net = net_node[1]
    wires, vias = [], []
    for w in find(net_node, 'wire'):
        path = find(w, 'path')[0]
        layer, width = path[1], float(path[2]) * scale
        pts = [(float(path[i]) * scale, float(path[i + 1]) * scale) for i in range(3, len(path), 2)]
        if all(any(s[0] == layer and on_seg(a, s[1], s[2]) and on_seg(b, s[1], s[2]) for s in fixed_segs) for a, b in zip(pts, pts[1:])):
            report['dropped_fixed_wires'] += 1; continue
        wires.append((layer, width, pts))
    for v in find(net_node, 'via'):
        x, y = float(v[2]) * scale, float(v[3]) * scale
        if any(math.hypot(x - fx, y - fy) < 0.002 for fx, fy in fixed_vias): report['dropped_fixed_vias'] += 1; continue
        vias.append((x, y))
    fixed = fixed_by_net.get(net) if net != GND else None
    if not wires and not vias: continue
    if fixed:
        wires.extend((layer, 0.0, [a, b]) for layer, a, b in fixed['segs'])
        vias.extend(v for v in fixed['vias'] if not any(math.hypot(v[0] - x, v[1] - y) < 0.002 for x, y in vias))
    fc_parent = {}
    def fc_find(a):
        fc_parent.setdefault(a, a)
        while fc_parent[a] != a: a = fc_parent[a]
        return a
    for tp in (fixed or {}).get('traces', []):
        tp = sorted(tp)
        for p_ in tp[1:]: fc_parent[fc_find(p_)] = fc_find(tp[0])
        fc_find(tp[0])
    mylands = net_lands.get(net, [])
    # graph: vertices (layer, x, y); edges with length; T-junction splitting
    key = lambda layer, p: (layer, round(p[0], 4), round(p[1], 4))
    segs = []  # [layer, a, b, width]
    for layer, width, pts in wires:
        for a, b in zip(pts, pts[1:]):
            if math.hypot(a[0] - b[0], a[1] - b[1]) > 1e-6: segs.append([layer, a, b, width])
    endpoints = {(layer, pts[0]) for layer, _, pts in wires} | {(layer, pts[-1]) for layer, _, pts in wires}
    for vx, vy in vias:
        for layer in LAYERS: endpoints.add((layer, (vx, vy)))
    # split every segment at the endpoints / via positions that lie strictly inside it (one pass)
    by_layer = {}
    for el, e in endpoints: by_layer.setdefault(el, []).append(e)
    split = []
    for layer, a, b, width in segs:
        inner = [e for e in by_layer.get(layer, []) if key(layer, e) not in (key(layer, a), key(layer, b)) and on_seg(e, a, b)]
        inner.sort(key=lambda e: (e[0] - a[0]) ** 2 + (e[1] - a[1]) ** 2)
        pts = [a] + inner + [b]
        for u, v in zip(pts, pts[1:]):
            if key(layer, u) != key(layer, v): split.append([layer, u, v, width])
    segs = split
    adj = {}
    def link(u, v, w):
        adj.setdefault(u, []).append((v, w)); adj.setdefault(v, []).append((u, w))
    for layer, a, b, width in segs: link(key(layer, a), key(layer, b), math.hypot(a[0] - b[0], a[1] - b[1]))
    via_nodes = {}
    for vx, vy in vias:
        vn = ('via', round(vx, 4), round(vy, 4)); via_nodes[vn] = (vx, vy)
        for layer in LAYERS:
            k = key(layer, (vx, vy))
            if k in adj or layer == PLANE: link(vn, k, 0.0)
        if net == GND: link(key(PLANE, (vx, vy)), ('plane',), 0.0)
    land_nodes = {}
    for l in mylands:
        ln = ('land', l['pin']); land_nodes[ln] = l
        for k in list(adj):
            if k[0] in l['layers'] and inside(l, k[1], k[2]): link(ln, k, 0.0)
    if net != GND:
        for i, a in enumerate(x for x in anchors if x['net'] == net):
            an = ('land', f"anchor{i}@{a['ref']}"); hit = False
            for k in list(adj):
                if k[0] in LAYERS and math.hypot(k[1] - a['x'], k[2] - a['y']) < 0.002: link(an, k, 0.0); hit = True
            if hit: land_nodes[an] = {**land_by_port_ref[a['ref']], 'anchor': a}
    # shortest paths between terminals through wire vertices / vias only
    def dijkstra(src, allowed_targets):
        dist, prev, heap = {src: 0.0}, {}, [(0.0, src)]
        while heap:
            d, u = heapq.heappop(heap)
            if d > dist.get(u, 1e18): continue
            if u != src and u in allowed_targets: return d, u, prev
            if u != src and (u[0] in ('land', 'plane')): continue   # terminals are not pass-through
            for v, w in adj.get(u, []):
                nd = d + w
                if nd < dist.get(v, 1e18): dist[v] = nd; prev[v] = u; heapq.heappush(heap, (nd, v))
        return None
    def walk(prev, src, dst):
        path, u = [dst], dst
        while u != src: u = prev[u]; path.append(u)
        return path[::-1]
    def to_pcb_path(nodes):
        out, layer = [], None
        for n in nodes:
            if n[0] == 'via' or n[0] in ('land', 'plane'): continue
            if layer is None: layer = n[0]
            if n[0] != layer:
                out.append({'x': out[-1]['x'], 'y': out[-1]['y'], 'via': True, 'toLayer': n[0]}); layer = n[0]
                if abs(out[-1]['x'] - n[1]) > 1e-6 or abs(out[-1]['y'] - n[2]) > 1e-6: out.append({'x': n[1], 'y': n[2]})
                continue
            if out and abs(out[-1]['x'] - n[1]) < 1e-6 and abs(out[-1]['y'] - n[2]) < 1e-6 and not out[-1].get('via'): continue
            out.append({'x': n[1], 'y': n[2]})
        return out
    def leg_width(nodes):
        ws = [w for layer, a, b, w in segs for n in nodes if w > 0 and n[0] == layer and key(layer, a) == n]
        return round(max(ws) if ws else 0.15, 3)
    served = set()
    net_report = {'lands': len(mylands), 'legs': 0, 'stubs': 0, 'unrouted': []}
    # components
    seen, comps = set(), []
    for start in list(adj):
        if start in seen: continue
        comp, stack = set(), [start]
        while stack:
            u = stack.pop()
            if u in comp: continue
            comp.add(u); stack.extend(v for v, _ in adj.get(u, []))
        seen |= comp; comps.append(comp)
    for comp in comps:
        clands = [n for n in comp if n[0] == 'land']
        if not clands: report['floating_fragments'] += 1; continue
        targets = set(clands[1:]) if len(clands) > 1 else set()
        order = [clands[0]]; pending = set(clands[1:])
        if net == GND and ('plane',) in comp:
            # every ground land gets its own stub to the plane when it can reach a via directly
            for ln in clands:
                r = dijkstra(ln, {('plane',)})
                if r:
                    nodes = walk(r[2], ln, r[1]); pth = to_pcb_path(nodes)
                    if pth and pth[-1].get('via') and pth[-1]['toLayer'] == PLANE: pth = pth[:-1]
                    pth.append({'x': pth[-1]['x'], 'y': pth[-1]['y'], 'via': True, 'toLayer': PLANE}) if pth else None
                    if not pth:  # via sits inside the land itself
                        l = land_nodes[ln]; pth = [{'x': r[1][1] if False else l['x'], 'y': l['y'], 'via': True, 'toLayer': PLANE}]
                    legs.append({'net': net, 'from': land_nodes[ln]['ref'], 'to': f'net.{GND}', 'width': leg_width(nodes), 'pcb_land': land_nodes[ln]['land'], 'path': pth})
                    served.add(ln); net_report['stubs'] += 1
            pending = {ln for ln in clands if ln not in served}
            if not pending: continue
            order = [next(iter(served & set(clands)))] if served & set(clands) else [pending.pop()]
        def cluster_of(n):
            ld = land_nodes[n]
            for p_ in (ld['anchor']['trace_ports'] if ld.get('anchor') else {ld['port']}):
                if p_ in fc_parent: return fc_find(p_)
            return None
        def absorb(n):  # n is connected now, and so is every land a fixed trace already joins to it
            served.add(n); pending.discard(n); cid = cluster_of(n)
            if cid is None: return
            for m in clands:
                if m not in served and cluster_of(m) == cid: served.add(m); pending.discard(m)
        pending = {n for n in clands if n not in served}
        if served & set(clands):
            for n in list(served & set(clands)): absorb(n)
        else:
            absorb(next((n for n in clands if land_nodes[n].get('anchor')), None) or next((n for n in clands if cluster_of(n) is not None), None) or order[0])
        while pending:
            best = None
            for ln in pending:
                r = dijkstra(ln, served & set(clands))
                if r and (best is None or r[0] < best[0]): best = (r[0], ln, r[1], r[2])
            if not best:
                net_report['unrouted'].extend(land_nodes[ln]['ref'] for ln in pending); break
            d, ln, dst, prev = best
            nodes = walk(prev, ln, dst)
            pth = to_pcb_path(nodes); anc = land_nodes[dst].get('anchor')
            if anc and pth and nodes[-2][0] != anc['layer']: pth.append({'x': anc['x'], 'y': anc['y'], 'via': True, 'toLayer': anc['layer']})
            legs.append({'net': net, 'from': land_nodes[ln]['ref'], 'to': land_nodes[dst]['ref'], 'width': leg_width(nodes), 'pcb_land': land_nodes[ln]['land'], 'path': pth})
            absorb(ln); net_report['legs'] += 1
    report['nets'][net] = net_report

# ---------------------------------------------------------------- component frames + output
for i, leg in enumerate(legs):
    comp = pcb_comps[land_comp[leg.pop('pcb_land')]]
    leg['id'] = f"fr_{i}_{leg['from'].replace('.', '_')}"
    leg['fromCentre'] = {'x': comp['center']['x'], 'y': comp['center']['y']}
    leg['fromRotation'] = comp.get('rotation') or 0
    leg['path'] = [{**p, 'x': round(p['x'], 4), 'y': round(p['y'], 4)} for p in leg['path']]
# lands the router never touched (still connected only by name)
covered = {l['from'] for l in legs} | {l['to'] for l in legs}
fixed_ports = set()
for t in c:
    if t['type'] == 'pcb_trace':
        for pt in t['route']:
            for k in ('start_pcb_port_id', 'end_pcb_port_id'):
                if pt.get(k): fixed_ports.add(pt[k])
for l in lands:
    if l['net'] and l['ref'] not in covered and l['port'] not in fixed_ports and len(net_lands[l['net']]) > 1:
        report['uncovered_lands'].append(f"{l['ref']} ({l['net']})")
if '--append' in sys.argv:
    prev = json.load(open(f'routing/{board_key}-routes.json'))['legs']
    for i, leg in enumerate(legs): leg['id'] = f"fr_{len(prev) + i}_{leg['from'].replace('.', '_')}"
    print(f'append mode: keeping {len(prev)} existing legs, adding {len(legs)}')
    legs = prev + legs
out = {'source': pad_map['source_sha256'], 'router': 'Freerouting 1.9.0 (local, ' + ses_path + ')', 'legs': legs}
json.dump(out, open(f'routing/{board_key}-routes.json', 'w'), indent=1)
json.dump(report, open(f'.tmp/router/{board_key}-import-report.json', 'w'), indent=1)
print(f"{len(legs)} legs written; dropped fixed echo {report['dropped_fixed_wires']} wires / {report['dropped_fixed_vias']} vias; floating fragments {report['floating_fragments']}")
unr = {n: r['unrouted'] for n, r in report['nets'].items() if r['unrouted']}
print('unrouted lands (router):', unr if unr else 'none')
print('uncovered lands (no copper at all):', report['uncovered_lands'] if report['uncovered_lands'] else 'none')