Files
ESP32_Serial_Swiss_Army_Knife/hardware/PCB/validation/pcb-routing-check.py
T
Commander1024 60c1e279d6 Add KiCad module carrier PCB draft
Introduce an editable KiCad 10 schematic and routed two-layer 82 × 80 mm
layout with local provisional footprints, validation tooling, and
component
research. Keep the RS-232 male-module mapping and mechanical clearances
explicitly provisional pending hardware verification.
2026-09-20 23:14:01 +02:00

123 lines
6.0 KiB
Python

#!/usr/bin/python3
"""Read-only draft audit; needs pcbnew, numpy, shapely and kicad-cli.
Writes fresh DRC and audit JSON reports, never modifies the board.
Independent continuous centreline/radius checks complement KiCad DRC. Restricted
here to the existing circle/axis-aligned rectangular THT pads and straight tracks.
"""
from collections import Counter
import hashlib
import importlib.util
import json
from pathlib import Path
import subprocess
import pcbnew as p
from shapely.geometry import Point, LineString, Polygon, box
ROOT = Path(__file__).resolve().parent.parent
spec = importlib.util.spec_from_file_location('router', ROOT / 'route_pcb_draft.py')
r = importlib.util.module_from_spec(spec)
spec.loader.exec_module(r)
board = p.LoadBoard(str(r.PCB))
original = p.LoadBoard(str(ROOT / 'validation/pcb-routing-input.kicad_pcb'))
assert r.identity(board) == r.identity(original), 'immutable source geometry/linkage changed'
items, holes = [], []
for f in board.GetFootprints():
for pad in f.Pads():
xy = r.pos(pad)
assert pad.GetOrientationDegrees() % 90 == 0
assert pad.GetShape() in (p.PAD_SHAPE_CIRCLE, p.PAD_SHAPE_RECT)
if pad.GetShape() == p.PAD_SHAPE_CIRCLE:
assert pad.GetSize().x == pad.GetSize().y
geom, radius = Point(xy), p.ToMM(pad.GetSize().x) / 2
else:
bb = pad.GetBoundingBox()
geom = box(p.ToMM(bb.GetX()), p.ToMM(bb.GetY()),
p.ToMM(bb.GetRight()), p.ToMM(bb.GetBottom()))
radius = 0
label = f.GetReference() + '.' + pad.GetNumber() + ':' + pad.m_Uuid.AsString()
layers = {l for l in r.LAYERS if pad.IsOnLayer(l)}
items.append((label, pad.GetNetCode(), layers, geom, radius))
if pad.GetDrillSize().x:
assert pad.GetDrillSize().x == pad.GetDrillSize().y
holes.append((label, Point(xy), p.ToMM(pad.GetDrillSize().x) / 2, False))
track_lengths = Counter()
widths = Counter()
for t in board.GetTracks():
is_via = isinstance(t, p.PCB_VIA)
if is_via:
geom, radius, layers = Point(r.pos(t)), p.ToMM(t.GetWidth(p.F_Cu)) / 2, set(r.LAYERS)
assert abs(radius - 0.35) < 1e-8 and t.GetDrillValue() == r.mm(0.3)
holes.append((t.m_Uuid.AsString(), geom, 0.15, True))
else:
geom = LineString([(p.ToMM(t.GetStart().x), p.ToMM(t.GetStart().y)),
(p.ToMM(t.GetEnd().x), p.ToMM(t.GetEnd().y))])
radius, layers = p.ToMM(t.GetWidth()) / 2, {t.GetLayer()}
expected = 0.5 if t.GetNetname() in ('/+3V3', '/GND') else 0.25
assert abs(2 * radius - expected) < 1e-8
widths[str(2 * radius)] += 1
track_lengths[t.GetNetname()] += geom.length
items.append((t.m_Uuid.AsString(), t.GetNetCode(), layers, geom, radius))
min_clearance = float('inf')
closest = None
for i, a in enumerate(items):
for b in items[i + 1:]:
if not a[2].intersection(b[2]) or (a[1] and a[1] == b[1]):
continue
distance = a[3].distance(b[3]) - a[4] - b[4]
if distance < min_clearance:
min_clearance, closest = distance, (a[0], b[0])
assert min_clearance >= 0.25 - 1e-6, (min_clearance, closest)
outline = Polygon([(100,100),(182,100),(182,180),(136,180),(136,161),(100,161)])
rf = box(110, 100, 131, 113)
min_edge, min_rf = float('inf'), float('inf')
for label, net, layers, geom, radius in items:
assert outline.covers(geom), label
edge_gap = outline.boundary.distance(geom) - radius
rf_gap = rf.distance(geom) - radius
min_edge, min_rf = min(min_edge, edge_gap), min(min_rf, rf_gap)
assert edge_gap >= 0.5 - 1e-6, (label, edge_gap)
assert rf_gap > 0, (label, rf_gap)
min_via_hole_gap = float('inf')
for i, a in enumerate(holes):
for b in holes[i + 1:]:
if not (a[3] or b[3]):
continue
gap = a[1].distance(b[1]) - a[2] - b[2]
min_via_hole_gap = min(min_via_hole_gap, gap)
assert gap >= 0.25 - 1e-6, (a[0], b[0], gap)
# Refusal paths must leave the routed board byte-for-byte unchanged.
before = r.PCB.read_bytes()
guards = []
for args in (['--route'], ['--route', '--overwrite'], []):
result = subprocess.run(['/usr/bin/python3', '-B', str(ROOT/'route_pcb_draft.py'), *args],
capture_output=True, text=True, timeout=20)
assert result.returncode == 2, (args, result.stdout, result.stderr)
assert r.PCB.read_bytes() == before
guards.append({'args': args, 'exit_code': result.returncode,
'reason': result.stderr.splitlines()[-1]})
subprocess.run(['kicad-cli', 'pcb', 'drc', '--format', 'json', '--all-track-errors',
'--severity-all', '--exit-code-violations', '-o',
str(ROOT/'validation/pcb-routing-drc.json'), str(r.PCB)],
check=True, timeout=60)
assert r.PCB.read_bytes() == before, 'board changed during audit'
drc = json.loads(ROOT.joinpath('validation/pcb-routing-drc.json').read_text())
assert not drc['violations'] and not drc['unconnected_items']
report = dict(immutable_geometry_nets_pads_linkage_preserved=True,
board_sha256=hashlib.sha256(before).hexdigest(),
independent_min_different_net_or_npth_clearance_mm=min_clearance,
closest_items=closest, min_copper_or_npth_edge_gap_mm=min_edge,
min_rf_gap_mm=min_rf, min_via_to_other_drilled_hole_gap_mm=min_via_hole_gap,
track_width_counts=widths, routed_length_mm_by_net=track_lengths,
tracks=sum(not isinstance(t,p.PCB_VIA) for t in board.GetTracks()),
vias=sum(isinstance(t,p.PCB_VIA) for t in board.GetTracks()),
copper_zones=sum(not z.GetIsRuleArea() for z in board.Zones()),
drc_freshly_run=True,
drc_violations=len(drc['violations']), unconnected_items=len(drc['unconnected_items']),
drc_ignored_checks=drc['ignored_checks'], overwrite_guard_tests=guards)
ROOT.joinpath('validation/pcb-routing-check.json').write_text(json.dumps(report, indent=2)+'\n')
print(json.dumps(report, indent=2))