Compare commits

...
Author SHA1 Message Date
michele b32d581d27 Merge protocol-cert-m0 (vSafe UNKNOWN 2.83) into ui-apple-shell.
Keep Apple shell + findings tree; include USB-C vSafe UNKNOWN from catalog (no invented volts). Changelog 2.83.0 covers both.
2026-09-22 18:49:52 +02:00
michele d5fff7c6dd Apple-like UI shell: findings tree and HDMI 1.4 UNKNOWN.
Keep Error/Warning/Info folders with + disclosure, then domains/rules,
then Certificazioni/Protocolli. FAIL stays first. HDMI 1.4 TMDS Zdiff
renders UNKNOWN — catalog JSON unchanged.
2026-09-22 18:41:20 +02:00
michele 29e1a3033d Leave USB-C vSafe UNKNOWN; AF zip has no USB-IF PD (2.83.0).
Alta Frequenza / af-allegati only hold TI SI (SNLA027B, SDAA499), which do not state vSafe5V/vSafe0V. CabCon R2.5 §1.6 defers volts to USB PD. HDMI 1.4 TMDS stays UNKNOWN.
2026-09-22 18:38:33 +02:00
michele bb83f63114 HDMI 2.0+ TI layout uses source_type VENDOR (2.82.0).
Add VENDOR to SourceType. HDMI 2.0/FRL fill TMDS1204 PCB numbers as VENDOR RECOMMENDED; TMDS1204-specific skew/vias/data_rate/SIGDET stay PHY_DEPENDENT. HDMI 1.4 TMDS remains UNKNOWN. Not HDMI Forum STANDARD.
2026-09-22 18:25:26 +02:00
michele 3c0b3e14dc Archive protocol PDFs on disk; HDMI 2.0/FRL TI layout as VENDOR (2.81.0).
INDEX.md lists cites. PDFs gitignored. HDMI 1.4 TMDS Zdiff stays UNKNOWN. pcbsync not packed.
2026-09-22 18:18:03 +02:00
michele 1455e974ac Add TI TMDS1204 HDMI layout as VENDOR/PHY only (2.80.0).
Sink/source ZPCB, skew, length, vias from SLLSF57A Tables 8-6/8-2. Generic hdmi-* TMDS Zdiff stays UNKNOWN. SLLA633 is SIGDET only.
2026-09-22 18:12:23 +02:00
michele 25aa608052 Fill HDMI CEC electrical from HDMI Specification 1.3 (2.79.0).
27 kΩ pull-up, VOL/VOH, 200 pF device / 700 pF cable, 5 Ω interconnect. TMDS/FRL Zdiff stays UNKNOWN.
2026-09-22 18:07:52 +02:00
michele d32af5627a Fill 1000BASE-T from IEEE 802.3-2012 Section Three Clause 40 (2.78.0).
Cable 100 Ω / 100 m / 570 ns / 50 ns skew stay CABLE. MDI 16 dB and 0.67 V are PHY. L2 PCB Zdiff UNKNOWN. 1000BASE-X 1000 Mb/s from Clause 36.
2026-09-22 18:03:44 +02:00
michele 8a93ffc1c5 Fill 10BASE-T numbers from IEEE 802.3-2015 Section One Clause 14 (2.77.0).
Cable 100 Ω / 100 m / 11.5 dB stay CABLE. L2 PCB Zdiff UNKNOWN. 1000BASE-T not in this volume.
2026-09-22 18:00:10 +02:00
michele 421f659054 Fill Type-C VBUS and CC holes from CabCon R2.5 and R2.0 (2.76.0).
R2.5 keeps Rp/Rd/Ra, charger 4.75/4.0 V, zOPEN, tCCDebounce, tVBUSON.
R2.0 August 2019 Table 4-32 fills Source CC shall-detect only. vSafe5V stays UNKNOWN.
2026-09-22 17:55:47 +02:00
michele c4cd046ea9 Fill USB 2.0 Rev 2.0 and Type-C CabCon R2.5 numbers (2.75.0).
LS/FS rates, pull-up/down, and VBUS come from the base spec. Cable Z0 90 ohm is CABLE; PCB should 90 ohm is RECOMMENDED. L2 differential_impedance stays UNKNOWN. Rp/Rd/Ra from CabCon Tables 4-27/28/29. IEEE Section One/Three and HDMI still missing. PDFs not in git.
2026-09-22 16:53:49 +02:00
michele f3be485b75 Fill protocol packs from recovered USB CTS, Type-C FTS, DVI 1.0, and IEEE 802.3-2012 Section Two (2.74.0).
NUMERIC rows cite document title, revision, section/table, and page. USB Zdiff, Type-C Rp/Rd, 10BASE-T, and 1000BASE-T stay UNKNOWN. PDFs are not in git.
2026-09-22 14:43:26 +02:00
michele 949f5f1541 Add protocol-certification M6/M7 USB-C Ethernet HDMI packs (2.73.0).
USB 2.0 is protocol, Type-C is connector, USB 2.0 over Type-C is the
implementation. Ethernet/HDMI skeletons stay UNKNOWN with needed_document.
No invented ohms; Mini HDMI is connector-only; RJ45 is not 1000BASE-T.
2026-09-22 13:40:37 +02:00
michele 7baffadd6b Add protocol-certification M10 catalog skeletons (2.72.0).
PCIe/CXL keep logical vs physical ids. I2C, CAN, RGMII and missing
high-speed processor interfaces get required_checks; numbers stay
UNKNOWN/MISSING_SOURCE until an official cite. No invented ohms.
2026-09-22 13:03:48 +02:00
michele 866c2e9242 Add protocol-certification M9 L3 channel certifier (2.71.0).
L3 CHANNEL structure for timing budget, insertion loss, return loss,
crosstalk, and complete channel. Numeric PASS/FAIL only from existing
channel FACT; otherwise a visible skip. No OpenEMS, no invented S-parameters,
no M6 packs.
2026-09-22 12:58:18 +02:00
michele 14272d7446 Add protocol-certification M8 DDR instance grouping (2.70.0).
Controller-aware per-memory instances, DQ/DQS vs addr/cmd/ctrl/ck, HBM not classic DDR PCB DQ. No universal mm/ps. No M6 packs, no L3 solver.
2026-09-22 12:46:01 +02:00
michele 24c4beecc9 Add protocol-certification M5 Protocolli report UI (2.69.0).
Instances, L0–L3 max level, measured/limit/margin/source/method, traceability chain. FAIL listed first, not under Warning. Visible skips. No pack numbers, no L3 solver.
2026-09-22 12:37:31 +02:00
michele 0848eb8416 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.
2026-09-22 12:30:13 +02:00
michele 10d21cdc80 Add protocol-certification M3 L1 geometric certifier (2.67.0).
Length, topology, vias, stubs, layer, grouping. Skew only vs NUMERIC mm or DESIGN LIMIT. No invented ohms/mm/ps. Not electrical. No L2/L3.
2026-09-22 12:22:18 +02:00
michele 3cc2e5469b Add protocol-certification M2 L0 structural certifier (2.66.0).
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.
2026-09-22 12:12:25 +02:00
michele fc6524a2a9 Add protocol-certification M1 instance recognition (2.65.0).
physical_bus_instance from declaration, silicon/part, pin/net names, and
pairs. USB and DDR fixtures emit groups; AXI4 internal attaches no PCB
nets. Ambiguity is REVIEW, not FAIL. No L0–L3 certifier and no invented Z.
2026-09-22 12:04:37 +02:00
michele 36ad979612 Add protocol-certification M0 catalog, loader, and empty report (2.64.0).
Versioned JSON skeletons without invented ohms/mm/ps. RECOMMENDED is not
FAIL-mandatory; typical-as-standard is rejected. Report Protocolli is empty
until recognition (M1+). Native src overlays inherited periscopex on the
package path.
2026-09-22 11:57:26 +02:00
55 changed files with 27107 additions and 443 deletions
+3
View File
@@ -65,3 +65,6 @@ edif-files/
# Cloud agent scratch
agent-tools/
# Licensed protocol PDFs — keep on disk as the local archive; do not commit binaries
standards/protocol-specs/*.pdf
+24 -4
View File
@@ -10,9 +10,29 @@ from pathlib import Path
from pkgutil import extend_path
_REPO = Path(__file__).resolve().parents[1]
for _p in (_REPO / "periscope" / "src", _REPO / "periscope" / "dependency"):
_SRC = _REPO / "periscope" / "src"
_DEP = _REPO / "periscope" / "dependency"
# Last insert is searched first: native src overlays inherited dependency.
for _p in (_DEP, _SRC):
_s = str(_p)
if _p.is_dir() and _s not in sys.path:
sys.path.insert(0, _s)
if not _p.is_dir():
continue
if _s in sys.path:
sys.path.remove(_s)
sys.path.insert(0, _s)
__path__ = list(extend_path(__path__, __name__))
def _prefer_native_backend(paths: list[str]) -> list[str]:
src, other, dep = [], [], []
for p in paths:
norm = p.replace("\\", "/")
if "/periscope/src/" in norm:
src.append(p)
elif "/periscope/dependency/" in norm:
dep.append(p)
else:
other.append(p)
return src + other + dep
__path__ = _prefer_native_backend(list(extend_path(__path__, __name__)))
+11 -1
View File
@@ -1,4 +1,14 @@
"""PinScope-inherited backend package (in-tree dependency)."""
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
__path__ = list(extend_path(__path__, __name__))
_src, _other, _dep = [], [], []
for _p in __path__:
_n = str(_p).replace("\\", "/")
if "/periscope/src/" in _n:
_src.append(_p)
elif "/periscope/dependency/" in _n:
_dep.append(_p)
else:
_other.append(_p)
__path__[:] = _src + _other + _dep
@@ -1,3 +1,13 @@
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
__path__ = list(extend_path(__path__, __name__))
_src, _other, _dep = [], [], []
for _p in __path__:
_n = str(_p).replace("\\", "/")
if "/periscope/src/" in _n:
_src.append(_p)
elif "/periscope/dependency/" in _n:
_dep.append(_p)
else:
_other.append(_p)
__path__[:] = _src + _other + _dep
@@ -380,6 +380,7 @@ class ValidationReport(BaseModel):
coverage: dict[str, list[str]] = {} # designator -> areas checked and found OK
review_errors: dict[str, str] = {} # designator -> error message for ICs whose review raised
not_reviewed: list[dict] = [] # [{"designator","reason"}] — ICs skipped (e.g. no datasheet PDF)
protocol_certification: dict[str, Any] | None = None
class FindingComment(BaseModel):
+11 -1
View File
@@ -1,4 +1,14 @@
"""Native Periscope backend modules."""
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
__path__ = list(extend_path(__path__, __name__))
_src, _other, _dep = [], [], []
for _p in __path__:
_n = str(_p).replace("\\", "/")
if "/periscope/src/" in _n:
_src.append(_p)
elif "/periscope/dependency/" in _n:
_dep.append(_p)
else:
_other.append(_p)
__path__[:] = _src + _other + _dep
+11 -1
View File
@@ -1,4 +1,14 @@
"""Native Periscope core modules (finding engine, PCB, checks)."""
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
__path__ = list(extend_path(__path__, __name__))
_src, _other, _dep = [], [], []
for _p in __path__:
_n = _p.replace("\\", "/")
if "/periscope/src/" in _n:
_src.append(_p)
elif "/periscope/dependency/" in _n:
_dep.append(_p)
else:
_other.append(_p)
__path__[:] = _src + _other + _dep
@@ -290,6 +290,24 @@ 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.")
_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.")
_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.")
_add("PE-PRT-L3-001", "TYPICAL", "REVIEW", domain="pcb",
requirement="L3 channel skip: missing existing IL/RL/crosstalk/budget FACT (no OpenEMS).")
_add("PE-PRT-L3-002", "MANDATORY", "RULE", domain="pcb",
requirement="L3 channel vs cited IL/RL/crosstalk/timing-budget FACT (no invented S-param).")
_seed()
@@ -385,6 +385,8 @@ class ValidationReport(BaseModel):
coverage: dict[str, list[str]] = {} # designator -> areas checked and found OK
review_errors: dict[str, str] = {} # designator -> error message for ICs whose review raised
not_reviewed: list[dict] = [] # [{"designator","reason"}] — ICs skipped (e.g. no datasheet PDF)
# M0 protocol cert: empty instances / "nessun protocollo riconosciuto". No Z.
protocol_certification: dict[str, Any] | None = None
class FindingComment(BaseModel):
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,440 @@
{
"$defs": {
"ConstraintSource": {
"properties": {
"document": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Document"
},
"organization": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Organization"
},
"revision": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Revision"
},
"section": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Section"
},
"table": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Table"
},
"page": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Page"
},
"url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Url"
}
},
"title": "ConstraintSource",
"type": "object"
},
"LogicalProtocol": {
"additionalProperties": false,
"properties": {
"id": {
"title": "Id",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
},
"bus_type": {
"enum": [
"MEMORY",
"PROCESSOR_INTERCONNECT",
"PERIPHERAL",
"CHIP_TO_CHIP",
"STORAGE",
"GRAPHICS",
"SERIAL",
"PARALLEL"
],
"title": "Bus Type",
"type": "string"
},
"family": {
"default": "",
"title": "Family",
"type": "string"
},
"notes": {
"default": "",
"title": "Notes",
"type": "string"
}
},
"required": [
"id",
"name",
"bus_type"
],
"title": "LogicalProtocol",
"type": "object"
},
"PhysicalInterface": {
"additionalProperties": false,
"properties": {
"id": {
"title": "Id",
"type": "string"
},
"logical_protocol_id": {
"title": "Logical Protocol Id",
"type": "string"
},
"bus_type": {
"enum": [
"MEMORY",
"PROCESSOR_INTERCONNECT",
"PERIPHERAL",
"CHIP_TO_CHIP",
"STORAGE",
"GRAPHICS",
"SERIAL",
"PARALLEL"
],
"title": "Bus Type",
"type": "string"
},
"physical_layer": {
"enum": [
"INTERNAL",
"PARALLEL_SINGLE_ENDED",
"PARALLEL_DIFFERENTIAL",
"SERIAL_SINGLE_ENDED",
"SERIAL_DIFFERENTIAL",
"MIXED"
],
"title": "Physical Layer",
"type": "string"
},
"pcb_relevant": {
"enum": [
"YES",
"NO",
"CONDITIONAL"
],
"title": "Pcb Relevant",
"type": "string"
},
"connector": {
"default": "none",
"title": "Connector",
"type": "string"
},
"recognition_hints": {
"items": {
"type": "string"
},
"title": "Recognition Hints",
"type": "array"
},
"required_checks": {
"items": {
"type": "string"
},
"title": "Required Checks",
"type": "array"
},
"constraints": {
"items": {
"$ref": "#/$defs/ProtocolConstraint"
},
"title": "Constraints",
"type": "array"
},
"notes": {
"default": "",
"title": "Notes",
"type": "string"
},
"incomplete": {
"default": false,
"title": "Incomplete",
"type": "boolean"
}
},
"required": [
"id",
"logical_protocol_id",
"bus_type",
"physical_layer",
"pcb_relevant"
],
"title": "PhysicalInterface",
"type": "object"
},
"ProtocolConstraint": {
"additionalProperties": false,
"properties": {
"id": {
"title": "Id",
"type": "string"
},
"parameter": {
"title": "Parameter",
"type": "string"
},
"value_kind": {
"enum": [
"NUMERIC",
"UNKNOWN",
"CONTROLLER_DEPENDENT",
"PHY_DEPENDENT",
"VENDOR_DEPENDENT",
"NOT_APPLICABLE",
"MISSING_SOURCE"
],
"title": "Value Kind",
"type": "string"
},
"mandatory": {
"enum": [
"MANDATORY",
"RECOMMENDED",
"OPTIONAL",
"INFORMATIONAL"
],
"title": "Mandatory",
"type": "string"
},
"source_type": {
"enum": [
"STANDARD",
"CONNECTOR",
"PHY",
"VENDOR",
"CONTROLLER",
"MEMORY",
"COMPONENT",
"CABLE",
"PCB",
"DERIVED"
],
"title": "Source Type",
"type": "string"
},
"source_class": {
"enum": [
"NORMATIVE",
"VENDOR",
"IMPLEMENTATION",
"DERIVED"
],
"title": "Source Class",
"type": "string"
},
"value": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"title": "Value"
},
"unit": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Unit"
},
"source": {
"anyOf": [
{
"$ref": "#/$defs/ConstraintSource"
},
{
"type": "null"
}
],
"default": null
},
"conditions": {
"items": {
"type": "string"
},
"title": "Conditions",
"type": "array"
},
"measurement_method": {
"anyOf": [
{
"enum": [
"DATASHEET",
"TDR",
"FIELD_SOLVER",
"PCB_STACKUP",
"FABRICATOR",
"SIMULATION",
"CALCULATION",
"EXTRACTION",
"MEASUREMENT"
],
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Measurement Method"
},
"limit_kind": {
"anyOf": [
{
"enum": [
"STANDARD",
"DESIGN"
],
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Limit Kind"
},
"value_origin": {
"default": "SPEC",
"enum": [
"SPEC",
"TYPICAL",
"EXAMPLE",
"DERIVED"
],
"title": "Value Origin",
"type": "string"
},
"typical": {
"default": false,
"title": "Typical",
"type": "boolean"
},
"needed_document": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Needed Document"
}
},
"required": [
"id",
"parameter",
"value_kind",
"mandatory",
"source_type",
"source_class"
],
"title": "ProtocolConstraint",
"type": "object"
}
},
"additionalProperties": false,
"properties": {
"schema_version": {
"title": "Schema Version",
"type": "string"
},
"logical_protocols": {
"items": {
"$ref": "#/$defs/LogicalProtocol"
},
"title": "Logical Protocols",
"type": "array"
},
"physical_interfaces": {
"items": {
"$ref": "#/$defs/PhysicalInterface"
},
"title": "Physical Interfaces",
"type": "array"
}
},
"required": [
"schema_version",
"logical_protocols",
"physical_interfaces"
],
"title": "ProtocolCatalog",
"type": "object"
}
@@ -0,0 +1,319 @@
"""M8: DDR physical instance grouping. Controller-aware. No universal mm/ps/ohm.
DQ/DQS byte lanes are separate from ADDRESS / COMMAND / CONTROL / CLOCK.
HBM is package/interposer — not classic DDR PCB DQ rules.
"""
from __future__ import annotations
import re
from backend.periscopex.models import Component, ComponentType, DesignGraph
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
from backend.periscopex.protocol_recognize import (
EvidenceKind,
PhysicalBusInstance,
SignalGroup,
_blob,
_EVIDENCE_SCORE,
)
PACK_MACROPHASE = "M8"
_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)(?:[_#]?(P|N))?(?:$|[_/.])", re.I)
_WCK_RE = re.compile(r"(?:^|[_/.])WCK", re.I)
_ADDR_RE = re.compile(r"(?:^|[_/.])(?:A|ADDR|BA|BG)(\d+)(?:$|[_/.])", re.I)
_CA_RE = re.compile(r"(?:^|[_/.])CA(\d+)(?:$|[_/.])", re.I)
_CMD_RE = re.compile(r"(?:^|[_/.])(?:RAS|CAS|WE|ACT)(?:[#_N]*)(?:$|[_/.])", re.I)
_CTRL_RE = re.compile(
r"(?:^|[_/.])(?:CS|CKE|ODT|RESET|ZQ)(?:[#_N]*)(?:$|[_/.])", re.I,
)
_HBM_RE = re.compile(
r"\bHBM(?:2E|3E|[23])?\b|H26M|MT54A|interposer",
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|K4B", re.I)),
("ddr2", re.compile(r"\bDDR2\b|MT47", re.I)),
("ddr1", re.compile(r"\bDDR1\b", re.I)),
]
_LPDDR_GEN = [
("lpddr5x", re.compile(r"LPDDR5X", re.I)),
("lpddr5", re.compile(r"LPDDR5", re.I)),
("lpddr4x", re.compile(r"LPDDR4X", re.I)),
("lpddr4", re.compile(r"LPDDR4", re.I)),
("lpddr3", re.compile(r"LPDDR3", re.I)),
("lpddr2", re.compile(r"LPDDR2", re.I)),
("lpddr-family", re.compile(r"\bLPDDR\b", re.I)),
]
_DDR4_PART = re.compile(r"MT41K|K4A|IS43TR|W631|EDY4016", re.I)
_MEMORY_PART = re.compile(r"MT41|MT47|K4A|K4B|IS43TR|W631|EDY4016", re.I)
_CONTROLLER_HINT = re.compile(
r"FPGA|XC7|XCKU|Artix|Kintex|Zynq|STM32|i\.?MX|RK33|controller|MIG|\bSoC\b",
re.I,
)
def _leaf(name: str) -> str:
n = normalize_kicad_hierarchy_net(name)
return n.split("/")[-1] if n else ""
def is_hbm_blob(text: str) -> bool:
return bool(_HBM_RE.search(text or ""))
def ddr_gen_from_text(text: str) -> str | None:
if is_hbm_blob(text):
return None
for gen, cre in _LPDDR_GEN:
if cre.search(text):
return gen
if _DDR4_PART.search(text):
return "ddr4"
for gen, cre in _DDR_GEN:
if cre.search(text):
return gen
return None
def hbm_gen_from_text(text: str) -> str | None:
if not is_hbm_blob(text):
return None
t = re.sub(r"[\s_\-]", "", text.upper())
for gen in ("HBM3E", "HBM2E", "HBM3", "HBM2", "HBM"):
if gen in t:
return gen.lower()
return "hbm-family"
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 memory_groups(net_names: list[str]) -> list[SignalGroup]:
"""DQ/DQS lanes vs ADDRESS/COMMAND/CONTROL vs CLOCK. No invented numbers."""
groups: list[SignalGroup] = list(byte_lanes(net_names))
data_nets = {n for g in groups for n in g.nets}
def _take(kind: str, gid: str, pred) -> None:
nets = [n for n in net_names if pred(_leaf(n)) and n not in data_nets]
if nets:
groups.append(SignalGroup(kind=kind, id=gid, nets=nets, roles={})) # type: ignore[arg-type]
_take("CLOCK_GROUP", "CK", lambda lf: _CK_RE.search(lf) and not _WCK_RE.search(lf))
_take("CLOCK_GROUP", "WCK", lambda lf: bool(_WCK_RE.search(lf)))
_take("ADDRESS_GROUP", "ADDRESS", lambda lf: bool(_ADDR_RE.search(lf)) and not _CA_RE.search(lf))
_take("ADDRESS_GROUP", "CA", lambda lf: bool(_CA_RE.search(lf)))
_take("COMMAND_GROUP", "COMMAND", lambda lf: bool(_CMD_RE.search(lf)))
_take("CONTROL_GROUP", "CONTROL", lambda lf: bool(_CTRL_RE.search(lf)))
return groups
def data_vs_addr_overlap(groups: list[SignalGroup]) -> list[str]:
data = {n for g in groups if g.kind == "BYTE_LANE" for n in g.nets}
other = {
n for g in groups
if g.kind in {"ADDRESS_GROUP", "COMMAND_GROUP", "CONTROL_GROUP", "CLOCK_GROUP"}
for n in g.nets
}
return sorted(data & other)
def find_controller(graph: DesignGraph, memory: Component, nets: list[str]) -> Component | None:
netset = set(nets)
for comp in graph.components.values():
if comp.reference == memory.reference:
continue
if comp.component_type != ComponentType.IC:
continue
blob = _blob(comp, graph)
if is_hbm_blob(blob):
continue
if ddr_gen_from_text(blob) and _MEMORY_PART.search(blob):
continue
pins = set(comp.pins.values())
if pins & netset:
return comp
return None
def phys_id_for_gen(gen: str, ifaces: dict) -> str:
if gen.startswith("lpddr"):
pid = f"{gen}-pcb"
return pid if pid in ifaces else "lpddr-family-pcb"
if gen == "ddr-family":
return "ddr-family-sdram-pcb"
pid = f"{gen}-sdram-pcb"
return pid if pid in ifaces else "ddr-family-sdram-pcb"
def build_ddr_instance(
graph: DesignGraph,
memory: Component,
gen: str,
evidence: EvidenceKind,
ifaces: dict,
) -> PhysicalBusInstance:
iface_id = phys_id_for_gen(gen, ifaces)
iface = ifaces[iface_id]
nets = [n for n in memory.pins.values() if n]
groups = memory_groups(nets)
overlap = data_vs_addr_overlap(groups)
controller = find_controller(graph, memory, nets)
lanes = [g for g in groups if g.kind == "BYTE_LANE"]
incomplete = not lanes
mixed = bool(overlap)
status = "REVIEW" if incomplete or mixed or gen in {"ddr-family", "lpddr-family"} else "RECOGNIZED"
notes = (
f"Controller-aware DDR instance. memory={memory.reference}"
f"{(' controller=' + controller.reference) if controller else ' (no controller IC on nets)'}."
" No universal mm/ps or Z."
)
if incomplete:
notes += " Byte-lane grouping incomplete — REVIEW, not PASS."
if mixed:
notes += f" Address/command mixed into DQ: {overlap}. Not certified as DDR data."
status = "REVIEW"
host = controller.reference if controller else memory.reference
peers = [memory.reference]
if controller:
peers = [memory.reference]
group_nets = sorted({n for g in groups for n in g.nets})
return PhysicalBusInstance(
instance_id=f"{iface_id}:{memory.reference}",
logical_protocol_id=gen,
physical_interface_id=iface_id,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE[evidence],
evidence_kind=evidence,
recognition_status=status,
nets=group_nets,
groups=groups,
host_ref=host,
peer_refs=peers,
ambiguous_logical_ids=["ddr-family"] if gen == "ddr-family" else [],
notes=notes,
)
def recognize_hbm(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
out: list[PhysicalBusInstance] = []
for comp in graph.components.values():
blob = _blob(comp, graph)
gen = hbm_gen_from_text(blob)
if not gen:
continue
pid = f"{gen}-package" if f"{gen}-package" in ifaces else "hbm-family-package"
iface = ifaces[pid]
out.append(PhysicalBusInstance(
instance_id=f"{pid}:{comp.reference}",
logical_protocol_id=gen if gen in {
"hbm", "hbm2", "hbm2e", "hbm3", "hbm3e", "hbm-family",
} else "hbm-family",
physical_interface_id=pid,
pcb_relevant="NO",
confidence=_EVIDENCE_SCORE["part" if comp.mpn else "silicon"],
evidence_kind="part" if comp.mpn else "silicon",
recognition_status="NOT_APPLICABLE",
nets=[],
groups=[],
host_ref=comp.reference,
notes=(
"HBM is package/interposer. Classic DDR PCB DQ rules do not apply. "
"pcb_relevant=NO."
),
))
return out
def recognize_ddr_instances(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
memories: list[tuple[Component, str, EvidenceKind]] = []
for comp in graph.components.values():
blob = _blob(comp, graph)
if is_hbm_blob(blob):
continue
gen = ddr_gen_from_text(blob)
if not gen:
continue
if _CONTROLLER_HINT.search(blob) and not _MEMORY_PART.search(blob):
continue
kind: EvidenceKind = "part" if (comp.mpn or _DDR4_PART.search(blob)) else "silicon"
memories.append((comp, gen, kind))
out = [build_ddr_instance(graph, m, gen, kind, ifaces) for m, gen, kind in memories]
if out:
return out
if any(is_hbm_blob(_blob(c, graph)) for c in graph.components.values()):
return []
net_gen = None
for n in graph.nets:
g = ddr_gen_from_text(_leaf(n))
if g:
net_gen = g
break
if 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 []
groups = memory_groups(list(graph.nets))
pid = phys_id_for_gen(net_gen, ifaces)
iface = ifaces[pid]
return [PhysicalBusInstance(
instance_id=f"{pid}:nets",
logical_protocol_id=net_gen,
physical_interface_id=pid,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["net_name"],
evidence_kind="net_name",
recognition_status="REVIEW",
nets=sorted({n for g in groups for n in g.nets}),
groups=groups,
notes="DDR nets without a memory MPN. Controller-aware grouping only. No universal mm/ps.",
)]
@@ -0,0 +1,361 @@
"""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 L1L3. 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, LayoutGraph
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",
"byte_lane_skew": "byte_to_byte_skew",
"ck_to_command_skew": "ck_to_command_skew",
"ck_to_address_skew": "ck_to_address_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:
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,
layout: LayoutGraph | None = None,
constraints_map: dict | None = None,
impedance_nets: list[dict] | dict | None = None,
channel_data: dict | None = None,
) -> tuple[ProtocolCertificationSection, list[Finding]]:
from backend.periscopex.protocol_l1 import certify_l1
from backend.periscopex.protocol_l2 import certify_l2
from backend.periscopex.protocol_l3 import LEVEL as L3_LEVEL, certify_l3
from backend.periscopex.protocol_report import (
PACK_MACROPHASE as REPORT_MACRO,
attach_instance_report,
result_rank,
)
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 = REPORT_MACRO
return sec, []
l0, findings = certify_l0(graph, instances, cat)
l1, l1_findings_out = certify_l1(graph, instances, layout, cat)
findings.extend(l1_findings_out)
l2, l2_findings_out = certify_l2(
graph, instances, cat,
layout=layout,
constraints_map=constraints_map,
impedance_nets=impedance_nets,
)
findings.extend(l2_findings_out)
l3, l3_findings_out = certify_l3(
instances, cat, channel_data=channel_data,
)
findings.extend(l3_findings_out)
ifaces = {p.id: p for p in cat.physical_interfaces}
dumps: list[dict[str, Any]] = []
for inst in instances:
row = inst.model_dump()
row["l0_checks"] = [c.model_dump() for c in l0.get(inst.instance_id, [])]
row["l1_checks"] = [c.model_dump() for c in l1.get(inst.instance_id, [])]
row["l2_checks"] = [c.model_dump() for c in l2.get(inst.instance_id, [])]
row["l3_checks"] = [c.model_dump() for c in l3.get(inst.instance_id, [])]
row.update(attach_instance_report(
inst,
ifaces.get(inst.physical_interface_id),
l0.get(inst.instance_id, []),
l1.get(inst.instance_id, []),
l2.get(inst.instance_id, []),
l3.get(inst.instance_id, []),
))
dumps.append(row)
dumps.sort(key=lambda r: result_rank(str(r.get("worst_result") or "UNKNOWN")))
sec = ProtocolCertificationSection(
schema_version=SCHEMA_VERSION,
macrophase=REPORT_MACRO,
recognized_instances=dumps,
message="",
max_level_reached=L3_LEVEL,
)
return sec, findings
@@ -0,0 +1,548 @@
"""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",
"ck_to_address_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 _grouping_recon(
inst: PhysicalBusInstance,
check: str,
mand: MandatoryClass,
kind: str,
) -> L1CheckResult:
lanes = [g for g in inst.groups if g.kind == "BYTE_LANE"]
addr_cmd = [
g for g in inst.groups
if g.kind in {"ADDRESS_GROUP", "COMMAND_GROUP", "CONTROL_GROUP", "CLOCK_GROUP"}
]
data_nets = {n for g in lanes for n in g.nets}
other_nets = {n for g in addr_cmd for n in g.nets}
mixed = sorted(data_nets & other_nets)
if mixed:
return _pack(
check, "UNKNOWN", mand, nets=mixed, value_kind=kind,
notes=(
f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
f"per grouping misto DQ e ADDRESS/COMMAND ({mixed}). Not PASS. "
"No universal mm/ps."
),
)
if check == "cmd_dat_grouping":
if not lanes or not addr_cmd:
return _skip_grouping(check, mand, inst)
return _pack(
check, "PASS", mand, nets=_instance_nets(inst), value_kind=kind,
notes=(
"DQ/DQS vs ADDRESS/COMMAND/CONTROL/CLOCK reconstructed separately. "
"Controller-aware. L1 is not electrical. No universal mm/ps."
),
)
if not lanes:
return _skip_grouping(check, mand, inst)
has_dqs = any(
k.upper().startswith("DQS") for g in lanes for k in g.roles
)
if check == "dqs_relationship_if_present" and not has_dqs:
return _skip_grouping(check, mand, inst)
if check == "byte_lane_mapping" and not has_dqs:
return _skip_grouping(check, mand, inst)
return _pack(
check, "PASS", mand, nets=_instance_nets(inst), value_kind=kind,
notes=(
"Byte-lane DQ/DQS grouping reconstructed. Not a global length-match PASS. "
"L1 is not electrical certification. No universal mm/ps."
),
)
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"
grouping_recon = {
"byte_lane_mapping", "dq_group", "dqs_relationship_if_present",
"cmd_dat_grouping",
}
skew_checks = {
"dq_to_dqs_skew", "byte_lane_skew", "ck_to_command_skew",
"ck_to_address_skew", "clock_to_data_skew", "lane_relationships",
"intra_pair_skew", "pair_skew",
}
lanes = [g for g in inst.groups if g.kind == "BYTE_LANE"]
if check in grouping_recon:
return _grouping_recon(inst, check, mand, kind)
if check in skew_checks:
if check in {
"dq_to_dqs_skew", "byte_lane_skew",
} and not lanes:
return _skip_grouping(check, mand, inst)
if layout is None:
dep = kind if kind in {
"CONTROLLER_DEPENDENT", "PHY_DEPENDENT", "VENDOR_DEPENDENT",
"UNKNOWN", "MISSING_SOURCE",
} else "CONTROLLER_DEPENDENT"
return _pack(
check, dep, mand, nets=_instance_nets(inst), value_kind=kind,
notes=(
"Controller/PHY dependent skew — no universal mm/ps. "
"L1 grouping is not a length-match PASS. Not electrical."
),
)
if layout is None:
return _skip_no_geometry(check, mand, inst)
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", "ck_to_address_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
@@ -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
@@ -0,0 +1,601 @@
"""M9: L3 CHANNEL protocol certifier. Timing budget / IL / RL / crosstalk.
Numerical execution only when channel FACT already exists (extracted dB/ps).
Never invent S-parameters. Never OpenEMS/FEM. Never M6 pack numbers.
L0/L1 are not electrical; this module does not call L0/L1 electrical.
RECOMMENDED never FAIL. Internals (pcb_relevant=NO) are NOT_APPLICABLE.
Visible skip: «Interfaccia X non certificata a L3 per mancanza di …».
"""
from __future__ import annotations
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.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 = "M9"
SOURCE = "protocol_l3"
LEVEL = "L3"
L3_CHECKS = frozenset({
"timing_budget",
"insertion_loss",
"return_loss",
"crosstalk",
"channel",
"connector",
"via_transitions",
"via_transition",
"package",
})
# Always structured on PCB-relevant instances even if the catalog omits them.
L3_CORE = (
"timing_budget",
"insertion_loss",
"return_loss",
"crosstalk",
"channel",
)
_DB_UNITS = frozenset({"db", "dbm", "dbi"})
_TIME_UNITS = frozenset({"ps", "ns", "us", "µs", "ms", "s"})
_FEM_MARKERS = ("openems", "fem", "field_solver", "field-solver", "full-wave")
MandatoryClass = Literal["MANDATORY", "RECOMMENDED", "OPTIONAL", "INFORMATIONAL"]
L3Result = Literal[
"PASS", "FAIL", "WARNING", "UNKNOWN", "NOT_APPLICABLE",
"VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT", "MISSING_SOURCE",
]
class L3CheckResult(BaseModel):
check: str
level: Literal["L3"] = "L3"
result: L3Result
mandatory: MandatoryClass
nets: list[str] = Field(default_factory=list)
measured_db: float | None = None
limit_db: float | None = None
margin_db: float | None = None
measured_ps: float | None = None
limit_ps: float | None = None
margin_ps: float | None = None
total_budget_ps: float | None = None
used_budget_ps: float | None = None
remaining_margin_ps: float | None = None
value_kind: str = ""
notes: str = ""
finding_class: str = ""
status: str = ""
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 _pack(
check: str,
result: str,
mandatory: MandatoryClass,
*,
nets: list[str] | None = None,
measured_db: float | None = None,
limit_db: float | None = None,
measured_ps: float | None = None,
limit_ps: float | None = None,
total_budget_ps: float | None = None,
used_budget_ps: float | None = None,
remaining_margin_ps: float | None = None,
value_kind: str = "",
notes: str = "",
) -> L3CheckResult:
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_db = None
if measured_db is not None and limit_db is not None:
margin_db = _db_margin(check, measured_db, limit_db)
margin_ps = remaining_margin_ps
if margin_ps is None and measured_ps is not None and limit_ps is not None:
margin_ps = measured_ps - limit_ps if limit_ps < 0 else limit_ps - measured_ps
return L3CheckResult(
check=check,
result=result, # type: ignore[arg-type]
mandatory=mandatory,
nets=nets or [],
measured_db=measured_db,
limit_db=limit_db,
margin_db=margin_db,
measured_ps=measured_ps,
limit_ps=limit_ps,
margin_ps=margin_ps,
total_budget_ps=total_budget_ps,
used_budget_ps=used_budget_ps,
remaining_margin_ps=remaining_margin_ps,
value_kind=value_kind,
notes=notes,
finding_class=cls,
status=status,
)
def _db_margin(check: str, measured: float, limit: float) -> float:
if check == "return_loss":
if limit < 0:
return limit - measured
return measured - limit
if limit < 0:
return measured - limit
return limit - measured
def _db_pass(check: str, measured: float, limit: float) -> bool:
"""IL/crosstalk: max magnitude (pos) or min S-param (neg). RL: min (pos) or max S11 (neg)."""
if check == "return_loss":
if limit < 0:
return measured <= limit
return measured >= limit
if limit < 0:
return measured >= limit
return measured <= limit
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 _kind_skip(kind: str) -> str:
if kind in {
"MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT",
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
}:
return kind
return "UNKNOWN"
def _blob_mentions_fem(obj: Any) -> bool:
text = str(obj).lower()
return any(m in text for m in _FEM_MARKERS)
def _looks_like_raw_sparam(facts: dict[str, Any]) -> bool:
"""Raw Touchstone / unevaluated S-matrix — we do not reduce it here."""
if facts.get("sparam_invented") is True:
return True
if facts.get("invent_sparam") is True:
return True
for key in ("touchstone", "sparam_file", "s2p", "s4p", "snp_path"):
if facts.get(key):
has_num = any(
_num(facts.get(k)) is not None
for k in (
"insertion_loss_db", "il_db", "s21_db",
"return_loss_db", "rl_db", "s11_db",
)
)
if not has_num:
return True
return False
def _as_fact_map(channel_data: Any) -> dict[str, Any]:
if channel_data is None:
return {}
if isinstance(channel_data, dict):
return channel_data
return {}
def lookup_channel_facts(
inst: PhysicalBusInstance,
channel_data: Any,
) -> dict[str, Any]:
"""Return FACT already on disk/in-memory. Never synthesize S-parameters."""
root = _as_fact_map(channel_data)
if not root:
return {}
candidates: list[Any] = []
for bag_key in ("instances", "physical_interfaces", "logical_protocols"):
bag = root.get(bag_key)
if isinstance(bag, dict):
if bag_key == "instances":
candidates.append(bag.get(inst.instance_id))
elif bag_key == "physical_interfaces":
candidates.append(bag.get(inst.physical_interface_id))
else:
candidates.append(bag.get(inst.logical_protocol_id))
candidates.append(root.get(inst.instance_id))
candidates.append(root.get(inst.physical_interface_id))
candidates.append(root.get(inst.logical_protocol_id))
default = root.get("default")
if isinstance(default, dict):
candidates.append(default)
records = root.get("records")
if isinstance(records, list):
for rec in records:
if not isinstance(rec, dict):
continue
if rec.get("instance_id") == inst.instance_id:
candidates.append(rec)
elif rec.get("physical_interface_id") == inst.physical_interface_id:
candidates.append(rec)
elif rec.get("logical_protocol_id") == inst.logical_protocol_id:
candidates.append(rec)
merged: dict[str, Any] = {}
for c in candidates:
if isinstance(c, dict):
merged.update(c)
return merged
def remaining_margin_ps(
total: float | None,
used: float | None,
remaining: float | None = None,
) -> float | None:
"""TOTAL USED = remaining margin. Prefer explicit remaining if given."""
if remaining is not None:
return remaining
if total is None or used is None:
return None
return total - used
def _used_from_parts(facts: dict[str, Any]) -> float | None:
keys = (
"package_delay_ps",
"pcb_trace_delay_ps",
"via_delay_ps",
"connector_delay_ps",
"cable_delay_ps",
"component_delay_ps",
)
parts = [_num(facts.get(k)) for k in keys]
present = [p for p in parts if p is not None]
if not present:
return _num(facts.get("used_budget_ps"))
extra = _num(facts.get("used_budget_ps"))
if extra is not None and extra >= sum(present):
return extra
return sum(present)
def _il_measured(facts: dict[str, Any]) -> float | None:
for k in ("insertion_loss_db", "il_db", "s21_db"):
n = _num(facts.get(k))
if n is not None:
return n
return None
def _rl_measured(facts: dict[str, Any]) -> float | None:
for k in ("return_loss_db", "rl_db", "s11_db"):
n = _num(facts.get(k))
if n is not None:
return n
return None
def _xt_measured(facts: dict[str, Any]) -> float | None:
for k in ("crosstalk_db", "xtalk_db", "next_db", "fext_db"):
n = _num(facts.get(k))
if n is not None:
return n
return None
def _limit_from_constraint(cons: ProtocolConstraint | None, units: set[str]) -> 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 units or unit == "":
return cons.value
return None
def _skip(
check: str,
mand: MandatoryClass,
inst: PhysicalBusInstance,
kind: str,
missing: str,
*,
nets: list[str] | None = None,
) -> L3CheckResult:
return _pack(
check,
_kind_skip(kind),
mand,
nets=nets,
value_kind=kind,
notes=(
f"Interfaccia {inst.physical_interface_id} non certificata a L3 "
f"per mancanza di {missing}. OpenEMS/FEM out of product; S-parameters "
f"are not invented. L0/L1 are not electrical certification. Not PASS."
),
)
def _run_l3_check(
inst: PhysicalBusInstance,
iface: PhysicalInterface,
check: str,
facts: dict[str, Any],
) -> L3CheckResult:
mand = _mandatory(iface, check)
cons = _constraint_for(iface, check)
kind = cons.value_kind if cons else "UNKNOWN"
nets = _instance_nets(inst)
if facts and (_looks_like_raw_sparam(facts) or _blob_mentions_fem(facts.get("method") or facts.get("solver") or "")):
return _skip(
check, mand, inst, kind,
"dati canale ridotti a FACT (Touchstone/OpenEMS/FEM non eseguiti nel prodotto)",
nets=nets,
)
if check == "timing_budget":
total = _num(facts.get("total_budget_ps"))
used = _used_from_parts(facts)
remaining = remaining_margin_ps(total, used, _num(facts.get("remaining_margin_ps")))
limit = _limit_from_constraint(cons, _TIME_UNITS)
if remaining is None or total is None:
return _skip(
check, mand, inst, kind,
"timing budget FACT (total_budget_ps e used/remaining)",
nets=nets,
)
if cons is None or cons.value_kind != "NUMERIC":
# Measured budget exists but no cited min-margin — report numbers, skip cert.
return _pack(
check, _kind_skip(kind), mand, nets=nets,
total_budget_ps=total, used_budget_ps=used,
remaining_margin_ps=remaining, measured_ps=remaining,
value_kind=kind,
notes=(
f"Timing budget FACT total={total:g} ps used={used if used is not None else ''} "
f"remaining={remaining:g} ps. Interfaccia {inst.physical_interface_id} "
f"non certificata a L3 per mancanza di limite citato. Not invented. Not OpenEMS."
),
)
min_margin = limit if limit is not None else 0.0
verdict = "PASS" if remaining >= min_margin else "FAIL"
return _pack(
check, verdict, mand, nets=nets,
total_budget_ps=total, used_budget_ps=used,
remaining_margin_ps=remaining, measured_ps=remaining,
limit_ps=min_margin, value_kind="NUMERIC",
notes=(
f"L3 timing budget TOTALUSED=MARGIN ({total:g}{used if used is not None else 0:g}={remaining:g} ps). "
f"Cited min margin {min_margin:g} ps. Not L0/L1 electrical. Not OpenEMS."
),
)
if check in {"insertion_loss", "return_loss", "crosstalk"}:
if check == "insertion_loss":
measured = _il_measured(facts)
missing = "insertion loss FACT (dB) — S-parameters non inventati"
elif check == "return_loss":
measured = _rl_measured(facts)
missing = "return loss FACT (dB) — S-parameters non inventati"
else:
measured = _xt_measured(facts)
missing = "crosstalk FACT (dB) — S-parameters non inventati"
limit = _limit_from_constraint(cons, _DB_UNITS)
if measured is None:
return _skip(check, mand, inst, kind, missing, nets=nets)
if cons is None or cons.value_kind != "NUMERIC" or limit is None:
return _pack(
check, _kind_skip(kind), mand, nets=nets,
measured_db=measured, value_kind=kind,
notes=(
f"Channel FACT {check}={measured:g} dB. Interfaccia "
f"{inst.physical_interface_id} non certificata a L3 per mancanza di "
f"limite citato (pack NUMERIC). Not invented S-param. Not OpenEMS."
),
)
verdict = "PASS" if _db_pass(check, measured, limit) else "FAIL"
return _pack(
check, verdict, mand, nets=nets,
measured_db=measured, limit_db=limit, value_kind="NUMERIC",
notes=(
f"L3 {check} FACT {measured:g} dB vs cited {limit:g} dB. "
f"Not invented. Not OpenEMS. L0/L1 are not electrical."
),
)
if check == "channel":
if not facts:
return _skip(
check, mand, inst, kind,
"modello di canale completo (IL/RL/budget FACT già presenti)",
nets=nets,
)
if facts.get("channel_complete") is True:
il = _il_measured(facts)
rl = _rl_measured(facts)
total = _num(facts.get("total_budget_ps"))
used = _used_from_parts(facts)
remaining = remaining_margin_ps(total, used, _num(facts.get("remaining_margin_ps")))
if il is not None and rl is not None and remaining is not None:
il_c = _constraint_for(iface, "insertion_loss")
rl_c = _constraint_for(iface, "return_loss")
tb_c = _constraint_for(iface, "timing_budget")
il_lim = _limit_from_constraint(il_c, _DB_UNITS)
rl_lim = _limit_from_constraint(rl_c, _DB_UNITS)
tb_lim = _limit_from_constraint(tb_c, _TIME_UNITS)
if il_lim is None or rl_lim is None:
return _skip(
check, mand, inst, kind,
"limiti citati per IL/RL sul canale completo",
nets=nets,
)
ok = _db_pass("insertion_loss", il, il_lim) and _db_pass("return_loss", rl, rl_lim)
min_m = tb_lim if tb_lim is not None else 0.0
ok = ok and remaining >= min_m
return _pack(
check, "PASS" if ok else "FAIL", mand, nets=nets,
measured_db=il, remaining_margin_ps=remaining, value_kind="NUMERIC",
notes=(
"L3 complete channel from existing FACT (not OpenEMS). "
f"IL={il:g} dB RL={rl:g} dB margin={remaining:g} ps."
),
)
return _skip(
check, mand, inst, kind,
"canale completo (channel_complete + IL/RL/budget FACT)",
nets=nets,
)
# connector / via / package at L3 — FACT only, never FEM
if check in {"connector", "via_transitions", "via_transition", "package"}:
return _skip(
check, mand, inst, kind,
f"{check} channel FACT (no OpenEMS/FEM)",
nets=nets,
)
return _pack(check, "UNKNOWN", mand, value_kind=kind, notes="Not an L3 channel check.")
def certify_instance_l3(
inst: PhysicalBusInstance,
iface: PhysicalInterface,
channel_data: Any = None,
) -> list[L3CheckResult]:
if inst.pcb_relevant == "NO" or iface.pcb_relevant == "NO":
return [_pack(
"channel", "NOT_APPLICABLE", "INFORMATIONAL",
notes=(
"Internal interconnect — L3 channel N/A, not FAIL. "
"L0/L1 are not electrical. Not OpenEMS."
),
)]
checks: list[str] = []
for c in L3_CORE:
if c not in checks:
checks.append(c)
for c in iface.required_checks:
if c in L3_CHECKS and c not in checks:
checks.append(c)
facts = lookup_channel_facts(inst, channel_data)
return [_run_l3_check(inst, iface, c, facts) for c in checks]
def l3_findings(inst: PhysicalBusInstance, results: list[L3CheckResult]) -> 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-L3-002"
status = "ERROR"
finding = f"L3 channel {r.check}: {r.notes}"
elif skip_family:
rule_id = "PE-PRT-L3-001"
status = "WARNING"
finding = r.notes or (
f"Interfaccia {inst.physical_interface_id} non certificata a L3 "
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_l3",
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="L3 channel only from existing FACT (dB/ps). No OpenEMS. No invented S-param.",
inference="L3 is channel. L0/L1 are not electrical. Timing budget = totalused=margin.",
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_l3(
instances: list[PhysicalBusInstance],
catalog: ProtocolCatalog | None = None,
*,
channel_data: Any = None,
) -> tuple[dict[str, list[L3CheckResult]], list[Finding]]:
cat = catalog or load_catalog()
ifaces = {p.id: p for p in cat.physical_interfaces}
by_id: dict[str, list[L3CheckResult]] = {}
findings: list[Finding] = []
for inst in instances:
iface = ifaces.get(inst.physical_interface_id)
if iface is None:
continue
rows = certify_instance_l3(inst, iface, channel_data=channel_data)
by_id[inst.instance_id] = rows
findings.extend(l3_findings(inst, rows))
return by_id, findings
def load_channel_data_file(path: Any) -> dict[str, Any] | None:
"""Load existing channel_data.json. Does not parse Touchstone or run FEM."""
from pathlib import Path
import json
p = Path(path) if path is not None else None
if p is None or not p.is_file():
return None
try:
raw = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(raw, dict):
return None
return raw
@@ -0,0 +1,814 @@
"""M1: physical_bus_instance recognition. No L0L3 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, ComponentType, DesignGraph
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
from backend.periscopex.protocol_catalog import (
ProtocolCatalog,
ProtocolCertificationSection,
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(?:[\s_\-]|\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|SSTX|SSRX|SS_T[XR]", re.I)
_USB4_RE = re.compile(r"USB[\s_\-]*4|\bUSB4\b|TBT4|THUNDERBOLT\s*4", re.I)
_USB_HS_RE = re.compile(r"HIGH\s*SPEED|\b480\b", re.I)
_USB_LSFS_RE = re.compile(r"LOW\s*SPEED|FULL\s*SPEED|\b12\s*MB|\b1\.5\s*MB", re.I)
_USB_SILICON_RE = re.compile(
r"CH340|CP210|FT232|USB\s*PHY|USB\s*CTRL|USB3300|TUSB|ISP150|MAX3421", re.I,
)
_RJ45_RE = re.compile(r"RJ45|8P8C|MAGJACK", re.I)
_ETH_PHY_RE = re.compile(
r"88E\d|LAN87|DP838|RTL821|KSZ90|BCM54|ETHERNET\s*PHY|\bPHY\b.*ETH|ETH.*PHY", re.I,
)
_ETH_MAG_RE = re.compile(r"MAGNETIC|PULSE\s*JACK|ETHERNET\s*XFR|LAN_XFR", re.I)
_HDMI_MINI_RE = re.compile(r"MINI\s*HDMI|HDMI[\s_\-]*C\b|HDMI[\s_\-]*MINI", re.I)
_HDMI_RE = re.compile(r"\bHDMI\b", re.I)
_HDMI_FRL_RE = re.compile(r"\bFRL\b|HDMI\s*2\.1", re.I)
_HDMI_TMDS_NET = re.compile(r"HDMI[_]?TX|HDMI[_]?CLK|HDMI[_]?D\d|TMDS", re.I)
_DVI_RE = re.compile(r"\bDVI\b|DVI[\s_\-]*[DAI]", 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 = ""
connector: str = ""
physical_implementation: 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_ethernet(graph, ifaces))
out.extend(_recognize_dvi(graph, ifaces))
out.extend(_recognize_hdmi(graph, ifaces))
out.extend(_recognize_hbm(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] = []
type_c = []
other_usb_conn = []
for comp in graph.components.values():
if comp.component_type != ComponentType.CONNECTOR:
continue
blob = _blob(comp, graph)
if _USB_C_RE.search(blob) or _USB_C_RE.search(comp.footprint or ""):
type_c.append(comp)
elif re.search(r"USB", blob, re.I) or re.search(r"USB", comp.footprint or "", re.I):
other_usb_conn.append(comp)
pairs = _usb_pairs(list(graph.nets))
silicon = [
c for c in graph.components.values()
if c.component_type == ComponentType.IC and (
_USB_SILICON_RE.search(_blob(c, graph))
or (pairs and any(
n in (c.pins or {}).values() for n in [p for pair in pairs for p in pair]
))
)
]
ss_nets = [n for n in graph.nets if _USB3_RE.search(_leaf(n)) or _USB3_RE.search(n)]
usb4_ev = any(_USB4_RE.search(_blob(c, graph)) for c in graph.components.values())
usb3_ev = bool(ss_nets) or any(
_USB3_RE.search(_blob(c, graph)) for c in [*type_c, *silicon]
)
if not type_c and not other_usb_conn and not pairs:
return []
hs = any(_USB_HS_RE.search(_blob(c, graph)) for c in [*type_c, *other_usb_conn, *silicon])
lsfs = any(_USB_LSFS_RE.search(_blob(c, graph)) for c in [*type_c, *other_usb_conn, *silicon])
if hs and not lsfs:
usb2_log, usb2_phys = "usb2-hs", "usb2-hs-dpair"
elif lsfs and not hs:
usb2_log, usb2_phys = "usb2-ls-fs", "usb2-ls-fs-dpair"
else:
usb2_log, usb2_phys = "usb2-hs", "usb2-hs-dpair"
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})
if type_c:
host = type_c[0].reference
pin_notes = _usb_c_orientation_note(type_c[0], graph)
combo = bool(pairs) and bool(silicon)
# Connector alone does not declare USB 2.0 / 3.x / USB4.
if not combo:
iface = ifaces["usb-c-receptacle"]
instances.append(PhysicalBusInstance(
instance_id=f"usb-c-receptacle:{host}",
logical_protocol_id="usb-c",
physical_interface_id="usb-c-receptacle",
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["part"],
evidence_kind="part",
recognition_status="REVIEW",
nets=nets,
groups=groups,
host_ref=host,
peer_refs=[c.reference for c in type_c],
notes=(
"USB Type-C connector only; protocol not inferred "
"(need nets + silicon + topology). Not USB3. Not USB4. "
+ pin_notes
),
connector="Type-C",
physical_implementation="",
))
return instances
iface = ifaces["usb-c-usb2-receptacle"]
amb = [] if hs ^ lsfs else ["usb2-ls", "usb2-fs", "usb2-hs"]
status: RecognitionStatus = "REVIEW" if amb or usb3_ev else "RECOGNIZED"
notes = (
"USB 2.0 over USB Type-C (connector+nets+silicon). "
"Type-C is the connector, USB 2.0 is the protocol. "
+ pin_notes
)
if usb3_ev:
notes += " SuperSpeed evidence present — USB3 is a separate implementation, not implied by Type-C."
amb = list(dict.fromkeys(amb + ["usb3-x"]))
instances.append(PhysicalBusInstance(
instance_id=f"usb-c-usb2-receptacle:{host}",
logical_protocol_id=usb2_log,
physical_interface_id="usb-c-usb2-receptacle",
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["silicon"],
evidence_kind="silicon",
recognition_status=status,
nets=nets,
groups=groups,
host_ref=host,
peer_refs=[c.reference for c in type_c] + [s.reference for s in silicon],
ambiguous_logical_ids=amb,
notes=notes,
connector="Type-C",
physical_implementation="USB 2.0 over USB Type-C",
))
if usb3_ev and "usb3-over-usb-c" in ifaces:
instances.append(PhysicalBusInstance(
instance_id=f"usb3-over-usb-c:{host}",
logical_protocol_id="usb3-x",
physical_interface_id="usb3-over-usb-c",
pcb_relevant="YES",
confidence=_EVIDENCE_SCORE["net_name"],
evidence_kind="net_name",
recognition_status="REVIEW",
nets=sorted(ss_nets),
host_ref=host,
notes="USB 3.x over Type-C from SS evidence; not USB4; not implied by connector alone.",
connector="Type-C",
physical_implementation="USB 3.x over USB Type-C",
))
if usb4_ev and "usb4-over-usb-c" in ifaces:
instances.append(PhysicalBusInstance(
instance_id=f"usb4-over-usb-c:{host}",
logical_protocol_id="usb4",
physical_interface_id="usb4-over-usb-c",
pcb_relevant="YES",
confidence=_EVIDENCE_SCORE["part"],
evidence_kind="part",
recognition_status="REVIEW",
host_ref=host,
notes="USB4 evidence distinct from Type-C and USB3.",
connector="Type-C",
physical_implementation="USB4 over USB Type-C",
))
return instances
if not pairs:
return []
host = other_usb_conn[0].reference if other_usb_conn else (
silicon[0].reference if silicon else (nets[0] if nets else "usb")
)
if not other_usb_conn and not silicon:
# Pairs without connector or silicon: net-name only, REVIEW.
evidence: EvidenceKind = "net_name"
conf = _EVIDENCE_SCORE["net_name"]
status = "REVIEW"
notes = "USB 2.0 inferred from D+/D nets only; connector+silicon missing."
else:
evidence = "silicon" if silicon else "part"
conf = _EVIDENCE_SCORE[evidence]
status = "REVIEW" if not (hs ^ lsfs) else "RECOGNIZED"
notes = "USB 2.0 from connector+nets+silicon/topology. No invented Z."
iface = ifaces[usb2_phys]
instances.append(PhysicalBusInstance(
instance_id=f"{usb2_phys}:{host}",
logical_protocol_id=usb2_log,
physical_interface_id=usb2_phys,
pcb_relevant=iface.pcb_relevant,
confidence=conf,
evidence_kind=evidence,
recognition_status=status,
nets=nets,
groups=groups,
host_ref=host,
peer_refs=[c.reference for c in other_usb_conn] + [s.reference for s in silicon],
ambiguous_logical_ids=["usb2-ls-fs", "usb2-hs"] if status == "REVIEW" else [],
notes=notes,
connector="",
physical_implementation="USB 2.0",
))
return instances
def _usb_c_orientation_note(comp: Component, graph: DesignGraph) -> str:
"""D+/D from schematic pins; do not assume a mux mapping."""
a_side = []
b_side = []
for pin, net in (comp.pins or {}).items():
pu = pin.upper()
if pu in {"A6", "A7"} and net:
a_side.append(f"{pin}={net}")
if pu in {"B6", "B7"} and net:
b_side.append(f"{pin}={net}")
if a_side and b_side:
return (
f" D+/D both orientations on schematic ({', '.join(a_side + b_side)}); "
f"mux/switch not assumed."
)
if a_side or b_side:
return (
f" D+/D orientation from schematic pins {', '.join(a_side or b_side)}; "
f"opposite-side mux not assumed."
)
return " D+/D orientation taken from schematic; mux not assumed."
def _recognize_ethernet(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
rj45 = [
c for c in graph.components.values()
if c.component_type == ComponentType.CONNECTOR
and (_RJ45_RE.search(_blob(c, graph)) or _RJ45_RE.search(c.footprint or ""))
]
phys = [
c for c in graph.components.values()
if c.component_type == ComponentType.IC and _ETH_PHY_RE.search(_blob(c, graph))
]
mags = [c for c in graph.components.values() if _ETH_MAG_RE.search(_blob(c, graph))]
named = " ".join(_blob(c, graph) for c in graph.components.values()) + " " + " ".join(graph.nets)
if not rj45 and not phys and not re.search(r"RGMII|SGMII|1000BASE|10BASE|100BASE", named, re.I):
return []
if rj45 and not phys and not mags and not re.search(
r"1000BASE-T|100BASE-TX|10BASE-T|SGMII|RGMII", named, re.I,
):
iface = ifaces.get("ethernet-rj45-unspecified")
if iface is None:
return []
host = rj45[0].reference
return [PhysicalBusInstance(
instance_id=f"ethernet-rj45-unspecified:{host}",
logical_protocol_id="ethernet-unspecified",
physical_interface_id="ethernet-rj45-unspecified",
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["part"],
evidence_kind="part",
recognition_status="REVIEW",
host_ref=host,
peer_refs=[c.reference for c in rj45],
notes="RJ45 connector alone is not 100BASE-TX or 1000BASE-T.",
connector="RJ45",
physical_implementation="",
)]
lid, pid = "100base-tx", "100base-tx-mdi"
if re.search(r"1000BASE-T|\bGBE\b|GIGABIT", named, re.I):
lid, pid = "1000base-t", "1000base-t-mdi"
elif re.search(r"10GBASE-T", named, re.I):
lid, pid = "10gbase-t", "10gbase-t-mdi"
elif re.search(r"2\.5GBASE", named, re.I):
lid, pid = "2.5gbase-t", "2.5gbase-t-mdi"
elif re.search(r"5GBASE-T", named, re.I):
lid, pid = "5gbase-t", "5gbase-t-mdi"
elif re.search(r"SGMII", named, re.I):
lid, pid = "sgmii", "sgmii-pcb"
elif re.search(r"1000BASE-X", named, re.I):
lid, pid = "1000base-x", "1000base-x-pcb"
elif re.search(r"100BASE-FX", named, re.I):
lid, pid = "100base-fx", "100base-fx-pcb"
elif re.search(r"10GBASE-R", named, re.I):
lid, pid = "10gbase-r", "10gbase-r-pcb"
elif re.search(r"10BASE-T", named, re.I):
lid, pid = "10base-t", "10base-t-mdi"
elif re.search(r"100BASE-TX", named, re.I):
lid, pid = "100base-tx", "100base-tx-mdi"
else:
return []
iface = ifaces.get(pid)
if iface is None:
return []
host = (phys[0].reference if phys else (rj45[0].reference if rj45 else lid))
return [PhysicalBusInstance(
instance_id=f"{pid}:{host}",
logical_protocol_id=lid,
physical_interface_id=pid,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["silicon"] if phys else _EVIDENCE_SCORE["part"],
evidence_kind="silicon" if phys else "part",
recognition_status="REVIEW",
host_ref=host,
notes="Ethernet variant from PHY/magnetics/naming; IEEE vs PHY vs PCB vs magnetics stay distinct. Incomplete without IEEE 802.3 on file.",
connector=iface.connector,
physical_implementation=lid,
)]
def _recognize_dvi(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
conns = [
c for c in graph.components.values()
if c.component_type == ComponentType.CONNECTOR and (
_DVI_RE.search(_blob(c, graph)) or _DVI_RE.search(c.footprint or "")
)
]
if not conns:
return []
pid = "dvi-1.0-tmds"
iface = ifaces.get(pid)
if iface is None:
return []
host = conns[0].reference
tmds = [
n for n in graph.nets
if _HDMI_TMDS_NET.search(n) or _HDMI_TMDS_NET.search(_leaf(n)) or _DVI_RE.search(n)
]
return [PhysicalBusInstance(
instance_id=f"{pid}:{host}",
logical_protocol_id="dvi",
physical_interface_id=pid,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["part"],
evidence_kind="part",
recognition_status="REVIEW" if not tmds else "RECOGNIZED",
nets=tmds,
host_ref=host,
notes="DVI 1.0 TMDS. Not HDMI. Connector is not HDMI Type A.",
connector="DVI",
physical_implementation="DVI 1.0 TMDS",
)]
def _recognize_hdmi(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
conns = [
c for c in graph.components.values()
if c.component_type == ComponentType.CONNECTOR and (
_HDMI_RE.search(_blob(c, graph)) or _HDMI_RE.search(c.footprint or "")
or _HDMI_MINI_RE.search(_blob(c, graph))
)
]
tmds = [n for n in graph.nets if _HDMI_TMDS_NET.search(n) or _HDMI_TMDS_NET.search(_leaf(n))]
blob = " ".join(_blob(c, graph) for c in graph.components.values())
dvi_present = any(
_DVI_RE.search(_blob(c, graph)) or _DVI_RE.search(c.footprint or "")
for c in graph.components.values()
)
if dvi_present and not re.search(r"\bHDMI\b", blob, re.I):
return []
frl = bool(_HDMI_FRL_RE.search(blob) or any(_HDMI_FRL_RE.search(n) for n in graph.nets))
mini = any(
_HDMI_MINI_RE.search(_blob(c, graph)) or _HDMI_MINI_RE.search(c.footprint or "")
for c in conns
)
if not conns and not tmds and not frl:
return []
if conns and not tmds and not frl:
host = conns[0].reference
conn_name = "HDMI-C-mini" if mini else "HDMI-A"
pid = "hdmi-1.4-tmds-type-c-mini" if mini else "hdmi-1.4-tmds-type-a"
iface = ifaces.get(pid)
if iface is None:
return []
return [PhysicalBusInstance(
instance_id=f"{pid}:{host}",
logical_protocol_id="hdmi-1.4",
physical_interface_id=pid,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["part"],
evidence_kind="part",
recognition_status="REVIEW",
host_ref=host,
notes=(
"HDMI connector only; Mini HDMI is CONNECTOR not a protocol. "
"Version/mode not assumed from connector. TMDS vs FRL not inferred."
),
connector=conn_name,
physical_implementation="",
)]
if frl:
lid, pid = "hdmi-frl", "hdmi-frl-pcb"
conn_name = "HDMI-C-mini" if mini else "HDMI-A"
else:
lid = "hdmi-1.4"
pid = "hdmi-1.4-tmds-type-c-mini" if mini else "hdmi-1.4-tmds-type-a"
conn_name = "HDMI-C-mini" if mini else "HDMI-A"
if re.search(r"HDMI\s*2\.0|HDMI2\.0", blob, re.I):
lid = "hdmi-2.0"
pid = "hdmi-2.0-tmds-type-c-mini" if mini else "hdmi-2.0-tmds-type-a"
iface = ifaces.get(pid)
if iface is None:
return []
host = conns[0].reference if conns else (tmds[0] if tmds else lid)
mode = "FRL" if frl else "TMDS"
return [PhysicalBusInstance(
instance_id=f"{pid}:{host}",
logical_protocol_id=lid,
physical_interface_id=pid,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["net_name"],
evidence_kind="net_name",
recognition_status="REVIEW",
nets=tmds,
host_ref=host,
notes=(
f"HDMI {mode} electrical; connector={conn_name}. "
f"Mini HDMI is not a protocol id. TMDS rules are not applied to FRL."
),
connector=conn_name,
physical_implementation=f"HDMI {mode}",
)]
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]:
from backend.periscopex.protocol_ddr import recognize_ddr_instances
return recognize_ddr_instances(graph, ifaces)
def _recognize_hbm(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
from backend.periscopex.protocol_ddr import recognize_hbm
return recognize_hbm(graph, ifaces)
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:
from backend.periscopex.protocol_l0 import protocol_exam
return protocol_exam(graph, catalog)[0]
@@ -0,0 +1,210 @@
"""M5/M9: protocol report view — checks, chain, FAIL before WARNING, visible skips.
Does not invent Z or S-parameters. L3 uses existing channel FACT only.
OpenEMS/FEM out of product. Does not add pack numbers.
L0/L1 are not electrical certification.
"""
from __future__ import annotations
from typing import Any
from backend.periscopex.protocol_catalog import PhysicalInterface, ProtocolConstraint
from backend.periscopex.protocol_l0 import _constraint_for
from backend.periscopex.protocol_recognize import PhysicalBusInstance
PACK_MACROPHASE = "M9"
_SKIP = frozenset({
"UNKNOWN", "MISSING_SOURCE", "VENDOR_DEPENDENT",
"CONTROLLER_DEPENDENT", "PHY_DEPENDENT",
})
L3_SKIP_NOTE = (
"L3 channel not run (no channel/S-param data; OpenEMS out of scope). Not PASS."
)
def result_rank(result: str) -> int:
"""FAIL is always first. Never bury FAIL under WARNING (HubAudio)."""
if result == "FAIL":
return 0
if result == "WARNING" or result in _SKIP:
return 1
if result == "NOT_APPLICABLE":
return 2
return 3
def instance_worst_result(results: list[str]) -> str:
if not results:
return "UNKNOWN"
return min(results, key=result_rank)
def _unit(dump: dict[str, Any]) -> str:
if dump.get("measured_mm") is not None or dump.get("limit_mm") is not None:
return "mm"
if dump.get("measured_ohm") is not None or dump.get("limit_ohm") is not None:
return "ohm"
if dump.get("measured_db") is not None or dump.get("limit_db") is not None:
return "dB"
if (
dump.get("measured_ps") is not None
or dump.get("limit_ps") is not None
or dump.get("remaining_margin_ps") is not None
):
return "ps"
return ""
def _measured(dump: dict[str, Any]) -> float | None:
for k in ("measured_ohm", "measured_mm", "measured_db", "measured_ps", "remaining_margin_ps"):
v = dump.get(k)
if isinstance(v, (int, float)):
return float(v)
return None
def _limit(dump: dict[str, Any]) -> float | None:
for k in ("limit_ohm", "limit_mm", "limit_ohm_max", "limit_db", "limit_ps"):
v = dump.get(k)
if isinstance(v, (int, float)):
return float(v)
return None
def _margin(dump: dict[str, Any]) -> float | None:
for k in ("margin_ohm", "margin_mm", "margin_db", "margin_ps", "remaining_margin_ps"):
v = dump.get(k)
if isinstance(v, (int, float)):
return float(v)
return None
def _group_for_net(inst: PhysicalBusInstance, net: str | None) -> str:
if not net:
if inst.groups:
return inst.groups[0].id or inst.groups[0].kind
return ""
for g in inst.groups:
if net in g.nets:
return g.id or g.kind
return ""
def _cite(cons: ProtocolConstraint | None) -> tuple[str, str, str]:
if cons is None:
return "", "", ""
src = cons.source
doc = ""
section = ""
if src is not None:
doc = src.document or src.organization or ""
section = src.section or src.table or (src.page or "")
method = cons.measurement_method or ""
return doc, section, method
def check_to_report_row(
dump: dict[str, Any],
inst: PhysicalBusInstance,
iface: PhysicalInterface | None,
) -> dict[str, Any]:
check = str(dump.get("check") or "")
result = str(dump.get("result") or "")
cons = _constraint_for(iface, check) if iface is not None else None
doc, section, method = _cite(cons)
nets = list(dump.get("nets") or [])
net = nets[0] if nets else (inst.nets[0] if inst.nets else "")
group = _group_for_net(inst, net or None)
skip = result in _SKIP or "non certificata" in str(dump.get("notes") or "")
row = {
"check": check,
"level": dump.get("level") or "",
"result": result,
"mandatory": dump.get("mandatory") or "",
"measured": _measured(dump),
"limit": _limit(dump),
"margin": _margin(dump),
"unit": _unit(dump),
"source": doc,
"method": method,
"notes": dump.get("notes") or "",
"skip_visible": skip,
"rank": result_rank(result),
"chain": {
"net": net,
"group": group,
"physical_interface_id": inst.physical_interface_id,
"logical_protocol_id": inst.logical_protocol_id,
"constraint_id": cons.id if cons else "",
"document": doc,
"section": section,
},
}
return row
def l3_not_run_row(inst: PhysicalBusInstance) -> dict[str, Any]:
return {
"check": "channel",
"level": "L3",
"result": "UNKNOWN",
"mandatory": "INFORMATIONAL",
"measured": None,
"limit": None,
"margin": None,
"unit": "",
"source": "",
"method": "",
"notes": L3_SKIP_NOTE,
"skip_visible": True,
"rank": result_rank("UNKNOWN"),
"chain": {
"net": inst.nets[0] if inst.nets else "",
"group": inst.groups[0].id if inst.groups else "",
"physical_interface_id": inst.physical_interface_id,
"logical_protocol_id": inst.logical_protocol_id,
"constraint_id": "",
"document": "",
"section": "",
},
}
def format_chain(chain: dict[str, Any]) -> str:
keys = (
"net", "group", "physical_interface_id", "logical_protocol_id",
"constraint_id", "document", "section",
)
parts = [str(chain.get(k) or "") for k in keys]
return "".join(parts)
def attach_instance_report(
inst: PhysicalBusInstance,
iface: PhysicalInterface | None,
l0: list[Any],
l1: list[Any],
l2: list[Any],
l3: list[Any] | None = None,
) -> dict[str, Any]:
dumps = (
[c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in l0]
+ [c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in l1]
+ [c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in l2]
+ [c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in (l3 or [])]
)
rows = [check_to_report_row(d, inst, iface) for d in dumps]
if not any(r["level"] == "L3" for r in rows):
rows.append(l3_not_run_row(inst))
rows.sort(key=lambda r: (r["rank"], str(r["level"]), str(r["check"])))
worst = instance_worst_result([str(r["result"]) for r in rows if r["level"] != "L3"])
# L3 skip/UNKNOWN must not upgrade instance to PASS or hide FAIL
return {
"report_checks": rows,
"worst_result": worst,
"fail_count": sum(1 for r in rows if r["result"] == "FAIL"),
"skip_count": sum(1 for r in rows if r.get("skip_visible")),
"chain_example": format_chain(rows[0]["chain"]) if rows else "",
}
@@ -28,6 +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_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
@@ -110,6 +111,17 @@ def _load_constraints_map(
return result
def _load_channel_data(ws: PipelineWorkspace) -> dict | None:
"""Use existing channel_data.json only. Do not parse Touchstone or run OpenEMS."""
from backend.periscopex.protocol_l3 import load_channel_data_file
for rel in ("channel_data.json", "uploads/channel_data.json"):
loaded = load_channel_data_file(ws.local_path(rel))
if loaded is not None:
return loaded
return None
def si_extract_needed_skips(
graph: DesignGraph,
cmap: dict,
@@ -375,6 +387,12 @@ async def run_pcb_pipeline(
return
_step(project_id, "write_report", "running")
proto_section, proto_findings = protocol_exam(
graph, layout=layout,
constraints_map=cmap, impedance_nets=zrep,
channel_data=_load_channel_data(ws),
)
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")
@@ -393,6 +411,7 @@ async def run_pcb_pipeline(
summary=summary,
coverage=coverage,
not_reviewed=skipped + si_skip,
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")
@@ -28,6 +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_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 (
@@ -208,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")
@@ -228,6 +231,7 @@ async def validate_design_async(
coverage=_sanitize_coverage(all_coverage),
review_errors=dict(review_errors),
not_reviewed=not_reviewed,
protocol_certification=proto_section.model_dump(),
)
except Exception:
report = ValidationReport(
@@ -238,6 +242,7 @@ async def validate_design_async(
coverage={},
review_errors=dict(review_errors),
not_reviewed=not_reviewed,
protocol_certification=proto_section.model_dump(),
)
report_dict = json.loads(report.model_dump_json(indent=2))
if preserved_comments is not None:
+150
View File
@@ -2,6 +2,156 @@
What's new in Periscope.
## 2.83.0 — 2026-09-22 — Apple-like shell; USB-C vSafe still UNKNOWN
Shell Finder/Settings: sidebar, large title, system font, hairline. Findings tree keeps Error / Warning / Info folders (FAIL first) with + disclosure, then domains/rules, then Certificazioni/Protocolli. HDMI 1.4 TMDS electrical displays UNKNOWN — no guessed Zdiff.
Alta Frequenza zip and `af-allegati` were searched for vSafe5V / vSafe0V. Contents are TI SI (`snla027b` AN-807, `sdaa499` EMI) — they do not state vSafe voltages. USB Type-C CabCon R2.5 §1.6 p.35 points at USB PD without a volt range. Charger Figure 4-39 4.755.5 V stays not-vSafe.
- [Changed] App chrome (`sidebar`, `globals.css`, dashboard list, report large title).
- [Changed] Findings tree grouping + protocol section inside the tree.
- [Changed] Protocol UI: HDMI 1.4 TMDS Zdiff/limit/measured show `UNKNOWN`.
- [Changed] Frontend tests `findings-forest`, `protocol-report`.
- [Changed] `vsafe5v` / `vsafe0v` needed_document; `catalog.json`.
- [Changed] INDEX archives AF TI SI as not packed for vSafe.
## 2.82.0 — 2026-09-22 — HDMI 2.0+ TI fields use source_type VENDOR
Michele exception: HDMI 2.0 and FRL electrical/PCB numbers from TMDS1204 / SLLA633 use `source_type=VENDOR` (not HDMI Forum STANDARD). TMDS1204-specific limits (skew, vias, FRL data_rate, SIGDET) are PHY_DEPENDENT with a document/rev/section cite and no number. HDMI 1.4 TMDS stays UNKNOWN. pcbsync.com not packed.
- [Changed] `SourceType` includes `VENDOR`; `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`.
## 2.81.0 — 2026-09-22 — Protocol PDF archive + HDMI 2.0/FRL TI VENDOR overlay
On-disk archive `standards/protocol-specs/` (PDFs gitignored). INDEX.md lists cites. HDMI 2.0 and FRL take TMDS1204 Table 8-6 layout as VENDOR/PHY RECOMMENDED — not HDMI Forum STANDARD. HDMI 1.4 TMDS Zdiff stays UNKNOWN. pcbsync.com not packed.
- [New] `standards/protocol-specs/INDEX.md`; `.gitignore` `standards/protocol-specs/*.pdf`.
- [Changed] HDMI 2.0 / FRL constraints; `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest pdf-pack / m6.
## 2.80.0 — 2026-09-22 — TI TMDS1204 HDMI layout as VENDOR/PHY only
TMDS1204 datasheet SLLSF57A fills sink/source PCB layout (ZPCB 90 Ω / 75 Ω ranges, skew, length, vias, XTALK 24 dB, 5W spacing, RINT, AC cap) on physical ids `tmds1204-hdmi-sink` / `tmds1204-hdmi-source`. source_type PHY, source_class VENDOR, RECOMMENDED. Generic `hdmi-*` TMDS/FRL Zdiff stays UNKNOWN. SLLA633 is SIGDET wakeup (PHY_DEPENDENT), no layout ohms. pcbsync.com blog not packed.
- [Changed] `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`, `test_protocol_catalog_m0.py`.
## 2.79.0 — 2026-09-22 — HDMI CEC electrical from HDMI Specification 1.3
`CEC_HDMI_Specification.pdf` is High-Definition Multimedia Interface Specification Version 1.3 (22 June 2006), not a CEC-only pamphlet. CEC pull-up 27 kΩ, VOL 0.6 V, VOH 2.5 V, device 200 pF / cable 700 pF, interconnect 5 Ω are packed. TMDS/FRL Zdiff/skew stay UNKNOWN (Adopter spec still required). DDC/HPD not packed.
- [Changed] `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`, `test_protocol_m6_m7.py`.
## 2.78.0 — 2026-09-22 — 1000BASE-T from IEEE 802.3-2012 Section Three
IEEE Std 802.3-2012 Section Three Clause 40 fills 1000BASE-T: 1000 Mb/s, 100 m Class D link, cable 100 Ω, MDI return loss 16 dB (140 MHz), pair skew 50 ns, isolation 2 MΩ / 1500 V rms, MDI peak 0.67 V, link delay 570 ns. Cable 100 Ω is **CABLE**; L2 PCB Zdiff stays UNKNOWN. Clause 36 fills 1000BASE-X 1000 Mb/s (optical Zdiff N/A). Insertion-loss formula not packed as a single dB.
- [Changed] `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`, `test_protocol_m6_m7.py`.
## 2.77.0 — 2026-09-22 — 10BASE-T from IEEE 802.3-2015 Section One
IEEE Std 802.3-2015 Section One Clause 14 fills 10BASE-T: 10 Mb/s, 100 m design-objective link, cable 100 Ω ± 15 Ω, 11.5 dB link insertion loss, MAU TD return loss 15 dB, isolation 2 MΩ / 1500 V rms, TD peak 2.2 V. Cable 100 Ω is **CABLE**, not L2 PCB Zdiff. 1000BASE-T (Clause 40) is not in this volume.
- [Changed] `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`, `test_protocol_m6_m7.py`.
## 2.76.0 — 2026-09-22 — Type-C CabCon R2.0 hole-fill (CC shall-detect; VBUS from R2.5)
USB Type-C Cable and Connector Specification Release 2.0 (August 2019) fills Source CC shall-detect voltages that R2.5 only illustrates as variance examples (Table 4-32 p.240: vRd 1.50 V, vOPEN 1.65 V, vRa 0.15 V). Type-C Current charger VBUS 4.755.5 V no-load / 4.05.5 V at 3 A, zOPEN 126 kΩ, tCCDebounce 100200 ms, and tVBUSON 275 ms stay on newer CabCon R2.5 (Figure 4-39 / Tables 4-30, 4-32, 4-34). Rp/Rd/Ra unchanged. vSafe5V volt range still needs USB PD.
- [Changed] `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`.
## 2.75.0 — 2026-09-22 — Protocol packs from USB 2.0 Rev 2.0 and Type-C CabCon R2.5
USB 2.0 Base Specification (April 27, 2000) fills LS 1.50 Mb/s, FS 12 Mb/s, HS 480 Mb/s, pull-up/down, VBUS 4.755.25 V, LS/FS edges. Cable Z0 90 Ω is `source_type=CABLE`; HS PCB traces *should* 90 Ω is RECOMMENDED PCB — L2 `differential_impedance` stays UNKNOWN so 45/50 Ω fixtures are not treated as Zdiff. Type-C Cable and Connector Specification Release 2.5 fills Rp/Rd/Ra from Tables 4-27/4-28/4-29. VBUS volts still need USB PD vSafe5V. IEEE 802.3 Section One/Three and HDMI Adopter remain missing.
- [Changed] `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`.
## 2.74.0 — 2026-09-22 — Protocol packs from recovered PDFs (USB CTS, Type-C FTS, DVI 1.0, IEEE 802.3-2012 §2)
NUMERIC constraints now carry title, revision, section/table, and page from the recovered PDFs. USB 2.0 HS fills data rate / rise-fall / 1.5 kΩ pull-up / DCR from Electrical CTS v1.08; Zdiff stays UNKNOWN (CTS does not state 90 Ω). Type-C Rp/Rd/VBUS volts stay UNKNOWN (Functional Test Spec points at Cable and Connector tables). DVI 1.0 is a separate protocol from HDMI. IEEE 802.3-2012 Section Two fills 100BASE-TX cabling 100 Ω / 100 m / isolation and 100 Mb/s; 10BASE-T Clause 14 and 1000BASE-T Clause 40 are not in Section Two.
- [Changed] `protocol_catalog.py` + `catalog.json`; DVI recognition.
- [New] pytest `tests/pcb/test_protocol_pdf_pack.py`.
## 2.73.0 — 2026-09-22 — Protocol certification M6/M7 (USB-C / Ethernet / HDMI packs)
USB 2.0 (LS/FS/HS) is the protocol; USB Type-C is a connector/interface system; USB 2.0 over Type-C is the physical implementation. Type-C does not imply USB3 or USB4. Mini HDMI is a connector, not a protocol. Ethernet variants (10/100/1000/2.5G/5G/10G, SGMII, 1000BASE-X, 100BASE-FX, 10GBASE-R) are incomplete skeletons. All listed numeric fields stay **UNKNOWN** with `needed_document` (USB-IF / IEEE 802.3 / HDMI Adopter). No invented ohms, no typical-as-standard, no OpenEMS.
- [Changed] `protocol_catalog.py` + `catalog.json`; `needed_document` on UNKNOWN/MISSING_SOURCE.
- [Changed] USB/Ethernet/HDMI recognition: connector alone is not the protocol.
- [New] pytest `tests/pcb/test_protocol_m6_m7.py`.
## 2.72.0 — 2026-09-22 — Protocol certification M10 (catalog skeletons)
Catalog skeletons and `required_checks` for **PCIe / CXL** (logical vs physical), **I2C**, **CAN**, **RGMII**, plus missing high-speed processor ids (PCI, PCI-X, CCIX, OpenCAPI, UPI, DMI, Infinity Fabric, HyperTransport, UCIe). Numbers stay **UNKNOWN / MISSING_SOURCE / PHY_DEPENDENT** until an official cite is in the pack. `pcb_relevant` is YES or CONDITIONAL as appropriate. No invented ohms, no USB-IF fill, no OpenEMS.
- [Changed] `protocol_catalog.py` + committed `catalog.json`.
- [New] pytest `tests/pcb/test_protocol_catalog_m10.py`.
## 2.71.0 — 2026-09-22 — Protocol certification M9 (L3 channel + visible skip)
L3 **CHANNEL** certifier: structure for timing budget (TOTALUSED=MARGIN), insertion loss, return loss, crosstalk, and complete channel. Numerical PASS/FAIL only when **existing channel FACT** (dB/ps) is already present — never invented S-parameters, never OpenEMS/FEM. Otherwise a visible skip: «Interfaccia X non certificata a L3 per mancanza di …». Internals (AXI/HBM) stay NOT_APPLICABLE. L0/L1 are not electrical. No M6 numeric packs.
- [New] `protocol_l3.py`; `PE-PRT-L3-001` / `PE-PRT-L3-002`.
- [New] pytest `tests/pcb/test_protocol_l3_m9.py`.
## 2.70.0 — 2026-09-22 — Protocol certification M8 (DDR instance + L1 grouping)
DDR **physical instances** are controller-aware: one instance per memory device, DQ/DQS **byte lanes** separate from ADDRESS / COMMAND / CONTROL / CLOCK. Skew/length stay **CONTROLLER_DEPENDENT / PHY_DEPENDENT / UNKNOWN** — no universal mm/ps/ohm. HBM is package/interposer (`pcb_relevant: NO`); classic DDR PCB DQ rules are not applied. M6 numeric packs and L3 solver are not in this release.
- [New] `protocol_ddr.py`; L1 grouping reconstruction without PCB geometry.
- [New] pytest `tests/pcb/test_protocol_ddr_m8.py`.
## 2.69.0 — 2026-09-22 — Protocol certification M5 (Protocolli report)
Report section **Protocolli**: recognised instances, max level L0L3 (L3 is a visible not-run skip, no solver), each check with measured / limit / margin / source / method, traceability chain net → group → physical interface → protocol → constraint → document → section. FAIL is listed first and styled as Error — not hidden under Warning (HubAudio). Visible skips stay on the page. No pack numbers, no invented Z, no OpenEMS.
- [New] `protocol_report.py`; Protocolli table UI.
- [New] pytest `tests/pcb/test_protocol_report_m5.py`; `protocol-report.test.ts`.
## 2.68.0 — 2026-09-22 — Protocol certification M4 (L2 electrical)
L2 ELECTRICAL certifier: impedance, termination, voltage/levels, rise/fall when sources exist. Z only from datasheet / stackup / fabricator / **cited pack** — never invented 90 Ω USB. USB-IF/IEEE only if the pack has a cite. Silicon cross is max PCB × PHY subset when datasheet windows exist. Length is not delay. No L3/OpenEMS. L0/L1 are **not** electrical. RECOMMENDED never FAIL. MISSING_SOURCE / UNKNOWN when cite is absent. Visible skip when Z cannot be obtained.
- [New] `protocol_l2.py`; `PE-PRT-L2-001` / `PE-PRT-L2-002`.
- [New] pytest `tests/pcb/test_protocol_l2_m4.py`.
## 2.67.0 — 2026-09-22 — Protocol certification M3 (L1 geometric)
L1 GEOMETRIC certifier: length, topology, vias, stubs, layer, grouping. Geometric skew only vs **NUMERIC** millimetre or an explicit **DESIGN LIMIT**. Otherwise UNKNOWN / VENDOR_DEPENDENT / CONTROLLER_DEPENDENT / PHY_DEPENDENT — never invent mm/ps. L1 is **not** electrical. No L2/L3. RECOMMENDED never FAIL. Internal interfaces stay NOT_APPLICABLE. Missing DQ/DQS grouping is a visible skip, not PASS.
- [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 L1L3, 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 L0L3 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 L0L3 certifiers are not in this release.
- [New] `protocol_catalog.py`, `protocol_data/catalog.json`, schema 1.0.0.
- [New] `protocol_certification` on `ValidationReport`; UI section Protocolli.
- [New] pytest `tests/pcb/test_protocol_catalog_m0.py`.
- [Fixed] Native `periscope/src` wins over inherited `periscopex` on the package path.
## 2.63.5 — 2026-09-22 — AF trace analysis (trigger, cascade, visible skips)
PCB exam adds **AF trace analysis** after unchanged `run_pcb_checks` (SI/HF stay). Trigger is λ/10 or tr/(6 tpd) from FACT only. Differential pairs are one unit. Z0/Zdiff only vs a **datasheet** window. Missing tr/f/εr/stackup/Z target/via span is a visible INSUFFICIENT finding («Pista ad alta frequenza non controllata per mancanza di …»), not invented 50/90 Ω. Trigger false → no AF finding. Numerical cascade is lossless RLGC sections (ImpedenceFinder Z0); not OpenEMS/FEM. Python. PE-AF-001 AI path unchanged.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "periscope-web",
"version": "2.63.4",
"version": "2.83.0",
"private": true,
"scripts": {
"sync-version": "node scripts/sync-version.mjs",
@@ -10,7 +10,7 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "node --experimental-strip-types --test src/lib/findings-forest.test.ts"
"test": "node --experimental-strip-types --test src/lib/findings-forest.test.ts src/lib/protocol-report.test.ts"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
@@ -6,16 +6,14 @@ import { useRouter, useSearchParams } from "next/navigation";
import {
ArrowRight,
CheckCircle2,
LayoutGrid,
List,
Loader2,
X,
} from "lucide-react";
import { useCredits } from "@/components/billing/credits-context";
import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog";
import { OnboardingSurvey } from "@/components/dashboard/onboarding-survey";
import { ProjectCard } from "@/components/dashboard/project-card";
import { ProjectsTable } from "@/components/dashboard/projects-table";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -23,13 +21,10 @@ import {
fetchProjects,
reconcileCheckoutSession,
} from "@/lib/api";
import type { CreditSnapshot, Project } from "@/lib/types";
import { cn } from "@/lib/utils";
import type { Project } from "@/lib/types";
type LayoutKind = "cards" | "table";
type TopupBanner = "pending" | "activated" | "timeout" | "dismissed";
const LAYOUT_KEY = "periscopex:dashboard:view";
const CLONE_STASH_KEY = "periscopex:cloneAsNewProjectId";
export default function DashboardPage() {
@@ -62,28 +57,9 @@ function DashboardBody() {
const [bannerVisible, setBannerVisible] = useState(false);
const [topupPhase, setTopupPhase] = useState<TopupBanner>("pending");
const [activatedDetail, setActivatedDetail] = useState<string | null>(null);
const [layout, setLayout] = useState<LayoutKind>("cards");
const ordered = useMemo(() => newestFirst(projects), [projects]);
useEffect(() => {
try {
const stored = localStorage.getItem(LAYOUT_KEY);
if (stored === "cards" || stored === "table") setLayout(stored);
} catch {
/* storage blocked */
}
}, []);
function persistLayout(next: LayoutKind) {
setLayout(next);
try {
localStorage.setItem(LAYOUT_KEY, next);
} catch {
/* ignore */
}
}
function stripCheckoutQuery() {
const url = new URL(window.location.href);
url.searchParams.delete("topup");
@@ -208,7 +184,7 @@ function DashboardBody() {
}
return (
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
<div className="mx-auto w-full max-w-[720px] flex-1 px-6 py-6">
<OnboardingSurvey />
{bannerVisible && topupPhase !== "dismissed" && (
<TopupBanner
@@ -217,116 +193,44 @@ function DashboardBody() {
onDismiss={hideBanner}
/>
)}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-lg font-semibold">Projects</h1>
<CreditsLine credits={credits} />
</div>
<CreateProjectDialog
rerunProject={rerunProject}
onRerunDone={() => setRerunProject(null)}
cloneAsNewProject={cloneAsNewProject}
onCloneAsNewDone={() => setCloneAsNewProject(null)}
onCreateProject={upsertProject}
/>
</div>
{!loading && projects.length > 0 && (
<div className="flex items-center justify-end gap-2 mb-4">
<div className="inline-flex rounded-lg border border-input p-0.5">
<button
type="button"
onClick={() => persistLayout("cards")}
aria-pressed={layout === "cards"}
title="Card view"
className={cn(
"p-1 rounded-md transition-colors",
layout === "cards"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<LayoutGrid className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => persistLayout("table")}
aria-pressed={layout === "table"}
title="Table view"
className={cn(
"p-1 rounded-md transition-colors",
layout === "table"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<List className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
<PageHeader
title="Progetti"
meta={credits ? `${credits.balance.toFixed(2)} crediti` : "Un progetto, una finestra."}
actions={
<CreateProjectDialog
rerunProject={rerunProject}
onRerunDone={() => setRerunProject(null)}
cloneAsNewProject={cloneAsNewProject}
onCloneAsNewDone={() => setCloneAsNewProject(null)}
onCreateProject={upsertProject}
/>
}
/>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-36 rounded-lg" />
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 rounded-[11px]" />
))}
</div>
) : projects.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">
No projects yet. Create one to get started.
</p>
) : layout === "table" ? (
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center">
<p className="text-[15px] font-medium tracking-tight">Nessun progetto</p>
<p className="mt-1 text-[13px] text-neutral-500">
Crea un progetto e carica netlist, BOM e PCB. Lesame parte da .
</p>
</div>
) : (
<ProjectsTable
projects={ordered}
onDeleted={dropProject}
onRerun={setRerunProject}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{ordered.map((p) => (
<ProjectCard
key={p.id}
project={p}
onDeleted={() => dropProject(p.id)}
onRerun={setRerunProject}
/>
))}
</div>
)}
</div>
);
}
function CreditsLine({ credits }: { credits: CreditSnapshot | null }) {
if (!credits) {
return (
<p className="text-sm text-muted-foreground">
Schematic validation projects
</p>
);
}
return (
<p className="text-sm text-muted-foreground">
{credits.balance.toFixed(2)} credits
{" · "}
<Link
href="/billing"
className="underline underline-offset-2 hover:text-foreground"
>
buy more
</Link>
{" · "}
<Link
href="/credits"
className="underline underline-offset-2 hover:text-foreground"
>
ledger
</Link>
</p>
);
}
function TopupBanner({
phase,
detail,
@@ -14,7 +14,7 @@ export default function AppShellLayout({
<AuthGate>
<div className="flex h-full">
<Sidebar />
<main className="flex-1 flex flex-col overflow-auto">
<main className="flex min-h-0 flex-1 flex-col overflow-auto bg-[#f5f5f7] dark:bg-[#1c1c1e]">
{children}
</main>
</div>
@@ -18,6 +18,7 @@ import { useReviewedFindings } from "@/hooks/use-reviewed-findings";
import { ReportSummary } from "@/components/report/report-summary";
import { FindingsList } from "@/components/report/findings-list";
import { FindingFocusView } from "@/components/report/finding-focus-view";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Toast, useToast } from "@/components/ui/toast";
@@ -239,18 +240,14 @@ function ReportContent({ projectId }: { projectId: string }) {
return (
<div
ref={rootRef}
className={cn("p-6 mx-auto w-full", focus ? "max-w-[1920px]" : "max-w-5xl")}
className={cn("mx-auto w-full px-6 py-6", focus ? "max-w-[1920px]" : "max-w-[720px]")}
>
<div className={cn("space-y-6", focus && "hidden")}>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-lg font-semibold">Validation Report</h1>
<p className="text-sm text-muted-foreground">
{report.findings.length} findings &middot;{" "}
{new Date(report.timestamp).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
<div className={cn("space-y-5", focus && "hidden")}>
<PageHeader
title={projectName || "Esame"}
meta={`${report.findings.length} findings · ${new Date(report.timestamp).toLocaleDateString()}`}
actions={
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
@@ -295,8 +292,9 @@ function ReportContent({ projectId }: { projectId: string }) {
>
Sign release
</Button>
</div>
</div>
</div>
}
/>
<ReportSummary
summary={report.summary}
reviewedCount={reviewedCount}
@@ -342,12 +340,12 @@ function ReportContent({ projectId }: { projectId: string }) {
</ul>
</div>
)}
{report.summary.total === 0 ? (
<div className="rounded-lg border border-border bg-card p-8 text-center space-y-2">
<p className="text-sm font-medium">No findings</p>
<p className="text-xs text-muted-foreground">
No IC datasheets were available for review. Upload datasheets for each IC
on the project page and re-run the pipeline to get findings.
{report.summary.total === 0 &&
!(report.protocol_certification?.recognized_instances?.length) ? (
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center">
<p className="text-[15px] font-medium tracking-tight">Nessun finding</p>
<p className="mt-1 text-[13px] text-neutral-500">
Carica i datasheet degli IC sulla pagina progetto e rilancia lesame.
</p>
</div>
) : (
@@ -368,6 +366,7 @@ function ReportContent({ projectId }: { projectId: string }) {
reportedFindingIds={reportedFindingIds}
reviews={reviews}
onReviewSaved={handleReviewSaved}
protocolSection={report.protocol_certification}
/>
)}
</div>
+26 -24
View File
@@ -6,10 +6,11 @@
/* Map semantic tokens onto Tailwind theme keys. Values live on :root / .dark. */
@theme inline {
--font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
system-ui, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Geist Mono", Menlo, monospace;
--font-heading: var(--font-sans);
--font-headline: var(--font-dm-serif-display), Georgia, serif;
--font-headline: var(--font-sans);
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -53,15 +54,15 @@
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--radius: 0.6875rem;
--background: #f5f5f7;
--foreground: #1d1d1f;
--card: #ffffff;
--card-foreground: #1d1d1f;
--popover: #ffffff;
--popover-foreground: #1d1d1f;
--primary: #007aff;
--primary-foreground: #ffffff;
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
@@ -69,22 +70,22 @@
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--border: rgba(0, 0, 0, 0.1);
--input: rgba(0, 0, 0, 0.1);
--ring: #007aff;
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--sidebar: #f5f5f7;
--sidebar-foreground: #1d1d1f;
--sidebar-primary: #007aff;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: rgba(0, 122, 255, 0.12);
--sidebar-accent-foreground: #007aff;
--sidebar-border: rgba(0, 0, 0, 0.1);
--sidebar-ring: #007aff;
}
.dark {
@@ -129,7 +130,8 @@
@apply font-sans;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground antialiased;
font-feature-settings: "tnum" 1;
}
}
+1 -18
View File
@@ -1,5 +1,4 @@
import type { Metadata, Viewport } from "next";
import { DM_Serif_Display, Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme/theme-provider";
import { ClerkThemeProvider } from "@/components/theme/clerk-theme-provider";
@@ -13,22 +12,6 @@ import {
TWITTER_HANDLE,
} from "@/lib/site";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const dmSerifDisplay = DM_Serif_Display({
variable: "--font-dm-serif-display",
weight: "400",
subsets: ["latin"],
});
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
@@ -109,7 +92,7 @@ export default function RootLayout({
<html
lang="en"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} ${dmSerifDisplay.variable} h-full antialiased`}
className="h-full antialiased"
>
<body className="h-full">
<ThemeProvider>
@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
export function PageHeader({
title,
meta,
actions,
}: {
title: string;
meta?: string;
actions?: ReactNode;
}) {
return (
<header className="flex flex-wrap items-end justify-between gap-3 pb-4">
<div className="min-w-0">
<h1 className="text-[32px] font-semibold leading-[1.1] tracking-tight text-neutral-900 dark:text-neutral-50">
{title}
</h1>
{meta ? (
<p className="mt-1 text-[13px] text-neutral-500 tabular-nums">{meta}</p>
) : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header>
);
}
@@ -4,20 +4,15 @@ import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState, type ReactNode } from "react";
import {
ArrowLeft,
Boxes,
CircuitBoard,
BookOpen,
ClipboardList,
LayoutDashboard,
Folder,
Library,
Loader2,
MessageSquareWarning,
Ruler,
ScrollText,
ScanLine,
Settings,
Shield,
TableProperties,
Zap,
} from "lucide-react";
import { PeriscopeMark } from "@/components/brand/periscope-mark";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
@@ -72,15 +67,15 @@ export function Sidebar() {
const isAdmin = user?.isAdmin ?? false;
return (
<aside className="w-56 shrink-0 border-r border-border bg-card flex flex-col min-h-0">
<div className="px-4 py-4 border-b border-border">
<aside className="flex w-[196px] shrink-0 flex-col border-r border-black/10 bg-[#f5f5f7]/90 backdrop-blur-md dark:border-white/10 dark:bg-[#1c1c1e]/90">
<div className="border-b border-black/10 px-3 py-3 dark:border-white/10">
<Link href="/dashboard" className="flex items-center gap-2">
<PeriscopeMark className="h-5 w-5" />
<span className="text-sm font-semibold tracking-tight">Periscope</span>
<span className="text-[13px] font-semibold tracking-tight">Periscope</span>
</Link>
</div>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{projectId ? (
<Suspense fallback={<nav className="flex-1 px-2 py-3" aria-hidden />}>
<ProjectNav
@@ -95,23 +90,23 @@ export function Sidebar() {
)}
</div>
<div className="border-t border-border">
<div className="border-t border-black/10 dark:border-white/10">
<SidebarCredits />
<div className="px-2 py-1.5 border-b border-border/60">
<div className="border-b border-black/[0.06] px-2 py-1.5 dark:border-white/10">
<button
type="button"
onClick={() => setFeedbackOpen(true)}
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
className="flex w-full items-center gap-2 rounded-[8px] px-2 py-1.5 text-[11px] text-neutral-500 transition-colors duration-150 ease-out hover:bg-black/[0.04] hover:text-neutral-900"
>
<MessageSquareWarning className="h-3.5 w-3.5" />
Feedback
</button>
</div>
<div className="px-4 py-3 flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-2.5">
<SidebarUserButton />
<Link
href="/changelog"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
className="text-[11px] tabular-nums text-neutral-500 hover:text-neutral-900"
>
v{APP_VERSION}
</Link>
@@ -125,36 +120,35 @@ export function Sidebar() {
function navItemClass(active: boolean) {
return cn(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
"flex items-center gap-2 rounded-[8px] px-2 py-[6px] text-[12px] transition-colors duration-150 ease-out",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
? "bg-[#007aff]/12 text-[#007aff]"
: "text-neutral-600 hover:bg-black/[0.04] hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-white",
);
}
function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
return (
<nav className="flex-1 px-2 py-3 space-y-0.5">
<nav className="flex-1 space-y-0.5 px-2 py-3">
<Link href="/dashboard" className={navItemClass(pathname === "/dashboard")}>
<LayoutDashboard className="h-4 w-4" />
Projects
<Folder className="h-3.5 w-3.5" />
Progetti
</Link>
<Link href="/library" className={navItemClass(pathname === "/library")}>
<Library className="h-4 w-4" />
Library
<Library className="h-3.5 w-3.5" />
Libreria
</Link>
<Link href="/feedback" className={navItemClass(pathname === "/feedback")}>
<MessageSquareWarning className="h-4 w-4" />
My Feedback
<MessageSquareWarning className="h-3.5 w-3.5" />
Feedback
</Link>
{isAdmin && (
<Link
href="/admin"
className={navItemClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
>
<Shield className="h-4 w-4" />
<Shield className="h-3.5 w-3.5" />
Admin
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
</Link>
)}
</nav>
@@ -162,19 +156,18 @@ function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean
}
type NavItem =
| { type: "route"; path: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean }
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean };
| { type: "route"; path: string; label: string; icon: typeof ClipboardList }
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList };
const PROJECT_NAV_ITEMS: NavItem[] = [
const PRIMARY_NAV: NavItem[] = [
{ type: "tab", tab: "bom", label: "Esame", icon: ScanLine },
{ type: "route", path: "/report", label: "Protocolli", icon: BookOpen },
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
{ type: "route", path: "/pcb", label: "Layout", icon: CircuitBoard },
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
{ type: "tab", tab: "domains", label: "Domains", icon: Boxes },
{ type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard },
{ type: "tab", tab: "derating", label: "Derating", icon: Zap },
{ type: "tab", tab: "impedance", label: "RF / Impedance", icon: Ruler },
{ type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true },
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
];
const SECONDARY_NAV: NavItem[] = [
{ type: "route", path: "/pcb", label: "Layout", icon: ScanLine },
{ type: "tab", tab: "settings", label: "Impostazioni", icon: Settings },
];
function NavLink({
@@ -220,8 +213,11 @@ function ProjectNav({
const isRunning = project?.status === "running";
const isOnProgress = pathname === `${base}/progress`;
const onNestedRoute = pathname.startsWith(`${base}/`);
const onReport = pathname === `${base}/report`;
function isActive(item: NavItem): boolean {
if (item.label === "Protocolli") return onReport;
if (item.label === "Report") return onReport;
if (item.type === "route") {
return pathname === `${base}${item.path}` && !currentTab;
}
@@ -232,62 +228,66 @@ function ProjectNav({
}
function getHref(item: NavItem): string {
if (item.label === "Protocolli") return `${base}/report`;
if (item.type === "route") return `${base}${item.path}`;
return `${base}?tab=${item.tab}`;
}
function renderItem(item: NavItem) {
const active = isActive(item);
const href = getHref(item);
const forceDocument = item.type === "tab" && onNestedRoute;
if (isRunning) {
return (
<span
key={item.label}
className="flex cursor-not-allowed items-center gap-2 rounded-[8px] px-2 py-[6px] text-[12px] text-neutral-400"
>
<item.icon className="h-3.5 w-3.5" />
{item.label}
</span>
);
}
return (
<NavLink key={item.label} href={href} active={active} forceDocument={forceDocument}>
<item.icon className="h-3.5 w-3.5" />
{item.label}
</NavLink>
);
}
return (
<nav className="flex-1 px-2 py-3 space-y-1">
<nav className="flex-1 space-y-1 px-2 py-3">
<NavLink href="/dashboard" forceDocument={onNestedRoute}>
<ArrowLeft className="h-4 w-4" />
Dashboard
<Folder className="h-3.5 w-3.5" />
Progetti
</NavLink>
<NavLink href="/library" forceDocument={onNestedRoute}>
<Library className="h-4 w-4" />
Library
<Library className="h-3.5 w-3.5" />
Libreria
</NavLink>
<div className="px-3 pt-3 pb-1">
<p className="text-xs font-semibold text-foreground truncate">
{project?.name ?? "Loading..."}
</p>
</div>
<p className="truncate px-2 pt-3 pb-1 text-[11px] font-medium text-neutral-500">
{project?.name ?? "…"}
</p>
<div className="space-y-0.5">
{isRunning && (
<NavLink href={`${base}/progress`} active={isOnProgress}>
<Loader2 className="h-4 w-4 animate-spin" />
Processing
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Esame in corso
</NavLink>
)}
{PROJECT_NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin).map((item) => {
const active = isActive(item);
const href = getHref(item);
const forceDocument = item.type === "tab" && onNestedRoute;
if (isRunning) {
return (
<span
key={item.label}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-muted-foreground/40 cursor-not-allowed"
>
<item.icon className="h-4 w-4" />
{item.label}
{item.adminOnly && (
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
)}
</span>
);
}
return (
<NavLink key={item.label} href={href} active={active} forceDocument={forceDocument}>
<item.icon className="h-4 w-4" />
{item.label}
{item.adminOnly && (
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
)}
</NavLink>
);
})}
{PRIMARY_NAV.map(renderItem)}
</div>
<div className="mt-3 space-y-0.5 border-t border-black/10 pt-2 dark:border-white/10">
{SECONDARY_NAV.map(renderItem)}
{isAdmin ? (
<NavLink href={`${base}?tab=logs`} forceDocument={onNestedRoute}>
<Shield className="h-3.5 w-3.5" />
Log
</NavLink>
) : null}
</div>
</nav>
);
@@ -12,6 +12,7 @@ import type {
FindingStatus,
DesignGraph,
Collaborator,
ProtocolCertificationSection,
} from "@/lib/types";
import { groupBy, getFindingKey } from "@/lib/utils";
import { isLayoutFinding } from "@/lib/layout-finding";
@@ -33,6 +34,7 @@ interface FindingsListProps {
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
protocolSection?: ProtocolCertificationSection | null;
}
const DEFAULT_STATUS: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
@@ -54,6 +56,7 @@ export function FindingsList({
reportedFindingIds,
reviews,
onReviewSaved,
protocolSection,
}: FindingsListProps) {
const searchParams = useSearchParams();
const router = useRouter();
@@ -161,8 +164,11 @@ export function FindingsList({
reportedFindingIds,
reviews,
onReviewSaved,
protocolSection,
};
const showTree = filtered.length > 0 || (protocolSection?.recognized_instances?.length ?? 0) > 0;
return (
<div className="space-y-4">
<ReportFilters
@@ -180,7 +186,7 @@ export function FindingsList({
onDomainChange={(v) => updateParams({ domain: v })}
designators={designators}
/>
{filtered.length > 0 ? (
{showTree ? (
<FindingsTree findings={filtered} {...treeProps} />
) : reviewedFindings.length === 0 && findings.length > 0 ? (
<FindingsTreeEmpty />
@@ -1,10 +1,8 @@
"use client";
import { useState, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { FindingCard } from "./finding-card";
import { StatusBadge } from "./status-badge";
import { ProtocolInstanceBody } from "./protocol-section";
import type {
Finding,
FindingComment,
@@ -12,10 +10,21 @@ import type {
Collaborator,
Component,
DesignGraph,
FindingStatus,
ProtocolCertificationSection,
} from "@/lib/types";
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
import { isAfAiFinding, isPcbExamFinding } from "@/lib/layout-finding";
import { groupByWorstStatus, worstStatus } from "@/lib/findings-forest";
import {
STATUS_TINT,
forestHasCertifications,
groupByWorstStatus,
isProtocolFinding,
protocolInstancesFromSection,
protocolInstancesForStatus,
worstStatus,
type DomainGroup,
type ProtocolTreeInstance,
} from "@/lib/findings-forest";
interface FindingsTreeProps {
findings: Finding[];
@@ -35,6 +44,9 @@ interface FindingsTreeProps {
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
protocolSection?: ProtocolCertificationSection | null;
selectedKey?: string | null;
onSelect?: (key: string) => void;
}
function pinNetLabel(f: Finding): string {
@@ -46,73 +58,126 @@ function pinNetLabel(f: Finding): string {
return f.rule_id || "Finding";
}
function DisclosureMark({ open }: { open: boolean }) {
return (
<span
aria-hidden
className={cn(
"inline-flex h-[11px] w-[11px] shrink-0 items-center justify-center rounded-[2px]",
"border border-black/20 text-[9px] leading-none text-neutral-600",
"dark:border-white/25 dark:text-neutral-300",
)}
>
{open ? "" : "+"}
</span>
);
}
function TreeBranch({
label,
extra,
findings,
children,
tint,
defaultOpen = false,
}: {
label: string;
extra?: string;
findings: Finding[];
findings: { status: FindingStatus }[];
children: ReactNode;
tint?: string;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(defaultOpen);
const worst = worstStatus(findings);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger
className="flex items-center gap-2 w-full py-2 px-2 rounded-md text-left hover:bg-muted/60 min-h-11 md:min-h-8"
<div>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={cn(
"flex w-full items-center gap-2 px-2 py-[5px] text-left text-[13px] transition-colors duration-150 ease-out",
"hover:bg-black/[0.04] dark:hover:bg-white/[0.06]",
tint,
)}
>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
)}
/>
<span className="font-mono text-sm truncate">{label}</span>
<DisclosureMark open={open} />
<span className="min-w-0 truncate font-medium tracking-tight">{label}</span>
{extra ? (
<span className="text-xs text-muted-foreground truncate hidden sm:inline">{extra}</span>
<span className="hidden min-w-0 truncate text-[11px] text-neutral-500 sm:inline">
{extra}
</span>
) : null}
<span className="ml-auto flex items-center gap-1.5 shrink-0">
<StatusBadge status={worst} />
<span className="text-xs text-muted-foreground">{findings.length}</span>
<span className="ml-auto shrink-0 font-mono text-[11px] tabular-nums text-neutral-500">
{findings.length}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pl-3 ml-3 border-l border-border space-y-0.5">{children}</div>
</CollapsibleContent>
</Collapsible>
<span className="sr-only">{worst}</span>
</button>
{open ? <div className="ml-[13px] border-l border-black/10 pl-2 dark:border-white/10">{children}</div> : null}
</div>
);
}
export function FindingsTree(props: FindingsTreeProps) {
const sorted = sortFindings(props.findings);
const af = sorted.filter(isAfAiFinding);
const pcb = sorted.filter((f) => isPcbExamFinding(f) && !isAfAiFinding(f));
const schematic = sorted.filter(
(f) => !isPcbExamFinding(f) && !isAfAiFinding(f),
);
const roots: { id: string; label: string; items: Finding[] }[] = [];
if (schematic.length) roots.push({ id: "schema", label: "Schematic", items: schematic });
if (pcb.length) roots.push({ id: "pcb", label: "PCB exam", items: pcb });
if (af.length) roots.push({ id: "af_ai", label: "AF Board + AI", items: af });
const protocolFindings = sorted.filter(isProtocolFinding);
const rest = sorted.filter((f) => !isProtocolFinding(f));
const folders = groupByWorstStatus(rest.length ? rest : sorted.filter((f) => !isProtocolFinding(f)));
const instances = protocolInstancesFromSection(props.protocolSection);
const order: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
const showRoots = roots.length > 1;
const merged = order
.map((status) => {
const folder = folders.find((g) => g.status === status);
const cert = forestHasCertifications(instances, protocolFindings, status);
if (!folder && !cert) return null;
return { status, folder, cert };
})
.filter(Boolean) as {
status: FindingStatus;
folder: ReturnType<typeof groupByWorstStatus>[number] | undefined;
cert: boolean;
}[];
if (merged.length === 0 && instances.length === 0) {
return <FindingsTreeEmpty />;
}
return (
<nav aria-label="Findings tree" className="rounded-lg border border-border bg-card p-2">
{roots.map((root) => {
const body = (
<DesignatorForest
{...props}
findings={root.items}
/>
);
if (!showRoots) return <div key={root.id}>{body}</div>;
<nav
aria-label="Findings tree"
className="overflow-hidden rounded-[11px] border border-black/10 bg-white/70 dark:border-white/10 dark:bg-white/[0.04]"
>
{merged.map(({ status, folder, cert }, index) => {
const label =
status === "ERROR" ? "Error" : status === "WARNING" ? "Warning" : "Info";
const findings = [
...(folder?.findings ?? []),
...protocolFindings.filter((f) => f.status === status),
];
const protocolAtStatus = protocolFindings.filter((f) => f.status === status);
const inst = protocolInstancesForStatus(instances, status);
const count = Math.max(findings.length, inst.length);
return (
<TreeBranch key={root.id} label={root.label} findings={root.items}>
{body}
<TreeBranch
key={status}
label={label}
findings={count ? findings : [{ status }]}
tint={STATUS_TINT[status]}
defaultOpen={index === 0}
>
{(folder?.domains ?? []).map((domain) => (
<DomainBranch key={domain.id} domain={domain} {...props} />
))}
{cert ? (
<CertificationsBranch
status={status}
instances={inst}
findings={protocolAtStatus}
{...props}
/>
) : null}
</TreeBranch>
);
})}
@@ -120,25 +185,18 @@ export function FindingsTree(props: FindingsTreeProps) {
);
}
function DesignatorForest({
findings,
graph,
...cardProps
}: FindingsTreeProps) {
const folders = groupByWorstStatus(findings);
function DomainBranch({
domain,
...props
}: { domain: DomainGroup } & FindingsTreeProps) {
return (
<div>
{folders.map((folder) => (
<TreeBranch
key={folder.status}
label={folder.label}
findings={folder.findings}
>
{folder.designators.map((row) => {
<TreeBranch label={domain.label} findings={domain.findings}>
{domain.rules.map((rule) => (
<TreeBranch key={rule.ruleId} label={rule.ruleId} findings={rule.findings}>
{rule.designators.map((row) => {
const d = row.designator;
const group = row.findings;
const component: Component | undefined = graph.components[d];
const component: Component | undefined = props.graph.components[d];
const extra = [
component?.mpn,
component?.component_subtype ? subtypeLabel(component.component_subtype) : "",
@@ -157,31 +215,31 @@ function DesignatorForest({
<TreeBranch key={d} label={d} extra={extra} findings={group}>
{leaves.map(([label, items]) => (
<TreeBranch key={`${d}:${label}`} label={label} findings={items}>
<div className="space-y-3 py-2">
<div className="space-y-2 py-1.5">
{sortFindings(items).map((item) => {
const key = cardProps.findingKeys.get(item);
const key = props.findingKeys.get(item);
return (
<FindingCard
key={key}
finding={item}
onViewReference={cardProps.onViewReference}
checked={key && cardProps.isReviewed ? cardProps.isReviewed(key) : undefined}
onViewReference={props.onViewReference}
checked={key && props.isReviewed ? props.isReviewed(key) : undefined}
onCheckedChange={
key && cardProps.onToggleReviewed
? () => cardProps.onToggleReviewed!(key)
key && props.onToggleReviewed
? () => props.onToggleReviewed!(key)
: undefined
}
comments={item.finding_id ? cardProps.comments?.[item.finding_id] : undefined}
projectId={cardProps.projectId}
collaborators={cardProps.collaborators}
currentUserId={cardProps.currentUserId}
currentUserName={cardProps.currentUserName}
onCommentAdded={cardProps.onCommentAdded}
onCommentDeleted={cardProps.onCommentDeleted}
onReportFinding={cardProps.onReportFinding}
isReported={!!(item.finding_id && cardProps.reportedFindingIds?.has(item.finding_id))}
review={item.finding_id ? cardProps.reviews?.[item.finding_id] : undefined}
onReviewSaved={cardProps.onReviewSaved}
comments={item.finding_id ? props.comments?.[item.finding_id] : undefined}
projectId={props.projectId}
collaborators={props.collaborators}
currentUserId={props.currentUserId}
currentUserName={props.currentUserName}
onCommentAdded={props.onCommentAdded}
onCommentDeleted={props.onCommentDeleted}
onReportFinding={props.onReportFinding}
isReported={!!(item.finding_id && props.reportedFindingIds?.has(item.finding_id))}
review={item.finding_id ? props.reviews?.[item.finding_id] : undefined}
onReviewSaved={props.onReviewSaved}
/>
);
})}
@@ -193,14 +251,88 @@ function DesignatorForest({
})}
</TreeBranch>
))}
</div>
</TreeBranch>
);
}
function CertificationsBranch({
status,
instances,
findings,
...props
}: {
status: FindingStatus;
instances: ProtocolTreeInstance[];
findings: Finding[];
} & FindingsTreeProps) {
const all = [...findings, ...instances.map(() => ({ status }))];
return (
<TreeBranch label="Certificazioni" findings={all.length ? all : [{ status }]} defaultOpen>
<TreeBranch
label="Protocolli"
findings={all.length ? all : [{ status }]}
defaultOpen
>
{instances.map((inst) => (
<TreeBranch
key={inst.instanceId}
label={`${inst.logicalId || "protocol"}${inst.physicalId || "—"}`}
extra={inst.recognition}
findings={[{ status: inst.status }]}
>
<ProtocolInstanceBody instance={inst} />
</TreeBranch>
))}
{findings.length > 0 ? (
<TreeBranch label="Findings" findings={findings}>
<div className="space-y-2 py-1.5">
{sortFindings(findings).map((item) => {
const key = props.findingKeys.get(item);
return (
<FindingCard
key={key}
finding={item}
onViewReference={props.onViewReference}
checked={key && props.isReviewed ? props.isReviewed(key) : undefined}
onCheckedChange={
key && props.onToggleReviewed
? () => props.onToggleReviewed!(key)
: undefined
}
comments={item.finding_id ? props.comments?.[item.finding_id] : undefined}
projectId={props.projectId}
collaborators={props.collaborators}
currentUserId={props.currentUserId}
currentUserName={props.currentUserName}
onCommentAdded={props.onCommentAdded}
onCommentDeleted={props.onCommentDeleted}
onReportFinding={props.onReportFinding}
isReported={!!(item.finding_id && props.reportedFindingIds?.has(item.finding_id))}
review={item.finding_id ? props.reviews?.[item.finding_id] : undefined}
onReviewSaved={props.onReviewSaved}
/>
);
})}
</div>
</TreeBranch>
) : null}
{instances.length === 0 && findings.length === 0 ? (
<p className="px-2 py-2 text-[13px] text-neutral-500">
{props.protocolSection?.message || "Nessun protocollo riconosciuto."}
</p>
) : null}
</TreeBranch>
</TreeBranch>
);
}
export function FindingsTreeEmpty() {
return (
<p className="text-sm text-muted-foreground text-center py-12">
No findings match your filters.
</p>
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center dark:border-white/10">
<p className="text-[15px] font-medium tracking-tight">Nessun finding visibile</p>
<p className="mt-1 text-[13px] text-neutral-500">
I filtri nascondono lelenco. Togli un filtro, oppure apri i findings già rivisti sotto.
</p>
</div>
);
}
@@ -0,0 +1,136 @@
"use client";
import type { ProtocolCertificationSection } from "@/lib/types";
import {
formatLimit,
formatMargin,
formatMeasured,
formatProtocolChain,
sortProtocolChecks,
type ProtocolReportCheck,
} from "@/lib/protocol-report";
import {
protocolInstancesFromSection,
type ProtocolTreeInstance,
} from "@/lib/findings-forest";
import { cn } from "@/lib/utils";
export function ProtocolInstanceBody({ instance }: { instance: ProtocolTreeInstance }) {
const checks = sortProtocolChecks(instance.checks);
const fail = instance.worst === "FAIL" || instance.status === "ERROR";
const logical = instance.logicalId;
const physical = instance.physicalId;
return (
<div className="space-y-2 py-2 pr-2">
<p className="text-[11px] tabular-nums text-neutral-500">
{logical} {physical}
{instance.recognition ? ` · ${instance.recognition}` : ""}
{instance.worst ? ` · ${instance.worst}` : ""}
</p>
{fail ? (
<p className="text-[11px] text-[rgb(180,40,35)]">FAIL in testa non sotto Warning.</p>
) : null}
<div className="overflow-x-auto">
<table className="w-full text-[11px] tabular-nums">
<thead>
<tr className="text-left text-neutral-500">
<th className="py-1 pr-2 font-medium">Lv</th>
<th className="py-1 pr-2 font-medium">Check</th>
<th className="py-1 pr-2 font-medium">Result</th>
<th className="py-1 pr-2 font-medium">Measured</th>
<th className="py-1 pr-2 font-medium">Limit</th>
<th className="py-1 pr-2 font-medium">Margin</th>
<th className="py-1 pr-2 font-medium">Source</th>
<th className="py-1 font-medium">Method</th>
</tr>
</thead>
<tbody>
{checks.map((c, j) => (
<ProtocolCheckRow
key={`${c.level}-${c.check}-${j}`}
check={c}
logicalId={logical}
physicalId={physical}
/>
))}
</tbody>
</table>
</div>
{instance.chainExample ? (
<p className="break-all text-[11px] text-neutral-500">Catena: {instance.chainExample}</p>
) : null}
{checks
.filter((c) => c.result === "FAIL" || c.skip_visible)
.map((c, j) =>
c.chain ? (
<p key={`chain-${j}`} className="break-all text-[11px] text-neutral-500">
{c.level}/{c.check}: {formatProtocolChain(c.chain)}
</p>
) : null,
)}
</div>
);
}
function ProtocolCheckRow({
check: c,
logicalId,
physicalId,
}: {
check: ProtocolReportCheck;
logicalId: string;
physicalId: string;
}) {
const isFail = c.result === "FAIL";
const unknown = c.result === "UNKNOWN";
const measured = formatMeasured(c, logicalId, physicalId);
const limit = formatLimit(c, logicalId, physicalId);
const margin = formatMargin(c, logicalId, physicalId);
return (
<tr
className={cn(
"border-t border-black/[0.06] dark:border-white/10",
isFail && "font-medium text-[rgb(180,40,35)]",
unknown && !isFail && "text-neutral-600",
)}
>
<td className="py-1 pr-2 font-mono">{c.level}</td>
<td className="py-1 pr-2">{c.check}</td>
<td className="py-1 pr-2">{c.result}{c.skip_visible && c.result !== "FAIL" ? " · skip" : ""}</td>
<td className="py-1 pr-2 font-mono">{measured}</td>
<td className="py-1 pr-2 font-mono">{limit}</td>
<td className="py-1 pr-2 font-mono">{margin}</td>
<td className="py-1 pr-2">{c.source || "—"}</td>
<td className="py-1">{c.method || "—"}</td>
</tr>
);
}
/** Standalone block — used only if the tree is empty of findings. */
export function ProtocolSection({
section,
}: {
section?: ProtocolCertificationSection | null;
}) {
const instances = protocolInstancesFromSection(section);
const message = section?.message || "Nessun protocollo riconosciuto.";
const maxLevel = section?.max_level_reached || "—";
return (
<section className="space-y-2">
<p className="text-[11px] text-neutral-500">
Livello massimo {maxLevel} (L0L3). HDMI 1.4 TMDS resta UNKNOWN niente Zdiff inventato.
</p>
{instances.length === 0 ? (
<p className="text-[13px] text-neutral-600">{message}</p>
) : (
<ul className="space-y-3">
{instances.map((inst) => (
<li key={inst.instanceId}>
<ProtocolInstanceBody instance={inst} />
</li>
))}
</ul>
)}
</section>
);
}
@@ -1,6 +1,5 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface ReportSummaryProps {
@@ -10,81 +9,36 @@ interface ReportSummaryProps {
totalCostUsd?: number | null;
}
const CELLS = [
{ key: "ERROR", label: "Error", color: "text-rose-600 dark:text-rose-400", barColor: "bg-rose-500" },
{ key: "WARNING", label: "Warning", color: "text-amber-600 dark:text-amber-400", barColor: "bg-amber-500" },
{ key: "INFO", label: "Info", color: "text-blue-600 dark:text-blue-400", barColor: "bg-blue-500" },
{ key: "total", label: "Total", color: "text-foreground", barColor: "" },
];
export function ReportSummary({
summary,
reviewedCount,
creditsSpent,
totalCostUsd,
}: ReportSummaryProps) {
const total = summary.total || 0;
const showCredits = typeof creditsSpent === "number" && creditsSpent > 0;
const showCost = typeof totalCostUsd === "number" && totalCostUsd > 0;
const extraCols = (showCredits ? 1 : 0) + (showCost ? 1 : 0);
const grid =
extraCols === 2 ? "grid-cols-7" : extraCols === 1 ? "grid-cols-6" : "grid-cols-5";
const errors = summary.ERROR ?? 0;
const warnings = summary.WARNING ?? 0;
const infos = summary.INFO ?? 0;
const total = summary.total || errors + warnings + infos;
const outcome =
errors > 0 ? "Error" : warnings > 0 ? "Attenzione" : total > 0 ? "Completato" : "Senza findings";
const bits = [
`${errors} Error`,
`${warnings} Warning`,
`${infos} Info`,
`${reviewedCount} rivisti`,
];
if (typeof totalCostUsd === "number" && totalCostUsd > 0) {
bits.push(`$${totalCostUsd.toFixed(2)}`);
}
if (typeof creditsSpent === "number" && creditsSpent > 0) {
bits.push(`${creditsSpent.toFixed(2)} crediti`);
}
return (
<div className="space-y-4">
<div className={cn("grid gap-4", grid)}>
{CELLS.map(({ key, label, color }) => (
<Card key={key}>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">{label}</p>
<p className={cn("text-3xl font-semibold font-mono tabular-nums", color)}>
{summary[key] ?? 0}
</p>
</CardContent>
</Card>
))}
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">Checked</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-emerald-600 dark:text-emerald-400">
{reviewedCount}
</p>
</CardContent>
</Card>
{showCost && (
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">API cost</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-foreground">
${totalCostUsd!.toFixed(2)}
</p>
</CardContent>
</Card>
)}
{showCredits && (
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">Credits</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-amber-600 dark:text-amber-400">
{creditsSpent!.toFixed(2)}
</p>
</CardContent>
</Card>
)}
</div>
{total > 0 && (
<div className="flex h-2 rounded-full overflow-hidden bg-muted">
{CELLS.filter((s) => s.key !== "total" && (summary[s.key] ?? 0) > 0).map(
({ key, barColor }) => (
<div
key={key}
className={cn("h-full", barColor)}
style={{ width: `${((summary[key] ?? 0) / total) * 100}%` }}
/>
),
)}
</div>
)}
</div>
<p className={cn("text-[13px] tabular-nums text-neutral-500")}>
<span className="font-medium text-neutral-800 dark:text-neutral-200">{outcome}</span>
{" · "}
{bits.join(" · ")}
</p>
);
}
@@ -1,17 +1,22 @@
import { Badge } from "@/components/ui/badge";
import type { FindingStatus } from "@/lib/types";
import { cn } from "@/lib/utils";
/** Footnote-weight status. Light fill only (~7%), never a saturated badge. */
const TONE: Record<FindingStatus, string> = {
ERROR: "bg-rose-500/10 text-rose-600 border-rose-500/30 dark:bg-rose-500/15 dark:text-rose-400",
WARNING: "bg-amber-500/10 text-amber-600 border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-400",
INFO: "bg-blue-500/10 text-blue-600 border-blue-500/30 dark:bg-blue-500/15 dark:text-blue-400",
ERROR: "bg-[rgba(255,59,48,0.07)] text-[rgb(160,30,28)]",
WARNING: "bg-[rgba(255,214,10,0.08)] text-[rgb(140,100,20)]",
INFO: "bg-[rgba(142,142,147,0.08)] text-[rgb(70,70,78)]",
};
export function StatusBadge({ status }: { status: FindingStatus }) {
return (
<Badge variant="outline" className={cn("text-xs font-medium", TONE[status])}>
<span
className={cn(
"inline-flex items-center rounded-[6px] px-1.5 py-px text-[11px] font-medium tracking-tight",
TONE[status],
)}
>
{status}
</Badge>
</span>
);
}
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { groupByWorstStatus, worstStatus } from "./findings-forest.ts";
import { groupByWorstStatus, groupDomains, isProtocolFinding, worstStatus } from "./findings-forest.ts";
import { isPcbExamFinding } from "./layout-finding.ts";
import type { Finding } from "./types.ts";
@@ -50,6 +50,28 @@ test("Error folder lists J2 connectors, not only U*", () => {
assert.equal(groups[1].label, "Warning");
});
test("Error folder lists domains and rules after status, protocol stays out of those domains", () => {
const groups = groupByWorstStatus([
f({ designator: "U18", status: "ERROR", rule_id: "PE-PLC-002" }),
f({ designator: "U18", status: "WARNING", rule_id: "PE-PLC-001" }),
f({
designator: "J1",
status: "ERROR",
rule_id: "PE-PRT-L0-001",
source: "protocol_l0",
}),
]);
assert.equal(groups[0].label, "Error");
assert.deepEqual(
groups[0].domains.map((d) => d.id),
["PLC"],
);
assert.equal(groups[0].domains[0].rules.map((r) => r.ruleId).sort().join(","), "PE-PLC-001,PE-PLC-002");
assert.ok(groups[0].findings.some((x) => x.rule_id === "PE-PRT-L0-001"));
assert.ok(isProtocolFinding({ rule_id: "PE-PRT-L0-001", source: "protocol_l0" }));
assert.equal(groupDomains(groups[0].findings.filter((x) => !isProtocolFinding(x))).length, 1);
});
test("PE-PLC stays in the PCB exam bucket with PE-BOM", () => {
assert.equal(
isPcbExamFinding({ source: "placement_check", rule_id: "PE-PLC-002" }),
@@ -1,8 +1,11 @@
/** Group report findings for the tree: status folders, then every ref (J2 included). */
/** Group report findings for the tree: status folders, then domains/rules, then Certificazioni. */
import type { Finding, FindingStatus } from "./types";
const RANK: Record<FindingStatus, number> = { ERROR: 0, WARNING: 1, INFO: 2 };
import type { Finding, FindingStatus, ProtocolCertificationSection } from "./types";
import {
instanceWorstResult,
protocolResultToFindingStatus,
type ProtocolReportCheck,
} from "./protocol-report.ts";
export const STATUS_FOLDER: Record<FindingStatus, string> = {
ERROR: "Error",
@@ -10,6 +13,46 @@ export const STATUS_FOLDER: Record<FindingStatus, string> = {
INFO: "Info",
};
export const STATUS_TINT: Record<FindingStatus, string> = {
ERROR: "bg-[rgba(255,59,48,0.07)]",
WARNING: "bg-[rgba(255,214,10,0.08)]",
INFO: "bg-[rgba(142,142,147,0.08)]",
};
const DOMAIN_LABEL: Record<string, string> = {
PLC: "Placement",
LAY: "Layout",
SI: "Signal integrity",
BOM: "BOM",
PWR: "Power",
THM: "Thermal",
VIA: "Vias",
KEL: "Kelvin",
STCH: "Stitching",
DDR: "DDR",
CPU: "CPU",
FPGA: "FPGA",
DRT: "Derating",
HIER: "Hierarchy",
TIM: "Timing",
PI: "Power integrity",
ESD: "ESD",
RET: "Return path",
SPOF: "SPOF",
EMI: "EMI",
STK: "Stackup",
ANT: "Antenna",
PDN: "PDN",
PD: "USB-PD",
AF: "AF Board + AI",
PRT: "Protocolli",
MUX: "Pin mux",
NC: "NC pins",
DEC: "Decoupling",
SCH: "Schematic",
PCB: "PCB exam",
};
export function worstStatus(findings: { status: FindingStatus }[]): FindingStatus {
if (findings.some((f) => f.status === "ERROR")) return "ERROR";
if (findings.some((f) => f.status === "WARNING")) return "WARNING";
@@ -21,11 +64,38 @@ export type DesignatorGroup = {
findings: Finding[];
};
export type RuleGroup = {
ruleId: string;
findings: Finding[];
designators: DesignatorGroup[];
};
export type DomainGroup = {
id: string;
label: string;
findings: Finding[];
rules: RuleGroup[];
};
export type StatusGroup = {
status: FindingStatus;
label: string;
findings: Finding[];
designators: DesignatorGroup[];
domains: DomainGroup[];
};
export type ProtocolTreeInstance = {
instanceId: string;
logicalId: string;
physicalId: string;
recognition: string;
worst: string;
status: FindingStatus;
checks: ProtocolReportCheck[];
notes: string;
chainExample: string;
raw: Record<string, unknown>;
};
/** Sidebar rows: ICs, connectors (J*), and any other ref. Never U-only. */
@@ -42,9 +112,68 @@ export function groupByDesignator(findings: Finding[]): DesignatorGroup[] {
.map(([designator, items]) => ({ designator, findings: items }));
}
export function isProtocolFinding(f: {
source?: string | null;
rule_id?: string | null;
}): boolean {
const src = f.source || "";
const rid = f.rule_id || "";
return src.startsWith("protocol_") || rid.startsWith("PE-PRT");
}
export function ruleDomain(f: Finding): { id: string; label: string } {
if (isProtocolFinding(f)) return { id: "PRT", label: "Protocolli" };
const rid = f.rule_id || "";
const m = rid.match(/^PE-([A-Z]+)/);
if (m) {
const id = m[1];
return { id, label: DOMAIN_LABEL[id] || id };
}
if ((f.finding_id || "").startsWith("PCB-") || (f.source || "").includes("pcb")) {
return { id: "PCB", label: DOMAIN_LABEL.PCB };
}
return { id: "SCH", label: DOMAIN_LABEL.SCH };
}
export function groupRules(findings: Finding[]): RuleGroup[] {
const byRule = new Map<string, Finding[]>();
for (const f of findings) {
const id = (f.rule_id || "").trim() || "(no rule)";
const list = byRule.get(id) ?? [];
list.push(f);
byRule.set(id, list);
}
return [...byRule.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([ruleId, items]) => ({
ruleId,
findings: items,
designators: groupByDesignator(items),
}));
}
export function groupDomains(findings: Finding[]): DomainGroup[] {
const byId = new Map<string, Finding[]>();
for (const f of findings) {
const { id } = ruleDomain(f);
const list = byId.get(id) ?? [];
list.push(f);
byId.set(id, list);
}
return [...byId.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([id, items]) => ({
id,
label: DOMAIN_LABEL[id] || id,
findings: items,
rules: groupRules(items),
}));
}
/**
* Error / Warning / Info folders. A ref lives in the folder of its worst child.
* U18 with PE-PLC-002 ERROR + five WARNING Error, badge ERROR, count 6.
* Protocol findings (PE-PRT) are excluded from domain folders they sit under Certificazioni.
*/
export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
const buckets: Record<FindingStatus, DesignatorGroup[]> = {
@@ -60,11 +189,80 @@ export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
.filter((status) => buckets[status].length > 0)
.map((status) => {
const designators = buckets[status];
const folderFindings = designators.flatMap((d) => d.findings);
const nonProtocol = folderFindings.filter((f) => !isProtocolFinding(f));
return {
status,
label: STATUS_FOLDER[status],
findings: designators.flatMap((d) => d.findings),
findings: folderFindings,
designators,
domains: groupDomains(nonProtocol),
};
});
}
function asChecks(row: Record<string, unknown>): ProtocolReportCheck[] {
const raw = row.report_checks;
if (Array.isArray(raw) && raw.length > 0) {
return raw as ProtocolReportCheck[];
}
const fallback: ProtocolReportCheck[] = [];
for (const key of ["l0_checks", "l1_checks", "l2_checks", "l3_checks"] as const) {
const list = row[key];
if (!Array.isArray(list)) continue;
for (const c of list) {
const rec = c as Record<string, unknown>;
fallback.push({
check: String(rec.check ?? ""),
level: String(rec.level ?? ""),
result: String(rec.result ?? ""),
notes: String(rec.notes ?? ""),
skip_visible:
String(rec.result ?? "") !== "PASS" &&
String(rec.result ?? "") !== "FAIL",
});
}
}
return fallback;
}
export function protocolInstancesFromSection(
section?: ProtocolCertificationSection | null,
): ProtocolTreeInstance[] {
const instances = section?.recognized_instances ?? [];
return instances.map((row, i) => {
const rec = row as Record<string, unknown>;
const worst = String(rec.worst_result ?? "");
const checks = asChecks(rec);
const results = checks.map((c) => c.result).filter(Boolean);
const worstResolved = worst || instanceWorstResult(results);
return {
instanceId: String(rec.instance_id ?? i),
logicalId: String(rec.logical_protocol_id ?? ""),
physicalId: String(rec.physical_interface_id ?? ""),
recognition: String(rec.recognition_status ?? ""),
worst: worstResolved,
status: protocolResultToFindingStatus(worstResolved),
checks,
notes: String(rec.notes ?? rec.message ?? ""),
chainExample: String(rec.chain_example ?? ""),
raw: rec,
};
});
}
export function protocolInstancesForStatus(
instances: ProtocolTreeInstance[],
status: FindingStatus,
): ProtocolTreeInstance[] {
return instances.filter((row) => row.status === status);
}
export function forestHasCertifications(
instances: ProtocolTreeInstance[],
protocolFindings: Finding[],
status: FindingStatus,
): boolean {
if (protocolInstancesForStatus(instances, status).length > 0) return true;
return protocolFindings.some((f) => f.status === status);
}
@@ -26,6 +26,9 @@ export function isLayoutFinding(f: {
f.source === "pdn_check" ||
f.source === "af_ai_hf" ||
f.source === "af_trace_check" ||
f.source === "protocol_l0" ||
f.source === "protocol_l1" ||
f.source === "protocol_l2" ||
rid.startsWith("PE-PLC") ||
rid.startsWith("PE-LAY") ||
rid.startsWith("PE-SI") ||
@@ -50,6 +53,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)
);
}
@@ -0,0 +1,114 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
formatLimit,
formatMeasured,
formatProtocolChain,
isHdmi14TmdsElectrical,
instanceWorstResult,
protocolResultRank,
protocolResultToFindingStatus,
sortProtocolChecks,
} from "./protocol-report.ts";
import { groupByWorstStatus } from "./findings-forest.ts";
import type { Finding } from "./types.ts";
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
return {
finding_id: null,
mpn: "",
aspect: null,
finding: partial.finding ?? "",
why: "",
source_page: null,
reference: "",
...partial,
};
}
test("FAIL ranks before WARNING and is not buried", () => {
assert.ok(protocolResultRank("FAIL") < protocolResultRank("WARNING"));
assert.ok(protocolResultRank("FAIL") < protocolResultRank("UNKNOWN"));
const sorted = sortProtocolChecks([
{ check: "z", result: "UNKNOWN", level: "L2" },
{ check: "topology", result: "FAIL", level: "L0" },
{ check: "length", result: "PASS", level: "L1" },
]);
assert.equal(sorted[0].result, "FAIL");
assert.equal(sorted[0].check, "topology");
assert.equal(instanceWorstResult(["UNKNOWN", "FAIL", "PASS"]), "FAIL");
assert.equal(protocolResultToFindingStatus("FAIL"), "ERROR");
});
test("chain is net → group → physical → protocol → constraint → document → section", () => {
const text = formatProtocolChain({
net: "USB_D+",
group: "DIFF_USB",
physical_interface_id: "usb2-hs-dpair",
logical_protocol_id: "usb2-hs",
constraint_id: "usb2-hs-dpair-differential_impedance",
document: "",
section: "",
});
assert.equal(
text,
"USB_D+ → DIFF_USB → usb2-hs-dpair → usb2-hs → usb2-hs-dpair-differential_impedance → — → —",
);
assert.ok(!text.includes("90"));
});
test("protocol FAIL finding stays in Error folder with WARNING skips on same ref", () => {
const groups = groupByWorstStatus([
f({
designator: "U1",
status: "ERROR",
rule_id: "PE-PRT-L0-001",
source: "protocol_l0",
finding: "L0 FAIL topology",
}),
f({
designator: "U1",
status: "WARNING",
rule_id: "PE-PRT-L2-001",
source: "protocol_l2",
finding: "non certificata a L2 per mancanza di Z",
}),
]);
assert.equal(groups[0].label, "Error");
assert.equal(groups[0].status, "ERROR");
assert.ok(groups[0].findings.some((x) => x.rule_id === "PE-PRT-L0-001"));
});
test("HDMI 1.4 TMDS Zdiff displays UNKNOWN, never a guessed ohm number", () => {
const row = {
check: "differential_impedance",
level: "L2",
result: "UNKNOWN",
measured: 100,
limit: 100,
margin: 0,
unit: "ohm",
chain: {
logical_protocol_id: "hdmi-1.4",
physical_interface_id: "hdmi-1.4-tmds-type-a",
},
};
assert.equal(isHdmi14TmdsElectrical(row), true);
assert.equal(formatLimit(row), "UNKNOWN");
assert.equal(formatMeasured(row), "UNKNOWN");
assert.ok(!formatLimit(row).includes("100"));
assert.ok(!formatMeasured(row).includes("Ω"));
});
test("HDMI 1.4 CEC is not treated as TMDS Zdiff", () => {
const row = {
check: "cec",
level: "L2",
result: "PASS",
limit: 27000,
unit: "ohm",
chain: { logical_protocol_id: "hdmi-1.4" },
};
assert.equal(isHdmi14TmdsElectrical(row), false);
assert.equal(formatLimit(row), "27000 ohm");
});
@@ -0,0 +1,174 @@
/** Protocol report ranking and chain. FAIL never ranks under WARNING. */
export const PROTOCOL_LEVELS = ["L0", "L1", "L2", "L3"] as const;
export type ProtocolCheckResult =
| "PASS"
| "FAIL"
| "WARNING"
| "UNKNOWN"
| "NOT_APPLICABLE"
| "VENDOR_DEPENDENT"
| "CONTROLLER_DEPENDENT"
| "PHY_DEPENDENT"
| "MISSING_SOURCE";
export type ProtocolChain = {
net?: string;
group?: string;
physical_interface_id?: string;
logical_protocol_id?: string;
constraint_id?: string;
document?: string;
section?: string;
};
export type ProtocolReportCheck = {
check: string;
level: string;
result: string;
mandatory?: string;
measured?: number | null;
limit?: number | null;
margin?: number | null;
unit?: string;
source?: string;
method?: string;
notes?: string;
skip_visible?: boolean;
rank?: number;
chain?: ProtocolChain;
};
const SKIP = new Set([
"UNKNOWN",
"MISSING_SOURCE",
"VENDOR_DEPENDENT",
"CONTROLLER_DEPENDENT",
"PHY_DEPENDENT",
]);
export function protocolResultRank(result: string): number {
if (result === "FAIL") return 0;
if (result === "WARNING" || SKIP.has(result)) return 1;
if (result === "NOT_APPLICABLE") return 2;
return 3;
}
export function sortProtocolChecks<T extends { result: string; level?: string; check?: string }>(
rows: T[],
): T[] {
return [...rows].sort((a, b) => {
const d = protocolResultRank(a.result) - protocolResultRank(b.result);
if (d !== 0) return d;
return String(a.level).localeCompare(String(b.level)) || String(a.check).localeCompare(String(b.check));
});
}
export function instanceWorstResult(results: string[]): string {
if (!results.length) return "UNKNOWN";
return results.reduce((best, r) =>
protocolResultRank(r) < protocolResultRank(best) ? r : best,
);
}
export function protocolResultToFindingStatus(
result: string,
): "ERROR" | "WARNING" | "INFO" {
if (result === "FAIL") return "ERROR";
if (result === "WARNING" || SKIP.has(result)) return "WARNING";
return "INFO";
}
export function formatProtocolChain(chain?: ProtocolChain | null): string {
if (!chain) return "—";
const keys: (keyof ProtocolChain)[] = [
"net",
"group",
"physical_interface_id",
"logical_protocol_id",
"constraint_id",
"document",
"section",
];
return keys.map((k) => chain[k] || "—").join(" → ");
}
const HDMI14_TMDS_ELECTRICAL = new Set([
"differential_impedance",
"impedance_tolerance",
"intra_pair_skew",
"inter_lane_skew",
"clock_data_relationship",
"data_rate",
"transition_rate",
"channel_loss",
"insertion_loss",
"return_loss",
"crosstalk",
]);
export function isHdmi14Protocol(id?: string | null): boolean {
const s = (id || "").toLowerCase();
return s === "hdmi-1.4" || s.startsWith("hdmi-1.4-");
}
/** HDMI 1.4 TMDS electrical is UNKNOWN until the Adopter spec is on file. CEC from HDMI 1.3 stays. */
export function isHdmi14TmdsElectrical(
row: Pick<ProtocolReportCheck, "check" | "chain">,
logicalId?: string | null,
physicalId?: string | null,
): boolean {
const logical = logicalId || row.chain?.logical_protocol_id || "";
const physical = physicalId || row.chain?.physical_interface_id || "";
if (!isHdmi14Protocol(logical) && !isHdmi14Protocol(physical)) return false;
const check = (row.check || "").toLowerCase();
if (check === "cec" || check.startsWith("cec_")) return false;
if (HDMI14_TMDS_ELECTRICAL.has(check)) return true;
return check.includes("impedance") || check.includes("zdiff");
}
export function formatUnknownNotGuessed(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): boolean {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return true;
return row.result === "UNKNOWN";
}
export function formatMeasured(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): string {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
if (row.result === "UNKNOWN") return "UNKNOWN";
if (row.measured == null) return "—";
const unit = row.unit ? ` ${row.unit}` : "";
return `${row.measured}${unit}`;
}
export function formatLimit(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): string {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
if (row.result === "UNKNOWN") return "UNKNOWN";
if (row.limit == null) return "—";
const unit = row.unit ? ` ${row.unit}` : "";
return `${row.limit}${unit}`;
}
export function formatMargin(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): string {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
if (row.result === "UNKNOWN") return "UNKNOWN";
if (row.margin == null) return "—";
const unit = row.unit ? ` ${row.unit}` : "";
return `${row.margin}${unit}`;
}
+9
View File
@@ -75,6 +75,15 @@ export interface ValidationReport {
comments?: Record<string, FindingComment[]>;
review_states?: Record<string, FindingReview>;
release?: ReportRelease;
protocol_certification?: ProtocolCertificationSection | null;
}
export interface ProtocolCertificationSection {
schema_version: string;
macrophase: string;
recognized_instances: Record<string, unknown>[];
message: string;
max_level_reached: string | null;
}
export type NetType = "power" | "ground" | "signal" | "unknown";
+1 -1
View File
@@ -1,3 +1,3 @@
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
export const APP_VERSION = "2.63.4";
export const APP_VERSION = "2.83.0";
export const APP_VERSION_DATE = "2026-09-22";
+27
View File
@@ -0,0 +1,27 @@
# Protocol specification archive
PDFs live **on disk** in this folder. They are **not** committed to GitHub/Gitea (license). See `.gitignore` `standards/protocol-specs/*.pdf`.
Mirror: agent-store `internal/protocol-specs/`.
TI files are **source_type VENDOR**, never HDMI Forum STANDARD. `https://pcbsync.com/hdmi-pcb-layout/` is a blog — **not packed**.
| Filename | Title | Revision | Cited by (physical / notes) | Class |
|---|---|---|---|---|
| `usb_20.pdf` | Universal Serial Bus Specification | Revision 2.0, April 27, 2000 | `usb2-ls-dpair`, `usb2-fs-dpair`, `usb2-hs-dpair` (data_rate, pull-up/down, VBUS, cable Z0, HS PCB *should*) | STANDARD |
| `USB_2.0_Electrical_Compliance_Test_Spec.pdf` | USB 2.0 Electrical Compliance Test Specification | Version 1.08, April 2026 | `usb2-hs-dpair` DCR 17/23/13 Ω, HS edges | STANDARD |
| `USB Type-C Spec R2.5 - March 2026.pdf` | USB Type-C Cable and Connector Specification | Release 2.5, March 2026 | `usb-c-receptacle` Rp/Rd/Ra, charger VBUS Fig 4-39, zOPEN, tCCDebounce, tVBUSON | STANDARD |
| `USB Type-C Spec R2.0 - August 2019.pdf` | USB Type-C Cable and Connector Specification | Release 2.0, August 2019 | `usb-c-receptacle` Source CC shall-detect Table 4-32 (vRd/vOPEN/vRa) | STANDARD |
| `USB_Type-C_Functional_Test_Spec.pdf` | USB Type-C Functional Test Specification Chapters 4 and 5 | Revision 0.91, March 3, 2024 | `usb-c-receptacle` cable IR drop 250/500 mV OPTIONAL CABLE | STANDARD (OPTIONAL CABLE) |
| `DVI_Specification_Revision_1.0.pdf` | Digital Visual Interface (DVI) | Revision 1.0, 02 April 1999 | `dvi-1.0-tmds` termination / Zdiff | STANDARD |
| `802.3-2012_section2.pdf` | IEEE Std 802.3-2012 IEEE Standard for Ethernet (Section Two) | 2012 | `100base-tx-mdi`, `100base-fx-pcb` | STANDARD |
| `802.3-2015_SECTION1.pdf` | IEEE Std 802.3-2015 IEEE Standard for Ethernet (Section One) | 2015 | `10base-t-mdi` Clause 14 | STANDARD |
| `802.3-2012_section3.pdf` | IEEE Std 802.3-2012 IEEE Standard for Ethernet (Section Three) | 2012 | `1000base-t-mdi` Clause 40; `1000base-x-pcb` Clause 36 | STANDARD |
| `CEC_HDMI_Specification.pdf` | High-Definition Multimedia Interface Specification | Version 1.3, June 22, 2006 | HDMI `cec*` fields (Supplement 1 + Table 4-27). **Not** used for TMDS/FRL Zdiff | STANDARD (CEC only) |
| `tmds1204.pdf` | TMDS1204 12Gbps HDMI Hybrid Redriver | SLLSF57A August 2022 / revised April 2024 | `tmds1204-hdmi-sink`, `tmds1204-hdmi-source`; HDMI **2.0 / FRL** common layout `source_type=VENDOR` | **VENDOR** |
| `slla633.pdf` | TMDS_CLOCK/FRL_Data Detection Design in HDMI Sink Applications TMDS1204 | SLLA633 May 2024 | HDMI 2.0+/`tmds1204-*` `sigdet_wakeup` PHY_DEPENDENT (no layout ohms) | **VENDOR** |
| `HDMI-FRL-Software-Datasheet-61W6169600.pdf` | HDMI 2.1 FRL Compliance Test Solution Datasheet (Tektronix) | (on file; 4 pages) | **not packed** — test-equipment datasheet, not HDMI Forum Adopter | archived only |
| `snla027b.pdf` | AN-807 Reflections: Computations and Waveforms | SNLA027B May 2004 / revised May 2004 | **not packed** for USB-C vSafe — TI SI, no vSafe5V/vSafe0V | archived only |
| `sdaa499.pdf` | Simple Methods to Reduce EMI from a PCB Trace | SDAA499 September 2026 | **not packed** for USB-C vSafe — TI EMI, no vSafe | archived only |
Duplicates on Desktop (same MD5, not stored twice here): `USB2_Electrical_Compliance.pdf`, `DVI_Spec_v1.0.pdf`, `USB_TypeC_Functional_Test.pdf`.
+5 -2
View File
@@ -16,8 +16,11 @@ for p in (
REPO_ROOT,
):
s = str(p)
if p.is_dir() and s not in sys.path:
sys.path.insert(0, s)
if not p.is_dir():
continue
if s in sys.path:
sys.path.remove(s)
sys.path.insert(0, s)
from backend.vendor_path import ensure_impedancefinder
+252
View File
@@ -0,0 +1,252 @@
"""M0 protocol pack: parse, reject typical-as-standard, no invented ohms."""
from __future__ import annotations
import copy
import json
import pytest
from backend.periscopex.models import ValidationReport
from backend.periscopex.protocol_catalog import (
CATALOG_PATH,
SCHEMA_PATH,
SCHEMA_VERSION,
ProtocolCatalogError,
build_m0_catalog,
empty_protocol_section,
fail_mandatory,
load_catalog,
map_protocol_outcome,
parse_catalog,
)
REQUIRED_LOGICAL = {
"usb2-ls-fs", "usb2-ls", "usb2-fs", "usb2-hs", "usb-c", "usb-c-usb2",
"usb3-x", "usb4", "100base-tx", "10base-t", "1000base-t",
"hdmi-1.4", "hdmi-2.0", "hdmi-frl", "tmds1204",
"dvi",
"ddr-family", "ddr4", "lpddr-family", "lpddr4",
"hbm-family", "hbm3", "hyperbus", "qspi", "octal-spi", "emmc", "ufs",
"axi4", "axi4-lite", "ahb", "apb", "wishbone", "avalon-mm", "avalon-st",
"tilelink", "pcie", "cxl",
"pci", "pci-x", "ccix", "opencapi", "upi", "dmi",
"infinity-fabric", "hypertransport", "ucie",
"i2c", "can", "rgmii",
}
def test_committed_catalog_parses_and_matches_builder():
loaded = load_catalog()
rebuilt = parse_catalog(build_m0_catalog())
assert loaded.schema_version == SCHEMA_VERSION
assert loaded.model_dump() == rebuilt.model_dump()
assert CATALOG_PATH.is_file()
assert SCHEMA_PATH.is_file()
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
assert "properties" in schema
assert schema["properties"]["schema_version"]
def test_logical_and_physical_ids_are_disjoint():
cat = load_catalog()
logical = {p.id for p in cat.logical_protocols}
physical = {p.id for p in cat.physical_interfaces}
assert not (logical & physical)
assert REQUIRED_LOGICAL <= logical
for iface in cat.physical_interfaces:
assert iface.id != iface.logical_protocol_id
assert iface.pcb_relevant in {"YES", "NO", "CONDITIONAL"}
def test_amba_internal_is_not_pcb_routing():
cat = load_catalog()
axi = next(p for p in cat.physical_interfaces if p.id == "axi4-internal")
assert axi.pcb_relevant == "NO"
assert axi.physical_layer == "INTERNAL"
assert axi.logical_protocol_id == "axi4"
assert axi.constraints[0].value_kind == "NOT_APPLICABLE"
def test_ddr_has_no_universal_mm_or_ps():
cat = load_catalog()
ddr4 = next(p for p in cat.physical_interfaces if p.id == "ddr4-sdram-pcb")
assert ddr4.pcb_relevant == "YES"
for c in ddr4.constraints:
assert c.value_kind in {
"CONTROLLER_DEPENDENT", "PHY_DEPENDENT", "UNKNOWN",
"VENDOR_DEPENDENT", "MISSING_SOURCE", "NOT_APPLICABLE",
}
assert c.value is None
assert c.unit is None
def test_hbm_dq_not_pcb_relevant():
cat = load_catalog()
hbm = next(p for p in cat.physical_interfaces if p.id == "hbm3-package")
assert hbm.pcb_relevant == "NO"
def test_hdmi_mini_is_same_logical_different_connector():
cat = load_catalog()
a = next(p for p in cat.physical_interfaces if p.id == "hdmi-1.4-tmds-type-a")
mini = next(
p for p in cat.physical_interfaces if p.id == "hdmi-1.4-tmds-type-c-mini"
)
assert a.logical_protocol_id == mini.logical_protocol_id == "hdmi-1.4"
assert a.connector == "HDMI-A"
assert mini.connector == "HDMI-C-mini"
def test_catalog_numeric_requires_official_cite_no_invented_usb_90ohm():
cat = load_catalog()
usb_z = []
for iface in cat.physical_interfaces:
for c in iface.constraints:
if c.value_kind == "NUMERIC":
assert c.value is not None
assert c.source is not None
assert c.source.document and c.source.organization
assert c.source.revision
assert c.source.page
assert c.source.section or c.source.table
if (
iface.logical_protocol_id.startswith("usb")
and c.parameter == "differential_impedance"
):
usb_z.append(c)
assert c.value_kind != "NUMERIC"
assert c.value is None
assert usb_z
blob = json.dumps([c.model_dump() for c in usb_z])
assert "90" not in blob
def test_recommended_is_not_fail_mandatory():
cat = load_catalog()
recs = [
c
for iface in cat.physical_interfaces
for c in iface.constraints
if c.mandatory == "RECOMMENDED"
]
assert recs, "catalog should include at least one RECOMMENDED skeleton"
for c in recs:
assert fail_mandatory(c) is False
cls, status, _ = map_protocol_outcome("FAIL", c.mandatory)
assert status != "ERROR"
assert cls != "RULE"
def test_mandatory_fail_can_be_error():
cls, status, _ = map_protocol_outcome("FAIL", "MANDATORY")
assert cls == "RULE"
assert status == "ERROR"
def test_reject_typical_as_standard():
raw = build_m0_catalog()
iface = raw["physical_interfaces"][0]
typical = copy.deepcopy(iface["constraints"][0])
typical["id"] = "bad-typical"
typical["typical"] = True
typical["value_origin"] = "TYPICAL"
typical["source_type"] = "STANDARD"
typical["source_class"] = "NORMATIVE"
typical["mandatory"] = "MANDATORY"
typical["value_kind"] = "UNKNOWN"
typical["value"] = None
iface = copy.deepcopy(iface)
iface["id"] = "usb2-hs-typical-bad"
iface["constraints"] = [typical]
raw["physical_interfaces"].append(iface)
with pytest.raises(ProtocolCatalogError, match="typical"):
parse_catalog(raw)
def test_reject_constraint_without_source_type():
raw = build_m0_catalog()
iface = copy.deepcopy(raw["physical_interfaces"][0])
iface["id"] = "usb2-hs-no-source"
bad = copy.deepcopy(iface["constraints"][0])
bad["id"] = "no-source"
del bad["source_type"]
iface["constraints"] = [bad]
raw["physical_interfaces"].append(iface)
with pytest.raises(ProtocolCatalogError):
parse_catalog(raw)
def test_reject_numeric_without_cite():
raw = build_m0_catalog()
iface = copy.deepcopy(raw["physical_interfaces"][0])
iface["id"] = "usb2-hs-fake-z"
iface["constraints"] = [{
"id": "fake-zdiff",
"parameter": "differential_impedance",
"value_kind": "NUMERIC",
"value": 90.0,
"unit": "ohm",
"mandatory": "MANDATORY",
"source_type": "STANDARD",
"source_class": "NORMATIVE",
"source": None,
"typical": False,
"value_origin": "SPEC",
"conditions": [],
}]
raw["physical_interfaces"].append(iface)
with pytest.raises(ProtocolCatalogError, match="cite"):
parse_catalog(raw)
def test_reject_boolean_pcb_relevant():
raw = build_m0_catalog()
iface = copy.deepcopy(raw["physical_interfaces"][0])
iface["id"] = "usb2-hs-bool-pcb"
iface["pcb_relevant"] = True
raw["physical_interfaces"].append(iface)
with pytest.raises(ProtocolCatalogError):
parse_catalog(raw)
def test_reject_same_logical_and_physical_id():
raw = build_m0_catalog()
iface = copy.deepcopy(raw["physical_interfaces"][0])
iface["id"] = iface["logical_protocol_id"]
raw["physical_interfaces"].append(iface)
with pytest.raises(ProtocolCatalogError, match="differ"):
parse_catalog(raw)
def test_empty_report_section_has_no_z():
section = empty_protocol_section()
dumped = section.model_dump()
assert section.message == "nessun protocollo riconosciuto"
assert section.recognized_instances == []
assert dumped.get("max_level_reached") is None
blob = json.dumps(dumped)
assert "90" not in blob
assert "ohm" not in blob.lower()
assert "Zdiff" not in blob
assert "z0" not in blob.lower()
report = ValidationReport(
project="t",
timestamp="2026-09-22T00:00:00Z",
findings=[],
summary={"ERROR": 0, "WARNING": 0, "INFO": 0},
protocol_certification=dumped,
)
assert report.protocol_certification["message"] == "nessun protocollo riconosciuto"
def test_pcie_cxl_physical_packs_are_empty_numbers():
cat = load_catalog()
for pid in ("pcie-phy-pcb", "cxl-phy-pcb"):
iface = next(p for p in cat.physical_interfaces if p.id == pid)
assert iface.logical_protocol_id in {"pcie", "cxl"}
assert iface.required_checks
for c in iface.constraints:
assert c.value_kind in {"MISSING_SOURCE", "UNKNOWN"}
assert c.value is None
+115
View File
@@ -0,0 +1,115 @@
"""M10 catalog skeletons: PCIe/CXL logical vs physical, I2C/CAN/RGMII, HS processor ids."""
from __future__ import annotations
import json
from backend.periscopex.protocol_catalog import (
CAN_CHECKS,
I2C_CHECKS,
PCIE_CHECKS,
PCI_PARALLEL_CHECKS,
RGMII_CHECKS,
build_m0_catalog,
load_catalog,
parse_catalog,
)
HS_LOGICAL = {
"pci", "pci-x", "pcie", "cxl", "ccix", "opencapi",
"upi", "dmi", "infinity-fabric", "hypertransport", "ucie",
}
NO_INVENTED = ("90", "100 ", "50 ", "120")
def test_m10_logical_physical_split_pcie_cxl():
cat = load_catalog()
pcie_log = next(p for p in cat.logical_protocols if p.id == "pcie")
cxl_log = next(p for p in cat.logical_protocols if p.id == "cxl")
pcie_phy = next(p for p in cat.physical_interfaces if p.id == "pcie-phy-pcb")
cxl_phy = next(p for p in cat.physical_interfaces if p.id == "cxl-phy-pcb")
assert pcie_log.id != pcie_phy.id
assert cxl_log.id != cxl_phy.id
assert pcie_phy.logical_protocol_id == "pcie"
assert cxl_phy.logical_protocol_id == "cxl"
assert pcie_phy.logical_protocol_id != cxl_phy.logical_protocol_id
assert pcie_phy.pcb_relevant == "YES"
assert cxl_phy.pcb_relevant == "YES"
assert set(pcie_phy.required_checks) == set(PCIE_CHECKS)
assert set(cxl_phy.required_checks) == set(PCIE_CHECKS)
def test_m10_high_speed_processor_ids_present():
cat = load_catalog()
logical = {p.id for p in cat.logical_protocols}
assert HS_LOGICAL <= logical
physical = {p.id: p for p in cat.physical_interfaces}
assert physical["pci-pcb"].pcb_relevant == "YES"
assert physical["pci-x-pcb"].pcb_relevant == "YES"
assert set(physical["pci-pcb"].required_checks) == set(PCI_PARALLEL_CHECKS)
assert physical["ccix-phy-pcb"].pcb_relevant == "YES"
assert physical["opencapi-phy-pcb"].pcb_relevant == "YES"
assert physical["hypertransport-phy-pcb"].pcb_relevant == "YES"
assert physical["upi-phy"].pcb_relevant == "CONDITIONAL"
assert physical["dmi-phy"].pcb_relevant == "CONDITIONAL"
assert physical["infinity-fabric-phy"].pcb_relevant == "CONDITIONAL"
assert physical["ucie-die-to-die"].pcb_relevant == "CONDITIONAL"
assert physical["ucie-die-to-die"].logical_protocol_id == "ucie"
for pid in HS_LOGICAL:
ifaces = [p for p in cat.physical_interfaces if p.logical_protocol_id == pid]
assert ifaces, pid
assert all(p.id != pid for p in ifaces)
def test_m10_i2c_can_rgmii_skeletons_named_checks_no_numbers():
cat = load_catalog()
i2c = next(p for p in cat.physical_interfaces if p.id == "i2c-pcb")
can = next(p for p in cat.physical_interfaces if p.id == "can-pcb")
rgmii = next(p for p in cat.physical_interfaces if p.id == "rgmii-pcb")
assert i2c.logical_protocol_id == "i2c"
assert can.logical_protocol_id == "can"
assert rgmii.logical_protocol_id == "rgmii"
assert i2c.pcb_relevant == "YES"
assert can.pcb_relevant == "YES"
assert rgmii.pcb_relevant == "YES"
assert set(i2c.required_checks) == set(I2C_CHECKS)
assert set(can.required_checks) == set(CAN_CHECKS)
assert set(rgmii.required_checks) == set(RGMII_CHECKS)
for iface in (i2c, can, rgmii):
for c in iface.constraints:
assert c.value is None
assert c.value_kind in {
"MISSING_SOURCE", "UNKNOWN", "PHY_DEPENDENT",
"CONTROLLER_DEPENDENT", "VENDOR_DEPENDENT", "NOT_APPLICABLE",
}
assert c.value_kind != "NUMERIC"
def test_m10_no_invented_ohms_and_no_usbif_fill():
loaded = load_catalog()
for iface in loaded.physical_interfaces:
for c in iface.constraints:
if c.value_kind == "NUMERIC":
assert c.source is not None
assert c.source.document
else:
assert c.value is None
assert c.unit is None
blob = json.dumps([
c.model_dump() for p in loaded.physical_interfaces for c in p.constraints
if p.logical_protocol_id in HS_LOGICAL | {"i2c", "can", "rgmii"}
])
for token in NO_INVENTED:
assert token not in blob
usb = next(p for p in loaded.physical_interfaces if p.id == "usb2-hs-dpair")
z = next(c for c in usb.constraints if c.parameter == "differential_impedance")
assert z.value_kind in {"MISSING_SOURCE", "UNKNOWN"}
assert z.value is None
assert z.source is None
def test_m10_builder_matches_committed_catalog():
loaded = load_catalog()
rebuilt = parse_catalog(build_m0_catalog())
assert loaded.model_dump() == rebuilt.model_dump()
+170
View File
@@ -0,0 +1,170 @@
"""M8 DDR instance + L1 grouping: byte lanes, addr/cmd/ctrl/ck, controller-aware, HBM N/A."""
from __future__ import annotations
import json
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Net,
NetType,
PinConnection,
)
from backend.periscopex.protocol_l0 import protocol_exam
from backend.periscopex.protocol_l1 import certify_l1
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 _ddr4_pins() -> dict[str, str]:
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",
"15": "DDR4_CS", "16": "DDR4_CKE",
})
return pins
def _fpga_ddr_graph() -> DesignGraph:
pins = _ddr4_pins()
mem = _ic("U5", pins, mpn="MT41K256M16TW", value="DDR4")
fpga = _ic("U3", dict(pins), mpn="XC7A100T", value="Artix-7 DDR3/DDR4 controller")
nets = {n: _net(n, ("U5", p), ("U3", p)) for p, n in pins.items()}
return DesignGraph(components={"U5": mem, "U3": fpga}, nets=nets)
def _two_ddr_graph() -> DesignGraph:
a = _ddr4_pins()
b = {p: n.replace("DDR4_", "DDR4B_") for p, n in a.items()}
m1 = _ic("U5", a, mpn="MT41K256M16TW", value="DDR4")
m2 = _ic("U6", b, mpn="MT41K256M16TW", value="DDR4")
fpga = _ic(
"U3",
{**{f"a{p}": n for p, n in a.items()}, **{f"b{p}": n for p, n in b.items()}},
mpn="XC7A100T", value="FPGA memory controller",
)
nets = {n: _net(n, ("U5", p), ("U3", f"a{p}")) for p, n in a.items()}
nets.update({n: _net(n, ("U6", p), ("U3", f"b{p}")) for p, n in b.items()})
return DesignGraph(components={"U5": m1, "U6": m2, "U3": fpga}, nets=nets)
def _hbm_graph() -> DesignGraph:
pins = {str(i): f"HBM_DQ{i}" for i in range(8)}
pins["8"] = "HBM_DQS0_P"
return DesignGraph(
components={"U8": _ic("U8", pins, mpn="H26M64202AMR", value="HBM2e stack")},
nets={n: _net(n, ("U8", p)) for p, n in pins.items()},
)
def test_controller_aware_groups_split():
insts = recognize_physical_buses(_fpga_ddr_graph())
ddr = [i for i in insts if i.logical_protocol_id == "ddr4"]
assert len(ddr) == 1
one = ddr[0]
assert one.host_ref == "U3"
assert "U5" in one.peer_refs
kinds = {g.kind for g in one.groups}
assert "BYTE_LANE" in kinds
assert "CLOCK_GROUP" in kinds
assert "ADDRESS_GROUP" in kinds
assert "COMMAND_GROUP" in kinds
assert "CONTROL_GROUP" in kinds
data = {n for g in one.groups if g.kind == "BYTE_LANE" for n in g.nets}
addr = {n for g in one.groups if g.kind == "ADDRESS_GROUP" for n in g.nets}
cmd = {n for g in one.groups if g.kind == "COMMAND_GROUP" for n in g.nets}
ctrl = {n for g in one.groups if g.kind == "CONTROL_GROUP" for n in g.nets}
assert "DDR4_DQ0" in data and "DDR4_DQS0_P" in data
assert "DDR4_A0" in addr
assert "DDR4_RAS" in cmd
assert "DDR4_CS" in ctrl or "DDR4_CKE" in ctrl
assert not (data & addr)
assert not (data & cmd)
assert not (data & ctrl)
blob = json.dumps(one.model_dump())
assert "90" not in blob
assert "ohm" not in blob.lower()
assert "50ps" not in blob.lower()
def test_two_ddr4_are_two_instances():
insts = recognize_physical_buses(_two_ddr_graph())
ddr = [i for i in insts if i.logical_protocol_id == "ddr4"]
assert len(ddr) == 2
refs = {tuple(sorted(i.peer_refs)) for i in ddr}
assert ("U5",) in refs and ("U6",) in refs
def test_l1_grouping_pass_without_layout_skew_stays_dependent():
g = _fpga_ddr_graph()
insts = recognize_physical_buses(g)
by, findings = certify_l1(g, insts, layout=None)
rows = [r for i in insts if i.logical_protocol_id == "ddr4" for r in by[i.instance_id]]
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
blob = json.dumps([r.model_dump() for r in rows])
assert "90" not in blob
assert "universal" in blob.lower() or "controller" in blob.lower()
def test_hbm_not_classic_ddr_dq_rules():
insts = recognize_physical_buses(_hbm_graph())
hbm = [i for i in insts if "hbm" in i.logical_protocol_id]
ddr = [i for i in insts if i.logical_protocol_id.startswith("ddr")]
assert hbm
assert not ddr
one = hbm[0]
assert one.pcb_relevant == "NO"
assert one.nets == []
assert one.groups == []
assert one.physical_interface_id.endswith("-package")
sec, findings = protocol_exam(_hbm_graph())
assert sec.macrophase == "M9"
assert all(f.status != "ERROR" for f in findings)
rows = [
c for r in sec.recognized_instances if "hbm" in str(r.get("logical_protocol_id"))
for c in (r.get("l0_checks") or []) + (r.get("l1_checks") or [])
]
assert rows
assert all(c.get("result") == "NOT_APPLICABLE" for c in rows)
blob = json.dumps(one.model_dump())
assert "BYTE_LANE" not in blob
def test_incomplete_grouping_not_pass():
pins = {str(i): f"MEM_DAT{i}" for i in range(8)}
g = DesignGraph(
components={"U5": _ic("U5", pins, mpn="MT41K256M16TW", value="DDR4")},
nets={n: _net(n, ("U5", p)) for p, n in pins.items()},
)
insts = recognize_physical_buses(g)
ddr = [i for i in insts if "ddr" in i.logical_protocol_id]
assert ddr
assert not any(g.kind == "BYTE_LANE" for g in ddr[0].groups)
by, _ = certify_l1(g, insts, layout=None)
rows = [r for i in ddr for r in by[i.instance_id]]
mapping = [r for r in rows if r.check == "byte_lane_mapping"]
assert mapping
assert mapping[0].result != "PASS"
+155
View File
@@ -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,
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 == "L3"
from backend.periscopex.protocol_report import PACK_MACROPHASE as L8_MACRO
assert sec.macrophase == L8_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")
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)
+264
View File
@@ -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 == "L3"
assert sec.macrophase == "M9"
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 == "L3"
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"])
+261
View File
@@ -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 == "L3"
assert sec.macrophase == "M9"
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 == "L3"
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
+316
View File
@@ -0,0 +1,316 @@
"""M9 L3 CHANNEL certifier: structure + skip; numeric only with existing FACT."""
from __future__ import annotations
import json
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Net,
NetType,
PinConnection,
)
from backend.periscopex.protocol_catalog import (
ConstraintSource,
PhysicalInterface,
ProtocolConstraint,
)
from backend.periscopex.protocol_l0 import protocol_exam
from backend.periscopex.protocol_l3 import (
_pack,
certify_instance_l3,
certify_l3,
remaining_margin_ps,
)
from backend.periscopex.protocol_recognize import (
PhysicalBusInstance,
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 _hdmi_graph() -> DesignGraph:
pins = {"1": "HDMI_TX0_P", "2": "HDMI_TX0_N", "3": "HDMI_CLK_P", "4": "HDMI_CLK_N"}
return DesignGraph(
components={
"U8": _ic("U8", pins, mpn="ADV7511", value="HDMI transmitter"),
"J8": Component(
reference="J8", value="HDMI-A",
footprint="HDMI",
component_type=ComponentType.CONNECTOR,
pins=pins,
),
},
nets={n: _net(n, ("U8", p), ("J8", p)) for p, n in pins.items()},
schematic_fields={"J8": {"protocol": "hdmi-1.4-tmds-type-a"}},
)
def _pcie_graph() -> DesignGraph:
pins = {"1": "PCIE_TX_P", "2": "PCIE_TX_N", "3": "PCIE_RX_P", "4": "PCIE_RX_N"}
return DesignGraph(
components={
"U7": _ic("U7", pins, mpn="PI7C9X2G304GP", value="PCIe switch"),
"J7": Component(
reference="J7", value="PCIe x1",
footprint="PCIE",
component_type=ComponentType.CONNECTOR,
pins=pins,
),
},
nets={n: _net(n, ("U7", p), ("J7", p)) for p, n in pins.items()},
schematic_fields={"U7": {"protocol": "pcie-phy-pcb"}},
)
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 _cite() -> ConstraintSource:
return ConstraintSource(
document="Fixture channel pack", organization="TESTLAB", section="4.1",
)
def _iface_with_numeric(*, il_db: float = -8.0, rl_db: float = -10.0,
margin_ps: float = 0.0) -> PhysicalInterface:
return PhysicalInterface(
id="fixture-hs-dpair",
logical_protocol_id="fixture-hs",
bus_type="SERIAL",
physical_layer="SERIAL_DIFFERENTIAL",
pcb_relevant="YES",
required_checks=[
"insertion_loss", "return_loss", "crosstalk",
"timing_budget", "channel",
],
constraints=[
ProtocolConstraint(
id="fixture-il", parameter="insertion_loss",
value_kind="NUMERIC", mandatory="MANDATORY",
source_type="STANDARD", source_class="NORMATIVE",
value=il_db, unit="dB", source=_cite(),
),
ProtocolConstraint(
id="fixture-rl", parameter="return_loss",
value_kind="NUMERIC", mandatory="MANDATORY",
source_type="STANDARD", source_class="NORMATIVE",
value=rl_db, unit="dB", source=_cite(),
),
ProtocolConstraint(
id="fixture-xt", parameter="crosstalk",
value_kind="NUMERIC", mandatory="RECOMMENDED",
source_type="STANDARD", source_class="NORMATIVE",
value=-20.0, unit="dB", source=_cite(),
),
ProtocolConstraint(
id="fixture-tb", parameter="timing_budget",
value_kind="NUMERIC", mandatory="MANDATORY",
source_type="STANDARD", source_class="NORMATIVE",
value=margin_ps, unit="ps", source=_cite(),
),
],
)
def _inst() -> PhysicalBusInstance:
return PhysicalBusInstance(
instance_id="fixture-1",
logical_protocol_id="fixture-hs",
physical_interface_id="fixture-hs-dpair",
pcb_relevant="YES",
confidence=1.0,
evidence_kind="net_name",
recognition_status="RECOGNIZED",
nets=["CH_P", "CH_N"],
host_ref="U1",
)
def test_remaining_margin_is_total_minus_used():
assert remaining_margin_ps(200.0, 120.0) == 80.0
assert remaining_margin_ps(200.0, 120.0, 77.0) == 77.0
assert remaining_margin_ps(None, 10.0) is None
def test_usb_hdmi_pcie_skip_without_channel_data():
for graph, token in ((_usb_connected(), "usb"), (_hdmi_graph(), "hdmi"), (_pcie_graph(), "pcie")):
sec, findings = protocol_exam(graph)
assert sec.max_level_reached == "L3"
assert sec.macrophase == "M9"
row = next(
r for r in sec.recognized_instances
if token in str(r.get("logical_protocol_id")).lower()
or token in str(r.get("physical_interface_id")).lower()
)
l3 = row["l3_checks"]
assert l3
names = {c["check"] for c in l3}
assert {"timing_budget", "insertion_loss", "return_loss", "crosstalk", "channel"} <= names
assert all(c["result"] != "PASS" for c in l3)
notes = " ".join(c.get("notes") or "" for c in l3)
assert "non certificata a L3 per mancanza" in notes
assert "OpenEMS" in notes
blob = json.dumps(l3)
assert "90" not in blob
assert any(f.rule_id == "PE-PRT-L3-001" for f in findings)
assert "s21" not in blob.lower() or "invent" in notes.lower()
def test_axi_l3_not_applicable_not_fail():
sec, findings = protocol_exam(_axi_graph())
row = next(
r for r in sec.recognized_instances
if "axi" in str(r.get("logical_protocol_id"))
)
l3 = row["l3_checks"]
assert l3
assert all(c["result"] == "NOT_APPLICABLE" for c in l3)
assert all(c["result"] != "FAIL" for c in l3)
assert all(f.rule_id != "PE-PRT-L3-002" for f in findings)
assert all(f.status != "ERROR" or f.source != "protocol_l3" for f in findings)
def test_fixture_channel_pass_with_existing_fact():
iface = _iface_with_numeric()
inst = _inst()
data = {
"instances": {
inst.instance_id: {
"insertion_loss_db": -3.0,
"return_loss_db": -15.0,
"crosstalk_db": -28.0,
"total_budget_ps": 200.0,
"used_budget_ps": 120.0,
"channel_complete": True,
}
}
}
rows = certify_instance_l3(inst, iface, channel_data=data)
by_check = {r.check: r for r in rows}
assert by_check["insertion_loss"].result == "PASS"
assert by_check["return_loss"].result == "PASS"
assert by_check["timing_budget"].result == "PASS"
assert by_check["timing_budget"].remaining_margin_ps == 80.0
assert by_check["channel"].result == "PASS"
assert by_check["insertion_loss"].measured_db == -3.0
notes = " ".join(r.notes.lower() for r in rows)
assert "not invented" in notes
assert "not openems" in notes
def test_fixture_channel_fail_mandatory_il():
iface = _iface_with_numeric()
inst = _inst()
data = {
inst.instance_id: {
"s21_db": -12.0,
"s11_db": -15.0,
"total_budget_ps": 200.0,
"used_budget_ps": 50.0,
}
}
rows = certify_instance_l3(inst, iface, channel_data=data)
il = next(r for r in rows if r.check == "insertion_loss")
assert il.result == "FAIL"
findings = []
from backend.periscopex.protocol_l3 import l3_findings
findings = l3_findings(inst, rows)
assert any(f.rule_id == "PE-PRT-L3-002" and f.status == "ERROR" for f in findings)
def test_recommended_never_fail_l3():
rec = _pack("crosstalk", "FAIL", "RECOMMENDED", notes="xt")
assert rec.result != "FAIL"
assert rec.status != "ERROR"
def test_raw_touchstone_without_fact_is_skip_not_invented():
iface = _iface_with_numeric()
inst = _inst()
data = {inst.instance_id: {"sparam_file": "lane.s4p", "touchstone": "lane.s4p"}}
rows = certify_instance_l3(inst, iface, channel_data=data)
assert all(r.result != "PASS" for r in rows)
notes = " ".join(r.notes for r in rows)
assert "non certificata a L3" in notes
assert "invent" in notes.lower() or "non inventati" in notes.lower() or "not invented" in notes.lower()
blob = json.dumps([r.model_dump() for r in rows])
# no synthesized S21 numbers
assert '"measured_db": null' in blob or all(r.measured_db is None for r in rows)
def test_openems_method_is_explicit_skip():
iface = _iface_with_numeric()
inst = _inst()
data = {
inst.instance_id: {
"insertion_loss_db": -2.0,
"method": "OpenEMS",
"solver": "FEM",
}
}
rows = certify_instance_l3(inst, iface, channel_data=data)
notes = " ".join(r.notes for r in rows)
assert "non certificata a L3" in notes
assert all(r.result != "PASS" for r in rows)
def test_no_sparam_invention_on_empty_channel_data():
insts = recognize_physical_buses(_usb_connected())
by, findings = certify_l3(insts, channel_data=None)
rows = [r for lst in by.values() for r in lst]
blob = json.dumps([r.model_dump() for r in rows])
assert "s21" not in blob.lower()
assert all(r.measured_db is None for r in rows)
assert any("mancanza" in r.notes for r in rows)
assert any(f.rule_id == "PE-PRT-L3-001" for f in findings)
def test_protocol_exam_attaches_l3_and_does_not_call_l0_electrical():
sec, _ = protocol_exam(_usb_connected())
row = sec.recognized_instances[0]
assert row.get("l3_checks")
assert all(c.get("level") == "L3" for c in row["l3_checks"])
notes = " ".join(c.get("notes") or "" for c in row["l3_checks"])
assert "L0/L1 are not electrical" in notes
l0notes = json.dumps(row.get("l0_checks") or [])
assert "ohm" not in l0notes.lower() or "not" in l0notes.lower()
+235
View File
@@ -0,0 +1,235 @@
"""M6/M7 USB-C/Ethernet/HDMI packs: UNKNOWN + needed_document, no invented ohms."""
from __future__ import annotations
import copy
import json
import pytest
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Net,
NetType,
PinConnection,
)
from backend.periscopex.protocol_catalog import (
ProtocolCatalogError,
build_m0_catalog,
load_catalog,
parse_catalog,
)
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 test_unknown_requires_needed_document():
raw = build_m0_catalog()
iface = copy.deepcopy(raw["physical_interfaces"][0])
iface["id"] = "usb2-hs-no-doc"
bad = copy.deepcopy(iface["constraints"][0])
bad["id"] = "no-doc"
bad["value_kind"] = "UNKNOWN"
bad["value"] = None
bad["needed_document"] = None
iface["constraints"] = [bad]
raw["physical_interfaces"].append(iface)
with pytest.raises(ProtocolCatalogError, match="needed_document"):
parse_catalog(raw)
def test_reject_typical_as_standard_still_holds():
raw = build_m0_catalog()
iface = copy.deepcopy(raw["physical_interfaces"][0])
typical = copy.deepcopy(iface["constraints"][0])
typical["id"] = "bad-typical-m67"
typical["typical"] = True
typical["value_origin"] = "TYPICAL"
typical["source_type"] = "STANDARD"
typical["source_class"] = "NORMATIVE"
typical["mandatory"] = "MANDATORY"
typical["value_kind"] = "UNKNOWN"
typical["value"] = None
typical["needed_document"] = "USB-IF Base Specification"
iface["id"] = "usb2-hs-typical-m67"
iface["constraints"] = [typical]
raw["physical_interfaces"].append(iface)
with pytest.raises(ProtocolCatalogError, match="typical"):
parse_catalog(raw)
def test_usb2_and_usb_c_unknown_with_usbif_needed_document_no_90ohm():
cat = load_catalog()
logical = {p.id for p in cat.logical_protocols}
assert {"usb2-ls", "usb2-fs", "usb2-hs", "usb-c", "usb3-x", "usb4"} <= logical
assert "mini-hdmi" not in logical
usb_ifaces = [
p for p in cat.physical_interfaces
if p.logical_protocol_id in {"usb2-ls", "usb2-fs", "usb2-hs", "usb2-ls-fs", "usb-c", "usb3-x", "usb4"}
or p.id.startswith("usb-")
]
assert usb_ifaces
z_blob = json.dumps([
c.model_dump() for p in usb_ifaces for c in p.constraints
if c.parameter == "differential_impedance"
])
assert "90" not in z_blob
for p in usb_ifaces:
for c in p.constraints:
if c.parameter == "differential_impedance":
assert c.value is None
assert c.value_kind != "NUMERIC"
if c.value_kind in {"UNKNOWN", "MISSING_SOURCE"}:
assert c.needed_document
assert "USB-IF" in c.needed_document or "specification" in c.needed_document.lower() or "Specification" in c.needed_document
if p.logical_protocol_id == "usb-c" and c.parameter in {"rp", "rd"}:
assert c.value_kind == "NUMERIC"
assert c.source and "Type-C" in (c.source.document or "")
over = next(p for p in cat.physical_interfaces if p.id == "usb-c-usb2-receptacle")
assert over.logical_protocol_id == "usb2-hs"
assert over.connector == "Type-C"
sys = next(p for p in cat.physical_interfaces if p.id == "usb-c-receptacle")
assert sys.logical_protocol_id == "usb-c"
assert sys.connector == "Type-C"
def test_usb_c_connector_alone_is_not_usb3_or_usb2_protocol():
g = DesignGraph(
components={
"J1": Component(
reference="J1", value="USB Type-C receptacle",
footprint="USB_C_Receptacle",
component_type=ComponentType.CONNECTOR,
pins={"A5": "CC1"},
),
},
nets={"CC1": _net("CC1", ("J1", "A5"))},
)
insts = recognize_physical_buses(g)
logs = {i.logical_protocol_id for i in insts}
assert "usb3-x" not in logs
assert "usb4" not in logs
assert "usb2-hs" not in logs
assert any(i.logical_protocol_id == "usb-c" for i in insts)
blob = json.dumps([i.model_dump() for i in insts])
assert "90" not in blob
def test_usb2_over_type_c_from_connector_nets_silicon_not_usb3():
g = 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")),
},
)
insts = recognize_physical_buses(g)
usb = [i for i in insts if i.logical_protocol_id.startswith("usb2")]
assert usb
one = usb[0]
assert one.connector == "Type-C"
assert "USB 2.0 over USB Type-C" in one.physical_implementation
assert one.logical_protocol_id != "usb3-x"
assert all(i.logical_protocol_id != "usb3-x" for i in insts)
assert "mux not assumed" in one.notes.lower() or "mux/switch not assumed" in one.notes.lower()
blob = json.dumps(one.model_dump())
assert "90" not in blob
def test_rj45_alone_is_not_1000base_t():
g = DesignGraph(
components={
"J9": Component(
reference="J9", value="RJ45",
footprint="RJ45",
component_type=ComponentType.CONNECTOR,
pins={"1": "TD+"},
),
},
nets={"TD+": _net("TD+", ("J9", "1"))},
)
insts = recognize_physical_buses(g)
logs = {i.logical_protocol_id for i in insts}
assert "1000base-t" not in logs
assert "100base-tx" not in logs
assert any(i.logical_protocol_id == "ethernet-unspecified" for i in insts)
def test_mini_hdmi_is_connector_not_protocol_id():
cat = load_catalog()
logical = {p.id for p in cat.logical_protocols}
assert "mini-hdmi" not in logical
assert "hdmi-mini" not in logical
mini = next(p for p in cat.physical_interfaces if p.id == "hdmi-1.4-tmds-type-c-mini")
assert mini.logical_protocol_id == "hdmi-1.4"
assert mini.connector == "HDMI-C-mini"
assert "CONNECTOR" in mini.notes or "connector" in mini.notes.lower()
g = DesignGraph(
components={
"J8": Component(
reference="J8", value="Mini HDMI",
footprint="HDMI_C_Mini",
component_type=ComponentType.CONNECTOR,
pins={"1": "HPD"},
),
},
nets={"HPD": _net("HPD", ("J8", "1"))},
)
insts = recognize_physical_buses(g)
assert all(i.logical_protocol_id != "mini-hdmi" for i in insts)
hdmi = [i for i in insts if "hdmi" in i.logical_protocol_id]
assert hdmi
assert hdmi[0].connector == "HDMI-C-mini"
assert hdmi[0].logical_protocol_id.startswith("hdmi-")
def test_ethernet_hdmi_skeletons_unknown_needed_document():
cat = load_catalog()
for lid in (
"2.5gbase-t", "5gbase-t",
"10gbase-t", "sgmii", "10gbase-r",
"hdmi-1.4",
):
ifaces = [p for p in cat.physical_interfaces if p.logical_protocol_id == lid]
assert ifaces, lid
assert all(p.incomplete for p in ifaces)
for p in ifaces:
for c in p.constraints:
if lid.startswith("hdmi") and (
c.parameter == "cec" or c.parameter.startswith("cec_")
):
continue
assert c.value is None
assert c.value_kind != "NUMERIC"
if c.value_kind in {"UNKNOWN", "MISSING_SOURCE"}:
assert c.needed_document
if lid.startswith("hdmi"):
assert "HDMI" in c.needed_document
else:
assert "IEEE" in c.needed_document or "PHY" in (c.needed_document or "") or "SGMII" in (c.needed_document or "")
tmds = next(p for p in cat.physical_interfaces if p.id == "hdmi-1.4-tmds-type-a")
frl = next(p for p in cat.physical_interfaces if p.id == "hdmi-frl-pcb")
assert tmds.logical_protocol_id != frl.logical_protocol_id
+309
View File
@@ -0,0 +1,309 @@
"""Recovered-PDF pack: USB Electrical CTS, Type-C FTS, DVI 1.0, IEEE 802.3-2012 §2."""
from __future__ import annotations
import json
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Net,
NetType,
PinConnection,
)
from backend.periscopex.protocol_catalog import load_catalog
from backend.periscopex.protocol_recognize import recognize_physical_buses
def _cite_ok(c) -> None:
assert c.value_kind == "NUMERIC"
assert c.value is not None
src = c.source
assert src is not None
assert src.document
assert src.organization
assert src.revision
assert src.page
assert src.section or src.table
def test_usb_cts_hs_numeric_cited_no_zdiff_90():
cat = load_catalog()
hs = next(p for p in cat.physical_interfaces if p.id == "usb2-hs-dpair")
by = {c.parameter: c for c in hs.constraints}
_cite_ok(by["data_rate"])
assert by["data_rate"].value == 480.00
assert by["data_rate"].unit == "Mb/s"
assert by["data_rate"].source.document == "Universal Serial Bus Specification"
assert "2.0" in by["data_rate"].source.revision
assert by["data_rate"].source.section == "7.1.11"
assert by["rise_time"].value == 300.0
assert by["fall_time"].value == 300.0
assert by["pull_up"].value == 1500.0
assert by["dc_resistance"].value == 17.0
assert by["dc_resistance_captive_cable_device"].value == 23.0
assert by["dc_resistance_host_hub_downstream"].value == 13.0
for key in (
"data_rate", "rise_time", "fall_time", "pull_up", "dc_resistance",
"dc_resistance_captive_cable_device", "dc_resistance_host_hub_downstream",
"cable_differential_impedance", "pcb_trace_nominal_zdiff",
):
_cite_ok(by[key])
assert by["cable_differential_impedance"].value == 90.0
assert by["cable_differential_impedance"].source_type == "CABLE"
assert by["pcb_trace_nominal_zdiff"].value == 90.0
assert by["pcb_trace_nominal_zdiff"].mandatory == "RECOMMENDED"
assert by["pcb_trace_nominal_zdiff"].source_type == "PCB"
z = by["differential_impedance"]
assert z.value_kind == "UNKNOWN"
assert z.value is None
ls = next(p for p in cat.physical_interfaces if p.id == "usb2-ls-dpair")
ls_rate = next(c for c in ls.constraints if c.parameter == "data_rate")
_cite_ok(ls_rate)
assert ls_rate.value == 1.50
fs = next(p for p in cat.physical_interfaces if p.id == "usb2-fs-dpair")
fs_rate = next(c for c in fs.constraints if c.parameter == "data_rate")
_cite_ok(fs_rate)
assert fs_rate.value == 12.000
blob = json.dumps(z.model_dump())
assert "90" not in blob
def test_type_c_rp_rd_from_cabcon_r25_vbus_volts_unknown():
cat = load_catalog()
rec = next(p for p in cat.physical_interfaces if p.id == "usb-c-receptacle")
by = {c.parameter: c for c in rec.constraints}
_cite_ok(by["rp"])
assert by["rp"].value == 56000.0
assert by["rp"].source.table == "Table 4-27"
assert by["rp"].source.revision == "Release 2.5, March 2026"
assert by["rp"].source_type == "CONNECTOR"
_cite_ok(by["rd"])
assert by["rd"].value == 5100.0
assert by["rd"].source.table == "Table 4-28"
_cite_ok(by["ra"])
assert by["ra"].value == 800.0
assert by["ra"].source_type == "CABLE"
_cite_ok(by["voltage"])
assert by["voltage"].value == 4.75
assert by["voltage"].unit == "V"
assert by["voltage"].source.table == "Figure 4-39"
assert by["voltage"].source.page == "233"
assert "2.5" in by["voltage"].source.revision
assert by["voltage"].source_type == "CABLE"
assert any("not USB PD vSafe5V" in x for x in by["voltage"].conditions)
_cite_ok(by["vbus_requirements"])
assert by["vbus_requirements"].value == 4.0
assert by["vbus_requirements"].source.table == "Figure 4-39"
_cite_ok(by["electrical_thresholds"])
assert by["electrical_thresholds"].value == 1.50
assert by["electrical_thresholds"].source.table == "Table 4-32"
assert by["electrical_thresholds"].source.page == "240"
assert "August 2019" in by["electrical_thresholds"].source.revision
assert by["electrical_thresholds"].source_type == "PCB"
_cite_ok(by["vopen_source_default"])
assert by["vopen_source_default"].value == 1.65
_cite_ok(by["vra_source_default_max"])
assert by["vra_source_default_max"].value == 0.15
_cite_ok(by["vrd_detect_threshold_default"])
assert by["vrd_detect_threshold_default"].value == 1.60
_cite_ok(by["termination"])
assert by["termination"].value == 126000.0
assert by["termination"].source.table == "Table 4-30"
assert "2.5" in by["termination"].source.revision
assert by["termination"].source_type == "CONNECTOR"
_cite_ok(by["timing"])
assert by["timing"].value == 100.0
assert by["timing"].source.table == "Table 4-34"
assert "2.5" in by["timing"].source.revision
_cite_ok(by["t_vbuson_max"])
assert by["t_vbuson_max"].value == 275.0
assert by["t_vbuson_max"].source.table == "Table 4-32"
assert by["t_vbuson_max"].source.page == "247"
assert "2.5" in by["t_vbuson_max"].source.revision
assert by["vsafe5v"].value_kind == "UNKNOWN"
assert by["vsafe5v"].value is None
assert "USB Power Delivery" in (by["vsafe5v"].needed_document or "")
assert "snla027b" in (by["vsafe5v"].needed_document or "")
assert "Figure 4-39" in (by["vsafe5v"].needed_document or "")
assert by["vsafe0v"].value_kind == "UNKNOWN"
assert by["vsafe0v"].value is None
assert "vSafe0V" in (by["vsafe0v"].needed_document or "")
hdmi14 = next(p for p in cat.physical_interfaces if p.id == "hdmi-1.4-tmds-type-a")
hz14 = next(c for c in hdmi14.constraints if c.parameter == "differential_impedance")
assert hz14.value_kind == "UNKNOWN"
assert hz14.value is None
_cite_ok(by["gnd_ir_drop_cable"])
assert by["gnd_ir_drop_cable"].value == 250.0
assert by["gnd_ir_drop_cable"].source_type == "CABLE"
assert by["gnd_ir_drop_cable"].mandatory == "OPTIONAL"
_cite_ok(by["vbus_ir_drop_cable"])
assert by["vbus_ir_drop_cable"].value == 500.0
assert "Functional Test" in by["vbus_ir_drop_cable"].source.document
def test_dvi_not_hdmi_numeric_from_dvi_10():
cat = load_catalog()
logical = {p.id for p in cat.logical_protocols}
assert "dvi" in logical
assert "mini-hdmi" not in logical
dvi = next(p for p in cat.physical_interfaces if p.id == "dvi-1.0-tmds")
assert dvi.logical_protocol_id == "dvi"
assert dvi.connector == "DVI"
by = {c.parameter: c for c in dvi.constraints}
_cite_ok(by["termination"])
assert by["termination"].value == 50.0
assert by["termination"].source.table == "Table 4-2"
_cite_ok(by["differential_impedance"])
assert by["differential_impedance"].value == 100.0
assert by["differential_impedance"].source.table == "Table 4-7"
assert by["differential_impedance"].source.organization == "DDWG"
hdmi = next(p for p in cat.physical_interfaces if p.id == "hdmi-1.4-tmds-type-a")
hz = next(c for c in hdmi.constraints if c.parameter == "differential_impedance")
assert hz.value_kind == "UNKNOWN"
assert hz.value is None
h20 = next(p for p in cat.physical_interfaces if p.id == "hdmi-2.0-tmds-type-a")
z20 = next(c for c in h20.constraints if c.parameter == "differential_impedance")
_cite_ok(z20)
assert z20.value == 90.0
assert z20.source_class == "VENDOR"
assert z20.source_type == "VENDOR"
assert z20.mandatory == "RECOMMENDED"
assert "TMDS1204" in z20.source.document
assert z20.source_class != "NORMATIVE"
assert z20.source_type != "STANDARD"
skew20 = next(c for c in h20.constraints if c.parameter == "intra_pair_skew")
assert skew20.value_kind == "PHY_DEPENDENT"
assert skew20.value is None
assert skew20.source_type == "VENDOR"
assert skew20.source and "SLLSF57A" in (skew20.source.revision or "")
sig20 = next(c for c in h20.constraints if c.parameter == "sigdet_wakeup")
assert sig20.value_kind == "PHY_DEPENDENT"
assert sig20.source_type == "VENDOR"
assert "SLLA633" in (sig20.source.revision or "")
h14_skew = next(c for c in hdmi.constraints if c.parameter == "intra_pair_skew")
assert h14_skew.value_kind == "UNKNOWN"
hby = {c.parameter: c for c in hdmi.constraints}
_cite_ok(hby["cec"])
assert hby["cec"].value == 27000.0
assert hby["cec"].source.table == "CEC Table 2"
assert "1.3" in hby["cec"].source.revision
assert hby["cec"].source_type == "PCB"
_cite_ok(hby["cec_device_capacitance"])
assert hby["cec_device_capacitance"].value == 200.0
assert hby["cec_device_capacitance"].source.table == "Table 4-27"
_cite_ok(hby["cec_cable_capacitance"])
assert hby["cec_cable_capacitance"].value == 700.0
assert hby["cec_cable_capacitance"].source_type == "CABLE"
_cite_ok(hby["cec_vol_max"])
assert hby["cec_vol_max"].value == 0.6
_cite_ok(hby["cec_voh_min"])
assert hby["cec_voh_min"].value == 2.5
_cite_ok(hby["cec_interconnect_resistance"])
assert hby["cec_interconnect_resistance"].value == 5.0
frl = next(p for p in cat.physical_interfaces if p.id == "hdmi-frl-pcb")
fz = next(c for c in frl.constraints if c.parameter == "differential_impedance")
assert fz.value == 90.0
assert fz.source_class == "VENDOR"
assert fz.source_type == "VENDOR"
fdr = next(c for c in frl.constraints if c.parameter == "data_rate")
assert fdr.value_kind == "PHY_DEPENDENT"
assert fdr.value is None
assert fdr.source_class == "VENDOR"
assert fdr.source_type == "VENDOR"
sink = next(p for p in cat.physical_interfaces if p.id == "tmds1204-hdmi-sink")
assert sink.logical_protocol_id == "tmds1204"
sby = {c.parameter: c for c in sink.constraints}
_cite_ok(sby["differential_impedance"])
assert sby["differential_impedance"].value == 90.0
assert sby["differential_impedance"].source_type == "VENDOR"
assert sby["differential_impedance"].source_class == "VENDOR"
assert sby["differential_impedance"].mandatory == "RECOMMENDED"
assert sby["differential_impedance"].source.table == "Table 8-6"
assert sby["differential_impedance"].source.organization == "Texas Instruments"
assert "SLLSF57A" in sby["differential_impedance"].source.revision
assert sby["differential_impedance"].source_class != "NORMATIVE"
src = next(p for p in cat.physical_interfaces if p.id == "tmds1204-hdmi-source")
rby = {c.parameter: c for c in src.constraints}
assert rby["differential_impedance"].value == 75.0
assert rby["differential_impedance"].source.table == "Table 8-2"
assert rby["differential_impedance"].source_class == "VENDOR"
assert rby["differential_impedance"].source_type == "VENDOR"
assert sby["sigdet_wakeup"].value_kind == "PHY_DEPENDENT"
assert sby["sigdet_wakeup"].source_type == "VENDOR"
g = DesignGraph(
components={
"J3": Component(
reference="J3", value="DVI-D receptacle",
footprint="DVI_D",
component_type=ComponentType.CONNECTOR,
pins={"1": "TX0+"},
),
},
nets={"TX0+": Net(
name="TX0+", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="J3", pin_number="1")],
)},
)
insts = recognize_physical_buses(g)
logs = {i.logical_protocol_id for i in insts}
assert "dvi" in logs
assert "hdmi-1.4" not in logs
assert "hdmi-2.0" not in logs
def test_ieee8023_section2_100base_tx_not_10base_or_1000():
cat = load_catalog()
tx = next(p for p in cat.physical_interfaces if p.id == "100base-tx-mdi")
by = {c.parameter: c for c in tx.constraints}
_cite_ok(by["data_rate"])
assert by["data_rate"].value == 100.0
assert by["data_rate"].source.document.startswith("IEEE Std 802.3-2012")
assert by["data_rate"].source.revision == "2012"
assert by["data_rate"].source.section == "21.1"
_cite_ok(by["cable_channel"])
assert by["cable_channel"].value == 100.0
assert by["cable_channel"].source_type == "CABLE"
assert by["cable_channel"].source.section == "25.4.9.2.2"
z = by["differential_impedance"]
assert z.value_kind == "UNKNOWN"
assert "TP-PMD" in (z.needed_document or "")
ten = next(p for p in cat.physical_interfaces if p.id == "10base-t-mdi")
tby = {c.parameter: c for c in ten.constraints}
_cite_ok(tby["data_rate"])
assert tby["data_rate"].value == 10.0
assert tby["data_rate"].source.revision == "2015"
assert tby["data_rate"].source.section == "14.1.1"
assert tby["data_rate"].source.page == "374"
_cite_ok(tby["cable_channel"])
assert tby["cable_channel"].value == 100.0
assert tby["cable_channel"].source_type == "CABLE"
assert tby["cable_channel"].source.section == "14.4.2.2"
assert tby["differential_impedance"].value_kind == "UNKNOWN"
assert "PCB" in (tby["differential_impedance"].needed_document or "")
gbe = next(p for p in cat.physical_interfaces if p.id == "1000base-t-mdi")
gby = {c.parameter: c for c in gbe.constraints}
_cite_ok(gby["data_rate"])
assert gby["data_rate"].value == 1000.0
assert gby["data_rate"].source.revision == "2012"
assert gby["data_rate"].source.section == "40.1"
assert gby["data_rate"].source.page == "179"
_cite_ok(gby["cable_channel"])
assert gby["cable_channel"].value == 100.0
assert gby["cable_channel"].source_type == "CABLE"
assert gby["cable_channel"].source.section == "40.7.2.2"
assert gby["differential_impedance"].value_kind == "UNKNOWN"
_cite_ok(gby["peak_differential_output"])
assert gby["peak_differential_output"].value == 0.67
assert gby["peak_differential_output"].source_type == "PHY"
x = next(p for p in cat.physical_interfaces if p.id == "1000base-x-pcb")
xr = next(c for c in x.constraints if c.parameter == "data_rate")
_cite_ok(xr)
assert xr.value == 1000.0
assert xr.source.section == "36.1.1"
fx = next(p for p in cat.physical_interfaces if p.id == "100base-fx-pcb")
fxr = next(c for c in fx.constraints if c.parameter == "data_rate")
_cite_ok(fxr)
assert fxr.value == 100.0
assert fxr.source.section == "26.1"
+155
View File
@@ -0,0 +1,155 @@
"""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 (
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 == []
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
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)
+222
View File
@@ -0,0 +1,222 @@
"""M5 protocol report: measured/limit/margin/source/method, chain, FAIL first, skips visible."""
from __future__ import annotations
import json
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Net,
NetType,
PinConnection,
)
from backend.periscopex.protocol_catalog import ProtocolCatalogError, parse_catalog
from backend.periscopex.protocol_l0 import protocol_exam
from backend.periscopex.protocol_report import (
instance_worst_result,
result_rank,
)
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_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 test_result_rank_fail_before_warning():
assert result_rank("FAIL") < result_rank("WARNING")
assert result_rank("FAIL") < result_rank("UNKNOWN")
assert instance_worst_result(["UNKNOWN", "FAIL", "PASS"]) == "FAIL"
def test_usb2_pair_l0_pass_l2_z_unknown_no_invented_ohm():
sec, _ = protocol_exam(_usb_connected())
assert sec.max_level_reached == "L3"
assert sec.macrophase == "M9"
usb = next(
r for r in sec.recognized_instances
if "usb" in str(r.get("logical_protocol_id"))
)
checks = usb["report_checks"]
l0_topo = [c for c in checks if c["level"] == "L0" and c["check"] == "topology"]
assert l0_topo and l0_topo[0]["result"] == "PASS"
z = [c for c in checks if c["check"] == "differential_impedance"]
assert z
assert z[0]["result"] in {"MISSING_SOURCE", "UNKNOWN"}
assert z[0]["skip_visible"] is True
assert z[0]["measured"] is None
assert z[0]["limit"] is None
blob = json.dumps(z[0])
assert "90" not in blob
chain = z[0]["chain"]
assert chain["physical_interface_id"]
assert chain["logical_protocol_id"]
assert chain["physical_interface_id"] != chain["logical_protocol_id"]
l3 = [c for c in checks if c["level"] == "L3"]
assert l3 and l3[0]["skip_visible"]
assert l3[0]["result"] != "PASS"
assert "OpenEMS" in l3[0]["notes"]
def test_fail_listed_before_warning_in_report_checks():
sec, findings = protocol_exam(_usb_dangling_minus())
usb = next(
r for r in sec.recognized_instances
if "usb" in str(r.get("logical_protocol_id"))
)
assert usb["worst_result"] == "FAIL"
assert usb["fail_count"] >= 1
results = [c["result"] for c in usb["report_checks"]]
assert "FAIL" in results
assert results.index("FAIL") < next(
i for i, r in enumerate(results) if r in {"UNKNOWN", "MISSING_SOURCE", "WARNING"}
)
assert any(f.status == "ERROR" and f.rule_id == "PE-PRT-L0-001" for f in findings)
def test_axi_na_visible_not_fail():
sec, _ = protocol_exam(_axi_graph())
row = next(
r for r in sec.recognized_instances
if "axi" in str(r.get("logical_protocol_id"))
)
checks = row["report_checks"]
assert any(c["result"] == "NOT_APPLICABLE" for c in checks)
assert row["worst_result"] != "FAIL"
assert all(c["result"] != "FAIL" for c in checks if c["level"] != "L3")
def test_ddr_grouping_skip_visible_not_pass():
sec, findings = protocol_exam(_ddr_no_grouping())
ddr = next(
r for r in sec.recognized_instances
if str(r.get("logical_protocol_id")).startswith("ddr")
)
grouping = [
c for c in ddr["report_checks"]
if c["check"] in {"byte_lane_mapping", "dq_to_dqs_skew", "byte_lane_skew"}
]
assert grouping
assert all(c["result"] != "PASS" for c in grouping)
assert ddr["worst_result"] == "FAIL" or any(c["skip_visible"] for c in grouping)
notes = " ".join((c.get("notes") or "") for c in grouping).lower()
assert "byte-lane" in notes or "grouping" in notes or "geometria" in notes or "dq" in notes
assert any(
f.rule_id in {"PE-PRT-L0-001", "PE-PRT-L1-002", "PE-PRT-L1-001"}
for f in findings
)
def test_report_check_has_measured_limit_margin_source_method_keys():
sec, _ = protocol_exam(_usb_connected())
usb = sec.recognized_instances[0]
row = usb["report_checks"][0]
for key in (
"measured", "limit", "margin", "source", "method",
"chain", "skip_visible", "result", "level",
):
assert key in row
chain = row["chain"]
for key in (
"net", "group", "physical_interface_id", "logical_protocol_id",
"constraint_id", "document", "section",
):
assert key in chain
def test_typical_still_rejected_as_standard():
raw = {
"schema_version": "1.0.0",
"logical_protocols": [{
"id": "usb2-hs", "name": "USB2 HS", "bus_type": "SERIAL",
}],
"physical_interfaces": [{
"id": "usb2-hs-dpair",
"logical_protocol_id": "usb2-hs",
"bus_type": "SERIAL",
"physical_layer": "SERIAL_DIFFERENTIAL",
"pcb_relevant": "YES",
"required_checks": ["differential_impedance"],
"constraints": [{
"id": "bad",
"parameter": "differential_impedance",
"value_kind": "NUMERIC",
"mandatory": "MANDATORY",
"source_type": "STANDARD",
"source_class": "NORMATIVE",
"value": 90,
"unit": "ohm",
"typical": True,
"source": {"document": "blog", "organization": "web"},
}],
}],
}
try:
parse_catalog(raw)
raise AssertionError("typical-as-standard must be rejected")
except ProtocolCatalogError:
pass