imrishabh18/rp2040-motor-controller

An RP2040-based NEMA17 stepper-motor controller with USB-C programming and PD motor-power negotiation, DRV8825 dual H-bridge drive, current/temperature telemetry, protected power filtering, status RGB LED, buzzer alarm, and optional magnetic encoder.

Version
1.0.42
License
unset
Stars
3

scripts/audit-manufacturing.py

import json,collections,math,hashlib,sys
from pathlib import Path
from shapely import make_valid
from shapely.geometry import Point,Polygon,LineString,box
from shapely.affinity import rotate,translate
from shapely.strtree import STRtree
from copper_geometry import trace_segments, subtract_drills
input_path=Path(sys.argv[1] if len(sys.argv)>1 else 'dist/index/circuit.json')
j=json.loads(input_path.read_text())
class DSU:
 def __init__(self):self.p={}
 def root(self,x):
  self.p.setdefault(x,x)
  if self.p[x]!=x:self.p[x]=self.root(self.p[x])
  return self.p[x]
 def join(self,a,b):self.p[self.root(a)]=self.root(b)
net=DSU()
for e in j:
 ids=e.get('connected_source_port_ids',[])+e.get('connected_source_net_ids',[]) if e['type']=='source_trace' else e.get('source_port_ids',[]) if e['type']=='source_component_internal_connection' else []
 for x in ids[1:]:net.join(ids[0],x)
sc={e['source_component_id']:e for e in j if e['type']=='source_component'}
sp={e['source_port_id']:e for e in j if e['type']=='source_port'}
pp={e['pcb_port_id']:e for e in j if e['type']=='pcb_port'}
st={e['source_trace_id']:e for e in j if e['type']=='source_trace'}
pt={e['pcb_trace_id']:e for e in j if e['type']=='pcb_trace'}
def trace_net(e):
 if e.get('source_trace_id') in st:
  s=st[e['source_trace_id']];ids=s.get('connected_source_port_ids',[])+s.get('connected_source_net_ids',[])
  if ids:return net.root(ids[0])
 if e.get('source_net_id'):return net.root(e['source_net_id'])
 if e.get('pcb_trace_id') in pt and e['type']!='pcb_trace':return trace_net(pt[e['pcb_trace_id']])
 for r in e.get('route',[]):
  for k in ('start_pcb_port_id','end_pcb_port_id'):
   if r.get(k) in pp:return net.root(pp[r[k]]['source_port_id'])
 return None
names=collections.defaultdict(set)
for e in j:
 if e['type']=='source_net':names[net.root(e['source_net_id'])].add(e['name'])
for p,s in sp.items():names[net.root(p)].add(sc.get(s['source_component_id'],{'name':s['source_component_id']})['name']+'.'+s['name'])
def label(n):return ', '.join(sorted(names[n]))
def pad(e,outer=False,hole=False):
 if e.get("shape")=="polygon":return make_valid(Polygon([(p["x"],p["y"]) for p in e["points"]]))
 prefix='hole_' if hole else 'outer_' if outer else '';shape=e.get('shape',e.get('hole_shape','circle'))
 if shape=='circle':g=Point(0,0).buffer(e.get(prefix+'diameter',2*e.get('radius',0))/2,quad_segs=48)
 else:
  w,h=e[prefix+'width'],e[prefix+'height'];r=min(w,h)/2 if 'pill' in shape else e.get('corner_radius',0)
  if r:g=LineString([(-w/2+r,-h/2+r),(w/2-r,h/2-r)]).buffer(r,quad_segs=48) if abs(w-2*r)<1e-8 or abs(h-2*r)<1e-8 else box(-w/2+r,-h/2+r,w/2-r,h/2-r).buffer(r,quad_segs=48)
  else:g=box(-w/2,-h/2,w/2,h/2)
 return translate(rotate(g,e.get('ccw_rotation',0),origin=(0,0)),e['x'],e['y'])
shapes=[];meta=[];barrels=[];holes=[];unknown=[]
def add(g,l,e,n):
 ids=[]
 if g.is_empty:return ids
 if g.geom_type in ('MultiPolygon','GeometryCollection'):
  for p in g.geoms:ids+=add(p,l,e,n)
 else:ids=[len(shapes)];shapes.append(g);meta.append({'layer':l,'id':e.get(e['type']+'_id'),'net':n,'type':e['type'],'port':e.get('pcb_port_id')})
 return ids
