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({
- 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.
{message}Protocolli
+ L2{" "}
+ {(row.l2_checks as Record