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/gen-gnd-mesh.py

#!/usr/bin/env python3
"""Generate routing/gnd-mesh.json for a board: for every land on the ground net, either a short
stub to a new plane via (preferred) or a straight same-layer link to the nearest other ground
land whose path is clear. Lands that get neither are listed so they can be handled by hand.

Usage: gen-gnd-mesh.py dist/<board>/circuit.json routing/<board>-gnd.json [NET]
Via: 0.4 mm land / 0.2 mm drill, >= 0.18 mm from any other land or hole on any layer and from
the board edge; link: 0.2 mm wide, >= 0.15 mm from every other land on its layer.
"""
import json, sys, math
src, out = sys.argv[1], sys.argv[2]; NET = sys.argv[3] if len(sys.argv) > 3 else 'GND'
VIA_D, CLEAR, STEP, LINK_W, LINK_MAX = 0.4, 0.18, 0.45, 0.2, 3.0
d = json.load(open(src))
board = next(e for e in d if e['type'] == 'pcb_board'); bw, bh = board['width'], board['height']; bx, by = board['center']['x'], board['center']['y']
comps = {c['pcb_component_id']: c for c in d if c['type'] == 'pcb_component'}
sname = {c['source_component_id']: c['name'] for c in d if c['type'] == 'source_component'}
sports = {p['source_port_id']: p for p in d if p['type'] == 'source_port'}
nets = {n['source_net_id']: n for n in d if n['type'] == 'source_net'}
gnd_ids = [nid for nid, n in nets.items() if n['name'] == NET]
gnd_key = None
for t in d:
    if t['type'] == 'source_trace' and any(n in t.get('connected_source_net_ids', []) for n in gnd_ids):
        for pid in t.get('connected_source_port_ids', []): gnd_key = sports[pid].get('subcircuit_connectivity_map_key'); break
    if gnd_key: break
gnd_ports = {pid for pid, p in sports.items() if p.get('subcircuit_connectivity_map_key') == gnd_key}
elems = [e for e in d if e['type'] in ('pcb_smtpad', 'pcb_plated_hole', 'pcb_hole')]
def rect(e):
    if e['type'] == 'pcb_smtpad':
        if e['shape'] == 'circle': r = e['radius']; return (e['x'] - r, e['y'] - r, e['x'] + r, e['y'] + r)
        return (e['x'] - e['width'] / 2, e['y'] - e['height'] / 2, e['x'] + e['width'] / 2, e['y'] + e['height'] / 2)
    if e.get('outer_width'):  # pill / oval plated hole (USB-C shell legs): rotated bounding box
        w, h = e['outer_width'], e['outer_height']
        if round((e.get('ccw_rotation') or 0) / 90) % 2: w, h = h, w
        return (e['x'] - w / 2, e['y'] - h / 2, e['x'] + w / 2, e['y'] + h / 2)
    r = (e.get('outer_diameter') or e.get('hole_diameter') or e.get('diameter') or max(e.get('hole_width', 0), e.get('hole_height', 0))) / 2
    return (e['x'] - r, e['y'] - r, e['x'] + r, e['y'] + r)
def rdist(x, y, r):
    x0, y0, x1, y1 = r; return math.hypot(max(x0 - x, 0, x - x1), max(y0 - y, 0, y - y1))
def seg_rect_dist(ax, ay, bx_, by_, r):
    # distance from segment AB to rectangle r (sampled)
    n = 12; best = 1e9
    for i in range(n + 1):
        t = i / n; best = min(best, rdist(ax + (bx_ - ax) * t, ay + (by_ - ay) * t, r))
    return best
rects = [(rect(e), e) for e in elems]
# escape lanes: the 0.6 mm strip outward of every land of a multi-pin part (chips, connectors) stays free of other
# parts' stub vias/links so the signal router can leave those lands (a stub via there blocked U5.FOD, U3.SCL ...)
ESCAPE = 0.6
pads_by_comp = {}
for e in elems:
    if e['type'] == 'pcb_smtpad' and e.get('pcb_component_id'): pads_by_comp.setdefault(e['pcb_component_id'], []).append(e)
escape_rects = []  # (rect, component id, layer)
for cid, pads in pads_by_comp.items():
    if len(pads) < 6: continue
    cx, cy = comps[cid]['center']['x'], comps[cid]['center']['y']
    hw = max(abs(p['x'] - cx) for p in pads) or 1; hh = max(abs(p['y'] - cy) for p in pads) or 1
    for p in pads:
        if p['shape'] == 'circle': continue
        x0, y0, x1, y1 = rect(p); ndx, ndy = (p['x'] - cx) / hw, (p['y'] - cy) / hh
        if abs(ndx) >= abs(ndy): r = (x1, y0 - 0.05, x1 + ESCAPE, y1 + 0.05) if ndx > 0 else (x0 - ESCAPE, y0 - 0.05, x0, y1 + 0.05)
        else: r = (x0 - 0.05, y1, x1 + 0.05, y1 + ESCAPE) if ndy > 0 else (x0 - 0.05, y0 - ESCAPE, x1 + 0.05, y0)
        escape_rects.append((r, cid, p['layer']))
