Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b32d581d27 | ||
|
|
d5fff7c6dd | ||
|
|
29e1a3033d | ||
|
|
bb83f63114 | ||
|
|
3c0b3e14dc | ||
|
|
1455e974ac | ||
|
|
25aa608052 | ||
|
|
d32af5627a | ||
|
|
8a93ffc1c5 | ||
|
|
421f659054 | ||
|
|
c4cd046ea9 | ||
|
|
f3be485b75 | ||
|
|
949f5f1541 | ||
|
|
7baffadd6b | ||
|
|
866c2e9242 | ||
|
|
14272d7446 | ||
|
|
24c4beecc9 | ||
|
|
0848eb8416 | ||
|
|
10d21cdc80 | ||
|
|
3cc2e5469b | ||
|
|
fc6524a2a9 | ||
|
|
36ad979612 |
@@ -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
@@ -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__)))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 L1–L3. No invented ohms/mm/ps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.periscopex.finding_engine import complete_finding
|
||||
from backend.periscopex.models import DesignGraph, Finding, 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 TOTAL−USED=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 = total−used=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 L0–L3 certifier. No invented Z.
|
||||
|
||||
Priority: declared > silicon/BOM > pinout > net/pin name > pair geometry.
|
||||
Ambiguity is REVIEW, never FAIL. AXI4 internal does not attach PCB nets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.periscopex.models import Component, 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:
|
||||
|
||||
@@ -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.75–5.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 (1–40 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.75–5.5 V no-load / 4.0–5.5 V at 3 A, zOPEN 126 kΩ, tCCDebounce 100–200 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.75–5.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 (TOTAL−USED=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 L0–L3 (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 L1–L3, no invented ohms/mm/ps.
|
||||
|
||||
- [New] `protocol_l0.py`; `PE-PRT-L0-001` / `PE-PRT-L0-002`.
|
||||
- [New] pytest `tests/pcb/test_protocol_l0_m2.py`.
|
||||
|
||||
## 2.65.0 — 2026-09-22 — Protocol certification M1 (instance recognition)
|
||||
|
||||
`physical_bus_instance` from declaration / silicon / part / pin / net / pairs. Output: instances, signal groups (DIFF pair, byte lane, clock/address), confidence. Ambiguity is **REVIEW**, never FAIL. AXI4 internal is recognised with `pcb_relevant: NO` and **no PCB nets**. No L0–L3 certifier. No invented ohms.
|
||||
|
||||
- [New] `protocol_recognize.py`; report Protocolli lists recognised instances or stays empty.
|
||||
- [New] pytest `tests/pcb/test_protocol_recognize_m1.py` (USB/DDR fixtures; AXI has no nets).
|
||||
|
||||
## 2.64.0 — 2026-09-22 — Protocol certification M0 (catalog, no numbers)
|
||||
|
||||
Versioned JSON protocol pack + Python loader/validator. Catalog skeletons for USB2 / USB-C-USB2 / 100BASE-TX / HDMI (connector distinct) / DDR / LPDDR / HBM / HyperBus / QSPI-octal / eMMC / UFS / AMBA / Wishbone / Avalon / TileLink / PCIe-CXL. Constraints without an official cite are UNKNOWN / CONTROLLER_DEPENDENT / PHY_DEPENDENT / MISSING_SOURCE — **no invented 90/100/50 Ω**. `logical_protocol_id` ≠ `physical_interface_id`. `pcb_relevant` is YES|NO|CONDITIONAL. RECOMMENDED is not FAIL-mandatory. Typical-as-standard is rejected by the parser. Report section **Protocolli** is empty (`nessun protocollo riconosciuto`). Recognition and L0–L3 certifiers are not in this release.
|
||||
|
||||
- [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.
|
||||
|
||||
@@ -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. L’esame parte da lì.
|
||||
</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 ·{" "}
|
||||
{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 l’esame.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -368,6 +366,7 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
reportedFindingIds={reportedFindingIds}
|
||||
reviews={reviews}
|
||||
onReviewSaved={handleReviewSaved}
|
||||
protocolSection={report.protocol_certification}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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,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 l’elenco. 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} (L0–L3). 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}`;
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user