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.
123 lines
6.0 KiB
Python
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))
|