Add protocol-certification M4 L2 electrical certifier (2.68.0).
Z/termination/levels from datasheet, stackup, fab, or cited pack only. Never invented USB ohms. No L3/OpenEMS. L0/L1 are not electrical.
This commit is contained in:
@@ -300,6 +300,10 @@ def _seed() -> None:
|
|||||||
requirement="L1 grouping (DQ/DQS / byte-lane) cannot be reconstructed — not PASS.")
|
requirement="L1 grouping (DQ/DQS / byte-lane) cannot be reconstructed — not PASS.")
|
||||||
_add("PE-PRT-L1-003", "MANDATORY", "RULE", domain="pcb",
|
_add("PE-PRT-L1-003", "MANDATORY", "RULE", domain="pcb",
|
||||||
requirement="L1 geometric length/via vs NUMERIC or DESIGN millimetre limit.")
|
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()
|
_seed()
|
||||||
|
|||||||
@@ -296,11 +296,14 @@ def protocol_exam(
|
|||||||
graph: DesignGraph | None,
|
graph: DesignGraph | None,
|
||||||
catalog: ProtocolCatalog | None = None,
|
catalog: ProtocolCatalog | None = None,
|
||||||
layout: LayoutGraph | None = None,
|
layout: LayoutGraph | None = None,
|
||||||
|
constraints_map: dict | None = None,
|
||||||
|
impedance_nets: list[dict] | dict | None = None,
|
||||||
) -> tuple[ProtocolCertificationSection, list[Finding]]:
|
) -> tuple[ProtocolCertificationSection, list[Finding]]:
|
||||||
from backend.periscopex.protocol_l1 import (
|
from backend.periscopex.protocol_l1 import certify_l1
|
||||||
PACK_MACROPHASE as L1_MACRO,
|
from backend.periscopex.protocol_l2 import (
|
||||||
LEVEL as L1_LEVEL,
|
PACK_MACROPHASE as L2_MACRO,
|
||||||
certify_l1,
|
LEVEL as L2_LEVEL,
|
||||||
|
certify_l2,
|
||||||
)
|
)
|
||||||
|
|
||||||
if graph is None:
|
if graph is None:
|
||||||
@@ -309,22 +312,30 @@ def protocol_exam(
|
|||||||
instances = recognize_physical_buses(graph, cat)
|
instances = recognize_physical_buses(graph, cat)
|
||||||
if not instances:
|
if not instances:
|
||||||
sec = empty_protocol_section()
|
sec = empty_protocol_section()
|
||||||
sec.macrophase = L1_MACRO
|
sec.macrophase = L2_MACRO
|
||||||
return sec, []
|
return sec, []
|
||||||
l0, findings = certify_l0(graph, instances, cat)
|
l0, findings = certify_l0(graph, instances, cat)
|
||||||
l1, l1_findings_out = certify_l1(graph, instances, layout, cat)
|
l1, l1_findings_out = certify_l1(graph, instances, layout, cat)
|
||||||
findings.extend(l1_findings_out)
|
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]] = []
|
dumps: list[dict[str, Any]] = []
|
||||||
for inst in instances:
|
for inst in instances:
|
||||||
row = inst.model_dump()
|
row = inst.model_dump()
|
||||||
row["l0_checks"] = [c.model_dump() for c in l0.get(inst.instance_id, [])]
|
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["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)
|
dumps.append(row)
|
||||||
sec = ProtocolCertificationSection(
|
sec = ProtocolCertificationSection(
|
||||||
schema_version=SCHEMA_VERSION,
|
schema_version=SCHEMA_VERSION,
|
||||||
macrophase=L1_MACRO,
|
macrophase=L2_MACRO,
|
||||||
recognized_instances=dumps,
|
recognized_instances=dumps,
|
||||||
message="",
|
message="",
|
||||||
max_level_reached=L1_LEVEL,
|
max_level_reached=L2_LEVEL,
|
||||||
)
|
)
|
||||||
return sec, findings
|
return sec, findings
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -376,7 +376,10 @@ async def run_pcb_pipeline(
|
|||||||
return
|
return
|
||||||
|
|
||||||
_step(project_id, "write_report", "running")
|
_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)
|
findings.extend(proto_findings)
|
||||||
assign_pcb_finding_ids(findings)
|
assign_pcb_finding_ids(findings)
|
||||||
annotate_findings_cad(findings, cad_index_from_graph(graph))
|
annotate_findings_cad(findings, cad_index_from_graph(graph))
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
What's new in Periscope.
|
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)
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "periscope-web",
|
"name": "periscope-web",
|
||||||
"version": "2.67.0",
|
"version": "2.68.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"sync-version": "node scripts/sync-version.mjs",
|
"sync-version": "node scripts/sync-version.mjs",
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ export function ProtocolSection({
|
|||||||
<section className="rounded-lg border border-border bg-card p-4 space-y-2">
|
<section className="rounded-lg border border-border bg-card p-4 space-y-2">
|
||||||
<h2 className="text-sm font-semibold">Protocolli</h2>
|
<h2 className="text-sm font-semibold">Protocolli</h2>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Logical protocol vs physical interface. L0 structural, L1 geometric
|
Logical protocol vs physical interface. L0 structural, L1 geometric,
|
||||||
(length, vias, layer, grouping). Not electrical; no invented ohms/mm/ps.
|
L2 electrical (Z from datasheet/stackup/fab or a cited pack — never
|
||||||
|
invented 90 Ω). L0/L1 are not electrical. Length is not delay.
|
||||||
</p>
|
</p>
|
||||||
{instances.length === 0 ? (
|
{instances.length === 0 ? (
|
||||||
<p className="text-sm">{message}</p>
|
<p className="text-sm">{message}</p>
|
||||||
@@ -59,6 +60,14 @@ export function ProtocolSection({
|
|||||||
.join("; ")}
|
.join("; ")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{Array.isArray(row.l2_checks) && row.l2_checks.length > 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
L2{" "}
|
||||||
|
{(row.l2_checks as Record<string, unknown>[])
|
||||||
|
.map((c) => `${String(c.check)}=${String(c.result)}`)
|
||||||
|
.join("; ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function isLayoutFinding(f: {
|
|||||||
f.source === "af_trace_check" ||
|
f.source === "af_trace_check" ||
|
||||||
f.source === "protocol_l0" ||
|
f.source === "protocol_l0" ||
|
||||||
f.source === "protocol_l1" ||
|
f.source === "protocol_l1" ||
|
||||||
|
f.source === "protocol_l2" ||
|
||||||
rid.startsWith("PE-PLC") ||
|
rid.startsWith("PE-PLC") ||
|
||||||
rid.startsWith("PE-LAY") ||
|
rid.startsWith("PE-LAY") ||
|
||||||
rid.startsWith("PE-SI") ||
|
rid.startsWith("PE-SI") ||
|
||||||
|
|||||||
@@ -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 == "NOT_APPLICABLE" for r in rows)
|
||||||
assert all(r.result != "FAIL" for r in rows)
|
assert all(r.result != "FAIL" for r in rows)
|
||||||
sec, findings = protocol_exam(_axi_graph())
|
sec, findings = protocol_exam(_axi_graph())
|
||||||
assert sec.max_level_reached == "L1"
|
assert sec.max_level_reached == "L2"
|
||||||
from backend.periscopex.protocol_l1 import PACK_MACROPHASE as L1_MACRO
|
from backend.periscopex.protocol_l2 import PACK_MACROPHASE as L2_MACRO
|
||||||
assert sec.macrophase == L1_MACRO
|
assert sec.macrophase == L2_MACRO
|
||||||
assert all(f.status != "ERROR" for f in findings)
|
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")
|
assert all("AXI_AWVALID" not in (f.net or "") for f in findings if f.rule_id == "PE-PRT-L0-001")
|
||||||
|
|
||||||
|
|||||||
@@ -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 == "NOT_APPLICABLE" for r in rows)
|
||||||
assert all(r.result != "FAIL" for r in rows)
|
assert all(r.result != "FAIL" for r in rows)
|
||||||
sec, exam_findings = protocol_exam(_axi_graph(), layout=LayoutGraph())
|
sec, exam_findings = protocol_exam(_axi_graph(), layout=LayoutGraph())
|
||||||
assert sec.max_level_reached == "L1"
|
assert sec.max_level_reached == "L2"
|
||||||
assert sec.macrophase == "M3"
|
assert sec.macrophase == "M4"
|
||||||
assert all(f.status != "ERROR" for f in exam_findings)
|
assert all(f.status != "ERROR" for f in exam_findings)
|
||||||
blob = json.dumps([r.model_dump() for r in rows])
|
blob = json.dumps([r.model_dump() for r in rows])
|
||||||
assert "electrical certification" not in blob.lower() or "not electrical" in blob.lower()
|
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():
|
def test_protocol_exam_attaches_l1_checks():
|
||||||
sec, _ = protocol_exam(_usb_connected(), layout=_usb_layout())
|
sec, _ = protocol_exam(_usb_connected(), layout=_usb_layout())
|
||||||
assert sec.max_level_reached == "L1"
|
assert sec.max_level_reached == "L2"
|
||||||
assert sec.recognized_instances
|
assert sec.recognized_instances
|
||||||
row = sec.recognized_instances[0]
|
row = sec.recognized_instances[0]
|
||||||
assert row.get("l1_checks")
|
assert row.get("l1_checks")
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user