mohan-bee/gimbal-one-budget-pcb

A 4-layer STM32-based gimbal controller PCB with an onboard LSM6DS3TR-C IMU, three DRV8313 3-phase motor drivers, protected 3S battery input, 3.3 V buck regulation, and SWD/UART control interfaces.

Version
0.1.0
License
unset
Stars
0

audit-geometry.py

PCBPCB preview for audit-geometry.py
SchematicSchematic preview for audit-geometry.py
"""Independent copper clearance audit of the saved router output (no pours)."""
import json,pathlib,math
from collections import defaultdict
from shapely.geometry import Point,LineString,box,Polygon
from shapely import affinity
from shapely.strtree import STRtree

r=pathlib.Path(__file__).resolve().parent;d=json.loads((r/'dist/controller/circuit.json').read_text())
ts=json.loads((r/'routing/checked-traces.json').read_text())
ref=json.loads((r/'routing/reference-input.json').read_text());pn={p['pcb_port_id']:c['name'] for c in ref['connections'] for p in c['pointsToConnect']}
names={x['source_net_id']:x['name'] for x in d if x['type']=='source_net'}
items=[]
def add(net,layer,g,tag):items.append((net,layer,g,tag))
for p in d:
    if p['type'] not in ['pcb_smtpad','pcb_plated_hole','pcb_via']:continue
    if p['type']=='pcb_via':
        if not p.get('source_net_id'):continue
        g=Point(p['x'],p['y']).buffer(p['outer_diameter']/2);net=p['source_net_id'];layers=['top','inner1','inner2','bottom']
    else:
        net=pn.get(p.get('pcb_port_id'),p.get('pcb_port_id'));layers=[p.get('layer','top')] if p['type']=='pcb_smtpad' else ['top','inner1','inner2','bottom']
        w=p.get('width',p.get('outer_width',p.get('rect_pad_width',p.get('outer_diameter',p.get('radius',.5)*2))));h=p.get('height',p.get('outer_height',p.get('rect_pad_height',w)))
        if p['shape']=='circle':g=Point(0,0).buffer(w/2)
        elif p['shape']=='pill':
            g=LineString([(0,-(h-w)/2),(0,(h-w)/2)]).buffer(w/2) if h>=w else LineString([(-(w-h)/2,0),((w-h)/2,0)]).buffer(h/2)
        else:g=box(-w/2,-h/2,w/2,h/2)
        g=affinity.translate(affinity.rotate(g,p.get('ccw_rotation',0)),p['x'],p['y'])
    for layer in layers:add(net,layer,g,p.get('pcb_port_id',p.get('pcb_via_id')))
via_seen=set()
for t in ts:
    n=t['connection_name']
    for a,b in zip(t['route'],t['route'][1:]):
        if a['route_type']==b['route_type']=='wire' and a['layer']==b['layer'] and math.hypot(a['x']-b['x'],a['y']-b['y'])>1e-8:
            add(n,a['layer'],LineString([(a['x'],a['y']),(b['x'],b['y'])]).buffer(max(a['width'],b['width'])/2),t['pcb_trace_id'])
    for p in t['route']:
        key=(round(p['x'],5),round(p['y'],5))
        if p['route_type']=='via' and key not in via_seen:
            via_seen.add(key)
            for layer in ['top','inner1','inner2','bottom']:add(n,layer,Point(p['x'],p['y']).buffer(p.get('outer_diameter',.45)/2),'via '+str(key))
issues={}
for layer in ['top','inner1','inner2','bottom']:
    rows=[i for i in items if i[1]==layer];tree=STRtree([i[2] for i in rows])
    for j,(n,_,g,tag) in enumerate(rows):
        for k in tree.query(g.buffer(.099)):
            if k<=j:continue
            m,_,h,label=rows[k]
            if n is None or m is None or n==m:continue
            dist=g.distance(h)
            if dist<.099:
                key=(layer,tag,label)
                if key not in issues or issues[key]['gap']>dist:issues[key]=dict(layer=layer,net1=names.get(n,n),net2=names.get(m,m),item1=tag,item2=label,gap=round(dist,6))
out=list(issues.values());(r/'power-geometry-audit.json').write_text(json.dumps(out,indent=2))
print('Independent clearance issues:',len(out));print(json.dumps(out[:25],indent=2))
# Check the actual top pour as well as traces; unused LSM pads must stay isolated.
keepout=box(23.1,-15.65,24.9,-14.35)
top_pours=[]
for p in d:
    if p['type']=='pcb_copper_pour' and p['layer']=='top':
        b=p['brep_shape'];ring=lambda ring:[(v['x'],v['y']) for v in ring['vertices']]
        top_pours.append(Polygon(ring(b['outer_ring']),[ring(r) for r in b.get('inner_rings',[])]))
assert top_pours
for poly in top_pours:assert poly.intersection(keepout).area<1e-8,'Top copper inside sensor keepout'
for net,layer,g,tag in items:
    if layer!='top':continue
    if tag in ['pcb_port_124','pcb_port_125','pcb_port_126']:
        assert all(g.distance(poly)>=.099 for poly in top_pours),f'Unused IMU pad {tag} contacts ground pour'
    if str(tag).startswith('source_net') or str(tag).startswith('via ') or str(tag).startswith('budget_'):
        assert not g.intersects(keepout),f'{tag} crosses sensor keepout'
assert not out,'Copper clearance issues'
(r/'imu-layout-validation.json').write_text(json.dumps({'status':'PASS','floatingPins':[9,10,11],'topCopperKeepoutMm':[1.8,1.3],'checks':['NC pad clearance to traces, vias, pads and top pours','top interior free of routed copper and pours','minimum 0.1 mm different-net copper clearance']},indent=2))
print('PASS: unused IMU pads isolated from pours and routed copper; sensor interior clear.')