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)