MustafaMulla29/rp2040-bldc-motor-controller-new

A USB‑C PD–powered RP2040 BLDC motor controller PCB with 3.3 V logic, MCT8329A sensorless commutation, three MOSFET half-bridges, protected power switching, USB data, controls, and motor connectors.

Version
0.3.82
License
unset
Stars
0

scripts/pcb-audit/audit-jlcpcb.py

"""Read-only geometry/BOM preflight against selected published JLCPCB limits.
This is not JLC's own DFM review. Input and generated exports are not modified.
"""
import sys,json,math,collections,hashlib
from pathlib import Path
import geometry as R
from shapely.geometry import Point,LineString,Polygon,box
from shapely.affinity import rotate
from shapely.strtree import STRtree
P=Path(__file__).resolve().parents[2]
raw=Path(sys.argv[1]).read_bytes() if len(sys.argv)>1 else (P/'dist/index/circuit.json').read_bytes();D=json.loads(raw)
of=lambda t:[x for x in D if x['type']==t]
S={x['source_component_id']:x for x in of('source_component')};C={x['pcb_component_id']:x for x in of('pcb_component')}
SP={x['source_port_id']:x for x in of('source_port')};PP={x['pcb_port_id']:x for x in of('pcb_port')};ST={x['source_trace_id']:x for x in of('source_trace')}
NN={x['source_net_id']:x['name'] for x in of('source_net')};NK={x['subcircuit_connectivity_map_key']:x['name'] for x in of('source_net')}
xy=lambda p:(p['x'],p['y'])
LAY=['top','inner1','inner2','bottom']
def pill(x,y,w,h,ang=0):
 g=LineString([(x-max(0,w-h)/2,y-max(0,h-w)/2),(x+max(0,w-h)/2,y+max(0,h-w)/2)]).buffer(min(w,h)/2,quad_segs=48)
 return rotate(g,ang,origin=(x,y))
def padshape(p):
 if p["shape"] == "polygon": return Polygon([xy(q) for q in p["points"]])
 x,y=xy(p);k=p['shape'];ang=p.get('rect_ccw_rotation',p.get('ccw_rotation',0))
 if k=='circle':return Point(x,y).buffer(p.get('outer_diameter',p.get('radius',0)*2)/2,quad_segs=48)
 w,h=p.get('rect_pad_width',p.get('outer_width',p.get('width'))),p.get('rect_pad_height',p.get('outer_height',p.get('height')))
 if k in ['pill','rotated_pill']:return pill(x,y,w,h,ang)
 if k in ['rect','rotated_rect','rotated_pill_hole_with_rect_pad']:return rotate(box(x-w/2,y-h/2,x+w/2,y+h/2),ang,origin=(x,y))
 raise ValueError(p)
def holeshape(p):
 x,y=xy(p)
 if p.get('hole_diameter') is not None:return Point(x,y).buffer(p['hole_diameter']/2,quad_segs=48)
 return pill(x,y,p['hole_width'],p['hole_height'],p.get('hole_ccw_rotation',p.get('ccw_rotation',0)))
def padnet(p):
 s=SP[PP[p['pcb_port_id']]['source_port_id']];return NK.get(s.get('subcircuit_connectivity_map_key'),'NC_'+s['source_port_id'])
def padlabel(p):
 s=SP[PP[p['pcb_port_id']]['source_port_id']];return S[s['source_component_id']]['name']+'.'+str(s['pin_number'])
records=[];holes=[];annuli=[]
def add(kind,id,net,layers,g,label=''):
 records.append(dict(kind=kind,id=id,net=net,layers=layers,g=g,label=label))
for p in of('pcb_smtpad')+of('pcb_plated_hole'):
 kind='smd' if p['type']=='pcb_smtpad' else 'pth';id=p.get('pcb_smtpad_id',p.get('pcb_plated_hole_id'))
 g=padshape(p);net=padnet(p);label=padlabel(p);ls=p.get('layers',[p.get('layer','top')]);add(kind,id,net,ls,g,label)
 if kind=='pth':
  h=holeshape(p);holes.append(dict(kind=kind,id=id,net=net,g=h,label=label))
  ring=g.boundary.distance(h.boundary) if g.covers(h) else -1;annuli.append(dict(label=label,ring=ring))
for v in of('pcb_via'):
 net=NN.get(v.get('source_net_id')) or NK.get(v.get('subcircuit_connectivity_map_key'));assert net
 add('via',v['pcb_via_id'],net,v['layers'],Point(*xy(v)).buffer(v['outer_diameter']/2,quad_segs=48))
 holes.append(dict(kind='via',id=v['pcb_via_id'],net=net,g=holeshape(v),label=v['pcb_via_id']))
for t in of('pcb_trace'):
 net=NN.get(t.get('connection_name')) or NK.get(ST.get(t.get('source_trace_id'),{}).get('subcircuit_connectivity_map_key'));assert net
 for a,b in zip(t['route'],t['route'][1:]):
  if a['route_type']==b['route_type']=='wire' and a['layer']==b['layer'] and math.dist(xy(a),xy(b))>1e-9:
   add('trace',t['pcb_trace_id'],net,[a['layer']],LineString([xy(a),xy(b)]).buffer(a['width']/2,quad_segs=32))