# hand-routed copper present in this build (RF lines): keep vias off it
segs = []
for tr in d:
    if tr['type'] != 'pcb_trace': continue
    # all hand-routed copper in the placement build (RF lines, hand legs); the mesh itself is empty at this point.
    # Every consecutive route pair is copper, wire->via and via->wire included (layer from the wire end)
    r = tr['route']
    for a, b in zip(r, r[1:]):
        layer = a.get('layer') if a.get('route_type') == 'wire' else b.get('layer') if b.get('route_type') == 'wire' else None
        if not layer: continue
        w = max(a.get('width') or 0, b.get('width') or 0) or 0.15
        segs.append((a['x'], a['y'], b['x'], b['y'], w, layer))
def seg_pt_dist(x, y, ax, ay, bx_, by_):
    dx, dy = bx_ - ax, by_ - ay; l2 = dx * dx + dy * dy
    t_ = 0 if l2 == 0 else max(0, min(1, ((x - ax) * dx + (y - ay) * dy) / l2))
    return math.hypot(x - (ax + t_ * dx), y - (ay + t_ * dy))
def seg_seg_dist(a, b, c, e):
    def cross(o, p, q): return (p[0] - o[0]) * (q[1] - o[1]) - (p[1] - o[1]) * (q[0] - o[0])
    d1, d2, d3, d4 = cross(c, e, a), cross(c, e, b), cross(a, b, c), cross(a, b, e)
    if ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0)): return 0.0
    return min(seg_pt_dist(a[0], a[1], c[0], c[1], e[0], e[1]), seg_pt_dist(b[0], b[1], c[0], c[1], e[0], e[1]),
               seg_pt_dist(c[0], c[1], a[0], a[1], b[0], b[1]), seg_pt_dist(e[0], e[1], a[0], a[1], b[0], b[1]))
def sel_of(pp):
    sp = sports[pp['source_port_id']]; name = sname[comps[[e for e in elems if e.get('pcb_port_id') == pp['pcb_port_id']][0]['pcb_component_id']]['source_component_id']]
    pn = sp.get('name') or ''; hints = pp.get('port_hints', [])
    pin_hint = next((h for h in hints if h.startswith('pin')), None) or next((f"pin{h}" for h in hints if h.isdigit()), None)
    return f"{name}.{pn}" if pn and not pn.startswith('pin') else f"{name}.{pin_hint or pn}"
gnd_pads = []
for pp in [p for p in d if p['type'] == 'pcb_port' and p.get('source_port_id') in gnd_ports]:
    pad = next((e for e in elems if e['type'] == 'pcb_smtpad' and e.get('pcb_port_id') == pp['pcb_port_id']), None)
    if pad: gnd_pads.append((sel_of(pp), pad))
gnd_pad_ids = {id(p) for _, p in gnd_pads}
# lands that already end a fixed trace (hand ties into a paddle etc.) are served by that copper
tied_ports = set()
for tr in d:
    if tr['type'] == 'pcb_trace':
        for pt in tr['route']:
            for k in ('start_pcb_port_id', 'end_pcb_port_id'):
                if pt.get(k): tied_ports.add(pt[k])
