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-jlc-footprints.py

#!/usr/bin/env python3
"""Compare every placed part's land pattern with JLCPCB's own library footprint (EasyEDA) for its LCSC number.

JLC places each part by ITS library footprint: the part's origin goes to the CPL Mid X/Y and the footprint is turned
by the CPL rotation. So for every part this fits our lands onto JLC's lands (by pad number, and by geometry alone
when the numbering schemes differ) over the four 90-degree rotations and reports:
  - the rotation JLC needs: top = pcb rotation - k; bottom = 180 - pcb rotation - k (JLC flips bottom parts about
    the part's X axis; KiKit #664, verified against JLC's assembly preview), k = the fitted rotation;
  - where JLC's footprint origin sits relative to our component centre (board frame), i.e. the CPL position JLC needs;
  - FLAGS: lands that fit by shape but not by number (the pad numbering, hence the pin-to-terminal mapping, differs
    from JLC's library: the MLT-5020 buzzer had + on the mechanical tab this way), and parts that fit no rotation.
Footprints are cached in .tmp/jlc/. Passives (R, C) are skipped: symmetric, and their 180-degree ambiguity is harmless.
Usage: python3 scripts/check-jlc-footprints.py [--json out.json]     exit 1 on any flag
"""
import json, math, os, subprocess, sys

CACHE = '.tmp/jlc'
RESID_OK = 0.15  # mm: land patterns may differ a little in pitch (0402 1.0 vs 0.96 mm); orientation errors are >> this


def load_board(path='dist/index/circuit.json'):
    return json.load(open(path))


def jlc_footprint(lcsc):
    os.makedirs(CACHE, exist_ok=True)
    f = f'{CACHE}/{lcsc}.json'
    if not os.path.exists(f) or os.path.getsize(f) < 100:
        url = f'https://easyeda.com/api/products/{lcsc}/components?version=6.4.19.5'
        r = subprocess.run(['curl', '-s', '-m', '30', '-A', 'Mozilla/5.0', url, '-o', f])  # curl: python's TLS store trips here
        if r.returncode: return None, f'fetch failed (curl {r.returncode})'
    try:
        ds = json.load(open(f))['result']['packageDetail']['dataStr']
    except Exception:
        return None, 'no EasyEDA footprint'
    hx, hy = float(ds['head']['x']), float(ds['head']['y'])
    pads = []
    for s in ds['shape']:
        if s.startswith('PAD~'):
            p = s.split('~')  # EasyEDA units are 10 mil, y points down
            pads.append((p[8], (float(p[2]) - hx) * 0.254, -(float(p[3]) - hy) * 0.254))
    return pads, None


def our_parts(d):
    src = {c['source_component_id']: c for c in d if c['type'] == 'source_component'}
    pp = {p['pcb_port_id']: p for p in d if p['type'] == 'pcb_port'}
    sp = {p['source_port_id']: p for p in d if p['type'] == 'source_port'}
    parts = {}
    for c in d:
        if c['type'] != 'pcb_component' or c.get('do_not_place'): continue
        s = src[c['source_component_id']]
        lcsc = ((s.get('supplier_part_numbers') or {}).get('jlcpcb') or [None])[0]
        if not lcsc or s.get('ftype') in ('simple_resistor', 'simple_capacitor'): continue
        parts[c['pcb_component_id']] = {'ref': s['name'], 'lcsc': lcsc, 'layer': c['layer'], 'rot': c.get('rotation') or 0,
                                        'center': (c['center']['x'], c['center']['y']), 'pads': []}
    for e in d:
        if e['type'] not in ('pcb_smtpad', 'pcb_plated_hole') or e.get('pcb_component_id') not in parts: continue
        part = parts[e['pcb_component_id']]
        dx, dy = e['x'] - part['center'][0], e['y'] - part['center'][1]
        if part['layer'] == 'bottom': dx = -dx  # look at the part from its own side
        a = math.radians(part['rot']) * (1 if part['layer'] == 'bottom' else -1)
        s = sp.get(pp.get(e.get('pcb_port_id'), {}).get('source_port_id'), {})
        part['pads'].append((str(s.get('pin_number')), dx * math.cos(a) - dy * math.sin(a), dx * math.sin(a) + dy * math.cos(a), e['type'] == 'pcb_smtpad'))
    return list(parts.values())


