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)