pixalynx/pixal-nfc-card

This code defines React components representing specific electronic hardware components—namely, a 100-ohm SMD resistor, a surface-mount LED, and a chip with labeled pins—each modeled with detailed footprints, 3D CAD models, and manufacturer part information for PCB design.

Version
1.1.0
License
unset
Stars
0

scripts/release.py

"""Canonical manufacturing export. Never send raw `tsci export` output.
No copper is modified: remove only the four erroneous antenna paste apertures;
replace exporter BOM/CPL with an explicit, checked population table.
"""
import csv, hashlib, io, json, re, shutil, subprocess, zipfile
import xml.etree.ElementTree as ET
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / 'fabrication' / 'rev-b'
OUT.mkdir(parents=True, exist_ok=True)
# Invalidate an older candidate before running any checks.
FINAL = OUT / 'pixal-nfc-card-rev-b-CAM-REVIEW.zip'
FINAL.unlink(missing_ok=True)

def run(args, log):
    r = subprocess.run(args, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    (OUT / log).write_text(r.stdout)
    print(r.stdout[-3000:])
    if r.returncode: raise RuntimeError(f'{args}: exit {r.returncode}; see {log}')

def records(c, t): return [e for e in c if e['type'] == t]
def close(a,b): return abs(a-b) < 1e-5

def normalize(c):
    c = json.loads(json.dumps(c))
    assert not [e for e in c if e['type'].endswith('_error')], 'Circuit errors'
    allowed_warnings={'source_no_power_pin_defined_warning','source_no_ground_pin_defined_warning'}
    warnings=[e for e in c if 'warning' in e['type']]
    assert len(warnings)==2 and {e['type'] for e in warnings}==allowed_warnings and all(e.get('message','').startswith('U1 has no pin with requires_') for e in warnings), 'New undisposed warning'
    sc = {e['source_component_id']:e for e in records(c, 'source_component')}
    assert {e['name'] for e in sc.values()} == {'U1','L1'}, 'Unexpected population'
    pc = {sc[e['source_component_id']]['name']:e for e in records(c,'pcb_component')}
    assert pc['L1']['do_not_place'] is True, 'L1 must be DNP'
    assert not pc['U1'].get('do_not_place',False)
    assert sc[pc['U1']['source_component_id']]['manufacturer_part_number'] == 'ST25TN01K-AFH5'
    holes = records(c, 'pcb_plated_hole')
    assert len(holes) == 2 and all(h['pcb_component_id']==pc['L1']['pcb_component_id'] for h in holes)
    assert all(close(h['outer_diameter'],.8) and close(h['hole_diameter'],.3) for h in holes)
    pads = {e['pcb_smtpad_id']:e for e in records(c,'pcb_smtpad')}
    assert len(pads)==5 and all(p['pcb_component_id']==pc['U1']['pcb_component_id'] for p in pads.values())
    sp={e['source_port_id']:e for e in records(c,'source_port')}
    pp={e['pcb_port_id']:e for e in records(c,'pcb_port')}
    def pin(pid):
        p=sp[pp[pid]['source_port_id']]
        return sc[p['source_component_id']]['name']+'.'+p['name']
    expected={'U1.NC3':(13.9,.5),'U1.NC1':(13.5,0),'U1.AC0':(13.1,.5),'U1.AC1':(13.1,-.5),'U1.NC2':(13.9,-.5)}
    for p in pads.values():
        x,y=expected[pin(p['pcb_port_id'])]
        assert close(p['x'],x) and close(p['y'],y), 'Pin mapping/orientation changed'
    connections=[]
    for t in records(c,'pcb_trace'):
        if not t.get('source_trace_id'): continue
        start,end=t['route'][0],t['route'][-1]
        connections.append({pin(start['start_pcb_port_id']),pin(end['end_pcb_port_id'])})
        for point, pid in [(start,start['start_pcb_port_id']),(end,end['end_pcb_port_id'])]:
            port=pp[pid]
            assert close(point['x'],port['x']) and close(point['y'],port['y']), 'Feed misses port'
    assert len(connections)==2 and {'L1.pin2','U1.AC0'} in connections and {'L1.pin1','U1.AC1'} in connections
    removed=[]
    for paste in records(c,'pcb_solder_paste'):
        if paste.get('pcb_smtpad_id') in pads:
            p = pads[paste['pcb_smtpad_id']]
            assert paste['shape']=='rect' and paste['layer']=='top'
            assert all(close(paste[k],p[k]) for k in ['x','y','width','height']), 'Paste must be 1:1'
            assert paste['width']*paste['height']/(2*.1*(paste['width']+paste['height'])) >= .66
        else:
            assert paste['shape']=='circle' and close(paste['radius'],.4)
            assert any(close(h['x'],paste['x']) and close(h['y'],paste['y']) for h in holes), 'Unknown paste: refusing to discard'
            removed.append(paste)
    assert len(removed)==4 and {(p['x'],p['y'],p['layer']) for p in removed} == {(h['x'],h['y'],ly) for h in holes for ly in ['top','bottom']}
    c = [e for e in c if e not in removed]
    assert len(records(c,'pcb_solder_paste')) == 5
    return c, pc['U1']

def csvtext(rows):
    s=io.StringIO();csv.writer(s,lineterminator='\n').writerows(rows);return s.getvalue()

run(['bun','run','typecheck'], 'typecheck.log')
run(['node_modules/.bin/tsci','build','--pcb-png','--schematic-png'], 'build.log')
raw=json.loads((ROOT/'dist/index/circuit.json').read_text())
c,u=normalize(raw)
# Fail closed: demonstrate that unintended paste reduction and population changes stop release.
for label, mutate in [
 ('reduced paste',lambda a: next(e for e in a if e['type']=='pcb_solder_paste').update(width=.14)),
 ('DNP IC',lambda a: next(e for e in a if e['type']=='pcb_component').update(do_not_place=True)),
 ('unowned paste',lambda a: a.append(dict(type='pcb_solder_paste',shape='circle',radius=.4,x=0,y=0,layer='top'))),
]:
    a=json.loads(json.dumps(raw));mutate(a)
    try: normalize(a)
    except (AssertionError,KeyError): print('PASS regression:',label)
    else: raise AssertionError('Regression missed: '+label)
normalized=OUT/'release.circuit.json';normalized.write_text(json.dumps(c,indent=2)+'\n')
run(['bun','scripts/audit-coil.ts',str(normalized)],'coil-audit.log')
run(['node_modules/.bin/tsci','export',str(normalized),'-f','gerbers','-o',str(OUT/'raw-export.zip')],'export.log')
folder=OUT/'gerbers'
if folder.exists(): shutil.rmtree(folder)
folder.mkdir()
with zipfile.ZipFile(OUT/'raw-export.zip') as z:
    for name in z.namelist():
        assert Path(name).name==name
        if name.endswith(('.gbr','.drl')): (folder/name).write_bytes(z.read(name))
(OUT/'raw-export.zip').unlink()
assert close(u['center']['x'],13.5) and close(u['center']['y'],0) and close(u['rotation'],180) and u['layer']=='top'
bom=[['Designator','Comment','Value','Footprint','LCSC Part #','Manufacturer','Manufacturer Part Number','Quantity'],['U1','Passive NFC Type 2 tag','ST25TN01K','UFDFPN5 1.7x1.4mm','C3303589','STMicroelectronics','ST25TN01K-AFH5',1]]
cpl=[['Designator','Mid X','Mid Y','Layer','Rotation'],['U1','13.5mm','0mm','top',180]]
assert {r[0] for r in bom[1:]} == {r[0] for r in cpl[1:]} == {'U1'}
(folder/'bom.csv').write_text(csvtext(bom));(folder/'pick_and_place.csv').write_text(csvtext(cpl))
run(['uv','run','--no-project','--with','shapely==2.1.2','--with','pillow==12.3.0','python','scripts/audit-manufacturing.py',str(OUT)],'manufacturing-audit.log')
for name in ['netlist','placement','schematic-placement']:
    run(['node_modules/.bin/tsci','check',name],name+'.log')
# This CLI reports advisory XML with exit code zero; inspect its contents too.
xml=re.search(r'<SchematicPlacementIssues>.*?</SchematicPlacementIssues>',(OUT/'schematic-placement.log').read_text(),re.S)
assert xml, 'Missing schematic diagnostic section'
issues=list(ET.fromstring(xml.group()))
assert len(issues)<=2 and all(e.tag=='TraceCanBeSimplifiedByMovingComponent' and e.attrib.get('targetComponentName')=='U1' and e.attrib.get('schematicTraceId') in ['schematic_trace_0','schematic_trace_1'] for e in issues), 'New schematic issue requires review'
for logfile in ['netlist.log','placement.log']:
    log=(OUT/logfile).read_text()
    assert 'Errors: 0' in log and 'Warnings: 0' in log, 'New DRC diagnostic'
# Accept only the two known bitmap false positives at physical coil terminations.
r=subprocess.run(['node_modules/.bin/tsci','check','shorts'],cwd=ROOT,text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)
(OUT/'shorts.log').write_text(r.stdout)
locations=[tuple(map(float,p)) for p in re.findall(r'bottom/gerber short at x=([-0-9.]+)mm y=([-0-9.]+)mm',r.stdout)]
assert r.returncode==1 and len(locations)==2 and 'Detected 2 shorts' in r.stdout, 'Unexpected shorts result'
assert all(any((x-h['x'])**2+(y-h['y'])**2 < .2**2 for h in records(c,'pcb_plated_hole')) for x,y in locations), 'New short location'
assert len(re.findall(r'pcb_plated_hole_0, pcb_plated_hole_1 <-> pcb_trace_0',r.stdout))==2, 'Unexpected short participants'
print('PASS: exactly two documented terminal-junction bitmap diagnostics; geometry audited independently')
for name in ['pcb.png','schematic.png']: shutil.copy2(ROOT/'dist/index'/name,OUT/name)
for p in (ROOT/'docs').glob('*'):
    if p.is_file(): shutil.copy2(p, OUT/p.name)
(OUT/'STATUS.txt').write_text('REV B: CAM review / prototype candidate. NOT production qualified.\nSupplier acceptance of 0.2mm IC lands, stencil, panelization and exact-part stock remain required.\nRF tuning and final mechanical qualification require physical samples.\nGerbers and CPL use the same board-centred mm origin, viewed from top.\nL1 is printed copper, not a placed part. Four antenna-hole paste circles removed by checked export.\n')
with zipfile.ZipFile(OUT/'single-unit-gerbers.zip','w',zipfile.ZIP_DEFLATED) as z:
    for p in sorted(folder.iterdir()):
        if p.suffix in ['.gbr','.drl']: z.write(p,p.name)
files=[p for p in ROOT.rglob('*') if p.is_file() and not any(x in p.relative_to(ROOT).parts for x in ['node_modules','dist','fabrication','.tscircuit','.agents','.claude','checks'])]
files += [p for p in OUT.rglob('*') if p.is_file() and p.name not in ['SHA256.json',FINAL.name]]
(OUT/'SHA256.json').write_text(json.dumps({str(p.relative_to(ROOT)):hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(files)},indent=2)+'\n')
with zipfile.ZipFile(FINAL,'w',zipfile.ZIP_DEFLATED) as z:
    for p in sorted(OUT.rglob('*')):
        if p.is_file() and p != FINAL: z.write(p,p.relative_to(OUT))
print('Created',FINAL)