Compare commits

...
Author SHA1 Message Date
michele 5359729140 USB/RMII cert lines and not_reviewed count fix (2.85.0).
USB D+/D− is one English certified/not-certified line vs the packed 90 Ω
cite. RMII ETH_RXD/TXD/TXEN is identified as PHY–MAC, not RJ45 100 Ω.
J2 missing-field L1/L2/L3 noise is silent. PCB AI failures go to
review_errors; not_reviewed count matches one designator per row.
2026-09-23 06:30:28 +02:00
23 changed files with 1030 additions and 147 deletions
@@ -27,7 +27,7 @@ from backend.periscopex.impedance_traces import analyze_specified_nets
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph, LayoutVia
from backend.periscopex.pcb_net_match import kicad_nets_match, refs_on_matched_net
from backend.periscopex.protocol_catalog import load_catalog
from backend.periscopex.si_check import bus_class, partner_net
from backend.periscopex.si_check import bus_class, partner_net, phy_mac_kind, single_ended_eth_mac
log = logging.getLogger(__name__)
SOURCE = "af_trace_check"
@@ -55,6 +55,8 @@ def _mdi_mate(name: str) -> str | None:
def _is_eth_mdi_net(net: str) -> bool:
if single_ended_eth_mac(net):
return False
return bus_class(net) == "eth_mdi"
@@ -162,8 +164,8 @@ def _ethernet_mdi_finding(
label = _label(unit)
if cite is None:
text = (
f"ERROR: Ethernet pair {label} speed is not identified on the "
"PHY/jack, so no 10/100 or 1000 clause was applied."
f"Ethernet not certified — because the speed on {label} is not "
"identified on the PHY/jack, so no cited clause was applied."
)
req = "Pair Z is certified only against the cited clause for the identified speed."
rec = "Identify the PHY/jack speed. No ohm value is assumed."
@@ -185,12 +187,12 @@ def _ethernet_mdi_finding(
missing.append("geometry")
miss = "/".join(missing)
text = (
f"ERROR: pair Z for {label} cannot be calculated from the PCB "
f"({miss} missing). Cited limit {limit:g} Ω, {cite_label}."
"Ethernet not certified — because Z cannot be calculated from the "
f"PCB stackup/geometry ({miss} missing). Cited {limit:g} Ω ({cite_label})."
)
rec = (
f"Pair Z was not calculated ({miss} missing). "
f"Cited limit {limit:g} Ω, {cite_label}."
f"Cited {limit:g} Ω ({cite_label})."
)
return _finding(
rule_id="PE-AF-002", unit=unit, finding=text,
@@ -202,10 +204,10 @@ def _ethernet_mdi_finding(
inside = abs(measured - limit) <= 1e-6 * max(1.0, abs(limit))
if inside:
text = (
f"PASS: {label} pair Z {measured:g} Ω is inside the cited limit "
f"{limit:g} Ω, {cite_label}."
f"Ethernet certified — calculated Z {measured:g} Ω vs cited "
f"{limit:g} Ω ({cite_label})."
)
rec = f"Pair Z matches {limit:g} Ω ({cite_label})."
rec = f"Calculated pair Z matches cited {limit:g} Ω ({cite_label})."
return _finding(
rule_id="PE-AF-002", unit=unit, finding=text,
facts=f"nets={label}; speed={speed}; Z={measured:g} Ω; limit={limit:g} Ω.",
@@ -214,11 +216,11 @@ def _ethernet_mdi_finding(
calculation=f"pair Z {measured:g} Ω compared to {limit:g} Ω.",
)
text = (
f"FAIL: {label} pair Z {measured:g} Ω is outside the cited limit "
f"{limit:g} Ω, {cite_label}."
f"Ethernet not certified — because Z {measured:g} Ω is outside cited "
f"{limit:g} Ω ({cite_label})."
)
rec = (
f"Pair Z {measured:g} Ω is outside {limit:g} Ω ({cite_label})."
f"Calculated pair Z {measured:g} Ω is outside cited {limit:g} Ω ({cite_label})."
)
return _finding(
rule_id="PE-AF-002", unit=unit, finding=text,
@@ -244,8 +246,16 @@ def check_af_traces(
if f.rule_id == "PE-SI-002" and f.net
}
zrows = _z_rows(impedance_nets)
rmii_nets: list[str] = []
for unit in _coalesce_eth_pairs(list(iter_af_units(layout, graph))):
if any(single_ended_eth_mac(n) or phy_mac_kind(n) == "rmii" for n in unit.nets):
if not any(_is_eth_mdi_net(n) for n in unit.nets):
rmii_nets.extend(unit.nets)
continue
if unit.bus == "eth_mdi":
if any(single_ended_eth_mac(n) for n in unit.nets):
rmii_nets.extend(unit.nets)
continue
out.append(_ethernet_mdi_finding(graph, layout, unit))
continue
trig = evaluate_trigger(graph, constraints_map, layout, unit)
@@ -255,10 +265,65 @@ def check_af_traces(
if not trig.af:
continue
out.extend(_analyze_triggered(graph, constraints_map, layout, trig, zrows, seen_si_z))
if rmii_nets:
out.append(_rmii_cert_finding(graph, rmii_nets))
complete_findings(out)
return out
def _rmii_cert_finding(graph: DesignGraph, nets: list[str]) -> Finding:
"""One line: PHYMAC is RMII/MII, not RJ45 100 Ω. No invented ohm."""
uniq = sorted({n for n in nets if n})
label = " / ".join(uniq[:6]) + (" / …" if len(uniq) > 6 else "")
kinds = {phy_mac_kind(n) or "rmii" for n in uniq}
if kinds == {"mii"}:
iface = "MII"
elif "rgmii" in kinds and "rmii" not in kinds:
iface = "RGMII"
else:
iface = "RMII"
phy_refs = sorted({
ref
for net in uniq
for ref in refs_on_matched_net(graph, net)
if (graph.components.get(ref) or None) is not None
and "LAN8720" in (
(graph.components[ref].mpn or "") + (graph.components[ref].value or "")
).upper()
})
host = phy_refs[0] if phy_refs else "layout"
text = (
f"{iface} certified — {label} identified as single-ended {iface} PHYMAC "
"(not an RJ45/MDI 100 Ω differential pair). No invented ohm."
)
rec = (
f"Keep {iface} as single-ended PHYMAC. Do not apply the MDI 100 Ω pair cite."
)
return Finding(
designator=host,
mpn="",
aspect="si",
finding=text,
facts=f"nets={label}; interface={iface}; phy={','.join(phy_refs) or ''}.",
requirement=(
f"{iface} data/enable nets are single-ended MACPHY. "
"They are not certified against the RJ45/MDI 100 Ω clause."
),
inference="Interface class from net names and PHY on the nets. No Z invented.",
why=text,
status="INFO",
recommendation=rec,
action=rec,
source=SOURCE,
rule_id="PE-AF-003",
finding_class="INFO",
provenance="TYPICAL",
evidence_status="SUFFICIENT",
net=uniq[0] if uniq else None,
pins=[],
)
def _z_rows(impedance_nets: list[dict] | dict | None) -> list[dict]:
raw = impedance_nets
if isinstance(impedance_nets, dict):
@@ -272,12 +337,15 @@ def _label(unit: AfUnit) -> str:
def _skip(unit: AfUnit, missing: tuple[str, ...], extra: str = "") -> Finding:
miss = ", ".join(missing)
text = f"Pista ad alta frequenza non controllata per mancanza di {miss}."
text = (
f"HF not certified — because {miss} is missing on {_label(unit)}. "
"No invented ohm."
)
if extra:
text = f"{text} {extra}".strip()
rec = (
f"Fornire {miss} dal datasheet o dallo stackup KiCad. "
"Non si assume 50 Ω o 90 Ω."
f"Provide {miss} from the datasheet or KiCad stackup. "
"50 Ω or 90 Ω is not assumed."
)
return Finding(
designator="layout",
+55 -2
View File
@@ -149,6 +149,52 @@ def check_pcb_derating(graph: DesignGraph) -> list[Finding]:
return out
def _expand_not_reviewed(rows: list) -> list[dict]:
"""One designator per entry. Comma-joined refs become separate rows."""
out: list[dict] = []
for row in rows:
if not isinstance(row, dict):
continue
raw = str(row.get("designator") or "").strip()
reason = str(row.get("reason") or "").strip() or "not reviewed"
if not raw:
continue
parts = [p.strip() for p in raw.replace(";", ",").split(",") if p.strip()]
if not parts:
parts = [raw]
for ref in parts:
out.append({"designator": ref, "reason": reason})
return out
def _merge_not_reviewed(
schema_nr: list,
pcb_nr: list,
covered: set[str],
review_errors: dict[str, str] | None = None,
) -> list[dict]:
"""Dedupe by designator. Drop ICs that were reviewed. Count == list length.
Legacy ``pcb_review error`` rows move into ``review_errors`` when possible
and are not kept as not-reviewed.
"""
errors = review_errors if review_errors is not None else {}
merged: dict[str, str] = {}
for row in _expand_not_reviewed(list(schema_nr or []) + list(pcb_nr or [])):
ref = row["designator"]
if ref in covered:
continue
reason = row["reason"]
if reason == "pcb_review error":
errors.setdefault(ref, "pcb_review error")
continue
prev = merged.get(ref)
if prev and prev != "pcb_review error" and reason == "pcb_review error":
continue
merged[ref] = reason
return [{"designator": k, "reason": merged[k]} for k in sorted(merged)]
def merge_schema_pcb_reports(
schema: dict | None, pcb: dict | None,
) -> dict | None:
@@ -189,10 +235,17 @@ def merge_schema_pcb_reports(
if st in summary:
summary[st] = summary.get(st, 0) + 1
out["summary"] = summary
covered = set((schema or {}).get("coverage") or {}) | set((pcb or {}).get("coverage") or {})
schema_err = dict((schema or {}).get("review_errors") or {})
pcb_err = dict((pcb or {}).get("review_errors") or {})
merged_err = {**schema_err, **pcb_err}
schema_nr = list((schema or {}).get("not_reviewed") or [])
pcb_nr = list((pcb or {}).get("not_reviewed") or [])
if schema_nr or pcb_nr:
out["not_reviewed"] = schema_nr + pcb_nr
out["not_reviewed"] = _merge_not_reviewed(schema_nr, pcb_nr, covered, merged_err)
if merged_err:
out["review_errors"] = merged_err
elif "review_errors" in out and not out["review_errors"]:
out.pop("review_errors", None)
return out
@@ -4,7 +4,9 @@ Skew is compared only when the constraint is NUMERIC (length unit) or an
explicit DESIGN LIMIT. Otherwise UNKNOWN / CONTROLLER_DEPENDENT /
PHY_DEPENDENT / VENDOR_DEPENDENT. Length is not delay. Not electrical.
RECOMMENDED never FAIL. Internal interfaces are NOT_APPLICABLE.
Missing DQ/DQS grouping or missing PCB geometry is a visible skip, not PASS.
Missing DQ/DQS grouping is a visible skip, not PASS. A geometric check
with no NUMERIC or DESIGN millimetre limit does not warn: the standard
did not set a mm limit, and one is not invented.
"""
from __future__ import annotations
@@ -497,11 +499,14 @@ def l1_findings(inst: PhysicalBusInstance, results: list[L1CheckResult]) -> list
finding = f"L1 geometric {r.check}: {r.notes}"
elif skip_family:
grouping = "grouping" in (r.notes or "").lower() or "dq/dqs" in (r.notes or "").lower()
if not grouping and r.limit_mm is None:
# No NUMERIC/DESIGN millimetre limit in the pack. Silence.
continue
rule_id = "PE-PRT-L1-002" if grouping else "PE-PRT-L1-001"
status = "WARNING"
finding = r.notes or (
f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
f"per {r.check} ({r.result}). Not electrical."
f"{inst.physical_interface_id} L1 {r.check} is {r.result}. "
"Geometric only, not electrical."
)
else:
rule_id = "PE-PRT-L0-002"
+308 -35
View File
@@ -1,21 +1,23 @@
"""M4: L2 ELECTRICAL protocol certifier. Z, termination, levels, rise/fall.
Z only from datasheet / stackup / fabricator / cited pack — never invented
90 Ω USB. USB-IF/IEEE numbers only if the pack has a cite. Silicon cross is
max PCB × PHY subset when datasheet windows exist. Length is not delay.
Not L3 / OpenEMS. L0/L1 are not electrical certification.
RECOMMENDED never FAIL. MISSING_SOURCE / UNKNOWN when cite is absent.
Visible skip (PE-AF-002 style) when Z cannot be obtained.
Z only from datasheet / stackup / fabricator / cited pack. USB 2.0 HS
D+/D pair Z is compared to the packed ``pcb_trace_nominal_zdiff``
(RECOMMENDED). That number is not invented here and is not a MANDATORY
FAIL. Silicon cross is max PCB × PHY subset when datasheet windows exist.
A missing field (termination cite, rise/fall, return path, voltage) does
not emit a warning. Length is not delay. Not L3 / OpenEMS.
"""
from __future__ import annotations
import math
from typing import Any, Literal
from pydantic import BaseModel, Field
from backend.periscopex.constraints_lookup import match_constraints
from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.impedance import GeometryError, TraceGeometry, coupled_diff_z
from backend.periscopex.models import (
ComponentConstraints,
ComponentType,
@@ -23,7 +25,6 @@ from backend.periscopex.models import (
Finding,
LayoutGraph,
)
from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.protocol_catalog import (
PhysicalInterface,
ProtocolCatalog,
@@ -31,8 +32,10 @@ from backend.periscopex.protocol_catalog import (
load_catalog,
map_protocol_outcome,
)
from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.protocol_l0 import _constraint_for, _mandatory
from backend.periscopex.protocol_recognize import PhysicalBusInstance
from backend.periscopex.si_check import single_ended_eth_mac
PACK_MACROPHASE = "M4"
SOURCE = "protocol_l2"
@@ -81,6 +84,8 @@ class L2CheckResult(BaseModel):
notes: str = ""
finding_class: str = ""
status: str = ""
# "" legacy, "silent" no finding, "warn" one WARNING, "error" one ERROR.
emit: str = ""
def _pack(
@@ -95,6 +100,7 @@ def _pack(
limit_ohm_max: float | None = None,
value_kind: str = "",
notes: str = "",
emit: str = "",
) -> L2CheckResult:
if result == "FAIL" and mandatory != "MANDATORY":
result = "WARNING"
@@ -120,6 +126,7 @@ def _pack(
notes=notes,
finding_class=cls,
status=status,
emit=emit,
)
@@ -286,6 +293,24 @@ def _pack_ohm_window(cons: ProtocolConstraint | None) -> tuple[float, float] | N
return None
_NOISE_CHECKS = frozenset({
"differential_impedance",
"impedance",
"impedance_if_required",
"termination",
"cc_termination",
"voltage",
"levels",
"rise_time",
"fall_time",
"timing",
"return_path",
"magnetics",
"phy_requirements",
"ac_coupling",
})
def _skip_z(check: str, mand: MandatoryClass, inst: PhysicalBusInstance, kind: str) -> L2CheckResult:
return _pack(
check, "UNKNOWN" if kind not in {
@@ -294,10 +319,10 @@ def _skip_z(check: str, mand: MandatoryClass, inst: PhysicalBusInstance, kind: s
} else kind,
mand,
value_kind=kind,
emit="silent",
notes=(
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
f"per mancanza di Z (datasheet/stackup/fab/cited pack). "
f"Not USB/IEC folklore. L0/L1 are not electrical certification."
f"{inst.physical_interface_id} has no cited Z window "
"(datasheet, stackup, fabricator, or pack). Not a warning."
),
)
@@ -349,8 +374,8 @@ def _run_l2_check(
"differential_impedance", "impedance", "impedance_if_required",
}:
return _z_check(
check, mand, cons, kind, inst, graph, nets,
constraints_map, impedance_nets,
check, mand, cons, kind, inst, iface, graph, nets,
constraints_map, impedance_nets, layout,
)
if check in {"termination", "cc_termination"}:
@@ -362,10 +387,10 @@ def _run_l2_check(
if check in {"rise_time", "fall_time", "timing"}:
return _pack(
check, "UNKNOWN" if kind in {"NUMERIC", "UNKNOWN", ""} else kind, mand,
nets=nets, value_kind=kind,
nets=nets, value_kind=kind, emit="silent",
notes=(
"Length is not delay. Rise/fall/timing UNKNOWN without a cited "
"time source (no tpd invented from mm). Not L3."
"Length is not delay. Rise/fall/timing stays UNKNOWN without a cited "
"time source (no tpd invented from mm). Not L3. Not a warning."
),
)
@@ -374,27 +399,234 @@ def _run_l2_check(
if verdict == "NUMERIC":
verdict = "UNKNOWN"
return _pack(
check, verdict, mand, nets=nets, value_kind=kind,
check, verdict, mand, nets=nets, value_kind=kind, emit="silent",
notes=(
f"{check} not certified without a cited electrical source. "
f"Not invented. L0/L1 are not electrical certification."
f"{check} has no cited electrical source. "
"Not invented. Not a warning."
),
)
return _pack(check, "UNKNOWN", mand, value_kind=kind, notes="Not an L2 electrical check.")
def _pcb_zdiff_cite(iface: PhysicalInterface) -> ProtocolConstraint | None:
"""Packed board Zdiff recommendation. Never a number typed in this module."""
for c in iface.constraints:
if c.parameter != "pcb_trace_nominal_zdiff":
continue
if c.value_kind != "NUMERIC" or c.value is None:
continue
unit = (c.unit or "").strip().lower()
if unit not in _OHM_UNITS:
continue
return c
return None
def _usb_dp_dm(inst: PhysicalBusInstance) -> tuple[str, str] | None:
for g in inst.groups:
if g.kind != "DIFFERENTIAL_PAIR" or len(g.nets) < 2:
continue
dp = g.roles.get("D+") or g.nets[0]
dm = g.roles.get("D-") or g.nets[1]
if single_ended_eth_mac(dp) or single_ended_eth_mac(dm):
return None
return dp, dm
return None
def _cite_label(cons: ProtocolConstraint) -> str:
src = cons.source
bits: list[str] = []
if src is not None:
if src.document:
bits.append(src.document)
if src.section:
bits.append(f"§{src.section}")
if src.page:
bits.append(f"p.{src.page}")
return ", ".join(bits) if bits else "cited pack"
def _zdiff_measured(nets: list[str], impedance_nets: list[dict] | dict | None) -> float | None:
"""Pair Z only. Single-ended Z0 is not a differential result."""
rows = _z_rows(impedance_nets)
vals: list[float] = []
for net in nets:
row = _row_for(rows, net)
if not row:
continue
for key in ("zdiff_ohm", "zdiff_avg_ohms", "zdiff_ohms"):
v = _num(row.get(key))
if v is not None and v > 0:
vals.append(v)
break
if not vals:
return None
return sum(vals) / len(vals)
def _median_width_mm(layout: LayoutGraph, net: str) -> float | None:
widths = sorted(
s.width for s in layout.segments
if s.net and kicad_nets_match(s.net, net) and s.width > 0
)
if not widths:
return None
return widths[len(widths) // 2]
def _pair_gap_mm(layout: LayoutGraph, a: str, b: str) -> float | None:
sa = [s for s in layout.segments if s.net and kicad_nets_match(s.net, a) and s.width > 0]
sb = [s for s in layout.segments if s.net and kicad_nets_match(s.net, b) and s.width > 0]
gaps: list[float] = []
for x in sa:
for y in sb:
if x.layer and y.layer and x.layer != y.layer:
continue
for px, py in (
(x.start, y.start), (x.start, y.end),
(x.end, y.start), (x.end, y.end),
):
edge = math.hypot(px[0] - py[0], px[1] - py[1]) - x.width / 2.0 - y.width / 2.0
if edge > 1e-6:
gaps.append(edge)
if not gaps:
return None
return min(gaps)
def usb_pair_z_ohm(
layout: LayoutGraph | None,
nets: tuple[str, str],
impedance_nets: list[dict] | dict | None,
) -> tuple[float | None, str]:
"""Calculated differential Z for one pair.
Returns (ohms, missing). missing is empty when ohms is set, otherwise
``stackup``, ``geometry``, or ``stackup/geometry``.
"""
stored = _zdiff_measured(list(nets), impedance_nets)
if stored is not None:
return stored, ""
has_copper = bool(
layout is not None and any(
s.net and s.width > 0 and (
kicad_nets_match(s.net, nets[0]) or kicad_nets_match(s.net, nets[1])
)
for s in layout.segments
)
)
stack = layout.stackup if layout is not None else None
dielectric = stack.dielectrics[0] if stack and stack.dielectrics else None
stack_ok = (
stack is not None
and dielectric is not None
and dielectric.er > 0
and dielectric.height_mm > 0
and stack.copper_thickness_mm is not None
and stack.copper_thickness_mm > 0
)
if not stack_ok:
return None, "stackup/geometry" if not has_copper else "stackup"
assert layout is not None and stack is not None and dielectric is not None
wa = _median_width_mm(layout, nets[0])
wb = _median_width_mm(layout, nets[1])
gap = _pair_gap_mm(layout, nets[0], nets[1])
if wa is None or wb is None or gap is None:
return None, "geometry"
try:
_zodd, _zeven, zdiff = coupled_diff_z(TraceGeometry(
h=dielectric.height_mm,
er=dielectric.er,
t=stack.copper_thickness_mm,
w=(wa + wb) / 2.0,
s=gap,
))
except GeometryError:
return None, "geometry"
if zdiff <= 0:
return None, "geometry"
return zdiff, ""
def _matches_cited(measured: float, cited: float) -> bool:
return abs(measured - cited) <= 1e-6 * max(1.0, abs(cited))
def _usb_line(kind: str, *, measured: float | None, cited: float, label: str) -> str:
"""One report line. The ohm value is the packed cite, not a second number."""
if kind == "certified" and measured is not None:
return (
f"USB certified — calculated Z {measured:g} Ω vs cited {cited:g} Ω ({label})."
)
if measured is not None:
return (
f"USB not certified — because Z {measured:g} Ω is outside cited "
f"{cited:g} Ω ({label})."
)
return (
"USB not certified — because Z cannot be calculated from the PCB "
f"stackup/geometry. Cited {cited:g} Ω ({label})."
)
def _usb_board_z(
check: str,
inst: PhysicalBusInstance,
cite: ProtocolConstraint,
pair: tuple[str, str],
layout: LayoutGraph | None,
impedance_nets: list[dict] | dict | None,
) -> L2CheckResult:
"""One line: calculated pair Z vs the packed board cite."""
cited = float(cite.value) # type: ignore[arg-type]
label = _cite_label(cite)
measured, _missing = usb_pair_z_ohm(layout, pair, impedance_nets)
mand: MandatoryClass = "RECOMMENDED"
if measured is None:
return L2CheckResult(
check=check, result="FAIL", mandatory=mand, nets=list(pair),
limit_ohm=cited, value_kind="NUMERIC", emit="not_certified",
notes=_usb_line("missing", measured=None, cited=cited, label=label),
finding_class="RULE", status="ERROR",
)
if _matches_cited(measured, cited):
return L2CheckResult(
check=check, result="PASS", mandatory=mand, nets=list(pair),
measured_ohm=measured, limit_ohm=cited,
limit_ohm_min=cited, limit_ohm_max=cited,
value_kind="NUMERIC", emit="certified",
notes=_usb_line("certified", measured=measured, cited=cited, label=label),
finding_class="INFO", status="INFO",
)
return L2CheckResult(
check=check, result="FAIL", mandatory=mand, nets=list(pair),
measured_ohm=measured, limit_ohm=cited,
limit_ohm_min=cited, limit_ohm_max=cited,
value_kind="NUMERIC", emit="not_certified",
notes=_usb_line("outside", measured=measured, cited=cited, label=label),
finding_class="RULE", status="ERROR",
)
def _z_check(
check: str,
mand: MandatoryClass,
cons: ProtocolConstraint | None,
kind: str,
inst: PhysicalBusInstance,
iface: PhysicalInterface,
graph: DesignGraph,
nets: list[str],
constraints_map: dict[str, ComponentConstraints] | None,
impedance_nets: list[dict] | dict | None,
layout: LayoutGraph | None,
) -> L2CheckResult:
pair = _usb_dp_dm(inst)
cite = _pcb_zdiff_cite(iface)
phy = _phy_z_windows(graph, inst, constraints_map)
if cite is not None and pair is not None and not phy:
return _usb_board_z(check, inst, cite, pair, layout, impedance_nets)
pack_w = _pack_ohm_window(cons)
windows: list[tuple[float, float]] = []
if pack_w:
@@ -403,7 +635,6 @@ def _z_check(
window = intersect_windows(windows) if windows else None
measured = measured_z_ohm(nets, impedance_nets)
if window is None:
# no cited pack number and no datasheet PHY window → never invent 90 Ω
if measured is None:
return _skip_z(check, mand, inst, kind)
return _pack(
@@ -413,15 +644,15 @@ def _z_check(
"VENDOR_DEPENDENT", "UNKNOWN",
} else "UNKNOWN",
mand,
nets=nets, measured_ohm=measured, value_kind=kind,
nets=nets, measured_ohm=measured, value_kind=kind, emit="silent",
notes=(
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
f"per mancanza di Z target (cited pack or PHY datasheet). "
f"Measured stackup/fab Z is not compared to invented USB/IEC ohms. "
f"L0/L1 are not electrical certification."
f"{inst.physical_interface_id} has measured Z and no cited target. "
"Not compared to an invented ohm. Not a warning."
),
)
if measured is None:
if cite is not None and pair is not None:
return _usb_board_z(check, inst, cite, pair, layout, impedance_nets)
return _skip_z(check, mand, inst, kind)
verdict = electrical_verdict(cons, measured, window)
source_note = (
@@ -474,10 +705,10 @@ def _termination_check(
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
} else "UNKNOWN"
return _pack(
check, verdict, mand, nets=nets, value_kind=kind,
check, verdict, mand, nets=nets, value_kind=kind, emit="silent",
notes=(
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
f"per mancanza di termination cite. No invented Rd/ohm."
f"{inst.physical_interface_id} has no termination cite. "
"No invented resistor. Not a warning."
),
)
measured = None
@@ -517,8 +748,8 @@ def _levels_check(
unit = (cons.unit or "").strip().lower()
if unit in _VOLT_UNITS:
return _pack(
check, "UNKNOWN", mand, value_kind="NUMERIC",
notes="Voltage NUMERIC in pack but PCB rail FACT not supplied — not invented 3.3 V.",
check, "UNKNOWN", mand, value_kind="NUMERIC", emit="silent",
notes="Voltage NUMERIC in pack but PCB rail FACT not supplied — not invented 3.3 V. Not a warning.",
)
if constraints_map:
for ref in [inst.host_ref, *inst.peer_refs]:
@@ -528,10 +759,10 @@ def _levels_check(
ds = match_constraints(comp.mpn or comp.value, constraints_map)
if ds and ds.absolute_maximum_ratings:
return _pack(
check, "UNKNOWN", mand, value_kind=kind,
check, "UNKNOWN", mand, value_kind=kind, emit="silent",
notes=(
"Silicon abs-max present; L2 levels need a cited operating "
"window vs measured rail — not assumed. Not L3."
"window vs measured rail — not assumed. Not L3. Not a warning."
),
)
verdict = kind if kind in {
@@ -539,15 +770,58 @@ def _levels_check(
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
} else "UNKNOWN"
return _pack(
check, verdict, mand, value_kind=kind,
notes="Levels/voltage UNKNOWN without a cited electrical source. Not invented.",
check, verdict, mand, value_kind=kind, emit="silent",
notes="Levels/voltage UNKNOWN without a cited electrical source. Not invented. Not a warning.",
)
def _pair_line_finding(inst: PhysicalBusInstance, r: L2CheckResult) -> Finding:
"""Certified or not certified. English. Not the datasheet boilerplate."""
designator = inst.host_ref or inst.physical_interface_id
certified = r.emit == "certified"
text = r.notes
if certified:
status = "INFO"
cls = "INFO"
action = "Calculated pair Z matches the cited limit."
else:
status = "ERROR"
cls = "RULE"
action = "Calculated pair Z is not certified. The finding states the reason."
f = Finding(
designator=designator,
mpn="",
aspect="protocol_l2",
finding=text,
why=text,
status=status, # type: ignore[arg-type]
source=SOURCE,
net=r.nets[0] if r.nets else None,
rule_id="PE-PRT-L2-002",
facts=text,
requirement=text,
inference="Calculated differential Z compared with the packed cite.",
provenance="MANDATORY",
finding_class=cls, # type: ignore[arg-type]
evidence_status="SUFFICIENT",
recommendation=action,
action=action,
)
return complete_finding(f)
def l2_findings(inst: PhysicalBusInstance, results: list[L2CheckResult]) -> list[Finding]:
out: list[Finding] = []
designator = inst.host_ref or inst.physical_interface_id
for r in results:
if r.emit in {"certified", "not_certified"}:
out.append(_pair_line_finding(inst, r))
continue
if r.emit == "silent" or (r.result in {
"UNKNOWN", "VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT",
"PHY_DEPENDENT", "MISSING_SOURCE",
} and r.check in _NOISE_CHECKS):
continue
if r.result == "PASS":
continue
if r.result == "FAIL" and r.mandatory != "MANDATORY":
@@ -568,8 +842,7 @@ def l2_findings(inst: PhysicalBusInstance, results: list[L2CheckResult]) -> list
rule_id = "PE-PRT-L2-001"
status = "WARNING"
finding = r.notes or (
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
f"per {r.check} ({r.result})."
f"{inst.physical_interface_id} L2 {r.check} is {r.result}."
)
else:
rule_id = "PE-PRT-L0-002"
@@ -509,9 +509,28 @@ def certify_instance_l3(
if c in L3_CHECKS and c not in checks:
checks.append(c)
facts = lookup_channel_facts(inst, channel_data)
if not _channel_fact_present(facts):
return []
return [_run_l3_check(inst, iface, c, facts) for c in checks]
def _channel_fact_present(facts: dict[str, Any]) -> bool:
"""True only when reduced channel FACT is already on hand."""
if not facts:
return False
if _looks_like_raw_sparam(facts):
return False
if _blob_mentions_fem(facts.get("method") or facts.get("solver") or ""):
return False
if _il_measured(facts) is not None or _rl_measured(facts) is not None:
return True
if _xt_measured(facts) is not None:
return True
if _num(facts.get("total_budget_ps")) is not None:
return True
return facts.get("channel_complete") is True
def l3_findings(inst: PhysicalBusInstance, results: list[L3CheckResult]) -> list[Finding]:
out: list[Finding] = []
designator = inst.host_ref or inst.physical_interface_id
@@ -533,12 +552,8 @@ def l3_findings(inst: PhysicalBusInstance, results: list[L3CheckResult]) -> list
status = "ERROR"
finding = f"L3 channel {r.check}: {r.notes}"
elif skip_family:
rule_id = "PE-PRT-L3-001"
status = "WARNING"
finding = r.notes or (
f"Interfaccia {inst.physical_interface_id} non certificata a L3 "
f"per {r.check} ({r.result})."
)
# No channel FACT, or a field with nothing to certify. Omit the warning.
continue
else:
rule_id = "PE-PRT-L0-002"
status = "INFO"
@@ -114,6 +114,12 @@ def check_to_report_row(
result = str(dump.get("result") or "")
cons = _constraint_for(iface, check) if iface is not None else None
doc, section, method = _cite(cons)
if check == "differential_impedance" and not doc and iface is not None:
for alt in iface.constraints:
if alt.parameter == "pcb_trace_nominal_zdiff" and alt.source is not None:
doc, section, method = _cite(alt)
cons = alt
break
nets = list(dump.get("nets") or [])
net = nets[0] if nets else (inst.nets[0] if inst.nets else "")
group = _group_for_net(inst, net or None)
@@ -130,6 +136,7 @@ def check_to_report_row(
"source": doc,
"method": method,
"notes": dump.get("notes") or "",
"emit": dump.get("emit") or "",
"skip_visible": skip,
"rank": result_rank(result),
"chain": {
@@ -181,6 +188,21 @@ def format_chain(chain: dict[str, Any]) -> str:
return "".join(parts)
def _noise_row(row: dict[str, Any]) -> bool:
"""Missing-field skips are not a certification line."""
if row.get("emit") == "silent":
return True
result = str(row.get("result") or "")
if result not in _SKIP:
return False
notes = str(row.get("notes") or "").lower()
if "grouping" in notes or "dq/dqs" in notes:
return False
if row.get("measured") is not None or row.get("limit") is not None:
return False
return True
def attach_instance_report(
inst: PhysicalBusInstance,
iface: PhysicalInterface | None,
@@ -195,9 +217,7 @@ def attach_instance_report(
+ [c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in l2]
+ [c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in (l3 or [])]
)
rows = [check_to_report_row(d, inst, iface) for d in dumps]
if not any(r["level"] == "L3" for r in rows):
rows.append(l3_not_run_row(inst))
rows = [r for r in (check_to_report_row(d, inst, iface) for d in dumps) if not _noise_row(r)]
rows.sort(key=lambda r: (r["rank"], str(r["level"]), str(r["check"])))
worst = instance_worst_result([str(r["result"]) for r in rows if r["level"] != "L3"])
# L3 skip/UNKNOWN must not upgrade instance to PASS or hide FAIL
+43 -4
View File
@@ -42,7 +42,12 @@ _HS_CLASS_RE = (
("hdmi", re.compile(r"HDMI", re.I)),
("pcie", re.compile(r"PCIE|PEX_", re.I)),
("sgmii", re.compile(r"SGMII", re.I)),
("rgmii", re.compile(r"RGMII|(?:^|[_/])GMII", re.I)),
("rgmii", re.compile(r"RGMII|(?:^|[_/])GMII|TX_CTL|RX_CTL|(?:ETH|MAC).*GTX", re.I)),
("rmii", re.compile(
r"(?:^|[_/])RMII|"
r"(?:ETH|MAC).*(?:TXD|RXD|TXEN|RXDV|CRS_DV|REF_CLK)",
re.I,
)),
("eth_mdi", re.compile(
r"(?:^|[_/])(MDI|TRD[0-3]|TCT|RCT)|"
r"ETH.?(?:TD|RD|TX|RX|TP)[+\-_PN0-3]|1000BASE|RJ45",
@@ -92,8 +97,10 @@ _BUS_TOKEN_EXPAND: dict[str, frozenset[str]] = {
"magnetics": frozenset({"eth_mdi"}),
"rgmii": frozenset({"rgmii"}),
"gmii": frozenset({"rgmii"}),
"mac_phy": frozenset({"rgmii", "sgmii"}),
"mac": frozenset({"rgmii", "sgmii"}),
"rmii": frozenset({"rmii"}),
"mii": frozenset({"rmii"}),
"mac_phy": frozenset({"rmii", "rgmii", "sgmii"}),
"mac": frozenset({"rmii", "rgmii", "sgmii"}),
"sgmii": frozenset({"sgmii"}),
"ddr": frozenset({"ddr3_clk", "ddr3_dqs", "ddr3_dq", "ddr3_addr"}),
"ddr3": frozenset({"ddr3_clk", "ddr3_dqs", "ddr3_dq", "ddr3_addr"}),
@@ -143,6 +150,31 @@ def partner_net(name: str) -> str | None:
return None
# RMII/MII data and TX enable. Not an MDI pair. Not a 100 Ω differential net.
_SINGLE_ENDED_ETH_RE = re.compile(
r"(?:^|[^A-Za-z0-9])(?:ETH_|MAC_)?(?:RXD|TXD|TXEN|RXDV|CRS_DV)(?:\d+)?(?:[^A-Za-z0-9]|$)",
re.I,
)
def single_ended_eth_mac(net: str) -> bool:
"""ETH_RXD / ETH_TXD / ETH_TXEN are single-ended. Not a differential pair."""
leaf = _leaf(net)
return bool(_SINGLE_ENDED_ETH_RE.search(leaf) or _SINGLE_ENDED_ETH_RE.search(net or ""))
def phy_mac_kind(net: str) -> str | None:
"""``rmii``, ``rgmii``, or ``mii`` for a PHYMAC net. Never ``eth_mdi``."""
leaf = _leaf(net).upper()
if re.search(r"RGMII|(?:^|[_/])GMII|TX_CTL|RX_CTL|(?:ETH|MAC).*GTX", leaf):
return "rgmii"
if single_ended_eth_mac(net) or "RMII" in leaf:
if re.search(r"(?:^|[_/])MII(?:$|[_/])", leaf) and "RMII" not in leaf:
return "mii"
return "rmii"
return None
def _leaf(net: str) -> str:
n = normalize_kicad_hierarchy_net(net)
return n.split("/")[-1] if n else ""
@@ -173,8 +205,10 @@ def bus_class(net: str) -> str | None:
return "usb3"
if "USB" in u and re.search(r"(D\+|D-|DP|DM)", leaf, re.I):
return "usb2"
if re.search(r"(?:ETH|MAC).*(TXD|RXD|TXC|RXC|TX_CLK|RX_CLK|TX_CTL|RX_CTL|TXEN|RXDV|GTX)", u):
if re.search(r"RGMII|(?:^|[_/])GMII|TX_CTL|RX_CTL|(?:ETH|MAC).*GTX", u):
return "rgmii"
if re.search(r"(?:ETH|MAC).*(TXD|RXD|TXC|RXC|TX_CLK|RX_CLK|TXEN|RXDV|CRS_DV|REF_CLK)|(?:^|[_/])RMII", u):
return "rmii"
if re.search(r"(?:^|[_/])ETH(?:$|[_/])", u) and re.search(r"[+\-]|_P$|_N$|_P/|_N/", leaf):
return "eth_mdi"
for cls, cre in _HS_CLASS_RE:
@@ -228,6 +262,7 @@ def _rule_target_buses(rule: dict) -> frozenset[str]:
scans: tuple[tuple[str, str], ...] = (
(r"super\s*speed|usb\s*3|sstx|ssrx", "usb3"),
(r"rgmii|gtx_clk|tx_ctl|rx_ctl", "rgmii"),
(r"\brmii\b|\bmii\b|txen|rxdv|crs_dv", "rmii"),
(r"sgmii", "sgmii"),
(r"mdi|rj-?45|magnetics|trd[0-3]|1000\s*base", "eth_mdi"),
(r"ddr3|\bddr\b", "ddr3"),
@@ -358,6 +393,8 @@ def _rule_nets(layout: LayoutGraph, rule: dict, graph: DesignGraph) -> list[str]
if targets:
if not _bus_in_targets(bc, targets):
continue
if single_ended_eth_mac(net) and "eth_mdi" in targets:
continue
else:
# No bus on the quote: pin-scoped only. Never paint USB/DDR/PHY.
if not pin:
@@ -530,6 +567,8 @@ def check_si(
want_diff = _num(rule.get("zdiff_ohm")) is not None or kind == "zdiff"
paired: set[tuple[str, str]] = set()
for net in nets:
if want_diff and single_ended_eth_mac(net):
continue
zrow = _z_row(rows, net)
partner = _partner_on_board(layout, net, zrow) if want_diff else None
key_net = _pair_key(net, partner) if partner else (net, "")
+20 -12
View File
@@ -154,16 +154,15 @@ def si_extract_needed_skips(
}
if not needs_layout_rules_refresh(payload, min_scan_version=scan_ver):
continue
refs = ",".join(sorted(refs_by_mpn.get(mpn, []))) or mpn
out.append({
"designator": refs,
"reason": (
f"SI layout_rules empty at model_version="
f"{cons.model_version}; re-run schematic review "
f"to re-extract pintable {scan_ver} "
"(PCB does not re-read the PDF)."
),
})
refs = sorted(refs_by_mpn.get(mpn, [])) or [mpn]
reason = (
f"SI layout_rules empty at model_version="
f"{cons.model_version}; re-run schematic review "
f"to re-extract pintable {scan_ver} "
"(PCB does not re-read the PDF)."
)
for ref in refs:
out.append({"designator": ref, "reason": reason})
return out
@@ -373,6 +372,7 @@ async def run_pcb_pipeline(
_step(project_id, "ai_review", "running", "layout vs shared library extraction")
coverage: dict[str, list[str]] = {}
skipped: list[dict] = []
review_errors: dict[str, str] = {}
try:
from backend.services.api_logs import ApiLogger
from backend.services.pcb_validation import review_pcb_ics
@@ -387,7 +387,7 @@ async def run_pcb_pipeline(
f"{ref} {tool} {detail}".strip(),
)
ai_findings, coverage, skipped = await review_pcb_ics(
ai_findings, coverage, skipped, review_errors = await review_pcb_ics(
graph, cmap, layout, plan, inventory,
pdf_dir, storage=storage, api_logger=logger_api,
on_progress=_prog,
@@ -396,6 +396,7 @@ async def run_pcb_pipeline(
logger_api.flush(storage, user_id, project_id)
detail = (
f"{len(ai_findings)} AI findings, {len(skipped)} skipped"
+ (f", {len(review_errors)} review errors" if review_errors else "")
)
except Exception:
logger.exception("PCB AI review failed — keeping deterministic findings")
@@ -427,13 +428,20 @@ async def run_pcb_pipeline(
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
for f in findings:
summary[f.status] = summary.get(f.status, 0) + 1
# Drop not_reviewed entries for ICs that were actually reviewed.
covered = set(coverage)
not_reviewed = [
row for row in (skipped + si_skip)
if str(row.get("designator") or "") not in covered
]
report = ValidationReport(
project=project_id,
timestamp=datetime.now(timezone.utc).isoformat(),
findings=findings,
summary=summary,
coverage=coverage,
not_reviewed=skipped + si_skip,
review_errors=review_errors,
not_reviewed=not_reviewed,
protocol_certification=proto_section.model_dump(),
)
report_path = ws.local_path("pcb_report.json")
@@ -54,15 +54,20 @@ async def review_pcb_ics(
storage=None,
api_logger: ApiLogger | None = None,
on_progress=None,
) -> tuple[list[Finding], dict[str, list[str]], list[dict]]:
) -> tuple[list[Finding], dict[str, list[str]], list[dict], dict[str, str]]:
"""Layout-only AI exam using ``library/extracted`` (or project extracted/).
Does not attach a datasheet PDF. ICs without a library pintable are skipped
with a reason to run schematic review first.
Returns ``(findings, coverage, not_reviewed, review_errors)``.
Real review exceptions go in ``review_errors`` never as
``not_reviewed`` with the blanket label ``pcb_review error``.
"""
findings: list[Finding] = []
coverage: dict[str, list[str]] = {}
skipped: list[dict] = []
review_errors: dict[str, str] = {}
cache: dict = {}
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
@@ -102,15 +107,21 @@ async def review_pcb_ics(
system_prompt=PCB_SYSTEM_PROMPT,
log_stage="pcb_review",
)
except Exception:
log.exception("PCB AI review failed for %s — skipping", ref)
skipped.append({"designator": ref, "reason": "pcb_review error"})
except Exception as exc:
msg = f"{type(exc).__name__}: {exc}"
log.exception("PCB AI review failed for %s — recording review_errors", ref)
review_errors[ref] = msg
continue
if not isinstance(result, ReviewResult):
# Prefer a visible false positive over a silent miss.
skipped.append({
"designator": ref,
"reason": "PCB review returned no result",
})
continue
_ensure_recs(result.findings)
annotate_findings_cad(result.findings, cad_index_from_graph(graph))
findings.extend(result.findings)
if result.checked_areas:
coverage[ref] = list(result.checked_areas)
return findings, coverage, skipped
# Empty checked_areas still means the IC was reviewed (no issues).
coverage[ref] = list(result.checked_areas or ["layout"])
return findings, coverage, skipped, review_errors
@@ -2,6 +2,19 @@
What's new in Periscope.
## 2.85.0 — 2026-09-23 — USB / RMII cert lines; not_reviewed count
USB 2.0 D+/D is one line: calculated pair Z versus the packed 90 Ω board cite. Certified names the measured ohm and the cite. Not certified says why — Z is outside that cite, or Z cannot be calculated from the PCB stackup/geometry. Missing length, stub, via, termination, rise/fall, and channel fields do not each warn. L3 is omitted when no channel fact is on file.
LAN8720A ETH_RXD / ETH_TXD / ETH_TXEN are RMII (single-ended PHYMAC), not RJ45/MDI 100 Ω. One line: `RMII certified — …`. No Italian «mancanza di tr, f» on those nets. RJ45 pairs keep `Ethernet certified / not certified`.
`not_reviewed` count matches the list (one designator per row). Real PCB AI failures go to `review_errors` with the exception message — not a blanket `pcb_review error` on every skip. ICs already in coverage are not listed as not-reviewed.
- [Changed] `protocol_l1.py`, `protocol_l2.py`, `protocol_l3.py`, `protocol_report.py`, `af_trace_check.py`, `si_check.py`.
- [Changed] `pcb_validation.py`, `pcb_pipeline.py`, `pcb_checks.py` merge; report UI copy.
- [Changed] Protocol tree shows the certification sentence.
- [Changed] pytest `tests/pcb/test_j2_usb_cert_line.py`, `test_rmii_cert_line.py`, `test_not_reviewed_count.py`.
## 2.84.0 — 2026-09-22 — Exposed-pad names, ESD cites, CC-via-net, Ethernet pair Z
PE-BOM-011 treats EP, EPAD, THERMAL PAD, exposed pad, and the footprint's exposed land (including pin_count+1) as the same pad when both sides have one. A datasheet exposed pad with no land on the footprint stays ERROR.
@@ -328,7 +328,10 @@ function ReportContent({ projectId }: { projectId: string }) {
{report.not_reviewed.length} component{report.not_reviewed.length === 1 ? "" : "s"} not reviewed
</p>
<p className="text-xs text-muted-foreground">
These components have no datasheet on file, so they were not checked against one. A reversed or mis-wired pin on an unreviewed part (e.g. a DNP footprint with no BOM entry) cannot be caught here verify these manually.
Each line is one designator. The reason is per part missing
datasheet, missing library extraction, or SI re-extract needed.
Parts that were reviewed are not listed here. Review failures
(with an error message) appear under failed reviews above.
</p>
<ul className="space-y-1 text-xs">
{report.not_reviewed.map((nr) => (
@@ -21,8 +21,16 @@ export function ProtocolInstanceBody({ instance }: { instance: ProtocolTreeInsta
const fail = instance.worst === "FAIL" || instance.status === "ERROR";
const logical = instance.logicalId;
const physical = instance.physicalId;
const certLine = checks.find((c) =>
/^(USB|Ethernet|RMII|MII|RGMII) (certified|not certified) —/.test(c.notes || ""),
);
return (
<div className="space-y-2 py-2 pr-2">
{certLine?.notes ? (
<p className="text-[13px] font-medium tracking-tight text-neutral-900 dark:text-neutral-100">
{certLine.notes}
</p>
) : null}
<p className="text-[11px] tabular-nums text-neutral-500">
{logical} {physical}
{instance.recognition ? ` · ${instance.recognition}` : ""}
@@ -71,7 +71,7 @@ test("protocol FAIL finding stays in Error folder with WARNING skips on same ref
status: "WARNING",
rule_id: "PE-PRT-L2-001",
source: "protocol_l2",
finding: "non certificata a L2 per mancanza di Z",
finding: "USB not certified — because Z cannot be calculated from the PCB stackup/geometry. Cited 90 Ω.",
}),
]);
assert.equal(groups[0].label, "Error");
+4 -2
View File
@@ -142,12 +142,14 @@ def test_missing_tr_f_is_visible_skip_no_ohm():
assert all(f.rule_id == "PE-AF-002" for f in out)
assert len(out) == 1
text = out[0].finding
assert "Pista ad alta frequenza non controllata per mancanza di" in text
assert text.startswith("HF not certified — because")
assert "tr" in text and "f" in text
assert "mancanza" not in text
assert out[0].evidence_status == "INSUFFICIENT"
assert out[0].finding_class == "REVIEW"
blob = (out[0].finding + out[0].facts).lower()
assert "90" not in blob and "50" not in blob and "ω" not in blob and "ohm" not in blob
assert "90 Ω" not in blob and "50 Ω" not in blob
assert "invented" in blob
def test_short_pair_with_tr_is_not_af():
+7 -5
View File
@@ -328,11 +328,12 @@ def test_lan8720_pair_without_z_is_error_with_100base_cite(monkeypatch):
f = findings[0]
assert f.status == "ERROR"
blob = f"{f.finding} {f.action} {f.requirement}"
assert blob.startswith("ERROR:") or "ERROR:" in f.finding
assert f.finding.startswith("Ethernet not certified — because Z cannot be calculated")
assert "100" in blob and "25.4.9" in blob
assert "stackup" in blob
assert "Fornire" not in blob
assert "tr" not in blob.lower()
assert "mancanza di tr" not in blob.lower()
assert "mancanza di f" not in blob.lower()
assert "90" not in blob
assert "Clause 40" not in blob
assert "1000BASE" not in blob
@@ -342,16 +343,17 @@ def test_lan8720_pair_without_z_is_error_with_100base_cite(monkeypatch):
def test_lan8720_pair_z_inside_cite_is_pass(monkeypatch):
findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, 100.0)
assert len(findings) == 1
assert findings[0].finding.startswith("PASS:")
assert findings[0].finding.startswith("Ethernet certified —")
assert findings[0].status == "INFO"
assert "RJ45_TXP" in findings[0].finding and "RJ45_TXN" in findings[0].finding
assert "RJ45_TXP" in (findings[0].facts or "") or "RJ45_TXP" in findings[0].finding
assert "RJ45_TXN" in (findings[0].facts or "") or "RJ45_TXN" in findings[0].finding
assert "25.4.9" in findings[0].finding
def test_lan8720_pair_z_outside_cite_is_fail(monkeypatch):
findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, 90.0)
assert len(findings) == 1
assert findings[0].finding.startswith("FAIL:")
assert findings[0].finding.startswith("Ethernet not certified — because Z")
assert findings[0].status == "ERROR"
assert "PASS" not in findings[0].finding
assert "25.4.9" in findings[0].finding
+134
View File
@@ -0,0 +1,134 @@
"""USB Type-C D+/D one-line certification; J2 has no WARNING wall."""
from __future__ import annotations
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
LayoutDielectric,
LayoutGraph,
LayoutSegment,
LayoutStackup,
Net,
NetType,
PinConnection,
ResistorSpecs,
)
from backend.periscopex.protocol_l0 import protocol_exam
from backend.periscopex.protocol_l2 import certify_l2, usb_pair_z_ohm
from backend.periscopex.protocol_recognize import recognize_physical_buses
def _ic(ref: str, pins: dict[str, str], *, mpn: str = "") -> Component:
return Component(
reference=ref, value=mpn or ref, footprint="",
component_type=ComponentType.IC, mpn=mpn or None, pins=pins,
)
def _net(name: str, *pairs: tuple[str, str]) -> Net:
return Net(
name=name, net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
)
def _usb_c_graph() -> DesignGraph:
return DesignGraph(
components={
"U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}, mpn="CH340E"),
"J2": Component(
reference="J2", value="USB_C",
footprint="Connector_USB:USB_C_Receptacle",
component_type=ComponentType.CONNECTOR,
pins={"A6": "USB_D+", "A7": "USB_D-", "A5": "CC1", "B5": "CC2",
"A4": "VBUS", "A1": "GND"},
),
"R1": Component(
reference="R1", value="5.1k", footprint="",
component_type=ComponentType.RESISTOR,
pins={"1": "CC1", "2": "GND"},
specs=ResistorSpecs(value_ohms=5100.0, value_formatted="5.1k"),
),
"R2": Component(
reference="R2", value="5.1k", footprint="",
component_type=ComponentType.RESISTOR,
pins={"1": "CC2", "2": "GND"},
specs=ResistorSpecs(value_ohms=5100.0, value_formatted="5.1k"),
),
},
nets={
"USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "A6")),
"USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "A7")),
"CC1": _net("CC1", ("J2", "A5"), ("R1", "1")),
"CC2": _net("CC2", ("J2", "B5"), ("R2", "1")),
"VBUS": _net("VBUS", ("J2", "A4")),
"GND": Net(
name="GND", net_type=NetType.GROUND,
pins=[
PinConnection(component_ref="J2", pin_number="A1"),
PinConnection(component_ref="R1", pin_number="2"),
PinConnection(component_ref="R2", pin_number="2"),
],
),
},
)
def _stack() -> LayoutStackup:
return LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="core", er=4.5, height_mm=0.15)],
copper_thickness_mm=0.035,
)
def test_usb_missing_z_is_one_not_certified_line():
sec, findings = protocol_exam(_usb_c_graph())
usb = [i for i in sec.recognized_instances if "usb" in (i.get("logical_protocol_id") or "")]
assert usb
notes = [
c.get("notes") or ""
for row in usb
for c in (row.get("report_checks") or row.get("l2_checks") or [])
]
cert = [n for n in notes if n.startswith("USB not certified —") or n.startswith("USB certified —")]
assert len(cert) == 1
assert cert[0].startswith("USB not certified — because Z cannot be calculated")
assert "90" in cert[0]
# No Italian warning wall on missing fields.
j2 = [f for f in findings if f.designator == "J2" and (f.rule_id or "").startswith("PE-PRT-L")]
assert not any("mancanza" in (f.finding or "") for f in j2)
assert not any(f.rule_id == "PE-PRT-L1-001" for f in j2)
assert not any(f.rule_id == "PE-PRT-L3-001" for f in j2)
# At most one L2 cert finding for the pair (optional L0 FAIL allowed separately).
l2 = [f for f in j2 if f.rule_id == "PE-PRT-L2-002"]
assert len(l2) <= 1
def test_usb_calculated_z_inside_cite_is_certified(monkeypatch):
graph = _usb_c_graph()
layout = LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(0, 0.15), end=(20, 0.15), width=0.2, layer="F.Cu", net="USB_D-"),
],
stackup=_stack(),
)
monkeypatch.setattr(
"backend.periscopex.protocol_l2.usb_pair_z_ohm",
lambda layout, nets, impedance_nets: (90.0, ""),
)
insts = recognize_physical_buses(graph)
_by, findings = certify_l2(graph, insts, layout=layout)
lines = [f.finding for f in findings if (f.finding or "").startswith("USB certified —")]
assert len(lines) == 1
assert "90" in lines[0]
assert "not invented" not in lines[0].lower()
def test_usb_pair_z_reports_missing_geometry():
z, missing = usb_pair_z_ohm(None, ("USB_D+", "USB_D-"), None)
assert z is None
assert "stackup" in missing or "geometry" in missing
+131
View File
@@ -0,0 +1,131 @@
"""not_reviewed count matches list; pcb_review error is not a blanket label."""
from __future__ import annotations
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, patch
from backend.periscopex.models import Component, ComponentConstraints, ComponentType, DesignGraph, Pin
from backend.periscopex.pcb_checks import merge_schema_pcb_reports
from backend.periscopex.review_parse import ReviewResult
from backend.services.pcb_validation import review_pcb_ics
def test_merge_expands_joined_designators_and_matches_count():
schema = {
"findings": [],
"coverage": {"U1": ["power"]},
"not_reviewed": [
{"designator": "U2,U3,U4", "reason": "no datasheet PDF"},
],
}
pcb = {
"findings": [],
"coverage": {"U5": ["layout"]},
"not_reviewed": [
{"designator": "U6", "reason": "no library extraction — run schematic review first"},
{"designator": "U1", "reason": "pcb_review error"}, # reviewed → drop
{"designator": "U7", "reason": "pcb_review error"}, # → review_errors
],
"review_errors": {},
}
merged = merge_schema_pcb_reports(schema, pcb)
nr = merged["not_reviewed"]
refs = [r["designator"] for r in nr]
assert refs == ["U2", "U3", "U4", "U6"]
assert len(nr) == len(refs)
assert "U1" not in refs # covered
assert "U7" not in refs
assert merged["review_errors"]["U7"] == "pcb_review error"
assert "pcb_review error" not in {r["reason"] for r in nr}
def test_merge_does_not_list_reviewed_as_not_reviewed():
schema = {
"findings": [],
"coverage": {"U10": ["abs_max"], "U11": ["pinout"]},
"not_reviewed": [{"designator": "U10", "reason": "no datasheet PDF"}],
}
pcb = {
"findings": [],
"coverage": {"U11": ["layout"]},
"not_reviewed": [{"designator": "U11", "reason": "pcb_review error"}],
}
merged = merge_schema_pcb_reports(schema, pcb)
assert merged["not_reviewed"] == []
def test_pcb_review_exception_goes_to_review_errors_not_not_reviewed():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LAN8720A", footprint="",
component_type=ComponentType.IC, mpn="LAN8720A",
pins={"1": "TXEN"},
),
},
nets={},
)
cons = ComponentConstraints(
mpn="LAN8720A",
pintable=[Pin(number="1", name="TXEN")],
absolute_maximum_ratings=[],
rules=[],
)
async def _boom(*_a, **_k):
raise RuntimeError("model timeout")
async def _run():
with patch(
"backend.services.pcb_validation.review_ic_async",
new=AsyncMock(side_effect=_boom),
):
return await review_pcb_ics(
graph, {"LAN8720A": cons}, None, None, None, Path("/tmp"),
)
findings, coverage, skipped, errors = asyncio.run(_run())
assert findings == []
assert coverage == {}
assert skipped == []
assert "U1" in errors
assert "RuntimeError" in errors["U1"]
assert "pcb_review error" not in errors["U1"]
def test_successful_pcb_review_is_covered_not_skipped():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="X", footprint="",
component_type=ComponentType.IC, mpn="HASPINTABLE",
pins={"1": "GND"},
),
},
nets={},
)
cons = ComponentConstraints(
mpn="HASPINTABLE",
pintable=[Pin(number="1", name="GND")],
absolute_maximum_ratings=[],
rules=[],
)
async def _ok(*_a, **_k):
return ReviewResult([], []), {}
async def _run():
with patch(
"backend.services.pcb_validation.review_ic_async",
new=AsyncMock(side_effect=_ok),
):
return await review_pcb_ics(
graph, {"HASPINTABLE": cons}, None, None, None, Path("/tmp"),
)
_f, coverage, skipped, errors = asyncio.run(_run())
assert coverage == {"U1": ["layout"]}
assert skipped == []
assert errors == {}
+2 -1
View File
@@ -619,8 +619,9 @@ def test_pcb_ai_skips_without_library_extraction():
graph, {}, None, None, None, Path("/tmp"),
)
_f, _c, skipped = asyncio.run(_run())
_f, _c, skipped, errors = asyncio.run(_run())
assert skipped
assert errors == {}
assert "library extraction" in skipped[0]["reason"]
+1 -2
View File
@@ -155,8 +155,7 @@ def test_usb_no_layout_is_visible_skip_not_pass():
assert rows
assert all(r.result != "PASS" or r.check == "never" for r in rows)
assert any(r.result == "UNKNOWN" for r in rows)
assert any(f.rule_id == "PE-PRT-L1-001" for f in findings)
assert any("non certificata a L1" in (f.finding or "") for f in findings)
assert not any(f.rule_id == "PE-PRT-L1-001" for f in findings)
def test_axi_internal_l1_not_applicable():
+13 -9
View File
@@ -109,21 +109,25 @@ def test_usb_z_missing_source_never_invents_90():
rows, findings, _ = _l2_for(_usb_connected(), "usb2", impedance_nets=zrep)
z = [r for r in rows if r.check == "differential_impedance"]
assert z
assert z[0].result in {"MISSING_SOURCE", "UNKNOWN"}
assert z[0].result != "PASS"
blob = json.dumps(z[0].model_dump())
assert "90" not in blob
assert any(f.rule_id == "PE-PRT-L2-001" for f in findings)
assert any("non certificata a L2" in (f.finding or "") for f in findings)
assert z[0].result == "FAIL"
assert z[0].limit_ohm == 90.0
assert z[0].measured_ohm is None
line = z[0].notes
assert line.startswith("USB not certified — because Z cannot be calculated")
assert "90" in line
assert "not invented" not in line.lower()
assert not any(f.rule_id == "PE-PRT-L2-001" for f in findings)
assert any(f.finding == line for f in findings)
def test_usb_z_skip_when_no_stackup_measurement():
rows, findings, _ = _l2_for(_usb_connected(), "usb2")
z = [r for r in rows if r.check == "differential_impedance"]
assert z
assert z[0].result in {"MISSING_SOURCE", "UNKNOWN"}
assert z[0].result == "FAIL"
assert z[0].measured_ohm is None
assert any("mancanza di Z" in (f.finding or "") for f in findings)
assert z[0].limit_ohm == 90.0
assert any(f.finding.startswith("USB not certified —") for f in findings)
def test_cited_pack_z_pass_and_fail():
@@ -220,7 +224,7 @@ def test_ddr_impedance_phy_dependent_not_invented_ohm():
blob = json.dumps(z[0].model_dump())
assert "40" not in blob
assert "90" not in blob
assert any(f.rule_id == "PE-PRT-L2-001" for f in findings)
assert not any(f.rule_id == "PE-PRT-L2-001" for f in findings)
def test_length_is_not_delay_timing_unknown():
+14 -29
View File
@@ -173,6 +173,7 @@ def test_remaining_margin_is_total_minus_used():
def test_usb_hdmi_pcie_skip_without_channel_data():
"""No channel FACT → no L3 warning wall (silence, not Italian mancanza)."""
for graph, token in ((_usb_connected(), "usb"), (_hdmi_graph(), "hdmi"), (_pcie_graph(), "pcie")):
sec, findings = protocol_exam(graph)
assert sec.max_level_reached == "L3"
@@ -183,17 +184,9 @@ def test_usb_hdmi_pcie_skip_without_channel_data():
or token in str(r.get("physical_interface_id")).lower()
)
l3 = row["l3_checks"]
assert l3
names = {c["check"] for c in l3}
assert {"timing_budget", "insertion_loss", "return_loss", "crosstalk", "channel"} <= names
assert all(c["result"] != "PASS" for c in l3)
notes = " ".join(c.get("notes") or "" for c in l3)
assert "non certificata a L3 per mancanza" in notes
assert "OpenEMS" in notes
blob = json.dumps(l3)
assert "90" not in blob
assert any(f.rule_id == "PE-PRT-L3-001" for f in findings)
assert "s21" not in blob.lower() or "invent" in notes.lower()
assert l3 == []
assert not any(f.rule_id == "PE-PRT-L3-001" for f in findings)
assert not any("mancanza" in (f.finding or "") for f in findings if f.source == "protocol_l3")
def test_axi_l3_not_applicable_not_fail():
@@ -269,13 +262,8 @@ def test_raw_touchstone_without_fact_is_skip_not_invented():
inst = _inst()
data = {inst.instance_id: {"sparam_file": "lane.s4p", "touchstone": "lane.s4p"}}
rows = certify_instance_l3(inst, iface, channel_data=data)
assert all(r.result != "PASS" for r in rows)
notes = " ".join(r.notes for r in rows)
assert "non certificata a L3" in notes
assert "invent" in notes.lower() or "non inventati" in notes.lower() or "not invented" in notes.lower()
blob = json.dumps([r.model_dump() for r in rows])
# no synthesized S21 numbers
assert '"measured_db": null' in blob or all(r.measured_db is None for r in rows)
# Raw S-param file is not reduced channel FACT — omit L3 (no warning wall).
assert rows == []
def test_openems_method_is_explicit_skip():
@@ -289,8 +277,8 @@ def test_openems_method_is_explicit_skip():
}
}
rows = certify_instance_l3(inst, iface, channel_data=data)
notes = " ".join(r.notes for r in rows)
assert "non certificata a L3" in notes
# OpenEMS/FEM is out of product — omit L3 rather than invent S-parameters.
assert rows == []
assert all(r.result != "PASS" for r in rows)
@@ -298,19 +286,16 @@ def test_no_sparam_invention_on_empty_channel_data():
insts = recognize_physical_buses(_usb_connected())
by, findings = certify_l3(insts, channel_data=None)
rows = [r for lst in by.values() for r in lst]
blob = json.dumps([r.model_dump() for r in rows])
assert "s21" not in blob.lower()
assert all(r.measured_db is None for r in rows)
assert any("mancanza" in r.notes for r in rows)
assert any(f.rule_id == "PE-PRT-L3-001" for f in findings)
assert rows == []
assert not any(f.rule_id == "PE-PRT-L3-001" for f in findings)
def test_protocol_exam_attaches_l3_and_does_not_call_l0_electrical():
sec, _ = protocol_exam(_usb_connected())
row = sec.recognized_instances[0]
assert row.get("l3_checks")
assert all(c.get("level") == "L3" for c in row["l3_checks"])
notes = " ".join(c.get("notes") or "" for c in row["l3_checks"])
assert "L0/L1 are not electrical" in notes
# L3 omitted without channel FACT; L0/L1/L2 still attached.
assert row.get("l3_checks") == []
assert row.get("l0_checks")
assert row.get("l2_checks")
l0notes = json.dumps(row.get("l0_checks") or [])
assert "ohm" not in l0notes.lower() or "not" in l0notes.lower()
+9 -10
View File
@@ -93,7 +93,7 @@ def test_result_rank_fail_before_warning():
assert instance_worst_result(["UNKNOWN", "FAIL", "PASS"]) == "FAIL"
def test_usb2_pair_l0_pass_l2_z_unknown_no_invented_ohm():
def test_usb2_pair_l0_pass_l2_z_cert_line():
sec, _ = protocol_exam(_usb_connected())
assert sec.max_level_reached == "L3"
assert sec.macrophase == "M9"
@@ -106,20 +106,19 @@ def test_usb2_pair_l0_pass_l2_z_unknown_no_invented_ohm():
assert l0_topo and l0_topo[0]["result"] == "PASS"
z = [c for c in checks if c["check"] == "differential_impedance"]
assert z
assert z[0]["result"] in {"MISSING_SOURCE", "UNKNOWN"}
assert z[0]["skip_visible"] is True
assert z[0]["measured"] is None
assert z[0]["limit"] is None
blob = json.dumps(z[0])
assert "90" not in blob
assert z[0]["result"] == "FAIL"
assert z[0]["limit"] == 90.0 or (z[0].get("notes") or "").find("90") >= 0
notes = z[0].get("notes") or ""
assert notes.startswith("USB not certified —")
assert "90" in notes
assert "not invented" not in notes.lower()
chain = z[0]["chain"]
assert chain["physical_interface_id"]
assert chain["logical_protocol_id"]
assert chain["physical_interface_id"] != chain["logical_protocol_id"]
# No L3 warning wall without channel FACT.
l3 = [c for c in checks if c["level"] == "L3"]
assert l3 and l3[0]["skip_visible"]
assert l3[0]["result"] != "PASS"
assert "OpenEMS" in l3[0]["notes"]
assert l3 == []
def test_fail_listed_before_warning_in_report_checks():
+110
View File
@@ -0,0 +1,110 @@
"""RMII PHYMAC is not RJ45 100 Ω; one English certification line."""
from __future__ import annotations
from backend.periscopex.af_trace_check import check_af_traces
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
LayoutDielectric,
LayoutGraph,
LayoutSegment,
LayoutStackup,
Net,
NetType,
PinConnection,
)
from backend.periscopex.si_check import bus_class, phy_mac_kind, single_ended_eth_mac
def _ic(ref: str, mpn: str, pins: dict[str, str]) -> Component:
return Component(
reference=ref, value=mpn, footprint="",
component_type=ComponentType.IC, mpn=mpn, pins=pins,
)
def _net(name: str, *pairs: tuple[str, str]) -> Net:
return Net(
name=name, net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
)
def _rmii_graph() -> DesignGraph:
return DesignGraph(
components={
"U19": _ic("U19", "LAN8720A", {
"1": "ETH_RXD0", "2": "ETH_RXD1",
"3": "ETH_TXD0", "4": "ETH_TXD1", "5": "ETH_TXEN",
}),
"U3": _ic("U3", "MCU", {
"10": "ETH_RXD0", "11": "ETH_RXD1",
"12": "ETH_TXD0", "13": "ETH_TXD1", "14": "ETH_TXEN",
}),
},
nets={
"ETH_RXD0": _net("ETH_RXD0", ("U19", "1"), ("U3", "10")),
"ETH_RXD1": _net("ETH_RXD1", ("U19", "2"), ("U3", "11")),
"ETH_TXD0": _net("ETH_TXD0", ("U19", "3"), ("U3", "12")),
"ETH_TXD1": _net("ETH_TXD1", ("U19", "4"), ("U3", "13")),
"ETH_TXEN": _net("ETH_TXEN", ("U19", "5"), ("U3", "14")),
},
)
def _layout() -> LayoutGraph:
segs = []
y = 0.0
for net in ("ETH_RXD0", "ETH_RXD1", "ETH_TXD0", "ETH_TXD1", "ETH_TXEN"):
segs.append(LayoutSegment(
start=(0, y), end=(40, y), width=0.15, layer="F.Cu", net=net,
))
y += 0.5
return LayoutGraph(
segments=segs,
stackup=LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="core", er=4.5, height_mm=0.15)],
copper_thickness_mm=0.035,
),
)
def test_eth_rxd_is_rmii_not_mdi():
for n in ("ETH_RXD0", "ETH_TXD0", "ETH_TXEN"):
assert single_ended_eth_mac(n)
assert bus_class(n) == "rmii"
assert phy_mac_kind(n) == "rmii"
assert bus_class(n) != "eth_mdi"
def test_rmii_one_certified_line_no_italian_tr_f():
findings = check_af_traces(_rmii_graph(), {}, _layout())
rmii = [f for f in findings if (f.finding or "").startswith("RMII certified —")]
assert len(rmii) == 1
blob = rmii[0].finding
assert "ETH_RXD" in blob or "ETH_TXD" in blob or "ETH_TXEN" in blob
assert "not an RJ45/MDI" in blob or "not an RJ45" in blob
assert "mancanza" not in blob
assert not any(
"mancanza di tr" in (f.finding or "") or "mancanza di f" in (f.finding or "")
for f in findings
)
assert not any(
f.rule_id == "PE-AF-002" and any(
single_ended_eth_mac(n) for n in ((f.net,) if f.net else ())
)
for f in findings
)
def test_rmii_not_given_100_ohm_mdi_finding():
findings = check_af_traces(_rmii_graph(), {}, _layout())
assert not any("25.4.9" in (f.finding or "") for f in findings)
assert not any(
f.finding.startswith("Ethernet certified") or f.finding.startswith("Ethernet not certified")
for f in findings
if f.net and single_ended_eth_mac(f.net)
)