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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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"]},
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"default_model_version": "1.11.0",
|
||||
"default_model_version": "1.12.0",
|
||||
"extract-pintable": {
|
||||
"skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY",
|
||||
"latest_version": "1784798970179642",
|
||||
|
||||
@@ -113,4 +113,6 @@ Geometria board: width/thickness, via, pour courtyard, placement mm, return/stit
|
||||
|
||||
**Fase B (questa slice):** gerarchia component→pin→net→block; derating PASS/MARGIN/RISK da Vop/Vrated; timing RC/strap solo con numeri; PI local vs bulk; ESD e return path come REVIEW.
|
||||
|
||||
**Fase B ancora aperta:** thermal FEM; SI oltre skew mm; BOM↔PCB↔datasheet; SPOF; EMI oltre ESD.
|
||||
**Fase B close-out:** BOM↔PCB↔datasheet (package/pinout/V/T/I); SPOF REVIEW; EMI solo con FACT in `layout_rules`; Tj da θJA+rame+via quando tutti i parametri ci sono.
|
||||
|
||||
**Fase B leftovers:** thermal FEM / OpenEMS; ImpedenceFinder LICENSE UNKNOWN; auto-place pcbnew; PinScope identity.
|
||||
|
||||
@@ -68,6 +68,10 @@ Mutex vs analisi e vs placement. SSE `pcb_*`. IDs `PCB-{ref}-{001}`. KiCad `/net
|
||||
| PE-ESD-001 / PE-RET-001 | REVIEW unless mandatory FACT |
|
||||
| PE-VIA-001 | via count + I_load (INFO, no tabella inventata) |
|
||||
| PE-THM-001 | P=I_load×drop, courtyard senza pour/via |
|
||||
| PE-THM-002 | Tj=Ta+P·θJA solo con P, θJA, area rame, conteggio via |
|
||||
| PE-BOM-010…014 | package / pinout / V / T / I quando BOM, PCB e datasheet hanno i numeri |
|
||||
| PE-SPOF-001 | un regolatore (o un XTAL condiviso) — REVIEW |
|
||||
| PE-EMI-001 | filtro EMI solo se `layout_rules` cita choke/ferrite/shield |
|
||||
| PE-KEL-001 | sense/Kelvin pintable + ≥2 altri sul net |
|
||||
| PE-STCH-001 | pour GND + segnale senza via GND nel bbox |
|
||||
| Creepage | **skip** senza numero datasheet/IEC |
|
||||
@@ -97,18 +101,17 @@ AI PCB: skip IC senza pintable (`run schematic review first`).
|
||||
|
||||
Emmaforo USB verdict: if library has e.g. 90 Ω ±10% (81–99), Zavg ~88 Ω **PASS** on average and **MARGIN/FAIL** if min (~46 Ω) is outside the window; ~2.5 mm intra-pair skew **FAIL** only when `length_match` mm exists and 2.5 > limit. Without library Z, **PE-SI-010** INFO (insufficient) — not a 90 Ω ERROR.
|
||||
|
||||
**B — still missing (close-out):**
|
||||
**B — still missing (true leftovers):**
|
||||
|
||||
| Gap | Why it is not this slice |
|
||||
| Gap | Why it stays out |
|
||||
| --- | --- |
|
||||
| Thermal FEM / energy | PE-THM-001 is courtyard vias/pour vs P=I×drop, not a field solve |
|
||||
| SPOF | No single-point-of-failure / redundancy graph check |
|
||||
| BOM ↔ PCB ↔ datasheet chain | Pad-net + MPN library; no full three-way BOM audit |
|
||||
| ImpedenceFinder license | Vendored closed-form core (`vendor/impedancefinder`, SOURCE.md); **no LICENSE file in-tree** — confirm upstream license before commercial redistribution |
|
||||
| CPWG / field solver | OpenEMS export explicitly excluded from the vendor snapshot |
|
||||
| EMI beyond ESD REVIEW | PE-ESD-001 is REVIEW, not IEC 61000 |
|
||||
| SI extraction coverage | New `layout_rules` kinds need a re-extract (`model_version` 1.11.0); old library JSON has no Z/skew until pintable is refreshed |
|
||||
| Thermal FEM / energy | PE-THM-002 is Tj=Ta+P·θJA with measured copper/vias; no spreading/FEM |
|
||||
| CPWG / OpenEMS | Field-solver export excluded from the ImpedenceFinder vendor snapshot |
|
||||
| ImpedenceFinder license | Upstream has **no LICENSE** (UNKNOWN). Not invented in-tree. See `vendor/impedancefinder/SOURCE.md` |
|
||||
| Auto-place / pcbnew write-back | Fase C |
|
||||
| PinScope identity / TOS | Other worker |
|
||||
|
||||
Shipped this close-out: BOM↔PCB↔datasheet (`PE-BOM-010`…`014`); SPOF REVIEW; EMI with datasheet FACT; gated θJA Tj; SI re-extract when library `layout_rules` lack SI kinds (`model_version` 1.12.0, `si_extract_needed.json`).
|
||||
|
||||
**C — fuori:** auto-place, write-back pcbnew, unire i job, sshd/keys, PinScope identity/fork.
|
||||
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
What's new in Periscope.
|
||||
|
||||
## 2.35.0 — 2026-09-20 — BOM↔PCB↔datasheet, SPOF, EMI FACT, gated Tj
|
||||
|
||||
Remaining Fase B slices except auto-place and thermal FEM / OpenEMS.
|
||||
|
||||
- [New] `PE-BOM-010`…`014` package / pinout / voltage / temp / current when BOM, PCB, and datasheet all have numbers.
|
||||
- [New] `PE-SPOF-001` single regulator or shared crystal — REVIEW, never ERROR.
|
||||
- [New] `PE-EMI-001` only if `layout_rules` quotes EMI/common-mode/ferrite/shield (not IEC 61000).
|
||||
- [New] `PE-THM-002` Tj = Ta + P·θJA when P, θJA, copper area, and via count exist; else INSUFFICIENT.
|
||||
- [Changed] Pintable skill `1.12.0`: SI+EMI kinds; empty SI on older extracts triggers schematic re-extract (`si_extract_needed.json` on PCB). ImpedenceFinder license remains UNKNOWN (no upstream LICENSE).
|
||||
|
||||
## 2.34.0 — 2026-09-20 — ImpedenceFinder SI checks (not a dump table)
|
||||
|
||||
USB/HDMI/PCIe/ETH/LVDS/DDR layout_rules (diff/SE Z, skew, max length, spacing, ref plane, vias, layer, return, series R) are compared to ImpedenceFinder + copper. One PASS/FAIL/MARGIN finding per requirement. I2C/GPIO/CC/REGN are not 50 Ω.
|
||||
@@ -10,7 +20,7 @@ USB/HDMI/PCIe/ETH/LVDS/DDR layout_rules (diff/SE Z, skew, max length, spacing, r
|
||||
- [Changed] `PE-SI-001` only on impedance-controlled buses matching the rule `net_class`.
|
||||
- [Changed] extract-pintable `layout_rules` kinds for SI; `default_model_version` 1.11.0.
|
||||
|
||||
|
||||
## 2.33.2 — 2026-09-20 — PCB complete UI, not a raw SSE error
|
||||
|
||||
A finished PCB exam no longer shows the raw watcher string `pcb_status=complete (terminal)`. The SSE hatch emits `pcb_complete`; the PCB page then opens `/report?domain=layout` (expand-on-click tree).
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ export function isLayoutFinding(f: {
|
||||
f.source === "timing_check" ||
|
||||
f.source === "pi_check" ||
|
||||
f.source === "esd_return_check" ||
|
||||
f.source === "bom_pcb_check" ||
|
||||
f.source === "spof_check" ||
|
||||
f.source === "emi_check" ||
|
||||
rid.startsWith("PE-PLC") ||
|
||||
rid.startsWith("PE-LAY") ||
|
||||
rid.startsWith("PE-SI") ||
|
||||
@@ -28,7 +31,10 @@ export function isLayoutFinding(f: {
|
||||
rid.startsWith("PE-TIM") ||
|
||||
rid.startsWith("PE-PI") ||
|
||||
rid.startsWith("PE-ESD") ||
|
||||
rid.startsWith("PE-RET")
|
||||
rid.startsWith("PE-RET") ||
|
||||
rid.startsWith("PE-SPOF") ||
|
||||
rid.startsWith("PE-EMI") ||
|
||||
/^PE-BOM-01[0-4]$/.test(rid)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +50,9 @@ export function isPcbExamFinding(f: {
|
||||
f.source === "timing_check" ||
|
||||
f.source === "pi_check" ||
|
||||
f.source === "esd_return_check" ||
|
||||
f.source === "bom_pcb_check" ||
|
||||
f.source === "spof_check" ||
|
||||
f.source === "emi_check" ||
|
||||
rid.startsWith("PE-PWR") ||
|
||||
rid.startsWith("PE-THM") ||
|
||||
rid.startsWith("PE-VIA") ||
|
||||
@@ -53,6 +62,9 @@ export function isPcbExamFinding(f: {
|
||||
rid.startsWith("PE-TIM") ||
|
||||
rid.startsWith("PE-PI") ||
|
||||
rid.startsWith("PE-ESD") ||
|
||||
rid.startsWith("PE-RET")
|
||||
rid.startsWith("PE-RET") ||
|
||||
rid.startsWith("PE-SPOF") ||
|
||||
rid.startsWith("PE-EMI") ||
|
||||
/^PE-BOM-01[0-4]$/.test(rid)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ You **must** look for layout guidance. Emit `layout_rules` as a list. Use `[]` o
|
||||
| `layer` | Required copper layer / topology |
|
||||
| `series_resistor` | Series R on the HS net (`value_ohms`) |
|
||||
| `return_path` | GND return via next to the pair |
|
||||
| `emi` / `common_mode` / `shield` | Common-mode choke, ferrite bead, shield, or EMI filter **quoted from this datasheet** (no IEC 61000 invention) |
|
||||
|
||||
Do **not** emit impedance/50 Ω rules for I2C, GPIO, EN, analog REGN, or USB CC. Do **not** invent USB 90 Ω unless **this** datasheet states a number.
|
||||
|
||||
|
||||
@@ -75,7 +75,10 @@
|
||||
"layer",
|
||||
"series_resistor",
|
||||
"return_path",
|
||||
"si"
|
||||
"si",
|
||||
"emi",
|
||||
"common_mode",
|
||||
"shield"
|
||||
]
|
||||
},
|
||||
"pin": {"type": ["string", "null"]},
|
||||
|
||||
@@ -89,6 +89,7 @@ def validate(data: dict) -> list[str]:
|
||||
"decoupling_proximity", "thermal_via", "keepout", "length_match",
|
||||
"impedance", "max_length", "spacing", "ref_plane", "si_via",
|
||||
"layer", "series_resistor", "return_path", "si",
|
||||
"emi", "common_mode", "shield",
|
||||
}
|
||||
for i, row in enumerate(data["layout_rules"]):
|
||||
if not isinstance(row, dict):
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Fase B close-out: BOM↔PCB↔datasheet, SPOF, EMI FACT, gated Tj, SI refresh."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.periscopex.layout_rules import needs_layout_rules_refresh
|
||||
from backend.periscopex.models import (
|
||||
AbsMaxRating,
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
InductorSpecs,
|
||||
LayoutFootprint,
|
||||
LayoutGraph,
|
||||
LayoutPad,
|
||||
LayoutVia,
|
||||
LayoutZone,
|
||||
Net,
|
||||
NetType,
|
||||
PackageInfo,
|
||||
Pin,
|
||||
PinConnection,
|
||||
SimpleComponentSpecs,
|
||||
)
|
||||
|
||||
|
||||
def test_si_refresh_when_old_extract_has_only_decoupling():
|
||||
assert needs_layout_rules_refresh(
|
||||
{
|
||||
"model_version": "1.10.0",
|
||||
"layout_rules": [{"kind": "decoupling_proximity", "max_distance_mm": 2}],
|
||||
},
|
||||
min_scan_version="1.12.0",
|
||||
)
|
||||
assert not needs_layout_rules_refresh(
|
||||
{
|
||||
"model_version": "1.12.0",
|
||||
"layout_rules": [],
|
||||
},
|
||||
min_scan_version="1.12.0",
|
||||
)
|
||||
assert not needs_layout_rules_refresh(
|
||||
{
|
||||
"model_version": "1.10.0",
|
||||
"layout_rules": [{"kind": "impedance", "zdiff_ohm": 90}],
|
||||
},
|
||||
min_scan_version="1.12.0",
|
||||
)
|
||||
assert not needs_layout_rules_refresh(
|
||||
{
|
||||
"model_version": "1.9.0",
|
||||
"layout_rules": [{"kind": "decoupling_proximity", "max_distance_mm": None}],
|
||||
},
|
||||
min_scan_version="1.10.0",
|
||||
)
|
||||
|
||||
|
||||
def test_package_family_mismatch_is_pe_bom_010():
|
||||
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
|
||||
|
||||
cons = ComponentConstraints(
|
||||
mpn="IC1",
|
||||
package_info=PackageInfo(
|
||||
base_family="MSP", package="LQFP-48", pin_count=48,
|
||||
),
|
||||
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 49)],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="IC", footprint="Package_DFN_QFN:QFN-16-1EP",
|
||||
component_type=ComponentType.IC, mpn="IC1",
|
||||
pins={str(i): "GND" for i in range(1, 17)},
|
||||
),
|
||||
},
|
||||
nets={"GND": Net(name="GND", net_type=NetType.GROUND, pins=[])},
|
||||
schematic_fields={"U1": {"footprint": "Package_DFN_QFN:QFN-16-1EP"}},
|
||||
bom_fields={"U1": {"footprint": "QFN-16"}},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={
|
||||
"U1": LayoutFootprint(
|
||||
reference="U1", footprint="Package_DFN_QFN:QFN-16-1EP_3x3mm",
|
||||
x=0, y=0, layer="F.Cu",
|
||||
pads=[LayoutPad(number=str(i), x=0, y=0, net="GND") for i in range(1, 17)],
|
||||
),
|
||||
},
|
||||
)
|
||||
findings = check_bom_pcb_datasheet(graph, {"IC1": cons}, layout)
|
||||
ids = {f.rule_id for f in findings}
|
||||
assert "PE-BOM-010" in ids
|
||||
assert "PE-BOM-011" in ids
|
||||
assert all(f.status == "ERROR" for f in findings if f.rule_id in {"PE-BOM-010", "PE-BOM-011"})
|
||||
|
||||
|
||||
def test_abs_max_voltage_and_temp_and_current():
|
||||
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
|
||||
|
||||
cons = ComponentConstraints(
|
||||
mpn="LDO1",
|
||||
package_info=PackageInfo(base_family="SPX", package="SOT-23-5", pin_count=5),
|
||||
pintable=[
|
||||
Pin(number="1", name="VIN"),
|
||||
Pin(number="2", name="GND"),
|
||||
Pin(number="5", name="VOUT"),
|
||||
],
|
||||
absolute_maximum_ratings=[
|
||||
AbsMaxRating(parameter="VIN", min=None, max=4.0, unit="V", source_page=3),
|
||||
AbsMaxRating(parameter="TJ", min=None, max=125, unit="C", source_page=3),
|
||||
AbsMaxRating(parameter="IOUT", min=None, max=0.1, unit="A", source_page=4),
|
||||
],
|
||||
rules=[],
|
||||
)
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="LDO", footprint="Package_TO_SOT_SMD:SOT-23-5",
|
||||
component_type=ComponentType.IC, mpn="LDO1",
|
||||
pins={"1": "VIN", "2": "GND", "5": "VOUT"},
|
||||
specs=SimpleComponentSpecs(
|
||||
specs_type="discrete",
|
||||
values={"i_load_a": 0.5, "ta_max_c": 150},
|
||||
),
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"VIN": Net(
|
||||
name="VIN", net_type=NetType.POWER, voltage=5.0,
|
||||
pins=[PinConnection(component_ref="U1", pin_number="1")],
|
||||
),
|
||||
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
|
||||
"VOUT": Net(name="VOUT", net_type=NetType.POWER, voltage=3.3, pins=[]),
|
||||
},
|
||||
)
|
||||
findings = check_bom_pcb_datasheet(graph, {"LDO1": cons}, None)
|
||||
ids = {f.rule_id for f in findings}
|
||||
assert "PE-BOM-012" in ids
|
||||
assert "PE-BOM-013" in ids
|
||||
assert "PE-BOM-014" in ids
|
||||
|
||||
|
||||
def test_inductor_current_rating():
|
||||
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
|
||||
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"L1": Component(
|
||||
reference="L1", value="2.2uH", footprint="",
|
||||
component_type=ComponentType.INDUCTOR, mpn="IND1",
|
||||
pins={"1": "SW", "2": "VOUT"},
|
||||
specs=InductorSpecs(
|
||||
value_henries=2.2e-6,
|
||||
value_formatted="2.2uH",
|
||||
current_rating_a="0.2",
|
||||
),
|
||||
),
|
||||
},
|
||||
nets={},
|
||||
)
|
||||
graph.components["L1"].specs # type: ignore
|
||||
# I_load on the inductor itself
|
||||
graph.components["L1"] = graph.components["L1"].model_copy(
|
||||
update={"specs": InductorSpecs(
|
||||
value_henries=2.2e-6, value_formatted="2.2uH", current_rating_a="0.2",
|
||||
)}
|
||||
)
|
||||
# specs_values reads SimpleComponentSpecs.values — inductor uses current_rating_a
|
||||
findings = check_bom_pcb_datasheet(graph, {}, None)
|
||||
# Without i_load on inductor specs, skip.
|
||||
assert all(f.rule_id != "PE-BOM-014" for f in findings)
|
||||
|
||||
|
||||
def test_spof_review_single_ldo():
|
||||
from backend.periscopex.finding_engine import complete_finding
|
||||
from backend.periscopex.spof_check import check_spof
|
||||
|
||||
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",
|
||||
component_subtype="ic.power.ldo",
|
||||
pins={"1": "VIN", "2": "+3V3"},
|
||||
),
|
||||
"U2": Component(
|
||||
reference="U2", value="MCU", footprint="",
|
||||
component_type=ComponentType.IC, mpn="MCU",
|
||||
pins={"1": "+3V3"},
|
||||
),
|
||||
"U3": Component(
|
||||
reference="U3", value="PHY", footprint="",
|
||||
component_type=ComponentType.IC, mpn="PHY",
|
||||
pins={"1": "+3V3"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"VIN": Net(name="VIN", net_type=NetType.POWER, pins=[]),
|
||||
"+3V3": Net(
|
||||
name="+3V3", net_type=NetType.POWER,
|
||||
pins=[
|
||||
PinConnection(component_ref="U1", pin_number="2"),
|
||||
PinConnection(component_ref="U2", pin_number="1"),
|
||||
PinConnection(component_ref="U3", pin_number="1"),
|
||||
],
|
||||
),
|
||||
},
|
||||
)
|
||||
findings = check_spof(graph, {"LDO1": cons})
|
||||
assert findings
|
||||
assert findings[0].rule_id == "PE-SPOF-001"
|
||||
complete_finding(findings[0])
|
||||
assert findings[0].finding_class == "REVIEW"
|
||||
assert findings[0].status != "ERROR"
|
||||
|
||||
|
||||
def test_emi_only_with_datasheet_fact():
|
||||
from backend.periscopex.emi_check import check_emi
|
||||
|
||||
cons = ComponentConstraints(
|
||||
mpn="PHY",
|
||||
pintable=[Pin(number="1", name="USB_DP")],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
layout_rules=[{
|
||||
"kind": "keepout",
|
||||
"note": "Place a common-mode choke on the USB pair",
|
||||
"source_page": 22,
|
||||
}],
|
||||
)
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U2": Component(
|
||||
reference="U2", value="PHY", footprint="",
|
||||
component_type=ComponentType.IC, mpn="PHY",
|
||||
pins={"1": "USB_DP"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"USB_DP": Net(
|
||||
name="USB_DP", net_type=NetType.SIGNAL,
|
||||
pins=[PinConnection(component_ref="U2", pin_number="1")],
|
||||
),
|
||||
},
|
||||
)
|
||||
findings = check_emi(graph, {"PHY": cons}, None)
|
||||
assert findings and findings[0].rule_id == "PE-EMI-001"
|
||||
cons2 = cons.model_copy(update={"layout_rules": []})
|
||||
assert check_emi(graph, {"PHY": cons2}, None) == []
|
||||
|
||||
|
||||
def test_tj_when_theta_copper_vias_exist():
|
||||
from backend.periscopex.pcb_power_thermal import check_pcb_junction_temp
|
||||
from backend.periscopex.models import LayoutSegment, 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",
|
||||
component_subtype="ic.power.ldo",
|
||||
pins={"1": "VIN", "2": "VOUT"},
|
||||
specs=SimpleComponentSpecs(
|
||||
specs_type="discrete",
|
||||
values={"i_load_a": 0.5, "theta_ja": 50, "tj_max": 150},
|
||||
),
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"VIN": Net(
|
||||
name="VIN", net_type=NetType.POWER, voltage=5.0,
|
||||
pins=[PinConnection(component_ref="U1", pin_number="1")],
|
||||
),
|
||||
"VOUT": Net(
|
||||
name="VOUT", net_type=NetType.POWER, voltage=3.3,
|
||||
pins=[PinConnection(component_ref="U1", pin_number="2")],
|
||||
),
|
||||
},
|
||||
)
|
||||
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",
|
||||
courtyard=[(-2, -2), (2, -2), (2, 2), (-2, 2)],
|
||||
pads=[LayoutPad(number="2", x=0, y=0, net="VOUT")],
|
||||
),
|
||||
},
|
||||
segments=[LayoutSegment(start=(0, 0), end=(5, 0), width=0.5, layer="F.Cu", net="VOUT")],
|
||||
vias=[LayoutVia(x=0.1, y=0.1, net="GND", drill=0.3)],
|
||||
zones=[LayoutZone(
|
||||
net="GND", layer="F.Cu",
|
||||
outlines=[[(-3, -3), (3, -3), (3, 3), (-3, 3)]],
|
||||
)],
|
||||
)
|
||||
findings = check_pcb_junction_temp(graph, {"LDO1": cons}, layout)
|
||||
assert findings and findings[0].rule_id == "PE-THM-002"
|
||||
assert findings[0].evidence_status == "SUFFICIENT"
|
||||
assert "Tj" in findings[0].finding
|
||||
assert findings[0].status == "INFO"
|
||||
|
||||
|
||||
def test_tj_insufficient_without_theta():
|
||||
from backend.periscopex.pcb_power_thermal import check_pcb_junction_temp
|
||||
|
||||
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={"i_load_a": 0.5},
|
||||
),
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"VIN": Net(
|
||||
name="VIN", net_type=NetType.POWER, voltage=5.0,
|
||||
pins=[PinConnection(component_ref="U1", pin_number="1")],
|
||||
),
|
||||
"VOUT": Net(
|
||||
name="VOUT", net_type=NetType.POWER, voltage=3.3,
|
||||
pins=[PinConnection(component_ref="U1", pin_number="2")],
|
||||
),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(footprints={})
|
||||
findings = check_pcb_junction_temp(graph, {"LDO1": cons}, layout)
|
||||
assert findings and findings[0].rule_id == "PE-THM-002"
|
||||
assert findings[0].evidence_status == "INSUFFICIENT"
|
||||
assert findings[0].status == "INFO"
|
||||
Vendored
+7
@@ -5,6 +5,13 @@ Commit: a0c8d0ec37c9a1b099082926e50a245778ec8d6e
|
||||
Included: closed-form core (`zsolver`, `geometry`, `planes`, `model`,
|
||||
`net_walk`, `net_analysis`, `report`).
|
||||
|
||||
**License: UNKNOWN.** Upstream
|
||||
https://github.com/manvalan/ImpedenceFinder at commit
|
||||
`a0c8d0ec37c9a1b099082926e50a245778ec8d6e` has **no LICENSE file**
|
||||
(HTTP 404 on `/LICENSE`). Periscope does **not** invent a license text
|
||||
in-tree. Confirm with the copyright holder before commercial
|
||||
redistribution of `vendor/impedancefinder`.
|
||||
|
||||
Excluded on purpose:
|
||||
- `gerber2ems_export.py`, `prepare_simulation.py`, `crop_board.py`,
|
||||
`simulate_net.sh` (OpenEMS / field-solver export)
|
||||
|
||||
Reference in New Issue
Block a user