for p in of('pcb_copper_pour'):
 assert p['shape']=='brep';b=p['brep_shape'];g=Polygon([xy(v) for v in b['outer_ring']['vertices']],[[xy(v) for v in r['vertices']] for r in b.get('inner_rings',[])])
 add('pour',p['pcb_copper_pour_id'],NN[p['source_net_id']],[p['layer']],g)
checks=collections.defaultdict(dict)
def hit(rule,key,data):
 old=checks[rule].get(key)
 if old is None or data['gap']<old['gap']:checks[rule][key]=data
# Hole to unrelated trace/copper: use separate published inner-layer rules.
for l in LAY:
 rs=[r for r in records if l in r['layers']];tree=STRtree([r['g'] for r in rs])
 for h in holes:
  for i in tree.query(h['g'].buffer(.301)):
   r=rs[int(i)]
   if h['net']==r['net']:continue
   if l.startswith('inner'):limit=.2 if h['kind']=='via' else .3;rule=h['kind']+'_hole_to_inner_copper'
   elif r['kind']=='trace':limit=.2 if h['kind']=='via' else .28;rule=h['kind']+'_hole_to_outer_trace'
   else:continue
   gap=h['g'].distance(r['g'])
   if gap<limit-1e-5:hit(rule,(h['id'],r['id'],l),dict(hole=h['label'],hole_net=h['net'],other=r['label'] or r['id'],other_net=r['net'],layer=l,gap=gap,required=limit,location=list(h['g'].centroid.coords)[0]))
 # Test all foreign copper pairs as well as drill and footprint rules.
 for i,a in enumerate(rs):
  for jj in tree.query(a['g'].buffer(.10001)):
   j=int(jj);b=rs[j]
   if j<=i or a['net']==b['net']:continue
   gap=a['g'].distance(b['g'])
   if gap<.1-1e-5:hit('foreign_copper_gap',(a['id'],b['id'],l),dict(a=a['label'] or a['id'],a_net=a['net'],b=b['label'] or b['id'],b_net=b['net'],gap=gap,required=.1,layer=l))
 sm=[r for r in rs if r['kind']=='smd'];stree=STRtree([r['g'] for r in sm])
 for i,a in enumerate(sm):
  for j in stree.query(a['g'].buffer(.151)):
   j=int(j);b=sm[j]
   if j<=i or a['net']==b['net']:continue
   gap=a['g'].distance(b['g'])
   if gap<.15-1e-5:hit('smd_pad_gap',(a['id'],b['id']),dict(a=a['label'],b=b['label'],gap=gap,required=.15,layer=l))
htree=STRtree([h['g'] for h in holes])
for i,a in enumerate(holes):
 for j in htree.query(a['g'].buffer(.451)):
  j=int(j);b=holes[j]
  if j<=i:continue
  if a['kind']==b['kind']=='via':lim=.2;rule='via_hole_to_hole'
  elif a['kind']==b['kind']=='pth':lim=.45;rule='pth_hole_to_hole'
  else:continue # Mixed hole class is not expressly resolved by the published table.
  gap=a['g'].distance(b['g'])
  if gap<lim-1e-5:hit(rule,(a['id'],b['id']),dict(a=a['label'],b=b['label'],gap=gap,required=lim))
board=of('pcb_board')[0]
if board.get('outline'):
 outline=Polygon([xy(p) for p in board['outline']])
else:
 center=board.get('center',{'x':0,'y':0})
 outline=box(center['x']-board['width']/2,center['y']-board['height']/2,center['x']+board['width']/2,center['y']+board['height']/2)
for r in records:
 gap=outline.boundary.distance(r['g']) if outline.covers(r['g']) else -1
 if gap<.2-1e-5:hit('copper_edge',(r['id'],),dict(id=r['label'] or r['id'],net=r['net'],gap=gap,required=.2))
# Native pad testpoints are fabricated copper, not purchased assembly parts.
fitted=[s for s in S.values() if not (s['ftype']=='simple_test_point' and s.get('footprint_variant')=='pad')]
missing=[dict(name=s['name'],type=s['ftype']) for s in fitted if not s.get('manufacturer_part_number')]
smalltext=[dict(text=t['text'],font_size=t['font_size'],layer=t['layer']) for t in of('pcb_silkscreen_text') if t.get('font_size',2)<1]
O=dict(sha256=hashlib.sha256(raw).hexdigest(),real_components=len(fitted),native_testpads=len(S)-len(fitted),missing_mpn=missing,small_silkscreen_text=smalltext,pth_annuli=annuli,findings={k:sorted(v.values(),key=lambda x:x['gap']) for k,v in checks.items()})
out=R.BASE/'jlcpcb-preflight.json';out.write_text(json.dumps(O,indent=2)+'\n')
print('SHA',O['sha256'],'missing parts',len(missing),'small silkscreen text',len(smalltext),'min PTH ring',min(x['ring'] for x in annuli))
for k,v in O['findings'].items():print(k,len(v),json.dumps(v[:4]))