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')