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))