Files
periscope/tests/test_kicad_pcb.py
michele facbaa2305 Show Action on finding cards and inject PCB geometry into AI context.
Layout and AI findings always get action (fallback recommendation). The card
renders that sentence in the body. PCB review context now includes vias under
the footprint, copper thickness, nearby widths, courtyard, and keepout polygons.
2026-09-20 09:13:51 +02:00

279 lines
9.3 KiB
Python

"""KiCad PCB ingest — parse only, no SI/creepage checks.
Favor: footprint+pad net; named nets; segment on a net.
Against: .kicad_sch rejected; missing Reference skipped; empty board ok.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
_PCB = """(kicad_pcb (version 20240108) (generator pcbnew)
(net 0 "")
(net 1 "GND")
(net 2 "+3V3")
(footprint "Resistor_SMD:R_0603_1608Metric"
(layer "F.Cu")
(at 10 20 0)
(property "Reference" "R1" (at 0 0 0) (effects (font (size 1 1))))
(property "Value" "10k" (at 0 0 0) (effects (font (size 1 1))))
(pad "1" smd roundrect (at -0.75 0) (size 0.8 0.9) (layers "F.Cu") (net 2 "+3V3"))
(pad "2" smd roundrect (at 0.75 0) (size 0.8 0.9) (layers "F.Cu") (net 1 "GND"))
)
(footprint "Resistor_SMD:R_0603_1608Metric"
(layer "F.Cu")
(at 0 0 0)
(property "Reference" "#PWR01" (at 0 0 0) (effects (font (size 1 1))))
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND"))
)
(segment (start 10 20) (end 12 20) (width 0.25) (layer "F.Cu") (net 1))
(via (at 11 20) (size 0.8) (drill 0.4) (layers "F.Cu" "B.Cu") (net 1))
)
"""
def test_parse_footprint_pads_and_nets(tmp_path: Path):
p = tmp_path / "board.kicad_pcb"
p.write_text(_PCB)
g = parse_kicad_pcb(p)
assert "GND" in g.nets and "+3V3" in g.nets
assert "R1" in g.footprints
r1 = g.footprints["R1"]
assert r1.x == 10 and r1.y == 20
by_num = {pad.number: pad for pad in r1.pads}
assert by_num["1"].net == "+3V3"
assert by_num["2"].net == "GND"
assert by_num["1"].x == pytest.approx(9.25)
assert by_num["2"].x == pytest.approx(10.75)
assert r1.courtyard == []
def test_parse_segment_and_via_resolve_net_name(tmp_path: Path):
p = tmp_path / "board.kicad_pcb"
p.write_text(_PCB)
g = parse_kicad_pcb(p)
assert len(g.segments) == 1
assert g.segments[0].net == "GND"
assert len(g.vias) == 1
assert g.vias[0].net == "GND"
def test_power_flag_footprint_is_skipped(tmp_path: Path):
p = tmp_path / "board.kicad_pcb"
p.write_text(_PCB)
g = parse_kicad_pcb(p)
assert "#PWR01" not in g.footprints
def test_kicad_sch_is_rejected_as_pcb(tmp_path: Path):
p = tmp_path / "sheet.kicad_sch"
p.write_text("(kicad_sch (version 20250114) (uuid \"1\"))\n")
with pytest.raises(ValueError, match="kicad_pcb"):
parse_kicad_pcb(p)
def test_parse_keepout_zone_polygon(tmp_path: Path):
p = tmp_path / "keepout.kicad_pcb"
p.write_text("""(kicad_pcb (version 20240108) (generator pcbnew)
(zone (net 0) (net_name "") (layer "F.Cu") (name "ANT_KEEPOUT")
(keepout (tracks not_allowed) (vias not_allowed) (copperpour not_allowed))
(polygon (pts (xy 0 0) (xy 10 0) (xy 10 8) (xy 0 8)))
)
)
""")
g = parse_kicad_pcb(p)
assert len(g.zones) == 1
z = g.zones[0]
assert z.keepout is True
assert z.name == "ANT_KEEPOUT"
assert len(z.outlines[0]) == 4
def test_empty_board_parses(tmp_path: Path):
p = tmp_path / "empty.kicad_pcb"
p.write_text("(kicad_pcb (version 20240108) (generator pcbnew))\n")
g = parse_kicad_pcb(p)
assert g.footprints == {}
assert g.segments == []
_PCB_V10 = """(kicad_pcb (version 20260206) (generator pcbnew)
(footprint "Package_TO_SOT_SMD:SOT-23-5"
(layer "F.Cu")
(at 0 0 0)
(property "Reference" "U3" (at 0 0 0) (effects (font (size 1 1))))
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "VSYS"))
(pad "2" smd rect (at 1 0) (size 1 1) (layers "F.Cu") (net "GND"))
(pad "5" smd rect (at 2 0) (size 1 1) (layers "F.Cu") (net "3V3_DIGITAL"))
)
(footprint "RF_Module:ESP"
(layer "F.Cu")
(at 10 0 0)
(property "Reference" "U5" (at 0 0 0) (effects (font (size 1 1))))
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "GND"))
(pad "2" smd rect (at 1 0) (size 1 1) (layers "F.Cu") (net "3V3_DIGITAL"))
(pad "3" smd rect (at 2 0) (size 1 1) (layers "F.Cu") (net "/ESP32_EN"))
)
)
"""
def test_kicad10_pad_net_string_form(tmp_path: Path):
from backend.periscopex.parsers_kicad_pcb import nets_from_pcb
p = tmp_path / "v10.kicad_pcb"
p.write_text(_PCB_V10)
g = parse_kicad_pcb(p)
assert g.footprints["U3"].pads[1].net == "GND"
nets = nets_from_pcb(g)
assert ("U3", "2") in nets["GND"]
assert ("U3", "5") in nets["3V3_DIGITAL"]
assert ("U5", "3") in nets["ESP32_EN"] # leading / stripped
def test_build_graph_prefers_pcb_nets_over_sch(tmp_path: Path):
"""Board pad nets win when sch geometry would swap rails."""
from backend.periscopex.graph import build_graph
sch = tmp_path / "netlist.kicad_sch"
# Minimal sch: only needs to parse as kicad_sch with some parts.
sch.write_text("""(kicad_sch (version 20250114) (uuid "1")
(lib_symbols
(symbol "Device:R"
(pin passive (at 0 3.81 90) (length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive (at 0 -3.81 90) (length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
(symbol (lib_id "Device:R") (at 0 0 0) (unit 1) (uuid "a")
(property "Reference" "R1" (at 0 0 0) (effects (font (size 1.27 1.27))))
(property "Value" "10k" (at 0 0 0) (effects (font (size 1.27 1.27))))
(pin "1" (uuid "p1")) (pin "2" (uuid "p2"))
)
(global_label "GND" (at 0 3.81 0) (uuid "b"))
)
""")
pcb = tmp_path / "pcb.kicad_pcb"
pcb.write_text(_PCB_V10)
bom = tmp_path / "bom.csv"
bom.write_text(
"Reference,Value,Footprint,Manufacturer Part Number\n"
"U3,AP2112,SOT23,\nU5,ESP32,,\nR1,10k,,\n"
)
g = build_graph(
sch, bom, tmp_path / "ex", tmp_path / "pat", tmp_path / "mod",
pcb_path=pcb,
)
assert g.components["U3"].pins["2"] == "GND"
assert g.components["U3"].pins["5"] == "3V3_DIGITAL"
assert g.components["U5"].pins["1"] == "GND"
assert g.components["U5"].pins["2"] == "3V3_DIGITAL"
def test_upload_pcb_sets_has_pcb(tmp_path: Path):
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
client = TestClient(app)
pid = client.post("/api/projects", json={"name": "board"}).json()["id"]
resp = client.post(
f"/api/projects/{pid}/upload/pcb",
files={"file": ("board.kicad_pcb", _PCB.encode(), "text/plain")},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["footprints"] == 1
assert body["nets"] >= 2
fresh = client.get(f"/api/projects/{pid}").json()
assert fresh["has_pcb"] is True
assert fresh["has_netlist"] is False
def test_upload_sch_as_pcb_is_rejected(tmp_path: Path):
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
client = TestClient(app)
pid = client.post("/api/projects", json={"name": "board"}).json()["id"]
resp = client.post(
f"/api/projects/{pid}/upload/pcb",
files={"file": ("sheet.kicad_sch", b"(kicad_sch (version 1))\n", "text/plain")},
)
assert resp.status_code == 400
fresh = client.get(f"/api/projects/{pid}").json()
assert not fresh.get("has_pcb")
class _FakeWs:
def __init__(self, root: Path):
self.root = root
def local_path(self, rel: str) -> Path:
return self.root / rel
def test_layout_graph_skipped_when_pcb_missing(tmp_path: Path):
from backend.services.pipeline import _write_layout_graph
(tmp_path / "uploads").mkdir()
_write_layout_graph(_FakeWs(tmp_path), "p1")
assert not (tmp_path / "layout_graph.json").is_file()
def test_layout_graph_fail_soft_on_invalid_pcb(tmp_path: Path):
from backend.services.pipeline import _write_layout_graph
uploads = tmp_path / "uploads"
uploads.mkdir()
(uploads / "pcb.kicad_pcb").write_text("(kicad_sch (version 1))\n")
_write_layout_graph(_FakeWs(tmp_path), "p1")
assert not (tmp_path / "layout_graph.json").is_file()
def test_layout_graph_written_from_valid_pcb(tmp_path: Path):
from backend.services.pipeline import _write_layout_graph
uploads = tmp_path / "uploads"
uploads.mkdir()
(uploads / "pcb.kicad_pcb").write_text(_PCB)
_write_layout_graph(_FakeWs(tmp_path), "p1")
out = tmp_path / "layout_graph.json"
assert out.is_file()
data = __import__("json").loads(out.read_text())
assert "R1" in data["footprints"]
def test_stackup_thickness_without_full_dielectrics(tmp_path: Path):
p = tmp_path / "t.kicad_pcb"
p.write_text("""(kicad_pcb (version 20240108)
(net 0 "")
(net 1 "VOUT")
(setup
(stackup
(layer "F.Cu" (type "copper") (thickness 0.035mm))
(layer "B.Cu" (type "copper") (thickness 0.035))
)
)
(via (at 1 1) (size 0.8) (drill 0.4) (layers "F.Cu" "B.Cu") (net 1))
(arc (start 0 0) (end 2 0) (width 0.4) (layer "F.Cu") (net 1))
)
""")
g = parse_kicad_pcb(p)
assert g.stackup is not None
assert g.stackup.copper_thickness_mm == pytest.approx(0.035)
assert len(g.vias) == 1
assert any(s.width == pytest.approx(0.4) and s.net == "VOUT" for s in g.segments)