Tighten PCB findings: actions, stitch gating, real copper.

Fill action on every finding and show it in the report. Stitch/return only
on large HS/power nets. Parse stackup thickness, arcs, and vias from the
board. IC courtyard pad copper is not PE-PLC-004 RULE. Recommended RC/cap
stay REVIEW.
This commit is contained in:
2026-09-20 08:58:54 +02:00
parent 1e379f6042
commit f63c1c3411
9 changed files with 326 additions and 59 deletions
+23
View File
@@ -133,3 +133,26 @@ def test_same_object_pcb_and_schema():
assert obj.finding_class
assert obj.provenance
assert obj.evidence_status
assert (obj.action or "").strip()
def test_empty_recommendation_gets_action():
f = Finding(designator="U1", finding="note", status="INFO", source="review")
complete_finding(f)
assert f.action.strip()
assert f.recommendation.strip() == f.action.strip()
def test_recommended_rc_cap_is_review_not_rule():
f = Finding(
designator="C1",
finding="10n vs 100n",
why="datasheet typical 100n",
status="WARNING",
rule_id="PE-DEC-002",
source="passive_rail_check",
)
complete_finding(f)
assert f.finding_class == "REVIEW"
assert f.provenance == "RECOMMENDED"
assert f.status != "ERROR"
+22
View File
@@ -237,3 +237,25 @@ def test_layout_graph_written_from_valid_pcb(tmp_path: Path):
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)
+47 -7
View File
@@ -394,12 +394,19 @@ def test_power_trace_ipc2221_errors_when_load_exceeds_ampacity():
assert findings[0].recommendation
def test_power_trace_uses_parsed_width_and_thickness():
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
graph, cmap, layout = _ldo_graph_layout(i_load=0.05, width=1.0, thickness=0.035)
findings = check_pcb_power_traces(graph, cmap, layout)
assert findings == []
def test_power_trace_skips_without_i_load():
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
graph, cmap, layout = _ldo_graph_layout(i_load=10)
graph.components["U1"].specs.values["i_load_a"] = None # type: ignore[union-attr]
# drop i_load
graph.components["U1"].specs = None
assert check_pcb_power_traces(graph, cmap, layout) == []
@@ -502,6 +509,38 @@ def test_thermal_copper_warns_without_pour_or_vias():
assert findings[0].recommendation
def test_gnd_stitch_skips_tiny_gpio():
from backend.periscopex.models import LayoutZone, PinConnection
from backend.periscopex.pcb_power_thermal import check_pcb_gnd_stitch
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="X",
pins={"1": "GPIO4"},
),
},
nets={
"GPIO4": Net(
name="GPIO4", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
},
)
layout = LayoutGraph(
footprints={},
segments=[
LayoutSegment(start=(0, 0), end=(0.1, 3), width=0.2, layer="F.Cu", net="GPIO4"),
],
zones=[
LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 5), (0, 5)]]),
],
vias=[],
)
assert check_pcb_gnd_stitch(graph, layout) == []
def test_gnd_stitch_info_when_signal_has_pour_but_no_via():
from backend.periscopex.models import LayoutVia, LayoutZone, PinConnection
from backend.periscopex.pcb_power_thermal import check_pcb_gnd_stitch
@@ -511,12 +550,12 @@ def test_gnd_stitch_info_when_signal_has_pour_but_no_via():
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="X",
pins={"1": "SDA"},
pins={"1": "USB_DP"},
),
},
nets={
"SDA": Net(
name="SDA", net_type=NetType.SIGNAL,
"USB_DP": Net(
name="USB_DP", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
},
@@ -524,17 +563,18 @@ def test_gnd_stitch_info_when_signal_has_pour_but_no_via():
layout = LayoutGraph(
footprints={},
segments=[
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="SDA"),
LayoutSegment(start=(0, 0), end=(20, 8), width=0.2, layer="F.Cu", net="USB_DP"),
],
zones=[
LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 5), (0, 5)]]),
LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 10), (0, 10)]]),
],
vias=[],
)
findings = check_pcb_gnd_stitch(graph, layout)
assert findings and findings[0].rule_id == "PE-STCH-001"
assert findings[0].status == "INFO"
layout.vias = [LayoutVia(x=10, y=0, net="GND", drill=0.3)]
assert findings[0].action
layout.vias = [LayoutVia(x=10, y=4, net="GND", drill=0.3)]
assert check_pcb_gnd_stitch(graph, layout) == []
+54
View File
@@ -262,6 +262,60 @@ def test_keepout_foreign_track_in_courtyard_is_ps_plc_004():
assert len(plc) == 1
assert plc[0].designator == "X1"
assert plc[0].net == "GND"
assert plc[0].finding_class == "REVIEW"
assert plc[0].action
def test_ic_courtyard_pad_nets_are_not_keepout_violations():
"""GND/I2C into an IC courtyard is normal copper to that part's pads."""
from backend.periscopex.models import Component, ComponentType, Net, NetType, PinConnection
cons = {
"MCU": ComponentConstraints(
mpn="MCU",
pintable=[Pin(number="1", name="SDA")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{"kind": "keepout", "pin": "1"}],
)
}
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="MCU",
pins={"1": "SDA", "2": "GND"},
),
},
nets={
"SDA": Net(
name="SDA", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
"GND": Net(
name="GND", net_type=NetType.GROUND,
pins=[PinConnection(component_ref="U1", pin_number="2")],
),
},
)
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer="F.Cu",
pads=[
LayoutPad(number="1", x=0.2, y=0.2, net="SDA"),
LayoutPad(number="2", x=0.8, y=0.2, net="GND"),
],
courtyard=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
),
},
segments=[
LayoutSegment(start=(0.4, 0.4), end=(0.6, 0.4), width=0.2, layer="F.Cu", net="GND"),
LayoutSegment(start=(0.3, 0.5), end=(0.5, 0.5), width=0.2, layer="F.Cu", net="SDA"),
],
)
findings = check_placement(graph, cons, layout)
assert [f for f in findings if f.rule_id == "PE-PLC-004"] == []
def test_keepout_own_net_in_courtyard_is_silent():