Add remaining PCB analysis checks to run_pcb_checks (2.62.1).

Phase B: stackup vs fab spec, via current with datasheet I (no IPC via
chart), ESD pad distance, USB-PD, PoE isolation voltage, antenna, and
PDN Z(f). Skip without evidence. Not DRC or FEM.
This commit is contained in:
2026-09-21 20:49:21 +02:00
parent 69d5dd9313
commit 20733f0ec6
15 changed files with 776 additions and 6 deletions
@@ -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
@@ -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]:
"""TVSconnector 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
@@ -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()
@@ -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 "",
+10 -1
View File
@@ -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)),
@@ -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,
@@ -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=[],
)]
@@ -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=[],
)]
@@ -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
@@ -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 |
@@ -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 TVSconnector, 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` TVSconnector **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.
+1 -1
View File
@@ -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",
@@ -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)
);
}
+1 -1
View File
@@ -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";
+297
View File
@@ -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