From 3cc2e5469bf29c677462478e2f24d8dd507ae69b Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 22 Sep 2026 12:12:25 +0200 Subject: [PATCH] Add protocol-certification M2 L0 structural certifier (2.66.0). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presence and connection only. FAIL solely on missing MANDATORY structure. AXI4 internal is NOT_APPLICABLE, not FAIL. RECOMMENDED never FAIL. No L1–L3 and no invented ohms/mm/ps. --- .../src/backend/periscopex/finding_engine.py | 4 + .../src/backend/periscopex/protocol_l0.py | 314 ++++++++++++++++++ .../backend/periscopex/protocol_recognize.py | 20 +- .../src/backend/services/pcb_pipeline.py | 6 +- periscope/src/backend/services/validation.py | 8 +- periscope/src/frontend/content/changelog.md | 7 + periscope/src/frontend/package.json | 2 +- .../components/report/protocol-section.tsx | 12 +- .../src/frontend/src/lib/layout-finding.ts | 2 + periscope/src/frontend/src/lib/version.ts | 2 +- tests/pcb/test_protocol_l0_m2.py | 155 +++++++++ tests/pcb/test_protocol_recognize_m1.py | 3 - 12 files changed, 506 insertions(+), 29 deletions(-) create mode 100644 periscope/src/backend/periscopex/protocol_l0.py create mode 100644 tests/pcb/test_protocol_l0_m2.py diff --git a/periscope/src/backend/periscopex/finding_engine.py b/periscope/src/backend/periscopex/finding_engine.py index d3518ee..4d399fa 100644 --- a/periscope/src/backend/periscopex/finding_engine.py +++ b/periscope/src/backend/periscopex/finding_engine.py @@ -290,6 +290,10 @@ def _seed() -> None: requirement="Via series inductance from drill, span, and dielectric height.") _add("PE-AF-061", "RECOMMENDED", "RISK", domain="pcb", requirement="Via stub length vs λ/20 when span and f are FACT.") + _add("PE-PRT-L0-001", "MANDATORY", "RULE", domain="pcb", + 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.") _seed() diff --git a/periscope/src/backend/periscopex/protocol_l0.py b/periscope/src/backend/periscopex/protocol_l0.py new file mode 100644 index 0000000..7d36204 --- /dev/null +++ b/periscope/src/backend/periscopex/protocol_l0.py @@ -0,0 +1,314 @@ +"""M2: L0 STRUCTURAL protocol certifier. Presence and connection only. + +FAIL only when a MANDATORY structural net/group is missing or unconnected. +Internal interconnects (AXI4, AMBA, HBM DQ) are NOT_APPLICABLE, never FAIL. +RECOMMENDED never FAIL. No L1–L3. No invented ohms/mm/ps. +""" + +from __future__ import annotations + +import re +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.pcb_net_match import normalize_kicad_hierarchy_net +from backend.periscopex.protocol_catalog import ( + SCHEMA_VERSION, + PhysicalInterface, + ProtocolCatalog, + ProtocolCertificationSection, + ProtocolConstraint, + empty_protocol_section, + load_catalog, + map_protocol_outcome, +) +from backend.periscopex.protocol_recognize import ( + PhysicalBusInstance, + recognize_physical_buses, +) + +PACK_MACROPHASE = "M2" +SOURCE = "protocol_l0" +LEVEL = "L0" + +L0_CHECKS = frozenset({ + "topology", + "byte_lane_mapping", + "cmd_dat_grouping", + "clk_reference", + "cc_rd_rp", + "vbus_gnd", + "pcb_routing", + "pcb_individual_dq_routing", + "dq_group", + "superspeed_pairs", +}) + +_CC_RE = re.compile(r"(?:^|[_/.])CC[12]$", re.I) +_VBUS_RE = re.compile(r"VBUS|\+?VUSB|USB_VBUS", re.I) +_GND_RE = re.compile(r"(?:^|[_/.])(?:GND|VSS|DGND)(?:$|[_/.])", re.I) +_SS_RE = re.compile(r"SSTX|SSRX|SS_T[XR]|USB3|USB_SS", re.I) + +MandatoryClass = Literal["MANDATORY", "RECOMMENDED", "OPTIONAL", "INFORMATIONAL"] + + +class L0CheckResult(BaseModel): + check: str + level: Literal["L0"] = "L0" + result: Literal[ + "PASS", "FAIL", "WARNING", "UNKNOWN", "NOT_APPLICABLE", "VENDOR_DEPENDENT", + ] + mandatory: MandatoryClass + nets: list[str] = Field(default_factory=list) + notes: str = "" + finding_class: str = "" + status: str = "" + + +def _leaf(name: str) -> str: + n = normalize_kicad_hierarchy_net(name) + return n.split("/")[-1] if n else "" + + +def _net_present(graph: DesignGraph, name: str) -> bool: + return name in graph.nets + + +def _net_connected(graph: DesignGraph, name: str) -> bool: + net = graph.nets.get(name) + if not net: + return False + refs = {p.component_ref for p in net.pins if p.component_ref} + return len(refs) >= 2 + + +def _constraint_for(iface: PhysicalInterface, check: str) -> ProtocolConstraint | None: + aliases = { + "cc_rd_rp": "cc_termination", + "pcb_routing": "pcb_routing", + "pcb_individual_dq_routing": "pcb_individual_dq_routing", + "superspeed_pairs": "superspeed_pairs", + "topology": "topology", + "byte_lane_mapping": "topology", + } + want = aliases.get(check, check) + for c in iface.constraints: + if c.parameter == want or c.parameter == check: + return c + return None + + +def _mandatory(iface: PhysicalInterface, check: str) -> MandatoryClass: + c = _constraint_for(iface, check) + if c: + return c.mandatory + if check in {"superspeed_pairs", "pcb_routing", "pcb_individual_dq_routing"}: + return "INFORMATIONAL" + return "MANDATORY" + + +def _pack(check: str, result: str, mandatory: MandatoryClass, *, + nets: list[str] | None = None, notes: str = "") -> L0CheckResult: + if result == "FAIL" and mandatory != "MANDATORY": + result = "WARNING" + cls, status, _ev = map_protocol_outcome(result, mandatory) # type: ignore[arg-type] + return L0CheckResult( + check=check, result=result, mandatory=mandatory, # type: ignore[arg-type] + nets=nets or [], notes=notes, + finding_class=cls, status=status, + ) + + +def _structural_nets_ok(graph: DesignGraph, names: list[str]) -> tuple[bool, str]: + missing = [n for n in names if not _net_present(graph, n)] + if missing: + return False, f"missing net(s): {', '.join(missing)}" + dangling = [n for n in names if not _net_connected(graph, n)] + if dangling: + return False, f"unconnected net(s): {', '.join(dangling)}" + return True, "present and connected" + + +def certify_instance_l0( + graph: DesignGraph, + inst: PhysicalBusInstance, + iface: PhysicalInterface, +) -> list[L0CheckResult]: + if inst.pcb_relevant == "NO" or iface.pcb_relevant == "NO": + return [_pack( + "pcb_routing", "NOT_APPLICABLE", "INFORMATIONAL", + notes="Internal interconnect — not PCB routing. Not FAIL.", + )] + out: list[L0CheckResult] = [] + checks = [c for c in iface.required_checks if c in L0_CHECKS] + if not checks: + checks = ["topology"] + for check in checks: + mand = _mandatory(iface, check) + out.append(_run_l0_check(graph, inst, check, mand)) + return out + + +def _run_l0_check( + graph: DesignGraph, + inst: PhysicalBusInstance, + check: str, + mand: MandatoryClass, +) -> L0CheckResult: + if check in {"pcb_routing", "pcb_individual_dq_routing"}: + return _pack(check, "NOT_APPLICABLE", mand, + notes="Not a PCB-routed individual channel at L0.") + if check == "superspeed_pairs": + ss = [n for n in graph.nets if _SS_RE.search(_leaf(n))] + return _pack( + check, "NOT_APPLICABLE", mand, nets=ss, + notes="USB2-only physical interface: SuperSpeed pairs not required.", + ) + if check == "topology": + names: list[str] = [] + for g in inst.groups: + if g.kind == "DIFFERENTIAL_PAIR": + names.extend(g.nets) + if not names: + names = list(inst.nets) + if len(names) < 2: + return _pack( + check, "FAIL", mand, nets=names, + notes="MANDATORY differential pair or bus nets not present.", + ) + ok, msg = _structural_nets_ok(graph, names) + return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg) + if check == "byte_lane_mapping" or check == "dq_group": + lanes = [g for g in inst.groups if g.kind == "BYTE_LANE"] + if not lanes: + return _pack( + check, "FAIL", mand, + notes="MANDATORY byte-lane / DQ group not reconstructed.", + ) + names = [n for g in lanes for n in g.nets] + ok, msg = _structural_nets_ok(graph, names) + return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg) + if check == "cmd_dat_grouping": + names = [n for g in inst.groups for n in g.nets] + if not names: + return _pack(check, "FAIL", mand, notes="No CMD/DAT group nets.") + ok, msg = _structural_nets_ok(graph, names) + return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg) + if check == "clk_reference": + clocks = [n for g in inst.groups if g.kind == "CLOCK_GROUP" for n in g.nets] + if not clocks: + return _pack(check, "FAIL", mand, notes="Clock reference net missing.") + ok, msg = _structural_nets_ok(graph, clocks) + return _pack(check, "PASS" if ok else "FAIL", mand, nets=clocks, notes=msg) + if check == "cc_rd_rp": + ccs = [n for n in graph.nets if _CC_RE.search(_leaf(n))] + if len(ccs) < 1: + return _pack(check, "FAIL", mand, notes="CC1/CC2 net not present.") + ok, msg = _structural_nets_ok(graph, ccs) + return _pack(check, "PASS" if ok else "FAIL", mand, nets=ccs, notes=msg) + if check == "vbus_gnd": + vbus = [n for n in graph.nets if _VBUS_RE.search(_leaf(n))] + gnd = [n for n in graph.nets if _GND_RE.search(_leaf(n))] + names = vbus + gnd + if not vbus or not gnd: + return _pack( + check, "FAIL", mand, nets=names, + notes="VBUS and/or GND net not present.", + ) + ok, msg = _structural_nets_ok(graph, names) + return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg) + return _pack(check, "NOT_APPLICABLE", mand, notes="Not an L0 structural check.") + + +def l0_findings( + inst: PhysicalBusInstance, + results: list[L0CheckResult], +) -> 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 + status = r.status if r.status in {"ERROR", "WARNING", "INFO"} else "INFO" + if r.result == "FAIL": + rule_id = "PE-PRT-L0-001" + finding = ( + f"L0 structural: {r.check} {r.notes or 'missing MANDATORY net/connection'}." + ) + else: + rule_id = "PE-PRT-L0-002" + finding = f"L0 {r.result}: {r.check} — {r.notes}".strip() + f = Finding( + designator=designator, + mpn="", + aspect="protocol_l0", + 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=( + "MANDATORY protocol nets must exist and connect at least two components." + if r.result == "FAIL" + else "Internal protocol is not certified as PCB routing." + ), + inference=f"{LEVEL} {r.result} (not electrical; no invented Z).", + provenance="MANDATORY" if r.mandatory == "MANDATORY" else "TYPICAL", + finding_class=r.finding_class if r.finding_class in {"RULE", "RISK", "REVIEW", "INFO"} else "INFO", + ) + out.append(complete_finding(f)) + return out + + +def certify_l0( + graph: DesignGraph, + instances: list[PhysicalBusInstance], + catalog: ProtocolCatalog | None = None, +) -> tuple[dict[str, list[L0CheckResult]], list[Finding]]: + cat = catalog or load_catalog() + ifaces = {p.id: p for p in cat.physical_interfaces} + by_id: dict[str, list[L0CheckResult]] = {} + findings: list[Finding] = [] + for inst in instances: + iface = ifaces.get(inst.physical_interface_id) + if iface is None: + continue + rows = certify_instance_l0(graph, inst, iface) + by_id[inst.instance_id] = rows + findings.extend(l0_findings(inst, rows)) + return by_id, findings + + +def protocol_exam( + graph: DesignGraph | None, + catalog: ProtocolCatalog | None = None, +) -> tuple[ProtocolCertificationSection, list[Finding]]: + 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 + return sec, [] + l0, findings = certify_l0(graph, instances, cat) + 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, [])] + dumps.append(row) + sec = ProtocolCertificationSection( + schema_version=SCHEMA_VERSION, + macrophase=PACK_MACROPHASE, + recognized_instances=dumps, + message="", + max_level_reached=LEVEL, + ) + return sec, findings diff --git a/periscope/src/backend/periscopex/protocol_recognize.py b/periscope/src/backend/periscopex/protocol_recognize.py index e6f49c3..aede08b 100644 --- a/periscope/src/backend/periscopex/protocol_recognize.py +++ b/periscope/src/backend/periscopex/protocol_recognize.py @@ -14,11 +14,8 @@ from pydantic import BaseModel, Field from backend.periscopex.models import Component, DesignGraph from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net from backend.periscopex.protocol_catalog import ( - EMPTY_PROTOCOL_MESSAGE, - SCHEMA_VERSION, ProtocolCatalog, ProtocolCertificationSection, - empty_protocol_section, load_catalog, ) @@ -548,17 +545,6 @@ def protocol_section_for_graph( graph: DesignGraph | None, catalog: ProtocolCatalog | None = None, ) -> ProtocolCertificationSection: - if graph is None: - return empty_protocol_section() - instances = recognize_physical_buses(graph, catalog) - if not instances: - sec = empty_protocol_section() - sec.macrophase = PACK_MACROPHASE - return sec - return ProtocolCertificationSection( - schema_version=SCHEMA_VERSION, - macrophase=PACK_MACROPHASE, - recognized_instances=[i.model_dump() for i in instances], - message="", - max_level_reached=None, - ) + from backend.periscopex.protocol_l0 import protocol_exam + + return protocol_exam(graph, catalog)[0] diff --git a/periscope/src/backend/services/pcb_pipeline.py b/periscope/src/backend/services/pcb_pipeline.py index ab53eca..ffa66c8 100644 --- a/periscope/src/backend/services/pcb_pipeline.py +++ b/periscope/src/backend/services/pcb_pipeline.py @@ -28,7 +28,7 @@ from backend.periscopex.layout_rules import needs_layout_rules_refresh from backend.periscopex.models import ( ComponentConstraints, ComponentType, DesignGraph, LayoutGraph, ValidationReport, ) -from backend.periscopex.protocol_recognize import protocol_section_for_graph +from backend.periscopex.protocol_l0 import protocol_exam from backend.periscopex.af_ai_hf import append_investigated, hypotheses_from_rows from backend.periscopex.af_trace_check import check_af_traces from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks @@ -376,6 +376,8 @@ async def run_pcb_pipeline( return _step(project_id, "write_report", "running") + proto_section, proto_findings = protocol_exam(graph) + findings.extend(proto_findings) assign_pcb_finding_ids(findings) annotate_findings_cad(findings, cad_index_from_graph(graph)) dec_path = ws.local_path("decisions.json") @@ -394,7 +396,7 @@ async def run_pcb_pipeline( summary=summary, coverage=coverage, not_reviewed=skipped + si_skip, - protocol_certification=protocol_section_for_graph(graph).model_dump(), + protocol_certification=proto_section.model_dump(), ) report_path = ws.local_path("pcb_report.json") report_path.write_text(report.model_dump_json(indent=2) + "\n") diff --git a/periscope/src/backend/services/validation.py b/periscope/src/backend/services/validation.py index a89d151..d4c2641 100644 --- a/periscope/src/backend/services/validation.py +++ b/periscope/src/backend/services/validation.py @@ -28,7 +28,7 @@ from backend.periscopex.interface_class_check import check_interface_classes from backend.periscopex.led_current_check import check_led_current from backend.periscopex.lifecycle import check_lifecycle, load_lifecycle_dir from backend.periscopex.models import ComponentType, DesignGraph, Finding, ValidationReport -from backend.periscopex.protocol_recognize import protocol_section_for_graph +from backend.periscopex.protocol_l0 import protocol_exam from backend.periscopex.nc_pin_check import check_nc_pins from backend.periscopex.parsers import ic_mpn_skip_reason from backend.periscopex.passive_rail_check import ( @@ -209,6 +209,8 @@ async def validate_design_async( return clean def _write_report(paused: bool = False) -> ValidationReport: + proto_section, proto_findings = protocol_exam(graph) + all_findings.extend(proto_findings) annotate_findings_cad(all_findings, graph.cad_index) assign_finding_ids(all_findings) dec_path = existing_path.with_name("decisions.json") @@ -229,7 +231,7 @@ async def validate_design_async( coverage=_sanitize_coverage(all_coverage), review_errors=dict(review_errors), not_reviewed=not_reviewed, - protocol_certification=protocol_section_for_graph(graph).model_dump(), + protocol_certification=proto_section.model_dump(), ) except Exception: report = ValidationReport( @@ -240,7 +242,7 @@ async def validate_design_async( coverage={}, review_errors=dict(review_errors), not_reviewed=not_reviewed, - protocol_certification=protocol_section_for_graph(graph).model_dump(), + protocol_certification=proto_section.model_dump(), ) report_dict = json.loads(report.model_dump_json(indent=2)) if preserved_comments is not None: diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index d664b70..34ec252 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.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. + +- [New] `protocol_l0.py`; `PE-PRT-L0-001` / `PE-PRT-L0-002`. +- [New] pytest `tests/pcb/test_protocol_l0_m2.py`. + ## 2.65.0 — 2026-09-22 — Protocol certification M1 (instance recognition) `physical_bus_instance` from declaration / silicon / part / pin / net / pairs. Output: instances, signal groups (DIFF pair, byte lane, clock/address), confidence. Ambiguity is **REVIEW**, never FAIL. AXI4 internal is recognised with `pcb_relevant: NO` and **no PCB nets**. No L0–L3 certifier. No invented ohms. diff --git a/periscope/src/frontend/package.json b/periscope/src/frontend/package.json index f5c8272..b9b4f64 100644 --- a/periscope/src/frontend/package.json +++ b/periscope/src/frontend/package.json @@ -1,6 +1,6 @@ { "name": "periscope-web", - "version": "2.65.0", + "version": "2.66.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 6f347be..4994e69 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. Recognition only (M1) — - no L0–L3 certifier, no invented impedance. + Logical protocol vs physical interface. L0 structural only — + presence and connection, no invented impedance.

{instances.length === 0 ? (

{message}

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

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

+ L0{" "} + {(row.l0_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 7249cec..c43a612 100644 --- a/periscope/src/frontend/src/lib/layout-finding.ts +++ b/periscope/src/frontend/src/lib/layout-finding.ts @@ -26,6 +26,7 @@ export function isLayoutFinding(f: { f.source === "pdn_check" || f.source === "af_ai_hf" || f.source === "af_trace_check" || + f.source === "protocol_l0" || rid.startsWith("PE-PLC") || rid.startsWith("PE-LAY") || rid.startsWith("PE-SI") || @@ -50,6 +51,7 @@ export function isLayoutFinding(f: { rid.startsWith("PE-ANT") || rid.startsWith("PE-PDN") || rid.startsWith("PE-AF") || + rid.startsWith("PE-PRT") || /^PE-BOM-01[0-4]$/.test(rid) ); } diff --git a/periscope/src/frontend/src/lib/version.ts b/periscope/src/frontend/src/lib/version.ts index bb8f7ee..6e343fd 100644 --- a/periscope/src/frontend/src/lib/version.ts +++ b/periscope/src/frontend/src/lib/version.ts @@ -1,3 +1,3 @@ /** Stamped from content/changelog.md by scripts/sync-version.mjs. */ -export const APP_VERSION = "2.65.0"; +export const APP_VERSION = "2.66.0"; export const APP_VERSION_DATE = "2026-09-22"; diff --git a/tests/pcb/test_protocol_l0_m2.py b/tests/pcb/test_protocol_l0_m2.py new file mode 100644 index 0000000..1ab4ab5 --- /dev/null +++ b/tests/pcb/test_protocol_l0_m2.py @@ -0,0 +1,155 @@ +"""M2 L0 structural certifier: presence/connection; AXI N/A; RECOMMENDED not FAIL.""" + +from __future__ import annotations + +import json + +from backend.periscopex.models import ( + Component, + ComponentType, + DesignGraph, + Net, + NetType, + PinConnection, +) +from backend.periscopex.protocol_l0 import ( + PACK_MACROPHASE, + _pack, + certify_l0, + protocol_exam, +) +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_dangling_minus() -> DesignGraph: + return DesignGraph( + components={ + "U1": _ic("U1", {"1": "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-", ("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_one_ended() -> DesignGraph: + pins = {str(i): f"DDR4_DQ{i}" for i in range(8)} + pins.update({"8": "DDR4_DQS0_P", "9": "DDR4_DQS0_N"}) + return DesignGraph( + components={"U5": _ic("U5", pins, mpn="MT41K256M16TW", value="DDR4")}, + nets={n: _net(n, ("U5", p)) for p, n in pins.items()}, + ) + + +def _l0_for(graph: DesignGraph, logical_sub: str) -> list: + insts = recognize_physical_buses(graph) + by, _ = certify_l0(graph, insts) + rows = [] + for inst in insts: + if logical_sub in inst.logical_protocol_id: + rows.extend(by.get(inst.instance_id, [])) + return rows + + +def test_usb_connected_pair_l0_topology_pass(): + rows = _l0_for(_usb_connected(), "usb2") + topo = [r for r in rows if r.check == "topology"] + assert topo + assert topo[0].result == "PASS" + assert topo[0].level == "L0" + blob = json.dumps(topo[0].model_dump()) + assert "ohm" not in blob.lower() + assert "90" not in blob + + +def test_usb_unconnected_dminus_l0_fail_mandatory(): + g = _usb_dangling_minus() + insts = recognize_physical_buses(g) + usb = [i for i in insts if "usb" in i.logical_protocol_id] + assert usb + by, findings = certify_l0(g, insts) + rows = [r for i in usb for r in by[i.instance_id]] + topo = [r for r in rows if r.check == "topology"] + assert topo + assert topo[0].result == "FAIL" + assert topo[0].mandatory == "MANDATORY" + assert any(f.status == "ERROR" and f.rule_id == "PE-PRT-L0-001" for f in findings) + + +def test_axi_internal_l0_not_applicable_not_fail(): + rows = _l0_for(_axi_graph(), "axi4") + assert rows + 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 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") + + +def test_ddr_unconnected_memory_fails_l0_connection(): + rows = _l0_for(_ddr_one_ended(), "ddr4") + mapping = [r for r in rows if r.check == "byte_lane_mapping"] + assert mapping + assert mapping[0].result == "FAIL" + assert "unconnected" in mapping[0].notes + + +def test_recommended_never_parses_as_fail(): + rec = _pack("return_path", "FAIL", "RECOMMENDED", notes="missing") + assert rec.result != "FAIL" + assert rec.status != "ERROR" + + +def test_l0_does_not_run_impedance(): + rows = _l0_for(_usb_connected(), "usb2") + assert not any(r.check in {"differential_impedance", "intra_pair_skew", "length"} for r in rows) diff --git a/tests/pcb/test_protocol_recognize_m1.py b/tests/pcb/test_protocol_recognize_m1.py index 59d2c98..a0409c4 100644 --- a/tests/pcb/test_protocol_recognize_m1.py +++ b/tests/pcb/test_protocol_recognize_m1.py @@ -13,7 +13,6 @@ from backend.periscopex.models import ( PinConnection, ) from backend.periscopex.protocol_recognize import ( - PACK_MACROPHASE, protocol_section_for_graph, recognize_physical_buses, ) @@ -143,7 +142,6 @@ def test_empty_graph_keeps_nessun_protocollo(): sec = protocol_section_for_graph(DesignGraph()) assert sec.message == "nessun protocollo riconosciuto" assert sec.recognized_instances == [] - assert sec.macrophase == PACK_MACROPHASE blob = json.dumps(sec.model_dump()) assert "ohm" not in blob.lower() @@ -151,7 +149,6 @@ def test_empty_graph_keeps_nessun_protocollo(): def test_report_section_lists_usb_without_z(): sec = protocol_section_for_graph(_usb_graph()) assert sec.recognized_instances - assert sec.max_level_reached is None blob = json.dumps(sec.model_dump()) assert "90" not in blob assert "Zdiff" not in blob