diff --git a/periscope/src/backend/periscopex/finding_engine.py b/periscope/src/backend/periscopex/finding_engine.py index 4d399fa..c385d2f 100644 --- a/periscope/src/backend/periscopex/finding_engine.py +++ b/periscope/src/backend/periscopex/finding_engine.py @@ -294,6 +294,12 @@ def _seed() -> None: requirement="Protocol nets required at L0 must exist and connect two components.") _add("PE-PRT-L0-002", "TYPICAL", "INFO", domain="pcb", requirement="Internal interconnect is NOT_APPLICABLE for PCB routing.") + _add("PE-PRT-L1-001", "TYPICAL", "REVIEW", domain="pcb", + requirement="L1 geometric skip: missing PCB geometry or NUMERIC/DESIGN millimetre limit.") + _add("PE-PRT-L1-002", "TYPICAL", "REVIEW", domain="pcb", + 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.") _seed() diff --git a/periscope/src/backend/periscopex/protocol_l0.py b/periscope/src/backend/periscopex/protocol_l0.py index 7d36204..f0ef665 100644 --- a/periscope/src/backend/periscopex/protocol_l0.py +++ b/periscope/src/backend/periscopex/protocol_l0.py @@ -13,7 +13,7 @@ from typing import Any, Literal from pydantic import BaseModel, Field from backend.periscopex.finding_engine import complete_finding -from backend.periscopex.models import DesignGraph, Finding +from backend.periscopex.models import DesignGraph, Finding, LayoutGraph from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net from backend.periscopex.protocol_catalog import ( SCHEMA_VERSION, @@ -93,6 +93,12 @@ def _constraint_for(iface: PhysicalInterface, check: str) -> ProtocolConstraint "superspeed_pairs": "superspeed_pairs", "topology": "topology", "byte_lane_mapping": "topology", + "byte_lane_skew": "byte_to_byte_skew", + "length": "max_length", + "clock_length": "max_length", + "data_length": "max_length", + "via_count": "vias", + "stub_length": "stub_length", } want = aliases.get(check, check) for c in iface.constraints: @@ -289,26 +295,36 @@ def certify_l0( def protocol_exam( graph: DesignGraph | None, catalog: ProtocolCatalog | None = None, + layout: LayoutGraph | None = None, ) -> tuple[ProtocolCertificationSection, list[Finding]]: + from backend.periscopex.protocol_l1 import ( + PACK_MACROPHASE as L1_MACRO, + LEVEL as L1_LEVEL, + certify_l1, + ) + if graph is None: return empty_protocol_section(), [] cat = catalog or load_catalog() instances = recognize_physical_buses(graph, cat) if not instances: sec = empty_protocol_section() - sec.macrophase = PACK_MACROPHASE + sec.macrophase = L1_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) 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, [])] dumps.append(row) sec = ProtocolCertificationSection( schema_version=SCHEMA_VERSION, - macrophase=PACK_MACROPHASE, + macrophase=L1_MACRO, recognized_instances=dumps, message="", - max_level_reached=LEVEL, + max_level_reached=L1_LEVEL, ) return sec, findings diff --git a/periscope/src/backend/periscopex/protocol_l1.py b/periscope/src/backend/periscopex/protocol_l1.py new file mode 100644 index 0000000..3adab4c --- /dev/null +++ b/periscope/src/backend/periscopex/protocol_l1.py @@ -0,0 +1,486 @@ +"""M3: L1 GEOMETRIC protocol certifier. Length, vias, layer, grouping, skew. + +Skew is compared only when the constraint is NUMERIC (length unit) or an +explicit DESIGN LIMIT. Otherwise UNKNOWN / CONTROLLER_DEPENDENT / +PHY_DEPENDENT / VENDOR_DEPENDENT. Length is not delay. Not electrical. +RECOMMENDED never FAIL. Internal interfaces are NOT_APPLICABLE. +Missing DQ/DQS grouping or missing PCB geometry is a visible skip, not PASS. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + +from backend.periscopex.finding_engine import complete_finding +from backend.periscopex.models import DesignGraph, Finding, LayoutGraph +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, SignalGroup +from backend.periscopex.si_check import net_length_mm + +PACK_MACROPHASE = "M3" +SOURCE = "protocol_l1" +LEVEL = "L1" + +L1_CHECKS = frozenset({ + "length", + "clock_length", + "data_length", + "intra_pair_skew", + "pair_skew", + "dq_to_dqs_skew", + "byte_lane_skew", + "ck_to_command_skew", + "clock_to_data_skew", + "vias", + "via_count", + "stubs", + "stub_length", + "topology", + "byte_lane_mapping", + "dq_group", + "dqs_relationship_if_present", + "cmd_dat_grouping", + "lane_relationships", +}) + +_TIME_UNITS = frozenset({"ps", "ns", "us", "µs", "ms", "s"}) + +MandatoryClass = Literal["MANDATORY", "RECOMMENDED", "OPTIONAL", "INFORMATIONAL"] +L1Result = Literal[ + "PASS", "FAIL", "WARNING", "UNKNOWN", "NOT_APPLICABLE", + "VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT", "MISSING_SOURCE", +] + + +class L1CheckResult(BaseModel): + check: str + level: Literal["L1"] = "L1" + result: L1Result + mandatory: MandatoryClass + nets: list[str] = Field(default_factory=list) + measured_mm: float | None = None + limit_mm: float | None = None + margin_mm: 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_mm: float | None = None, + limit_mm: float | None = None, + value_kind: str = "", + notes: str = "", +) -> L1CheckResult: + 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 + if measured_mm is not None and limit_mm is not None: + margin = limit_mm - measured_mm + return L1CheckResult( + check=check, + result=result, # type: ignore[arg-type] + mandatory=mandatory, + nets=nets or [], + measured_mm=measured_mm, + limit_mm=limit_mm, + margin_mm=margin, + value_kind=value_kind, + notes=notes, + finding_class=cls, + status=status, + ) + + +def geometric_verdict(constraint: ProtocolConstraint | None, measured_mm: float | None) -> str: + """PASS/FAIL only with NUMERIC length limit or DESIGN LIMIT. Never invent ps.""" + if constraint is None: + return "UNKNOWN" + kind = constraint.value_kind + unit = (constraint.unit or "").strip().lower() + design = constraint.limit_kind == "DESIGN" and constraint.value is not None + numeric = kind == "NUMERIC" and constraint.value is not None + if numeric or design: + if unit in _TIME_UNITS: + return "UNKNOWN" + if measured_mm is None: + return "UNKNOWN" + if measured_mm <= constraint.value: + return "PASS" + return "FAIL" + if kind in { + "UNKNOWN", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT", + "VENDOR_DEPENDENT", "MISSING_SOURCE", "NOT_APPLICABLE", + }: + return kind + return "UNKNOWN" + + +def _len(layout: LayoutGraph, net: str) -> float: + return net_length_mm(layout, net) + + +def _via_count(layout: LayoutGraph, net: str) -> int: + return sum(1 for v in layout.vias if v.net == net) + + +def _layers(layout: LayoutGraph, net: str) -> list[str]: + return sorted({s.layer for s in layout.segments if s.net == net and s.layer}) + + +def _instance_nets(inst: PhysicalBusInstance) -> list[str]: + names: list[str] = [] + for g in inst.groups: + names.extend(g.nets) + names.extend(inst.nets) + # preserve order, unique + 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 _skip_no_geometry(check: str, mand: MandatoryClass, inst: PhysicalBusInstance) -> L1CheckResult: + return _pack( + check, "UNKNOWN", mand, + notes=( + f"Interfaccia {inst.physical_interface_id} non certificata a L1 " + f"per mancanza di geometria PCB (piste). L1 is geometric, not electrical." + ), + ) + + +def _skip_grouping(check: str, mand: MandatoryClass, inst: PhysicalBusInstance) -> L1CheckResult: + return _pack( + check, "UNKNOWN", mand, + notes=( + f"Interfaccia {inst.physical_interface_id} non certificata a L1 " + f"per mancanza di grouping (DQ/DQS). Not PASS." + ), + ) + + +def certify_instance_l1( + graph: DesignGraph, + inst: PhysicalBusInstance, + iface: PhysicalInterface, + layout: LayoutGraph | None, +) -> list[L1CheckResult]: + if inst.pcb_relevant == "NO" or iface.pcb_relevant == "NO": + return [_pack( + "pcb_routing", "NOT_APPLICABLE", "INFORMATIONAL", + notes="Internal interconnect — L1 geometric N/A, not FAIL, not electrical.", + )] + checks = [c for c in iface.required_checks if c in L1_CHECKS] + if not checks: + checks = ["length", "topology"] + return [_run_l1_check(graph, inst, iface, layout, c) for c in checks] + + +def _run_l1_check( + graph: DesignGraph, + inst: PhysicalBusInstance, + iface: PhysicalInterface, + layout: LayoutGraph | None, + check: str, +) -> L1CheckResult: + mand = _mandatory(iface, check) + cons = _constraint_for(iface, check) + kind = cons.value_kind if cons else "UNKNOWN" + if layout is None: + return _skip_no_geometry(check, mand, inst) + + grouping_names = { + "byte_lane_mapping", "dq_group", "dqs_relationship_if_present", + "dq_to_dqs_skew", "byte_lane_skew", + } + lanes = [g for g in inst.groups if g.kind == "BYTE_LANE"] + if check in grouping_names and not lanes: + return _skip_grouping(check, mand, inst) + if check == "cmd_dat_grouping": + cmd = [g for g in inst.groups if g.kind in {"COMMAND_GROUP", "ADDRESS_GROUP", "CLOCK_GROUP"}] + if not cmd: + return _skip_grouping(check, mand, inst) + return _pack( + check, "PASS", mand, nets=_instance_nets(inst), value_kind=kind, + notes="CMD/DAT grouping present. L1 is not electrical certification.", + ) + + if check in {"byte_lane_mapping", "dq_group", "dqs_relationship_if_present"}: + return _pack( + check, "PASS", mand, nets=_instance_nets(inst), value_kind=kind, + notes="Geometric grouping present. L1 is not electrical certification.", + ) + + if check == "topology": + nets = _instance_nets(inst) + routed = [n for n in nets if _len(layout, n) > 0] + if not routed: + return _skip_no_geometry(check, mand, inst) + layers = sorted({ly for n in routed for ly in _layers(layout, n)}) + return _pack( + check, "PASS", mand, nets=routed, value_kind=kind, + notes=f"Routed on layers {layers}. Geometric topology only, not electrical.", + ) + + if check in {"length", "clock_length", "data_length"}: + nets = _clock_or_data_nets(inst, check) + if not nets: + nets = _instance_nets(inst) + if not nets: + return _skip_no_geometry(check, mand, inst) + measured = max(_len(layout, n) for n in nets) + limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None + verdict = geometric_verdict(cons, measured) + return _pack( + check, verdict, mand, nets=nets, measured_mm=measured, + limit_mm=limit, value_kind=kind, + notes="Geometric length (mm). Not delay; not electrical Z.", + ) + + if check in {"vias", "via_count"}: + nets = _instance_nets(inst) + count = sum(_via_count(layout, n) for n in nets) + measured = float(count) + verdict = geometric_verdict(cons, measured) + return _pack( + check, verdict, mand, nets=nets, measured_mm=measured, + limit_mm=cons.value if cons and cons.value_kind == "NUMERIC" else None, + value_kind=kind, + notes=f"Via count {count} (geometric). No invented via ampacity.", + ) + + if check in {"stubs", "stub_length"}: + verdict = geometric_verdict(cons, None) + if verdict == "FAIL": + verdict = "UNKNOWN" + return _pack( + check, verdict if verdict != "PASS" else "UNKNOWN", mand, + nets=_instance_nets(inst), value_kind=kind, + notes="Stub length not certified without a NUMERIC/DESIGN millimetre limit. Not invented.", + ) + + if check in {"intra_pair_skew", "pair_skew"}: + return _pair_skew(inst, layout, cons, mand, check, kind) + + if check == "dq_to_dqs_skew": + return _dq_dqs_skew(inst, lanes, layout, cons, mand, kind) + + if check == "byte_lane_skew": + return _lane_spread(lanes, layout, cons, mand, kind, "byte_lane_skew") + + if check in {"ck_to_command_skew", "clock_to_data_skew", "lane_relationships"}: + measured = _group_spread(inst, layout) + verdict = geometric_verdict(cons, measured) + return _pack( + check, verdict, mand, nets=_instance_nets(inst), + measured_mm=measured, value_kind=kind, + notes="Geometric length spread. No invented ps. Not electrical.", + ) + return _pack(check, "UNKNOWN", mand, value_kind=kind, notes="Not an L1 geometric check.") + + +def _clock_or_data_nets(inst: PhysicalBusInstance, check: str) -> list[str]: + if check == "clock_length": + return [n for g in inst.groups if g.kind == "CLOCK_GROUP" for n in g.nets] + if check == "data_length": + return [ + n for g in inst.groups + if g.kind in {"BYTE_LANE", "DATA_LANE", "DIFFERENTIAL_PAIR"} + for n in g.nets + ] + return _instance_nets(inst) + + +def _pair_skew( + inst: PhysicalBusInstance, + layout: LayoutGraph, + cons: ProtocolConstraint | None, + mand: MandatoryClass, + check: str, + kind: str, +) -> L1CheckResult: + pairs = [g for g in inst.groups if g.kind == "DIFFERENTIAL_PAIR"] + if not pairs: + return _pack( + check, "UNKNOWN", mand, value_kind=kind, + notes="No differential pair group for geometric skew.", + ) + deltas: list[float] = [] + nets: list[str] = [] + for g in pairs: + if len(g.nets) < 2: + continue + a, b = g.nets[0], g.nets[1] + deltas.append(abs(_len(layout, a) - _len(layout, b))) + nets.extend([a, b]) + measured = max(deltas) if deltas else None + verdict = geometric_verdict(cons, measured) + limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None + return _pack( + check, verdict, mand, nets=nets, measured_mm=measured, + limit_mm=limit, value_kind=kind, + notes="Intra-pair geometric length delta (mm). Not ps, not electrical Z.", + ) + + +def _dq_dqs_skew( + inst: PhysicalBusInstance, + lanes: list[SignalGroup], + layout: LayoutGraph, + cons: ProtocolConstraint | None, + mand: MandatoryClass, + kind: str, +) -> L1CheckResult: + deltas: list[float] = [] + nets: list[str] = [] + for lane in lanes: + dqs = [n for k, n in lane.roles.items() if k.upper().startswith("DQS")] + dqs_len = [_len(layout, n) for n in dqs] + ref = sum(dqs_len) / len(dqs_len) if dqs_len else None + if ref is None: + continue + for key, net in lane.roles.items(): + if key.upper().startswith("DQ") and not key.upper().startswith("DQS"): + deltas.append(abs(_len(layout, net) - ref)) + nets.append(net) + nets.extend(dqs) + measured = max(deltas) if deltas else None + if measured is None: + return _skip_grouping("dq_to_dqs_skew", mand, inst) + verdict = geometric_verdict(cons, measured) + limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None + return _pack( + "dq_to_dqs_skew", verdict, mand, nets=nets, measured_mm=measured, + limit_mm=limit, value_kind=kind, + notes="DQ-to-DQS geometric length delta (mm). No invented ps.", + ) + + +def _lane_spread( + lanes: list[SignalGroup], + layout: LayoutGraph, + cons: ProtocolConstraint | None, + mand: MandatoryClass, + kind: str, + check: str, +) -> L1CheckResult: + spreads: list[float] = [] + nets: list[str] = [] + for lane in lanes: + lens = [_len(layout, n) for n in lane.nets] + if not lens: + continue + spreads.append(max(lens) - min(lens)) + nets.extend(lane.nets) + measured = max(spreads) if spreads else None + verdict = geometric_verdict(cons, measured) + limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None + return _pack( + check, verdict, mand, nets=nets, measured_mm=measured, + limit_mm=limit, value_kind=kind, + notes="Byte-lane geometric length spread (mm). Not a global length-match PASS.", + ) + + +def _group_spread(inst: PhysicalBusInstance, layout: LayoutGraph) -> float | None: + nets = _instance_nets(inst) + lens = [_len(layout, n) for n in nets if _len(layout, n) > 0] + if len(lens) < 2: + return None + return max(lens) - min(lens) + + +def l1_findings(inst: PhysicalBusInstance, results: list[L1CheckResult]) -> 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-L1-003" + status = "ERROR" + finding = f"L1 geometric {r.check}: {r.notes}" + elif skip_family: + grouping = "grouping" in (r.notes or "").lower() or "dq/dqs" in (r.notes or "").lower() + rule_id = "PE-PRT-L1-002" if grouping else "PE-PRT-L1-001" + status = "WARNING" + finding = r.notes or ( + f"Interfaccia {inst.physical_interface_id} non certificata a L1 " + f"per {r.check} ({r.result}). Not electrical." + ) + else: + rule_id = "PE-PRT-L0-002" + status = "INFO" + finding = r.notes + f = Finding( + designator=designator, + mpn="", + aspect="protocol_l1", + 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="L1 geometric vs NUMERIC/DESIGN length only; never invent mm/ps.", + inference="L1 is geometric, not electrical certification.", + 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_l1( + graph: DesignGraph, + instances: list[PhysicalBusInstance], + layout: LayoutGraph | None, + catalog: ProtocolCatalog | None = None, +) -> tuple[dict[str, list[L1CheckResult]], list[Finding]]: + cat = catalog or load_catalog() + ifaces = {p.id: p for p in cat.physical_interfaces} + by_id: dict[str, list[L1CheckResult]] = {} + findings: list[Finding] = [] + for inst in instances: + iface = ifaces.get(inst.physical_interface_id) + if iface is None: + continue + rows = certify_instance_l1(graph, inst, iface, layout) + by_id[inst.instance_id] = rows + findings.extend(l1_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 ffa66c8..33e67c8 100644 --- a/periscope/src/backend/services/pcb_pipeline.py +++ b/periscope/src/backend/services/pcb_pipeline.py @@ -376,7 +376,7 @@ async def run_pcb_pipeline( return _step(project_id, "write_report", "running") - proto_section, proto_findings = protocol_exam(graph) + proto_section, proto_findings = protocol_exam(graph, layout=layout) 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 34ec252..dca8b3e 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.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. + +- [New] `protocol_l1.py`; `PE-PRT-L1-001` / `PE-PRT-L1-002` / `PE-PRT-L1-003`. +- [New] pytest `tests/pcb/test_protocol_l1_m3.py`. + ## 2.66.0 — 2026-09-22 — Protocol certification M2 (L0 structural) L0 STRUCTURAL certifier: nets must exist and connect at least two components. FAIL only for missing **MANDATORY** structure. AXI4 / internal interconnects are **NOT_APPLICABLE** (not FAIL). RECOMMENDED never FAIL. No L1–L3, no invented ohms/mm/ps. diff --git a/periscope/src/frontend/package.json b/periscope/src/frontend/package.json index b9b4f64..86cdd10 100644 --- a/periscope/src/frontend/package.json +++ b/periscope/src/frontend/package.json @@ -1,6 +1,6 @@ { "name": "periscope-web", - "version": "2.66.0", + "version": "2.67.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 4994e69..5f2529e 100644 --- a/periscope/src/frontend/src/components/report/protocol-section.tsx +++ b/periscope/src/frontend/src/components/report/protocol-section.tsx @@ -13,8 +13,8 @@ export function ProtocolSection({

Protocolli

- Logical protocol vs physical interface. L0 structural only — - presence and connection, no invented impedance. + Logical protocol vs physical interface. L0 structural, L1 geometric + (length, vias, layer, grouping). Not electrical; no invented ohms/mm/ps.

{instances.length === 0 ? (

{message}

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

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

+ L1{" "} + {(row.l1_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 c43a612..df73b16 100644 --- a/periscope/src/frontend/src/lib/layout-finding.ts +++ b/periscope/src/frontend/src/lib/layout-finding.ts @@ -27,6 +27,7 @@ export function isLayoutFinding(f: { f.source === "af_ai_hf" || f.source === "af_trace_check" || f.source === "protocol_l0" || + f.source === "protocol_l1" || 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 1ab4ab5..48bf825 100644 --- a/tests/pcb/test_protocol_l0_m2.py +++ b/tests/pcb/test_protocol_l0_m2.py @@ -13,7 +13,6 @@ from backend.periscopex.models import ( PinConnection, ) from backend.periscopex.protocol_l0 import ( - PACK_MACROPHASE, _pack, certify_l0, protocol_exam, @@ -130,8 +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 == "L0" - assert sec.macrophase == PACK_MACROPHASE + assert sec.max_level_reached == "L1" + from backend.periscopex.protocol_l1 import PACK_MACROPHASE as L1_MACRO + assert sec.macrophase == L1_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 new file mode 100644 index 0000000..940a59f --- /dev/null +++ b/tests/pcb/test_protocol_l1_m3.py @@ -0,0 +1,264 @@ +"""M3 L1 GEOMETRIC certifier: mm vs NUMERIC/DESIGN only; no invented ps/ohm.""" + +from __future__ import annotations + +import json + +from backend.periscopex.models import ( + Component, + ComponentType, + DesignGraph, + LayoutGraph, + LayoutSegment, + LayoutVia, + Net, + NetType, + PinConnection, +) +from backend.periscopex.protocol_catalog import ( + ConstraintSource, + PhysicalInterface, + ProtocolConstraint, +) +from backend.periscopex.protocol_l0 import protocol_exam +from backend.periscopex.protocol_l1 import ( + _pack, + certify_instance_l1, + certify_l1, + geometric_verdict, +) +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 _usb_layout(*, length_p: float = 10.0, length_m: float = 10.1) -> LayoutGraph: + return LayoutGraph( + segments=[ + LayoutSegment(start=(0, 0), end=(length_p, 0), width=0.2, layer="F.Cu", net="USB_D+"), + LayoutSegment(start=(0, 0.2), end=(length_m, 0.2), width=0.2, layer="F.Cu", net="USB_D-"), + ], + vias=[LayoutVia(x=1.0, y=0.0, net="USB_D+", drill=0.3, layers=("F.Cu", "B.Cu"))], + ) + + +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_with_lanes() -> 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 _ddr_no_grouping() -> DesignGraph: + pins = {str(i): f"MEM_DAT{i}" for i in range(8)} + return DesignGraph( + components={"U5": _ic("U5", pins, mpn="MT41K256M16TW", value="DDR4")}, + nets={n: _net(n, ("U5", p)) for p, n in pins.items()}, + ) + + +def _cite() -> ConstraintSource: + return ConstraintSource(document="project-netclass.txt", organization="design") + + +def _design_mm(parameter: str, value: float) -> ProtocolConstraint: + return ProtocolConstraint( + id=f"design-{parameter}", + parameter=parameter, + value_kind="NUMERIC", + mandatory="MANDATORY", + source_type="PCB", + source_class="IMPLEMENTATION", + value=value, + unit="mm", + source=_cite(), + limit_kind="DESIGN", + ) + + +def _l1_for(graph: DesignGraph, logical_sub: str, layout: LayoutGraph | None): + insts = recognize_physical_buses(graph) + by, findings = certify_l1(graph, insts, layout) + 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 test_usb_skew_without_numeric_is_unknown_not_invented_ps(): + rows, _, _ = _l1_for(_usb_connected(), "usb2", _usb_layout()) + skew = [r for r in rows if r.check == "intra_pair_skew"] + assert skew + assert skew[0].result in {"UNKNOWN", "MISSING_SOURCE", "VENDOR_DEPENDENT"} + assert skew[0].result != "PASS" + blob = json.dumps(skew[0].model_dump()) + assert "ohm" not in blob.lower() + assert "90" not in blob + assert "ps" not in blob.lower() or skew[0].result != "PASS" + + +def test_usb_length_unknown_without_numeric_limit(): + rows, _, _ = _l1_for(_usb_connected(), "usb2", _usb_layout()) + length = [r for r in rows if r.check == "length"] + assert length + assert length[0].measured_mm is not None + assert length[0].result in {"UNKNOWN", "MISSING_SOURCE"} + assert length[0].limit_mm is None + + +def test_usb_no_layout_is_visible_skip_not_pass(): + rows, findings, _ = _l1_for(_usb_connected(), "usb2", None) + assert rows + assert all(r.result != "PASS" or r.check == "never" for r in rows) + assert any(r.result == "UNKNOWN" for r in rows) + assert any(f.rule_id == "PE-PRT-L1-001" for f in findings) + assert any("non certificata a L1" in (f.finding or "") for f in findings) + + +def test_axi_internal_l1_not_applicable(): + rows, findings, _ = _l1_for(_axi_graph(), "axi4", LayoutGraph()) + assert rows + 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 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() + + +def test_ddr_missing_grouping_visible_skip_not_pass(): + rows, findings, insts = _l1_for(_ddr_no_grouping(), "ddr4", LayoutGraph()) + assert insts + mapping = [r for r in rows if r.check in {"byte_lane_mapping", "dq_to_dqs_skew", "byte_lane_skew"}] + assert mapping + assert all(r.result != "PASS" for r in mapping) + assert any(r.result == "UNKNOWN" for r in mapping) + assert any(f.rule_id == "PE-PRT-L1-002" for f in findings) + assert any("grouping" in (f.finding or "").lower() for f in findings) + + +def test_ddr_reconstructed_lane_skew_stays_phy_dependent(): + layout = LayoutGraph(segments=[ + LayoutSegment(start=(0, 0), end=(20, 0), layer="F.Cu", net="DDR4_DQ0"), + LayoutSegment(start=(0, 0), end=(21, 0), layer="F.Cu", net="DDR4_DQS0_P"), + ]) + rows, _, insts = _l1_for(_ddr_with_lanes(), "ddr4", layout) + assert any(g.kind == "BYTE_LANE" for i in insts for g in i.groups) + mapping = [r for r in rows if r.check == "byte_lane_mapping"] + assert mapping and mapping[0].result == "PASS" + skew = [r for r in rows if r.check == "dq_to_dqs_skew"] + assert skew + assert skew[0].result in {"PHY_DEPENDENT", "CONTROLLER_DEPENDENT", "VENDOR_DEPENDENT", "UNKNOWN"} + assert skew[0].result != "PASS" + assert skew[0].limit_mm is None + + +def test_numeric_mm_design_limit_pass_and_fail(): + pass_v = geometric_verdict(_design_mm("length", 50.0), 10.0) + fail_v = geometric_verdict(_design_mm("length", 50.0), 80.0) + assert pass_v == "PASS" + assert fail_v == "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=["length"], + constraints=[_design_mm("length", 50.0)], + ) + short = certify_instance_l1(graph, usb, iface, _usb_layout(length_p=10, length_m=10)) + assert short[0].result == "PASS" + assert short[0].measured_mm == 10.0 + assert short[0].limit_mm == 50.0 + long = certify_instance_l1(graph, usb, iface, _usb_layout(length_p=80, length_m=80)) + assert long[0].result == "FAIL" + assert long[0].mandatory == "MANDATORY" + + +def test_time_unit_numeric_is_unknown_not_converted_ps(): + cons = ProtocolConstraint( + id="skew-ps", + parameter="intra_pair_skew", + value_kind="NUMERIC", + mandatory="MANDATORY", + source_type="STANDARD", + source_class="NORMATIVE", + value=15.0, + unit="ps", + source=ConstraintSource(document="USB-IF", organization="USB-IF"), + limit_kind="STANDARD", + ) + assert geometric_verdict(cons, 0.05) == "UNKNOWN" + + +def test_recommended_never_fail_l1(): + rec = _pack("length", "FAIL", "RECOMMENDED", notes="over") + assert rec.result != "FAIL" + assert rec.status != "ERROR" + + +def test_l1_does_not_run_impedance(): + rows, _, _ = _l1_for(_usb_connected(), "usb2", _usb_layout()) + assert not any(r.check in {"differential_impedance", "impedance", "termination"} for r in rows) + blob = json.dumps([r.model_dump() for r in rows]) + assert "90" not in blob + assert "electrical Z" in blob or "not electrical" in blob.lower() + + +def test_protocol_exam_attaches_l1_checks(): + sec, _ = protocol_exam(_usb_connected(), layout=_usb_layout()) + assert sec.max_level_reached == "L1" + assert sec.recognized_instances + row = sec.recognized_instances[0] + assert row.get("l1_checks") + assert all(c.get("level") == "L1" for c in row["l1_checks"])