Files
periscope/tests/test_pcb_software_bugs.py
T
michele c6abdae762 Skip PE-PWR/PE-VIA without datasheet current (2.61.1).
Trace-width vs current runs only with I_load/Imax/I_abs. Missing I is a
skip, not INFO INSUFFICIENT, not USB 500 mA, not Iout_max-as-load.
2026-09-21 12:11:52 +02:00

357 lines
12 KiB
Python

"""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_skips_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 == []
pwr = check_pcb_power_traces(graph, {"LDO1": cons}, layout)
assert pwr == []
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