Fix PCB software false positives (2.60.11).
KiCad 9/10 name-only nets fill the index. PE-LAY-004 counts tracks, vias, and pours and skips NC nets. Missing I_load is INSUFFICIENT. EP pads are not PE-BOM-011; abs-max binds the named pin. Kelvin/EMI/PI ignore NC and IN±. LQW decodes nH; BLM/FB is a bead, not DCR as Z.
This commit is contained in:
Vendored
+3167
File diff suppressed because it is too large
Load Diff
@@ -514,7 +514,10 @@ def test_power_trace_skips_without_i_load():
|
||||
graph, cmap, layout = _ldo_graph_layout(i_load=10)
|
||||
graph.components["U1"].specs.values["i_load_a"] = None # type: ignore[union-attr]
|
||||
graph.components["U1"].specs = None
|
||||
assert check_pcb_power_traces(graph, cmap, layout) == []
|
||||
findings = check_pcb_power_traces(graph, cmap, layout)
|
||||
assert findings
|
||||
assert findings[0].rule_id == "PE-PWR-001"
|
||||
assert findings[0].evidence_status == "INSUFFICIENT"
|
||||
|
||||
|
||||
def test_kelvin_sense_pin_on_shared_net():
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
"""Regression: PCB software false positives (not HubAudio copper)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
|
||||
from backend.periscopex.emi_check import check_emi
|
||||
from backend.periscopex.models import (
|
||||
AbsMaxRating,
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
InductorSpecs,
|
||||
LayoutFootprint,
|
||||
LayoutGraph,
|
||||
LayoutPad,
|
||||
LayoutSegment,
|
||||
LayoutZone,
|
||||
Net,
|
||||
NetType,
|
||||
PackageInfo,
|
||||
Pin,
|
||||
PinConnection,
|
||||
ResistorSpecs,
|
||||
SimpleComponentSpecs,
|
||||
ValueDecoder,
|
||||
)
|
||||
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
|
||||
from backend.periscopex.pcb_checks import unrouted_pad_nets
|
||||
from backend.periscopex.pcb_power_thermal import (
|
||||
check_pcb_kelvin,
|
||||
check_pcb_power_traces,
|
||||
check_pcb_via_current,
|
||||
)
|
||||
from backend.periscopex.pi_check import check_power_integrity
|
||||
from backend.periscopex.resolve_passives import decode_value
|
||||
from backend.services.passive_from_mpn import specs_from_mpn
|
||||
from backend.services.passive_from_value import specs_from_bom_value
|
||||
|
||||
_DRC = Path(__file__).parent / "fixtures" / "HubAudio-DRC.rpt"
|
||||
_HUB_PCB = Path(
|
||||
"/Users/michelebigi/Development/HubAudio/hardware/kicad/HubAudio/HubAudio.kicad_pcb"
|
||||
)
|
||||
|
||||
_KICAD10 = """(kicad_pcb (version 20260206) (generator pcbnew)
|
||||
(net "GND")
|
||||
(net "+3V3")
|
||||
(footprint "R_0603"
|
||||
(layer "F.Cu")
|
||||
(at 10 10 0)
|
||||
(property "Reference" "R1" (at 0 0 0) (effects (font (size 1 1))))
|
||||
(pad "1" smd rect (at -0.5 0) (size 0.8 0.9) (layers "F.Cu") (net "+3V3"))
|
||||
(pad "2" smd rect (at 0.5 0) (size 0.8 0.9) (layers "F.Cu") (net "GND"))
|
||||
)
|
||||
(segment (start 10 10) (end 12 10) (width 0.2) (layer "F.Cu") (net "+3V3"))
|
||||
(via (at 11 10) (size 0.8) (drill 0.4) (layers "F.Cu" "B.Cu") (net "GND"))
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def test_kicad10_name_only_nets_fill_index(tmp_path: Path):
|
||||
p = tmp_path / "k10.kicad_pcb"
|
||||
p.write_text(_KICAD10)
|
||||
g = parse_kicad_pcb(p)
|
||||
assert "GND" in g.nets and "+3V3" in g.nets
|
||||
assert g.footprints["R1"].pads[0].net == "+3V3"
|
||||
assert g.segments[0].net == "+3V3"
|
||||
assert g.vias[0].net == "GND"
|
||||
|
||||
|
||||
def test_lay004_zone_counts_as_copper():
|
||||
layout = LayoutGraph(
|
||||
footprints={
|
||||
"C1": LayoutFootprint(
|
||||
reference="C1", x=0, y=0, layer="F.Cu",
|
||||
pads=[
|
||||
LayoutPad(number="1", x=0, y=0, net="GND"),
|
||||
LayoutPad(number="2", x=1, y=0, net="USB_DP"),
|
||||
],
|
||||
),
|
||||
},
|
||||
zones=[
|
||||
LayoutZone(
|
||||
net="GND", layer="In1.Cu",
|
||||
outlines=[[(0, 0), (10, 0), (10, 10), (0, 10)]],
|
||||
),
|
||||
],
|
||||
)
|
||||
missing = unrouted_pad_nets(layout)
|
||||
assert missing == ["USB_DP"]
|
||||
|
||||
|
||||
def test_lay004_skips_unconnected_nc_and_matches_sheet_prefix():
|
||||
layout = LayoutGraph(
|
||||
footprints={
|
||||
"U1": LayoutFootprint(
|
||||
reference="U1", x=0, y=0, layer="F.Cu",
|
||||
pads=[
|
||||
LayoutPad(number="1", x=0, y=0, net="/Codec/LINE_OUT_R"),
|
||||
LayoutPad(number="2", x=1, y=0, net="unconnected-(U1-NC-Pad2)"),
|
||||
],
|
||||
),
|
||||
},
|
||||
segments=[
|
||||
LayoutSegment(
|
||||
start=(0, 0), end=(2, 0), width=0.2, layer="F.Cu",
|
||||
net="Codec/LINE_OUT_R",
|
||||
),
|
||||
],
|
||||
)
|
||||
assert unrouted_pad_nets(layout) == []
|
||||
|
||||
|
||||
def test_drc_unconnected_items_count():
|
||||
text = _DRC.read_text(encoding="utf-8", errors="replace")
|
||||
m = re.search(r"Found (\d+) unconnected pads", text)
|
||||
assert m and int(m.group(1)) == 30
|
||||
n = len(re.findall(r"^\[unconnected_items\]", text, re.M))
|
||||
assert n == 30
|
||||
|
||||
|
||||
def test_hubaudio_pcb_lay004_vs_drc_order_of_magnitude():
|
||||
if not _HUB_PCB.is_file():
|
||||
return
|
||||
layout = parse_kicad_pcb(_HUB_PCB)
|
||||
assert layout.nets, "KiCad 10 name-only nets must fill LayoutGraph.nets"
|
||||
missing = unrouted_pad_nets(layout)
|
||||
assert len(missing) <= 30, missing[:20]
|
||||
assert len(missing) < 139
|
||||
|
||||
|
||||
def test_ep_split_pads_not_bom_011():
|
||||
cons = ComponentConstraints(
|
||||
mpn="SW",
|
||||
package_info=PackageInfo(base_family="TI", package="SON-8-EP", pin_count=8),
|
||||
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 9)],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
pads = [LayoutPad(number=str(i), x=0, y=0, net="GND") for i in range(1, 9)]
|
||||
pads += [
|
||||
LayoutPad(number="9", x=0, y=0, net="GND", pinfunction="EP"),
|
||||
LayoutPad(number="9_1", x=0.2, y=0, net="GND"),
|
||||
LayoutPad(number="9_2", x=0.4, y=0, net="GND"),
|
||||
]
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="SW", footprint="SON-8",
|
||||
component_type=ComponentType.IC, mpn="SW",
|
||||
pins={str(i): "GND" for i in range(1, 9)},
|
||||
),
|
||||
},
|
||||
nets={},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={"U1": LayoutFootprint(reference="U1", x=0, y=0, pads=pads)},
|
||||
)
|
||||
ids = {f.rule_id for f in check_bom_pcb_datasheet(graph, {"SW": cons}, layout)}
|
||||
assert "PE-BOM-011" not in ids
|
||||
|
||||
|
||||
def test_vddcr_absmax_does_not_bind_3v3_io():
|
||||
cons = ComponentConstraints(
|
||||
mpn="PHY",
|
||||
package_info=PackageInfo(base_family="SMS", package="QFN-24", pin_count=24),
|
||||
pintable=[
|
||||
Pin(number="6", name="VDDCR"),
|
||||
Pin(number="8", name="VDDIO"),
|
||||
],
|
||||
absolute_maximum_ratings=[
|
||||
AbsMaxRating(
|
||||
parameter="Digital Core Supply Voltage (VDDCR)",
|
||||
min=None, max=1.5, unit="V", source_page=52,
|
||||
),
|
||||
AbsMaxRating(
|
||||
parameter="Positive voltage on XTAL2, with respect to ground",
|
||||
min=None, max=2.5, unit="V", source_page=52,
|
||||
),
|
||||
AbsMaxRating(
|
||||
parameter="Ground voltage differences VSS to VSS (thermal pad)",
|
||||
min=None, max=0.3, unit="V", source_page=7,
|
||||
),
|
||||
],
|
||||
rules=[],
|
||||
)
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U19": Component(
|
||||
reference="U19", value="PHY", footprint="QFN-24",
|
||||
component_type=ComponentType.IC, mpn="PHY",
|
||||
pins={"6": "Net-VDDCR", "8": "3V3_ETHERNET"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"3V3_ETHERNET": Net(
|
||||
name="3V3_ETHERNET", net_type=NetType.POWER, voltage=3.3,
|
||||
pins=[PinConnection(component_ref="U19", pin_number="8")],
|
||||
),
|
||||
"Net-VDDCR": Net(
|
||||
name="Net-VDDCR", net_type=NetType.POWER, voltage=1.2,
|
||||
pins=[PinConnection(component_ref="U19", pin_number="6")],
|
||||
),
|
||||
},
|
||||
)
|
||||
findings = check_bom_pcb_datasheet(graph, {"PHY": cons}, None)
|
||||
assert all(f.rule_id != "PE-BOM-012" for f in findings)
|
||||
|
||||
|
||||
def test_kelvin_ignores_crs_and_in_plus():
|
||||
cons = ComponentConstraints(
|
||||
mpn="PHY",
|
||||
pintable=[
|
||||
Pin(number="11", name="CRS_DV/MODE2", description="carrier sense"),
|
||||
Pin(number="10", name="IN+", description="differential input"),
|
||||
],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U19": Component(
|
||||
reference="U19", value="PHY", footprint="",
|
||||
component_type=ComponentType.IC, mpn="PHY",
|
||||
pins={"11": "ETH_CRS_DV", "10": "VSYS"},
|
||||
),
|
||||
"R1": Component(
|
||||
reference="R1", value="10k", footprint="",
|
||||
component_type=ComponentType.RESISTOR, mpn="",
|
||||
pins={"1": "ETH_CRS_DV", "2": "VSYS"},
|
||||
),
|
||||
"U4": Component(
|
||||
reference="U4", value="AMP", footprint="",
|
||||
component_type=ComponentType.IC, mpn="X",
|
||||
pins={"1": "ETH_CRS_DV", "2": "VSYS"},
|
||||
),
|
||||
},
|
||||
nets={},
|
||||
)
|
||||
assert check_pcb_kelvin(graph, {"PHY": cons}) == []
|
||||
|
||||
|
||||
def test_emi_and_pi_skip_unconnected_nets():
|
||||
cons = ComponentConstraints(
|
||||
mpn="MOD",
|
||||
pintable=[
|
||||
Pin(number="38", name="USB_DN"),
|
||||
Pin(number="23", name="NC"),
|
||||
Pin(number="31", name="VDD_USB"),
|
||||
],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
layout_rules=[{
|
||||
"kind": "emi",
|
||||
"note": "antenna port 50 ohm ferrite",
|
||||
"source_page": 12,
|
||||
}],
|
||||
)
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U38": Component(
|
||||
reference="U38", value="BT", footprint="",
|
||||
component_type=ComponentType.IC, mpn="MOD",
|
||||
pins={
|
||||
"38": "unconnected-(U38-USB_DN-Pad38)",
|
||||
"31": "unconnected-(U38-VDD_USB-Pad31)",
|
||||
"23": "unconnected-(U12-NC-Pad23)",
|
||||
},
|
||||
),
|
||||
},
|
||||
nets={},
|
||||
)
|
||||
assert check_emi(graph, {"MOD": cons}, None) == []
|
||||
assert check_power_integrity(graph, {"MOD": cons}) == []
|
||||
|
||||
|
||||
def test_lqw_decoder_and_unknown_skip():
|
||||
dec = ValueDecoder(
|
||||
type="murata_lqw_inductance", base_unit="nH", output_unit="H",
|
||||
)
|
||||
assert abs(decode_value("18N", dec) - 18e-9) < 1e-15
|
||||
model = specs_from_mpn("LQW18AN18NJ00D")
|
||||
assert model is not None
|
||||
assert abs(model.specs.value_henries - 18e-9) < 1e-15
|
||||
bad = ValueDecoder(type="nope", base_unit="H", output_unit="H")
|
||||
try:
|
||||
decode_value("18N", bad)
|
||||
raise AssertionError("expected unknown decoder")
|
||||
except ValueError as exc:
|
||||
assert "Unknown decoder type" in str(exc)
|
||||
|
||||
|
||||
def test_fb1_blm_is_bead_not_dcr_resistor():
|
||||
model = specs_from_mpn("BLM18BB600SN1D")
|
||||
assert model is not None
|
||||
assert model.specs.component_subtype == "passive.ferrite_bead"
|
||||
assert model.specs.impedance_ohm == 60.0
|
||||
dcr = specs_from_bom_value("BLM18BB600SN1D", "0.25 ohm", "FB")
|
||||
assert dcr is not None
|
||||
assert dcr.specs.component_subtype == "passive.ferrite_bead"
|
||||
assert isinstance(dcr.specs, InductorSpecs)
|
||||
assert not isinstance(dcr.specs, ResistorSpecs)
|
||||
assert dcr.specs.impedance_ohm == 60.0
|
||||
assert getattr(dcr.specs, "value_ohms", None) is None
|
||||
|
||||
|
||||
def test_via_current_insufficient_without_i_load():
|
||||
from backend.periscopex.models import LayoutStackup
|
||||
|
||||
cons = ComponentConstraints(
|
||||
mpn="LDO1",
|
||||
pintable=[Pin(number="1", name="VIN"), Pin(number="2", name="VOUT")],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="LDO", footprint="",
|
||||
component_type=ComponentType.IC, mpn="LDO1",
|
||||
pins={"1": "VIN", "2": "VOUT"},
|
||||
specs=SimpleComponentSpecs(specs_type="discrete", values={}),
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"VOUT": Net(name="VOUT", net_type=NetType.POWER, pins=[]),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
stackup=LayoutStackup(
|
||||
copper_layers=["F.Cu"], dielectrics=[], copper_thickness_mm=0.035,
|
||||
),
|
||||
footprints={"U1": LayoutFootprint(reference="U1", x=0, y=0, layer="F.Cu")},
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(5, 0), width=0.5, layer="F.Cu", net="VOUT"),
|
||||
],
|
||||
)
|
||||
findings = check_pcb_via_current(graph, {"LDO1": cons}, layout)
|
||||
assert findings and findings[0].rule_id == "PE-VIA-001"
|
||||
assert findings[0].evidence_status == "INSUFFICIENT"
|
||||
pwr = check_pcb_power_traces(graph, {"LDO1": cons}, layout)
|
||||
assert pwr and pwr[0].rule_id == "PE-PWR-001"
|
||||
assert pwr[0].evidence_status == "INSUFFICIENT"
|
||||
|
||||
|
||||
def test_thinking_reasoning_content_echo_still_present():
|
||||
from pathlib import Path
|
||||
|
||||
src = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "periscope" / "src" / "backend" / "services" / "llm" / "deepseek_provider.py"
|
||||
)
|
||||
text = src.read_text(encoding="utf-8")
|
||||
assert "reasoning_content" in text
|
||||
assert "asst[\"reasoning_content\"] = reasoning" in text or 'asst["reasoning_content"] = reasoning' in text
|
||||
@@ -209,9 +209,9 @@ def test_true_mismatch_24_vs_25_still_fires(tmp_path: Path):
|
||||
ids, layout = _bom_ids(
|
||||
tmp_path,
|
||||
n_pads=24,
|
||||
extra_smd=[(25, 1.8, 1.8, "SIG")],
|
||||
extra_smd=[(30, 1.8, 1.8, "SIG")],
|
||||
)
|
||||
assert len(layout.footprints["U1"].pads) == 25
|
||||
assert len(layout.footprints["U1"].pads) == 25 # 24 signal + extra 30
|
||||
assert "PE-BOM-011" in ids
|
||||
|
||||
|
||||
@@ -230,7 +230,7 @@ def test_vias_do_not_hide_true_mismatch(tmp_path: Path):
|
||||
ids25, layout25 = _bom_ids(
|
||||
tmp2,
|
||||
n_pads=24,
|
||||
extra_smd=[(25, 1.8, 1.8, "SIG")],
|
||||
extra_smd=[(30, 1.8, 1.8, "SIG")],
|
||||
stitch_via_pads=stitches,
|
||||
board_vias=board,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user