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.
357 lines
12 KiB
Python
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
|