mohan-bee/flight-controller-v2
A 64 mm STM32F405 flight-controller PCB with ICM-42688 IMU, SPI flash, USB-C/ESD, battery buck and 3.3 V regulation, four motor outputs, service I/O, and PETG standoff mounting hardware.
- Version
- 0.1.0
- License
- ISC
- Stars
- 0
tools/audit-copper.py
import json, math, sys
from pathlib import Path
from shapely.geometry import Point, LineString, Polygon, box
from shapely.affinity import rotate, translate
from shapely.strtree import STRtree
root = Path(__file__).resolve().parents[1]
c = json.loads((root / 'dist/index/circuit.json').read_text())
sources = {x['source_port_id']: x for x in c if x['type'] == 'source_port'}
ports = {x['pcb_port_id']: x for x in c if x['type'] == 'pcb_port'}
traces = {x['source_trace_id']: x for x in c if x['type'] == 'source_trace'}
netnames = {x['subcircuit_connectivity_map_key']: x['name'] for x in c if x['type'] == 'source_net'}
pcbtraces = {x['pcb_trace_id']: x for x in c if x['type'] == 'pcb_trace'}
shapes = []
def add(geom, net, layers, ident, kind):
assert geom.is_valid, ident
shapes.append(dict(geom=geom, net=net, layers=set(layers), id=ident, kind=kind))
def buffered(shape, radius):
# Circumscribed approximation: never underestimate circular copper.
return shape.buffer(radius / math.cos(math.pi / 256), resolution=64)
def pill(w, h):
r = min(w, h) / 2
if w >= h:
return buffered(LineString([(-(w-h)/2, 0), ((w-h)/2, 0)]), r) if w > h else buffered(Point(0,0),r)
return buffered(LineString([(0, -(h-w)/2), (0, (h-w)/2)]), r)
for x in c:
t = x['type']
if t in ['pcb_smtpad', 'pcb_plated_hole']:
source = sources[ports[x['pcb_port_id']]['source_port_id']] if x.get('pcb_port_id') else None
net = source.get('subcircuit_connectivity_map_key', 'unassigned_' + source['source_port_id']) if source else 'unassigned_' + x[t+'_id']
if x['shape'] == 'circle':
geom = buffered(Point(0,0),x['radius'] if t == 'pcb_smtpad' else x['outer_diameter']/2)
elif x['shape'] == 'circular_hole_with_rect_pad':
w,h=x['rect_pad_width'],x['rect_pad_height']
geom=rotate(box(-w/2,-h/2,w/2,h/2),x.get('rect_ccw_rotation',0),origin=(0,0))
else:
w,h = (x['width'],x['height']) if t == 'pcb_smtpad' else (x['outer_width'],x['outer_height'])
geom = box(-w/2,-h/2,w/2,h/2) if x['shape'] == 'rect' else pill(w,h)
assert x['shape'] in ['rect','pill','rotated_pill'], x
geom = translate(rotate(geom, x.get('ccw_rotation',0), origin=(0,0)), x['x'],x['y'])
add(geom,net,[x['layer']] if t == 'pcb_smtpad' else x['layers'],x[t+'_id'],'pad')
elif t == 'pcb_via':
add(buffered(Point(x['x'],x['y']),x['outer_diameter']/2), x['subcircuit_connectivity_map_key'],x['layers'],x['pcb_via_id'],'via')
elif t == 'pcb_trace':
net = traces[x['source_trace_id']]['subcircuit_connectivity_map_key']
r = x['route']
for i, (a,b) in enumerate(zip(r,r[1:])):
if a['route_type'] != 'wire' or b['route_type'] != 'wire' or a['layer'] != b['layer']: continue
segment = LineString([(a['x'],a['y']),(b['x'],b['y'])])
if segment.length == 0: segment = Point(a['x'],a['y'])
add(buffered(segment,a['width']/2),net,[a['layer']],x['pcb_trace_id']+':'+str(i),'trace')
geoms = [x['geom'] for x in shapes]
tree = STRtree(geoms)
violations = []
for i,a in enumerate(shapes):
for j in tree.query(a['geom'].buffer(.201)):
j=int(j)
if j<=i: continue
b=shapes[j]
if a['net']==b['net'] or not a['layers'] & b['layers']: continue
gap=a['geom'].distance(b['geom'])
required=.2 if 'pad' in (a['kind'],b['kind']) else (.1 if a['kind']==b['kind']=='via' else .15)
if gap+.001 < required:
violations.append(dict(a=a['id'],b=b['id'],nets=[netnames.get(a['net'],a['net']),netnames.get(b['net'],b['net'])],gap_mm=round(gap,6),required_mm=required,layers=sorted(a['layers'] & b['layers'])))
holes = [x for x in c if x['type']=='pcb_hole']
for hole in holes:
center=Point(hole['x'],hole['y'])
for i in tree.query(center.buffer(hole['hole_diameter']/2+.31)):
a=shapes[int(i)]
if a['kind'] == 'pad': continue # Source rule specifies trace-to-hole clearance.
gap=a['geom'].distance(center)-hole['hole_diameter']/2
if gap+.001<.3:
violations.append(dict(a=a['id'],b=hole['pcb_hole_id'],gap_mm=round(gap,6),required_mm=.3))
report=dict(status='pass' if not violations else 'fail', copper_primitives=len(shapes), violations=violations,
scope='Independent Shapely copper-distance audit: pads, through vias, track segments and NPTHs on all four layers; excludes copper pours, silkscreen, mask and connectivity. Circular approximation is conservative; 0.001 mm numerical tolerance.')
(root/'validation/tscircuit-independent-copper.json').write_text(json.dumps(report,indent=2)+'\n')
print(report['status'],len(shapes),'copper primitives;',len(violations),'violations')
print(json.dumps(violations[:12],indent=2))
if __name__ == '__main__':
sys.exit(bool(violations))