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.
279 lines
9.3 KiB
Python
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)
|