def fit(ours, jlc, by_number):
    best = None
    jm = {}
    for n, x, y in jlc: jm.setdefault(n, []).append((x, y))
    for k in range(4):
        a = math.radians(k * 90)
        R = [(n, x * math.cos(a) - y * math.sin(a), x * math.sin(a) + y * math.cos(a)) for n, x, y, smd in ours if smd or not by_number]
        if by_number:
            pairs = [((rx, ry), min(jm[n], key=lambda q: math.hypot(q[0] - rx, q[1] - ry))) for n, rx, ry in R if n in jm]
            if len(pairs) < max(2, len(R) // 2): continue
        else:
            bx = lambda P: ((max(p[0] for p in P) + min(p[0] for p in P)) / 2, (max(p[1] for p in P) + min(p[1] for p in P)) / 2)
            (cx, cy), (jx, jy) = bx([(x, y) for _, x, y in R]), bx([(x, y) for _, x, y in jlc])
            pairs = [((rx, ry), min(((x, y) for _, x, y in jlc), key=lambda q: math.hypot(q[0] - (rx - cx + jx), q[1] - (ry - cy + jy)))) for _, rx, ry in R]
        ox = sum(b[0] - p[0] for p, b in pairs) / len(pairs); oy = sum(b[1] - p[1] for p, b in pairs) / len(pairs)
        res = max(math.hypot(b[0] - p[0] - ox, b[1] - p[1] - oy) for p, b in pairs)
        if best is None or res < best['resid'] - 1e-9: best = {'k': k * 90, 'resid': res, 'ox': ox, 'oy': oy, 'n': len(pairs)}
    return best


def analyse(d):
    rows = []
    for part in sorted(our_parts(d), key=lambda p: p['ref']):
        jlc, err = jlc_footprint(part['lcsc'])
        row = {'ref': part['ref'], 'lcsc': part['lcsc'], 'layer': part['layer'], 'rot': part['rot'], 'flags': []}
        rows.append(row)
        if not jlc: row['note'] = err; continue
        fn, fg = fit(part['pads'], jlc, True), fit(part['pads'], jlc, False)
        f = fn if fn and fn['resid'] <= RESID_OK else fg
        row.update(method='number' if f is fn else 'shape', k=f['k'], resid=round(f['resid'], 3))
        if f['resid'] > RESID_OK: row['flags'].append(f'no rotation fits JLC footprint (residual {f["resid"]:.2f} mm)'); continue
        if fn and fn['resid'] > RESID_OK and fg['resid'] <= RESID_OK:
            row['flags'].append('lands match JLC by shape but NOT by pad number: pin-to-terminal mapping differs from JLC library')
        # JLC origin in our part frame, then in board coordinates
        a = math.radians(-f['k']); vx, vy = -f['ox'], -f['oy']
        lx, ly = vx * math.cos(a) - vy * math.sin(a), vx * math.sin(a) + vy * math.cos(a)
        r = math.radians(part['rot'])
        if part['layer'] == 'top':
            bx_, by_ = lx * math.cos(r) - ly * math.sin(r), lx * math.sin(r) + ly * math.cos(r)
            row['jlc_rotation'] = (part['rot'] - f['k']) % 360
        else:  # undo: local = R(+rot) . mirror_x . board
            bx_, by_ = lx * math.cos(-r) - ly * math.sin(-r), lx * math.sin(-r) + ly * math.cos(-r); bx_ = -bx_
            row['jlc_rotation'] = (180 - part['rot'] - f['k']) % 360
        row['origin_offset'] = (round(bx_, 3), round(by_, 3))
    return rows


if __name__ == '__main__':
    rows = analyse(load_board())
    bad = 0
    print(f"{'ref':5} {'lcsc':11} {'layer':6} {'pcb':>4} {'jlc':>4} {'fit':>6} {'offset mm':>15}  notes")
    for r in rows:
        off = r.get('origin_offset')
        print(f"{r['ref']:5} {r['lcsc']:11} {r['layer']:6} {r['rot']:4} {str(r.get('jlc_rotation', '-')):>4} {r.get('method', '-'):>6} "
              f"{('(%+.2f,%+.2f)' % off) if off else '-':>15}  {'; '.join(r['flags']) or r.get('note', '')}")
        bad += bool(r['flags'])
    if '--json' in sys.argv: json.dump(rows, open(sys.argv[sys.argv.index('--json') + 1], 'w'), indent=1)
    print(f"{len(rows)} parts compared with JLC library footprints; {bad} flagged")
    sys.exit(1 if bad else 0)