# only the via-bearing end of a tie is dropped (its footprint holes already reach the plane and the router must never
# use it as an endpoint); the other end of the tie still gets its own stub
holed_ports = {e.get('pcb_port_id') for e in d if e['type'] == 'pcb_plated_hole'}
gnd_pads = [(s_, p) for s_, p in gnd_pads if not (p.get('pcb_port_id') in tied_ports and p.get('pcb_port_id') in holed_ports)]
stubs, links, taken, todo = [], [], [(v['x'], v['y']) for v in d if v['type'] == 'pcb_via'], []  # existing vias (hand legs) are taken spots
for sel, pad in gnd_pads:
    comp = comps[pad['pcb_component_id']]
    w, h = (pad['radius'] * 2, pad['radius'] * 2) if pad['shape'] == 'circle' else (pad['width'], pad['height'])
    dx, dy = pad['x'] - comp['center']['x'], pad['y'] - comp['center']['y']
    sx, sy = math.copysign(1, dx or 1), math.copysign(1, dy or 1)
    cands = [(sx, 0), (0, sy), (0, -sy), (-sx, 0), (0.7071 * sx, 0.7071), (0.7071 * sx, -0.7071)] if abs(dx) >= abs(dy) else [(0, sy), (sx, 0), (-sx, 0), (0, -sy), (0.7071, 0.7071 * sy), (-0.7071, 0.7071 * sy)]
    placed = None
    for ux, uy in cands:
        for extra in (0, 0.15, 0.3, 0.45):
            x = pad['x'] + ux * (w / 2 + STEP + extra); y = pad['y'] + uy * (h / 2 + STEP + extra)
            if abs(x - bx) > bw / 2 - 0.55 or abs(y - by) > bh / 2 - 0.55: continue  # 0.2 via radius + 0.3 edge clearance
            if any(rdist(x, y, r) < VIA_D / 2 + CLEAR for r, e in rects if e is not pad): continue
            if any(math.hypot(tx - x, ty - y) < VIA_D + CLEAR for tx, ty in taken): continue
            if any(rdist(x, y, r) < VIA_D / 2 + 0.15 for r, cid, _ in escape_rects if cid != pad['pcb_component_id']): continue
            if any(seg_rect_dist(pad['x'], pad['y'], x, y, r) < LINK_W / 2 + 0.15 for r, cid, l in escape_rects if cid != pad['pcb_component_id'] and l == pad['layer']): continue
            if any(seg_pt_dist(x, y, *s[:4]) < VIA_D / 2 + s[4] / 2 + 0.2 for s in segs): continue
            if any(s_[5] == pad['layer'] and seg_seg_dist((pad['x'], pad['y']), (x, y), (s_[0], s_[1]), (s_[2], s_[3])) < LINK_W / 2 + s_[4] / 2 + 0.12 for s_ in segs): continue
            # the stub segment from the pad centre to the via must not touch other lands on the pad's layer
            if any(seg_rect_dist(pad['x'], pad['y'], x, y, r) < LINK_W / 2 + 0.15 for r, e in rects if e is not pad and e['type'] == 'pcb_smtpad' and e.get('layer') == pad['layer']): continue
            placed = (round(x, 3), round(y, 3)); break
        if placed: break
    if placed:
        taken.append(placed)
        rot = math.radians(comp.get('rotation') or 0)
        vx, vy = placed[0] - comp['center']['x'], placed[1] - comp['center']['y']
        lx, ly = vx * math.cos(-rot) - vy * math.sin(-rot), vx * math.sin(-rot) + vy * math.cos(-rot)
        # core applies only translate+rotate to pcbPath points (no bottom mirror), so no x flip here
        stubs.append({"port": sel, "layer": pad['layer'], "pad": [round(pad['x'], 3), round(pad['y'], 3)], "via": list(placed), "local": [round(lx, 3), round(ly, 3)]})
    else:
        todo.append((sel, pad))
# second pass: link leftover pads to the nearest ground land on the same layer with a clear straight path
final_todo = []
for sel, pad in todo:
    best = None
    resolved = {s['port'] for s in stubs}
    for sel2, pad2 in gnd_pads:
        if pad2 is pad or pad2['layer'] != pad['layer'] or sel2 not in resolved: continue
        dist = math.hypot(pad2['x'] - pad['x'], pad2['y'] - pad['y'])
        if dist > LINK_MAX or (best and dist >= best[0]): continue
        blocked = any(seg_rect_dist(pad['x'], pad['y'], pad2['x'], pad2['y'], r) < LINK_W / 2 + 0.15 for r, e in rects if e is not pad and e is not pad2 and e['type'] == 'pcb_smtpad' and e.get('layer') == pad['layer'])
        if not blocked and any(s_[5] == pad['layer'] and seg_seg_dist((pad['x'], pad['y']), (pad2['x'], pad2['y']), (s_[0], s_[1]), (s_[2], s_[3])) < LINK_W / 2 + s_[4] / 2 + 0.12 for s_ in segs): blocked = True
        if not blocked and any(seg_rect_dist(pad['x'], pad['y'], pad2['x'], pad2['y'], r) < LINK_W / 2 + 0.15 for r, cid, l in escape_rects if cid not in (pad['pcb_component_id'], pad2['pcb_component_id']) and l == pad['layer']): blocked = True
        if not blocked: best = (dist, sel2, pad2)
    if best: links.append({"from": sel, "to": best[1], "layer": pad['layer'], "a": [round(pad['x'], 3), round(pad['y'], 3)], "b": [round(best[2]['x'], 3), round(best[2]['y'], 3)]})
    else: final_todo.append(sel)
json.dump({"stubs": stubs, "links": links, "unresolved": final_todo}, open(out, 'w'), indent=1)
print(f"{len(gnd_pads)} ground lands: {len(stubs)} via stubs, {len(links)} links, unresolved {final_todo}")