seveibar/pd-dual-stepper-controller

Bottom-side PCB silkscreen artwork consisting of embedded mirrored TS Circuit SVG logos, with via-clearance-aware lower-board placement.

Version
0.1.0
License
unset
Stars
0

scripts/audit-dual-power.py

"""Check motor/input power uses outer copper, with >=0.3 mm exposed tracks.
Thin reconstruction links entirely inside pads or wider same-net copper are not
physical necks. Kelvin telemetry and low-current voltage-sense branches are
excluded from the load-path set.
"""
import json,sys,hashlib
from pathlib import Path
from collections import defaultdict
from shapely.geometry import Point,LineString,Polygon,box
from shapely.affinity import rotate,translate
from shapely.ops import unary_union
from copper_geometry import trace_segments
path=Path(sys.argv[1] if len(sys.argv)>1 else 'dist/dual/circuit.json');j=json.loads(path.read_text());parent={}
def root(x):
 parent.setdefault(x,x)
 if parent[x]!=x:parent[x]=root(parent[x])
 return parent[x]
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:]:parent[root(x)]=root(ids[0])
st={e['source_trace_id']:e for e in j if e['type']=='source_trace'};pp={e['pcb_port_id']:e for e in j if e['type']=='pcb_port'};gnd=root(next(e['source_net_id'] for e in j if e['type']=='source_net' and e['name']=='GND'))
source_net={k:root((e['connected_source_port_ids']+e['connected_source_net_ids'])[0]) for k,e in st.items()}
loads={k for k,e in st.items() if source_net[k]!=gnd and (e.get('min_trace_thickness',0)>=.5 or e.get('name','').startswith(('ISEN_','VM_BULK')))}
segments=[];pads=defaultdict(list);wide=defaultdict(list)
for e in j:
 if e['type']=='pcb_trace' and e.get('source_trace_id') in loads:
  n=source_net[e['source_trace_id']]
  for a,b,l,w in trace_segments(e['route']):
   segments.append((e['pcb_trace_id'],n,a,b,l,w))
   if w>=.3-1e-8:wide[n,l].append(LineString([a,b]).buffer(w/2))
 if e['type'] not in ['pcb_smtpad','pcb_plated_hole'] or e.get('pcb_port_id') not in pp:continue
 n=root(pp[e['pcb_port_id']]['source_port_id']);prefix='outer_' if e['type']=='pcb_plated_hole' else ''
 if e['shape']=='polygon':g=Polygon([(p['x'],p['y']) for p in e['points']])
 else:
  if e['shape']=='circle':g=Point(0,0).buffer(e.get(prefix+'diameter',2*e.get('radius',0))/2,quad_segs=32)
  else:
   w,h=e[prefix+'width'],e[prefix+'height'];r=min(w,h)/2 if 'pill' in e['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=32) if min(w,h)<=2*r+1e-8 else box(-w/2+r,-h/2+r,w/2-r,h/2-r).buffer(r,quad_segs=32)
   else:g=box(-w/2,-h/2,w/2,h/2)
  g=translate(rotate(g,e.get('ccw_rotation',0),origin=(0,0)),e['x'],e['y'])
 for l in e.get('layers',[e.get('layer')]):pads[n,l].append(g)
cover={k:unary_union(pads[k]+wide[k]) for k in set(pads)|set(wide)};violations=[]
for tid,n,a,b,l,w in segments:
 if l not in ['top','bottom']:violations.append({'trace':tid,'issue':'inner-layer load path','layer':l})
 if w<.3-1e-8:
  exposed=LineString([a,b]).difference(cover[n,l].buffer(1e-6))
  if exposed.length>1e-5:violations.append({'trace':tid,'issue':'exposed track below 0.3 mm','widthMm':w,'lengthMm':exposed.length})
r={'status':'pass' if not violations else 'fail','circuitJsonSha256':hashlib.sha256(path.read_bytes()).hexdigest(),'loadSourceCount':len(loads),'loadRouteCount':len(set(x[0] for x in segments)),'allowedLoadLayers':['top','bottom'],'minimumExposedLoadTrackWidthMm':.3,'violations':violations}
Path('reports/power-copper-audit.json').write_text(json.dumps(r,indent=2)+'\n');print(json.dumps(r,indent=2));sys.exit(bool(violations))