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:
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from backend.periscopex.models import (
|
from backend.periscopex.models import (
|
||||||
@@ -210,3 +211,138 @@ def check_return_path(
|
|||||||
pins=[],
|
pins=[],
|
||||||
))
|
))
|
||||||
return out
|
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
|
||||||
|
|||||||
@@ -250,6 +250,22 @@ def _seed() -> None:
|
|||||||
requirement="FPGA config flash reported when a flash IC is on the graph.")
|
requirement="FPGA config flash reported when a flash IC is on the graph.")
|
||||||
_add("PE-FPGA-003", "TYPICAL", "INFO", domain="shared",
|
_add("PE-FPGA-003", "TYPICAL", "INFO", domain="shared",
|
||||||
requirement="FPGA core/IO rails reported only when VCCINT/VCCIO (named) exist.")
|
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()
|
_seed()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from backend.periscopex.models import (
|
|||||||
Finding,
|
Finding,
|
||||||
NetType,
|
NetType,
|
||||||
ResistorSpecs,
|
ResistorSpecs,
|
||||||
|
SimpleComponentSpecs,
|
||||||
)
|
)
|
||||||
from backend.periscopex.pcb_net_match import (
|
from backend.periscopex.pcb_net_match import (
|
||||||
is_no_connect_net,
|
is_no_connect_net,
|
||||||
@@ -424,9 +425,35 @@ def _poe_one(graph: DesignGraph, comp: Component, evidence: str) -> list[Finding
|
|||||||
facts, req,
|
facts, req,
|
||||||
f"Use PoE magnetics / MagJack isolation on {ref}.",
|
f"Use PoE magnetics / MagJack isolation on {ref}.",
|
||||||
mpn=comp.mpn or ""))
|
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
|
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:
|
def _blob(graph: DesignGraph | None, comp: Component) -> str:
|
||||||
parts = [
|
parts = [
|
||||||
comp.value or "",
|
comp.value or "",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from collections import Counter
|
|||||||
from backend.periscopex.derating import build_derating_table
|
from backend.periscopex.derating import build_derating_table
|
||||||
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
|
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
|
||||||
from backend.periscopex.emi_check import check_emi
|
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.finding_engine import complete_findings
|
||||||
from backend.periscopex.functional_groups import FunctionalGroupsReport
|
from backend.periscopex.functional_groups import FunctionalGroupsReport
|
||||||
from backend.periscopex.hierarchy import check_hierarchy
|
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.pi_check import check_power_integrity
|
||||||
from backend.periscopex.placement_check import check_placement
|
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.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_bus_class import check_memory_fpga_classes
|
||||||
from backend.periscopex.hf_line_check import check_hf_lines
|
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.si_check import check_si
|
||||||
from backend.periscopex.spof_check import check_spof
|
from backend.periscopex.spof_check import check_spof
|
||||||
from backend.periscopex.timing_check import check_timing
|
from backend.periscopex.timing_check import check_timing
|
||||||
@@ -283,7 +287,12 @@ def run_pcb_checks(
|
|||||||
("timing_check", lambda: check_timing(graph, constraints_map)),
|
("timing_check", lambda: check_timing(graph, constraints_map)),
|
||||||
("pi_check", lambda: check_power_integrity(graph, constraints_map, layout)),
|
("pi_check", lambda: check_power_integrity(graph, constraints_map, layout)),
|
||||||
("esd_check", lambda: check_esd(graph, constraints_map)),
|
("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)),
|
("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)),
|
("bom_pcb", lambda: check_bom_pcb_datasheet(graph, constraints_map, layout)),
|
||||||
("spof_check", lambda: check_spof(graph, constraints_map)),
|
("spof_check", lambda: check_spof(graph, constraints_map)),
|
||||||
("emi_check", lambda: check_emi(graph, constraints_map, layout)),
|
("emi_check", lambda: check_emi(graph, constraints_map, layout)),
|
||||||
|
|||||||
@@ -278,10 +278,37 @@ def check_pcb_via_current(
|
|||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
n, _drill = _via_stats(layout, net)
|
n, drill = _via_stats(layout, net)
|
||||||
if n > 0:
|
if n > 0:
|
||||||
# Geometry is present; no via-ampacity table in the library — do not
|
rec = (
|
||||||
# invent a rating or claim the vias were not parsed.
|
"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
|
continue
|
||||||
out.append(Finding(
|
out.append(Finding(
|
||||||
designator=ref,
|
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-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-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-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-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-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 |
|
| 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.
|
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)
|
## 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.
|
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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "periscope-web",
|
"name": "periscope-web",
|
||||||
"version": "2.62.0",
|
"version": "2.62.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"sync-version": "node scripts/sync-version.mjs",
|
"sync-version": "node scripts/sync-version.mjs",
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export function isLayoutFinding(f: {
|
|||||||
f.source === "emi_check" ||
|
f.source === "emi_check" ||
|
||||||
f.source === "hf_line_check" ||
|
f.source === "hf_line_check" ||
|
||||||
f.source === "hf_bus_class" ||
|
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-PLC") ||
|
||||||
rid.startsWith("PE-LAY") ||
|
rid.startsWith("PE-LAY") ||
|
||||||
rid.startsWith("PE-SI") ||
|
rid.startsWith("PE-SI") ||
|
||||||
@@ -39,6 +43,10 @@ export function isLayoutFinding(f: {
|
|||||||
rid.startsWith("PE-RET") ||
|
rid.startsWith("PE-RET") ||
|
||||||
rid.startsWith("PE-SPOF") ||
|
rid.startsWith("PE-SPOF") ||
|
||||||
rid.startsWith("PE-EMI") ||
|
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)
|
/^PE-BOM-01[0-4]$/.test(rid)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -70,6 +78,10 @@ export function isPcbExamFinding(f: {
|
|||||||
rid.startsWith("PE-RET") ||
|
rid.startsWith("PE-RET") ||
|
||||||
rid.startsWith("PE-SPOF") ||
|
rid.startsWith("PE-SPOF") ||
|
||||||
rid.startsWith("PE-EMI") ||
|
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)
|
/^PE-BOM-01[0-4]$/.test(rid)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
|
/** 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";
|
export const APP_VERSION_DATE = "2026-09-21";
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user