for e in j:
 t=e['type'];n=None
 if t in ('pcb_smtpad','pcb_plated_hole'):
  p=pp.get(e.get('pcb_port_id'),{});n=net.root(p['source_port_id']) if p.get('source_port_id') else None
  if t=='pcb_smtpad':add(pad(e),e['layer'],e,n)
  else:
   h=pad(e,hole=True);g=pad(e,outer=True).difference(h);ids=[]
   for l in e['layers']:ids+=add(g,l,e,n)
   barrels.append(ids);holes.append((h,e,n))
 elif t=='pcb_via':
  n=trace_net(e);h=Point(e['x'],e['y']).buffer(e['hole_diameter']/2,quad_segs=48);g=Point(e['x'],e['y']).buffer(e['outer_diameter']/2,quad_segs=48).difference(h);ids=[]
  for l in e['layers']:ids+=add(g,l,e,n)
  barrels.append(ids);holes.append((h,e,n))
 elif t=='pcb_trace':
  n=trace_net(e)
  for a,b,layer,width in trace_segments(e['route']):
   g=LineString([a,b]).buffer(width/2,quad_segs=48);add(g,layer,e,n)
 elif t=='pcb_copper_pour':
  n=net.root(e['source_net_id']);b=e['brep_shape'];xy=lambda r:[(v['x'],v['y']) for v in r['vertices']];add(make_valid(Polygon(xy(b['outer_ring']),[xy(r) for r in b['inner_rings']])),e['layer'],e,n)
 else:continue
 if n is None:unknown.append(e.get(t+'_id'))
shapes,meta,barrels=subtract_drills(shapes,meta,barrels,[(h,e['layers']) for h,e,n in holes],lambda m:m['layer'])
physical=DSU();shorts=[]
for i in range(len(shapes)):physical.root(i)
for ids in barrels:
 for x in ids[1:]:physical.join(ids[0],x)
for l in ('top','inner1','inner2','bottom'):
 inds=[i for i,m in enumerate(meta) if m['layer']==l];tree=STRtree([shapes[i] for i in inds])
 for i in inds:
  for k in tree.query(shapes[i].buffer(1e-7)):
   o=inds[k]
   if o<=i:continue
   if shapes[i].distance(shapes[o])<=1e-7:
    physical.join(i,o)
    if meta[i]['net'] is not None and meta[o]['net'] is not None and meta[i]['net']!=meta[o]['net']:shorts.append([meta[i],meta[o]])
copper_only_groups=len(set(physical.root(i) for i in range(len(shapes))))
# Join permanent internal contacts in assembled parts; do not join through DNP parts.
dnp={e['source_component_id'] for e in j if e['type']=='pcb_component' and e.get('do_not_place')}
internal_links=[]
for e in j:
 if e['type']!='source_component_internal_connection' or e['source_component_id'] in dnp:continue
 ports=set(e['source_port_ids']);ids=[i for i,m in enumerate(meta) if m['port'] in pp and pp[m['port']].get('source_port_id') in ports]
 for i in ids[1:]:physical.join(ids[0],i)
 internal_links.append({'component':sc[e['source_component_id']]['name'],'ports':list(ports)})
groups=collections.defaultdict(list)
for i in range(len(shapes)):groups[physical.root(i)].append(i)
netgroups=collections.defaultdict(set)
for i,m in enumerate(meta):
 if m['net'] is not None:netgroups[m['net']].add(physical.root(i))
splits=[]
for n,gs in netgroups.items():
 if len(gs)>1:
  splits.append({'net':label(n),'groups':[{'elements':sorted(set(meta[i]['id'] for i in groups[g])),'ports':sorted(set(meta[i]['port'] for i in groups[g] if meta[i]['port']))} for g in gs]})
# Manufacturer hole spacing, deduplicating coincident via records.
unique=[];seen=set()
for h,e,n in holes:
 key=(round(e['x'],6),round(e['y'],6),e.get('hole_diameter'),e.get('hole_width'),e.get('hole_height'),round(e.get('ccw_rotation',0),6))
 if key not in seen:unique.append((h,e,n));seen.add(key)
holeissues=[]
for i,(h,e,n) in enumerate(unique):
 for h2,e2,n2 in unique[i+1:]:
  d=h.distance(h2);limit=.2 if e['type']==e2['type']=='pcb_via' else .45
  if d<limit-1e-6:holeissues.append({'a':e[e['type']+'_id'],'b':e2[e2['type']+'_id'],'distanceMm':d,'requiredMm':limit,'centers':[[e['x'],e['y']],[e2['x'],e2['y']]]})
# Check JLCPCB's listed via-hole-to-track and inner-layer hole-to-copper clearances.
hole_copper_issues=[]
for layer in ('top','inner1','inner2','bottom'):
 inds=[i for i,m in enumerate(meta) if m['layer']==layer and (layer.startswith('inner') or m['type']=='pcb_trace')]
 tree=STRtree([shapes[i] for i in inds]);hits={}
 for h,e,n in unique:
  if e['type']!='pcb_via':continue
  for k in tree.query(h.buffer(.2)):
   i=inds[k];m=meta[i]
   if n==m['net'] or m['net'] is None:continue
   d=h.distance(shapes[i])
   if d<.2-1e-6:
    key=(e['pcb_via_id'],m['id'],layer)
    if key not in hits or hits[key]['distanceMm']>d:hits[key]={'via':e['pcb_via_id'],'other':m['id'],'layer':layer,'distanceMm':d,'requiredMm':.2,'viaCenter':[e['x'],e['y']]}
 hole_copper_issues.extend(hits.values())
