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