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:
@@ -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,15 +333,29 @@ 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:]):
|
||||
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
|
||||
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 ""
|
||||
fx, fy, frot = _at(node)
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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", "")
|
||||
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`.
|
||||
|
||||
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