# Pad drills and mechanical holes have different manufacturer minima from
# signal vias. Check them explicitly instead of treating a clean via report
# as evidence about every drill on the board.
rules=json.loads((Path(__file__).resolve().parents[1]/'engineering/jlcpcb-clearance-rules.json').read_text())['minimumMm']
pth_issues=[];npth_issues=[]
npths=[(pad(e,hole=True),e) for e in j if e['type']=='pcb_hole']
for layer in ('top','inner1','inner2','bottom'):
 inds=[i for i,m in enumerate(meta) if m['layer']==layer]
 tree=STRtree([shapes[i] for i in inds]);hits={}
 for h,e,n in unique:
  if e['type']!='pcb_plated_hole':continue
  limit=rules['innerPthDrillToCopper'] if layer.startswith('inner') else rules['pthDrillToTrace']
  for k in tree.query(h.buffer(limit)):
   i=inds[k];m=meta[i]
   if n==m['net'] or m['net'] is None:continue
   if not layer.startswith('inner') and m['type']!='pcb_trace':continue
   d=h.distance(shapes[i]);key=(e['pcb_plated_hole_id'],m['id'],layer)
   if d<limit-1e-6 and (key not in hits or d<hits[key]['distanceMm']):
    hits[key]={'hole':e['pcb_plated_hole_id'],'other':m['id'],'layer':layer,'distanceMm':d,'requiredMm':limit}
 pth_issues.extend(hits.values());hits={}
 for h,e in npths:
  limit=rules['npthToCopper']
  for k in tree.query(h.buffer(limit)):
   i=inds[k];m=meta[i];d=h.distance(shapes[i]);key=(e['pcb_hole_id'],m['id'],layer)
   if d<limit-1e-6 and (key not in hits or d<hits[key]['distanceMm']):
    hits[key]={'hole':e['pcb_hole_id'],'other':m['id'],'layer':layer,'distanceMm':d,'requiredMm':limit}
 npth_issues.extend(hits.values())
board=next(e for e in j if e['type']=='pcb_board')
outline=Polygon([(v['x'],v['y']) for v in board['outline']])
edge_issues=[];limit=rules['copperToBoardEdge']
for g,m in zip(shapes,meta):
 d=g.distance(outline.boundary)
 if not outline.covers(g) or d<limit-1e-6:
  edge_issues.append({'element':m['id'],'layer':m['layer'],'distanceMm':d,'requiredMm':limit})
# Imported USB power lands are polygons; include their true outlines even when
# the pinned native checker only understands rectangular and circular pads.
polygon_ids={e['pcb_smtpad_id'] for e in j if e['type']=='pcb_smtpad' and e.get('shape')=='polygon'}
polygon_issues=[]
for i,m in enumerate(meta):
 if m['id'] not in polygon_ids:continue
 for k,n in enumerate(meta):
  if i==k or m['layer']!=n['layer'] or m['net']==n['net']:continue
  if n['type'] not in ('pcb_smtpad','pcb_plated_hole','pcb_trace','pcb_via'):continue
  limit=rules['padToPad'] if n['type'] in ('pcb_smtpad','pcb_plated_hole') else rules['viaToPad'] if n['type']=='pcb_via' else rules['motorPowerToPad'] if st.get(pt[n['id']].get('source_trace_id'),{}).get('min_trace_thickness',0)>=.5 else rules['padToTrace']
  d=shapes[i].distance(shapes[k])
  if d<limit-1e-6:polygon_issues.append({'pad':m['id'],'other':n['id'],'layer':m['layer'],'distanceMm':d,'requiredMm':limit})
result={'sha256':hashlib.sha256(input_path.read_bytes()).hexdigest(),'shapeCount':len(shapes),'netsChecked':len(netgroups),'bareCopperGroups':copper_only_groups,'assembledGroups':len(groups),'internalConnections':internal_links,'unmappedElements':unknown,'shortContacts':shorts,'polygonPadClearanceViolations':polygon_issues,'splitNets':splits,'holeSpacingViolations':holeissues,'holeToCopperViolations':hole_copper_issues,'pthDrillToCopperViolations':pth_issues,'npthToCopperViolations':npth_issues,'copperToBoardEdgeViolations':edge_issues}
Path('reports/manufacturing-audit.json').write_text(json.dumps(result,indent=2))
print(json.dumps({**result,'shortContacts':len(shorts),'holeSpacingViolations':len(holeissues),'holeToCopperViolations':len(hole_copper_issues)},indent=2)[:12000])

if polygon_issues or shorts or splits or holeissues or hole_copper_issues or pth_issues or npth_issues or edge_issues or unknown:sys.exit('Manufacturing audit failed')