#!/usr/bin/env python3 """Read-only PCB checks and fresh KiCad reports; does not regenerate or save PCB. Requires KiCad's pcbnew binding in system Python. Schematic footprint parity must be clean, including the user-confirmed U2 reference footprint assignment. """ import hashlib import json from pathlib import Path import subprocess import xml.etree.ElementTree as ET import pcbnew as p from update_u1_graphics import verify ROOT=Path(__file__).resolve().parent PCB=ROOT/"serial-carrier.kicad_pcb" OUT=ROOT/"validation" def run(*args): subprocess.run(args,check=True,cwd=ROOT) run("kicad-cli","sch","export","netlist","--format","kicadxml","-o",str(OUT/"pcb-source-netlist.xml"),str(ROOT/"serial-carrier.kicad_sch")) run("kicad-cli","pcb","drc","--format","json","--all-track-errors","--severity-all","--schematic-parity","-o",str(OUT/"pcb-parity-drc.json"),str(PCB)) board=p.LoadBoard(str(PCB)) verify(board) from update_presentation import expected_tree, CAPTION from validate_u1_update import parse, canonical tree=parse(PCB) assert ['paper', '"A3"'] in tree, "A3 worksheet required to clear the title block" assert canonical(expected_tree(tree)) == canonical(tree), "Stale presentation labels" assert board.GetTitleBlock().GetComment(0) == "" assert any(isinstance(g,p.PCB_TEXT) and g.GetText()==CAPTION for g in board.GetDrawings()) source=ET.parse(OUT/"pcb-source-netlist.xml").getroot() comps={c.get("ref"):c for c in source.findall("components/comp")} footprints={f.GetReference():f for f in board.GetFootprints()} assert set(footprints)==set(comps)|{"H1","H2","H3","H4"} assert board.GetCopperLayerCount()==2 assert p.ToMM(board.GetDesignSettings().GetBoardThickness())==1.6 outline=[d for d in board.GetDrawings() if d.GetLayer()==p.Edge_Cuts] assert len(outline)==6 points={(round(p.ToMM(v.x)-100,5),round(p.ToMM(v.y)-100,5)) for d in outline for v in (d.GetStart(),d.GetEnd())} expected_outline=[(36,0),(82,0),(82,80),(0,80),(0,19),(36,19)] assert points==set(expected_outline) expected_edges={frozenset((a,b)) for a,b in zip(expected_outline,expected_outline[1:]+expected_outline[:1])} actual_edges={frozenset((round(p.ToMM(v.x)-100,5),round(p.ToMM(v.y)-100,5)) for v in (d.GetStart(),d.GetEnd())) for d in outline} assert actual_edges==expected_edges placements={"U1":(8,23),"U2":(43,26.670),"DS1":(43.97,36), "R1":(42.16,32.6685),"R2":(53.16,32.6685),"R3":(64.16,32.6685), "SW1":(41.72,68),"SW2":(53.72,68),"SW3":(65.72,68), "H1":(3.5,22.5),"H2":(3.5,75),"H3":(78,4),"H4":(78,76)} for ref,(x,y) in placements.items(): position=footprints[ref].GetPosition() assert position.x==p.FromMM(x+100) and position.y==p.FromMM(y+100),ref assert footprints[ref].GetOrientationDegrees()==0,ref assert len(board.Zones())==1 reserve=board.Zones()[0] assert reserve.GetIsRuleArea() and reserve.IsOnLayer(p.F_Cu) and reserve.IsOnLayer(p.B_Cu) assert reserve.GetDoNotAllowTracks() and reserve.GetDoNotAllowVias() and reserve.GetDoNotAllowZoneFills() contour=reserve.Outline().COutline(0) assert {(p.ToMM(contour.CPoint(i).x),p.ToMM(contour.CPoint(i).y)) for i in range(contour.PointCount())}=={(110,119),(131,119),(131,132),(110,132)} # Reference bare-PCB front is 26.670 mm above the U2 pad-row origin. u2_front=p.ToMM(footprints['U2'].GetPosition().y)-26.670 assert abs(u2_front-100)<1e-6 assert any(isinstance(g,p.PCB_SHAPE) and g.GetLayer()==p.F_Fab and g.GetShape()==p.SHAPE_T_SEGMENT and g.GetStart().y==p.FromMM(100) and g.GetEnd().y==p.FromMM(100) for g in footprints['U2'].GraphicalItems()), 'U2 bare-PCB front not flush' assert max(x for x,y in points)-min(x for x,y in points)==82 assert max(y for x,y in points)-min(y for x,y in points)==80 padnets={} for net in source.findall("nets/net"): for node in net.findall("node"): padnets[(node.get("ref"),node.get("pin"))]=net.get("name") seen=set() for ref,comp in comps.items(): fp=footprints[ref] assert fp.GetValue()==comp.findtext("value"),ref assert fp.GetPath().AsString().endswith("/"+comp.findtext("tstamps")),ref wanted=comp.findtext("footprint") if ref=="U2": assert wanted=="Carrier:MAX3243_Reference_Provisional" assert fp.GetFPIDAsString()=="Carrier:MAX3243_Reference_Provisional" else: assert fp.GetFPIDAsString()==wanted,ref for pad in fp.Pads(): if not pad.GetNumber(): continue key=ref,pad.GetNumber() assert pad.GetNetname()==padnets[key],(key,pad.GetNetname(),padnets[key]) seen.add(key) assert seen==set(padnets),"Missing or extra electrical pads" for ref in ("H1","H2","H3","H4"): assert footprints[ref].IsBoardOnly() pads=list(footprints[ref].Pads()) assert len(pads)==1 and pads[0].GetDrillSize().x==p.FromMM(3.2) assert pads[0].GetAttribute()==p.PAD_ATTRIB_NPTH tracks=list(board.GetTracks()) assert tracks,"Draft unexpectedly unrouted" for track in tracks: if isinstance(track,p.PCB_VIA): assert track.GetDrillValue()==p.FromMM(0.3) assert track.GetWidth(p.F_Cu)==p.FromMM(0.7) else: assert track.GetLayer() in (p.F_Cu,p.B_Cu) expected=0.5 if track.GetNetname() in ("/+3V3","/GND") else 0.25 assert track.GetWidth()==p.FromMM(expected) report=json.loads((OUT/"pcb-parity-drc.json").read_text()) assert not report["violations"],report["violations"] assert not report["unconnected_items"],report["unconnected_items"] parity=report["schematic_parity"] assert not parity,parity summary={ "carrier_bounds_mm":[82,80], "outline_local_mm":expected_outline, "placements_local_mm":placements, "rf_reserve_absolute_mm":[110,119,131,132], "u2_bare_pcb_front_local_y_mm":round(u2_front-100,6), "nominal_user_confirmed_rs232_metal_mm":{"shell_overhang":3.870,"hex_mouth_overhang":2.870,"flange_front_inset":1.930}, "tracks":sum(not isinstance(t,p.PCB_VIA) for t in tracks), "vias":sum(isinstance(t,p.PCB_VIA) for t in tracks), "copper_layers":2, "draft_thickness_mm":1.6, "electrical_footprints":len(comps), "proposed_M3_mounts":4, "physical_DRC_violations":0, "unconnected_items":0, "schematic_pad_nets_match":True, "schematic_parity_violations":0, "project_settings_sha256":hashlib.sha256((ROOT/'serial-carrier.kicad_pro').read_bytes()).hexdigest(), "ignored_DRC_checks":report["ignored_checks"], "user_accepted":["module dimensions/visual fit and USB clearance", "male pinout/header/footprint and pin-by-pin review", "M3 carrier/module and M2 display hardware", "RF keepout and power/ground routing"], "not_independently_verified":["reported 10 mm assembled module elevation", "regulator margin", "USB backfeed safety", "OLED onboard pull-up resistance/rail", "manufacturer RF qualification", "fabrication process/certification"], "new_hardware_tests":False, } (OUT/"pcb-validation-summary.json").write_text(json.dumps(summary,indent=2)+"\n") print("PCB draft verified: 82 x 80 mm; 2 copper layers; 0 physical DRC violations; 0 unrouted.") print("All schematic pad nets and footprint assignments match. Zero schematic parity violations.") print("Not fabrication-ready; see ../../docs/devel/hardware/PCB/pcb-draft-notes.md and ignored checks in the DRC report.")