Files
ESP32_Serial_Swiss_Army_Knife/hardware/PCB/validate_footprints.py
T
Commander1024 901afbe754 Integrate Measured ESP32 PCB Geometry
- Add measured F.Fab bounds and USB mouth markers
- Add guarded preservation and regression validation
- Refresh review exports and remove draft presentation labels
2026-09-21 22:54:37 +02:00

144 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""Read-only regression checks for the two provisional module footprints.
Run with the system Python that provides KiCad's installed pcbnew binding:
/usr/bin/python3 -B hardware/PCB/validate_footprints.py
Paths resolve relative to this script, independent of the working directory.
No board, reports, or footprint files are written. Passing checks do not establish
physical connector fit, electrical correctness, DRC, or fabrication readiness.
"""
from collections import Counter
from pathlib import Path
import sys
import unittest
try:
import pcbnew as k
except ImportError as exc:
raise SystemExit(
"KiCad pcbnew binding unavailable. Use the system Python associated with "
"your KiCad installation (on this machine: /usr/bin/python3)."
) from exc
LIBRARY = Path(__file__).resolve().parent / "Carrier.pretty"
def xy(vector):
"""Keep native integer coordinates so comparisons do not hide deviations."""
return vector.x, vector.y
def mm(x, y):
return k.FromMM(x), k.FromMM(y)
class FootprintRegression(unittest.TestCase):
def load(self, name):
self.assertTrue((LIBRARY / (name + ".kicad_mod")).is_file(), name)
footprint = k.FootprintLoad(str(LIBRARY), name)
self.assertIsNotNone(footprint, f"KiCad could not parse {name}")
self.assertEqual(footprint.GetValue(), name)
self.assertEqual(footprint.GetLayer(), k.F_Cu)
self.assertIn("PROVISIONAL connector fit", footprint.GetLibDescription())
self.assertEqual(len(list(footprint.Zones())), 0, "Unexpected zone/keepout")
self.assertEqual(len(list(footprint.Models())), 0, "Unexpected 3D envelope")
graphics = list(footprint.GraphicalItems())
for item in graphics:
self.assertNotIn(item.GetLayer(), (k.F_CrtYd, k.B_CrtYd, k.Edge_Cuts))
self.assertIsInstance(item, (k.PCB_TEXT, k.PCB_SHAPE),
"Unexpected graphic/envelope type")
self.assertTrue(any(isinstance(item, k.PCB_TEXT)
and item.GetText() == "PROVISIONAL CONNECTOR FIT"
for item in graphics), "Missing visible provisional label")
return footprint
def check_header(self, footprint, positions, hole_count=0):
pads = list(footprint.Pads())
self.assertEqual(len(pads), len(positions) + hole_count)
numbered = [pad for pad in pads if pad.GetNumber()]
# Compare multiplicities so duplicate numbers cannot hide missing pads.
self.assertEqual(Counter(p.GetNumber() for p in numbered),
Counter({str(n): 1 for n in positions}))
for pad in numbered:
number = int(pad.GetNumber())
with self.subTest(pad=number):
self.assertEqual(xy(pad.GetPosition()), mm(*positions[number]))
self.assertEqual(xy(pad.GetSize()), mm(1.7, 1.7))
self.assertEqual(xy(pad.GetDrillSize()), mm(1, 1))
self.assertEqual(pad.GetDrillShape(), k.PAD_DRILL_SHAPE_CIRCLE)
self.assertEqual(pad.GetAttribute(), k.PAD_ATTRIB_PTH)
self.assertEqual(pad.GetShape(),
k.PAD_SHAPE_RECT if number == 1 else k.PAD_SHAPE_CIRCLE)
self.assertEqual(pad.GetNetCode(), 0, "Unexpected assigned net")
for layer in (k.F_Cu, k.B_Cu, k.F_Mask, k.B_Mask):
self.assertTrue(pad.IsOnLayer(layer), "Missing copper/mask layer")
for layer in (k.F_Paste, k.B_Paste):
self.assertFalse(pad.IsOnLayer(layer), "Unexpected paste layer")
return [pad for pad in pads if not pad.GetNumber()]
def test_oled(self):
footprint = self.load("OLED_26mm_I2C_Provisional")
positions = {n: (x, 1.5) for n, x in
enumerate((9.19, 11.73, 14.27, 16.81), 1)}
holes = self.check_header(footprint, positions, hole_count=4)
self.assertEqual(Counter(xy(p.GetPosition()) for p in holes),
Counter(mm(x, y) for x, y in (
(1.75, 1.75), (24.25, 1.75),
(1.75, 24.25), (24.25, 24.25))))
for pad in holes:
with self.subTest(hole=xy(pad.GetPosition())):
self.assertEqual(pad.GetAttribute(), k.PAD_ATTRIB_NPTH)
self.assertEqual(pad.GetShape(), k.PAD_SHAPE_CIRCLE)
self.assertEqual(pad.GetDrillShape(), k.PAD_DRILL_SHAPE_CIRCLE)
self.assertEqual(xy(pad.GetSize()), mm(2, 2))
self.assertEqual(xy(pad.GetDrillSize()), mm(2, 2))
self.assertEqual(pad.GetNetCode(), 0)
shapes = [g for g in footprint.GraphicalItems() if isinstance(g, k.PCB_SHAPE)]
self.assertEqual(len(shapes), 2, "Only board/display rectangles are verified")
self.assertEqual(Counter((xy(g.GetStart()), xy(g.GetEnd())) for g in shapes),
Counter([(mm(0, 0), mm(26, 26)),
(mm(1, 4.5), mm(25.5, 21))]))
for shape in shapes:
self.assertEqual(shape.GetShape(), k.SHAPE_T_RECT)
self.assertEqual(shape.GetLayer(), k.F_Fab)
self.assertEqual(shape.GetWidth(), k.FromMM(0.1))
self.assertFalse(shape.IsAnyFill())
# Stroke styles are not checked: this binding returns opaque LINE_STYLE
# pointers, so comparing GetLineStyle() results would compare identities,
# not the solid/dash values, and also emits SWIG leak warnings.
def test_hw678(self):
footprint = self.load("HW678_2x22_Provisional")
positions = {
row * 22 + index + 1: (x, round(index * 2.54, 2))
for row, x in enumerate((0, 25.4)) for index in range(22)
}
self.check_header(footprint, positions)
shapes = [g for g in footprint.GraphicalItems() if isinstance(g, k.PCB_SHAPE)]
expected = [
(k.SHAPE_T_RECT, mm(-1.365, -1.365), mm(26.725, 54.705), k.FromMM(0.1)),
(k.SHAPE_T_RECT, mm(4.045, -8.255), mm(21.675, -1.365), k.FromMM(0.1)),
(k.SHAPE_T_SEGMENT, mm(2.795, 54.705), mm(11.665, 54.705), k.FromMM(0.3)),
(k.SHAPE_T_SEGMENT, mm(14.195, 54.705), mm(23.065, 54.705), k.FromMM(0.3)),
]
self.assertEqual(Counter((g.GetShape(), xy(g.GetStart()), xy(g.GetEnd()),
g.GetWidth()) for g in shapes), Counter(expected))
for shape in shapes:
self.assertEqual(shape.GetLayer(), k.F_Fab)
self.assertFalse(shape.IsAnyFill())
# Only measured planar bounds/mouth spans: no invented shell depth or RF area.
self.assertAlmostEqual(26.725 - (-1.365), 28.09)
self.assertAlmostEqual(54.705 - (-1.365), 56.07)
self.assertAlmostEqual(54.705 - (-8.255), 62.96)
self.assertAlmostEqual(26.725 - 25.4, 1.325)
if __name__ == "__main__":
print(f"KiCad {k.GetBuildVersion()}; library: {LIBRARY}", flush=True)
suite = unittest.defaultTestLoader.loadTestsFromTestCase(FootprintRegression)
result = unittest.TextTestRunner(verbosity=2).run(suite)
sys.exit(0 if result.wasSuccessful() else 1)