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-layout.py

"""Check the layout constraints requested in the power-routing review."""
import json,math,sys,os,hashlib
from pathlib import Path
import shapely
from shapely.geometry import Point, Polygon, box
from shapely.affinity import rotate
path=Path(sys.argv[1]);raw=path.read_bytes();c=json.loads(raw)
nets={r['source_net_id']:r['name'] for r in c if r['type']=='source_net'}
critical={'VBUS_RAW','PD_COMMON_S','PD_SWITCHED','PD9','PD9_FUSED','PD_LOAD','VMOTOR','LS_RETURN','PHASE_U','PHASE_V','PHASE_W'}
violations=[];lengths={};vias=set()
for t in c:
 if t['type']=='pcb_via':vias.add((round(t['x'],6),round(t['y'],6)))
 if t['type']!='pcb_trace':continue
 net=nets.get(t.get('connection_name'))
 for a,b in zip(t['route'],t['route'][1:]):
  if a['route_type']!=b['route_type'] or a['route_type']!='wire' or a['layer']!=b['layer']:continue
  length=math.hypot(a['x']-b['x'],a['y']-b['y']);w=min(a['width'],b['width'])
  if length<1e-8:continue
  if net in critical and w>=.8-1e-7:
   lengths[a['layer']]=lengths.get(a['layer'],0)+length
   if a['layer'] not in ['top','bottom']:violations.append({'trace':t['pcb_trace_id'],'net':net,'layer':a['layer'],'width':w})
  if net=='LOGIC_SW' and a['layer']!='top':violations.append({'trace':t['pcb_trace_id'],'net':net,'message':'Buck switch loop must stay on top'})
parts=[r for r in c if r['type']=='pcb_component']
for p in parts:
 if p['layer']!='top':violations.append({'component':p['pcb_component_id'],'message':'Bottom-side assembly'})
# Check physical component courtyards, not only electrical pad clearance.
source={r['source_component_id']:r for r in c if r['type']=='source_component'}
components={r['pcb_component_id']:r for r in parts}
names={k:source.get(v['source_component_id'],{}).get('name',k) for k,v in components.items()}
courtyards={}
for r in c:
 kind=r['type']
 if not kind.startswith('pcb_courtyard_'):continue
 if kind=='pcb_courtyard_outline':shape=Polygon([(p['x'],p['y']) for p in r['outline']])
 elif kind=='pcb_courtyard_circle':shape=Point(r['center']['x'],r['center']['y']).buffer(r['radius'])
 elif kind=='pcb_courtyard_rect':
  x,y=r['center']['x'],r['center']['y'];w,h=r['width'],r['height']
  shape=rotate(box(x-w/2,y-h/2,x+w/2,y+h/2),r.get('ccw_rotation',0),origin=(x,y))
 else:raise ValueError('Unhandled courtyard: '+kind)
 k=r['pcb_component_id'];courtyards[k]=shapely.union_all([courtyards[k],shape]) if k in courtyards else shape
# Bare service pads also need unobstructed space for a probe approach.
# Reserve 0.5 mm beyond the 1.5 mm pad, matching the chosen test-pad footprint.
board=next(r for r in c if r['type']=='pcb_board')
bx,by=board['center']['x'],board['center']['y']
boardArea=box(bx-board['width']/2,by-board['height']/2,bx+board['width']/2,by+board['height']/2)
probePads=[]
for k,part in components.items():
 sc=source.get(part['source_component_id'],{})
 if sc.get('ftype')!='simple_test_point' or sc.get('footprint_variant')!='pad':continue
 probePads.append(sc['name'])
 pad=next((r for r in c if r['type']=='pcb_smtpad' and r.get('pcb_component_id')==k),None)
 if pad is None or pad.get('shape')!='circle':
  violations.append({'component':sc['name'],'message':'Expected circular service pad'});continue
 required=Point(pad['x'],pad['y']).buffer(pad['radius']+.5)
 if k not in courtyards or required.difference(courtyards[k].buffer(1e-6)).area>1e-6:
  violations.append({'component':sc['name'],'message':'Missing courtyard or less than 0.5 mm radial probe clearance'})
 if required.difference(boardArea).area>1e-6:
  violations.append({'component':sc['name'],'message':'Probe courtyard extends beyond the board edge'})
