diff --git a/periscope/src/backend/periscopex/antenna_layout_check.py b/periscope/src/backend/periscopex/antenna_layout_check.py new file mode 100644 index 0000000..e3f45eb --- /dev/null +++ b/periscope/src/backend/periscopex/antenna_layout_check.py @@ -0,0 +1,87 @@ +"""Antenna keepout and matching — only if an antenna is on the graph. + +No invented 50 Ω. No FEM. Skip when the antenna is absent. +""" + +from __future__ import annotations + +import re + +from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph + +SOURCE = "antenna_layout_check" + +_ANT_REF_RE = re.compile(r"^ANT", re.I) +_FEED_RE = re.compile(r"ANT_FEED|RF_ANT|ANTENNA_FEED", re.I) +_ZONE_RE = re.compile(r"antenna|ant_zone|rf_antenna", re.I) + + +def check_antenna_layout( + graph: DesignGraph, + layout: LayoutGraph | None, +) -> list[Finding]: + ants = [ + c for c in graph.components.values() + if _ANT_REF_RE.match(c.reference or "") + ] + feeds = [n for n in graph.nets if _FEED_RE.search(n)] + if not ants and not feeds: + return [] + out: list[Finding] = [] + keepout = [] + if layout is not None: + keepout = [ + z for z in layout.zones + if z.keepout and (z.net and _ZONE_RE.search(z.net) or _ZONE_RE.search(z.name or "")) + ] + ref = ants[0].reference if ants else "ANT" + if keepout: + rec = "Keep copper out of the antenna keepout listed on the board." + names = [z.net or z.name or "keepout" for z in keepout] + out.append(Finding( + designator=ref, mpn="", aspect="antenna", + finding=f"Antenna keepout present: {', '.join(names)}.", + facts=f"keepout_zones={names}; antenna_refs={[c.reference for c in ants]}; feeds={feeds}.", + requirement="Report antenna keepout when that zone exists on the PCB.", + inference="No keepout millimetre is invented.", + why="Antenna checks run only when an antenna marker/net exists.", + status="INFO", recommendation=rec, action=rec, source=SOURCE, + rule_id="PE-ANT-001", finding_class="INFO", provenance="TYPICAL", + evidence_status="SUFFICIENT", net=feeds[0] if feeds else None, pins=[], + )) + else: + rec = "Add a keepout zone around the antenna if the antenna datasheet requires one." + out.append(Finding( + designator=ref, mpn="", aspect="antenna", + finding="Antenna is present; keepout zone is not evidenced.", + facts=f"antenna_refs={[c.reference for c in ants]}; feeds={feeds}; keepout_zones=0.", + requirement="Antenna keepout millimetres from the antenna datasheet (none on file).", + inference="INSUFFICIENT — not inventing a keepout distance.", + why="Missing keepout is not an invented millimetre FAIL.", + status="INFO", recommendation=rec, action=rec, source=SOURCE, + rule_id="PE-ANT-001", finding_class="INFO", provenance="TYPICAL", + evidence_status="INSUFFICIENT", net=feeds[0] if feeds else None, pins=[], + )) + match_refs = [] + for name in feeds: + net = graph.nets.get(name) + if not net: + continue + for pin in net.pins: + c = graph.components.get(pin.component_ref) + if c and c.component_type in {ComponentType.INDUCTOR, ComponentType.CAPACITOR}: + match_refs.append(c.reference) + if match_refs: + rec = "No matching-network change required; this is a BOM FACT." + out.append(Finding( + designator=ref, mpn="", aspect="antenna", + finding=f"Antenna matching parts on feed: {', '.join(sorted(set(match_refs)))}.", + facts=f"matching={sorted(set(match_refs))}; feeds={feeds}.", + requirement="Report L/C matching that exists on the antenna feed.", + inference="Missing match network is a skip unless a datasheet matching FACT exists.", + why="Do not invent a 50 Ω match.", + status="INFO", recommendation=rec, action=rec, source=SOURCE, + rule_id="PE-ANT-002", finding_class="INFO", provenance="TYPICAL", + evidence_status="SUFFICIENT", net=feeds[0] if feeds else None, pins=[], + )) + return out diff --git a/periscope/src/backend/periscopex/esd_return_check.py b/periscope/src/backend/periscopex/esd_return_check.py index 8c0d78a..528a789 100644 --- a/periscope/src/backend/periscopex/esd_return_check.py +++ b/periscope/src/backend/periscopex/esd_return_check.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math import re from backend.periscopex.models import ( @@ -210,3 +211,138 @@ def check_return_path( pins=[], )) return out + + +def _pad_xy(layout: LayoutGraph, ref: str) -> list[tuple[float, float, str]]: + fp = layout.footprints.get(ref) + if not fp: + return [] + return [(p.x, p.y, p.net) for p in fp.pads] + + +def _min_pad_distance_mm( + a: list[tuple[float, float, str]], + b: list[tuple[float, float, str]], + net: str, +) -> float | None: + best = None + for ax, ay, an in a: + if an and not kicad_nets_match(an, net): + continue + for bx, by, bn in b: + if bn and not kicad_nets_match(bn, net): + continue + d = math.hypot(ax - bx, ay - by) + if best is None or d < best: + best = d + return best + + +def check_esd_distance( + graph: DesignGraph, + constraints_map: dict[str, ComponentConstraints] | None, + layout: LayoutGraph | None, +) -> list[Finding]: + """TVS–connector pad distance. Vias are not pads. Skip without both lands.""" + if layout is None: + return [] + from backend.periscopex.constraints_lookup import match_constraints as match_cons + from backend.periscopex.si_check import _num, _quote + + tvs = [ + (ref, comp) for ref, comp in sorted(graph.components.items()) + if _is_esd_part(comp) + ] + connectors = [ + (ref, comp) for ref, comp in sorted(graph.components.items()) + if _is_j_connector(comp) + ] + if not tvs or not connectors: + return [] + out: list[Finding] = [] + seen: set[tuple[str, str, str]] = set() + for j_ref, j_comp in connectors: + j_pads = _pad_xy(layout, j_ref) + if not j_pads: + continue + for d_ref, d_comp in tvs: + d_pads = _pad_xy(layout, d_ref) + if not d_pads: + continue + nets = sorted({ + n for n in list(j_comp.pins.values()) + list(d_comp.pins.values()) if n + }) + for net in nets: + if _skip_esd_net(net): + continue + key = (j_ref, d_ref, normalize_kicad_hierarchy_net(net)) + if key in seen: + continue + dist = _min_pad_distance_mm(j_pads, d_pads, net) + if dist is None: + continue + seen.add(key) + lim = None + quote = "" + cons = match_cons(d_comp.mpn or d_comp.value, constraints_map or {}) + if cons: + for rule in cons.layout_rules or []: + if str(rule.get("kind") or "") not in {"esd", "keepout"}: + continue + lim = _num(rule.get("max_distance_mm")) + quote = _quote(rule) + if lim is not None: + break + facts = ( + f"pad_distance_mm={dist:.3f}; connector={j_ref}; tvs={d_ref}; " + f"net={net}; vias_not_used=1." + ) + calc = f"min hypot between {j_ref} pads and {d_ref} pads = {dist:.3f} mm." + if lim is None: + rec = ( + "Re-extract the TVS layout millimetre. " + "Do not assume a default ESD gap." + ) + out.append(Finding( + designator=d_ref, mpn=d_comp.mpn or "", aspect="esd", + finding=( + f"Unverified: {d_ref} is {dist:.2f} mm from {j_ref} — " + "library has no ESD millimetre FACT." + ), + facts=facts, calculation=calc, + requirement=( + "Datasheet ESD max_distance_mm from TVS to connector " + "(none on file)." + ), + inference="INSUFFICIENT — not inventing an ESD keep-in millimetre.", + why="Distance is pad-to-pad; vias and tracks are not lands.", + status="INFO", recommendation=rec, action=rec, + source="esd_return_check", rule_id="PE-ESD-002", + finding_class="INFO", provenance="TYPICAL", + evidence_status="INSUFFICIENT", net=net, pins=[], + )) + continue + fail = dist > lim + 1e-9 + rec = ( + f"Move {d_ref} within {lim:g} mm of {j_ref}." + if fail + else "TVS-to-connector distance meets the datasheet millimetre." + ) + out.append(Finding( + designator=d_ref, mpn=d_comp.mpn or "", aspect="esd", + finding=( + f"{'FAIL' if fail else 'PASS'}: {d_ref}–{j_ref} " + f"{dist:.2f} mm (max {lim:g} mm)." + ), + facts=facts, calculation=calc, + requirement=f"ESD max_distance_mm={lim:g} ({quote or 'layout_rules'}).", + inference="FAIL vs datasheet millimetre." if fail else "", + why="Pad-to-pad only; a via on the net is not the TVS land.", + status="WARNING" if fail else "INFO", + recommendation=rec, action=rec, + source="esd_return_check", rule_id="PE-ESD-002", + finding_class="RISK" if fail else "INFO", + provenance="RECOMMENDED" if fail else "TYPICAL", + evidence_status="SUFFICIENT", net=net, pins=[], + )) + return out diff --git a/periscope/src/backend/periscopex/finding_engine.py b/periscope/src/backend/periscopex/finding_engine.py index d24fc6c..87e45a0 100644 --- a/periscope/src/backend/periscopex/finding_engine.py +++ b/periscope/src/backend/periscopex/finding_engine.py @@ -250,6 +250,22 @@ def _seed() -> None: requirement="FPGA config flash reported when a flash IC is on the graph.") _add("PE-FPGA-003", "TYPICAL", "INFO", domain="shared", requirement="FPGA core/IO rails reported only when VCCINT/VCCIO (named) exist.") + _add("PE-STK-001", "TYPICAL", "REVIEW", domain="pcb", + requirement="Board copper thickness vs a sourced fab stackup FACT (never 1 oz).") + _add("PE-VIA-002", "TYPICAL", "INFO", domain="pcb", + requirement="Via count/drill with datasheet I; IPC via chart is not applied.") + _add("PE-ESD-002", "RECOMMENDED", "RISK", domain="pcb", + requirement="TVS-to-connector pad distance vs datasheet millimetres.") + _add("PE-PD-001", "TYPICAL", "INFO", domain="shared", + requirement="USB-PD contract only with a PD controller FACT (not USB2 Rd).") + _add("PE-POE-004", "TYPICAL", "INFO", domain="shared", + requirement="PoE isolation voltage only from a sourced number.") + _add("PE-ANT-001", "TYPICAL", "INFO", domain="pcb", + requirement="Antenna keepout reported when an antenna and that zone exist.") + _add("PE-ANT-002", "TYPICAL", "INFO", domain="pcb", + requirement="Antenna matching L/C reported when those parts exist on the feed.") + _add("PE-PDN-001", "TYPICAL", "INFO", domain="pcb", + requirement="PDN Z(f) only with frequency-domain FACT; SI Z0 is not PDN.") _seed() diff --git a/periscope/src/backend/periscopex/interface_class_check.py b/periscope/src/backend/periscopex/interface_class_check.py index 3c8ebdd..e2bed7a 100644 --- a/periscope/src/backend/periscopex/interface_class_check.py +++ b/periscope/src/backend/periscopex/interface_class_check.py @@ -17,6 +17,7 @@ from backend.periscopex.models import ( Finding, NetType, ResistorSpecs, + SimpleComponentSpecs, ) from backend.periscopex.pcb_net_match import ( is_no_connect_net, @@ -424,9 +425,35 @@ def _poe_one(graph: DesignGraph, comp: Component, evidence: str) -> list[Finding facts, req, f"Use PoE magnetics / MagJack isolation on {ref}.", mpn=comp.mpn or "")) + iso_v = _isolation_v(comp) + if iso_v is not None: + out.append(_info( + ref, "PE-POE-004", + f"{ref} PoE isolation {iso_v:g} V (datasheet FACT).", + f"isolation_v={iso_v:g}.", + "PoE isolation voltage is reported only from a sourced number — never IEC-invented.", + mpn=comp.mpn or "", + )) return out +def _isolation_v(comp: Component) -> float | None: + specs = comp.specs + if not isinstance(specs, SimpleComponentSpecs): + return None + for key in ("isolation_v", "isolation_voltage_v", "hipot_v"): + raw = specs.values.get(key) + if isinstance(raw, bool) or raw is None: + continue + try: + v = float(raw) + except (TypeError, ValueError): + continue + if v > 0: + return v + return None + + def _blob(graph: DesignGraph | None, comp: Component) -> str: parts = [ comp.value or "", diff --git a/periscope/src/backend/periscopex/pcb_checks.py b/periscope/src/backend/periscopex/pcb_checks.py index 5384544..054d5a5 100644 --- a/periscope/src/backend/periscopex/pcb_checks.py +++ b/periscope/src/backend/periscopex/pcb_checks.py @@ -8,7 +8,7 @@ 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.esd_return_check import check_esd, check_esd_distance, check_return_path from backend.periscopex.finding_engine import complete_findings from backend.periscopex.functional_groups import FunctionalGroupsReport from backend.periscopex.hierarchy import check_hierarchy @@ -29,8 +29,12 @@ 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.interface_class_check import SOURCE as IF_SOURCE, check_interface_classes +from backend.periscopex.antenna_layout_check import check_antenna_layout from backend.periscopex.hf_bus_class import check_memory_fpga_classes from backend.periscopex.hf_line_check import check_hf_lines +from backend.periscopex.pdn_check import check_pdn +from backend.periscopex.stackup_check import check_stackup +from backend.periscopex.usb_pd_check import check_usb_pd from backend.periscopex.si_check import check_si from backend.periscopex.spof_check import check_spof from backend.periscopex.timing_check import check_timing @@ -283,7 +287,12 @@ def run_pcb_checks( ("timing_check", lambda: check_timing(graph, constraints_map)), ("pi_check", lambda: check_power_integrity(graph, constraints_map, layout)), ("esd_check", lambda: check_esd(graph, constraints_map)), + ("esd_distance", lambda: check_esd_distance(graph, constraints_map, layout)), ("return_path", lambda: check_return_path(graph, layout)), + ("stackup_check", lambda: check_stackup(graph, constraints_map, layout)), + ("usb_pd", lambda: check_usb_pd(graph)), + ("antenna_layout", lambda: check_antenna_layout(graph, layout)), + ("pdn_check", lambda: check_pdn(graph, layout, impedance_nets)), ("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)), diff --git a/periscope/src/backend/periscopex/pcb_power_thermal.py b/periscope/src/backend/periscopex/pcb_power_thermal.py index e4e3a58..b99cab0 100644 --- a/periscope/src/backend/periscopex/pcb_power_thermal.py +++ b/periscope/src/backend/periscopex/pcb_power_thermal.py @@ -278,10 +278,37 @@ def check_pcb_via_current( if key in seen: continue seen.add(key) - n, _drill = _via_stats(layout, net) + n, drill = _via_stats(layout, net) if n > 0: - # Geometry is present; no via-ampacity table in the library — do not - # invent a rating or claim the vias were not parsed. + rec = ( + "Supply a datasheet via current or plating thickness before judging " + "via ampacity. IPC via charts are not applied." + ) + drill_txt = f"{drill:g}" if drill is not None else "—" + out.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="layout_power", + finding=( + f"{net} carries I_load={i_load:.3g} A on {n} via(s) " + f"(min drill {drill_txt} mm); no via ampacity table in the library." + ), + why="I from datasheet; via count and drill from the board. No invented IPC via k.", + status="INFO", + recommendation=rec, + action=rec, + source="pcb_power_thermal", + rule_id="PE-VIA-002", + evidence_status="INSUFFICIENT", + facts=( + f"I_load={i_load:.3g} A; via_count={n}; min_drill_mm={drill_txt}; " + f"board_vias={len(layout.vias)}." + ), + requirement="Via ampacity table is not in the library (IPC via chart not applied).", + inference="INSUFFICIENT — not inventing via ampacity from an IPC chart.", + net=net, + pins=[], + )) continue out.append(Finding( designator=ref, diff --git a/periscope/src/backend/periscopex/pdn_check.py b/periscope/src/backend/periscopex/pdn_check.py new file mode 100644 index 0000000..062887b --- /dev/null +++ b/periscope/src/backend/periscopex/pdn_check.py @@ -0,0 +1,42 @@ +"""PDN Z(f). Skip without a frequency-domain FACT. SI Z0 is not PDN.""" + +from __future__ import annotations + +from backend.periscopex.models import DesignGraph, Finding, LayoutGraph + +SOURCE = "pdn_check" + + +def check_pdn( + graph: DesignGraph, + layout: LayoutGraph | None, + impedance_nets: list[dict] | dict | None = None, +) -> list[Finding]: + """Require explicit PDN Z(f) rows. ImpedenceFinder SI Z0 is not Z(f).""" + _ = graph + _ = layout + raw = impedance_nets + if isinstance(impedance_nets, dict): + raw = list(impedance_nets.get("pdn") or impedance_nets.get("pdn_nets") or []) + rows = [r for r in (raw or []) if isinstance(r, dict)] + pdn = [ + r for r in rows + if str(r.get("kind") or "").lower() == "pdn" + or r.get("frequency_hz") is not None + or r.get("z_ohm_at_hz") is not None + ] + if not pdn: + return [] + rec = "Use measured PDN Z(f); do not invent a target impedance." + facts = f"pdn_rows={len(pdn)}." + return [Finding( + designator="PDN", mpn="", aspect="pdn", + finding=f"PDN Z(f) FACT present ({len(pdn)} row(s)).", + facts=facts, + requirement="PDN Z(f) is reported only with frequency-domain measurements.", + inference="No target Z(f) is invented.", + why="PDN is last; SI differential Z0 is a different check.", + status="INFO", recommendation=rec, action=rec, source=SOURCE, + rule_id="PE-PDN-001", finding_class="INFO", provenance="TYPICAL", + evidence_status="SUFFICIENT", pins=[], + )] diff --git a/periscope/src/backend/periscopex/stackup_check.py b/periscope/src/backend/periscopex/stackup_check.py new file mode 100644 index 0000000..c5dfb71 --- /dev/null +++ b/periscope/src/backend/periscopex/stackup_check.py @@ -0,0 +1,63 @@ +"""Stackup vs fab spec. Skip without a sourced fab FACT. Never default 1 oz.""" + +from __future__ import annotations + +from backend.periscopex.constraints_lookup import match_constraints as _match_constraints +from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph +from backend.periscopex.si_check import _num, _quote + +SOURCE = "stackup_check" + + +def check_stackup( + graph: DesignGraph, + constraints_map: dict, + layout: LayoutGraph | None, +) -> list[Finding]: + if layout is None or layout.stackup is None: + return [] + board_t = layout.stackup.copper_thickness_mm + fab_t = None + quote = "" + for _ref, comp in sorted(graph.components.items()): + if comp.component_type != ComponentType.IC: + continue + cons = _match_constraints(comp.mpn or comp.value, constraints_map) + if not cons: + continue + for rule in cons.layout_rules or []: + if str(rule.get("kind") or "") != "stackup": + continue + fab_t = _num(rule.get("copper_thickness_mm")) + quote = _quote(rule) + if fab_t is not None: + break + if fab_t is not None: + break + if fab_t is None or board_t is None or board_t <= 0: + return [] + same = abs(board_t - fab_t) <= 1e-9 + rec = ( + "Match the board stackup copper thickness to the fab spec, then re-run." + if not same else "Board copper thickness matches the fab spec." + ) + return [Finding( + designator="layout", mpn="", aspect="stackup", + finding=( + f"{'PASS' if same else 'REVIEW'}: board copper_thickness_mm={board_t:g} " + f"vs fab {fab_t:g} mm." + ), + facts=( + f"board_copper_thickness_mm={board_t:g}; fab_copper_thickness_mm={fab_t:g}; " + f"copper_layers={layout.stackup.copper_layers}." + ), + requirement=f"Fab stackup copper thickness {fab_t:g} mm ({quote or 'layout_rules'}).", + inference="" if same else "REVIEW — no invented 1 oz default or IPC copper weight.", + why="Compare parsed KiCad stackup to a sourced fab thickness only.", + status="INFO" if same else "WARNING", + recommendation=rec, action=rec, source=SOURCE, + rule_id="PE-STK-001", + finding_class="INFO" if same else "REVIEW", + provenance="TYPICAL", + evidence_status="SUFFICIENT", pins=[], + )] diff --git a/periscope/src/backend/periscopex/usb_pd_check.py b/periscope/src/backend/periscopex/usb_pd_check.py new file mode 100644 index 0000000..478ee0d --- /dev/null +++ b/periscope/src/backend/periscopex/usb_pd_check.py @@ -0,0 +1,41 @@ +"""USB-PD power contract. Skip without a PD controller. Not SI. Not USB2 Rd.""" + +from __future__ import annotations + +import re + +from backend.periscopex.models import ComponentType, DesignGraph, Finding + +SOURCE = "usb_pd_check" + +_PD_RE = re.compile( + r"FUSB302|STUSB|IP2721|TPS257|CYPD|CH224|HUSB238|" + r"USB[\s\-]?PD|POWER\s*DELIVERY", + re.I, +) + + +def check_usb_pd(graph: DesignGraph) -> list[Finding]: + chips = [ + c for c in graph.components.values() + if c.component_type == ComponentType.IC and _PD_RE.search( + " ".join(str(x or "") for x in (c.mpn, c.value, c.component_subtype)) + ) + ] + if not chips: + return [] + out: list[Finding] = [] + rec = "No USB-PD contract change from this class FACT." + for comp in sorted(chips, key=lambda c: c.reference): + out.append(Finding( + designator=comp.reference, mpn=comp.mpn or "", aspect="usb_pd", + finding=f"{comp.reference} USB-PD controller is on this graph.", + facts=f"mpn={comp.mpn!r}; value={comp.value!r}.", + requirement="USB-PD contract runs only with a PD IC FACT — not USB2 Rd/CC.", + inference="PDO voltages are not invented; missing PDO is a skip.", + why="USB-PD is a power contract, not SuperSpeed SI.", + status="INFO", recommendation=rec, action=rec, source=SOURCE, + rule_id="PE-PD-001", finding_class="INFO", provenance="TYPICAL", + evidence_status="SUFFICIENT", pins=[], + )) + return out diff --git a/periscope/src/docs/interface-class-certifiers.md b/periscope/src/docs/interface-class-certifiers.md index 3b7324a..3cca2ff 100644 --- a/periscope/src/docs/interface-class-certifiers.md +++ b/periscope/src/docs/interface-class-certifiers.md @@ -17,6 +17,7 @@ Classe dichiarata o inferita (FACT/REQUIREMENT da BOM, net, footprint, sheet). S | PE-USBC-001…005 | c’è un receptacle USB-C | classe USB2/USB3, CC1/CC2, Rd/Rp 5.1 kΩ, VBUS/GND, SuperSpeed solo se USB3 | | PE-ETH-001…004 | c’è un RJ45 | 10/100 vs GbE, coppie TX/RX o 4 MDI, magnetics se GbE, Bob Smith REVIEW | | PE-POE-001…003 | evidenza PoE sul jack | classe/tipo se citati; isolamento/magnetics; **skip** se RJ45 nudo | +| PE-POE-004 | PoE + `isolation_v` | tensione di isolamento solo se sourced; niente IEC inventato | | PE-DDR-* | solo se c’è un dispositivo DDR | non inventato su HubAudio (nessun DDR in BOM) | | PE-CPU-* | solo se c’è un bus parallelo D/A/controllo | SRAM/NOR/FMC/mux AD; niente bus inventato | | PE-FPGA-* | solo se c’è un FPGA | bank/IO rails e flash di config se presenti; niente FPGA inventata | diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index f7d24cb..a328926 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,6 +2,18 @@ What's new in Periscope. +## 2.62.1 — 2026-09-21 — PCB analysis remainder (Phase B) + +Stackup vs fab spec, via current with datasheet I (no IPC via chart), ESD pad distance TVS–connector, USB-PD contract, PoE isolation voltage, antenna keepout/matching, PDN Z(f). Same pipeline and finding quality as 2.61.2 / 2.62.0. Skip if no evidence. Not DRC. Not FEM. + +- [New] `PE-STK-001` board copper thickness vs sourced fab stackup (never 1 oz). +- [New] `PE-VIA-002` I + via count/drill FACT; IPC via ampacity is not applied. +- [New] `PE-ESD-002` TVS–connector **pad** distance (via ≠ pad); INFO if no mm. +- [New] `PE-PD-001` USB-PD only with a PD controller (not USB2 Rd). +- [New] `PE-POE-004` isolation voltage only from a sourced number. +- [New] `PE-ANT-001`/`002` antenna keepout and matching if an antenna exists. +- [New] `PE-PDN-001` Z(f) only with frequency-domain FACT; SI Z0 is not PDN. + ## 2.62.0 — 2026-09-21 — HF lines/buses in PCB checks (Phase A) USB / Eth / DDR / MIPI / PCIe / HDMI / LVDS / HF clock in `run_pcb_checks` at the same finding quality as 2.61.2. Pair integrity: stub (track only; via ≠ pad ≠ zone), reference-split under the line, BOM termination. CPU data/address/control and FPGA only if that device is on the graph. Skip if no evidence. Not DRC. Not FEM. Datasheet millimetres only; no invented Z/I. diff --git a/periscope/src/frontend/package.json b/periscope/src/frontend/package.json index 292b32f..c40a2cb 100644 --- a/periscope/src/frontend/package.json +++ b/periscope/src/frontend/package.json @@ -1,6 +1,6 @@ { "name": "periscope-web", - "version": "2.62.0", + "version": "2.62.1", "private": true, "scripts": { "sync-version": "node scripts/sync-version.mjs", diff --git a/periscope/src/frontend/src/lib/layout-finding.ts b/periscope/src/frontend/src/lib/layout-finding.ts index 2928641..e9204ec 100644 --- a/periscope/src/frontend/src/lib/layout-finding.ts +++ b/periscope/src/frontend/src/lib/layout-finding.ts @@ -20,6 +20,10 @@ export function isLayoutFinding(f: { f.source === "emi_check" || f.source === "hf_line_check" || f.source === "hf_bus_class" || + f.source === "stackup_check" || + f.source === "usb_pd_check" || + f.source === "antenna_layout_check" || + f.source === "pdn_check" || rid.startsWith("PE-PLC") || rid.startsWith("PE-LAY") || rid.startsWith("PE-SI") || @@ -39,6 +43,10 @@ export function isLayoutFinding(f: { rid.startsWith("PE-RET") || rid.startsWith("PE-SPOF") || rid.startsWith("PE-EMI") || + rid.startsWith("PE-STK") || + rid.startsWith("PE-PD-") || + rid.startsWith("PE-ANT") || + rid.startsWith("PE-PDN") || /^PE-BOM-01[0-4]$/.test(rid) ); } @@ -70,6 +78,10 @@ export function isPcbExamFinding(f: { rid.startsWith("PE-RET") || rid.startsWith("PE-SPOF") || rid.startsWith("PE-EMI") || + rid.startsWith("PE-STK") || + rid.startsWith("PE-PD-") || + rid.startsWith("PE-ANT") || + rid.startsWith("PE-PDN") || /^PE-BOM-01[0-4]$/.test(rid) ); } diff --git a/periscope/src/frontend/src/lib/version.ts b/periscope/src/frontend/src/lib/version.ts index 03a8a05..0f7ad37 100644 --- a/periscope/src/frontend/src/lib/version.ts +++ b/periscope/src/frontend/src/lib/version.ts @@ -1,3 +1,3 @@ /** Stamped from content/changelog.md by scripts/sync-version.mjs. */ -export const APP_VERSION = "2.62.0"; +export const APP_VERSION = "2.62.1"; export const APP_VERSION_DATE = "2026-09-21"; diff --git a/tests/test_pcb_phase_b.py b/tests/test_pcb_phase_b.py new file mode 100644 index 0000000..7b52ba6 --- /dev/null +++ b/tests/test_pcb_phase_b.py @@ -0,0 +1,297 @@ +"""Phase B: stackup, via I, ESD mm, USB-PD, PoE isolation, antenna, PDN. + +Skip without evidence. No invented I/Z/mm/IPC via chart. Via ≠ pad ≠ track ≠ zone. +""" + +from __future__ import annotations + +from backend.periscopex.antenna_layout_check import check_antenna_layout +from backend.periscopex.esd_return_check import check_esd_distance +from backend.periscopex.finding_engine import complete_finding, lookup_rule +from backend.periscopex.interface_class_check import check_interface_classes +from backend.periscopex.models import ( + Component, + ComponentConstraints, + ComponentType, + DesignGraph, + LayoutDielectric, + LayoutFootprint, + LayoutGraph, + LayoutPad, + LayoutSegment, + LayoutStackup, + LayoutVia, + LayoutZone, + Net, + NetType, + Pin, + PinConnection, + SimpleComponentSpecs, +) +from backend.periscopex.pcb_checks import run_pcb_checks +from backend.periscopex.pcb_power_thermal import check_pcb_via_current +from backend.periscopex.pdn_check import check_pdn +from backend.periscopex.stackup_check import check_stackup +from backend.periscopex.usb_pd_check import check_usb_pd + + +def _ic(ref, pins, *, mpn="PART", specs=None, subtype=None): + return Component( + reference=ref, value=mpn, footprint="", + component_type=ComponentType.IC, mpn=mpn, + component_subtype=subtype, pins=pins, specs=specs, + ) + + +def _net(name, *pairs, ntype=NetType.SIGNAL): + return Net( + name=name, net_type=ntype, + pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs], + ) + + +def test_stackup_skips_without_fab_spec(): + layout = LayoutGraph( + stackup=LayoutStackup( + copper_layers=["F.Cu", "B.Cu"], + dielectrics=[LayoutDielectric(name="d1", er=4.5, height_mm=0.2)], + copper_thickness_mm=0.035, + ), + segments=[LayoutSegment(start=(0, 0), end=(1, 0), width=0.2, layer="F.Cu", net="GND")], + ) + g = DesignGraph(components={"U1": _ic("U1", {"1": "GND"})}, nets={"GND": _net("GND", ("U1", "1"), ntype=NetType.GROUND)}) + assert check_stackup(g, {}, layout) == [] + assert check_stackup(g, {}, None) == [] + + +def test_stackup_vs_fab_spec_is_review_not_invented_oz(): + layout = LayoutGraph( + stackup=LayoutStackup( + copper_layers=["F.Cu", "B.Cu"], + dielectrics=[LayoutDielectric(name="d1", er=4.5, height_mm=0.2)], + copper_thickness_mm=0.035, + ), + ) + cons = ComponentConstraints( + mpn="FAB", pintable=[Pin(number="1", name="GND")], + absolute_maximum_ratings=[], rules=[], + layout_rules=[{ + "kind": "stackup", "copper_thickness_mm": 0.070, + "note": "fab 2 oz", "source_page": 1, + }], + ) + g = DesignGraph(components={"U1": _ic("U1", {"1": "GND"}, mpn="FAB")}, nets={}) + findings = check_stackup(g, {"FAB": cons}, layout) + assert len(findings) == 1 + assert findings[0].rule_id == "PE-STK-001" + assert findings[0].status == "WARNING" + assert findings[0].finding_class == "REVIEW" + assert "0.035" in findings[0].facts + assert "0.07" in findings[0].facts.replace("070", "0.07") or "0.070" in findings[0].facts + assert "1 oz" not in findings[0].finding.lower() + complete_finding(findings[0]) + assert findings[0].status != "ERROR" + + +def test_via_current_skips_without_i_does_not_invent_ipc(): + layout = LayoutGraph( + segments=[LayoutSegment(start=(0, 0), end=(5, 0), width=0.5, layer="F.Cu", net="VOUT")], + vias=[LayoutVia(x=1, y=0, net="VOUT", drill=0.3)], + ) + g = DesignGraph( + components={"U1": _ic("U1", {"1": "VIN", "2": "VOUT"}, mpn="LDO1", + specs=SimpleComponentSpecs(specs_type="discrete", values={}))}, + nets={"VOUT": _net("VOUT", ("U1", "2"), ntype=NetType.POWER)}, + ) + cons = ComponentConstraints(mpn="LDO1", pintable=[Pin(number="2", name="VOUT")], + absolute_maximum_ratings=[], rules=[]) + assert check_pcb_via_current(g, {"LDO1": cons}, layout) == [] + + g.components["U1"].specs = SimpleComponentSpecs( + specs_type="discrete", values={"i_load": 1.2}, + ) + findings = check_pcb_via_current(g, {"LDO1": cons}, layout) + via2 = [f for f in findings if f.rule_id == "PE-VIA-002"] + assert len(via2) == 1 + assert via2[0].status == "INFO" + assert via2[0].evidence_status == "INSUFFICIENT" + blob = f"{via2[0].finding} {via2[0].requirement} {via2[0].inference} {via2[0].facts}" + assert "1.2" in via2[0].facts + assert "0.3" in via2[0].facts + assert "IPC" not in via2[0].finding + assert "k =" not in blob.lower() + complete_finding(via2[0]) + assert via2[0].status != "ERROR" + + +def test_esd_distance_uses_pads_not_vias(): + g = DesignGraph( + components={ + "J2": Component( + reference="J2", value="USB_C", footprint="", + component_type=ComponentType.CONNECTOR, pins={"A6": "USB_D+"}, + ), + "D1": _ic("D1", {"1": "USB_D+"}, mpn="USBLC6", subtype="ic.protection.esd"), + }, + nets={"USB_D+": _net("USB_D+", ("J2", "A6"), ("D1", "1"))}, + ) + layout = LayoutGraph( + footprints={ + "J2": LayoutFootprint(reference="J2", x=0, y=0, pads=[ + LayoutPad(number="A6", x=0.0, y=0.0, net="USB_D+"), + ]), + "D1": LayoutFootprint(reference="D1", x=8.0, y=0.0, pads=[ + LayoutPad(number="1", x=8.0, y=0.0, net="USB_D+"), + ]), + }, + vias=[LayoutVia(x=1.0, y=0.0, net="USB_D+", drill=0.3)], + segments=[LayoutSegment(start=(0, 0), end=(8, 0), width=0.2, layer="F.Cu", net="USB_D+")], + ) + findings = check_esd_distance(g, {}, layout) + dist = [f for f in findings if f.rule_id == "PE-ESD-002"] + assert len(dist) == 1 + assert dist[0].status == "INFO" + assert dist[0].evidence_status == "INSUFFICIENT" + assert "8.000" in dist[0].facts or "8.00" in dist[0].facts + assert "via" not in dist[0].facts.lower() or "pad" in dist[0].facts.lower() + assert dist[0].calculation + cons = ComponentConstraints( + mpn="USBLC6", pintable=[Pin(number="1", name="IO")], + absolute_maximum_ratings=[], rules=[], + layout_rules=[{ + "kind": "esd", "max_distance_mm": 3.0, + "note": "TVS within 3 mm of connector", "source_page": 5, + }], + ) + fail = check_esd_distance(g, {"USBLC6": cons}, layout) + hit = [f for f in fail if f.rule_id == "PE-ESD-002"][0] + assert hit.finding.startswith("FAIL:") + complete_finding(hit) + assert hit.status == "WARNING" + assert hit.status != "ERROR" or hit.provenance == "MANDATORY" + + +def test_usb_pd_skips_without_pd_controller(): + g = DesignGraph( + components={ + "J2": Component( + reference="J2", value="USB_C_Receptacle_USB2.0_16P", footprint="", + component_type=ComponentType.CONNECTOR, pins={"A5": "USB_CC1"}, + ), + }, + nets={"USB_CC1": _net("USB_CC1", ("J2", "A5"))}, + ) + assert check_usb_pd(g) == [] + g.components["U8"] = _ic("U8", {"1": "USB_CC1", "2": "VBUS"}, mpn="FUSB302B") + g.nets["VBUS"] = _net("VBUS", ("U8", "2"), ntype=NetType.POWER) + found = check_usb_pd(g) + assert [f.rule_id for f in found] == ["PE-PD-001"] + assert found[0].status == "INFO" + assert found[0].evidence_status == "SUFFICIENT" + + +def test_poe_isolation_skips_without_voltage_fact(): + g = DesignGraph( + components={ + "J1": Component( + reference="J1", value="RJ45 PoE MagJack", footprint="", + component_type=ComponentType.CONNECTOR, mpn="ARJP11A", + pins={"1": "ETH_TX+"}, + ), + }, + nets={"ETH_TX+": _net("ETH_TX+", ("J1", "1"))}, + ) + findings = check_interface_classes(g) + assert not any(f.rule_id == "PE-POE-004" for f in findings) + g.components["J1"].specs = SimpleComponentSpecs( + specs_type="connector", values={"isolation_v": 1500.0}, + ) + with_v = check_interface_classes(g) + iso = [f for f in with_v if f.rule_id == "PE-POE-004"] + assert len(iso) == 1 + assert iso[0].status == "INFO" + assert "1500" in iso[0].facts + assert iso[0].evidence_status == "SUFFICIENT" + + +def test_antenna_skips_when_absent(): + g = DesignGraph( + components={"U1": _ic("U1", {"1": "GPIO1"})}, + nets={"GPIO1": _net("GPIO1", ("U1", "1"))}, + ) + layout = LayoutGraph(segments=[ + LayoutSegment(start=(0, 0), end=(2, 0), width=0.2, layer="F.Cu", net="GPIO1"), + ]) + assert check_antenna_layout(g, layout) == [] + + +def test_antenna_keepout_and_match_are_facts(): + g = DesignGraph( + components={ + "U1": _ic("U1", {"1": "ANT_FEED"}, mpn="ESP32"), + "ANT1": Component( + reference="ANT1", value="ANT", footprint="RF_Antenna:ANT", + component_type=ComponentType.UNKNOWN, pins={"1": "ANT_FEED"}, + ), + "L1": Component( + reference="L1", value="3.3nH", footprint="", + component_type=ComponentType.INDUCTOR, pins={"1": "ANT_FEED", "2": "RF_OUT"}, + ), + }, + nets={ + "ANT_FEED": _net("ANT_FEED", ("U1", "1"), ("ANT1", "1"), ("L1", "1")), + "RF_OUT": _net("RF_OUT", ("L1", "2")), + }, + ) + layout = LayoutGraph( + footprints={"ANT1": LayoutFootprint(reference="ANT1", x=10, y=10, pads=[ + LayoutPad(number="1", x=10, y=10, net="ANT_FEED"), + ])}, + zones=[LayoutZone( + net="antenna", layer="F.Cu", keepout=True, + outlines=[[(8, 8), (14, 8), (14, 14), (8, 14)]], + )], + segments=[LayoutSegment(start=(0, 0), end=(10, 10), width=0.3, layer="F.Cu", net="ANT_FEED")], + ) + findings = check_antenna_layout(g, layout) + ids = {f.rule_id for f in findings} + assert "PE-ANT-001" in ids + assert "PE-ANT-002" in ids + keep = [f for f in findings if f.rule_id == "PE-ANT-001"][0] + assert keep.status == "INFO" + assert keep.evidence_status == "SUFFICIENT" + assert "50" not in keep.requirement + + +def test_pdn_skips_without_zf(): + g = DesignGraph(components={"U1": _ic("U1", {"1": "3V3"})}, nets={ + "3V3": _net("3V3", ("U1", "1"), ntype=NetType.POWER), + }) + layout = LayoutGraph(segments=[ + LayoutSegment(start=(0, 0), end=(5, 0), width=0.5, layer="F.Cu", net="3V3"), + ]) + assert check_pdn(g, layout, None) == [] + assert check_pdn(g, layout, {"nets": [{"net_name": "USB_D+", "z0_avg_ohms": 88}]}) == [] + + +def test_run_pcb_checks_phase_b_absent_is_silent(): + g = DesignGraph(components={"U1": _ic("U1", {"1": "GND"})}, nets={ + "GND": _net("GND", ("U1", "1"), ntype=NetType.GROUND), + }) + ids = {f.rule_id for f in run_pcb_checks(g, {}, None)} + assert "PE-STK-001" not in ids + assert "PE-VIA-002" not in ids + assert "PE-ESD-002" not in ids + assert "PE-PD-001" not in ids + assert "PE-POE-004" not in ids + assert "PE-ANT-001" not in ids + assert "PE-PDN-001" not in ids + + +def test_phase_b_rule_catalog(): + for rid in ( + "PE-STK-001", "PE-VIA-002", "PE-ESD-002", "PE-PD-001", + "PE-POE-004", "PE-ANT-001", "PE-ANT-002", "PE-PDN-001", + ): + rec = lookup_rule(rid) + assert rec is not None, rid