diff --git a/periscope/src/backend/periscopex/protocol_recognize.py b/periscope/src/backend/periscopex/protocol_recognize.py new file mode 100644 index 0000000..e6f49c3 --- /dev/null +++ b/periscope/src/backend/periscopex/protocol_recognize.py @@ -0,0 +1,564 @@ +"""M1: physical_bus_instance recognition. No L0–L3 certifier. No invented Z. + +Priority: declared > silicon/BOM > pinout > net/pin name > pair geometry. +Ambiguity is REVIEW, never FAIL. AXI4 internal does not attach PCB nets. +""" + +from __future__ import annotations + +import re +from typing import Any, Literal + +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, +) + +PACK_MACROPHASE = "M1" + +EvidenceKind = Literal[ + "declared", + "silicon", + "part", + "pinout", + "net_name", + "pair_geometry", +] +GroupKind = Literal[ + "DIFFERENTIAL_PAIR", + "BYTE_LANE", + "DATA_LANE", + "CLOCK_GROUP", + "ADDRESS_GROUP", + "COMMAND_GROUP", + "CONTROL_GROUP", + "STROBE_GROUP", + "CHANNEL", + "SUBCHANNEL", +] +RecognitionStatus = Literal["RECOGNIZED", "REVIEW", "NOT_APPLICABLE"] + +_EVIDENCE_SCORE: dict[str, float] = { + "declared": 1.0, + "silicon": 0.9, + "part": 0.85, + "pinout": 0.7, + "net_name": 0.55, + "pair_geometry": 0.4, +} + +_USB_C_RE = re.compile(r"USB[\s_\-]*C\b|TYPE[\s_\-]*C|USB4085", re.I) +_USB2_RE = re.compile(r"USB[\s_\-]*2|16P", re.I) +_USB3_RE = re.compile(r"USB[\s_\-]*3|SUPER\s*SPEED|\bSS\b", re.I) +_USB_HS_RE = re.compile(r"HIGH\s*SPEED|\b480\b|\bHS\b", re.I) +_USB_LSFS_RE = re.compile(r"LOW\s*SPEED|FULL\s*SPEED|\b12\s*MB|\b1\.5\s*MB", re.I) +_DDR_GEN = [ + ("ddr5", re.compile(r"\bDDR5\b", re.I)), + ("ddr4", re.compile(r"\bDDR4\b|MT41|K4A|IS43TR", re.I)), + ("ddr3l", re.compile(r"\bDDR3L\b", re.I)), + ("ddr3", re.compile(r"\bDDR3\b|MT41|K4B|IS43TR", re.I)), + ("ddr2", re.compile(r"\bDDR2\b|MT47", re.I)), + ("ddr1", re.compile(r"\bDDR1\b|\bDDR\b", re.I)), +] +_DDR4_PART = re.compile(r"MT41K|K4A|IS43TR|W631|EDY4016", re.I) +_AXI_RE = re.compile(r"\bAXI4(?:-LITE|-STREAM)?\b|\bAXI\b", re.I) +_AXI_C2C_RE = re.compile(r"CHIP[\s\-]*TO[\s\-]*CHIP|C2C|AXI.*PHY", re.I) +_DECLARE_RE = re.compile( + r"^(?:protocol|logical_protocol|physical_interface|bus)$", re.I, +) +_DQ_RE = re.compile(r"(?:^|[_/.])DQ(\d+)(?:$|[_/.])", re.I) +_DQS_RE = re.compile(r"(?:^|[_/.])DQS(\d*)(?:[_#]?(P|N|PINS|PLUS|MINUS))?(?:$|[_/.])", re.I) +_DM_RE = re.compile(r"(?:^|[_/.])(?:DM|DBI)(\d+)(?:$|[_/.])", re.I) +_CK_RE = re.compile(r"(?:^|[_/.])(?:CK|CLK)(?:[_#]?(P|N))?(?:$|[_/.])", re.I) +_ADDR_RE = re.compile(r"(?:^|[_/.])(?:A|ADDR|BA|BG)(\d+)(?:$|[_/.])", re.I) +_CMD_RE = re.compile( + r"(?:^|[_/.])(?:RAS|CAS|WE|ACT|CS|CKE|ODT|RESET|ZQ)(?:[#_N]*)(?:$|[_/.])", + re.I, +) +_USB_DP_RE = re.compile( + r"(?:^|[_/.])(?:USB[_]?D\+|USB[_]?DP|D\+)$", re.I, +) +_USB_DN_RE = re.compile( + r"(?:^|[_/.])(?:USB[_]?D\-|USB[_]?DM|D\-)$", re.I, +) + + +class SignalGroup(BaseModel): + kind: GroupKind + id: str + nets: list[str] = Field(default_factory=list) + roles: dict[str, str] = Field(default_factory=dict) + + +class PhysicalBusInstance(BaseModel): + instance_id: str + logical_protocol_id: str + physical_interface_id: str + pcb_relevant: Literal["YES", "NO", "CONDITIONAL"] + confidence: float + evidence_kind: EvidenceKind + recognition_status: RecognitionStatus + nets: list[str] = Field(default_factory=list) + groups: list[SignalGroup] = Field(default_factory=list) + host_ref: str = "" + peer_refs: list[str] = Field(default_factory=list) + ambiguous_logical_ids: list[str] = Field(default_factory=list) + notes: str = "" + + +def _leaf(name: str) -> str: + n = normalize_kicad_hierarchy_net(name) + return n.split("/")[-1] if n else "" + + +def _blob(comp: Component, graph: DesignGraph) -> str: + extra = [] + for bag in ( + (graph.bom_fields or {}).get(comp.reference) or {}, + (graph.schematic_fields or {}).get(comp.reference) or {}, + ): + extra.extend(str(v) for v in bag.values() if v) + specs = "" + if comp.specs is not None and getattr(comp.specs, "values", None): + specs = " ".join(str(v) for v in comp.specs.values.values() if v) + return " ".join( + str(x or "") + for x in ( + comp.mpn, comp.value, comp.footprint, comp.component_subtype, specs, *extra, + ) + ) + + +def _declared_ids(graph: DesignGraph) -> list[tuple[str, str, str]]: + """(ref, logical_or_physical_id, raw).""" + hits: list[tuple[str, str, str]] = [] + bags: list[tuple[str, dict]] = [] + for ref, row in (graph.bom_fields or {}).items(): + bags.append((ref, row or {})) + for ref, row in (graph.schematic_fields or {}).items(): + bags.append((ref, row or {})) + for ref, comp in graph.components.items(): + if comp.specs is not None and getattr(comp.specs, "values", None): + bags.append((ref, dict(comp.specs.values))) + for ref, row in bags: + for key, val in row.items(): + if val is None: + continue + if not _DECLARE_RE.match(str(key)): + continue + hits.append((ref, str(val).strip().lower(), str(val))) + return hits + + +def _pn_pairs(net_names: list[str]) -> list[tuple[str, str]]: + by_leaf = {_leaf(n): n for n in net_names if n} + pairs: list[tuple[str, str]] = [] + used: set[str] = set() + for leaf, full in by_leaf.items(): + if full in used: + continue + mate_leaf = None + if leaf.endswith("+"): + mate_leaf = leaf[:-1] + "-" + elif leaf.endswith("_P") or leaf.endswith("_p"): + mate_leaf = leaf[:-2] + "_N" + elif leaf.upper().endswith("DP"): + stem = leaf[:-2] + mate_leaf = stem + ("DM" if leaf[-2].isupper() else "dm") + if mate_leaf and mate_leaf in by_leaf: + other = by_leaf[mate_leaf] + pairs.append((full, other)) + used.add(full) + used.add(other) + return pairs + + +def _usb_pairs(net_names: list[str]) -> list[tuple[str, str]]: + plus = [n for n in net_names if _USB_DP_RE.search(_leaf(n))] + minus = [n for n in net_names if _USB_DN_RE.search(_leaf(n))] + found: list[tuple[str, str]] = [] + used: set[str] = set() + generic = _pn_pairs(net_names) + for a, b in generic: + la, lb = _leaf(a).upper(), _leaf(b).upper() + if "USB" in la or "USB" in lb or la.startswith("D") or lb.startswith("D"): + found.append((a, b)) + used.add(a) + used.add(b) + for p in plus: + if p in used: + continue + pl = _leaf(p) + for m in minus: + if m in used: + continue + if pl.replace("+", "-").replace("DP", "DM").replace("_P", "_N") == _leaf(m): + found.append((p, m)) + used.add(p) + used.add(m) + break + if not found and len(plus) == 1 and len(minus) == 1: + found.append((plus[0], minus[0])) + return found + + +def _byte_lanes(net_names: list[str]) -> list[SignalGroup]: + dq: dict[int, str] = {} + dqs: dict[int, dict[str, str]] = {} + dm: dict[int, str] = {} + for n in net_names: + leaf = _leaf(n) + m = _DQ_RE.search(leaf) + if m and "DQS" not in leaf.upper(): + dq[int(m.group(1))] = n + continue + m = _DQS_RE.search(leaf) + if m: + idx = int(m.group(1) or "0") + pol = (m.group(2) or "P").upper()[:1] + dqs.setdefault(idx, {})[pol] = n + continue + m = _DM_RE.search(leaf) + if m: + dm[int(m.group(1))] = n + lanes: list[SignalGroup] = [] + lane_ids = set(i // 8 for i in dq) | set(dqs) | set(dm) + for lane in sorted(lane_ids): + roles: dict[str, str] = {} + nets: list[str] = [] + for bit in range(lane * 8, lane * 8 + 8): + if bit in dq: + roles[f"DQ{bit}"] = dq[bit] + nets.append(dq[bit]) + if lane in dqs: + for pol, net in dqs[lane].items(): + roles[f"DQS{lane}_{pol}"] = net + nets.append(net) + if lane in dm: + roles[f"DM{lane}"] = dm[lane] + nets.append(dm[lane]) + if nets: + lanes.append(SignalGroup( + kind="BYTE_LANE", id=f"BYTE_LANE_{lane}", nets=nets, roles=roles, + )) + return lanes + + +def _iface_map(catalog: ProtocolCatalog) -> dict[str, Any]: + return {p.id: p for p in catalog.physical_interfaces} + + +def _logical_ids(catalog: ProtocolCatalog) -> set[str]: + return {p.id for p in catalog.logical_protocols} + + +def recognize_physical_buses( + graph: DesignGraph, + catalog: ProtocolCatalog | None = None, +) -> list[PhysicalBusInstance]: + cat = catalog or load_catalog() + ifaces = _iface_map(cat) + logical = _logical_ids(cat) + out: list[PhysicalBusInstance] = [] + out.extend(_recognize_declared(graph, ifaces, logical)) + out.extend(_recognize_usb(graph, ifaces)) + out.extend(_recognize_ddr(graph, ifaces)) + out.extend(_recognize_axi(graph, ifaces)) + return _dedupe_instances(out) + + +def _recognize_declared( + graph: DesignGraph, + ifaces: dict, + logical: set[str], +) -> list[PhysicalBusInstance]: + out: list[PhysicalBusInstance] = [] + for ref, ident, raw in _declared_ids(graph): + ident = ident.replace("_", "-") + phys_id = None + log_id = None + if ident in ifaces: + phys_id = ident + log_id = ifaces[ident].logical_protocol_id + elif ident in logical: + log_id = ident + for pid, iface in ifaces.items(): + if iface.logical_protocol_id == ident and iface.pcb_relevant == "NO": + phys_id = pid + break + if phys_id is None: + for pid, iface in ifaces.items(): + if iface.logical_protocol_id == ident: + phys_id = pid + break + if not phys_id or not log_id: + continue + iface = ifaces[phys_id] + nets: list[str] = [] + if iface.pcb_relevant != "NO": + comp = graph.components.get(ref) + if comp: + nets = sorted({n for n in comp.pins.values() if n}) + out.append(PhysicalBusInstance( + instance_id=f"{phys_id}:{ref}", + logical_protocol_id=log_id, + physical_interface_id=phys_id, + pcb_relevant=iface.pcb_relevant, + confidence=_EVIDENCE_SCORE["declared"], + evidence_kind="declared", + recognition_status=( + "NOT_APPLICABLE" if iface.pcb_relevant == "NO" else "RECOGNIZED" + ), + nets=nets, + host_ref=ref, + notes=f"declared {raw}", + )) + return out + + +def _recognize_usb(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]: + instances: list[PhysicalBusInstance] = [] + connectors = [] + for comp in graph.components.values(): + blob = _blob(comp, graph) + if _USB_C_RE.search(blob) or _USB_C_RE.search(comp.footprint): + connectors.append(comp) + pairs = _usb_pairs(list(graph.nets)) + if not connectors and not pairs: + return [] + + usb3 = any(_USB3_RE.search(_blob(c, graph)) for c in connectors) + usb2_mark = any(_USB2_RE.search(_blob(c, graph)) for c in connectors) + hs = any(_USB_HS_RE.search(_blob(c, graph)) for c in connectors) + lsfs = any(_USB_LSFS_RE.search(_blob(c, graph)) for c in connectors) + ambiguous = False + notes = "" + if connectors and usb3 and usb2_mark: + ambiguous = True + notes = "USB2 vs USB3 on the same receptacle — REVIEW, not FAIL." + if not hs and not lsfs and pairs: + ambiguous = True + notes = (notes + " " if notes else "") + "USB2 speed not declared; HS vs LS/FS REVIEW." + + if hs and not lsfs: + logical, phys = "usb2-hs", "usb2-hs-dpair" + elif lsfs and not hs: + logical, phys = "usb2-ls-fs", "usb2-ls-fs-dpair" + else: + logical, phys = "usb2-hs", "usb2-hs-dpair" + ambiguous = True + + groups = [ + SignalGroup( + kind="DIFFERENTIAL_PAIR", + id=f"USB2_D_{i}", + nets=[a, b], + roles={"D+": a, "D-": b}, + ) + for i, (a, b) in enumerate(pairs) + ] + nets = sorted({n for g in groups for n in g.nets}) + host = connectors[0].reference if connectors else (nets[0] if nets else "usb") + evidence: EvidenceKind = "part" if connectors else "net_name" + if connectors and any(_USB_C_RE.search(_blob(c, graph)) for c in connectors): + cphys = "usb-c-usb2-receptacle" + ciface = ifaces[cphys] + status: RecognitionStatus = "REVIEW" if ambiguous or usb3 else "RECOGNIZED" + instances.append(PhysicalBusInstance( + instance_id=f"{cphys}:{host}", + logical_protocol_id="usb-c-usb2", + physical_interface_id=cphys, + pcb_relevant=ciface.pcb_relevant, + confidence=_EVIDENCE_SCORE[evidence], + evidence_kind=evidence, + recognition_status=status, + nets=nets, + groups=groups, + host_ref=host, + peer_refs=[c.reference for c in connectors], + ambiguous_logical_ids=["usb-c-usb2", "usb2-hs"] if ambiguous else [], + notes=notes, + )) + return instances + + iface = ifaces[phys] + status = "REVIEW" if ambiguous else "RECOGNIZED" + amb = ["usb2-ls-fs", "usb2-hs"] if ambiguous else [] + instances.append(PhysicalBusInstance( + instance_id=f"{phys}:{host}", + logical_protocol_id=logical, + physical_interface_id=phys, + pcb_relevant=iface.pcb_relevant, + confidence=_EVIDENCE_SCORE[evidence], + evidence_kind=evidence, + recognition_status=status, + nets=nets, + groups=groups, + host_ref=host, + peer_refs=[c.reference for c in connectors], + ambiguous_logical_ids=amb, + notes=notes, + )) + return instances + + +def _ddr_gen_from_text(text: str) -> str | None: + if _DDR4_PART.search(text): + return "ddr4" + for gen, cre in _DDR_GEN: + if cre.search(text): + return gen + return None + + +def _recognize_ddr(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]: + memories: list[tuple[Component, str, EvidenceKind]] = [] + for comp in graph.components.values(): + blob = _blob(comp, graph) + gen = _ddr_gen_from_text(blob) + if not gen: + continue + kind: EvidenceKind = "part" if (comp.mpn or _DDR4_PART.search(blob)) else "silicon" + memories.append((comp, gen, kind)) + net_gen = None + for n in graph.nets: + g = _ddr_gen_from_text(_leaf(n)) + if g: + net_gen = g + break + if not memories and not net_gen: + dq = [n for n in graph.nets if _DQ_RE.search(_leaf(n)) and "DQS" not in _leaf(n).upper()] + dqs = [n for n in graph.nets if "DQS" in _leaf(n).upper()] + if len(dq) >= 8 and dqs: + net_gen = "ddr-family" + else: + return [] + + gen = memories[0][1] if memories else net_gen or "ddr-family" + phys = f"{gen}-sdram-pcb" if f"{gen}-sdram-pcb" in ifaces else "ddr-family-sdram-pcb" + if gen == "ddr-family": + phys = "ddr-family-sdram-pcb" + iface = ifaces[phys] + host = memories[0][0].reference if memories else "" + evidence: EvidenceKind = memories[0][2] if memories else "net_name" + all_nets = list(graph.nets) + if host: + host_nets = list(graph.components[host].pins.values()) + all_nets = host_nets or all_nets + lanes = _byte_lanes(all_nets) + groups = list(lanes) + ck = [n for n in all_nets if _CK_RE.search(_leaf(n))] + if ck: + groups.append(SignalGroup(kind="CLOCK_GROUP", id="CK", nets=ck, roles={})) + addr = [n for n in all_nets if _ADDR_RE.search(_leaf(n))] + if addr: + groups.append(SignalGroup(kind="ADDRESS_GROUP", id="ADDRESS", nets=addr, roles={})) + cmd = [n for n in all_nets if _CMD_RE.search(_leaf(n))] + if cmd: + groups.append(SignalGroup(kind="COMMAND_GROUP", id="COMMAND_CONTROL", nets=cmd, roles={})) + nets = sorted({n for g in groups for n in g.nets}) + incomplete = bool(memories) and not lanes + status: RecognitionStatus = "REVIEW" if incomplete or gen == "ddr-family" else "RECOGNIZED" + notes = "" + if incomplete: + notes = "DDR device present but byte-lane grouping incomplete — REVIEW, not FAIL." + if gen == "ddr-family": + notes = (notes + " " if notes else "") + "DDR generation not unique." + return [PhysicalBusInstance( + instance_id=f"{phys}:{host or 'nets'}", + logical_protocol_id=gen, + physical_interface_id=phys, + pcb_relevant=iface.pcb_relevant, + confidence=_EVIDENCE_SCORE[evidence], + evidence_kind=evidence, + recognition_status=status, + nets=nets, + groups=groups, + host_ref=host, + peer_refs=[c.reference for c, _, _ in memories], + ambiguous_logical_ids=["ddr-family"] if gen == "ddr-family" else [], + notes=notes, + )] + + +def _recognize_axi(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]: + hits: list[Component] = [] + chip_to_chip = False + for comp in graph.components.values(): + blob = _blob(comp, graph) + if _AXI_RE.search(blob): + hits.append(comp) + if _AXI_C2C_RE.search(blob): + chip_to_chip = True + declared = any(i == "axi4" or i.startswith("axi4") for _, i, _ in _declared_ids(graph)) + if not hits and not declared: + # Net names alone (AXI_AWVALID) do not invent a PCB AXI bus. + return [] + if chip_to_chip: + iface = ifaces["axi4-chip-to-chip-phy"] + host = hits[0].reference if hits else "axi4" + return [PhysicalBusInstance( + instance_id=f"{iface.id}:{host}", + logical_protocol_id="axi4", + physical_interface_id=iface.id, + pcb_relevant=iface.pcb_relevant, + confidence=_EVIDENCE_SCORE["silicon"], + evidence_kind="silicon", + recognition_status="REVIEW", + nets=[], + host_ref=host, + notes="AXI chip-to-chip is CONDITIONAL; PHY docs required. No AXI nets assumed.", + )] + iface = ifaces["axi4-internal"] + host = hits[0].reference if hits else "axi4" + return [PhysicalBusInstance( + instance_id=f"{iface.id}:{host}", + logical_protocol_id="axi4", + physical_interface_id=iface.id, + pcb_relevant="NO", + confidence=_EVIDENCE_SCORE["silicon"], + evidence_kind="silicon", + recognition_status="NOT_APPLICABLE", + nets=[], + groups=[], + host_ref=host, + peer_refs=[c.reference for c in hits], + notes="AXI4 is an on-chip interconnect. No PCB nets.", + )] + + +def _dedupe_instances(items: list[PhysicalBusInstance]) -> list[PhysicalBusInstance]: + by_id: dict[str, PhysicalBusInstance] = {} + for inst in items: + prev = by_id.get(inst.instance_id) + if prev is None or inst.confidence > prev.confidence: + by_id[inst.instance_id] = inst + return sorted(by_id.values(), key=lambda i: (-i.confidence, i.instance_id)) + + +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, + ) diff --git a/periscope/src/backend/services/pcb_pipeline.py b/periscope/src/backend/services/pcb_pipeline.py index aa5b23f..ab53eca 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_catalog import empty_protocol_section +from backend.periscopex.protocol_recognize import protocol_section_for_graph 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 @@ -394,7 +394,7 @@ async def run_pcb_pipeline( summary=summary, coverage=coverage, not_reviewed=skipped + si_skip, - protocol_certification=empty_protocol_section().model_dump(), + protocol_certification=protocol_section_for_graph(graph).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 a3bc2db..a89d151 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_catalog import empty_protocol_section +from backend.periscopex.protocol_recognize import protocol_section_for_graph 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 ( @@ -229,7 +229,7 @@ async def validate_design_async( coverage=_sanitize_coverage(all_coverage), review_errors=dict(review_errors), not_reviewed=not_reviewed, - protocol_certification=empty_protocol_section().model_dump(), + protocol_certification=protocol_section_for_graph(graph).model_dump(), ) except Exception: report = ValidationReport( @@ -240,7 +240,7 @@ async def validate_design_async( coverage={}, review_errors=dict(review_errors), not_reviewed=not_reviewed, - protocol_certification=empty_protocol_section().model_dump(), + protocol_certification=protocol_section_for_graph(graph).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 a3c9c27..d664b70 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.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. + +- [New] `protocol_recognize.py`; report Protocolli lists recognised instances or stays empty. +- [New] pytest `tests/pcb/test_protocol_recognize_m1.py` (USB/DDR fixtures; AXI has no nets). + ## 2.64.0 — 2026-09-22 — Protocol certification M0 (catalog, no numbers) Versioned JSON protocol pack + Python loader/validator. Catalog skeletons for USB2 / USB-C-USB2 / 100BASE-TX / HDMI (connector distinct) / DDR / LPDDR / HBM / HyperBus / QSPI-octal / eMMC / UFS / AMBA / Wishbone / Avalon / TileLink / PCIe-CXL. Constraints without an official cite are UNKNOWN / CONTROLLER_DEPENDENT / PHY_DEPENDENT / MISSING_SOURCE — **no invented 90/100/50 Ω**. `logical_protocol_id` ≠ `physical_interface_id`. `pcb_relevant` is YES|NO|CONDITIONAL. RECOMMENDED is not FAIL-mandatory. Typical-as-standard is rejected by the parser. Report section **Protocolli** is empty (`nessun protocollo riconosciuto`). Recognition and L0–L3 certifiers are not in this release. diff --git a/periscope/src/frontend/package.json b/periscope/src/frontend/package.json index cd16444..f5c8272 100644 --- a/periscope/src/frontend/package.json +++ b/periscope/src/frontend/package.json @@ -1,6 +1,6 @@ { "name": "periscope-web", - "version": "2.64.0", + "version": "2.65.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 4c636a4..6f347be 100644 --- a/periscope/src/frontend/src/components/report/protocol-section.tsx +++ b/periscope/src/frontend/src/components/report/protocol-section.tsx @@ -7,24 +7,45 @@ export function ProtocolSection({ }: { section?: ProtocolCertificationSection | null; }) { - const message = section?.message || "nessun protocollo riconosciuto"; const instances = section?.recognized_instances ?? []; + const message = section?.message || "nessun protocollo riconosciuto"; return (

Protocolli

- Logical protocol vs physical interface. M0 loads the catalog only — - no recognition, no invented impedance. + Logical protocol vs physical interface. Recognition only (M1) — + no L0–L3 certifier, no invented impedance.

{instances.length === 0 ? (

{message}

) : ( -
diff --git a/periscope/src/frontend/src/lib/version.ts b/periscope/src/frontend/src/lib/version.ts index 18c87ea..bb8f7ee 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.64.0"; +export const APP_VERSION = "2.65.0"; export const APP_VERSION_DATE = "2026-09-22"; diff --git a/tests/pcb/test_protocol_recognize_m1.py b/tests/pcb/test_protocol_recognize_m1.py new file mode 100644 index 0000000..59d2c98 --- /dev/null +++ b/tests/pcb/test_protocol_recognize_m1.py @@ -0,0 +1,158 @@ +"""M1 physical_bus_instance recognition: USB/DDR fixtures; AXI has no PCB nets.""" + +from __future__ import annotations + +import json + +from backend.periscopex.models import ( + Component, + ComponentType, + DesignGraph, + Net, + NetType, + PinConnection, +) +from backend.periscopex.protocol_recognize import ( + PACK_MACROPHASE, + protocol_section_for_graph, + 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_graph() -> DesignGraph: + return DesignGraph( + components={ + "U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}, mpn="CH340E"), + "J2": Component( + reference="J2", value="USB_C_Receptacle_USB2.0_16P", + footprint="USB_C_Receptacle_USB2.0_16P", + component_type=ComponentType.CONNECTOR, + pins={"A6": "USB_D+", "A7": "USB_D-"}, + ), + }, + nets={ + "USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "A6")), + "USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "A7")), + }, + ) + + +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", "10": "DDR4_DM0", + "11": "DDR4_CK_P", "12": "DDR4_CK_N", + "13": "DDR4_A0", "14": "DDR4_RAS", + }) + u_mem = _ic("U5", pins, mpn="MT41K256M16TW", value="DDR4") + nets = {n: _net(n, ("U5", p)) for p, n in pins.items()} + return DesignGraph(components={"U5": u_mem}, nets=nets) + + +def _axi_graph() -> DesignGraph: + pins = { + "1": "AXI_AWVALID", + "2": "AXI_WDATA", + "3": "AXI_RDATA", + "4": "VCCINT", + } + return DesignGraph( + components={ + "U3": _ic("U3", pins, mpn="XC7A100T", value="AXI4 interconnect"), + }, + nets={ + "AXI_AWVALID": _net("AXI_AWVALID", ("U3", "1")), + "AXI_WDATA": _net("AXI_WDATA", ("U3", "2")), + "AXI_RDATA": _net("AXI_RDATA", ("U3", "3")), + "VCCINT": _net("VCCINT", ("U3", "4")), + }, + ) + + +def test_usb_net_names_make_diff_pair_instance(): + insts = recognize_physical_buses(_usb_graph()) + usb = [i for i in insts if "usb" in i.logical_protocol_id] + assert usb, insts + one = usb[0] + assert one.physical_interface_id != one.logical_protocol_id + assert one.pcb_relevant in {"YES", "CONDITIONAL"} + pairs = [g for g in one.groups if g.kind == "DIFFERENTIAL_PAIR"] + assert pairs + nets = set(pairs[0].nets) + assert "USB_D+" in nets and "USB_D-" in nets + assert one.recognition_status != "FAIL" + assert one.evidence_kind in {"part", "net_name", "silicon"} + + +def test_usb_speed_ambiguity_is_review_not_fail(): + insts = recognize_physical_buses(_usb_graph()) + usb = [i for i in insts if "usb" in i.logical_protocol_id][0] + assert usb.recognition_status == "REVIEW" + assert "FAIL" not in usb.recognition_status + assert usb.ambiguous_logical_ids + + +def test_ddr_net_names_rebuild_byte_lane(): + insts = recognize_physical_buses(_ddr_graph()) + ddr = [i for i in insts if i.logical_protocol_id.startswith("ddr")] + assert ddr + one = ddr[0] + assert one.physical_interface_id == "ddr4-sdram-pcb" + assert one.logical_protocol_id == "ddr4" + lanes = [g for g in one.groups if g.kind == "BYTE_LANE"] + assert lanes + assert "DDR4_DQ0" in lanes[0].nets + assert "DDR4_DQS0_P" in lanes[0].nets + clocks = [g for g in one.groups if g.kind == "CLOCK_GROUP"] + assert clocks + assert one.recognition_status != "FAIL" + blob = json.dumps(one.model_dump()) + assert "90" not in blob + assert "ohm" not in blob.lower() + + +def test_axi4_internal_does_not_create_pcb_nets(): + insts = recognize_physical_buses(_axi_graph()) + axi = [i for i in insts if i.logical_protocol_id == "axi4"] + assert axi + one = axi[0] + assert one.physical_interface_id == "axi4-internal" + assert one.pcb_relevant == "NO" + assert one.nets == [] + assert one.groups == [] + assert one.recognition_status == "NOT_APPLICABLE" + assert "AXI_AWVALID" not in one.nets + assert "AXI_WDATA" not in one.nets + + +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() + + +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 + assert all(row.get("recognition_status") != "FAIL" for row in sec.recognized_instances)