spacing=[];items=list(courtyards.items());screws=[]
for r in c:
 if r['type']=='pcb_hole' and abs(r.get('hole_diameter',0)-3.2)<1e-6:
  screws.append((r['pcb_hole_id'],Point(r['x'],r['y']).buffer(3.2,quad_segs=64)))
for i,(ka,a) in enumerate(items):
 for kb,b in items[:i]:
  if components[ka]['layer']!=components[kb]['layer']:continue
  if a.intersection(b).area>1e-6:spacing.append({'components':[names[ka],names[kb]],'message':'Overlapping component courtyards'})
 for id,b in screws:
  if a.intersection(b).area>1e-6:spacing.append({'component':names[ka],'hole':id,'message':'Component courtyard intrudes into 6.4 mm screw-head envelope'})
violations.extend(spacing)
# Record critical bypass placement distances as measurements. They are not a
# substitute for routed-loop, signal-integrity or thermal qualification.
sp={r['source_port_id']:r for r in c if r['type']=='source_port'}
pin={}
for r in c:
 if r['type']!='pcb_port':continue
 q=sp.get(r['source_port_id'],{});part=source.get(q.get('source_component_id'))
 if part and q.get('pin_number') is not None:pin[f"{part['name']}.{q['pin_number']}"]=r
measurements=[]
for a,b in [('U11.8','C140.1'),('U5.45','C48.1'),('U5.50','C47.1'),('U5.23','C46.1'),('U5.44','C49.1'),('U6.8','C53.1'),('U7.2','C80.1'),('U7.5','C70.1'),('U7.36','C78.1'),('U7.26','C77.1'),('U2.5','C5.1'),('U3.3','C10.1')]:
 p,q=pin[a],pin[b];measurements.append({'from':a,'to':b,'padCenterDistanceMm':math.hypot(p['x']-q['x'],p['y']-q['y'])})
# Keep the required regulator output capacitor in its reviewed near location.
# Routed continuity and width are checked independently by audit-widths.py.
regulatorCap=next(x for x in measurements if x['from']=='U5.45')
if regulatorCap['padCenterDistanceMm']>3.6:violations.append({'component':'C48','message':'VREG_OUT capacitor exceeds reviewed 3.6 mm placement target'})
# Require a short, direct top-layer flash bypass and a nearby ground via.
# These are project layout targets, not manufacturer-specified distance limits.
flash={}
for name,maximum in [('flash_bypass_vcc',2.0),('flash_bypass_ground',1.1)]:
 t=next((r for r in c if r['type']=='pcb_trace' and r['pcb_trace_id']==name),None)
 if t is None:
  violations.append({'trace':name,'message':'Missing local flash bypass route'});continue
 rr=t['route'];length=sum(math.hypot(a['x']-b['x'],a['y']-b['y']) for a,b in zip(rr,rr[1:]))
 ok=all(q['route_type']=='wire' and q['layer']=='top' and q['width']>=.2-1e-7 for q in rr) and length<=maximum
 flash[name]={'lengthMm':length,'maximumLengthMm':maximum,'topLayerWidthPass':ok}
 if not ok:violations.append({'trace':name,'message':'Flash bypass route exceeds length/layer/width target'})
# The PD regulator capacitor ordering now permits one short top-layer trace
# for each regulator. The 2.2 mm threshold is this design's regression target.
pdBypass={}
for trace,net,a,b in [('pd_1v2_direct','PD_1V2','U1.21','C3.1'),('pd_2v7_direct','PD_2V7','U1.23','C2.1')]:
 t=next((r for r in c if r['type']=='pcb_trace' and r['pcb_trace_id']==trace),None)
 if t is None:
  violations.append({'trace':trace,'message':'Missing direct PD bypass route'});continue
 rr=t['route'];length=sum(math.hypot(x['x']-y['x'],x['y']-y['y']) for x,y in zip(rr,rr[1:]))
 ok=all(q['route_type']=='wire' and q['layer']=='top' and q['width']>=.2-1e-7 for q in rr) and length<=2.2 and nets.get(t.get('connection_name'))==net
 # The physical connectivity audit separately verifies the entire net.
 anchors=all(any(math.hypot(q['x']-pin[k]['x'],q['y']-pin[k]['y'])<1e-6 for q in [rr[0],rr[-1]]) for k in [a,b])
 pdBypass[net]={'from':a,'to':b,'lengthMm':length,'maximumLengthMm':2.2,'topLayerWidthPass':ok,'padAnchorsPass':anchors}
 if not(ok and anchors):violations.append({'trace':trace,'message':'PD bypass length/layer/width/anchor target failed'})
