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/export-fab.py

#!/usr/bin/env python3
"""JLCPCB fabrication + assembly package for the routed board -> fab/.

1. Build, then refuse to continue unless `bun scripts/validate.ts --routed` passes (connectivity, clearance, drills).
2. Normalise vias to plain through-holes. Every via on this board is drilled L1-L4 and was routed as one, but the
   circuit JSON carries partial layer spans (ground stubs pad->inner1; the second via of each stacked pair that
   lib/geometry.ts throughVia emits), and `tsci export` then writes blind/buried drill files (drill-L1-L2 etc.) that
   JLC's 4-layer process would reject. Stacked duplicates are dropped; pours and copper are untouched.
3. `tsci export -f gerbers` from that JSON; keep the Gerber + Excellon files for the board zip.
4. BOM grouped per LCSC part in JLC's columns (DNP parts are already absent).
5. CPL in JLC's columns with the rotation / origin JLC needs per scripts/check-jlc-footprints.py (fits each land
   pattern to JLC's library footprint; top = rot - k, bottom = 180 - rot - k). Differences from tscircuit's own CPL are
   listed in fab/FAB-NOTES.md.
Usage: python3 scripts/export-fab.py [--skip-build]
"""
import csv, io, json, os, subprocess, sys, zipfile
sys.dont_write_bytecode = True  # no scripts/__pycache__ in the project (tsci push would upload it)
sys.path.insert(0, os.path.dirname(__file__))
import importlib.util
spec = importlib.util.spec_from_file_location('jlcfit', os.path.join(os.path.dirname(__file__), 'check-jlc-footprints.py'))
jlcfit = importlib.util.module_from_spec(spec); spec.loader.exec_module(jlcfit)

NAME = 'nrf9151-gps-tracker'
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
os.chdir(ROOT)
ALL = ['top', 'inner1', 'inner2', 'bottom']

def run(cmd, **kw):
    r = subprocess.run(cmd, capture_output=True, text=True, **kw)
    return r.returncode, r.stdout + r.stderr

if '--skip-build' not in sys.argv:
    code, out = run(['npx', 'tsci', 'build', 'index.circuit.tsx'])
    if code: sys.exit('build failed:\n' + out[-2000:])
code, out = run(['bun', 'scripts/validate.ts', '--routed'])
print(out.strip().splitlines()[-1])
if code: sys.exit('validation failed - not exporting')

# 2. through-hole vias only
d = json.load(open('dist/index/circuit.json'))
os.makedirs('.tmp/fab', exist_ok=True); os.makedirs('fab', exist_ok=True)
seen, norm, stacked = set(), [], 0
for e in d:
    if e['type'] == 'pcb_via':
        k = (round(e['x'], 4), round(e['y'], 4))
        if k in seen: stacked += 1; continue
        seen.add(k); e = dict(e, from_layer='top', to_layer='bottom', layers=ALL)
    norm.append(e)
json.dump(norm, open('.tmp/fab/index.circuit.json', 'w'))

# 3. tscircuit export
zpath = os.path.abspath('.tmp/fab/tsci-export.zip')
code, out = run(['npx', 'tsci', 'export', os.path.abspath('.tmp/fab/index.circuit.json'), '-f', 'gerbers', '-o', zpath])
if code or not os.path.exists(zpath): sys.exit('tsci export failed:\n' + out[-2000:])
warnings = [l for l in out.splitlines() if 'cannot verify' in l]
z = zipfile.ZipFile(zpath)
drills = [n for n in z.namelist() if n.endswith('.drl')]
assert sorted(drills) == ['drill-L1-L4.drl', 'drill_npth.drl'], f'unexpected drill files {drills}'
with zipfile.ZipFile(f'fab/{NAME}-gerbers.zip', 'w', zipfile.ZIP_DEFLATED) as out_z:
    for n in z.namelist():
        if n.endswith(('.gbr', '.drl')): out_z.writestr(n, z.read(n))

