main
noah amfm-receiver-molecule: KiCad project, fab files, 3D and README (6/7) 53707c4 1d ago
"""Order-readiness audit: every LCSC number in the JLC BOM checked against JLCPCB's live catalog
(exists, MPN, package vs footprint, value, stock for N boards), plus BOM <-> CPL <-> board consistency."""
import csv, json, os, re, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import jlc

ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
BOARDS = int(os.environ.get('BOARDS', '5'))
rows = list(csv.DictReader(open(os.path.join(ROOT, 'fab', 'amfm_receiver_BOM.csv'))))
jbom = list(csv.DictReader(open(os.path.join(ROOT, 'fab', 'amfm_receiver_BOM_JLCPCB.csv'))))
cpl = list(csv.DictReader(open(os.path.join(ROOT, 'fab', 'amfm_receiver_CPL_JLCPCB.csv'))))

SI = {'p': 1e-12, 'n': 1e-9, 'u': 1e-6, 'µ': 1e-6, 'm': 1e-3, 'k': 1e3, 'K': 1e3, 'M': 1e6, 'G': 1e9, '': 1}


def num(s):
    """'4.7u' / '4u7' / '10kΩ' / '100nF' / '2.2uH' / '1M' -> float."""
    if not s:
        return None
    s = s.replace('Ω', '').replace('ohm', '').replace('F', '').replace('H', '').strip()
    m = re.match(r'^(\d+)([pnuµmkKMG])(\d+)$', s)
    if m:
        return float(m.group(1) + '.' + m.group(3)) * SI[m.group(2)]
    m = re.match(r'^([\d.]+)\s*([pnuµmkKMG]?)', s)
    return float(m.group(1)) * SI[m.group(2)] if m else None


def pkg_key(fp):
    m = re.search(r'_(0201|0402|0603|0805|1206|1210|1812|2512)_', fp + '_')
    if m:
        return m.group(1)
    for k in ('SOT-23-6', 'SOT-23-5', 'SOT-23', 'SOT-223', 'SOT-363', 'SOT-323', 'SOD-123', 'SOD-323', 'SOD-523',
              'SOIC-8', 'SOIC-14', 'MSOP-8', 'TSSOP', 'QFN', 'SMA', 'SMB', 'SMC', 'USB_C', 'HCCF'):
        if k.lower() in fp.lower():
            return k
    return None


def pkg_ok(fp, jpkg, describe):
    k = pkg_key(fp)
    j = (jpkg or '').upper().replace(' ', '')
    if k is None:
        return None
    if k in ('0201', '0402', '0603', '0805', '1206', '1210', '1812', '2512'):
        return k in j
    if k == 'QFN':
        return 'QFN' in j
    if k == 'SOT-23':
        return j in ('SOT-23', 'SOT-23-3', 'SOT-23(TO-236)', 'TO-236') or j.startswith('SOT-23-3')
    if k == 'USB_C':
        return True
    if k == 'HCCF':
        return 'SMD' in j or True
    return k.replace('-', '') in j.replace('-', '')


def value_check(r, p):
    """Compare the designed value with JLC's attribute (R, C, L only)."""
    ref = r['Designators'].split()[0]
    attrs = p['attrs']
    want = num(r['Value'])
    key = {'R': 'Resistance', 'C': 'Capacitance', 'L': 'Inductance'}.get(re.match(r'[A-Z]+', ref).group(0))
    if not key or want is None:
        return None, ''
    got_s = next((v for k, v in attrs.items() if k and k.lower().startswith(key.lower())), None)
    got = num(got_s or '')
    if got is None:
        return None, got_s or '?'
    return abs(got - want) / want < 0.011, got_s


report, problems = [], []
for r in rows:
    code = r['LCSC']
    if not code:
        continue
    qty = int(r['Qty'])
    p = jlc.part(code)
    if p is None:
        problems.append((code, r['Value'], 'NOT FOUND in JLC catalog'))
        report.append(dict(code=code, value=r['Value'], refs=r['Designators'], status='NOT FOUND'))
        continue
    issues = []
    if (p['mpn'] or '').strip().upper() != r['MPN'].strip().upper():
        issues.append('MPN mismatch: BOM %s vs JLC %s' % (r['MPN'], p['mpn']))
    pk = pkg_ok(r['Footprint'], p['package'], p['describe'])
    if pk is False:
        issues.append('package mismatch: footprint %s vs JLC %s' % (r['Footprint'], p['package']))
    vok, vgot = value_check(r, p)
    if vok is False:
        issues.append('value mismatch: BOM %s vs JLC %s' % (r['Value'], vgot))
    need = qty * BOARDS
    if p['stock'] < need:
        issues.append('stock %d < %d needed for %d boards' % (p['stock'], need, BOARDS))
    elif p['stock'] < need * 4:
        issues.append('low stock: %d (need %d for %d boards)' % (p['stock'], need, BOARDS))
    for i in issues:
        problems.append((code, r['Value'], i))
    report.append(dict(code=code, value=r['Value'], refs=r['Designators'], qty=qty, mpn=p['mpn'], package=p['package'],
                       footprint=r['Footprint'], type=p['type'], preferred=p['preferred'], stock=p['stock'],
                       price=p['price'], jlc_value=vgot, issues=issues))
    print('%-10s %-8s %-3d %-24s %-16s %-9s stock=%-9d %s' % (code, r['Value'][:8], qty, (p['mpn'] or '')[:24],
          (p['package'] or '')[:16], p['type'], p['stock'], '; '.join(issues) or 'OK'), flush=True)

# ---- consistency: JLC BOM vs CPL vs full BOM
bom_refs = set()
for r in jbom:
    bom_refs |= set(x.strip() for x in r['Designator'].split(','))
cpl_refs = set(r['Designator'] for r in cpl)
full_refs = set()
for r in rows:
    if r['LCSC']:
        full_refs |= set(r['Designators'].split())
no_lcsc = [r for r in rows if not r['LCSC']]
print()
print('JLC BOM refs %d, CPL refs %d, full-BOM refs with LCSC %d' % (len(bom_refs), len(cpl_refs), len(full_refs)))
print('in BOM not CPL:', sorted(bom_refs - cpl_refs))
print('in CPL not BOM:', sorted(cpl_refs - bom_refs))
print('full BOM LCSC refs missing from JLC BOM:', sorted(full_refs - bom_refs))
print('BOM lines without LCSC (not assembled):', [(r['Value'], r['Designators']) for r in no_lcsc])
ext = [x for x in report if x.get('type') and x['type'].lower().startswith('expand')]
print('Extended parts: %d unique  (JLC loading fee applies per unique Extended part)' % len(ext))
cost = sum((x.get('price') or 0) * x.get('qty', 0) for x in report)
print('Parts cost per board (unit price at qty-1 tier): $%.2f' % cost)
print()
print('PROBLEMS (%d):' % len(problems))
for pr in problems:
    print('  %-10s %-10s %s' % pr)
json.dump(dict(report=report, problems=problems, boards=BOARDS), open(os.path.join(ROOT, 'build', 'jlc_audit.json'), 'w'), indent=1)