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.
This commit is contained in:
2026-09-20 12:29:27 +02:00
parent 65a91419a2
commit 1b3529501f
20 changed files with 1337 additions and 30 deletions
+417
View File
@@ -0,0 +1,417 @@
"""BOM ↔ PCB footprint ↔ datasheet package / pinout / ratings.
Skip when a side is missing. No invented JEDEC land patterns or IEC
clearances. Thermal/EP pads may differ by one from pin_count.
"""
from __future__ import annotations
import re
from backend.periscopex.models import (
AbsMaxRating,
CapacitorSpecs,
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
InductorSpecs,
LayoutGraph,
PackageInfo,
)
from backend.periscopex.thermal_check import (
_LOAD_KEYS,
_first,
_net_voltage,
_specs_values,
)
from backend.periscopex.validate import _match_constraints
_FAMILIES = (
"LQFP", "TQFP", "VQFP", "WQFN", "HVQFN", "VQFN", "QFN", "DFN",
"WSON", "USON", "SOIC", "SSOP", "TSSOP", "MSOP", "SOP",
"SOT-223", "SOT223", "SOT-23", "SOT23", "SOT-89", "SOT89",
"WLCSP", "BGA", "LGA", "CSP", "SC-70", "SC70",
"TO-252", "TO252", "TO-263", "QFP",
)
_EP_PAD = re.compile(r"^(?:EP|PAD|TAB|TH|THERMAL|DIEPAD)$", re.I)
_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)
_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)
def _family(blob: str) -> str | None:
u = (blob or "").upper().replace(" ", "")
if not u:
return None
for fam in _FAMILIES:
if fam.replace("-", "") in u.replace("-", ""):
return fam
return None
def _bom_fp(graph: DesignGraph, ref: str) -> str:
row = (graph.bom_fields or {}).get(ref) or {}
return str(row.get("footprint") or row.get("Footprint") or "")
def _sch_fp(graph: DesignGraph, ref: str, comp: Component) -> str:
row = (graph.schematic_fields or {}).get(ref) or {}
return str(row.get("footprint") or comp.footprint or "")
def _signal_pads(numbers: list[str]) -> set[str]:
out: set[str] = set()
for n in numbers:
s = str(n).strip()
if not s or _EP_PAD.match(s):
continue
out.add(s)
return out
def _package_row(cons: ComponentConstraints | None, specs) -> PackageInfo | None:
if cons and cons.package_info:
return cons.package_info
pkg = getattr(specs, "package_info", None)
if isinstance(pkg, PackageInfo):
return pkg
return None
def _cap_vrated(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, CapacitorSpecs) and specs.voltage_rating_v:
try:
return float(str(specs.voltage_rating_v).replace("V", "").strip())
except (TypeError, ValueError):
return None
return None
def _ind_irated(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, InductorSpecs) and specs.current_rating_a:
try:
return float(str(specs.current_rating_a).replace("A", "").strip())
except (TypeError, ValueError):
return None
return None
def _rating_unit_v(r: AbsMaxRating) -> bool:
return (r.unit or "").lower() in {"v", "volt", "volts"}
def _rating_unit_c(r: AbsMaxRating) -> bool:
u = (r.unit or "").lower().replace("°", "")
return u in {"c", "degc", "celsius"}
def _rating_unit_a(r: AbsMaxRating) -> bool:
return (r.unit or "").lower() in {"a", "amp", "amps"}
def check_bom_pcb_datasheet(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
) -> list[Finding]:
"""Three-way mismatches when BOM, PCB, and library all have numbers."""
cmap = constraints_map or {}
out: list[Finding] = []
for ref, comp in sorted(graph.components.items()):
if ref.startswith("#"):
continue
cons = _match_constraints(comp.mpn or comp.value, cmap)
fp_layout = layout.footprints.get(ref) if layout else None
pcb_name = (fp_layout.footprint if fp_layout else "") or ""
sch_name = _sch_fp(graph, ref, comp)
bom_name = _bom_fp(graph, ref)
pkg = _package_row(cons, comp.specs)
out.extend(_package_findings(
ref, comp, pkg, sch_name, bom_name, pcb_name,
))
out.extend(_pinout_findings(ref, comp, cons, fp_layout, pkg))
out.extend(_voltage_findings(graph, ref, comp, cons))
out.extend(_temp_findings(ref, comp, cons))
out.extend(_current_findings(graph, ref, comp, cons))
return out
def _mismatch(
*,
ref: str, mpn: str, rule_id: str, finding: str, facts: str,
requirement: str, rec: str, status: str = "ERROR",
inference: str = "", net: str | None = None,
) -> Finding:
return Finding(
designator=ref,
mpn=mpn,
aspect="bom_pcb",
finding=finding,
facts=facts,
requirement=requirement,
inference=inference or "Measured three-way mismatch — not a guessed land pattern.",
why=requirement,
status=status,
recommendation=rec,
action=rec,
source="bom_pcb_check",
rule_id=rule_id,
evidence_status="SUFFICIENT",
net=net,
pins=[ref],
)
def _package_findings(
ref: str,
comp: Component,
pkg: PackageInfo | None,
sch_name: str,
bom_name: str,
pcb_name: str,
) -> list[Finding]:
out: list[Finding] = []
mpn = comp.mpn or ""
names = {
"schematic": sch_name,
"BOM": bom_name,
"PCB": pcb_name,
}
present = {k: v for k, v in names.items() if (v or "").strip()}
fams = {k: _family(v) for k, v in present.items()}
known = {k: f for k, f in fams.items() if f}
if len(set(known.values())) > 1:
rec = (
f"Align {ref} footprint/package across BOM, schematic, and PCB "
f"to the datasheet package {pkg.package if pkg else '(unknown)'}."
)
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-010",
finding=(
f"{ref} package family disagrees: "
+ ", ".join(f"{k}={v}" for k, v in sorted(known.items()))
+ (f"; datasheet={pkg.package}" if pkg else ".")
),
facts=(
f"sch={sch_name!r} bom={bom_name!r} pcb={pcb_name!r} "
f"datasheet={getattr(pkg, 'package', None)!r}."
),
requirement="BOM, PCB footprint, and datasheet package family must match.",
rec=rec,
))
ds_fam = _family(pkg.package) if pkg else None
if ds_fam and known and ds_fam not in known.values() and any(known.values()):
# Datasheet family vs every CAD family.
if all(f != ds_fam for f in known.values()):
rec = f"Change {ref}'s footprint to the datasheet package {pkg.package}."
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-010",
finding=(
f"{ref} datasheet package {pkg.package} ({ds_fam}) does not "
f"match CAD families {sorted(set(known.values()))}."
),
facts=f"datasheet={pkg.package!r}; CAD={present!r}.",
requirement="PCB/BOM footprint family must match datasheet package_info.",
rec=rec,
))
return out
def _pinout_findings(
ref: str,
comp: Component,
cons: ComponentConstraints | None,
fp_layout,
pkg: PackageInfo | None,
) -> list[Finding]:
out: list[Finding] = []
mpn = comp.mpn or ""
sch_pins = _signal_pads(list(comp.pins.keys()))
ds_pins = _signal_pads(
[str(p.number) for p in (cons.pintable if cons else [])]
)
pcb_pins = _signal_pads(
[p.number for p in (fp_layout.pads if fp_layout else [])]
)
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:
rec = (
f"Replace {ref}'s PCB footprint so pad count matches datasheet "
f"pin_count={ds_count}."
)
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-011",
finding=(
f"{ref} datasheet pin_count={ds_count} vs PCB signal pads={pcb_count}."
),
facts=f"pin_count={ds_count}; PCB pads={sorted(pcb_pins)}; sch={sorted(sch_pins)}.",
requirement="Datasheet pin_count must match the PCB footprint (EP ±1 allowed).",
rec=rec,
))
if ds_pins and pcb_pins:
missing_pcb = sorted(ds_pins - pcb_pins)
extra_pcb = sorted(pcb_pins - ds_pins)
if missing_pcb or extra_pcb:
rec = (
f"Fix {ref} pinout: pads vs pintable. Missing on PCB: "
f"{missing_pcb or ''}; extra on PCB: {extra_pcb or ''}."
)
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-011",
finding=(
f"{ref} pintable vs PCB pads differ "
f"(missing {missing_pcb}, extra {extra_pcb})."
),
facts=f"pintable={sorted(ds_pins)}; PCB={sorted(pcb_pins)}; sch={sorted(sch_pins)}.",
requirement="Each datasheet pin number must exist as a PCB pad.",
rec=rec,
))
elif ds_pins and sch_pins and ds_pins != sch_pins:
missing = sorted(ds_pins - sch_pins)
extra = sorted(sch_pins - ds_pins)
if missing or extra:
rec = f"Align {ref} schematic pins with the datasheet pintable."
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-011",
finding=(
f"{ref} pintable vs schematic pins differ "
f"(missing {missing}, extra {extra})."
),
facts=f"pintable={sorted(ds_pins)}; sch={sorted(sch_pins)}.",
requirement="Schematic pin numbers must match the datasheet pintable.",
rec=rec,
status="ERROR",
))
return out
def _voltage_findings(
graph: DesignGraph,
ref: str,
comp: Component,
cons: ComponentConstraints | None,
) -> list[Finding]:
out: list[Finding] = []
mpn = comp.mpn or ""
vrated = _cap_vrated(comp)
if vrated is not None:
return out # PE-DRT-* owns capacitor Vop/Vrated.
if not cons:
return out
for r in cons.absolute_maximum_ratings:
if r.max is None or not _rating_unit_v(r) or _NOT_V.search(r.parameter or ""):
continue
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):
continue
net = graph.pin_net(ref, str(pin.number))
if not net:
continue
vop = _net_voltage(graph, net)
if vop is None:
continue
if vop > float(r.max):
rec = (
f"Lower '{net}' below {r.max:g} V or change {ref} "
f"({r.parameter} abs-max)."
)
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-012",
finding=(
f"{ref} '{net}' is {vop:g} V; datasheet {r.parameter} "
f"abs-max is {r.max:g} {r.unit}."
),
facts=f"Vop={vop:g} V; abs-max {r.parameter}={r.max:g} {r.unit} p.{r.source_page}.",
requirement="Operating voltage must stay within absolute maximum ratings.",
rec=rec,
net=net,
))
return out
def _temp_findings(
ref: str,
comp: Component,
cons: ComponentConstraints | None,
) -> list[Finding]:
"""Operating temp vs abs-max only when both numbers exist (no Ta=25 invented)."""
if not cons:
return []
values = _specs_values(comp)
ta = _first(values, ("ta_max_c", "operating_temp_max_c", "tamb_max_c", "ta_max"))
if ta is None:
return []
out: list[Finding] = []
mpn = comp.mpn or ""
for r in cons.absolute_maximum_ratings:
if r.max is None or not _rating_unit_c(r):
continue
if not _T_PARAM.search(r.parameter or ""):
continue
if ta > float(r.max):
rec = f"Keep {ref} ambient/operating temperature ≤ {r.max:g} °C."
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-013",
finding=(
f"{ref} operating temp {ta:g} °C exceeds {r.parameter} "
f"abs-max {r.max:g} {r.unit}."
),
facts=f"Ta_max={ta:g} °C from specs; abs-max {r.parameter}={r.max:g}.",
requirement="Specified operating temperature must not exceed abs-max T.",
rec=rec,
status="ERROR",
))
return out
def _current_findings(
graph: DesignGraph,
ref: str,
comp: Component,
cons: ComponentConstraints | None,
) -> list[Finding]:
out: list[Finding] = []
mpn = comp.mpn or ""
i_load = _first(_specs_values(comp), _LOAD_KEYS)
irated = _ind_irated(comp)
if irated is not None and i_load is not None and i_load > irated:
rec = f"Replace {ref} with an inductor rated above {i_load:g} A."
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-014",
finding=f"{ref} I_load={i_load:g} A exceeds current rating {irated:g} A.",
facts=f"I_load={i_load:g} A; Irated={irated:g} A.",
requirement="Load current must not exceed the inductor current rating.",
rec=rec,
))
if not cons or i_load is None:
return out
for r in cons.absolute_maximum_ratings:
if r.max is None or not _rating_unit_a(r):
continue
if not _I_PARAM.search(r.parameter or ""):
continue
if i_load > float(r.max):
rec = f"Cut the load on {ref} below {r.max:g} A ({r.parameter})."
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-014",
finding=(
f"{ref} I_load={i_load:g} A exceeds abs-max {r.parameter} "
f"{r.max:g} A."
),
facts=f"I_load={i_load:g} A; abs-max {r.parameter}={r.max:g} A p.{r.source_page}.",
requirement="I_load must stay within absolute maximum current.",
rec=rec,
))
return out
+133
View File
@@ -0,0 +1,133 @@
"""EMI beyond ESD — only when a datasheet FACT names a filter.
No IEC 61000 invention. Common-mode / ferrite / shield from layout_rules
notes or kind, checked against parts on the same HS connector net.
"""
from __future__ import annotations
import re
from backend.periscopex.models import (
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
LayoutGraph,
)
from backend.periscopex.pcb_net_match import (
normalize_kicad_hierarchy_net,
refs_on_matched_net,
)
from backend.periscopex.si_check import bus_class, skip_si_net
from backend.periscopex.validate import _match_constraints
_EMI_RE = re.compile(
r"(emi|emc|common[-\s]?mode|cmc|choke|ferrite\s*bead|shield(?:ing)?|"
r"pi\s*filter|lc\s*filter)",
re.I,
)
_EMI_KINDS = frozenset({"emi", "common_mode", "shield"})
def _is_filter_part(comp) -> bool:
sub = (comp.component_subtype or "").lower()
val = (comp.value or "").lower()
blob = f"{sub} {val} {comp.mpn or ''}"
if "esd" in sub or "protection" in sub:
return False
if "ferrite" in blob or "common" in blob or "choke" in blob or "cmc" in blob:
return True
if comp.component_type == ComponentType.INDUCTOR and _EMI_RE.search(blob):
return True
if (comp.reference or "").upper().startswith(("FB", "L")) and _EMI_RE.search(blob):
return True
return False
def _emi_rules(cons: ComponentConstraints | None) -> list[dict]:
if not cons:
return []
rows: list[dict] = []
for rule in cons.layout_rules or []:
if not isinstance(rule, dict):
continue
kind = str(rule.get("kind") or "")
note = str(rule.get("note") or "")
if kind in _EMI_KINDS or _EMI_RE.search(note) or _EMI_RE.search(kind):
rows.append(rule)
return rows
def check_emi(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None = None,
layout: LayoutGraph | None = None,
) -> list[Finding]:
"""REVIEW when a sourced EMI/filter FACT has no matching part on the net."""
_ = layout
cmap = constraints_map or {}
out: list[Finding] = []
seen: set[tuple[str, str]] = set()
for ref, comp in sorted(graph.components.items()):
if comp.component_type not in (ComponentType.IC, ComponentType.CONNECTOR):
continue
cons = _match_constraints(comp.mpn or comp.value, cmap)
rules = _emi_rules(cons)
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):
continue
if not bus_class(net) and comp.component_type != ComponentType.CONNECTOR:
continue
key = (ref, normalize_kicad_hierarchy_net(net))
if key in seen:
continue
on_net = refs_on_matched_net(graph, net)
if any(
r in graph.components and _is_filter_part(graph.components[r])
for r in on_net
):
continue
quote = next(
(str(r.get("note") or "") for r in rules if r.get("note")),
"",
)
page = next(
(r.get("source_page") for r in rules if r.get("source_page")),
None,
)
rec = (
f"Add the datasheet EMI/filter part on '{net}' "
f"(common-mode / ferrite / shield as quoted). Not IEC 61000."
)
seen.add(key)
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="emi",
finding=(
f"{net} has a datasheet EMI/filter FACT and no ferrite/"
f"common-mode/choke part on the net."
),
facts=(
f"net={net}; pin={pin}; filter_parts=none; "
f"quote={quote!r}; page={page}."
),
requirement=quote or "Datasheet layout_rules EMI/filter note.",
inference="Filter missing vs sourced note — REVIEW, not IEC.",
why="EMI only when the library quotes a filter; ESD is PE-ESD-001.",
status="WARNING",
recommendation=rec,
action=rec,
source="emi_check",
source_page=int(page) if isinstance(page, int) else None,
source_quote=quote,
rule_id="PE-EMI-001",
evidence_status="SUFFICIENT",
net=net,
pins=[str(pin)],
))
return out
+16
View File
@@ -177,6 +177,22 @@ def _seed() -> None:
requirement="Connector/USB net ESD — REVIEW unless a mandatory FACT exists.")
_add("PE-RET-001", "TYPICAL", "REVIEW", domain="pcb",
requirement="High-speed net over a GND pour should have a nearby GND via.")
_add("PE-THM-002", "TYPICAL", "RISK", domain="pcb",
requirement="Tj = Ta + P·θJA when copper area, vias, P, and θJA all exist.")
_add("PE-BOM-010", "MANDATORY", "RULE", domain="pcb",
requirement="BOM / PCB footprint family must match datasheet package.")
_add("PE-BOM-011", "MANDATORY", "RULE", domain="pcb",
requirement="Datasheet pin_count / pintable must match PCB pads.")
_add("PE-BOM-012", "MANDATORY", "RULE", domain="pcb",
requirement="Operating voltage must not exceed the sourced rating / abs-max.")
_add("PE-BOM-013", "MANDATORY", "RULE", domain="pcb",
requirement="Specified operating temperature must not exceed abs-max T.")
_add("PE-BOM-014", "MANDATORY", "RULE", domain="pcb",
requirement="I_load must not exceed the sourced current rating / abs-max.")
_add("PE-SPOF-001", "TYPICAL", "REVIEW", domain="pcb",
requirement="Single regulator or crystal feeding many ICs is REVIEW, not ERROR.")
_add("PE-EMI-001", "RECOMMENDED", "REVIEW", domain="pcb",
requirement="Datasheet EMI/filter FACT vs ferrite/common-mode on the net.")
_seed()
+33 -10
View File
@@ -6,11 +6,14 @@ from typing import Any
from packaging.version import Version
KNOWN_KINDS = frozenset({
"decoupling_proximity", "thermal_via", "keepout", "length_match",
"impedance", "max_length", "spacing", "ref_plane", "si_via",
"layer", "series_resistor", "return_path", "si",
SI_KINDS = frozenset({
"impedance", "length_match", "max_length", "spacing",
"ref_plane", "si_via", "layer", "series_resistor", "return_path", "si",
})
EMI_KINDS = frozenset({"emi", "common_mode", "shield"})
KNOWN_KINDS = frozenset({
"decoupling_proximity", "thermal_via", "keepout",
}) | SI_KINDS | EMI_KINDS
def _num(v: Any) -> float | None:
@@ -36,25 +39,45 @@ def has_any_layout_rule(raw: object) -> bool:
return False
def has_si_layout_rule(raw: object) -> bool:
if not isinstance(raw, list):
return False
for row in raw:
if isinstance(row, dict) and str(row.get("kind") or "").strip() in SI_KINDS:
return True
return False
def needs_layout_rules_refresh(
data: dict,
*,
min_scan_version: str,
) -> bool:
"""True when layout_rules are empty and the extract predates the scan version.
"""True when this extract should be re-run against the current pintable skill.
After a successful extract at ``min_scan_version`` or newer, an empty
``layout_rules`` list means the datasheet had no guidance — do not loop.
After a successful extract at ``min_scan_version`` or newer, empty
``layout_rules`` means the datasheet had no guidance — do not loop.
From skill 1.11.0, SI kinds are first-class. An older JSON that only
has decoupling/thermal rules is stale and must be re-extracted so
ImpedenceFinder checks are not starved.
"""
if has_any_layout_rule(data.get("layout_rules")):
return False
ver = str(data.get("model_version") or "0.0.0")
if not min_scan_version or min_scan_version == "0.0.0":
return False
try:
return Version(ver) < Version(min_scan_version)
stale = Version(ver) < Version(min_scan_version)
except Exception:
return True
if not stale:
return False
try:
need_si = Version(min_scan_version) >= Version("1.11.0")
except Exception:
need_si = False
if need_si:
return not has_si_layout_rule(data.get("layout_rules"))
return not has_any_layout_rule(data.get("layout_rules"))
def validate_layout_rules(raw: list | None) -> tuple[list[dict], list[str]]:
+8
View File
@@ -6,6 +6,8 @@ import logging
from collections import Counter
from backend.periscopex.derating import build_derating_table
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
from backend.periscopex.emi_check import check_emi
from backend.periscopex.esd_return_check import check_esd, check_return_path
from backend.periscopex.finding_engine import complete_findings
from backend.periscopex.functional_groups import FunctionalGroupsReport
@@ -14,6 +16,7 @@ from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
from backend.periscopex.pcb_net_match import check_pcb_net_match
from backend.periscopex.pcb_power_thermal import (
check_pcb_gnd_stitch,
check_pcb_junction_temp,
check_pcb_kelvin,
check_pcb_power_traces,
check_pcb_thermal_copper,
@@ -22,6 +25,7 @@ from backend.periscopex.pcb_power_thermal import (
from backend.periscopex.pi_check import check_power_integrity
from backend.periscopex.placement_check import check_placement
from backend.periscopex.si_check import check_si
from backend.periscopex.spof_check import check_spof
from backend.periscopex.timing_check import check_timing
log = logging.getLogger(__name__)
@@ -193,6 +197,10 @@ def run_pcb_checks(
("pi_check", lambda: check_power_integrity(graph, constraints_map, layout)),
("esd_check", lambda: check_esd(graph, constraints_map)),
("return_path", lambda: check_return_path(graph, layout)),
("bom_pcb", lambda: check_bom_pcb_datasheet(graph, constraints_map, layout)),
("spof_check", lambda: check_spof(graph, constraints_map)),
("emi_check", lambda: check_emi(graph, constraints_map, layout)),
("pcb_junction_temp", lambda: check_pcb_junction_temp(graph, constraints_map, layout)),
):
try:
out.extend(fn())
+155
View File
@@ -372,6 +372,161 @@ def check_pcb_thermal_copper(
return out
def _shoelace_mm2(pts: list[tuple[float, float]]) -> float:
if len(pts) < 3:
return 0.0
s = 0.0
n = len(pts)
for i in range(n):
x1, y1 = pts[i]
x2, y2 = pts[(i + 1) % n]
s += x1 * y2 - x2 * y1
return abs(s) / 2.0
def check_pcb_junction_temp(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
) -> list[Finding]:
"""Tj = Ta + P·θJA when P, θJA, copper area, and via count all exist.
Copper area and via count are FACT only — θJA is not scaled with an
invented spreading/FEM model. Missing any parameter → INSUFFICIENT.
"""
if layout is None:
return []
out: list[Finding] = []
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)
values = _specs_values(comp)
i_load = _first(values, _LOAD_KEYS)
if i_load is None:
continue
vin_n = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
vout_n = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
vin = _net_voltage(graph, vin_n) if vin_n else None
vout = _net_voltage(graph, vout_n) if vout_n else None
if vin is None or vout is None or vin <= vout:
continue
p = i_load * (vin - vout)
theta = _first(values, _THETA_KEYS)
tjmax = _first(values, _TJMAX_KEYS)
fp = layout.footprints.get(ref)
region = _footprint_region(fp) if fp else []
via_n = 0
copper_mm2 = 0.0
if len(region) >= 3:
for v in layout.vias:
if _in_poly(v.x, v.y, region):
via_n += 1
cy = _shoelace_mm2(region)
for z in layout.zones:
if not z.net:
continue
leaf = normalize_kicad_hierarchy_net(z.net).upper()
ok_net = False
if vout_n and kicad_nets_match(z.net, vout_n):
ok_net = True
elif vin_n and kicad_nets_match(z.net, vin_n):
ok_net = True
elif leaf in {"GND", "AGND", "DGND", "PGND", "VSS"} or "GND" in leaf:
ok_net = True
if not ok_net:
continue
for outline in z.outlines:
if len(outline) >= 3 and _in_poly(fp.x, fp.y, outline):
copper_mm2 += min(cy, _shoelace_mm2(outline)) if cy else _shoelace_mm2(outline)
break
missing: list[str] = []
if theta is None:
missing.append("θJA")
if len(region) < 3:
missing.append("courtyard")
rec_ins = (
"Add datasheet θJA, a KiCad courtyard, and copper/via geometry; "
"Tj is not estimated from invented 1 oz / 10 °C defaults."
)
if missing:
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="layout_thermal",
finding=(
f"{ref} P≈{p:.3g} W but Tj is not estimated "
f"(missing {', '.join(missing)})."
),
facts=(
f"P≈{p:.3g} W; θJA={theta}; copper_mm2={copper_mm2:.3g}; "
f"vias_in_courtyard={via_n}; missing={missing}."
),
requirement="Tj = Ta + P·θJA only with measured copper area, via count, and θJA.",
inference="Insufficient evidence — not a FEM solve.",
why="No invented spreading model or default copper weight.",
status="INFO",
recommendation=rec_ins,
action=rec_ins,
source="pcb_power_thermal",
rule_id="PE-THM-002",
evidence_status="INSUFFICIENT",
net=vout_n,
pins=[],
))
continue
tj = _TA_C + p * theta
calc = (
f"Tj = {_TA_C:g} + {p:.4g}×{theta:g} = {tj:.1f} °C "
f"(copper_mm2={copper_mm2:.3g}, vias={via_n}; θJA not derated)."
)
over = tjmax is not None and tj > tjmax
rec = (
f"Lower P or θJA so Tj stays below {tjmax:g} °C."
if over else
"Tj estimate is within the known θJA model; FEM not used."
)
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="layout_thermal",
finding=(
f"{ref} Tj≈{tj:.0f} °C (Ta={_TA_C:.0f} °C, P≈{p:.3g} W, "
f"θJA={theta:g} °C/W, copper≈{copper_mm2:.3g} mm², vias={via_n})"
+ (f"; Tjmax={tjmax:g} °C." if tjmax is not None else ".")
),
facts=(
f"P={p:.4g} W; θJA={theta:g}; copper_mm2={copper_mm2:.4g}; "
f"vias={via_n}; Ta={_TA_C:g}; Tj={tj:.2f}"
+ (f"; Tjmax={tjmax:g}" if tjmax is not None else "")
+ "."
),
requirement=(
"Datasheet θJA applies as published; copper/vias reported as FACT."
),
inference=(
"Tj exceeds Tjmax under this θJA model."
if over else
"Closed-form θJA estimate — not a thermal FEM."
),
why="All of P, θJA, courtyard copper area, and via count were measured.",
status="WARNING" if over else "INFO",
recommendation=rec,
action=rec,
source="pcb_power_thermal",
rule_id="PE-THM-002",
evidence_status="SUFFICIENT",
calculation=calc,
assumptions=[
f"Ta={_TA_C:g} °C (explicit default, not a 10 °C rise).",
"θJA is used as published; no via/copper spreading formula.",
],
net=vout_n,
pins=[],
))
return out
def _is_gnd_name(name: str) -> bool:
leaf = normalize_kicad_hierarchy_net(name).upper()
return leaf in {"GND", "AGND", "DGND", "PGND", "VSS"} or leaf.endswith("/GND")
+3 -2
View File
@@ -642,8 +642,9 @@ def check_si(
lb = _z_field(zb, "length_mm") or net_length_mm(layout, partner)
skew = abs(la - lb) if partner else 0.0
rec = (
"Extract Z0/skew/spacing/vias/layer from the PHY/module datasheet "
"into layout_rules, then re-run. Do not assume 90 Ω."
"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",
+134
View File
@@ -0,0 +1,134 @@
"""Single-point-of-failure on power rails — REVIEW, never RULE ERROR.
FACT: how many regulators drive a rail and how many ICs sit on it.
REQUIREMENT: redundancy is not a datasheet shall unless stated.
INFERENCE: this rail is a SPOF — review, do not invent IEC SIL.
"""
from __future__ import annotations
from collections import defaultdict
from backend.periscopex.models import (
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
NetType,
)
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
from backend.periscopex.power_margin_check import _regulators
_SKIP_RAIL = frozenset({"GND", "AGND", "DGND", "PGND", "VSS"})
def check_spof(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None = None,
) -> list[Finding]:
cmap = constraints_map or {}
producers: dict[str, list[str]] = defaultdict(list)
for ref, _comp, _cons, _vin, vout in _regulators(graph, cmap):
key = normalize_kicad_hierarchy_net(vout)
if key.upper() in _SKIP_RAIL:
continue
producers[key].append(ref)
out: list[Finding] = []
seen: set[str] = set()
for net_name, net in sorted(graph.nets.items()):
key = normalize_kicad_hierarchy_net(net_name)
if key in seen or key.upper() in _SKIP_RAIL:
continue
if net.net_type != NetType.POWER and not producers.get(key):
continue
regs = producers.get(key) or []
if len(regs) != 1:
continue
ics = [
r for r in graph.components_on_net(net_name)
if r in graph.components
and graph.components[r].component_type == ComponentType.IC
and r not in regs
]
if len(ics) < 1:
continue
seen.add(key)
reg = regs[0]
rcomp = graph.components[reg]
rec = (
f"Review whether '{net_name}' needs a second source; {reg} is the "
f"only regulator feeding {len(ics)} IC(s). This is not a design-rule ERROR."
)
out.append(Finding(
designator=reg,
mpn=rcomp.mpn or "",
aspect="spof",
finding=(
f"Rail '{net_name}' has a single regulator ({reg}) and "
f"{len(ics)} downstream IC(s) ({', '.join(ics[:8])}"
f"{'' if len(ics) > 8 else ''})."
),
facts=(
f"producers={regs}; downstream_ics={ics}; "
f"net_type={net.net_type.value}."
),
requirement=(
"Redundancy / dual-supply is REVIEW unless a datasheet shall "
"names a second source (none assumed)."
),
inference="Single point of failure on this rail — engineering review.",
why="SPOF is not a measured RULE; no IEC SIL invented.",
status="INFO",
recommendation=rec,
action=rec,
source="spof_check",
rule_id="PE-SPOF-001",
evidence_status="SUFFICIENT",
net=net_name,
pins=[],
))
# Crystal / clock: one XTAL feeding one MCU is normal — only flag when
# several ICs share one crystal net with no second oscillator FACT.
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.CRYSTAL:
continue
nets = [n for n in set(comp.pins.values()) if n]
ics: list[str] = []
for n in nets:
for r in graph.components_on_net(n):
c = graph.components.get(r)
if c and c.component_type == ComponentType.IC and r not in ics:
ics.append(r)
xtals = [
r for r, c in graph.components.items()
if c.component_type == ComponentType.CRYSTAL
]
if len(ics) < 2 or len(xtals) > 1:
continue
rec = (
f"Review clock redundancy: {ref} is the only crystal and feeds "
f"{len(ics)} ICs. Not a RULE."
)
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="spof",
finding=(
f"{ref} is the only crystal and clocks {len(ics)} ICs "
f"({', '.join(ics)})."
),
facts=f"xtals={xtals}; ics_on_crystal_nets={ics}.",
requirement="A second oscillator is not assumed from IEC or folklore.",
inference="Clock SPOF — REVIEW.",
why="Counted crystals and IC pins on those nets.",
status="INFO",
recommendation=rec,
action=rec,
source="spof_check",
rule_id="PE-SPOF-001",
evidence_status="SUFFICIENT",
pins=[],
))
return out
+4 -1
View File
@@ -139,7 +139,7 @@ PINTABLE_TOOL = {
"PCB layout constraints from typical-application / PCB layout pages. "
"kind: decoupling_proximity | thermal_via | keepout | length_match | "
"impedance | max_length | spacing | ref_plane | si_via | layer | "
"series_resistor | return_path | si. "
"series_resistor | return_path | si | emi | common_mode | shield. "
"Fields: pin, cap_value_hint, max_distance_mm (ONLY if the PDF states a "
"number — never invent 3 mm/JEDEC), same_layer (bool), min_via_count, "
"max_via_count, z0_ohm, zdiff_ohm, tolerance_pct, z_min_ohm, z_max_ohm, "
@@ -165,6 +165,9 @@ PINTABLE_TOOL = {
"series_resistor",
"return_path",
"si",
"emi",
"common_mode",
"shield",
],
},
"pin": {"type": ["string", "null"]},
+28 -2
View File
@@ -12,6 +12,7 @@ import logging
from datetime import datetime, timezone
from pathlib import Path
from backend.config import settings as app_settings
from backend.periscopex.cad_bridge import (
annotate_findings_cad,
build_cad_bridge,
@@ -21,6 +22,7 @@ from backend.periscopex.cad_bridge import (
from backend.periscopex.finding_engine import apply_decisions
from backend.periscopex.functional_groups import build_placement_plan
from backend.periscopex.graph import build_graph
from backend.periscopex.layout_rules import needs_layout_rules_refresh
from backend.periscopex.models import ComponentConstraints, DesignGraph, LayoutGraph, ValidationReport
from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks
from backend.periscopex.pcb_inventory import build_pcb_inventory
@@ -220,9 +222,33 @@ async def run_pcb_pipeline(
_step(project_id, "checks", "running")
findings = run_pcb_checks(graph, cmap, layout, plan, zrep)
si_skip: list[dict] = []
scan_ver = app_settings.get_default_model_version()
for mpn, cons in sorted(cmap.items()):
payload = {
"model_version": cons.model_version,
"layout_rules": cons.layout_rules,
}
if needs_layout_rules_refresh(
payload, min_scan_version=scan_ver,
):
si_skip.append({
"designator": mpn,
"reason": (
f"SI layout_rules empty at model_version="
f"{cons.model_version}; re-run schematic review "
f"to re-extract pintable {scan_ver} "
"(PCB does not re-read the PDF)."
),
})
if si_skip:
sipath = ws.local_path("si_extract_needed.json")
sipath.write_text(json.dumps(si_skip, indent=2) + "\n")
ws._upload_file("si_extract_needed.json")
_step(
project_id, "checks", "complete",
f"{len(findings)} deterministic findings",
f"{len(findings)} deterministic findings"
+ (f"; {len(si_skip)} SI re-extract" if si_skip else ""),
)
if _cancelled(storage, user_id, project_id):
@@ -283,7 +309,7 @@ async def run_pcb_pipeline(
findings=findings,
summary=summary,
coverage=coverage,
not_reviewed=skipped,
not_reviewed=skipped + si_skip,
)
report_path = ws.local_path("pcb_report.json")
report_path.write_text(report.model_dump_json(indent=2) + "\n")
+1 -1
View File
@@ -1,5 +1,5 @@
{
"default_model_version": "1.11.0",
"default_model_version": "1.12.0",
"extract-pintable": {
"skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY",
"latest_version": "1784798970179642",