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:
2026-09-21 11:06:38 +02:00
parent 0d817c876f
commit 07ad5c72fa
16 changed files with 3801 additions and 50 deletions
@@ -36,14 +36,20 @@ _FAMILIES = (
"TO-252", "TO252", "TO-263", "QFP",
)
_EP_PAD = re.compile(
r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD)(?:[_-]?\d+)?$",
r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|THERMAL[\s_]?PAD)(?:[_-]?\d+)?$",
re.I,
)
_EP_SPLIT = re.compile(r"^\d+_\d+$")
_V_PARAM = re.compile(
r"(?:^|[\s_/])(VCC|VDD|VIN|VSUPPLY|V_IN|SUPPLY|VDS|VCEO|VOLTAGE)",
re.I,
)
_NOT_V = re.compile(r"(IOUT|CURRENT|POWER|PD|TJ|TSTG|TEMP)", re.I)
_GND_DIFF_V = re.compile(
r"ground voltage|vss to vss|vss.?to.?vss|thermal pad",
re.I,
)
_RAIL_TOKEN = re.compile(r"\b(V[A-Z][A-Z0-9]*)\b")
_T_PARAM = re.compile(r"(T[JA]|TJMAX|T_J|TSTG|TSTORAGE|OPERATING.?TEMP)", re.I)
_I_PARAM = re.compile(r"(IOUT|I_OUT|IDC|ICONT|CURRENT)", re.I)
@@ -68,27 +74,41 @@ def _sch_fp(graph: DesignGraph, ref: str, comp: Component) -> str:
return str(row.get("footprint") or comp.footprint or "")
def _signal_pads(numbers: list[str]) -> set[str]:
def _is_ep_land(number: str, pinfunction: str = "", pin_count: int | None = None) -> bool:
s = str(number).strip()
pf = str(pinfunction or "").strip()
if _EP_PAD.match(s) or (pf and _EP_PAD.match(pf)):
return True
if s.lower().replace(" ", "") in {"thermalpad", "thermal"}:
return True
if _EP_SPLIT.match(s):
return True
if pin_count and s.isdigit() and int(s) == pin_count + 1:
return True
if pin_count and pin_count <= 48 and re.match(r"^[A-Z]\d+$", s):
return True
return False
def _signal_pads(numbers: list[str], pin_count: int | None = None) -> set[str]:
out: set[str] = set()
for n in numbers:
s = str(n).strip()
if not s or _EP_PAD.match(s):
if not s or _is_ep_land(s, pin_count=pin_count):
continue
out.add(s)
return out
def _pcb_signal_pads(fp_layout) -> set[str]:
def _pcb_signal_pads(fp_layout, pin_count: int | None = None) -> set[str]:
"""Component lands only. Vias are not in ``fp.pads``; EP/thermal names skip."""
out: set[str] = set()
if fp_layout is None:
return out
for pad in fp_layout.pads:
n = str(pad.number).strip()
if not n or _EP_PAD.match(n):
continue
pf = str(getattr(pad, "pinfunction", "") or "").strip()
if pf and _EP_PAD.match(pf):
if not n or _is_ep_land(n, pf, pin_count):
continue
out.add(n)
return out
@@ -253,11 +273,13 @@ def _pinout_findings(
) -> list[Finding]:
out: list[Finding] = []
mpn = comp.mpn or ""
sch_pins = _signal_pads(list(comp.pins.keys()))
pin_count = pkg.pin_count if pkg else None
sch_pins = _signal_pads(list(comp.pins.keys()), pin_count)
ds_pins = _signal_pads(
[str(p.number) for p in (cons.pintable if cons else [])]
[str(p.number) for p in (cons.pintable if cons else [])],
pin_count,
)
pcb_pins = _pcb_signal_pads(fp_layout)
pcb_pins = _pcb_signal_pads(fp_layout, pin_count)
ds_count = pkg.pin_count if pkg else (len(ds_pins) or None)
pcb_count = len(pcb_pins) if pcb_pins else None
if ds_count and pcb_count and abs(ds_count - pcb_count) > 1:
@@ -311,6 +333,23 @@ def _pinout_findings(
return out
def _absmax_binds_pin(parameter: str, pin) -> bool:
"""Bind a voltage abs-max to the named pin, not every supply pin."""
param = parameter or ""
if _GND_DIFF_V.search(param) and not re.search(r"\bVIN\b|\bVDD\b|\bVCC\b", param, re.I):
return False
pin_u = f"{pin.name or ''} {pin.description or ''}".upper()
xtal = re.search(r"XTAL\d*", param, re.I)
if xtal:
return xtal.group(0).upper() in pin_u
rails = _RAIL_TOKEN.findall(param.upper())
rails = [r for r in rails if r not in {"VOLTAGE", "VSS", "VEE"}]
if not rails:
return False
name_u = (pin.name or "").upper()
return any(r == name_u or r in name_u.split("/") or name_u.startswith(r) for r in rails)
def _voltage_findings(
graph: DesignGraph,
ref: str,
@@ -330,8 +369,7 @@ def _voltage_findings(
if not _V_PARAM.search(r.parameter or ""):
continue
for pin in cons.pintable:
blob = f"{pin.number} {pin.name} {pin.description or ''}"
if not _V_PARAM.search(blob):
if not _absmax_binds_pin(r.parameter or "", pin):
continue
net = graph.pin_net(ref, str(pin.number))
if not net:
@@ -16,6 +16,7 @@ from backend.periscopex.models import (
LayoutGraph,
)
from backend.periscopex.pcb_net_match import (
is_no_connect_net,
normalize_kicad_hierarchy_net,
refs_on_matched_net,
)
@@ -78,7 +79,7 @@ def check_emi(
if not rules:
continue
for pin, net in sorted(comp.pins.items(), key=lambda x: str(x[0])):
if not net or skip_si_net(net):
if not net or is_no_connect_net(net) or skip_si_net(net):
continue
if not bus_class(net) and comp.component_type != ComponentType.CONNECTOR:
continue
@@ -333,14 +333,28 @@ def parse_kicad_pcb(path: str | Path) -> LayoutGraph:
if not isinstance(node, list) or not node:
continue
tag = _tag(node)
if tag == "net" and len(node) >= 3 and not any(isinstance(x, list) and x and x[0] == "node" for x in node[1:]):
try:
code = int(_fnum(node[1]))
except (TypeError, ValueError):
if tag == "net" and not any(
isinstance(x, list) and x and x[0] == "node" for x in node[1:]
):
# KiCad 6/7: ``(net 1 "GND")``. KiCad 9/10 table or pad: ``(net "GND")``.
if len(node) >= 3:
try:
code = int(_fnum(node[1]))
except (TypeError, ValueError):
name = str(node[1])
if name:
nets.setdefault(name, len(nets))
continue
name = str(node[2])
if name:
nets[name] = code
continue
name = str(node[2])
if name:
nets[name] = code
if len(node) == 2 and not isinstance(node[1], list):
token = node[1]
if isinstance(token, str) and not str(token).replace(".", "", 1).isdigit():
name = str(token)
if name:
nets.setdefault(name, len(nets))
continue
if tag in {"footprint", "module"}:
fp_name = str(node[1]) if len(node) > 1 and not isinstance(node[1], list) else ""
@@ -412,6 +426,16 @@ def parse_kicad_pcb(path: str | Path) -> LayoutGraph:
zones.extend(_parse_zone(node, nets))
continue
if not nets:
names: list[str] = []
for fp in footprints.values():
names.extend(p.net for p in fp.pads if p.net)
names.extend(s.net for s in segments if s.net)
names.extend(v.net for v in vias if v.net)
names.extend(z.net for z in zones if z.net)
for i, name in enumerate(dict.fromkeys(names)):
nets[name] = i
return LayoutGraph(
nets=nets,
footprints=footprints,
+36 -14
View File
@@ -13,7 +13,11 @@ from backend.periscopex.finding_engine import complete_findings
from backend.periscopex.functional_groups import FunctionalGroupsReport
from backend.periscopex.hierarchy import check_hierarchy
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
from backend.periscopex.pcb_net_match import check_pcb_net_match, kicad_nets_match
from backend.periscopex.pcb_net_match import (
check_pcb_net_match,
is_no_connect_net,
kicad_nets_match,
)
from backend.periscopex.pcb_power_thermal import (
check_pcb_gnd_stitch,
check_pcb_junction_temp,
@@ -173,23 +177,38 @@ def merge_schema_pcb_reports(
return out
def _incomplete_layout_finding(layout: LayoutGraph | None) -> list[Finding]:
"""INFO when pads exist on nets that have no track copper."""
if layout is None or not layout.footprints:
return []
def _net_has_copper(layout: LayoutGraph, net: str) -> bool:
"""Track, via, or pour (not keepout) — KiCad DRC does not require a segment."""
for s in layout.segments:
if s.net and kicad_nets_match(s.net, net):
return True
for v in layout.vias:
if v.net and kicad_nets_match(v.net, net):
return True
for z in layout.zones:
if z.keepout or not z.net:
continue
if kicad_nets_match(z.net, net):
return True
return False
def unrouted_pad_nets(layout: LayoutGraph) -> list[str]:
"""Pad nets with no copper. NC / ``unconnected-(…)`` nets are omitted."""
pad_nets = {
p.net
for fp in layout.footprints.values()
for p in fp.pads
if p.net
if p.net and not is_no_connect_net(p.net)
}
if not pad_nets:
return sorted(n for n in pad_nets if not _net_has_copper(layout, n))
def _incomplete_layout_finding(layout: LayoutGraph | None) -> list[Finding]:
"""INFO when pads exist on nets that have no track, via, or pour copper."""
if layout is None or not layout.footprints:
return []
seg_nets = {s.net for s in layout.segments if s.net}
missing = sorted(
n for n in pad_nets
if not any(kicad_nets_match(n, s) for s in seg_nets)
)
missing = unrouted_pad_nets(layout)
if not missing:
return []
rec = (
@@ -207,8 +226,11 @@ def _incomplete_layout_finding(layout: LayoutGraph | None) -> list[Finding]:
f"{len(layout.segments)} track segment(s); {len(missing)} pad net(s) "
f"have no copper ({sample}{extra})."
),
facts=f"unrouted_pad_nets={len(missing)}; segments={len(layout.segments)}.",
requirement="Routing-dependent PCB checks need track geometry.",
facts=(
f"unrouted_pad_nets={len(missing)}; segments={len(layout.segments)}; "
f"vias={len(layout.vias)}; zones={len(layout.zones)}."
),
requirement="Routing-dependent PCB checks need track, via, or zone geometry.",
inference="INSUFFICIENT — same MODE=pcb exam, no incomplete-board mode.",
why="Unfinished placing/routing is not a fabricated millimetre FAIL.",
status="INFO",
@@ -9,8 +9,15 @@ emit per-pad reconnect findings.
from __future__ import annotations
import re
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
_NO_CONNECT_NET = re.compile(
r"(?:^|/)unconnected[-(]|^(?:n/?c|n\.c\.|no[_-]?connect)",
re.I,
)
def normalize_kicad_hierarchy_net(name: str) -> str:
"""Drop leading slashes; keep inner sheet path (``sheet/NET``)."""
@@ -37,6 +44,13 @@ def refs_on_matched_net(graph: DesignGraph, net_name: str) -> list[str]:
return sorted(refs)
def is_no_connect_net(name: str) -> bool:
"""KiCad ``unconnected-(U1-NC-Pad3)`` / NC nets — not unfinished routing."""
n = normalize_kicad_hierarchy_net(name)
leaf = n.split("/")[-1] if n else ""
return bool(_NO_CONNECT_NET.search(n) or _NO_CONNECT_NET.search(leaf))
def kicad_nets_match(a: str, b: str) -> bool:
"""True if schematic and PCB names are the same KiCad net.
@@ -36,7 +36,11 @@ _IPC_C = 0.725
_IPC_K_EXT = 0.048
_IPC_K_INT = 0.024
_TJMAX_KEYS = ("tj_max", "tj_max_c", "tjmax", "max_junction_temp_c", "t_jmax")
_SENSE_RE = re.compile(r"(kelvin|sense|isns|i_sns|cs\+|cs-|iout_sns)", re.I)
_SENSE_RE = re.compile(
r"(kelvin|current[-\s]?sense|isns|i_sns|iout_sns|\bcs\+|\bcs-)",
re.I,
)
_NOT_KELVIN_PIN = re.compile(r"^(?:IN|VIN)[+\-]?$", re.I)
_HS_NET_RE = re.compile(
r"(USB|DP|DM|D\+|D-|HS|HDMI|MIPI|DDR|CLK|XTAL|HFX|LFX|SWP|DIFF)",
re.I,
@@ -131,14 +135,37 @@ def check_pcb_power_traces(
out: list[Finding] = []
seen_nets: set[str] = set()
for ref, comp in sorted(graph.components.items()):
got = _load_on_output(graph, constraints_map, ref)
if not got:
if comp.component_type != ComponentType.IC:
continue
i_load, net, _ = got
key = normalize_kicad_hierarchy_net(net)
i_load = _first(_specs_values(comp), _LOAD_KEYS)
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
vout = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
if not vout:
continue
key = normalize_kicad_hierarchy_net(vout)
if key in seen_nets:
continue
seen_nets.add(key)
net = vout
if i_load is None or i_load <= 0:
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="layout_power",
finding=(
f"{net} has no datasheet I_load; IPC-2221 ampacity is not applied."
),
why="I_load is required; width/current are never invented.",
status="INFO",
recommendation="Add I_load to the shared library extraction, then re-run PCB review.",
source="pcb_power_thermal",
rule_id="PE-PWR-001",
evidence_status="INSUFFICIENT",
assumptions=["No default current; IPC-2221 skipped."],
net=net,
pins=[],
))
continue
w_mm, layers = _min_width_layers(layout, net)
if w_mm is None:
continue
@@ -232,14 +259,35 @@ def check_pcb_via_current(
out: list[Finding] = []
seen: set[str] = set()
for ref, comp in sorted(graph.components.items()):
got = _load_on_output(graph, constraints_map, ref)
if not got:
if comp.component_type != ComponentType.IC:
continue
i_load = _first(_specs_values(comp), _LOAD_KEYS)
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
net = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
if not net:
continue
i_load, net, _ = got
key = normalize_kicad_hierarchy_net(net)
if key in seen:
continue
seen.add(key)
if i_load is None or i_load <= 0:
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="layout_power",
finding=(
f"{net} has no datasheet I_load; via current is not rated."
),
why="I_load is required; via ampacity is never invented.",
status="INFO",
recommendation="Add I_load to the shared library extraction, then re-run PCB review.",
source="pcb_power_thermal",
rule_id="PE-VIA-001",
evidence_status="INSUFFICIENT",
net=net,
pins=[],
))
continue
n, _drill = _via_stats(layout, net)
if n > 0:
# Geometry is present; no via-ampacity table in the library — do not
@@ -641,6 +689,8 @@ def check_pcb_kelvin(
x for x in (str(pin.number), pin.name, pin.description or "")
if x
)
if _NOT_KELVIN_PIN.match(pin.name or ""):
continue
if not _SENSE_RE.search(blob):
continue
net = graph.pin_net(ref, str(pin.number))
+2 -1
View File
@@ -15,6 +15,7 @@ from backend.periscopex.models import (
)
from backend.periscopex.passive_rail_check import _is_ic_supply_pin
from backend.periscopex.pcb_net_match import (
is_no_connect_net,
normalize_kicad_hierarchy_net,
refs_on_matched_net,
)
@@ -82,7 +83,7 @@ def check_power_integrity(
cons = _match_constraints(comp.mpn or comp.value, cmap)
i_load = _i_load(comp)
for pin_num, net in sorted(comp.pins.items(), key=lambda x: str(x[0])):
if not net:
if not net or is_no_connect_net(net):
continue
net_key = normalize_kicad_hierarchy_net(net)
if net_key in seen:
@@ -68,6 +68,20 @@ def _decode_letter_decimal(digits: str, decoder: ValueDecoder) -> float:
return float(digits)
def _decode_lqw_henries(digits: str) -> float:
c = (digits or "").strip().upper()
nh: float | None = None
if re.fullmatch(r"[0-9]N[0-9]", c):
nh = float(f"{c[0]}.{c[2]}")
elif re.fullmatch(r"[0-9]{2}N", c):
nh = float(c[:2])
elif re.fullmatch(r"R[0-9]{2}", c):
nh = float(f"0.{c[1:]}") * 1000.0
if nh is None:
raise ValueError(f"Cannot decode LQW inductance {digits!r}")
return nh * 1e-9
def decode_value(digits: str, decoder: ValueDecoder, tolerance_code: str | None = None) -> float:
if decoder.type == "eia3_pf":
pf = _decode_eia3_pf(digits)
@@ -76,6 +90,9 @@ def decode_value(digits: str, decoder: ValueDecoder, tolerance_code: str | None
return _decode_eia4_ohm(digits, tolerance_code or "", decoder)
if decoder.type == "letter_decimal_ohm":
return _decode_letter_decimal(digits, decoder)
if decoder.type in {"murata_lqw_inductance", "lqw_nh"}:
henries = _decode_lqw_henries(digits)
return henries if decoder.output_unit == "H" else henries * 1e9
raise ValueError(f"Unknown decoder type: {decoder.type}")
@@ -269,9 +286,20 @@ def resolve_bom(
try:
pat, groups = match
by_name = {f.name: f for f in pat.fields}
digits = groups.get("resistance") or groups.get("capacitance") or ""
digits = (
groups.get("resistance")
or groups.get("capacitance")
or groups.get("inductance")
or groups.get("l")
or ""
)
tol_code = groups.get("tolerance", "")
value = decode_value(digits, pat.value_decoder, tol_code)
try:
value = decode_value(digits, pat.value_decoder, tol_code)
except ValueError as exc:
if skipped is not None:
skipped.append(SkippedItem(mpn, "passive_resolve", str(exc)))
continue
formatted = _format_value(value, pat.value_decoder.output_unit)
def _lookup(field: str, group: str) -> str | None:
@@ -135,9 +135,13 @@ def _leaf(net: str) -> str:
def skip_si_net(net: str) -> bool:
"""I2C / GPIO / EN / analog / USB-CC — not impedance-controlled pairs."""
from backend.periscopex.pcb_net_match import is_no_connect_net
leaf = _leaf(net)
if not leaf:
return True
if is_no_connect_net(net):
return True
if re.search(r"CC[12]", leaf, re.I):
return True
return bool(_SKIP_RE.search(leaf))
@@ -57,6 +57,8 @@ _LQW18AN = re.compile(
r"^LQW18AN(?P<l>[0-9]N[0-9]|[0-9]{2}N|R[0-9]{2})(?P<tol>[BCSGHJKD])\d{2}",
re.IGNORECASE,
)
# Murata BLM: size + series + 3-digit Z (12×10^n Ω @ 100 MHz). DCR is not Z.
_BLM = re.compile(r"^BLM\d{2}[A-Z]{2}(?P<z>\d{3})", re.IGNORECASE)
_LQW_TOL = {
"B": "±0.1nH",
"C": "±0.2nH",
@@ -176,4 +178,14 @@ def specs_from_mpn(mpn: str) -> ComponentModel | None:
}
return _model(raw, "passive.inductor", values)
m = _BLM.match(raw)
if m:
code = m.group("z")
ohms = float(int(code[:2]) * (10 ** int(code[2])))
values = {
"impedance_ohm": ohms,
"value_formatted": _spice(str(ohms), None, "ohm"),
}
return _model(raw, "passive.ferrite_bead", values)
return None
@@ -72,7 +72,7 @@ def specs_from_bom_value(
if prefix == "FB" or "@" in compact:
m = _FB.match(compact) or _FB.match(raw)
if m:
if m and "@" in compact:
z = _spice(m.group("num"), m.group("mul"), "ohm")
freq = re.sub(r"\s+", "", m.group("freq") or "")
values = {
@@ -80,6 +80,24 @@ def specs_from_bom_value(
"value_formatted": f"{z}@{freq}" if freq else z,
}
return _model(mpn, "passive.ferrite_bead", values)
if prefix == "FB":
# Ohm without @freq is DCR, not Z. Do not invent impedance.
m_dcr = _RES_UNIT.match(compact) or _RES_BARE_MUL.match(compact)
dcr = None
if m_dcr:
dcr = _spice(m_dcr.group("num"), m_dcr.group("mul"), "ohm")
from_mpn = None
try:
from backend.services.passive_from_mpn import specs_from_mpn
from_mpn = specs_from_mpn(mpn)
except Exception:
from_mpn = None
if from_mpn is not None:
return from_mpn
values: dict[str, str] = {"value_formatted": "FB"}
if dcr is not None:
values["dcr_ohms"] = dcr
return _model(mpn, "passive.ferrite_bead", values)
if prefix in {"C", ""}:
m = _CAP.match(compact) or _CAP.match(raw)
@@ -2,6 +2,17 @@
What's new in Periscope.
## 2.60.11 — 2026-09-21 — PCB software false positives
KiCad 9/10 boards have no numeric net table (`(net "GND")` on pads/tracks). The net index is filled from those names. PE-LAY-004 counts copper on **tracks, vias, and pours**, skips `unconnected-(…)` NC nets, and matches `/sheet/` prefixes — it must not report ~139 “unrouted” nets when KiCad DRC `unconnected_items` is ~30 (islands on already-named nets). Missing `I_load` is INSUFFICIENT on PE-PWR-001 / PE-VIA-001. Thermal/EP extra pads are not PE-BOM-011 ERROR. Voltage abs-max binds to the named pin. Kelvin ignores CRS/IN±. EMI/PI skip NC nets. LQW18AN `murata_lqw_inductance` decodes nH. FB/BLM beads are ferrite; DCR is not Z. Thinking `reasoning_content` echo unchanged.
- [Fixed] Empty `LayoutGraph.nets` on KiCad 9/10 name-only nets.
- [Fixed] PE-LAY-004 zone/via/NC join vs DRC unconnected order of magnitude.
- [Fixed] PE-PWR-001 / PE-VIA-001 emit INSUFFICIENT without `I_load`.
- [Fixed] PE-BOM-011 EP/thermal extras; PE-BOM-012 pin-bound abs-max.
- [Fixed] PE-KEL-001 / PE-EMI-001 / PE-PI-001 NC and IN± / CRS false positives.
- [Fixed] LQW decoder; BLM/FB not typed as a DCR resistor.
## 2.60.10 — 2026-09-21 — Hierarchical sheets + DeepSeek thinking echo
Apri progetto still skips `.history` / `.git` / backups so the folder pick does not hang. It now keeps **every** linked schematic: all `*.kicad_sch` next to the `.kicad_pro`, plus `Sheetfile` / `(sheet` / `file=` children in a subfolder. Not only `HubAudio.kicad_sch`.
+3167
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -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():
+358
View File
@@ -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
+3 -3
View File
@@ -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,
)