Replace findings list with a tree; filter ESD/PI false positives.
PE-ESD-001 only on J* connector–IC nets (skip NC, unconnected, VSYS/GND/3V3). PE-PI-001 treats any capacitor on the rail, including KiCad slash prefixes, as decoupling. Sort findings ERROR then WARNING then INFO, then RULE/RISK/REVIEW/INFO. Report sidebar is a collapsed expand-on-click tree instead of an all-open list. GET /report and complete_findings fail-soft and sort the same way.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
"""ESD on connector nets and HS return path — REVIEW unless a MANDATORY FACT exists."""
|
||||
"""ESD on connector↔IC paths and HS return path — REVIEW unless a MANDATORY FACT exists."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
@@ -10,13 +12,22 @@ from backend.periscopex.models import (
|
||||
LayoutGraph,
|
||||
NetType,
|
||||
)
|
||||
from backend.periscopex.pcb_net_match import kicad_nets_match, normalize_kicad_hierarchy_net
|
||||
from backend.periscopex.pcb_net_match import (
|
||||
kicad_nets_match,
|
||||
normalize_kicad_hierarchy_net,
|
||||
refs_on_matched_net,
|
||||
)
|
||||
from backend.periscopex.pcb_power_thermal import _HS_NET_RE, _is_gnd_name
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
_USB_RE = __import__("re").compile(
|
||||
r"(USB|DP|DM|D\+|D-|VBUS|CC1|CC2|HDMI|ESD)",
|
||||
__import__("re").I,
|
||||
_J_REF_RE = re.compile(r"^J\d+", re.I)
|
||||
_UNCONNECTED_RE = re.compile(r"^unconnected", re.I)
|
||||
_NC_NET_RE = re.compile(
|
||||
r"^(?:n/?c|n\.c\.|nc|unconnected|no[_-]?connect|not[_-]?connected)$",
|
||||
re.I,
|
||||
)
|
||||
_ONBOARD_POWER_RE = re.compile(
|
||||
r"^(?:GND|AGND|DGND|PGND|GNDA|VSYS|3V3|\+3V3|3\.3V)$",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
@@ -29,68 +40,104 @@ def _is_esd_part(comp) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _net_leaf(net: str) -> str:
|
||||
n = normalize_kicad_hierarchy_net(net)
|
||||
return n.split("/")[-1] if n else ""
|
||||
|
||||
|
||||
def _skip_esd_net(net: str) -> bool:
|
||||
leaf = _net_leaf(net)
|
||||
if not leaf:
|
||||
return True
|
||||
if _UNCONNECTED_RE.match(leaf) or _NC_NET_RE.match(leaf):
|
||||
return True
|
||||
if _ONBOARD_POWER_RE.match(leaf):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_j_connector(comp) -> bool:
|
||||
if comp.component_type != ComponentType.CONNECTOR:
|
||||
return False
|
||||
return bool(_J_REF_RE.match(comp.reference or ""))
|
||||
|
||||
|
||||
def check_esd(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||
) -> list[Finding]:
|
||||
"""Connector / USB net without an ESD part → REVIEW. On-die clamp is not a RULE."""
|
||||
cmap = constraints_map or {}
|
||||
"""One REVIEW per J* connector → IC net with no ESD part. No GPIO/NC/rail spam."""
|
||||
_ = constraints_map
|
||||
out: list[Finding] = []
|
||||
seen: set[str] = set()
|
||||
connector_nets: set[str] = set()
|
||||
for comp in graph.components.values():
|
||||
if comp.component_type != ComponentType.CONNECTOR:
|
||||
continue
|
||||
connector_nets.update(n for n in comp.pins.values() if n)
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
cons = _match_constraints(comp.mpn or comp.value, cmap)
|
||||
clamp = list((cons.internal_features.esd_clamp_pins if cons and cons.internal_features else []) or [])
|
||||
for pin_num, net in sorted(comp.pins.items(), key=lambda x: str(x[0])):
|
||||
if not net or net in seen:
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
|
||||
connectors: list[tuple[str, object]] = [
|
||||
(ref, comp)
|
||||
for ref, comp in sorted(graph.components.items())
|
||||
if _is_j_connector(comp)
|
||||
]
|
||||
|
||||
for j_ref, j_comp in connectors:
|
||||
for j_pin, net in sorted(j_comp.pins.items(), key=lambda x: str(x[0])):
|
||||
if not net or _skip_esd_net(net):
|
||||
continue
|
||||
interesting = net in connector_nets or bool(_USB_RE.search(net))
|
||||
if clamp:
|
||||
tokens = [net, str(pin_num)]
|
||||
if cons:
|
||||
pin = cons.pin_by_number(pin_num)
|
||||
if pin and pin.name:
|
||||
tokens.append(pin.name)
|
||||
if any(c.upper() in t.upper() for c in clamp for t in tokens):
|
||||
interesting = True
|
||||
if not interesting:
|
||||
net_key = normalize_kicad_hierarchy_net(net)
|
||||
on_net = refs_on_matched_net(graph, net)
|
||||
if any(
|
||||
r in graph.components and _is_esd_part(graph.components[r])
|
||||
for r in on_net
|
||||
):
|
||||
continue
|
||||
seen.add(net)
|
||||
if any(_is_esd_part(graph.components[r]) for r in graph.components_on_net(net) if r in graph.components):
|
||||
ics = [
|
||||
r for r in on_net
|
||||
if r in graph.components
|
||||
and graph.components[r].component_type == ComponentType.IC
|
||||
]
|
||||
if not ics:
|
||||
continue
|
||||
rec = (
|
||||
f"Add a datasheet-specified ESD device on {net}, or record a "
|
||||
"designer decision if the connector is unused."
|
||||
)
|
||||
out.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="esd",
|
||||
finding=f"{net} reaches {ref} with no ESD/protection part on the net.",
|
||||
facts=f"Net {net}; ESD parts on net: 0; connector={net in connector_nets}.",
|
||||
requirement=(
|
||||
"External ESD is recommended unless the extraction lists a "
|
||||
"mandatory clamp requirement with a measured FACT."
|
||||
),
|
||||
inference="REVIEW — on-die esd_clamp_pins are not a board RULE.",
|
||||
why="No invented IEC 61000 level.",
|
||||
status="WARNING",
|
||||
recommendation=rec,
|
||||
action=rec,
|
||||
source="esd_return_check",
|
||||
rule_id="PE-ESD-001",
|
||||
finding_class="REVIEW",
|
||||
provenance="RECOMMENDED",
|
||||
evidence_status="SUFFICIENT",
|
||||
net=net,
|
||||
pins=[f"{ref}.{pin_num}"],
|
||||
))
|
||||
for ic_ref in sorted(ics):
|
||||
path = (j_ref, ic_ref, net_key)
|
||||
if path in seen:
|
||||
continue
|
||||
seen.add(path)
|
||||
ic = graph.components[ic_ref]
|
||||
ic_pin = next(
|
||||
(str(p) for p, n in ic.pins.items() if n and kicad_nets_match(n, net)),
|
||||
"",
|
||||
)
|
||||
rec = (
|
||||
f"Add a datasheet-specified ESD device on {net} between "
|
||||
f"{j_ref} and {ic_ref}."
|
||||
)
|
||||
out.append(Finding(
|
||||
designator=ic_ref,
|
||||
mpn=ic.mpn or "",
|
||||
aspect="esd",
|
||||
finding=(
|
||||
f"{net} is a {j_ref}–{ic_ref} path with no ESD/"
|
||||
"protection part on the net."
|
||||
),
|
||||
facts=(
|
||||
f"Net {net}; connector={j_ref}.{j_pin}; IC={ic_ref}; "
|
||||
"ESD parts on net: 0."
|
||||
),
|
||||
requirement=(
|
||||
"External ESD is recommended on connector–IC nets unless "
|
||||
"the extraction lists a mandatory clamp with a measured FACT."
|
||||
),
|
||||
inference="REVIEW — on-die esd_clamp_pins are not a board RULE.",
|
||||
why="Only J* ∩ IC nets; NC, unconnected, VSYS/GND/3V3 excluded.",
|
||||
status="WARNING",
|
||||
recommendation=rec,
|
||||
action=rec,
|
||||
source="esd_return_check",
|
||||
rule_id="PE-ESD-001",
|
||||
finding_class="REVIEW",
|
||||
provenance="RECOMMENDED",
|
||||
evidence_status="SUFFICIENT",
|
||||
net=net,
|
||||
pins=[p for p in (f"{j_ref}.{j_pin}", f"{ic_ref}.{ic_pin}" if ic_pin else "") if p],
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@@ -111,7 +158,8 @@ def check_return_path(
|
||||
out: list[Finding] = []
|
||||
seen: set[str] = set()
|
||||
for net_name, net in sorted(graph.nets.items()):
|
||||
if net.net_type != NetType.SIGNAL:
|
||||
ntype = getattr(net.net_type, "value", net.net_type)
|
||||
if ntype != NetType.SIGNAL and ntype != "signal":
|
||||
continue
|
||||
if not _HS_NET_RE.search(net_name or ""):
|
||||
continue
|
||||
|
||||
@@ -7,6 +7,7 @@ never becomes ERROR. LLM output is REVIEW, never RULE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable, Literal
|
||||
|
||||
@@ -14,6 +15,11 @@ from pydantic import BaseModel
|
||||
|
||||
from backend.periscopex.models import Finding
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_STATUS_ORDER = {"ERROR": 0, "WARNING": 1, "INFO": 2}
|
||||
_CLASS_ORDER = {"RULE": 0, "RISK": 1, "REVIEW": 2, "INFO": 3}
|
||||
|
||||
Provenance = Literal["MANDATORY", "RECOMMENDED", "TYPICAL", "EXAMPLE"]
|
||||
FindingClass = Literal["RULE", "RISK", "REVIEW", "INFO"]
|
||||
EvidenceStatus = Literal["SUFFICIENT", "INSUFFICIENT"]
|
||||
@@ -256,9 +262,31 @@ def complete_finding(f: Finding) -> Finding:
|
||||
return f
|
||||
|
||||
|
||||
def sort_findings(findings: list[Finding]) -> list[Finding]:
|
||||
"""ERROR then WARNING then INFO; within that RULE > RISK > REVIEW > INFO."""
|
||||
findings.sort(
|
||||
key=lambda f: (
|
||||
_STATUS_ORDER.get(f.status or "INFO", 9),
|
||||
_CLASS_ORDER.get(f.finding_class or "INFO", 9),
|
||||
f.designator or "",
|
||||
f.rule_id or "",
|
||||
f.finding or "",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def complete_findings(findings: list[Finding]) -> None:
|
||||
for f in findings:
|
||||
complete_finding(f)
|
||||
try:
|
||||
complete_finding(f)
|
||||
except Exception:
|
||||
log.exception(
|
||||
"complete_finding failed for %s/%s",
|
||||
getattr(f, "designator", "?"),
|
||||
getattr(f, "rule_id", None),
|
||||
)
|
||||
sort_findings(findings)
|
||||
|
||||
|
||||
def apply_decisions(
|
||||
|
||||
@@ -67,12 +67,14 @@ def build_hierarchy(
|
||||
ref_block[g.ref] = bid
|
||||
else:
|
||||
for net in graph.nets.values():
|
||||
if net.net_type.value != "power":
|
||||
ntype = getattr(net.net_type, "value", net.net_type)
|
||||
if str(ntype) != "power":
|
||||
continue
|
||||
ics = [
|
||||
p.component_ref for p in net.pins
|
||||
if (graph.components.get(p.component_ref)
|
||||
and graph.components[p.component_ref].component_type.value == "ic")
|
||||
and str(getattr(graph.components[p.component_ref].component_type, "value",
|
||||
graph.components[p.component_ref].component_type)) == "ic")
|
||||
]
|
||||
if not ics:
|
||||
continue
|
||||
@@ -103,7 +105,7 @@ def build_hierarchy(
|
||||
break
|
||||
comps.append(HierarchyComponent(
|
||||
ref=ref,
|
||||
component_type=comp.component_type.value,
|
||||
component_type=str(getattr(comp.component_type, "value", comp.component_type)),
|
||||
pins=pins,
|
||||
nets=nets,
|
||||
block_id=bid,
|
||||
@@ -120,7 +122,7 @@ def build_hierarchy(
|
||||
break
|
||||
hnets.append(HierarchyNet(
|
||||
name=name,
|
||||
net_type=net.net_type.value,
|
||||
net_type=str(getattr(net.net_type, "value", net.net_type)),
|
||||
components=refs,
|
||||
block_id=bid,
|
||||
))
|
||||
@@ -131,7 +133,7 @@ def check_hierarchy(graph: DesignGraph, plan: FunctionalGroupsReport | None = No
|
||||
"""FACT: IC pin with an empty net. Skip unnamed power-flags."""
|
||||
out: list[Finding] = []
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type.value != "ic":
|
||||
if str(getattr(comp.component_type, "value", comp.component_type)) != "ic":
|
||||
continue
|
||||
dangling = [str(n) for n, net in comp.pins.items() if not (net or "").strip()]
|
||||
if not dangling:
|
||||
|
||||
@@ -22,6 +22,21 @@ def normalize_kicad_hierarchy_net(name: str) -> str:
|
||||
return n
|
||||
|
||||
|
||||
def refs_on_matched_net(graph: DesignGraph, net_name: str) -> list[str]:
|
||||
"""Component refs on *net_name*, including KiCad ``/``-prefixed aliases."""
|
||||
refs: set[str] = set()
|
||||
if not net_name:
|
||||
return []
|
||||
for name, net in graph.nets.items():
|
||||
if name == net_name or kicad_nets_match(name, net_name):
|
||||
refs.update(pc.component_ref for pc in net.pins)
|
||||
for ref, comp in graph.components.items():
|
||||
for pin_net in comp.pins.values():
|
||||
if pin_net and (pin_net == net_name or kicad_nets_match(pin_net, net_name)):
|
||||
refs.add(ref)
|
||||
return sorted(refs)
|
||||
|
||||
|
||||
def kicad_nets_match(a: str, b: str) -> bool:
|
||||
"""True if schematic and PCB names are the same KiCad net.
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ from backend.periscopex.models import (
|
||||
LayoutGraph,
|
||||
)
|
||||
from backend.periscopex.passive_rail_check import _is_ic_supply_pin
|
||||
from backend.periscopex.pcb_net_match import (
|
||||
normalize_kicad_hierarchy_net,
|
||||
refs_on_matched_net,
|
||||
)
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
_LOAD_KEYS = ("i_load_a", "i_load", "iout", "i_out")
|
||||
@@ -78,15 +82,18 @@ 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 or net in seen:
|
||||
if not net:
|
||||
continue
|
||||
net_key = normalize_kicad_hierarchy_net(net)
|
||||
if net_key in seen:
|
||||
continue
|
||||
if not _is_ic_supply_pin(graph, cons, pin_num, net):
|
||||
continue
|
||||
seen.add(net)
|
||||
seen.add(net_key)
|
||||
locals_: list[tuple[str, float]] = []
|
||||
bulks: list[tuple[str, float]] = []
|
||||
unvalued = 0
|
||||
for cref in graph.components_on_net(net):
|
||||
for cref in refs_on_matched_net(graph, net):
|
||||
ccomp = graph.components.get(cref)
|
||||
if not ccomp:
|
||||
continue
|
||||
@@ -99,7 +106,8 @@ def check_power_integrity(
|
||||
bulks.append((cref, farads))
|
||||
else:
|
||||
locals_.append((cref, farads))
|
||||
if not locals_ and unvalued == 0:
|
||||
# Any capacitor on the rail (local, bulk, or unvalued) is decoupling.
|
||||
if not locals_ and not bulks and unvalued == 0:
|
||||
rec = f"Add a local decoupling capacitor on {net} at {ref}."
|
||||
facts = f"{ref} supply {net}: 0 local caps (<1 µF); bulk={len(bulks)}."
|
||||
if i_load is not None:
|
||||
|
||||
Reference in New Issue
Block a user