pixalynx/cm5-minimal-carrier
A 4-layer Raspberry Pi CM5 carrier board routes USB‑C 5V power through an RT9742 load switch with TVS protection and capacitive filtering, while providing ESD-protected HDMI output, CM5 connectors, test points, and ground/power planes.
- Version
- 0.1.1
- License
- unset
- Stars
- 0
scripts/audit-manufacturing.py
"""Check the actual CAM exports against the emitted circuit, not just design intent.
Run with KiCad's Python after Gerber/drill/IPC export. This is an export-consistency
and selected DFM audit, not an impedance solver or a manufacturer's CAM approval.
"""
import csv, hashlib, json, math, pathlib, re, sys
import pcbnew as k
R=pathlib.Path(sys.argv[1]).resolve() if len(sys.argv)>1 else pathlib.Path(__file__).resolve().parents[1]; O=R/'output'; G=O/'fabrication-REVIEW'
C=json.loads((R/'dist/index/circuit.json').read_text())
D=json.loads((R/'lib/design.json').read_text()); M=json.loads((R/'lib/manufacturing.json').read_text())
B=k.LoadBoard(str(O/'cm5-minimal.kicad_pcb')); checks=[]
def check(name, condition):
assert condition, name
checks.append(name)
def mm(v): return k.ToMM(v)
def key(x,y): return (round(x,4),round(y,4))
def cam(x,y): return key(x-M['origin']['boardX'],y-M['origin']['boardY'])
def on_layer(p,layer): return p.IsOnLayer(layer)
def gerber(name):
s=(G/name).read_text()
assert '%FSLAX46Y46*%' in s and '%MOMM*%' in s
points=[]; flashes=[]; aperture=None; apertures={};regions=[];contours=None
for line in s.splitlines():
if line=='G36*':contours=[];continue
if line=='G37*':regions.append(contours);contours=None;continue
a=re.match(r'%ADD(\d+)([CRO]),([^*]+)\*%',line)
if a: apertures[int(a[1])]={'shape':a[2],'size':[float(v) for v in a[3].split('X')]}
a=re.fullmatch(r'D(\d+)\*',line)
if a: aperture=int(a[1])
p=re.fullmatch(r'X(-?\d+)Y(-?\d+)D0([123])\*',line)
if p:
q=key(int(p[1])/1e6,int(p[2])/1e6);points.append(q)
if contours is not None:
if p[3]=='2':contours.append([])
contours[-1].append(q)
if p[3]=='3': flashes.append((q,apertures.get(aperture)))
return {'points':points,'flashes':flashes,'regions':regions}
def inside_ring(q,ring):
inside=False
for a,b in zip(ring,ring[1:]+ring[:1]):
if (a[1]>q[1])!=(b[1]>q[1]) and q[0]<(b[0]-a[0])*(q[1]-a[1])/(b[1]-a[1])+a[0]:inside=not inside
return inside
def exposed(q,art):
if any(sum(inside_ring(q,ring) for ring in contours)%2 for contours in art['regions']):return True
for p,a in art['flashes']:
assert a is not None, 'Unsupported mask aperture'
dx=abs(p[0]-q[0]);dy=abs(p[1]-q[1]);s=a['size']
if a['shape']=='C' and math.hypot(dx,dy)<=s[0]/2:return True
if a['shape']=='R' and dx<=s[0]/2 and dy<=s[1]/2:return True
if a['shape']=='O':
radius=min(s)/2
if math.hypot(max(0,dx-(s[0]/2-radius)),max(0,dy-(s[1]/2-radius)))<=radius:return True
return False
def drills(name):
tools={};hits=[];slots=[];tool=None;start=None
for line in (G/name).read_text().splitlines():
t=re.fullmatch(r'T(\d+)C([\d.]+)',line)
if t: tools[int(t[1])]=float(t[2]);continue
t=re.fullmatch(r'T(\d+)',line)
if t: tool=int(t[1]);continue
q=re.fullmatch(r'(G00|G01)?X(-?[\d.]+)Y(-?[\d.]+)',line)
if not q: continue
p=(float(q[2]),float(q[3]))
if q[1]=='G00': start=p
elif q[1]=='G01':
assert start is not None
slots.append({'center':key((p[0]+start[0])/2,(p[1]+start[1])/2),
'width':tools[tool],'length':round(math.dist(start,p)+tools[tool],4)})
start=None
else: hits.append({'center':key(*p),'diameter':tools[tool]})
return hits,slots
outline=gerber('cm5-minimal-Edge_Cuts.gm1')['points']
check('Gerber outline uses the CPL origin: (0,0) to (64,60) mm',set(outline)=={(0,0),(64,0),(64,60),(0,60)})
aux=mm(B.GetDesignSettings().GetAuxOrigin())
check('KiCad drill/place origin is the bottom-left physical board corner',key(*aux)==(68,130))
sc={p['source_component_id']:p['name'] for p in C if p['type']=='source_component'}
pcb={p['pcb_component_id']:p for p in C if p['type']=='pcb_component'}
by_ref={f.GetReference():f for f in B.GetFootprints()}
copper={l:gerber('cm5-minimal-'+suffix)['flashes'] for l,suffix in [('top','F_Cu.gtl'),('bottom','B_Cu.gbl')]}
mask={l:gerber('cm5-minimal-'+suffix) for l,suffix in [('top','F_Mask.gts'),('bottom','B_Mask.gbs')]}
paste={l:gerber('cm5-minimal-'+suffix)['flashes'] for l,suffix in [('top','F_Paste.gtp'),('bottom','B_Paste.gbp')]}
for e in (p for p in C if p['type']=='pcb_smtpad'):
q=cam(e['x'],e['y']);layer=e['layer']
assert q in {p[0] for p in copper[layer]}, f"Missing copper flash: {q}"
assert exposed(q,mask[layer]), f"Missing mask opening: {q}"
if sc[pcb[e['pcb_component_id']]['source_component_id']].startswith('TP'):
assert q not in {p[0] for p in paste[layer]}, f"Paste on probe pad: {q}"
else:
assert q in {p[0] for p in paste[layer]}, f"Missing component paste: {q}"
check('Every emitted SMT pad appears at the same coordinates in copper and solder mask; only fitted parts receive paste',True)
with (O/'jlcpcb-cpl-REVIEW.csv').open() as f: cpl=list(csv.DictReader(f))
with (O/'jlcpcb-bom.csv').open() as f: bom=list(csv.DictReader(f))
check('CPL and BOM contain exactly the 15 fitted references, excluding probe pads',
{p['Designator'] for p in cpl}=={p['ref'] for p in D['parts']}=={s for row in bom for s in row['Designator'].split(',')})
for row in cpl:
p=next(p for p in pcb.values() if sc[p['source_component_id']]==row['Designator'])
assert key(float(row['Mid X'].removesuffix('mm')),float(row['Mid Y'].removesuffix('mm')))==cam(p['center']['x'],p['center']['y'])
assert row['Layer'].lower()==p['layer'] and float(row['Rotation'])==p['rotation']
check('CPL coordinates share the Gerber origin and preserve declared layers/rotations (library registration still requires approval)',True)
pth,slots=drills('cm5-minimal-PTH.drl');npth,np_slots=drills('cm5-minimal-NPTH.drl')
vias=[e for e in C if e['type']=='pcb_via'];holes=[e for e in C if e['type']=='pcb_hole'];plated=[e for e in C if e['type']=='pcb_plated_hole']
check('All plated via drills retain their coordinates and 0.2 mm diameter',
{tuple([*p['center'],p['diameter']]) for p in pth}=={tuple([*cam(p['x'],p['y']),p['hole_diameter']]) for p in vias})
check('All NPTH holes retain their coordinates and diameters',not np_slots and
{tuple([*p['center'],p['diameter']]) for p in npth}=={tuple([*cam(p['x'],p['y']),p['hole_diameter']]) for p in holes})
check('Eight connector stakes remain plated routed slots with correct centres, widths and lengths',len(slots)==len(plated)==8 and
{tuple([*p['center'],p['width'],p['length']]) for p in slots}==
{tuple([*cam(p['x'],p['y']),round(min(p['hole_width'],p['hole_height']),4),round(max(p['hole_width'],p['hole_height']),4)]) for p in plated})
kv=[v for v in B.GetTracks() if isinstance(v,k.PCB_VIA)]
check('Every via is through-hole and explicitly tented on both surfaces',len(kv)==len(vias) and all(v.GetViaType()==k.VIATYPE_THROUGH and v.IsTented(k.F_Mask) and v.IsTented(k.B_Mask) for v in kv))
check('No via-centre mask openings are present in final mask Gerbers',all(not exposed(cam(v['x'],v['y']),mask[l]) for v in vias for l in ['top','bottom']))
# For these orthogonal rectangular lands, bounding-box separation is exact.
# Circular/oval pads use conservative bounding boxes, which cannot overstate a web.
maskpads=[]
for f in B.GetFootprints():
for p in f.Pads():
if p.GetAttribute()==k.PAD_ATTRIB_NPTH: continue
size=mm(p.GetSize());angle=math.radians(p.GetOrientationDegrees())
w=abs(size[0]*math.cos(angle))+abs(size[1]*math.sin(angle));h=abs(size[0]*math.sin(angle))+abs(size[1]*math.cos(angle))
margin=mm(p.GetLocalSolderMaskMargin());x,y=mm(p.GetPosition())
for layer in [k.F_Mask,k.B_Mask]:
if p.IsOnLayer(layer):maskpads.append((f.GetReference()+'.'+p.GetNumber(),layer,x,y,w+2*margin,h+2*margin))
minimum=(float('inf'),None)
for i,a in enumerate(maskpads):
for b in maskpads[i+1:]:
if a[1]!=b[1]:continue
gap=math.hypot(max(0,abs(a[2]-b[2])-(a[4]+b[4])/2),max(0,abs(a[3]-b[3])-(a[5]+b[5])/2))
if gap<minimum[0]:minimum=(gap,[a[0],b[0]])
check('Nominal solder-mask webs are at least 0.10 mm for green 1 oz fabrication',minimum[0]>=M['minimumMaskWebMm']-1e-6)
ipc=(O/'cm5-minimal.ipc').read_text().splitlines();ipcpoints=[]
check('IPC-D-356 declares its 0.0001 inch coordinate unit', 'P UNITS CUST 0' in ipc)
for line in ipc:
p=re.search(r'X([+-]\d+)Y([+-]\d+)',line)
if p:ipcpoints.append((line[:3],line[3:17].strip(),int(p[1])*.00254,int(p[2])*.00254,line))
for tp in M['testPoints']:
q=cam(tp['x'],tp['y'])
assert any(p[1]==tp['net'] and tp['ref'] in p[4] and math.dist(p[2:4],q)<.004 for p in ipcpoints),f"IPC probe pad missing: {tp['ref']}"
check('Electrical-test netlist locates each power/ground probe pad on its intended net in the same origin',True)
proj=json.loads((O/'cm5-minimal.kicad_pro').read_text());drc=json.loads((O/'drc.json').read_text())
check('Final independent DRC is clean with no exclusions',not proj['board']['design_settings']['drc_exclusions'] and not drc['violations'] and not drc['unconnected_items'])
files=[R/'dist/index/circuit.json',O/'cm5-minimal.kicad_pcb',O/'cm5-minimal.kicad_pro',O/'drc.json',O/'cm5-minimal.ipc',O/'jlcpcb-bom.csv',O/'jlcpcb-cpl-REVIEW.csv',*sorted(G.glob('*.g*')),*sorted(G.glob('*.drl'))]
report={'status':'CAM CONSISTENCY CHECKS PASS / MANUFACTURER RELEASE PENDING','checks':checks,
'originMm':[0,0],'boardSizeMm':[64,60],'viaCount':len(vias),'platedSlots':len(slots),'nonPlatedHoles':len(npth),
'minimumNominalMaskWebMm':round(minimum[0],6),'minimumMaskWebBetween':minimum[1],
'ipcCoordinateUnitMm':.00254,'sha256':{str(p.relative_to(R)):hashlib.sha256(p.read_bytes()).hexdigest() for p in files},
'notVerified':['JLC placement-library registration/centroids','Order-specific stack-up and impedance coupon approval','CAM edits or process qualification at manufacturer','Physical power, HDMI and thermal performance']}
(O/'manufacturing-audit.json').write_text(json.dumps(report,indent=2)+'\n')
print(f'{len(checks)} manufacturing checks passed; {len(vias)} tented vias, {len(slots)} plated slots, minimum nominal mask web {minimum[0]:.3f} mm')