shibosoftwaredev/f1c100s-linux-nema17-stepper-controller
A four-layer NEMA 17 motor-controller PCB combining an Allwinner F1C100S Linux computer, USB‑PD/buck power system, W5500 Ethernet, STM32 real-time motion control, DRV8825 stepper driver, protection/temperature/current telemetry, USB interfaces, and local LED/buzzer diagnostics.
- Version
- 0.4.56
- License
- unset
- Stars
- 0
scripts/compare-drill-clearance.py
"""Compare actual emitted drill/foreign-metal clearances to the saved release.
Does not change routing or waive findings. Deduplicates repeated native paths
by unioning copper on each net/layer. Baseline net labels identify inherited
geometry only; the current circuit's distinct nets remain distinct for checks.
"""
from route_geometry import *
from collections import defaultdict
from shapely.ops import unary_union
from shapely.strtree import STRtree
import hashlib
import sys
baseline_path = Path(sys.argv[1])
current_path = Path(sys.argv[2] if len(sys.argv) > 2 else 'dist/index/circuit.json')
baseline = json.loads(baseline_path.read_text())
current = json.loads(current_path.read_text())
def port_names(circuit):
names = {e['source_component_id']: e['name'] for e in circuit if e['type'] == 'source_component'}
return {e['source_port_id']: f"{names[e['source_component_id']]}.{e.get('pin_number', e['name'])}"
for e in circuit if e['type'] == 'source_port'}
base_root = connectivity(baseline)
base_names = port_names(baseline)
base_groups = defaultdict(list)
for pid, name in base_names.items():
base_groups[base_root(pid)].append(name)
base_label = {name: min(group) for group in base_groups.values() for name in group}
def collect(circuit):
root = connectivity(circuit)
names = port_names(circuit)
members = defaultdict(list)
for pid, name in names.items():
members[root(pid)].append(name)
labels = {}
for net, group in members.items():
old = {base_label[n] for n in group if n in base_label}
# Labels identify inherited geometry, not electrical equivalence.
# A deliberately changed circuit (e.g. host VBUS isolation) must not
# be mapped to one arbitrarily chosen old rail. Give merged identities
# a new label; every actual distinct current net is still checked.
# A separate netlist regression check approves intentional net changes.
labels[net] = next(iter(old)) if len(old)==1 else 'NEW:' + min(group)
sources = {e['source_trace_id']: e for e in circuit if e['type'] == 'source_trace'}
def source_net(source):
ids = source.get('connected_source_port_ids', []) + source.get('connected_source_net_ids', [])
return root(ids[0]) if ids else None
traces = {e['pcb_trace_id']: e for e in circuit if e['type'] == 'pcb_trace'}
ports = {e['pcb_port_id']: e for e in circuit if e['type'] == 'pcb_port'}
metal = defaultdict(list)
for trace in traces.values():
source = sources.get(trace.get('source_trace_id'))
if not source:
continue
net = source_net(source)
for a, b in zip(trace['route'], trace['route'][1:]):
if a['route_type'] == b['route_type'] == 'wire' and a['layer'] == b['layer']:
if math.dist((a['x'], a['y']), (b['x'], b['y'])) > 1e-8:
metal[net, a['layer']].append(LineString([(a['x'], a['y']), (b['x'], b['y'])])
.buffer(max(a['width'], b['width']) / 2, quad_segs=16))
for e in circuit:
if e['type'] not in ['pcb_smtpad', 'pcb_plated_hole']:
continue
port = ports.get(e.get('pcb_port_id'))
net = root(port['source_port_id']) if port else 'UNCONNECTED:' + e[e['type'] + '_id']
for layer in e.get('layers', [e.get('layer')]):
metal[net, layer].append(geometry(e, 'outer_' if e['type'] == 'pcb_plated_hole' else ''))
union = {key: unary_union(items) for key, items in metal.items()}
vias = []
for e in circuit:
if e['type'] != 'pcb_via':
continue
trace = traces.get(e.get('pcb_trace_id'), {})
source = sources.get(e.get('source_trace_id', trace.get('source_trace_id')))
assert source, ('via without trace identity', e)
net = source_net(source)
vias.append(dict(x=e['x'], y=e['y'], net=net, label=labels.get(net, str(net)),
hole=e['hole_diameter']))
return union, labels, vias
def audit(data):
copper, labels, vias = data
keys = list(copper)
tree = STRtree([copper[k] for k in keys])
findings = []
for via in vias:
hole = Point(via['x'], via['y']).buffer(via['hole'] / 2, quad_segs=32)
for i in tree.query(hole.buffer(.2)):
net, layer = keys[int(i)]
if net == via['net']:
continue
clearance = hole.distance(copper[net, layer])
if clearance < .2 - .0002:
findings.append(dict(via=via, foreignNet=labels.get(net, str(net)), layer=layer,
drillEdgeClearance=round(clearance, 6), required=.2))
return findings
base_data, current_data = collect(baseline), collect(current)
base_findings, findings = audit(base_data), audit(current_data)
base_copper = defaultdict(list)
for (net, layer), g in base_data[0].items():
base_copper[base_data[1].get(net, str(net)), layer].append(g)
base_copper = {key: unary_union(items) for key, items in base_copper.items()}
base_keys = {(round(f['via']['x'], 4), round(f['via']['y'], 4), f['foreignNet'], f['layer'])
for f in base_findings}
regressions = []
for finding in findings:
via = finding['via']
key = (round(via['x'], 4), round(via['y'], 4), finding['foreignNet'], finding['layer'])
if key not in base_keys:
regressions.append(finding)
continue
# Check the actual foreign metal in the clearance zone, not just a matching
# site count. The 0.00005 mm buffer covers native/SES coordinate rounding.
current_shapes = [g for (net, layer), g in current_data[0].items()
if layer == finding['layer'] and current_data[1].get(net, str(net)) == finding['foreignNet']]
zone = Point(via['x'], via['y']).buffer(via['hole'] / 2 + .2 - .0002, quad_segs=32)
added = unary_union(current_shapes).intersection(zone).difference(
base_copper[finding['foreignNet'], finding['layer']].buffer(.00005))
if added.area > 1e-7:
regressions.append({**finding, 'addedForeignCopperAreaMm2': added.area})
report = dict(baselinePath=str(baseline_path), currentPath=str(current_path),
baselineSha256=hashlib.sha256(baseline_path.read_bytes()).hexdigest(),
circuitSha256=hashlib.sha256(current_path.read_bytes()).hexdigest(),
ruleSource='https://jlcpcb.com/capabilities/pcb-capabilities',
deduplication='Union of foreign track/land copper per actual net and layer',
baselineFindings=len(base_findings), currentFindings=len(findings),
regressionCount=len(regressions), regressions=regressions, findings=findings,
caveat='Existing findings remain fabrication blockers; zero regressions is not a fabrication pass.')
Path(os.environ.get('DRILL_REPORT_OUTPUT','reports/drill-clearance-regression.json')).write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps({k: report[k] for k in ['baselineFindings', 'currentFindings', 'regressionCount']}))
if regressions:
raise SystemExit(1)