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-driver-thermal-vias.py
"""Check the DRV8825 heat path and the actual exported drill/mask geometry.
Uses Shapely (the same environment as audit-manufacturing.py). The small Gerber
reader deliberately rejects unsupported drawing commands instead of assuming
an unrecognized mask operation is harmless.
"""
import hashlib
import json
import re
import zipfile
import sys
from pathlib import Path
from shapely.geometry import Point, LineString, Polygon, box
from shapely.affinity import translate, rotate
from shapely.ops import unary_union
from gerber_geometry import gerber_geometry
root = Path(__file__).resolve().parents[1]
folder = root / (sys.argv[1] if len(sys.argv)>1 else 'dist/dual')
driver_name = sys.argv[2] if len(sys.argv)>2 else 'DRIVER'
circuit_path = folder / 'circuit.json'
archive = folder / 'gerbers.zip'
j = json.loads(circuit_path.read_text())
sha = hashlib.sha256(circuit_path.read_bytes()).hexdigest()
manufacturing = json.loads((root / 'reports/manufacturing-audit.json').read_text())
assert manufacturing['sha256'] == sha, 'Run the complete manufacturing audit first'
for field in ('unmappedElements', 'shortContacts', 'splitNets', 'holeSpacingViolations', 'holeToCopperViolations', 'pthDrillToCopperViolations', 'npthToCopperViolations', 'copperToBoardEdgeViolations'):
assert not manufacturing[field], f'Manufacturing audit failed: {field}'
driver = next(e for e in j if e['type'] == 'source_component' and e['name'] == driver_name)
pc = next(e for e in j if e['type'] == 'pcb_component' and e['source_component_id'] == driver['source_component_id'])
ep = next(e for e in j if e['type'] == 'pcb_smtpad' and e.get('pcb_component_id') == pc['pcb_component_id'] and 'pin29' in e.get('port_hints', []))
land = box(ep['x']-ep['width']/2, ep['y']-ep['height']/2, ep['x']+ep['width']/2, ep['y']+ep['height']/2)
land = rotate(land, ep.get('ccw_rotation', 0), origin=(ep['x'], ep['y']))
ground = next(e['source_net_id'] for e in j if e['type'] == 'source_net' and e['name'] == 'GND')
ep_source = next(e['source_port_id'] for e in j if e['type']=='pcb_port' and e['pcb_port_id']==ep['pcb_port_id'])
joins = [e for e in j if e['type']=='source_trace' and re.fullmatch(r'DRV_IMPORTED_EP_\d+ connectivity', e.get('display_name','')) and ep_source in e['connected_source_port_ids']]
assert len(joins) == 12
vias = []
for join in joins:
assert ep['pcb_port_id'] in {e['pcb_port_id'] for e in j if e['type'] == 'pcb_port' and e.get('source_port_id') in join['connected_source_port_ids']}
matching = [e for e in j if e['type'] == 'pcb_via' and e.get('source_trace_id') == join['source_trace_id']]
assert len(matching) == 1
vias.extend(matching)
assert len({(v['x'], v['y']) for v in vias}) == 12
pours = {}
for layer in ('inner1', 'inner2', 'bottom'):
shapes = []
for e in j:
if e['type'] != 'pcb_copper_pour' or e['layer'] != layer or e['source_net_id'] != ground:
continue
b = e['brep_shape']; xy = lambda r: [(v['x'], v['y']) for v in r['vertices']]
shapes.append(Polygon(xy(b['outer_ring']), [xy(r) for r in b['inner_rings']]))
pours[layer] = unary_union(shapes)
with zipfile.ZipFile(archive) as z:
top_mask = gerber_geometry(z.read('F_Mask.gbr').decode())
bottom_mask = gerber_geometry(z.read('B_Mask.gbr').decode())
drill_text = z.read('drill-L1-L4.drl').decode()
assert 'FileFunction,Plated,1,4,PTH' in drill_text
sizes = {int(n): float(d) for n, d in re.findall(r'^T(\d+)C([\d.]+)$', drill_text, re.M)}
tool = None; drills = []
for line in drill_text.splitlines():
if re.fullmatch(r'T\d+', line): tool = int(line[1:])
m = re.fullmatch(r'X(-?[\d.]+)Y(-?[\d.]+)', line)
if m: drills.append((float(m[1]), float(m[2]), sizes[tool]))
details = []
for via in vias:
x, y = via['x'], via['y']; center = Point(x, y)
assert abs(via['hole_diameter'] - 0.3000248) < 1e-8 and abs(via['outer_diameter'] - 0.5000244) < 1e-8
assert set(via['layers']) == {'top', 'inner1', 'inner2', 'bottom'}
assert land.covers(center.buffer(via['outer_diameter']/2)), 'Via ring leaves exposed pad'
# Require the full circumference of the ring embedded in ground copper,
# not only a spoke connection (TI PowerPAD SLMA004B, Figure 3).
for layer, copper in pours.items():
assert copper.buffer(1e-7).covers(center.buffer(via['outer_diameter']/2)), f'No solid {layer} pour around {via["pcb_via_id"]}'
assert top_mask.buffer(1e-7).covers(center.buffer(via['outer_diameter']/2)), 'Top thermal solder land was masked'
mask_clearance = center.distance(bottom_mask) - via['outer_diameter']/2
assert mask_clearance >= 0.1, 'Bottom solder-mask opening exposes a thermal via'
# Excellon coordinates are rounded to four decimal places in millimetres.
assert sum(abs(dx-x) <= 0.00005001 and abs(dy-y) <= 0.00005001 and abs(d-via['hole_diameter']) < 1e-5 for dx, dy, d in drills) == 1
details.append({'x': x, 'y': y, 'drillMm': via['hole_diameter'], 'padMm': via['outer_diameter'], 'bottomMaskOpeningClearanceMm': mask_clearance})
result = {
'status': 'pass', 'circuitJsonSha256': sha,
'gerbersSha256': hashlib.sha256(archive.read_bytes()).hexdigest(),
'driver': driver_name, 'inPadThermalViaCount': len(vias), 'manuallyAddedThermalViaCount': 0, 'supplierPart': 'C81582',
'solidGroundContactLayers': ['top thermal pad', 'inner1', 'inner2', 'bottom'],
'topThermalPadSolderable': True, 'bottomThermalViasTentedInGerber': True,
'platedThroughDrillsVerified': True, 'vias': details,
}
(root / f'reports/{driver_name.lower()}-thermal-vias.json').write_text(json.dumps(result, indent=2)+'\n')
print(json.dumps(result, indent=2))