# 4. BOM grouped for JLC
src = {c['name']: c for c in d if c['type'] == 'source_component'}
groups = {}
for r in csv.DictReader(io.StringIO(z.read('bom.csv').decode())):
    lcsc = r['JLCPCB Part #'].strip()
    if not lcsc: sys.exit(f"{r['Designator']} has no LCSC part number")
    s = src.get(r['Designator'], {})
    comment = s.get('manufacturer_part_number') or r['Comment'] or r['Value']
    g = groups.setdefault(lcsc, {'Comment': comment, 'Designator': [], 'Footprint': r['Footprint'], 'JLCPCB Part #': lcsc})
    g['Designator'].append(r['Designator'])
with open(f'fab/{NAME}-bom.csv', 'w', newline='') as f:
    w = csv.DictWriter(f, fieldnames=['Comment', 'Designator', 'Footprint', 'JLCPCB Part #']); w.writeheader()
    for g in sorted(groups.values(), key=lambda g: g['Designator'][0]):
        w.writerow({**g, 'Designator': ','.join(sorted(g['Designator'], key=lambda x: (x.rstrip('0123456789'), int(x[len(x.rstrip('0123456789')):] or 0))))})

# 5. CPL with JLC rotations / origins
fits = {r['ref']: r for r in jlcfit.analyse(d)}
flagged = [r for r in fits.values() if r['flags']]
if flagged: sys.exit('JLC footprint check flagged: ' + '; '.join(f"{r['ref']}: {r['flags']}" for r in flagged))
changes, rows = [], []
for r in csv.DictReader(io.StringIO(z.read('pick_and_place.csv').decode())):
    ref, x, y, rot = r['Designator'], float(r['Mid X']), float(r['Mid Y']), float(r['Rotation']) % 360
    fit = fits.get(ref)
    if fit and 'jlc_rotation' in fit:
        if abs((fit['jlc_rotation'] - rot + 180) % 360 - 180) > 0.5:
            changes.append(f"{ref}: rotation {rot:g} -> {fit['jlc_rotation']:g} (fitted to JLC's {fit['lcsc']} footprint)")
            rot = fit['jlc_rotation']
        ox, oy = fit['origin_offset']
        if abs(ox) >= 0.02 or abs(oy) >= 0.02:
            changes.append(f"{ref}: position ({x:.3f},{y:.3f}) -> ({x + ox:.3f},{y + oy:.3f}), JLC footprint origin offset")
            x, y = x + ox, y + oy
    rows.append({'Designator': ref, 'Mid X': f'{x:.3f}mm', 'Mid Y': f'{y:.3f}mm', 'Layer': r['Layer'].capitalize(), 'Rotation': f'{rot:g}'})
with open(f'fab/{NAME}-cpl.csv', 'w', newline='') as f:
    w = csv.DictWriter(f, fieldnames=['Designator', 'Mid X', 'Mid Y', 'Layer', 'Rotation']); w.writeheader(); w.writerows(rows)

# summary for the notes
dnp = sorted(c['name'] for c in d if c['type'] == 'source_component' and any(
    p['type'] == 'pcb_component' and p['source_component_id'] == c['source_component_id'] and p.get('do_not_place') for p in d))
hits = sum(1 for l in z.read('drill-L1-L4.drl').decode().splitlines() if l.startswith('X'))
summary = {'stacked_vias_merged': stacked, 'through_vias': len(seen), 'pth_drill_hits': hits, 'bom_lines': len(groups),
           'placements': len(rows), 'bottom_placements': sum(r['Layer'] == 'Bottom' for r in rows), 'dnp': dnp,
           'cpl_changes': changes, 'tscircuit_rotation_warnings': warnings}
json.dump(summary, open('.tmp/fab/summary.json', 'w'), indent=1)
print(json.dumps(summary, indent=1))