Files
periscope/backend/periscopex/si_check.py
T
michele 1b3529501f Ship remaining PCB plan slices: BOM chain, SPOF, EMI FACT, gated Tj.
Package/pinout/ratings mismatches, SPOF as REVIEW, EMI only with a
datasheet quote, and Tj from copper+vias+θJA when every parameter exists.
Re-extract SI layout_rules from 1.12.0; ImpedenceFinder license stays UNKNOWN.
2026-09-20 12:29:27 +02:00

678 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SI checks: datasheet layout_rules vs board + ImpedenceFinder.
USB / HDMI / PCIe / Ethernet / LVDS / DDR (and any net_class on an
``impedance`` / ``length_match`` / … rule) are checked. I2C, GPIO, EN,
analog REGN, USB CC are not 50 Ω pairs. No invented USB/IEC Z0.
"""
from __future__ import annotations
import math
import re
from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph, LayoutSegment
from backend.periscopex.pcb_net_match import kicad_nets_match, normalize_kicad_hierarchy_net
from backend.periscopex.pcb_power_thermal import _is_gnd_name
from backend.periscopex.validate import _match_constraints
_PAIR_SUFFIXES = (
("_DP", "_DM"),
("_P", "_N"),
("+", "-"),
("_D+", "_D-"),
(".D+", ".D-"),
)
_SKIP_RE = re.compile(
r"(?:^|[_/.\-])(SDA|SCL|I2C|GPIO\d*|GP\d+|EN|ENABLE|NRST|RST|REGN|"
r"CC[12]|SBU|ID|VBUS|VBAT|LED|ADC|NTC|STRAP)(?:$|[_/.\-]|\d)",
re.I,
)
_HS_CLASS_RE = (
("usb", re.compile(r"USB", re.I)),
("hdmi", re.compile(r"HDMI", re.I)),
("pcie", re.compile(r"PCIE|PEX_", re.I)),
("ethernet", re.compile(r"(?:^|[_/])(ETH|MDI|TRD[0-3])", re.I)),
("lvds", re.compile(r"LVDS", re.I)),
("ddr", re.compile(r"DDR|DQS|(?:^|[_/])DQ\d+", re.I)),
)
_SI_KINDS = frozenset({
"impedance", "length_match", "max_length", "spacing",
"ref_plane", "si_via", "layer", "series_resistor", "return_path", "si",
})
def _seg_len(seg: LayoutSegment) -> float:
return math.hypot(seg.end[0] - seg.start[0], seg.end[1] - seg.start[1])
def net_length_mm(layout: LayoutGraph, net: str) -> float:
return sum(
_seg_len(s) for s in layout.segments
if s.net and kicad_nets_match(s.net, net)
)
def partner_net(name: str) -> str | None:
n = name or ""
for a, b in _PAIR_SUFFIXES:
if n.endswith(a):
return n[: -len(a)] + b
if n.endswith(b):
return n[: -len(b)] + a
return None
def _leaf(net: str) -> str:
n = normalize_kicad_hierarchy_net(net)
return n.split("/")[-1] if n else ""
def skip_si_net(net: str) -> bool:
"""I2C / GPIO / EN / analog / USB-CC — not impedance-controlled pairs."""
leaf = _leaf(net)
if not leaf:
return True
if re.search(r"CC[12]", leaf, re.I):
return True
return bool(_SKIP_RE.search(leaf))
def bus_class(net: str) -> str | None:
if skip_si_net(net):
return None
leaf = _leaf(net)
if "USB" in leaf.upper() and re.search(r"(D\+|D-|DP|DM)", leaf, re.I):
return "usb"
for cls, cre in _HS_CLASS_RE:
if cls == "usb":
continue
if cre.search(leaf):
return cls
return None
def _is_hs_net(net: str) -> bool:
return bus_class(net) is not None
def _z_row(rows: list[dict] | None, net: str) -> dict | None:
for row in rows or []:
if not isinstance(row, dict) or row.get("error"):
continue
name = str(row.get("net_name") or row.get("name") or "")
if name and kicad_nets_match(name, net):
return row
return None
def _z_field(row: dict | None, *keys: str) -> float | None:
if not row:
return None
for k in keys:
v = row.get(k)
if isinstance(v, (int, float)):
return float(v)
return None
def _num(v) -> float | None:
if v is None or isinstance(v, bool):
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _z_window(rule: dict) -> tuple[float, float] | None:
lo = _num(rule.get("z_min_ohm"))
hi = _num(rule.get("z_max_ohm"))
if lo is not None and hi is not None and hi >= lo:
return (lo, hi)
nom = _num(rule.get("zdiff_ohm")) or _num(rule.get("z0_ohm"))
tol = _num(rule.get("tolerance_pct"))
if nom is not None and tol is not None and tol > 0:
return (nom * (1 - tol / 100.0), nom * (1 + tol / 100.0))
return None
def _quote(rule: dict) -> str:
note = (rule.get("note") or "").strip()
page = rule.get("source_page")
bits = [note] if note else []
if page is not None:
bits.append(f"p.{page}")
return "; ".join(bits) or "layout_rules"
def _collect_rules(graph: DesignGraph, constraints_map: dict) -> list[tuple[str, dict]]:
out: list[tuple[str, dict]] = []
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
if not cons:
continue
ic_nets = {n for n in (comp.pins.values()) if n}
for rule in cons.layout_rules or []:
kind = str(rule.get("kind") or "")
if kind not in _SI_KINDS:
continue
out.append((ref, {**rule, "_ic": ref, "_ic_nets": ic_nets, "_mpn": comp.mpn or ""}))
return out
def _rule_nets(layout: LayoutGraph, rule: dict, graph: DesignGraph) -> list[str]:
names = {s.net for s in layout.segments if s.net}
nc = str(rule.get("net_class") or "").strip().lower()
pin = str(rule.get("pin") or "").strip()
ic = rule.get("_ic")
ic_nets: set[str] = rule.get("_ic_nets") or set()
picked: list[str] = []
for net in sorted(names):
if skip_si_net(net):
continue
if pin and ic:
sch = graph.pin_net(ic, pin) if hasattr(graph, "pin_net") else None
if sch and not kicad_nets_match(sch, net):
if pin.upper() not in _leaf(net).upper():
continue
if nc:
bc = bus_class(net) or ""
if nc not in bc and nc not in _leaf(net).lower():
continue
else:
on_ic = net in ic_nets or any(kicad_nets_match(net, n) for n in ic_nets)
if not on_ic or not _is_hs_net(net):
continue
picked.append(net)
return picked
def _pair_key(a: str, b: str) -> tuple[str, str]:
return tuple(sorted((a, b))) # type: ignore[return-value]
def _partner_on_board(layout: LayoutGraph, net: str, zrow: dict | None) -> str | None:
names = {s.net for s in layout.segments if s.net}
if zrow:
p = zrow.get("partner_net_name")
if isinstance(p, str) and p:
for n in names:
if kicad_nets_match(p, n) and not skip_si_net(n):
return n
p = partner_net(net)
if p:
for n in names:
if kicad_nets_match(p, n) and not skip_si_net(n):
return n
return None
def _min_pair_spacing_mm(layout: LayoutGraph, a: str, b: str) -> float | None:
sa = [s for s in layout.segments if s.net and kicad_nets_match(s.net, a)]
sb = [s for s in layout.segments if s.net and kicad_nets_match(s.net, b)]
best = None
for x in sa:
wx = x.width / 2.0
for y in sb:
wy = y.width / 2.0
for px, py in (
(x.start, y.start), (x.start, y.end),
(x.end, y.start), (x.end, y.end),
):
d = math.hypot(px[0] - py[0], px[1] - py[1]) - wx - wy
if best is None or d < best:
best = d
return best
def _via_count(layout: LayoutGraph, net: str) -> int:
return sum(1 for v in layout.vias if v.net and kicad_nets_match(v.net, net))
def _layers(layout: LayoutGraph, net: str) -> list[str]:
return sorted({
s.layer for s in layout.segments
if s.net and kicad_nets_match(s.net, net) and s.layer
})
def _series_resistors(graph: DesignGraph, net: str) -> list[tuple[str, float | None]]:
from backend.periscopex.models import ResistorSpecs
from backend.periscopex.pcb_net_match import refs_on_matched_net
out: list[tuple[str, float | None]] = []
for ref in refs_on_matched_net(graph, net):
c = graph.components.get(ref)
if not c or c.component_type != ComponentType.RESISTOR:
continue
ohms = None
if isinstance(c.specs, ResistorSpecs) and c.specs.value_ohms:
ohms = float(c.specs.value_ohms)
out.append((ref, ohms))
return out
def _gnd_via_in_net_bbox(layout: LayoutGraph, net: str) -> bool:
segs = [s for s in layout.segments if s.net and kicad_nets_match(s.net, net)]
if not segs:
return False
xs = [c for s in segs for c in (s.start[0], s.end[0])]
ys = [c for s in segs for c in (s.start[1], s.end[1])]
xmin, xmax, ymin, ymax = min(xs), max(xs), min(ys), max(ys)
pad = 2.0
return any(
v.net and _is_gnd_name(v.net)
and xmin - pad <= v.x <= xmax + pad and ymin - pad <= v.y <= ymax + pad
for v in layout.vias
)
def _si_finding(
*,
rule_id: str,
net: str,
mpn: str,
designator: str,
verdict: str,
finding: str,
facts: str,
requirement: str,
source_page: int | None,
quote: str,
rec: str,
provenance: str,
finding_class: str,
) -> Finding:
status = {"PASS": "INFO", "MARGIN": "WARNING", "FAIL": "WARNING"}[verdict]
if verdict == "FAIL" and provenance == "MANDATORY" and finding_class == "RULE":
status = "ERROR"
return Finding(
designator=designator or "layout",
mpn=mpn,
aspect="si",
finding=f"{verdict}: {finding}",
facts=facts,
requirement=requirement,
inference="" if verdict == "PASS" else f"{verdict} vs datasheet layout_rules.",
why=requirement,
status=status, # type: ignore[arg-type]
recommendation=rec,
action=rec,
source="si_check",
rule_id=rule_id,
finding_class=finding_class, # type: ignore[arg-type]
provenance=provenance, # type: ignore[arg-type]
evidence_status="SUFFICIENT",
net=net,
pins=[],
source_page=source_page,
source_quote=quote,
)
def _param(rule: dict) -> str:
if rule.get("kind") == "si":
return str(rule.get("parameter") or "").lower()
return str(rule.get("kind") or "")
def check_si(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
impedance_nets: list[dict] | dict | None = None,
) -> list[Finding]:
if layout is None or not layout.segments:
return []
raw = impedance_nets
if isinstance(impedance_nets, dict):
raw = list(impedance_nets.get("nets") or [])
rows: list[dict] = [r for r in (raw or []) if isinstance(r, dict)]
findings: list[Finding] = []
rules = _collect_rules(graph, constraints_map)
seen: set[tuple] = set()
for _ref, rule in rules:
kind = _param(rule)
nets = _rule_nets(layout, rule, graph)
quote = _quote(rule)
page = rule.get("source_page") if isinstance(rule.get("source_page"), int) else None
mpn = str(rule.get("_mpn") or "")
ic = str(rule.get("_ic") or "layout")
if kind in ("impedance", "zdiff", "z0"):
window = _z_window(rule)
nom = _num(rule.get("zdiff_ohm")) or _num(rule.get("z0_ohm"))
want_diff = _num(rule.get("zdiff_ohm")) is not None or kind == "zdiff"
paired: set[tuple[str, str]] = set()
for net in nets:
zrow = _z_row(rows, net)
partner = _partner_on_board(layout, net, zrow) if want_diff else None
key_net = _pair_key(net, partner) if partner else (net, "")
if key_net in paired:
continue
paired.add(key_net)
sig = ("z", key_net, id(rule))
if sig in seen:
continue
seen.add(sig)
avg = _z_field(zrow, "z0_avg_ohms", "z0_ohm", "mean_z0", "z0")
zmin = _z_field(zrow, "z0_min_ohms")
zmax = _z_field(zrow, "z0_max_ohms")
topo = ""
if zrow:
t = zrow.get("topologies")
if isinstance(t, (list, tuple)):
topo = ",".join(str(x) for x in t)
elif t:
topo = str(t)
pair_lbl = f"{net}/{partner}" if partner else net
facts = (
f"ImpedenceFinder {pair_lbl}: avg={avg} min={zmin} max={zmax} Ω; "
f"topology={topo or '—'}"
)
req = quote
if window:
req = f"Z={'diff ' if want_diff else ''}{window[0]:g}{window[1]:g} Ω ({quote})"
elif nom is not None:
req = f"Z={'diff ' if want_diff else ''}{nom:g} Ω ({quote})"
rec = f"Adjust {net} geometry toward the datasheet Z, then re-run PCB review."
if window is None and nom is None:
continue
if avg is None:
findings.append(_si_finding(
rule_id="PE-SI-002", net=net, mpn=mpn, designator=ic,
verdict="FAIL",
finding=f"{net} has no ImpedenceFinder Z0 (need stackup + routed copper).",
facts=facts, requirement=req, source_page=page, quote=quote,
rec=rec, provenance="RECOMMENDED", finding_class="RISK",
))
continue
if window is None:
findings.append(_si_finding(
rule_id="PE-SI-002", net=net, mpn=mpn, designator=ic,
verdict="MARGIN",
finding=(
f"{pair_lbl} Zavg={avg:.1f} Ω vs datasheet {nom:g} Ω "
"(no tolerance in library)."
),
facts=facts, requirement=req, source_page=page, quote=quote,
rec=rec, provenance="RECOMMENDED", finding_class="REVIEW",
))
continue
lo, hi = window
avg_ok = lo <= avg <= hi
min_ok = zmin is None or lo <= zmin <= hi
max_ok = zmax is None or lo <= zmax <= hi
if avg_ok and min_ok and max_ok:
verdict, cls, prov = "PASS", "INFO", "TYPICAL"
elif avg_ok:
verdict, cls, prov = "MARGIN", "RISK", "RECOMMENDED"
else:
verdict, cls, prov = "FAIL", "RISK", "RECOMMENDED"
findings.append(_si_finding(
rule_id="PE-SI-002", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=(
f"{pair_lbl} Zavg={avg:.1f} Ω "
f"(min {zmin if zmin is not None else '—'}, "
f"max {zmax if zmax is not None else '—'}) vs {lo:g}{hi:g} Ω."
),
facts=facts, requirement=req, source_page=page, quote=quote,
rec=rec if verdict != "PASS" else "No Z0 change required.",
provenance=prov, finding_class=cls,
))
elif kind in ("length_match", "skew"):
lim = _num(rule.get("max_distance_mm"))
if lim is None:
continue
done: set[tuple[str, str]] = set()
for net in nets:
zrow = _z_row(rows, net)
partner = _partner_on_board(layout, net, zrow)
if not partner:
continue
key = _pair_key(net, partner)
if key in done:
continue
done.add(key)
la = _z_field(zrow, "length_mm") or net_length_mm(layout, net)
zb = _z_row(rows, partner)
lb = _z_field(zb, "length_mm") or net_length_mm(layout, partner)
skew = abs(la - lb)
verdict = "PASS" if skew <= lim else "FAIL"
rec = (
"Length-match the differential pair."
if verdict == "FAIL"
else "Intra-pair skew is within the datasheet millimetre."
)
findings.append(_si_finding(
rule_id="PE-SI-001", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=f"{key[0]}/{key[1]} skew {skew:.2f} mm (max {lim:g} mm).",
facts=f"L({key[0]})={la:.2f} mm; L({key[1]})={lb:.2f} mm; |Δ|={skew:.2f} mm.",
requirement=f"length_match max_distance_mm={lim:g} ({quote})",
source_page=page, quote=quote, rec=rec,
provenance="MANDATORY" if verdict == "FAIL" else "TYPICAL",
finding_class="RULE" if verdict == "FAIL" else "INFO",
))
elif kind == "max_length":
lim = _num(rule.get("max_distance_mm"))
if lim is None:
continue
for net in nets:
zrow = _z_row(rows, net)
length = _z_field(zrow, "length_mm") or net_length_mm(layout, net)
verdict = "PASS" if length <= lim else "FAIL"
findings.append(_si_finding(
rule_id="PE-SI-003", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=f"{net} length {length:.2f} mm (max {lim:g} mm).",
facts=f"length_mm={length:.2f} (ImpedenceFinder or copper sum).",
requirement=f"max_length {lim:g} mm ({quote})",
source_page=page, quote=quote,
rec="Shorten the net or document the exception." if verdict == "FAIL" else "Length is within the datasheet max.",
provenance="RECOMMENDED",
finding_class="RISK" if verdict == "FAIL" else "INFO",
))
elif kind == "spacing":
gap_min = _num(rule.get("min_spacing_mm")) or _num(rule.get("max_distance_mm"))
if gap_min is None:
continue
done_s: set[tuple[str, str]] = set()
for net in nets:
zrow = _z_row(rows, net)
partner = _partner_on_board(layout, net, zrow)
if not partner:
continue
key = _pair_key(net, partner)
if key in done_s:
continue
done_s.add(key)
gap = _min_pair_spacing_mm(layout, net, partner)
if gap is None:
continue
verdict = "PASS" if gap + 1e-9 >= gap_min else "FAIL"
findings.append(_si_finding(
rule_id="PE-SI-004", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=f"{key[0]}/{key[1]} edge gap {gap:.3f} mm (min {gap_min:g} mm).",
facts=f"min edge-to-edge {gap:.3f} mm from copper segments.",
requirement=f"spacing min_spacing_mm={gap_min:g} ({quote})",
source_page=page, quote=quote,
rec="Increase intra-pair gap to the datasheet millimetre." if verdict == "FAIL" else "Pair spacing meets the datasheet.",
provenance="RECOMMENDED",
finding_class="RISK" if verdict == "FAIL" else "INFO",
))
elif kind in ("ref_plane", "layer"):
want_topo = str(rule.get("topology") or "").strip().upper()
want_plane = str(rule.get("ref_plane") or "").lower()
for net in nets:
zrow = _z_row(rows, net)
flags = zrow.get("flags") if zrow else ()
if isinstance(flags, str):
flags = (flags,)
flags_l = " ".join(str(f).lower() for f in (flags or ()))
topos = zrow.get("topologies") if zrow else ()
if isinstance(topos, str):
topos = (topos,)
layers = _layers(layout, net)
facts = f"topology={topos or '—'}; layers={layers}; flags={flags_l or '—'}"
ok = True
reasons: list[str] = []
if want_topo:
names = {str(t).upper() for t in (topos or ())}
if names and want_topo not in names and not any(want_topo in n for n in names):
ok = False
reasons.append(f"topology {topos}{want_topo}")
if kind == "ref_plane" or "gnd" in want_plane or "ground" in want_plane:
if "void" in flags_l or "no_reference" in flags_l:
ok = False
reasons.append("ImpedenceFinder flags missing/void reference plane")
verdict = "PASS" if ok else "FAIL"
findings.append(_si_finding(
rule_id="PE-SI-005", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=("Ref plane/layer OK on " + net) if ok else (f"{net}: " + "; ".join(reasons)),
facts=facts, requirement=quote or "datasheet ref plane / layer",
source_page=page, quote=quote,
rec="Restore a continuous GND reference under the pair." if not ok else "Reference plane matches the datasheet.",
provenance="RECOMMENDED",
finding_class="RISK" if not ok else "INFO",
))
elif kind == "si_via":
mx = rule.get("max_via_count")
mn = rule.get("min_via_count")
mx_i = int(mx) if isinstance(mx, int) else None
mn_i = int(mn) if isinstance(mn, int) else None
if mx_i is None and mn_i is None:
continue
for net in nets:
n = _via_count(layout, net)
fail = (mx_i is not None and n > mx_i) or (mn_i is not None and n < mn_i)
verdict = "FAIL" if fail else "PASS"
findings.append(_si_finding(
rule_id="PE-SI-006", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=f"{net} via count {n} (min={mn_i} max={mx_i}).",
facts=f"vias on net={n}.",
requirement=f"si_via min={mn_i} max={mx_i} ({quote})",
source_page=page, quote=quote,
rec="Change via count on the HS net to the datasheet limit." if fail else "Via count matches the datasheet.",
provenance="RECOMMENDED",
finding_class="RISK" if fail else "INFO",
))
elif kind == "series_resistor":
want = _num(rule.get("value_ohms"))
for net in nets:
rs = _series_resistors(graph, net)
if not rs:
verdict = "FAIL"
facts = "series R on net: 0"
elif want is None:
verdict = "PASS"
facts = f"series R={rs}"
else:
ok_r = any(
ohms is not None and want > 0 and abs(ohms - want) / want <= 0.2
for _r, ohms in rs
)
verdict = "PASS" if ok_r else "MARGIN"
facts = f"series R={rs}; target={want:g} Ω"
findings.append(_si_finding(
rule_id="PE-SI-009", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=f"{net} series resistor {'present' if rs else 'missing'}.",
facts=facts, requirement=quote or f"series_resistor {want} Ω",
source_page=page, quote=quote,
rec="Place the datasheet series resistor on the HS net." if verdict == "FAIL" else "Series R matches the datasheet.",
provenance="RECOMMENDED",
finding_class="RISK" if verdict != "PASS" else "INFO",
))
elif kind == "return_path":
for net in nets:
ok = _gnd_via_in_net_bbox(layout, net)
verdict = "PASS" if ok else "FAIL"
findings.append(_si_finding(
rule_id="PE-SI-008", net=net, mpn=mpn, designator=ic,
verdict=verdict,
finding=f"{net} GND via in bbox: {ok}.",
facts=f"GND via near {net} bbox={ok}.",
requirement=quote or "datasheet return via",
source_page=page, quote=quote,
rec="Add a GND return via next to the pair." if not ok else "Return via present.",
provenance="RECOMMENDED",
finding_class="REVIEW" if not ok else "INFO",
))
hs_present = [s.net for s in layout.segments if s.net and _is_hs_net(s.net)]
si_kinds = {
"impedance", "zdiff", "z0", "length_match", "skew", "max_length",
"spacing", "ref_plane", "layer", "si_via", "series_resistor", "return_path",
}
if hs_present and not any(_param(r) in si_kinds for _i, r in rules):
shown: set[str] = set()
for net in hs_present:
bc = bus_class(net)
if skip_si_net(net) or not bc or bc in shown:
continue
shown.add(bc)
zrow = _z_row(rows, net)
partner = _partner_on_board(layout, net, zrow)
avg = _z_field(zrow, "z0_avg_ohms", "z0_ohm", "mean_z0", "z0")
zmin = _z_field(zrow, "z0_min_ohms")
zmax = _z_field(zrow, "z0_max_ohms")
la = _z_field(zrow, "length_mm") or net_length_mm(layout, net)
lb = 0.0
if partner:
zb = _z_row(rows, partner)
lb = _z_field(zb, "length_mm") or net_length_mm(layout, partner)
skew = abs(la - lb) if partner else 0.0
rec = (
"Re-run schematic review so pintable extract ≥ 1.12.0 fills "
"layout_rules (Z0/skew/spacing). Do not assume 90 Ω. PCB does "
"not re-read the PDF."
)
findings.append(Finding(
designator="layout",
mpn="",
aspect="si",
finding=(
f"Unverified: {bc} {net}"
+ (f"/{partner}" if partner else "")
+ f" ImpedenceFinder Zavg={avg} Ω (min {zmin}, max {zmax}); "
f"skew={skew:.2f} mm — library has no SI FACT."
),
facts=(
f"avg={avg} min={zmin} max={zmax} Ω; "
f"L={la:.2f}/{lb:.2f} mm; topologies={zrow.get('topologies') if zrow else None}."
),
requirement="Datasheet layout_rules impedance/length_match (none on file).",
inference="Not USB/IEC 90 Ω folklore; CC/GPIO/I2C are not this check.",
why="Insufficient library SI numbers.",
status="INFO",
recommendation=rec,
action=rec,
source="si_check",
rule_id="PE-SI-010",
finding_class="INFO",
provenance="TYPICAL",
evidence_status="INSUFFICIENT",
net=net,
pins=[],
))
return findings