From 1b3529501fe785ea1ee04184f7b45301b137d281 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Sun, 20 Sep 2026 12:29:27 +0200 Subject: [PATCH] Ship remaining PCB plan slices: BOM chain, SPOF, EMI FACT, gated Tj. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/periscopex/bom_pcb_check.py | 417 ++++++++++++++++++++++++ backend/periscopex/emi_check.py | 133 ++++++++ backend/periscopex/finding_engine.py | 16 + backend/periscopex/layout_rules.py | 43 ++- backend/periscopex/pcb_checks.py | 8 + backend/periscopex/pcb_power_thermal.py | 155 +++++++++ backend/periscopex/si_check.py | 5 +- backend/periscopex/spof_check.py | 134 ++++++++ backend/services/extraction.py | 5 +- backend/services/pcb_pipeline.py | 30 +- backend/skills_manifest.json | 2 +- docs/motore-finding.md | 4 +- docs/piano-pcb-review.md | 21 +- frontend/content/changelog.md | 12 +- frontend/src/lib/layout-finding.ts | 16 +- skills/extract-pintable/SKILL.md | 1 + skills/extract-pintable/schema.json | 5 +- skills/extract-pintable/validate.py | 1 + tests/test_pcb_plan_closeout.py | 352 ++++++++++++++++++++ vendor/impedancefinder/SOURCE.md | 7 + 20 files changed, 1337 insertions(+), 30 deletions(-) create mode 100644 backend/periscopex/bom_pcb_check.py create mode 100644 backend/periscopex/emi_check.py create mode 100644 backend/periscopex/spof_check.py create mode 100644 tests/test_pcb_plan_closeout.py diff --git a/backend/periscopex/bom_pcb_check.py b/backend/periscopex/bom_pcb_check.py new file mode 100644 index 0000000..6e83ddd --- /dev/null +++ b/backend/periscopex/bom_pcb_check.py @@ -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 diff --git a/backend/periscopex/emi_check.py b/backend/periscopex/emi_check.py new file mode 100644 index 0000000..cb57cca --- /dev/null +++ b/backend/periscopex/emi_check.py @@ -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 diff --git a/backend/periscopex/finding_engine.py b/backend/periscopex/finding_engine.py index 826550d..e645208 100644 --- a/backend/periscopex/finding_engine.py +++ b/backend/periscopex/finding_engine.py @@ -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() diff --git a/backend/periscopex/layout_rules.py b/backend/periscopex/layout_rules.py index 4e670dc..c360421 100644 --- a/backend/periscopex/layout_rules.py +++ b/backend/periscopex/layout_rules.py @@ -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]]: diff --git a/backend/periscopex/pcb_checks.py b/backend/periscopex/pcb_checks.py index fe4e706..6612d40 100644 --- a/backend/periscopex/pcb_checks.py +++ b/backend/periscopex/pcb_checks.py @@ -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()) diff --git a/backend/periscopex/pcb_power_thermal.py b/backend/periscopex/pcb_power_thermal.py index 500eeb0..a5edffb 100644 --- a/backend/periscopex/pcb_power_thermal.py +++ b/backend/periscopex/pcb_power_thermal.py @@ -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") diff --git a/backend/periscopex/si_check.py b/backend/periscopex/si_check.py index ce60c22..eb4c00c 100644 --- a/backend/periscopex/si_check.py +++ b/backend/periscopex/si_check.py @@ -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", diff --git a/backend/periscopex/spof_check.py b/backend/periscopex/spof_check.py new file mode 100644 index 0000000..017d89b --- /dev/null +++ b/backend/periscopex/spof_check.py @@ -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 diff --git a/backend/services/extraction.py b/backend/services/extraction.py index e58749f..559bda0 100644 --- a/backend/services/extraction.py +++ b/backend/services/extraction.py @@ -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"]}, diff --git a/backend/services/pcb_pipeline.py b/backend/services/pcb_pipeline.py index 83dd6e2..5fc8497 100644 --- a/backend/services/pcb_pipeline.py +++ b/backend/services/pcb_pipeline.py @@ -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") diff --git a/backend/skills_manifest.json b/backend/skills_manifest.json index b4e9424..8c34037 100644 --- a/backend/skills_manifest.json +++ b/backend/skills_manifest.json @@ -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", diff --git a/docs/motore-finding.md b/docs/motore-finding.md index 7c15a83..ac55173 100644 --- a/docs/motore-finding.md +++ b/docs/motore-finding.md @@ -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. diff --git a/docs/piano-pcb-review.md b/docs/piano-pcb-review.md index 7c905d8..b128375 100644 --- a/docs/piano-pcb-review.md +++ b/docs/piano-pcb-review.md @@ -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. diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 0bcea90..8b2f26c 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -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). diff --git a/frontend/src/lib/layout-finding.ts b/frontend/src/lib/layout-finding.ts index fdb7b3d..2df436b 100644 --- a/frontend/src/lib/layout-finding.ts +++ b/frontend/src/lib/layout-finding.ts @@ -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) ); } diff --git a/skills/extract-pintable/SKILL.md b/skills/extract-pintable/SKILL.md index 9595596..80708fb 100644 --- a/skills/extract-pintable/SKILL.md +++ b/skills/extract-pintable/SKILL.md @@ -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. diff --git a/skills/extract-pintable/schema.json b/skills/extract-pintable/schema.json index a1729c2..f264ccf 100644 --- a/skills/extract-pintable/schema.json +++ b/skills/extract-pintable/schema.json @@ -75,7 +75,10 @@ "layer", "series_resistor", "return_path", - "si" + "si", + "emi", + "common_mode", + "shield" ] }, "pin": {"type": ["string", "null"]}, diff --git a/skills/extract-pintable/validate.py b/skills/extract-pintable/validate.py index 90dfcc0..6660c96 100644 --- a/skills/extract-pintable/validate.py +++ b/skills/extract-pintable/validate.py @@ -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): diff --git a/tests/test_pcb_plan_closeout.py b/tests/test_pcb_plan_closeout.py new file mode 100644 index 0000000..f7e42ff --- /dev/null +++ b/tests/test_pcb_plan_closeout.py @@ -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" diff --git a/vendor/impedancefinder/SOURCE.md b/vendor/impedancefinder/SOURCE.md index 584c154..2a2e222 100644 --- a/vendor/impedancefinder/SOURCE.md +++ b/vendor/impedancefinder/SOURCE.md @@ -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)