mohan-bee/rc-link-breakaway-panel

A four-layer RC link panel combining an STM32/E28 2.4‑GHz transmitter with USB-RP2040 socket and an STM32/E28 receiver providing six buffered PWM servo outputs, regulated 3.3‑V power, headers, controls, and protection/decoupling.

Version
0.1.5
License
unset
Stars
0

tools/audit-copper.py

import json, math, os, 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(Path(os.environ.get('PHONE_TX_AUDIT_CIRCUIT',root/'dist/index/circuit.json')).read_text())
report_directory=Path(os.environ.get('PHONE_TX_AUDIT_REPORT_DIR',root/'validation'))
report_directory.mkdir(parents=True,exist_ok=True)
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'] and not a['kind']==b['kind']=='via') or not a['layers'] & b['layers']: continue
        gap=a['geom'].distance(b['geom'])
        required=.1 if a['kind']==b['kind']=='via' else .2
        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:
    group=next((g for g in c if g["type"]=="source_group" and g.get("subcircuit_id")==hole.get("subcircuit_id")),{})
    board_rule=next(b for b in c if b['type']=='pcb_board').get('min_trace_to_hole_edge_clearance',.5)
    required=.25 if hole["hole_diameter"]<1 else (.3 if group.get("name")=="RX" else board_rule)
    center=Point(hole['x'],hole['y'])
    for i in tree.query(center.buffer(hole['hole_diameter']/2+.51)):
        a=shapes[int(i)]
        gap=a['geom'].distance(center)-hole['hole_diameter']/2
        if gap+.001<required:
            violations.append(dict(a=a['id'],b=hole['pcb_hole_id'],gap_mm=round(gap,6),required_mm=required))
for via in (x for x in c if x['type']=='pcb_via'):
    drill=buffered(Point(via['x'],via['y']),via['hole_diameter']/2)
    for pad in (x for x in c if x['type']=='pcb_smtpad'):
        shape=next(s for s in shapes if s['id']==pad['pcb_smtpad_id'])
        if drill.intersects(shape['geom']):
            violations.append(dict(a=via['pcb_via_id'],b=pad['pcb_smtpad_id'],reason='via drill intersects SMD land'))
boards=[Polygon([(p["x"],p["y"]) for p in b["outline"]]) for b in c if b["type"]=="pcb_board" and b.get("outline")]
if not boards:
    b=next(b for b in c if b["type"]=="pcb_board"); boards=[box(-b["width"]/2,-b["height"]/2,b["width"]/2,b["height"]/2)]
for shape in shapes:
    if not any(boundary.buffer(-.499).covers(shape["geom"]) for boundary in boards):
        violations.append(dict(a=shape["id"],reason="copper outside board outlines"))
def keepout_geometry(k):
    x,y=k['center']['x'],k['center']['y']
    if k['shape']=='circle':
        return Point(x,y).buffer(k['radius'])
    assert k['shape']=='rect',k
    return box(x-k['width']/2,y-k['height']/2,x+k['width']/2,y+k['height']/2)
for k in (x for x in c if x['type']=='pcb_keepout'):
    region=keepout_geometry(k)
    for a in shapes:
        if a['layers'] & set(k['layers']) and a['geom'].distance(region)<.099:
            violations.append(dict(a=a['id'],b=k['pcb_keepout_id'],gap_mm=round(a['geom'].distance(region),6),required_mm=.1))
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.')
(report_directory/'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))