# Keep the shrunken board's local power-switch bypass short on top copper.
powerBypass={}
for name,maximum,width in [('layout_PD_LOAD_1',3.15,.3),('ground_join_C140_2',1.65,.2)]:
 t=next((r for r in c if r['type']=='pcb_trace' and r['pcb_trace_id']==name),None)
 if t is None:
  violations.append({'trace':name,'message':'Missing power-switch bypass route'});continue
 rr=t['route'];length=sum(math.hypot(a['x']-b['x'],a['y']-b['y']) for a,b in zip(rr,rr[1:]))
 ok=all(q['route_type']=='wire' and q['layer']=='top' and q['width']>=width-1e-7 for q in rr) and length<=maximum
 powerBypass[name]={'lengthMm':length,'maximumLengthMm':maximum,'minimumWidthMm':width,'pass':ok}
 if not ok:violations.append({'trace':name,'message':'Power-switch bypass exceeds reviewed length/layer/width target'})
# Local layout regression limits for C90; no component current rating is inferred.
bridgeBypass={}
for name,maximum in [('bridge_bypass_0',5.35),('local_ground_C90_2',1.5)]:
 t=next((r for r in c if r['type']=='pcb_trace' and r['pcb_trace_id']==name),None)
 if t is None:
  violations.append({'trace':name,'message':'Missing C90 bypass route'});continue
 rr=t['route'];length=sum(math.hypot(a['x']-b['x'],a['y']-b['y']) for a,b in zip(rr,rr[1:]))
 ok=all(q['route_type']=='wire' and q['layer']=='top' and q['width']>=.6-1e-7 for q in rr) and length<=maximum
 bridgeBypass[name]={'lengthMm':length,'maximumLengthMm':maximum,'minimumWidthMm':.6,'pass':ok}
 if not ok:violations.append({'trace':name,'message':'C90 bypass exceeds reviewed length/layer/width target'})
viaNets={r['subcircuit_connectivity_map_key']:r['name'] for r in c if r['type']=='source_net'}
viaCounts={};manualGround=0
for r in c:
 if r['type']!='pcb_via':continue
 net=viaNets.get(r.get('subcircuit_connectivity_map_key'),nets.get(r.get('source_net_id')))
 viaCounts[net]=viaCounts.get(net,0)+1
 if r.get('pcb_port_ids') and net=='GND':manualGround+=1
report={'sha256':hashlib.sha256(raw).hexdigest(),'scope':'Main supply/motor runs >=0.8 mm on outer copper; buck switch node on top; top-side assembly; component/screw clearance; local flash and PD regulator bypass. Does not certify electrical or thermal performance.','uniqueVias':len(vias),'viaCountsByNet':viaCounts,'explicitGroundAndThermalVias':manualGround,'pcbComponentRecords':len(parts),'courtyardsChecked':len(courtyards),'testPadProbeCourtyardsChecked':len(probePads),'screwHeadEnvelopeDiameterMm':6.4,'componentSpacingFindings':spacing,'criticalBypassPlacement':measurements,'flashBypass':flash,'pdRegulatorBypass':pdBypass,'powerSwitchBypass':powerBypass,'bridgeBypass':bridgeBypass,'widePowerLengthMmByLayer':lengths,'violations':violations}
out=Path(os.environ.get('PCB_AUDIT_OUTPUT',Path(__file__).resolve().parents[2]/'assembly/dist/results'));out.mkdir(parents=True,exist_ok=True);(out/'layout-quality.json').write_text(json.dumps(report,indent=2)+'\n')
print('Layout constraints:',len(violations),'findings;',len(vias),'unique vias;',len(courtyards),'component courtyards checked;',manualGround,'explicit ground/thermal vias')
if violations:
 print(json.dumps(violations,indent=2));raise SystemExit(1)