diff --git a/periscope/src/backend/periscopex/finding_engine.py b/periscope/src/backend/periscopex/finding_engine.py index c385d2f..a7c2077 100644 --- a/periscope/src/backend/periscopex/finding_engine.py +++ b/periscope/src/backend/periscopex/finding_engine.py @@ -300,6 +300,10 @@ def _seed() -> None: requirement="L1 grouping (DQ/DQS / byte-lane) cannot be reconstructed — not PASS.") _add("PE-PRT-L1-003", "MANDATORY", "RULE", domain="pcb", requirement="L1 geometric length/via vs NUMERIC or DESIGN millimetre limit.") + _add("PE-PRT-L2-001", "TYPICAL", "REVIEW", domain="pcb", + requirement="L2 electrical skip: Z/termination/levels need datasheet/stackup/fab/cited pack.") + _add("PE-PRT-L2-002", "MANDATORY", "RULE", domain="pcb", + requirement="L2 Z/termination vs cited pack or PHY datasheet window.") _seed() diff --git a/periscope/src/backend/periscopex/protocol_l0.py b/periscope/src/backend/periscopex/protocol_l0.py index f0ef665..8f9fd56 100644 --- a/periscope/src/backend/periscopex/protocol_l0.py +++ b/periscope/src/backend/periscopex/protocol_l0.py @@ -296,11 +296,14 @@ def protocol_exam( graph: DesignGraph | None, catalog: ProtocolCatalog | None = None, layout: LayoutGraph | None = None, + constraints_map: dict | None = None, + impedance_nets: list[dict] | dict | None = None, ) -> tuple[ProtocolCertificationSection, list[Finding]]: - from backend.periscopex.protocol_l1 import ( - PACK_MACROPHASE as L1_MACRO, - LEVEL as L1_LEVEL, - certify_l1, + from backend.periscopex.protocol_l1 import certify_l1 + from backend.periscopex.protocol_l2 import ( + PACK_MACROPHASE as L2_MACRO, + LEVEL as L2_LEVEL, + certify_l2, ) if graph is None: @@ -309,22 +312,30 @@ def protocol_exam( instances = recognize_physical_buses(graph, cat) if not instances: sec = empty_protocol_section() - sec.macrophase = L1_MACRO + sec.macrophase = L2_MACRO return sec, [] l0, findings = certify_l0(graph, instances, cat) l1, l1_findings_out = certify_l1(graph, instances, layout, cat) findings.extend(l1_findings_out) + l2, l2_findings_out = certify_l2( + graph, instances, cat, + layout=layout, + constraints_map=constraints_map, + impedance_nets=impedance_nets, + ) + findings.extend(l2_findings_out) dumps: list[dict[str, Any]] = [] for inst in instances: row = inst.model_dump() row["l0_checks"] = [c.model_dump() for c in l0.get(inst.instance_id, [])] row["l1_checks"] = [c.model_dump() for c in l1.get(inst.instance_id, [])] + row["l2_checks"] = [c.model_dump() for c in l2.get(inst.instance_id, [])] dumps.append(row) sec = ProtocolCertificationSection( schema_version=SCHEMA_VERSION, - macrophase=L1_MACRO, + macrophase=L2_MACRO, recognized_instances=dumps, message="", - max_level_reached=L1_LEVEL, + max_level_reached=L2_LEVEL, ) return sec, findings diff --git a/periscope/src/backend/periscopex/protocol_l2.py b/periscope/src/backend/periscopex/protocol_l2.py new file mode 100644 index 0000000..30f9af1 --- /dev/null +++ b/periscope/src/backend/periscopex/protocol_l2.py @@ -0,0 +1,624 @@ +"""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. +""" + +from __future__ import annotations + +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.models import ( + ComponentConstraints, + ComponentType, + DesignGraph, + Finding, + LayoutGraph, +) +from backend.periscopex.pcb_net_match import kicad_nets_match +from backend.periscopex.protocol_catalog import ( + PhysicalInterface, + ProtocolCatalog, + ProtocolConstraint, + load_catalog, + map_protocol_outcome, +) +from backend.periscopex.protocol_l0 import _constraint_for, _mandatory +from backend.periscopex.protocol_recognize import PhysicalBusInstance + +PACK_MACROPHASE = "M4" +SOURCE = "protocol_l2" +LEVEL = "L2" + +L2_CHECKS = frozenset({ + "differential_impedance", + "impedance", + "impedance_if_required", + "termination", + "cc_termination", + "voltage", + "levels", + "rise_time", + "fall_time", + "return_path", + "phy_requirements", + "magnetics", + "timing", + "ac_coupling", +}) + +_OHM_UNITS = frozenset({"ohm", "ohms", "ω", "Ω", ""}) +_VOLT_UNITS = frozenset({"v", "volt", "volts"}) +_TIME_UNITS = frozenset({"ps", "ns", "us", "µs", "ms", "s"}) + +MandatoryClass = Literal["MANDATORY", "RECOMMENDED", "OPTIONAL", "INFORMATIONAL"] +L2Result = Literal[ + "PASS", "FAIL", "WARNING", "UNKNOWN", "NOT_APPLICABLE", + "VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT", "MISSING_SOURCE", +] + + +class L2CheckResult(BaseModel): + check: str + level: Literal["L2"] = "L2" + result: L2Result + mandatory: MandatoryClass + nets: list[str] = Field(default_factory=list) + measured_ohm: float | None = None + limit_ohm: float | None = None + limit_ohm_min: float | None = None + limit_ohm_max: float | None = None + margin_ohm: float | None = None + value_kind: str = "" + notes: str = "" + finding_class: str = "" + status: str = "" + + +def _pack( + check: str, + result: str, + mandatory: MandatoryClass, + *, + nets: list[str] | None = None, + measured_ohm: float | None = None, + limit_ohm: float | None = None, + limit_ohm_min: float | None = None, + limit_ohm_max: float | None = None, + value_kind: str = "", + notes: str = "", +) -> L2CheckResult: + if result == "FAIL" and mandatory != "MANDATORY": + result = "WARNING" + mapped = result + if result in {"CONTROLLER_DEPENDENT", "PHY_DEPENDENT"}: + mapped = "VENDOR_DEPENDENT" + cls, status, _ev = map_protocol_outcome(mapped, mandatory) # type: ignore[arg-type] + margin = None + hi = limit_ohm_max if limit_ohm_max is not None else limit_ohm + if measured_ohm is not None and hi is not None: + margin = hi - measured_ohm + return L2CheckResult( + check=check, + result=result, # type: ignore[arg-type] + mandatory=mandatory, + nets=nets or [], + measured_ohm=measured_ohm, + limit_ohm=limit_ohm, + limit_ohm_min=limit_ohm_min, + limit_ohm_max=limit_ohm_max, + margin_ohm=margin, + value_kind=value_kind, + notes=notes, + finding_class=cls, + status=status, + ) + + +def _num(v: Any) -> float | None: + if v is None or isinstance(v, bool): + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _z_window_from_rule(rule: dict) -> tuple[float, float] | None: + lo = _num(rule.get("z_min_ohm")) + hi = _num(rule.get("z_max_ohm")) + if lo is not None and hi is not None and hi >= lo: + return (lo, hi) + nom = _num(rule.get("zdiff_ohm")) or _num(rule.get("z0_ohm")) + tol = _num(rule.get("tolerance_pct")) + if nom is not None and tol is not None and tol > 0: + return (nom * (1 - tol / 100.0), nom * (1 + tol / 100.0)) + if nom is not None: + return (nom, nom) + return None + + +def intersect_windows(windows: list[tuple[float, float]]) -> tuple[float, float] | None: + """Silicon cross: max of mins × min of maxes (PHY subset).""" + if not windows: + return None + lo = max(w[0] for w in windows) + hi = min(w[1] for w in windows) + if lo > hi: + return None + return (lo, hi) + + +def electrical_verdict( + constraint: ProtocolConstraint | None, + measured: float | None, + window: tuple[float, float] | None, +) -> str: + """PASS/FAIL only with a cited NUMERIC ohm/volt or a datasheet window.""" + if window is not None: + if measured is None: + return "UNKNOWN" + if window[0] <= measured <= window[1]: + return "PASS" + return "FAIL" + if constraint is None: + return "UNKNOWN" + kind = constraint.value_kind + if kind == "NUMERIC" and constraint.value is not None: + unit = (constraint.unit or "").strip().lower() + if unit in _TIME_UNITS: + return "UNKNOWN" + if measured is None: + return "UNKNOWN" + if unit in _OHM_UNITS: + return "PASS" if abs(measured - constraint.value) < 1e-6 else "FAIL" + return "PASS" if measured <= constraint.value else "FAIL" + if kind in { + "UNKNOWN", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT", + "VENDOR_DEPENDENT", "MISSING_SOURCE", "NOT_APPLICABLE", + }: + return kind + return "UNKNOWN" + + +def _instance_nets(inst: PhysicalBusInstance) -> list[str]: + names: list[str] = [] + for g in inst.groups: + names.extend(g.nets) + names.extend(inst.nets) + seen: set[str] = set() + out: list[str] = [] + for n in names: + if n not in seen: + seen.add(n) + out.append(n) + return out + + +def _z_rows(impedance_nets: list[dict] | dict | None) -> list[dict]: + raw: Any = impedance_nets + if isinstance(impedance_nets, dict): + raw = list(impedance_nets.get("nets") or []) + return [r for r in (raw or []) if isinstance(r, dict) and not r.get("error")] + + +def _row_for(rows: list[dict], net: str) -> dict | None: + for row in rows: + name = str(row.get("net_name") or row.get("name") or "") + if name and kicad_nets_match(name, net): + return row + return None + + +def _z_from_row(row: dict | None) -> float | None: + if not row: + return None + for k in ("zdiff_ohm", "zdiff_avg_ohms", "z0_avg_ohms", "z0_ohm", "mean_z0", "z0"): + v = _num(row.get(k)) + if v is not None: + return v + return None + + +def measured_z_ohm(nets: list[str], impedance_nets: list[dict] | dict | None) -> float | None: + rows = _z_rows(impedance_nets) + vals = [_z_from_row(_row_for(rows, n)) for n in nets] + have = [v for v in vals if v is not None] + if not have: + return None + return sum(have) / len(have) + + +def _phy_z_windows( + graph: DesignGraph, + inst: PhysicalBusInstance, + constraints_map: dict[str, ComponentConstraints] | None, +) -> list[tuple[float, float]]: + if not constraints_map: + return [] + refs = [r for r in [inst.host_ref, *inst.peer_refs] if r] + inst_nets = set(_instance_nets(inst)) + for ref, comp in graph.components.items(): + if ref in refs: + continue + pins = set(comp.pins.values()) + if pins & inst_nets: + refs.append(ref) + windows: list[tuple[float, float]] = [] + seen: set[tuple[float, float]] = set() + for ref in refs: + comp = graph.components.get(ref) + if comp is None or comp.component_type != ComponentType.IC: + continue + cons = match_constraints(comp.mpn or comp.value, constraints_map) + if cons is None: + continue + for rule in cons.layout_rules or []: + kind = str(rule.get("kind") or "").lower() + param = str(rule.get("parameter") or "").lower() + if kind not in {"impedance", "zdiff", "z0", "si"} and param not in { + "impedance", "zdiff", "z0", + }: + continue + w = _z_window_from_rule(rule) + if w and w not in seen: + seen.add(w) + windows.append(w) + return windows + + +def _pack_ohm_window(cons: ProtocolConstraint | None) -> tuple[float, float] | None: + if cons is None or cons.value_kind != "NUMERIC" or cons.value is None: + return None + unit = (cons.unit or "").strip().lower() + if unit in _TIME_UNITS or unit in _VOLT_UNITS: + return None + if unit in _OHM_UNITS or unit in {"", "ohm"}: + return (cons.value, cons.value) + return None + + +def _skip_z(check: str, mand: MandatoryClass, inst: PhysicalBusInstance, kind: str) -> L2CheckResult: + return _pack( + check, "UNKNOWN" if kind not in { + "MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT", + "VENDOR_DEPENDENT", "NOT_APPLICABLE", + } else kind, + mand, + value_kind=kind, + 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." + ), + ) + + +def certify_instance_l2( + graph: DesignGraph, + inst: PhysicalBusInstance, + iface: PhysicalInterface, + *, + layout: LayoutGraph | None = None, + constraints_map: dict[str, ComponentConstraints] | None = None, + impedance_nets: list[dict] | dict | None = None, +) -> list[L2CheckResult]: + if inst.pcb_relevant == "NO" or iface.pcb_relevant == "NO": + return [_pack( + "pcb_routing", "NOT_APPLICABLE", "INFORMATIONAL", + notes="Internal interconnect — L2 electrical N/A, not FAIL. L0/L1 are not electrical.", + )] + checks = [c for c in iface.required_checks if c in L2_CHECKS] + if not checks: + return [] + return [ + _run_l2_check( + graph, inst, iface, c, + layout=layout, + constraints_map=constraints_map, + impedance_nets=impedance_nets, + ) + for c in checks + ] + + +def _run_l2_check( + graph: DesignGraph, + inst: PhysicalBusInstance, + iface: PhysicalInterface, + check: str, + *, + layout: LayoutGraph | None, + constraints_map: dict[str, ComponentConstraints] | None, + impedance_nets: list[dict] | dict | None, +) -> L2CheckResult: + mand = _mandatory(iface, check) + cons = _constraint_for(iface, check) + kind = cons.value_kind if cons else "UNKNOWN" + nets = _instance_nets(inst) + + if check in { + "differential_impedance", "impedance", "impedance_if_required", + }: + return _z_check( + check, mand, cons, kind, inst, graph, nets, + constraints_map, impedance_nets, + ) + + if check in {"termination", "cc_termination"}: + return _termination_check(check, mand, cons, kind, inst, graph, nets, constraints_map) + + if check in {"voltage", "levels"}: + return _levels_check(check, mand, cons, kind, inst, constraints_map, graph) + + 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, + notes=( + "Length is not delay. Rise/fall/timing UNKNOWN without a cited " + "time source (no tpd invented from mm). Not L3." + ), + ) + + if check in {"return_path", "magnetics", "phy_requirements", "ac_coupling"}: + verdict = kind if kind not in {"NUMERIC"} else "UNKNOWN" + if verdict == "NUMERIC": + verdict = "UNKNOWN" + return _pack( + check, verdict, mand, nets=nets, value_kind=kind, + notes=( + f"{check} not certified without a cited electrical source. " + f"Not invented. L0/L1 are not electrical certification." + ), + ) + return _pack(check, "UNKNOWN", mand, value_kind=kind, notes="Not an L2 electrical check.") + + +def _z_check( + check: str, + mand: MandatoryClass, + cons: ProtocolConstraint | None, + kind: str, + inst: PhysicalBusInstance, + graph: DesignGraph, + nets: list[str], + constraints_map: dict[str, ComponentConstraints] | None, + impedance_nets: list[dict] | dict | None, +) -> L2CheckResult: + phy = _phy_z_windows(graph, inst, constraints_map) + pack_w = _pack_ohm_window(cons) + windows: list[tuple[float, float]] = [] + if pack_w: + windows.append(pack_w) + windows.extend(phy) + 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( + check, + kind if kind in { + "MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT", + "VENDOR_DEPENDENT", "UNKNOWN", + } else "UNKNOWN", + mand, + nets=nets, measured_ohm=measured, value_kind=kind, + 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." + ), + ) + if measured is None: + return _skip_z(check, mand, inst, kind) + verdict = electrical_verdict(cons, measured, window) + source_note = ( + "PHY datasheet subset × cited pack" + if pack_w and phy + else ("cited pack" if pack_w else "PHY datasheet subset") + ) + return _pack( + check, verdict, mand, nets=nets, measured_ohm=measured, + limit_ohm=window[1], + limit_ohm_min=window[0], + limit_ohm_max=window[1], + value_kind=kind if not pack_w else "NUMERIC", + notes=( + f"L2 Z vs {source_note} {window[0]:g}–{window[1]:g} Ω. " + f"Not folklore. Length is not delay. Not L3." + ), + ) + + +def _termination_check( + check: str, + mand: MandatoryClass, + cons: ProtocolConstraint | None, + kind: str, + inst: PhysicalBusInstance, + graph: DesignGraph, + nets: list[str], + constraints_map: dict[str, ComponentConstraints] | None, +) -> L2CheckResult: + spec_ohm: float | None = None + if cons and cons.value_kind == "NUMERIC" and cons.value is not None: + unit = (cons.unit or "").strip().lower() + if unit in _OHM_UNITS: + spec_ohm = cons.value + if spec_ohm is None and constraints_map: + for ref in [inst.host_ref, *inst.peer_refs]: + comp = graph.components.get(ref) + if not comp: + continue + ds = match_constraints(comp.mpn or comp.value, constraints_map) + if not ds: + continue + for rule in ds.layout_rules or []: + if str(rule.get("kind") or "") in {"series_resistor", "termination"}: + spec_ohm = _num(rule.get("value_ohms")) or spec_ohm + if spec_ohm is None: + verdict = kind if kind in { + "MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT", + "VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE", + } else "UNKNOWN" + return _pack( + check, verdict, mand, nets=nets, value_kind=kind, + notes=( + f"Interfaccia {inst.physical_interface_id} non certificata a L2 " + f"per mancanza di termination cite. No invented Rd/ohm." + ), + ) + measured = None + netset = set(nets) + for comp in graph.components.values(): + if comp.component_type != ComponentType.RESISTOR: + continue + if not (set(comp.pins.values()) & netset): + continue + specs = getattr(comp, "specs", None) + ohms = getattr(specs, "value_ohms", None) if specs is not None else None + if isinstance(ohms, (int, float)): + measured = float(ohms) + break + if measured is None: + return _pack( + check, "UNKNOWN", mand, nets=nets, limit_ohm=spec_ohm, value_kind=kind, + notes="Termination spec present but resistor value not FACT on the graph.", + ) + verdict = "PASS" if abs(measured - spec_ohm) < 1e-3 else "FAIL" + return _pack( + check, verdict, mand, nets=nets, measured_ohm=measured, limit_ohm=spec_ohm, + value_kind=kind, notes="Termination vs cited/datasheet ohms. Not invented.", + ) + + +def _levels_check( + check: str, + mand: MandatoryClass, + cons: ProtocolConstraint | None, + kind: str, + inst: PhysicalBusInstance, + constraints_map: dict[str, ComponentConstraints] | None, + graph: DesignGraph, +) -> L2CheckResult: + if cons and cons.value_kind == "NUMERIC" and cons.value is not None: + 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.", + ) + if constraints_map: + for ref in [inst.host_ref, *inst.peer_refs]: + comp = graph.components.get(ref) + if not comp: + continue + 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, + notes=( + "Silicon abs-max present; L2 levels need a cited operating " + "window vs measured rail — not assumed. Not L3." + ), + ) + verdict = kind if kind in { + "MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT", + "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.", + ) + + +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.result == "PASS": + continue + if r.result == "FAIL" and r.mandatory != "MANDATORY": + continue + skip_family = r.result in { + "UNKNOWN", "VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT", + "PHY_DEPENDENT", "MISSING_SOURCE", + } + fail = r.result == "FAIL" and r.mandatory == "MANDATORY" + na = r.result == "NOT_APPLICABLE" + if not skip_family and not fail and not na: + continue + if fail: + rule_id = "PE-PRT-L2-002" + status = "ERROR" + finding = f"L2 electrical {r.check}: {r.notes}" + elif skip_family: + 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})." + ) + else: + rule_id = "PE-PRT-L0-002" + status = "INFO" + finding = r.notes + f = Finding( + designator=designator, + mpn="", + aspect="protocol_l2", + finding=finding, + why=r.notes, + status=status, + source=SOURCE, + net=r.nets[0] if r.nets else None, + rule_id=rule_id, + facts=finding, + requirement="L2 Z/termination/levels only from datasheet/stackup/fab/cited pack.", + inference="L2 is electrical. L0/L1 are not. Length is not delay. Not L3.", + provenance="MANDATORY" if fail else "TYPICAL", + finding_class="RULE" if fail else ("REVIEW" if skip_family else "INFO"), + evidence_status="INSUFFICIENT" if skip_family else "SUFFICIENT", + ) + out.append(complete_finding(f)) + return out + + +def certify_l2( + graph: DesignGraph, + instances: list[PhysicalBusInstance], + catalog: ProtocolCatalog | None = None, + *, + layout: LayoutGraph | None = None, + constraints_map: dict[str, ComponentConstraints] | None = None, + impedance_nets: list[dict] | dict | None = None, +) -> tuple[dict[str, list[L2CheckResult]], list[Finding]]: + cat = catalog or load_catalog() + ifaces = {p.id: p for p in cat.physical_interfaces} + by_id: dict[str, list[L2CheckResult]] = {} + findings: list[Finding] = [] + for inst in instances: + iface = ifaces.get(inst.physical_interface_id) + if iface is None: + continue + rows = certify_instance_l2( + graph, inst, iface, + layout=layout, + constraints_map=constraints_map, + impedance_nets=impedance_nets, + ) + by_id[inst.instance_id] = rows + findings.extend(l2_findings(inst, rows)) + return by_id, findings diff --git a/periscope/src/backend/services/pcb_pipeline.py b/periscope/src/backend/services/pcb_pipeline.py index 33e67c8..5ed77ab 100644 --- a/periscope/src/backend/services/pcb_pipeline.py +++ b/periscope/src/backend/services/pcb_pipeline.py @@ -376,7 +376,10 @@ async def run_pcb_pipeline( return _step(project_id, "write_report", "running") - proto_section, proto_findings = protocol_exam(graph, layout=layout) + proto_section, proto_findings = protocol_exam( + graph, layout=layout, + constraints_map=cmap, impedance_nets=zrep, + ) findings.extend(proto_findings) assign_pcb_finding_ids(findings) annotate_findings_cad(findings, cad_index_from_graph(graph)) diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index dca8b3e..fec72a1 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,6 +2,13 @@ What's new in Periscope. +## 2.68.0 — 2026-09-22 — Protocol certification M4 (L2 electrical) + +L2 ELECTRICAL certifier: impedance, termination, voltage/levels, rise/fall when sources exist. Z only from datasheet / stackup / fabricator / **cited pack** — never invented 90 Ω USB. USB-IF/IEEE only if the pack has a cite. Silicon cross is max PCB × PHY subset when datasheet windows exist. Length is not delay. No L3/OpenEMS. L0/L1 are **not** electrical. RECOMMENDED never FAIL. MISSING_SOURCE / UNKNOWN when cite is absent. Visible skip when Z cannot be obtained. + +- [New] `protocol_l2.py`; `PE-PRT-L2-001` / `PE-PRT-L2-002`. +- [New] pytest `tests/pcb/test_protocol_l2_m4.py`. + ## 2.67.0 — 2026-09-22 — Protocol certification M3 (L1 geometric) L1 GEOMETRIC certifier: length, topology, vias, stubs, layer, grouping. Geometric skew only vs **NUMERIC** millimetre or an explicit **DESIGN LIMIT**. Otherwise UNKNOWN / VENDOR_DEPENDENT / CONTROLLER_DEPENDENT / PHY_DEPENDENT — never invent mm/ps. L1 is **not** electrical. No L2/L3. RECOMMENDED never FAIL. Internal interfaces stay NOT_APPLICABLE. Missing DQ/DQS grouping is a visible skip, not PASS. diff --git a/periscope/src/frontend/package.json b/periscope/src/frontend/package.json index 86cdd10..3334837 100644 --- a/periscope/src/frontend/package.json +++ b/periscope/src/frontend/package.json @@ -1,6 +1,6 @@ { "name": "periscope-web", - "version": "2.67.0", + "version": "2.68.0", "private": true, "scripts": { "sync-version": "node scripts/sync-version.mjs", diff --git a/periscope/src/frontend/src/components/report/protocol-section.tsx b/periscope/src/frontend/src/components/report/protocol-section.tsx index 5f2529e..f5d6e38 100644 --- a/periscope/src/frontend/src/components/report/protocol-section.tsx +++ b/periscope/src/frontend/src/components/report/protocol-section.tsx @@ -13,8 +13,9 @@ export function ProtocolSection({

Protocolli

- Logical protocol vs physical interface. L0 structural, L1 geometric - (length, vias, layer, grouping). Not electrical; no invented ohms/mm/ps. + Logical protocol vs physical interface. L0 structural, L1 geometric, + L2 electrical (Z from datasheet/stackup/fab or a cited pack — never + invented 90 Ω). L0/L1 are not electrical. Length is not delay.

{instances.length === 0 ? (

{message}

@@ -59,6 +60,14 @@ export function ProtocolSection({ .join("; ")}

) : null} + {Array.isArray(row.l2_checks) && row.l2_checks.length > 0 ? ( +

+ L2{" "} + {(row.l2_checks as Record[]) + .map((c) => `${String(c.check)}=${String(c.result)}`) + .join("; ")} +

+ ) : null} ); })} diff --git a/periscope/src/frontend/src/lib/layout-finding.ts b/periscope/src/frontend/src/lib/layout-finding.ts index df73b16..7f89d20 100644 --- a/periscope/src/frontend/src/lib/layout-finding.ts +++ b/periscope/src/frontend/src/lib/layout-finding.ts @@ -28,6 +28,7 @@ export function isLayoutFinding(f: { f.source === "af_trace_check" || f.source === "protocol_l0" || f.source === "protocol_l1" || + f.source === "protocol_l2" || rid.startsWith("PE-PLC") || rid.startsWith("PE-LAY") || rid.startsWith("PE-SI") || diff --git a/tests/pcb/test_protocol_l0_m2.py b/tests/pcb/test_protocol_l0_m2.py index 48bf825..8c31d26 100644 --- a/tests/pcb/test_protocol_l0_m2.py +++ b/tests/pcb/test_protocol_l0_m2.py @@ -129,9 +129,9 @@ def test_axi_internal_l0_not_applicable_not_fail(): assert all(r.result == "NOT_APPLICABLE" for r in rows) assert all(r.result != "FAIL" for r in rows) sec, findings = protocol_exam(_axi_graph()) - assert sec.max_level_reached == "L1" - from backend.periscopex.protocol_l1 import PACK_MACROPHASE as L1_MACRO - assert sec.macrophase == L1_MACRO + assert sec.max_level_reached == "L2" + from backend.periscopex.protocol_l2 import PACK_MACROPHASE as L2_MACRO + assert sec.macrophase == L2_MACRO assert all(f.status != "ERROR" for f in findings) assert all("AXI_AWVALID" not in (f.net or "") for f in findings if f.rule_id == "PE-PRT-L0-001") diff --git a/tests/pcb/test_protocol_l1_m3.py b/tests/pcb/test_protocol_l1_m3.py index 940a59f..e6cdc6b 100644 --- a/tests/pcb/test_protocol_l1_m3.py +++ b/tests/pcb/test_protocol_l1_m3.py @@ -165,8 +165,8 @@ def test_axi_internal_l1_not_applicable(): assert all(r.result == "NOT_APPLICABLE" for r in rows) assert all(r.result != "FAIL" for r in rows) sec, exam_findings = protocol_exam(_axi_graph(), layout=LayoutGraph()) - assert sec.max_level_reached == "L1" - assert sec.macrophase == "M3" + assert sec.max_level_reached == "L2" + assert sec.macrophase == "M4" assert all(f.status != "ERROR" for f in exam_findings) blob = json.dumps([r.model_dump() for r in rows]) assert "electrical certification" not in blob.lower() or "not electrical" in blob.lower() @@ -257,7 +257,7 @@ def test_l1_does_not_run_impedance(): def test_protocol_exam_attaches_l1_checks(): sec, _ = protocol_exam(_usb_connected(), layout=_usb_layout()) - assert sec.max_level_reached == "L1" + assert sec.max_level_reached == "L2" assert sec.recognized_instances row = sec.recognized_instances[0] assert row.get("l1_checks") diff --git a/tests/pcb/test_protocol_l2_m4.py b/tests/pcb/test_protocol_l2_m4.py new file mode 100644 index 0000000..d012184 --- /dev/null +++ b/tests/pcb/test_protocol_l2_m4.py @@ -0,0 +1,261 @@ +"""M4 L2 ELECTRICAL certifier: Z from cite/datasheet/stackup only; never 90 Ω folklore.""" + +from __future__ import annotations + +import json + +from backend.periscopex.models import ( + Component, + ComponentConstraints, + ComponentType, + DesignGraph, + LayoutGraph, + Net, + NetType, + PinConnection, +) +from backend.periscopex.protocol_catalog import ( + ConstraintSource, + PhysicalInterface, + ProtocolConstraint, +) +from backend.periscopex.protocol_l0 import protocol_exam +from backend.periscopex.protocol_l2 import ( + _pack, + certify_instance_l2, + certify_l2, + electrical_verdict, + intersect_windows, +) +from backend.periscopex.protocol_recognize import recognize_physical_buses + + +def _ic(ref: str, pins: dict[str, str], *, mpn: str = "", value: str = "") -> Component: + return Component( + reference=ref, value=value or mpn, 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_connected() -> DesignGraph: + return DesignGraph( + components={ + "U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}, mpn="CH340E"), + "J2": Component( + reference="J2", value="USB2_TypeA", + footprint="USB_A", + component_type=ComponentType.CONNECTOR, + pins={"2": "USB_D+", "3": "USB_D-"}, + ), + }, + nets={ + "USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "2")), + "USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "3")), + }, + ) + + +def _axi_graph() -> DesignGraph: + return DesignGraph( + components={ + "U3": _ic("U3", {"1": "AXI_AWVALID"}, mpn="XC7A100T", value="AXI4 interconnect"), + }, + nets={"AXI_AWVALID": _net("AXI_AWVALID", ("U3", "1"))}, + ) + + +def _ddr_graph() -> DesignGraph: + pins = {str(i): f"DDR4_DQ{i}" for i in range(8)} + pins.update({"8": "DDR4_DQS0_P", "9": "DDR4_DQS0_N"}) + u_mem = _ic("U5", pins, mpn="MT41K256M16TW", value="DDR4") + u_cpu = _ic("U4", {str(i): pins[str(i)] for i in pins}, mpn="STM32", value="MCU") + nets = {n: _net(n, ("U5", p), ("U4", p)) for p, n in pins.items()} + return DesignGraph(components={"U5": u_mem, "U4": u_cpu}, nets=nets) + + +def _cons(mpn: str, rules: list[dict]) -> ComponentConstraints: + return ComponentConstraints( + mpn=mpn, pintable=[], absolute_maximum_ratings=[], rules=[], + layout_rules=rules, + ) + + +def _l2_for(graph: DesignGraph, logical_sub: str, **kwargs): + insts = recognize_physical_buses(graph) + by, findings = certify_l2(graph, insts, **kwargs) + rows = [] + for inst in insts: + if logical_sub in inst.logical_protocol_id: + rows.extend(by.get(inst.instance_id, [])) + return rows, findings, insts + + +def _cite() -> ConstraintSource: + return ConstraintSource(document="USB 2.0 spec", organization="USB-IF", section="7.1.1") + + +def test_usb_z_missing_source_never_invents_90(): + zrep = {"nets": [ + {"net_name": "USB_D+", "z0_avg_ohms": 45.0}, + {"net_name": "USB_D-", "z0_avg_ohms": 46.0}, + ]} + 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) + + +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].measured_ohm is None + assert any("mancanza di Z" in (f.finding or "") for f in findings) + + +def test_cited_pack_z_pass_and_fail(): + graph = _usb_connected() + insts = recognize_physical_buses(graph) + usb = [i for i in insts if "usb" in i.logical_protocol_id][0] + iface = PhysicalInterface( + id="usb2-hs-dpair", + logical_protocol_id="usb2-hs", + bus_type="SERIAL", + physical_layer="SERIAL_DIFFERENTIAL", + pcb_relevant="YES", + required_checks=["differential_impedance"], + constraints=[ProtocolConstraint( + id="usb-z", + parameter="differential_impedance", + value_kind="NUMERIC", + mandatory="MANDATORY", + source_type="STANDARD", + source_class="NORMATIVE", + value=90.0, + unit="ohm", + source=_cite(), + limit_kind="STANDARD", + )], + ) + zrep = {"nets": [{"net_name": "USB_D+", "zdiff_ohm": 90.0}]} + ok = certify_instance_l2(graph, usb, iface, impedance_nets=zrep) + assert ok[0].result == "PASS" + assert ok[0].measured_ohm == 90.0 + bad = certify_instance_l2( + graph, usb, iface, + impedance_nets={"nets": [{"net_name": "USB_D+", "zdiff_ohm": 40.0}]}, + ) + assert bad[0].result == "FAIL" + + +def test_silicon_cross_phy_subset(): + assert intersect_windows([(80.0, 100.0), (85.0, 95.0)]) == (85.0, 95.0) + graph = _usb_connected() + cmap = { + "CH340E": _cons("CH340E", [{ + "kind": "impedance", + "z_min_ohm": 80, + "z_max_ohm": 100, + "note": "CH340E USB", + "source_page": 12, + }]), + } + # second PHY on the same nets + extra = _ic("U9", {"1": "USB_D+", "2": "USB_D-"}, mpn="USBPHY1") + graph.components["U9"] = extra + cmap["USBPHY1"] = _cons("USBPHY1", [{ + "kind": "zdiff", + "z_min_ohm": 85, + "z_max_ohm": 95, + "note": "PHY subset", + "source_page": 3, + }]) + zrep = {"nets": [{"net_name": "USB_D+", "zdiff_ohm": 90.0}]} + rows, _, _ = _l2_for(graph, "usb2", constraints_map=cmap, impedance_nets=zrep) + z = [r for r in rows if r.check == "differential_impedance"] + assert z + assert z[0].result == "PASS" + assert z[0].limit_ohm_min == 85.0 + assert z[0].limit_ohm_max == 95.0 + zrep_lo = {"nets": [{"net_name": "USB_D+", "zdiff_ohm": 70.0}]} + rows_lo, findings, _ = _l2_for( + graph, "usb2", constraints_map=cmap, impedance_nets=zrep_lo, + ) + zlo = [r for r in rows_lo if r.check == "differential_impedance"] + assert zlo[0].result == "FAIL" + assert any(f.rule_id == "PE-PRT-L2-002" for f in findings) + + +def test_axi_internal_l2_not_applicable(): + rows, _, _ = _l2_for(_axi_graph(), "axi4") + assert rows + assert all(r.result == "NOT_APPLICABLE" for r in rows) + sec, findings = protocol_exam(_axi_graph()) + assert sec.max_level_reached == "L2" + assert sec.macrophase == "M4" + assert all(f.status != "ERROR" for f in findings) + blob = json.dumps([r.model_dump() for r in rows]) + assert "L0/L1 are not electrical" in blob + + +def test_ddr_impedance_phy_dependent_not_invented_ohm(): + rows, findings, _ = _l2_for(_ddr_graph(), "ddr4") + z = [r for r in rows if r.check == "impedance"] + assert z + assert z[0].result in {"PHY_DEPENDENT", "UNKNOWN", "VENDOR_DEPENDENT"} + assert z[0].result != "PASS" + 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) + + +def test_length_is_not_delay_timing_unknown(): + cons = ProtocolConstraint( + id="t", + parameter="timing", + value_kind="NUMERIC", + mandatory="MANDATORY", + source_type="STANDARD", + source_class="NORMATIVE", + value=100.0, + unit="ps", + source=_cite(), + ) + assert electrical_verdict(cons, 12.0, None) == "UNKNOWN" + + +def test_recommended_never_fail_l2(): + rec = _pack("return_path", "FAIL", "RECOMMENDED", notes="weak") + assert rec.result != "FAIL" + assert rec.status != "ERROR" + + +def test_l2_does_not_run_l3_or_geometry(): + rows, _, _ = _l2_for(_usb_connected(), "usb2") + assert not any(r.check in {"insertion_loss", "return_loss", "length", "intra_pair_skew"} for r in rows) + blob = json.dumps([r.model_dump() for r in rows]) + assert "openems" not in blob.lower() + + +def test_protocol_exam_attaches_l2_checks(): + sec, _ = protocol_exam(_usb_connected()) + assert sec.max_level_reached == "L2" + row = sec.recognized_instances[0] + assert row.get("l2_checks") + assert all(c.get("level") == "L2" for c in row["l2_checks"]) + assert row.get("l1_checks") is not None + assert row.get("l0_checks") is not None