techmannih/nema34-mounted-stepper-controller

A 48 V USB‑C PD 3.1 EPR NEMA‑34 stepper controller PCB combining STM32/TMC5160 control, external MOSFET motor bridges, protected power and regenerative braking, USB data, telemetry, diagnostics, status LEDs, and buzzer alarms.

Version
0.3.92
License
unset
Stars
1

scripts/check-native-decoupling.py

#!/usr/bin/env python3
"""Measure local bypass and buzzer bulk paths in final component-side copper.

Planes are excluded. Ordinary bypasses stay on the IC side; U18/C52 alone
uses one specified through via, including its barrel in the unchanged 5 mm limit.
"""
import argparse
from collections import defaultdict
import hashlib
import heapq
import json
import math
from pathlib import Path

import pcbnew
import wx


def xy(point):
    return (pcbnew.ToMM(point.x), pcbnew.ToMM(point.y))


def vector(point):
    return pcbnew.VECTOR2I(*(pcbnew.FromMM(v) for v in point))


def route_length(start, end, tracks, pads, layer):
    net = start.GetNetCode()
    segments = [(xy(t.GetStart()), xy(t.GetEnd()), pcbnew.ToMM(t.GetWidth()) / 2)
                for t in tracks if t.GetNetCode() == net and t.GetLayer() == layer]
    points = sorted({point for a, b, _ in segments for point in (a, b)})
    points.extend([xy(start.GetPosition()), xy(end.GetPosition())])
    first, last = len(points) - 2, len(points) - 1
    graph = defaultdict(list)

    def connect(i, j):
        distance = math.dist(points[i], points[j])
        graph[i].append((j, distance))
        graph[j].append((i, distance))

    for a, b, radius in segments:
        dx, dy = b[0] - a[0], b[1] - a[1]
        square = dx * dx + dy * dy
        on_segment = []
        for i, point in enumerate(points):
            ratio = ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / square if square else 0
            ratio = max(0, min(1, ratio))
            projection = (a[0] + ratio * dx, a[1] + ratio * dy)
            if math.dist(point, projection) <= radius:
                on_segment.append((ratio, i))
        on_segment.sort()
        for (_, i), (_, j) in zip(on_segment, on_segment[1:]):
            connect(i, j)

    for pad in pads:
        if pad.GetNetCode() != net or not pad.IsOnLayer(layer):
            continue
        shape = pad.GetEffectiveShape(layer)
        indices = [i for i, point in enumerate(points) if shape.Collide(vector(point), 0)]
        for offset, i in enumerate(indices):
            for j in indices[offset + 1:]:
                connect(i, j)

    distances, queue = {first: 0}, [(0, first)]
    while queue:
        distance, node = heapq.heappop(queue)
        if node == last:
            return distance
        if distance != distances[node]:
            continue
        for neighbour, length in graph[node]:
            candidate = distance + length
            if candidate < distances.get(neighbour, math.inf):
                distances[neighbour] = candidate
                heapq.heappush(queue, (candidate, neighbour))
    return None


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('board', type=Path)
    parser.add_argument('output', type=Path)
    args = parser.parse_args()
    app = wx.App(False)
    board = pcbnew.LoadBoard(str(args.board.resolve()))
    footprints = {f.GetReference(): f for f in board.GetFootprints()}
    all_tracks = list(board.GetTracks())
    tracks = [t for t in all_tracks if not isinstance(t, pcbnew.PCB_VIA)]
    pads = [p for f in footprints.values() for p in f.Pads()]
    target_path = Path('docs/decoupling-targets.json')
    targets = json.loads(target_path.read_text())
    results, errors = [], []
    for ic, pin, capacitor, *options in targets:
        start = next(p for p in footprints[ic].Pads() if p.GetNumber() == str(pin))
        end = next(p for p in footprints[capacitor].Pads() if p.GetNumber() == '1')
        layer = footprints[ic].GetLayer()
        same_side = layer in (pcbnew.F_Cu, pcbnew.B_Cu) and footprints[capacitor].GetLayer() == layer
        length = route_length(start, end, tracks, pads, layer) if same_side and start.GetNetCode() == end.GetNetCode() else None
        transition = options[0].get('layer_transition') if options else None
        via_evidence = None
        if transition:
            assert (ic,pin,capacitor)==('U18',11,'C52')
            assert transition['from']=='bottom' and transition['to']=='top'
            assert transition['native_xy_mm']==[99.4,103.9] and transition['source_xy_mm']==[-.6,-3.9]
            assert transition['pad_mm']==.6 and transition['drill_mm']==.3 and transition['maximum_vias']==1
            assert transition['barrel_mm']==1.6 and board.GetDesignSettings().GetBoardThickness()==1600000
            vias=[t for t in all_tracks if isinstance(t,pcbnew.PCB_VIA) and math.dist(xy(t.GetPosition()),(99.4,103.9))<1e-6]
            if len(vias)==1 and layer==pcbnew.B_Cu and footprints[capacitor].GetLayer()==pcbnew.F_Cu:
                via=vias[0]
                if via.GetNetCode()==start.GetNetCode()==end.GetNetCode() and all(via.GetWidth(ly)==600000 for ly in (pcbnew.F_Cu,pcbnew.In1_Cu,pcbnew.In2_Cu,pcbnew.B_Cu)) and via.GetDrillValue()==300000 and via.GetViaType()==pcbnew.VIATYPE_THROUGH:
                    bottom=route_length(start,via,tracks,pads,pcbnew.B_Cu)
                    top=route_length(via,end,tracks,pads,pcbnew.F_Cu)
                    if bottom is not None and top is not None:
                        length=bottom+1.6+top
                        via_evidence=dict(xy_mm=xy(via.GetPosition()),pad_mm=.6,drill_mm=.3,barrel_mm=1.6,bottom_mm=bottom,top_mm=top)
        passed = length is not None and length <= 5
        results.append(dict(ic=ic, pin=pin, capacitor=capacitor,
                            layer=board.GetLayerName(layer), route_length_mm=length, maximum_mm=5, passed=passed,
                            layer_transition=via_evidence))
        if not passed:
            errors.append(f'{ic}.{pin} to {capacitor}.1: no approved copper path within 5 mm ({length})')
    # The buzzer bulk branch has a separate 7 mm body-clearance budget. Do not
    # relax the 5 mm IC bypass limit to accommodate the larger buzzer package.
    buzzer = next(p for p in footprints['BZ1'].Pads() if p.GetNumber() == '1')
    bulk = next(p for p in footprints['C96'].Pads() if p.GetNumber() == '1')
    same_top = footprints['BZ1'].GetLayer() == footprints['C96'].GetLayer() == pcbnew.F_Cu
    bulk_length = route_length(buzzer, bulk, tracks, pads, pcbnew.F_Cu) if same_top and buzzer.GetNetCode() == bulk.GetNetCode() else None
    bulk_result = dict(component='BZ1', pin=1, capacitor='C96', layer='F.Cu',
                       route_length_mm=bulk_length, maximum_mm=7,
                       passed=bulk_length is not None and bulk_length <= 7)
    if not bulk_result['passed']:
        errors.append(f'BZ1.1 to C96.1: no top-layer bulk path within 7 mm ({bulk_length})')
    digest = lambda path: hashlib.sha256(path.read_bytes()).hexdigest()
    report = dict(board_sha256=digest(args.board), targets_sha256=digest(target_path),
                  checker_sha256=digest(Path(__file__)), results=results, bulk_results=[bulk_result], errors=errors,
                  limitation='Copper-path geometry only; ground-loop inductance and powered ripple require measurement.')
    args.output.write_text(json.dumps(report, indent=2) + '\n')
    print(json.dumps(dict(checked=len(results), errors=errors)))
    raise SystemExit(bool(errors))


if __name__ == '__main__':
    main()