diff --git a/periscope/src/backend/periscopex/af_trace_check.py b/periscope/src/backend/periscopex/af_trace_check.py index 22efa72..af7c3db 100644 --- a/periscope/src/backend/periscopex/af_trace_check.py +++ b/periscope/src/backend/periscopex/af_trace_check.py @@ -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: PHY–MAC 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} PHY–MAC " + "(not an RJ45/MDI 100 Ω differential pair). No invented ohm." + ) + rec = ( + f"Keep {iface} as single-ended PHY–MAC. 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 MAC–PHY. " + "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", diff --git a/periscope/src/backend/periscopex/pcb_checks.py b/periscope/src/backend/periscopex/pcb_checks.py index 054d5a5..70cf9e5 100644 --- a/periscope/src/backend/periscopex/pcb_checks.py +++ b/periscope/src/backend/periscopex/pcb_checks.py @@ -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 diff --git a/periscope/src/backend/periscopex/protocol_l1.py b/periscope/src/backend/periscopex/protocol_l1.py index 1df30f4..73a5a7a 100644 --- a/periscope/src/backend/periscopex/protocol_l1.py +++ b/periscope/src/backend/periscopex/protocol_l1.py @@ -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" diff --git a/periscope/src/backend/periscopex/protocol_l2.py b/periscope/src/backend/periscopex/protocol_l2.py index 30f9af1..a6ff93c 100644 --- a/periscope/src/backend/periscopex/protocol_l2.py +++ b/periscope/src/backend/periscopex/protocol_l2.py @@ -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" diff --git a/periscope/src/backend/periscopex/protocol_l3.py b/periscope/src/backend/periscopex/protocol_l3.py index 96b9a5e..d3dfb2e 100644 --- a/periscope/src/backend/periscopex/protocol_l3.py +++ b/periscope/src/backend/periscopex/protocol_l3.py @@ -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" diff --git a/periscope/src/backend/periscopex/protocol_report.py b/periscope/src/backend/periscopex/protocol_report.py index fc7524b..b63b23c 100644 --- a/periscope/src/backend/periscopex/protocol_report.py +++ b/periscope/src/backend/periscopex/protocol_report.py @@ -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 diff --git a/periscope/src/backend/periscopex/si_check.py b/periscope/src/backend/periscopex/si_check.py index d6c0a76..1d23f37 100644 --- a/periscope/src/backend/periscopex/si_check.py +++ b/periscope/src/backend/periscopex/si_check.py @@ -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 PHY–MAC 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, "") diff --git a/periscope/src/backend/services/pcb_pipeline.py b/periscope/src/backend/services/pcb_pipeline.py index b78dc6b..1010df5 100644 --- a/periscope/src/backend/services/pcb_pipeline.py +++ b/periscope/src/backend/services/pcb_pipeline.py @@ -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") diff --git a/periscope/src/backend/services/pcb_validation.py b/periscope/src/backend/services/pcb_validation.py index 71b6b51..d33ae44 100644 --- a/periscope/src/backend/services/pcb_validation.py +++ b/periscope/src/backend/services/pcb_validation.py @@ -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 diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index 65aa38e..f662a4d 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -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 PHY–MAC), 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. diff --git a/periscope/src/frontend/src/app/(app)/project/[id]/report/page.tsx b/periscope/src/frontend/src/app/(app)/project/[id]/report/page.tsx index 80f596c..ee37820 100644 --- a/periscope/src/frontend/src/app/(app)/project/[id]/report/page.tsx +++ b/periscope/src/frontend/src/app/(app)/project/[id]/report/page.tsx @@ -328,7 +328,10 @@ function ReportContent({ projectId }: { projectId: string }) { {report.not_reviewed.length} component{report.not_reviewed.length === 1 ? "" : "s"} not reviewed
- 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.
+ {certLine.notes} +
+ ) : null}{logical} → {physical} {instance.recognition ? ` · ${instance.recognition}` : ""} diff --git a/periscope/src/frontend/src/lib/protocol-report.test.ts b/periscope/src/frontend/src/lib/protocol-report.test.ts index ba613bd..208b923 100644 --- a/periscope/src/frontend/src/lib/protocol-report.test.ts +++ b/periscope/src/frontend/src/lib/protocol-report.test.ts @@ -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"); diff --git a/tests/pcb/test_af_trace_check.py b/tests/pcb/test_af_trace_check.py index f1437ea..e2fdd56 100644 --- a/tests/pcb/test_af_trace_check.py +++ b/tests/pcb/test_af_trace_check.py @@ -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(): diff --git a/tests/pcb/test_bom_esd_ep_cc_eth.py b/tests/pcb/test_bom_esd_ep_cc_eth.py index cc87cb7..c6130b7 100644 --- a/tests/pcb/test_bom_esd_ep_cc_eth.py +++ b/tests/pcb/test_bom_esd_ep_cc_eth.py @@ -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 diff --git a/tests/pcb/test_j2_usb_cert_line.py b/tests/pcb/test_j2_usb_cert_line.py new file mode 100644 index 0000000..bae9420 --- /dev/null +++ b/tests/pcb/test_j2_usb_cert_line.py @@ -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 diff --git a/tests/pcb/test_not_reviewed_count.py b/tests/pcb/test_not_reviewed_count.py new file mode 100644 index 0000000..f76ac73 --- /dev/null +++ b/tests/pcb/test_not_reviewed_count.py @@ -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 == {} diff --git a/tests/pcb/test_pcb_review.py b/tests/pcb/test_pcb_review.py index 81eda7b..19f6d3a 100644 --- a/tests/pcb/test_pcb_review.py +++ b/tests/pcb/test_pcb_review.py @@ -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"] diff --git a/tests/pcb/test_protocol_l1_m3.py b/tests/pcb/test_protocol_l1_m3.py index 0448f0c..b391fb4 100644 --- a/tests/pcb/test_protocol_l1_m3.py +++ b/tests/pcb/test_protocol_l1_m3.py @@ -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(): diff --git a/tests/pcb/test_protocol_l2_m4.py b/tests/pcb/test_protocol_l2_m4.py index 75f7324..d3e7e78 100644 --- a/tests/pcb/test_protocol_l2_m4.py +++ b/tests/pcb/test_protocol_l2_m4.py @@ -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(): diff --git a/tests/pcb/test_protocol_l3_m9.py b/tests/pcb/test_protocol_l3_m9.py index 281ccae..3b8fe16 100644 --- a/tests/pcb/test_protocol_l3_m9.py +++ b/tests/pcb/test_protocol_l3_m9.py @@ -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() diff --git a/tests/pcb/test_protocol_report_m5.py b/tests/pcb/test_protocol_report_m5.py index 5531f26..5adab32 100644 --- a/tests/pcb/test_protocol_report_m5.py +++ b/tests/pcb/test_protocol_report_m5.py @@ -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(): diff --git a/tests/pcb/test_rmii_cert_line.py b/tests/pcb/test_rmii_cert_line.py new file mode 100644 index 0000000..9061c7f --- /dev/null +++ b/tests/pcb/test_rmii_cert_line.py @@ -0,0 +1,110 @@ +"""RMII PHY–MAC 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) + )