USB/RMII cert lines and not_reviewed count fix (2.85.0).
USB D+/D− is one English certified/not-certified line vs the packed 90 Ω cite. RMII ETH_RXD/TXD/TXEN is identified as PHY–MAC, not RJ45 100 Ω. J2 missing-field L1/L2/L3 noise is silent. PCB AI failures go to review_errors; not_reviewed count matches one designator per row.
This commit is contained in:
@@ -27,7 +27,7 @@ from backend.periscopex.impedance_traces import analyze_specified_nets
|
|||||||
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph, LayoutVia
|
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph, LayoutVia
|
||||||
from backend.periscopex.pcb_net_match import kicad_nets_match, refs_on_matched_net
|
from backend.periscopex.pcb_net_match import kicad_nets_match, refs_on_matched_net
|
||||||
from backend.periscopex.protocol_catalog import load_catalog
|
from backend.periscopex.protocol_catalog import load_catalog
|
||||||
from backend.periscopex.si_check import bus_class, partner_net
|
from backend.periscopex.si_check import bus_class, partner_net, phy_mac_kind, single_ended_eth_mac
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
SOURCE = "af_trace_check"
|
SOURCE = "af_trace_check"
|
||||||
@@ -55,6 +55,8 @@ def _mdi_mate(name: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _is_eth_mdi_net(net: str) -> bool:
|
def _is_eth_mdi_net(net: str) -> bool:
|
||||||
|
if single_ended_eth_mac(net):
|
||||||
|
return False
|
||||||
return bus_class(net) == "eth_mdi"
|
return bus_class(net) == "eth_mdi"
|
||||||
|
|
||||||
|
|
||||||
@@ -162,8 +164,8 @@ def _ethernet_mdi_finding(
|
|||||||
label = _label(unit)
|
label = _label(unit)
|
||||||
if cite is None:
|
if cite is None:
|
||||||
text = (
|
text = (
|
||||||
f"ERROR: Ethernet pair {label} speed is not identified on the "
|
f"Ethernet not certified — because the speed on {label} is not "
|
||||||
"PHY/jack, so no 10/100 or 1000 clause was applied."
|
"identified on the PHY/jack, so no cited clause was applied."
|
||||||
)
|
)
|
||||||
req = "Pair Z is certified only against the cited clause for the identified speed."
|
req = "Pair Z is certified only against the cited clause for the identified speed."
|
||||||
rec = "Identify the PHY/jack speed. No ohm value is assumed."
|
rec = "Identify the PHY/jack speed. No ohm value is assumed."
|
||||||
@@ -185,12 +187,12 @@ def _ethernet_mdi_finding(
|
|||||||
missing.append("geometry")
|
missing.append("geometry")
|
||||||
miss = "/".join(missing)
|
miss = "/".join(missing)
|
||||||
text = (
|
text = (
|
||||||
f"ERROR: pair Z for {label} cannot be calculated from the PCB "
|
"Ethernet not certified — because Z cannot be calculated from the "
|
||||||
f"({miss} missing). Cited limit {limit:g} Ω, {cite_label}."
|
f"PCB stackup/geometry ({miss} missing). Cited {limit:g} Ω ({cite_label})."
|
||||||
)
|
)
|
||||||
rec = (
|
rec = (
|
||||||
f"Pair Z was not calculated ({miss} missing). "
|
f"Pair Z was not calculated ({miss} missing). "
|
||||||
f"Cited limit {limit:g} Ω, {cite_label}."
|
f"Cited {limit:g} Ω ({cite_label})."
|
||||||
)
|
)
|
||||||
return _finding(
|
return _finding(
|
||||||
rule_id="PE-AF-002", unit=unit, finding=text,
|
rule_id="PE-AF-002", unit=unit, finding=text,
|
||||||
@@ -202,10 +204,10 @@ def _ethernet_mdi_finding(
|
|||||||
inside = abs(measured - limit) <= 1e-6 * max(1.0, abs(limit))
|
inside = abs(measured - limit) <= 1e-6 * max(1.0, abs(limit))
|
||||||
if inside:
|
if inside:
|
||||||
text = (
|
text = (
|
||||||
f"PASS: {label} pair Z {measured:g} Ω is inside the cited limit "
|
f"Ethernet certified — calculated Z {measured:g} Ω vs cited "
|
||||||
f"{limit:g} Ω, {cite_label}."
|
f"{limit:g} Ω ({cite_label})."
|
||||||
)
|
)
|
||||||
rec = f"Pair Z matches {limit:g} Ω ({cite_label})."
|
rec = f"Calculated pair Z matches cited {limit:g} Ω ({cite_label})."
|
||||||
return _finding(
|
return _finding(
|
||||||
rule_id="PE-AF-002", unit=unit, finding=text,
|
rule_id="PE-AF-002", unit=unit, finding=text,
|
||||||
facts=f"nets={label}; speed={speed}; Z={measured:g} Ω; limit={limit:g} Ω.",
|
facts=f"nets={label}; speed={speed}; Z={measured:g} Ω; limit={limit:g} Ω.",
|
||||||
@@ -214,11 +216,11 @@ def _ethernet_mdi_finding(
|
|||||||
calculation=f"pair Z {measured:g} Ω compared to {limit:g} Ω.",
|
calculation=f"pair Z {measured:g} Ω compared to {limit:g} Ω.",
|
||||||
)
|
)
|
||||||
text = (
|
text = (
|
||||||
f"FAIL: {label} pair Z {measured:g} Ω is outside the cited limit "
|
f"Ethernet not certified — because Z {measured:g} Ω is outside cited "
|
||||||
f"{limit:g} Ω, {cite_label}."
|
f"{limit:g} Ω ({cite_label})."
|
||||||
)
|
)
|
||||||
rec = (
|
rec = (
|
||||||
f"Pair Z {measured:g} Ω is outside {limit:g} Ω ({cite_label})."
|
f"Calculated pair Z {measured:g} Ω is outside cited {limit:g} Ω ({cite_label})."
|
||||||
)
|
)
|
||||||
return _finding(
|
return _finding(
|
||||||
rule_id="PE-AF-002", unit=unit, finding=text,
|
rule_id="PE-AF-002", unit=unit, finding=text,
|
||||||
@@ -244,8 +246,16 @@ def check_af_traces(
|
|||||||
if f.rule_id == "PE-SI-002" and f.net
|
if f.rule_id == "PE-SI-002" and f.net
|
||||||
}
|
}
|
||||||
zrows = _z_rows(impedance_nets)
|
zrows = _z_rows(impedance_nets)
|
||||||
|
rmii_nets: list[str] = []
|
||||||
for unit in _coalesce_eth_pairs(list(iter_af_units(layout, graph))):
|
for unit in _coalesce_eth_pairs(list(iter_af_units(layout, graph))):
|
||||||
|
if any(single_ended_eth_mac(n) or phy_mac_kind(n) == "rmii" for n in unit.nets):
|
||||||
|
if not any(_is_eth_mdi_net(n) for n in unit.nets):
|
||||||
|
rmii_nets.extend(unit.nets)
|
||||||
|
continue
|
||||||
if unit.bus == "eth_mdi":
|
if unit.bus == "eth_mdi":
|
||||||
|
if any(single_ended_eth_mac(n) for n in unit.nets):
|
||||||
|
rmii_nets.extend(unit.nets)
|
||||||
|
continue
|
||||||
out.append(_ethernet_mdi_finding(graph, layout, unit))
|
out.append(_ethernet_mdi_finding(graph, layout, unit))
|
||||||
continue
|
continue
|
||||||
trig = evaluate_trigger(graph, constraints_map, layout, unit)
|
trig = evaluate_trigger(graph, constraints_map, layout, unit)
|
||||||
@@ -255,10 +265,65 @@ def check_af_traces(
|
|||||||
if not trig.af:
|
if not trig.af:
|
||||||
continue
|
continue
|
||||||
out.extend(_analyze_triggered(graph, constraints_map, layout, trig, zrows, seen_si_z))
|
out.extend(_analyze_triggered(graph, constraints_map, layout, trig, zrows, seen_si_z))
|
||||||
|
if rmii_nets:
|
||||||
|
out.append(_rmii_cert_finding(graph, rmii_nets))
|
||||||
complete_findings(out)
|
complete_findings(out)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _rmii_cert_finding(graph: DesignGraph, nets: list[str]) -> Finding:
|
||||||
|
"""One line: PHY–MAC is RMII/MII, not RJ45 100 Ω. No invented ohm."""
|
||||||
|
uniq = sorted({n for n in nets if n})
|
||||||
|
label = " / ".join(uniq[:6]) + (" / …" if len(uniq) > 6 else "")
|
||||||
|
kinds = {phy_mac_kind(n) or "rmii" for n in uniq}
|
||||||
|
if kinds == {"mii"}:
|
||||||
|
iface = "MII"
|
||||||
|
elif "rgmii" in kinds and "rmii" not in kinds:
|
||||||
|
iface = "RGMII"
|
||||||
|
else:
|
||||||
|
iface = "RMII"
|
||||||
|
phy_refs = sorted({
|
||||||
|
ref
|
||||||
|
for net in uniq
|
||||||
|
for ref in refs_on_matched_net(graph, net)
|
||||||
|
if (graph.components.get(ref) or None) is not None
|
||||||
|
and "LAN8720" in (
|
||||||
|
(graph.components[ref].mpn or "") + (graph.components[ref].value or "")
|
||||||
|
).upper()
|
||||||
|
})
|
||||||
|
host = phy_refs[0] if phy_refs else "layout"
|
||||||
|
text = (
|
||||||
|
f"{iface} certified — {label} identified as single-ended {iface} PHY–MAC "
|
||||||
|
"(not an RJ45/MDI 100 Ω differential pair). No invented ohm."
|
||||||
|
)
|
||||||
|
rec = (
|
||||||
|
f"Keep {iface} as single-ended PHY–MAC. Do not apply the MDI 100 Ω pair cite."
|
||||||
|
)
|
||||||
|
return Finding(
|
||||||
|
designator=host,
|
||||||
|
mpn="",
|
||||||
|
aspect="si",
|
||||||
|
finding=text,
|
||||||
|
facts=f"nets={label}; interface={iface}; phy={','.join(phy_refs) or '—'}.",
|
||||||
|
requirement=(
|
||||||
|
f"{iface} data/enable nets are single-ended MAC–PHY. "
|
||||||
|
"They are not certified against the RJ45/MDI 100 Ω clause."
|
||||||
|
),
|
||||||
|
inference="Interface class from net names and PHY on the nets. No Z invented.",
|
||||||
|
why=text,
|
||||||
|
status="INFO",
|
||||||
|
recommendation=rec,
|
||||||
|
action=rec,
|
||||||
|
source=SOURCE,
|
||||||
|
rule_id="PE-AF-003",
|
||||||
|
finding_class="INFO",
|
||||||
|
provenance="TYPICAL",
|
||||||
|
evidence_status="SUFFICIENT",
|
||||||
|
net=uniq[0] if uniq else None,
|
||||||
|
pins=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _z_rows(impedance_nets: list[dict] | dict | None) -> list[dict]:
|
def _z_rows(impedance_nets: list[dict] | dict | None) -> list[dict]:
|
||||||
raw = impedance_nets
|
raw = impedance_nets
|
||||||
if isinstance(impedance_nets, dict):
|
if isinstance(impedance_nets, dict):
|
||||||
@@ -272,12 +337,15 @@ def _label(unit: AfUnit) -> str:
|
|||||||
|
|
||||||
def _skip(unit: AfUnit, missing: tuple[str, ...], extra: str = "") -> Finding:
|
def _skip(unit: AfUnit, missing: tuple[str, ...], extra: str = "") -> Finding:
|
||||||
miss = ", ".join(missing)
|
miss = ", ".join(missing)
|
||||||
text = f"Pista ad alta frequenza non controllata per mancanza di {miss}."
|
text = (
|
||||||
|
f"HF not certified — because {miss} is missing on {_label(unit)}. "
|
||||||
|
"No invented ohm."
|
||||||
|
)
|
||||||
if extra:
|
if extra:
|
||||||
text = f"{text} {extra}".strip()
|
text = f"{text} {extra}".strip()
|
||||||
rec = (
|
rec = (
|
||||||
f"Fornire {miss} dal datasheet o dallo stackup KiCad. "
|
f"Provide {miss} from the datasheet or KiCad stackup. "
|
||||||
"Non si assume 50 Ω o 90 Ω."
|
"50 Ω or 90 Ω is not assumed."
|
||||||
)
|
)
|
||||||
return Finding(
|
return Finding(
|
||||||
designator="layout",
|
designator="layout",
|
||||||
|
|||||||
@@ -149,6 +149,52 @@ def check_pcb_derating(graph: DesignGraph) -> list[Finding]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_not_reviewed(rows: list) -> list[dict]:
|
||||||
|
"""One designator per entry. Comma-joined refs become separate rows."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
raw = str(row.get("designator") or "").strip()
|
||||||
|
reason = str(row.get("reason") or "").strip() or "not reviewed"
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
parts = [p.strip() for p in raw.replace(";", ",").split(",") if p.strip()]
|
||||||
|
if not parts:
|
||||||
|
parts = [raw]
|
||||||
|
for ref in parts:
|
||||||
|
out.append({"designator": ref, "reason": reason})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_not_reviewed(
|
||||||
|
schema_nr: list,
|
||||||
|
pcb_nr: list,
|
||||||
|
covered: set[str],
|
||||||
|
review_errors: dict[str, str] | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Dedupe by designator. Drop ICs that were reviewed. Count == list length.
|
||||||
|
|
||||||
|
Legacy ``pcb_review error`` rows move into ``review_errors`` when possible
|
||||||
|
and are not kept as not-reviewed.
|
||||||
|
"""
|
||||||
|
errors = review_errors if review_errors is not None else {}
|
||||||
|
merged: dict[str, str] = {}
|
||||||
|
for row in _expand_not_reviewed(list(schema_nr or []) + list(pcb_nr or [])):
|
||||||
|
ref = row["designator"]
|
||||||
|
if ref in covered:
|
||||||
|
continue
|
||||||
|
reason = row["reason"]
|
||||||
|
if reason == "pcb_review error":
|
||||||
|
errors.setdefault(ref, "pcb_review error")
|
||||||
|
continue
|
||||||
|
prev = merged.get(ref)
|
||||||
|
if prev and prev != "pcb_review error" and reason == "pcb_review error":
|
||||||
|
continue
|
||||||
|
merged[ref] = reason
|
||||||
|
return [{"designator": k, "reason": merged[k]} for k in sorted(merged)]
|
||||||
|
|
||||||
|
|
||||||
def merge_schema_pcb_reports(
|
def merge_schema_pcb_reports(
|
||||||
schema: dict | None, pcb: dict | None,
|
schema: dict | None, pcb: dict | None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
@@ -189,10 +235,17 @@ def merge_schema_pcb_reports(
|
|||||||
if st in summary:
|
if st in summary:
|
||||||
summary[st] = summary.get(st, 0) + 1
|
summary[st] = summary.get(st, 0) + 1
|
||||||
out["summary"] = summary
|
out["summary"] = summary
|
||||||
|
covered = set((schema or {}).get("coverage") or {}) | set((pcb or {}).get("coverage") or {})
|
||||||
|
schema_err = dict((schema or {}).get("review_errors") or {})
|
||||||
|
pcb_err = dict((pcb or {}).get("review_errors") or {})
|
||||||
|
merged_err = {**schema_err, **pcb_err}
|
||||||
schema_nr = list((schema or {}).get("not_reviewed") or [])
|
schema_nr = list((schema or {}).get("not_reviewed") or [])
|
||||||
pcb_nr = list((pcb or {}).get("not_reviewed") or [])
|
pcb_nr = list((pcb or {}).get("not_reviewed") or [])
|
||||||
if schema_nr or pcb_nr:
|
out["not_reviewed"] = _merge_not_reviewed(schema_nr, pcb_nr, covered, merged_err)
|
||||||
out["not_reviewed"] = schema_nr + pcb_nr
|
if merged_err:
|
||||||
|
out["review_errors"] = merged_err
|
||||||
|
elif "review_errors" in out and not out["review_errors"]:
|
||||||
|
out.pop("review_errors", None)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ Skew is compared only when the constraint is NUMERIC (length unit) or an
|
|||||||
explicit DESIGN LIMIT. Otherwise UNKNOWN / CONTROLLER_DEPENDENT /
|
explicit DESIGN LIMIT. Otherwise UNKNOWN / CONTROLLER_DEPENDENT /
|
||||||
PHY_DEPENDENT / VENDOR_DEPENDENT. Length is not delay. Not electrical.
|
PHY_DEPENDENT / VENDOR_DEPENDENT. Length is not delay. Not electrical.
|
||||||
RECOMMENDED never FAIL. Internal interfaces are NOT_APPLICABLE.
|
RECOMMENDED never FAIL. Internal interfaces are NOT_APPLICABLE.
|
||||||
Missing DQ/DQS grouping or missing PCB geometry is a visible skip, not PASS.
|
Missing DQ/DQS grouping is a visible skip, not PASS. A geometric check
|
||||||
|
with no NUMERIC or DESIGN millimetre limit does not warn: the standard
|
||||||
|
did not set a mm limit, and one is not invented.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -497,11 +499,14 @@ def l1_findings(inst: PhysicalBusInstance, results: list[L1CheckResult]) -> list
|
|||||||
finding = f"L1 geometric {r.check}: {r.notes}"
|
finding = f"L1 geometric {r.check}: {r.notes}"
|
||||||
elif skip_family:
|
elif skip_family:
|
||||||
grouping = "grouping" in (r.notes or "").lower() or "dq/dqs" in (r.notes or "").lower()
|
grouping = "grouping" in (r.notes or "").lower() or "dq/dqs" in (r.notes or "").lower()
|
||||||
|
if not grouping and r.limit_mm is None:
|
||||||
|
# No NUMERIC/DESIGN millimetre limit in the pack. Silence.
|
||||||
|
continue
|
||||||
rule_id = "PE-PRT-L1-002" if grouping else "PE-PRT-L1-001"
|
rule_id = "PE-PRT-L1-002" if grouping else "PE-PRT-L1-001"
|
||||||
status = "WARNING"
|
status = "WARNING"
|
||||||
finding = r.notes or (
|
finding = r.notes or (
|
||||||
f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
|
f"{inst.physical_interface_id} L1 {r.check} is {r.result}. "
|
||||||
f"per {r.check} ({r.result}). Not electrical."
|
"Geometric only, not electrical."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
rule_id = "PE-PRT-L0-002"
|
rule_id = "PE-PRT-L0-002"
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
"""M4: L2 ELECTRICAL protocol certifier. Z, termination, levels, rise/fall.
|
"""M4: L2 ELECTRICAL protocol certifier. Z, termination, levels, rise/fall.
|
||||||
|
|
||||||
Z only from datasheet / stackup / fabricator / cited pack — never invented
|
Z only from datasheet / stackup / fabricator / cited pack. USB 2.0 HS
|
||||||
90 Ω USB. USB-IF/IEEE numbers only if the pack has a cite. Silicon cross is
|
D+/D− pair Z is compared to the packed ``pcb_trace_nominal_zdiff``
|
||||||
max PCB × PHY subset when datasheet windows exist. Length is not delay.
|
(RECOMMENDED). That number is not invented here and is not a MANDATORY
|
||||||
Not L3 / OpenEMS. L0/L1 are not electrical certification.
|
FAIL. Silicon cross is max PCB × PHY subset when datasheet windows exist.
|
||||||
RECOMMENDED never FAIL. MISSING_SOURCE / UNKNOWN when cite is absent.
|
A missing field (termination cite, rise/fall, return path, voltage) does
|
||||||
Visible skip (PE-AF-002 style) when Z cannot be obtained.
|
not emit a warning. Length is not delay. Not L3 / OpenEMS.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from backend.periscopex.constraints_lookup import match_constraints
|
from backend.periscopex.constraints_lookup import match_constraints
|
||||||
from backend.periscopex.finding_engine import complete_finding
|
from backend.periscopex.finding_engine import complete_finding
|
||||||
|
from backend.periscopex.impedance import GeometryError, TraceGeometry, coupled_diff_z
|
||||||
from backend.periscopex.models import (
|
from backend.periscopex.models import (
|
||||||
ComponentConstraints,
|
ComponentConstraints,
|
||||||
ComponentType,
|
ComponentType,
|
||||||
@@ -23,7 +25,6 @@ from backend.periscopex.models import (
|
|||||||
Finding,
|
Finding,
|
||||||
LayoutGraph,
|
LayoutGraph,
|
||||||
)
|
)
|
||||||
from backend.periscopex.pcb_net_match import kicad_nets_match
|
|
||||||
from backend.periscopex.protocol_catalog import (
|
from backend.periscopex.protocol_catalog import (
|
||||||
PhysicalInterface,
|
PhysicalInterface,
|
||||||
ProtocolCatalog,
|
ProtocolCatalog,
|
||||||
@@ -31,8 +32,10 @@ from backend.periscopex.protocol_catalog import (
|
|||||||
load_catalog,
|
load_catalog,
|
||||||
map_protocol_outcome,
|
map_protocol_outcome,
|
||||||
)
|
)
|
||||||
|
from backend.periscopex.pcb_net_match import kicad_nets_match
|
||||||
from backend.periscopex.protocol_l0 import _constraint_for, _mandatory
|
from backend.periscopex.protocol_l0 import _constraint_for, _mandatory
|
||||||
from backend.periscopex.protocol_recognize import PhysicalBusInstance
|
from backend.periscopex.protocol_recognize import PhysicalBusInstance
|
||||||
|
from backend.periscopex.si_check import single_ended_eth_mac
|
||||||
|
|
||||||
PACK_MACROPHASE = "M4"
|
PACK_MACROPHASE = "M4"
|
||||||
SOURCE = "protocol_l2"
|
SOURCE = "protocol_l2"
|
||||||
@@ -81,6 +84,8 @@ class L2CheckResult(BaseModel):
|
|||||||
notes: str = ""
|
notes: str = ""
|
||||||
finding_class: str = ""
|
finding_class: str = ""
|
||||||
status: str = ""
|
status: str = ""
|
||||||
|
# "" legacy, "silent" no finding, "warn" one WARNING, "error" one ERROR.
|
||||||
|
emit: str = ""
|
||||||
|
|
||||||
|
|
||||||
def _pack(
|
def _pack(
|
||||||
@@ -95,6 +100,7 @@ def _pack(
|
|||||||
limit_ohm_max: float | None = None,
|
limit_ohm_max: float | None = None,
|
||||||
value_kind: str = "",
|
value_kind: str = "",
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
|
emit: str = "",
|
||||||
) -> L2CheckResult:
|
) -> L2CheckResult:
|
||||||
if result == "FAIL" and mandatory != "MANDATORY":
|
if result == "FAIL" and mandatory != "MANDATORY":
|
||||||
result = "WARNING"
|
result = "WARNING"
|
||||||
@@ -120,6 +126,7 @@ def _pack(
|
|||||||
notes=notes,
|
notes=notes,
|
||||||
finding_class=cls,
|
finding_class=cls,
|
||||||
status=status,
|
status=status,
|
||||||
|
emit=emit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -286,6 +293,24 @@ def _pack_ohm_window(cons: ProtocolConstraint | None) -> tuple[float, float] | N
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_NOISE_CHECKS = frozenset({
|
||||||
|
"differential_impedance",
|
||||||
|
"impedance",
|
||||||
|
"impedance_if_required",
|
||||||
|
"termination",
|
||||||
|
"cc_termination",
|
||||||
|
"voltage",
|
||||||
|
"levels",
|
||||||
|
"rise_time",
|
||||||
|
"fall_time",
|
||||||
|
"timing",
|
||||||
|
"return_path",
|
||||||
|
"magnetics",
|
||||||
|
"phy_requirements",
|
||||||
|
"ac_coupling",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def _skip_z(check: str, mand: MandatoryClass, inst: PhysicalBusInstance, kind: str) -> L2CheckResult:
|
def _skip_z(check: str, mand: MandatoryClass, inst: PhysicalBusInstance, kind: str) -> L2CheckResult:
|
||||||
return _pack(
|
return _pack(
|
||||||
check, "UNKNOWN" if kind not in {
|
check, "UNKNOWN" if kind not in {
|
||||||
@@ -294,10 +319,10 @@ def _skip_z(check: str, mand: MandatoryClass, inst: PhysicalBusInstance, kind: s
|
|||||||
} else kind,
|
} else kind,
|
||||||
mand,
|
mand,
|
||||||
value_kind=kind,
|
value_kind=kind,
|
||||||
|
emit="silent",
|
||||||
notes=(
|
notes=(
|
||||||
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
|
f"{inst.physical_interface_id} has no cited Z window "
|
||||||
f"per mancanza di Z (datasheet/stackup/fab/cited pack). "
|
"(datasheet, stackup, fabricator, or pack). Not a warning."
|
||||||
f"Not USB/IEC folklore. L0/L1 are not electrical certification."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -349,8 +374,8 @@ def _run_l2_check(
|
|||||||
"differential_impedance", "impedance", "impedance_if_required",
|
"differential_impedance", "impedance", "impedance_if_required",
|
||||||
}:
|
}:
|
||||||
return _z_check(
|
return _z_check(
|
||||||
check, mand, cons, kind, inst, graph, nets,
|
check, mand, cons, kind, inst, iface, graph, nets,
|
||||||
constraints_map, impedance_nets,
|
constraints_map, impedance_nets, layout,
|
||||||
)
|
)
|
||||||
|
|
||||||
if check in {"termination", "cc_termination"}:
|
if check in {"termination", "cc_termination"}:
|
||||||
@@ -362,10 +387,10 @@ def _run_l2_check(
|
|||||||
if check in {"rise_time", "fall_time", "timing"}:
|
if check in {"rise_time", "fall_time", "timing"}:
|
||||||
return _pack(
|
return _pack(
|
||||||
check, "UNKNOWN" if kind in {"NUMERIC", "UNKNOWN", ""} else kind, mand,
|
check, "UNKNOWN" if kind in {"NUMERIC", "UNKNOWN", ""} else kind, mand,
|
||||||
nets=nets, value_kind=kind,
|
nets=nets, value_kind=kind, emit="silent",
|
||||||
notes=(
|
notes=(
|
||||||
"Length is not delay. Rise/fall/timing UNKNOWN without a cited "
|
"Length is not delay. Rise/fall/timing stays UNKNOWN without a cited "
|
||||||
"time source (no tpd invented from mm). Not L3."
|
"time source (no tpd invented from mm). Not L3. Not a warning."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -374,27 +399,234 @@ def _run_l2_check(
|
|||||||
if verdict == "NUMERIC":
|
if verdict == "NUMERIC":
|
||||||
verdict = "UNKNOWN"
|
verdict = "UNKNOWN"
|
||||||
return _pack(
|
return _pack(
|
||||||
check, verdict, mand, nets=nets, value_kind=kind,
|
check, verdict, mand, nets=nets, value_kind=kind, emit="silent",
|
||||||
notes=(
|
notes=(
|
||||||
f"{check} not certified without a cited electrical source. "
|
f"{check} has no cited electrical source. "
|
||||||
f"Not invented. L0/L1 are not electrical certification."
|
"Not invented. Not a warning."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return _pack(check, "UNKNOWN", mand, value_kind=kind, notes="Not an L2 electrical check.")
|
return _pack(check, "UNKNOWN", mand, value_kind=kind, notes="Not an L2 electrical check.")
|
||||||
|
|
||||||
|
|
||||||
|
def _pcb_zdiff_cite(iface: PhysicalInterface) -> ProtocolConstraint | None:
|
||||||
|
"""Packed board Zdiff recommendation. Never a number typed in this module."""
|
||||||
|
for c in iface.constraints:
|
||||||
|
if c.parameter != "pcb_trace_nominal_zdiff":
|
||||||
|
continue
|
||||||
|
if c.value_kind != "NUMERIC" or c.value is None:
|
||||||
|
continue
|
||||||
|
unit = (c.unit or "").strip().lower()
|
||||||
|
if unit not in _OHM_UNITS:
|
||||||
|
continue
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _usb_dp_dm(inst: PhysicalBusInstance) -> tuple[str, str] | None:
|
||||||
|
for g in inst.groups:
|
||||||
|
if g.kind != "DIFFERENTIAL_PAIR" or len(g.nets) < 2:
|
||||||
|
continue
|
||||||
|
dp = g.roles.get("D+") or g.nets[0]
|
||||||
|
dm = g.roles.get("D-") or g.nets[1]
|
||||||
|
if single_ended_eth_mac(dp) or single_ended_eth_mac(dm):
|
||||||
|
return None
|
||||||
|
return dp, dm
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cite_label(cons: ProtocolConstraint) -> str:
|
||||||
|
src = cons.source
|
||||||
|
bits: list[str] = []
|
||||||
|
if src is not None:
|
||||||
|
if src.document:
|
||||||
|
bits.append(src.document)
|
||||||
|
if src.section:
|
||||||
|
bits.append(f"§{src.section}")
|
||||||
|
if src.page:
|
||||||
|
bits.append(f"p.{src.page}")
|
||||||
|
return ", ".join(bits) if bits else "cited pack"
|
||||||
|
|
||||||
|
|
||||||
|
def _zdiff_measured(nets: list[str], impedance_nets: list[dict] | dict | None) -> float | None:
|
||||||
|
"""Pair Z only. Single-ended Z0 is not a differential result."""
|
||||||
|
rows = _z_rows(impedance_nets)
|
||||||
|
vals: list[float] = []
|
||||||
|
for net in nets:
|
||||||
|
row = _row_for(rows, net)
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
for key in ("zdiff_ohm", "zdiff_avg_ohms", "zdiff_ohms"):
|
||||||
|
v = _num(row.get(key))
|
||||||
|
if v is not None and v > 0:
|
||||||
|
vals.append(v)
|
||||||
|
break
|
||||||
|
if not vals:
|
||||||
|
return None
|
||||||
|
return sum(vals) / len(vals)
|
||||||
|
|
||||||
|
|
||||||
|
def _median_width_mm(layout: LayoutGraph, net: str) -> float | None:
|
||||||
|
widths = sorted(
|
||||||
|
s.width for s in layout.segments
|
||||||
|
if s.net and kicad_nets_match(s.net, net) and s.width > 0
|
||||||
|
)
|
||||||
|
if not widths:
|
||||||
|
return None
|
||||||
|
return widths[len(widths) // 2]
|
||||||
|
|
||||||
|
|
||||||
|
def _pair_gap_mm(layout: LayoutGraph, a: str, b: str) -> float | None:
|
||||||
|
sa = [s for s in layout.segments if s.net and kicad_nets_match(s.net, a) and s.width > 0]
|
||||||
|
sb = [s for s in layout.segments if s.net and kicad_nets_match(s.net, b) and s.width > 0]
|
||||||
|
gaps: list[float] = []
|
||||||
|
for x in sa:
|
||||||
|
for y in sb:
|
||||||
|
if x.layer and y.layer and x.layer != y.layer:
|
||||||
|
continue
|
||||||
|
for px, py in (
|
||||||
|
(x.start, y.start), (x.start, y.end),
|
||||||
|
(x.end, y.start), (x.end, y.end),
|
||||||
|
):
|
||||||
|
edge = math.hypot(px[0] - py[0], px[1] - py[1]) - x.width / 2.0 - y.width / 2.0
|
||||||
|
if edge > 1e-6:
|
||||||
|
gaps.append(edge)
|
||||||
|
if not gaps:
|
||||||
|
return None
|
||||||
|
return min(gaps)
|
||||||
|
|
||||||
|
|
||||||
|
def usb_pair_z_ohm(
|
||||||
|
layout: LayoutGraph | None,
|
||||||
|
nets: tuple[str, str],
|
||||||
|
impedance_nets: list[dict] | dict | None,
|
||||||
|
) -> tuple[float | None, str]:
|
||||||
|
"""Calculated differential Z for one pair.
|
||||||
|
|
||||||
|
Returns (ohms, missing). missing is empty when ohms is set, otherwise
|
||||||
|
``stackup``, ``geometry``, or ``stackup/geometry``.
|
||||||
|
"""
|
||||||
|
stored = _zdiff_measured(list(nets), impedance_nets)
|
||||||
|
if stored is not None:
|
||||||
|
return stored, ""
|
||||||
|
has_copper = bool(
|
||||||
|
layout is not None and any(
|
||||||
|
s.net and s.width > 0 and (
|
||||||
|
kicad_nets_match(s.net, nets[0]) or kicad_nets_match(s.net, nets[1])
|
||||||
|
)
|
||||||
|
for s in layout.segments
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stack = layout.stackup if layout is not None else None
|
||||||
|
dielectric = stack.dielectrics[0] if stack and stack.dielectrics else None
|
||||||
|
stack_ok = (
|
||||||
|
stack is not None
|
||||||
|
and dielectric is not None
|
||||||
|
and dielectric.er > 0
|
||||||
|
and dielectric.height_mm > 0
|
||||||
|
and stack.copper_thickness_mm is not None
|
||||||
|
and stack.copper_thickness_mm > 0
|
||||||
|
)
|
||||||
|
if not stack_ok:
|
||||||
|
return None, "stackup/geometry" if not has_copper else "stackup"
|
||||||
|
assert layout is not None and stack is not None and dielectric is not None
|
||||||
|
wa = _median_width_mm(layout, nets[0])
|
||||||
|
wb = _median_width_mm(layout, nets[1])
|
||||||
|
gap = _pair_gap_mm(layout, nets[0], nets[1])
|
||||||
|
if wa is None or wb is None or gap is None:
|
||||||
|
return None, "geometry"
|
||||||
|
try:
|
||||||
|
_zodd, _zeven, zdiff = coupled_diff_z(TraceGeometry(
|
||||||
|
h=dielectric.height_mm,
|
||||||
|
er=dielectric.er,
|
||||||
|
t=stack.copper_thickness_mm,
|
||||||
|
w=(wa + wb) / 2.0,
|
||||||
|
s=gap,
|
||||||
|
))
|
||||||
|
except GeometryError:
|
||||||
|
return None, "geometry"
|
||||||
|
if zdiff <= 0:
|
||||||
|
return None, "geometry"
|
||||||
|
return zdiff, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_cited(measured: float, cited: float) -> bool:
|
||||||
|
return abs(measured - cited) <= 1e-6 * max(1.0, abs(cited))
|
||||||
|
|
||||||
|
|
||||||
|
def _usb_line(kind: str, *, measured: float | None, cited: float, label: str) -> str:
|
||||||
|
"""One report line. The ohm value is the packed cite, not a second number."""
|
||||||
|
if kind == "certified" and measured is not None:
|
||||||
|
return (
|
||||||
|
f"USB certified — calculated Z {measured:g} Ω vs cited {cited:g} Ω ({label})."
|
||||||
|
)
|
||||||
|
if measured is not None:
|
||||||
|
return (
|
||||||
|
f"USB not certified — because Z {measured:g} Ω is outside cited "
|
||||||
|
f"{cited:g} Ω ({label})."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"USB not certified — because Z cannot be calculated from the PCB "
|
||||||
|
f"stackup/geometry. Cited {cited:g} Ω ({label})."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _usb_board_z(
|
||||||
|
check: str,
|
||||||
|
inst: PhysicalBusInstance,
|
||||||
|
cite: ProtocolConstraint,
|
||||||
|
pair: tuple[str, str],
|
||||||
|
layout: LayoutGraph | None,
|
||||||
|
impedance_nets: list[dict] | dict | None,
|
||||||
|
) -> L2CheckResult:
|
||||||
|
"""One line: calculated pair Z vs the packed board cite."""
|
||||||
|
cited = float(cite.value) # type: ignore[arg-type]
|
||||||
|
label = _cite_label(cite)
|
||||||
|
measured, _missing = usb_pair_z_ohm(layout, pair, impedance_nets)
|
||||||
|
mand: MandatoryClass = "RECOMMENDED"
|
||||||
|
if measured is None:
|
||||||
|
return L2CheckResult(
|
||||||
|
check=check, result="FAIL", mandatory=mand, nets=list(pair),
|
||||||
|
limit_ohm=cited, value_kind="NUMERIC", emit="not_certified",
|
||||||
|
notes=_usb_line("missing", measured=None, cited=cited, label=label),
|
||||||
|
finding_class="RULE", status="ERROR",
|
||||||
|
)
|
||||||
|
if _matches_cited(measured, cited):
|
||||||
|
return L2CheckResult(
|
||||||
|
check=check, result="PASS", mandatory=mand, nets=list(pair),
|
||||||
|
measured_ohm=measured, limit_ohm=cited,
|
||||||
|
limit_ohm_min=cited, limit_ohm_max=cited,
|
||||||
|
value_kind="NUMERIC", emit="certified",
|
||||||
|
notes=_usb_line("certified", measured=measured, cited=cited, label=label),
|
||||||
|
finding_class="INFO", status="INFO",
|
||||||
|
)
|
||||||
|
return L2CheckResult(
|
||||||
|
check=check, result="FAIL", mandatory=mand, nets=list(pair),
|
||||||
|
measured_ohm=measured, limit_ohm=cited,
|
||||||
|
limit_ohm_min=cited, limit_ohm_max=cited,
|
||||||
|
value_kind="NUMERIC", emit="not_certified",
|
||||||
|
notes=_usb_line("outside", measured=measured, cited=cited, label=label),
|
||||||
|
finding_class="RULE", status="ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _z_check(
|
def _z_check(
|
||||||
check: str,
|
check: str,
|
||||||
mand: MandatoryClass,
|
mand: MandatoryClass,
|
||||||
cons: ProtocolConstraint | None,
|
cons: ProtocolConstraint | None,
|
||||||
kind: str,
|
kind: str,
|
||||||
inst: PhysicalBusInstance,
|
inst: PhysicalBusInstance,
|
||||||
|
iface: PhysicalInterface,
|
||||||
graph: DesignGraph,
|
graph: DesignGraph,
|
||||||
nets: list[str],
|
nets: list[str],
|
||||||
constraints_map: dict[str, ComponentConstraints] | None,
|
constraints_map: dict[str, ComponentConstraints] | None,
|
||||||
impedance_nets: list[dict] | dict | None,
|
impedance_nets: list[dict] | dict | None,
|
||||||
|
layout: LayoutGraph | None,
|
||||||
) -> L2CheckResult:
|
) -> L2CheckResult:
|
||||||
|
pair = _usb_dp_dm(inst)
|
||||||
|
cite = _pcb_zdiff_cite(iface)
|
||||||
phy = _phy_z_windows(graph, inst, constraints_map)
|
phy = _phy_z_windows(graph, inst, constraints_map)
|
||||||
|
if cite is not None and pair is not None and not phy:
|
||||||
|
return _usb_board_z(check, inst, cite, pair, layout, impedance_nets)
|
||||||
pack_w = _pack_ohm_window(cons)
|
pack_w = _pack_ohm_window(cons)
|
||||||
windows: list[tuple[float, float]] = []
|
windows: list[tuple[float, float]] = []
|
||||||
if pack_w:
|
if pack_w:
|
||||||
@@ -403,7 +635,6 @@ def _z_check(
|
|||||||
window = intersect_windows(windows) if windows else None
|
window = intersect_windows(windows) if windows else None
|
||||||
measured = measured_z_ohm(nets, impedance_nets)
|
measured = measured_z_ohm(nets, impedance_nets)
|
||||||
if window is None:
|
if window is None:
|
||||||
# no cited pack number and no datasheet PHY window → never invent 90 Ω
|
|
||||||
if measured is None:
|
if measured is None:
|
||||||
return _skip_z(check, mand, inst, kind)
|
return _skip_z(check, mand, inst, kind)
|
||||||
return _pack(
|
return _pack(
|
||||||
@@ -413,15 +644,15 @@ def _z_check(
|
|||||||
"VENDOR_DEPENDENT", "UNKNOWN",
|
"VENDOR_DEPENDENT", "UNKNOWN",
|
||||||
} else "UNKNOWN",
|
} else "UNKNOWN",
|
||||||
mand,
|
mand,
|
||||||
nets=nets, measured_ohm=measured, value_kind=kind,
|
nets=nets, measured_ohm=measured, value_kind=kind, emit="silent",
|
||||||
notes=(
|
notes=(
|
||||||
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
|
f"{inst.physical_interface_id} has measured Z and no cited target. "
|
||||||
f"per mancanza di Z target (cited pack or PHY datasheet). "
|
"Not compared to an invented ohm. Not a warning."
|
||||||
f"Measured stackup/fab Z is not compared to invented USB/IEC ohms. "
|
|
||||||
f"L0/L1 are not electrical certification."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if measured is None:
|
if measured is None:
|
||||||
|
if cite is not None and pair is not None:
|
||||||
|
return _usb_board_z(check, inst, cite, pair, layout, impedance_nets)
|
||||||
return _skip_z(check, mand, inst, kind)
|
return _skip_z(check, mand, inst, kind)
|
||||||
verdict = electrical_verdict(cons, measured, window)
|
verdict = electrical_verdict(cons, measured, window)
|
||||||
source_note = (
|
source_note = (
|
||||||
@@ -474,10 +705,10 @@ def _termination_check(
|
|||||||
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
|
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
|
||||||
} else "UNKNOWN"
|
} else "UNKNOWN"
|
||||||
return _pack(
|
return _pack(
|
||||||
check, verdict, mand, nets=nets, value_kind=kind,
|
check, verdict, mand, nets=nets, value_kind=kind, emit="silent",
|
||||||
notes=(
|
notes=(
|
||||||
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
|
f"{inst.physical_interface_id} has no termination cite. "
|
||||||
f"per mancanza di termination cite. No invented Rd/ohm."
|
"No invented resistor. Not a warning."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
measured = None
|
measured = None
|
||||||
@@ -517,8 +748,8 @@ def _levels_check(
|
|||||||
unit = (cons.unit or "").strip().lower()
|
unit = (cons.unit or "").strip().lower()
|
||||||
if unit in _VOLT_UNITS:
|
if unit in _VOLT_UNITS:
|
||||||
return _pack(
|
return _pack(
|
||||||
check, "UNKNOWN", mand, value_kind="NUMERIC",
|
check, "UNKNOWN", mand, value_kind="NUMERIC", emit="silent",
|
||||||
notes="Voltage NUMERIC in pack but PCB rail FACT not supplied — not invented 3.3 V.",
|
notes="Voltage NUMERIC in pack but PCB rail FACT not supplied — not invented 3.3 V. Not a warning.",
|
||||||
)
|
)
|
||||||
if constraints_map:
|
if constraints_map:
|
||||||
for ref in [inst.host_ref, *inst.peer_refs]:
|
for ref in [inst.host_ref, *inst.peer_refs]:
|
||||||
@@ -528,10 +759,10 @@ def _levels_check(
|
|||||||
ds = match_constraints(comp.mpn or comp.value, constraints_map)
|
ds = match_constraints(comp.mpn or comp.value, constraints_map)
|
||||||
if ds and ds.absolute_maximum_ratings:
|
if ds and ds.absolute_maximum_ratings:
|
||||||
return _pack(
|
return _pack(
|
||||||
check, "UNKNOWN", mand, value_kind=kind,
|
check, "UNKNOWN", mand, value_kind=kind, emit="silent",
|
||||||
notes=(
|
notes=(
|
||||||
"Silicon abs-max present; L2 levels need a cited operating "
|
"Silicon abs-max present; L2 levels need a cited operating "
|
||||||
"window vs measured rail — not assumed. Not L3."
|
"window vs measured rail — not assumed. Not L3. Not a warning."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
verdict = kind if kind in {
|
verdict = kind if kind in {
|
||||||
@@ -539,15 +770,58 @@ def _levels_check(
|
|||||||
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
|
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
|
||||||
} else "UNKNOWN"
|
} else "UNKNOWN"
|
||||||
return _pack(
|
return _pack(
|
||||||
check, verdict, mand, value_kind=kind,
|
check, verdict, mand, value_kind=kind, emit="silent",
|
||||||
notes="Levels/voltage UNKNOWN without a cited electrical source. Not invented.",
|
notes="Levels/voltage UNKNOWN without a cited electrical source. Not invented. Not a warning.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pair_line_finding(inst: PhysicalBusInstance, r: L2CheckResult) -> Finding:
|
||||||
|
"""Certified or not certified. English. Not the datasheet boilerplate."""
|
||||||
|
designator = inst.host_ref or inst.physical_interface_id
|
||||||
|
certified = r.emit == "certified"
|
||||||
|
text = r.notes
|
||||||
|
if certified:
|
||||||
|
status = "INFO"
|
||||||
|
cls = "INFO"
|
||||||
|
action = "Calculated pair Z matches the cited limit."
|
||||||
|
else:
|
||||||
|
status = "ERROR"
|
||||||
|
cls = "RULE"
|
||||||
|
action = "Calculated pair Z is not certified. The finding states the reason."
|
||||||
|
f = Finding(
|
||||||
|
designator=designator,
|
||||||
|
mpn="",
|
||||||
|
aspect="protocol_l2",
|
||||||
|
finding=text,
|
||||||
|
why=text,
|
||||||
|
status=status, # type: ignore[arg-type]
|
||||||
|
source=SOURCE,
|
||||||
|
net=r.nets[0] if r.nets else None,
|
||||||
|
rule_id="PE-PRT-L2-002",
|
||||||
|
facts=text,
|
||||||
|
requirement=text,
|
||||||
|
inference="Calculated differential Z compared with the packed cite.",
|
||||||
|
provenance="MANDATORY",
|
||||||
|
finding_class=cls, # type: ignore[arg-type]
|
||||||
|
evidence_status="SUFFICIENT",
|
||||||
|
recommendation=action,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
return complete_finding(f)
|
||||||
|
|
||||||
|
|
||||||
def l2_findings(inst: PhysicalBusInstance, results: list[L2CheckResult]) -> list[Finding]:
|
def l2_findings(inst: PhysicalBusInstance, results: list[L2CheckResult]) -> list[Finding]:
|
||||||
out: list[Finding] = []
|
out: list[Finding] = []
|
||||||
designator = inst.host_ref or inst.physical_interface_id
|
designator = inst.host_ref or inst.physical_interface_id
|
||||||
for r in results:
|
for r in results:
|
||||||
|
if r.emit in {"certified", "not_certified"}:
|
||||||
|
out.append(_pair_line_finding(inst, r))
|
||||||
|
continue
|
||||||
|
if r.emit == "silent" or (r.result in {
|
||||||
|
"UNKNOWN", "VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT",
|
||||||
|
"PHY_DEPENDENT", "MISSING_SOURCE",
|
||||||
|
} and r.check in _NOISE_CHECKS):
|
||||||
|
continue
|
||||||
if r.result == "PASS":
|
if r.result == "PASS":
|
||||||
continue
|
continue
|
||||||
if r.result == "FAIL" and r.mandatory != "MANDATORY":
|
if r.result == "FAIL" and r.mandatory != "MANDATORY":
|
||||||
@@ -568,8 +842,7 @@ def l2_findings(inst: PhysicalBusInstance, results: list[L2CheckResult]) -> list
|
|||||||
rule_id = "PE-PRT-L2-001"
|
rule_id = "PE-PRT-L2-001"
|
||||||
status = "WARNING"
|
status = "WARNING"
|
||||||
finding = r.notes or (
|
finding = r.notes or (
|
||||||
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
|
f"{inst.physical_interface_id} L2 {r.check} is {r.result}."
|
||||||
f"per {r.check} ({r.result})."
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
rule_id = "PE-PRT-L0-002"
|
rule_id = "PE-PRT-L0-002"
|
||||||
|
|||||||
@@ -509,9 +509,28 @@ def certify_instance_l3(
|
|||||||
if c in L3_CHECKS and c not in checks:
|
if c in L3_CHECKS and c not in checks:
|
||||||
checks.append(c)
|
checks.append(c)
|
||||||
facts = lookup_channel_facts(inst, channel_data)
|
facts = lookup_channel_facts(inst, channel_data)
|
||||||
|
if not _channel_fact_present(facts):
|
||||||
|
return []
|
||||||
return [_run_l3_check(inst, iface, c, facts) for c in checks]
|
return [_run_l3_check(inst, iface, c, facts) for c in checks]
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_fact_present(facts: dict[str, Any]) -> bool:
|
||||||
|
"""True only when reduced channel FACT is already on hand."""
|
||||||
|
if not facts:
|
||||||
|
return False
|
||||||
|
if _looks_like_raw_sparam(facts):
|
||||||
|
return False
|
||||||
|
if _blob_mentions_fem(facts.get("method") or facts.get("solver") or ""):
|
||||||
|
return False
|
||||||
|
if _il_measured(facts) is not None or _rl_measured(facts) is not None:
|
||||||
|
return True
|
||||||
|
if _xt_measured(facts) is not None:
|
||||||
|
return True
|
||||||
|
if _num(facts.get("total_budget_ps")) is not None:
|
||||||
|
return True
|
||||||
|
return facts.get("channel_complete") is True
|
||||||
|
|
||||||
|
|
||||||
def l3_findings(inst: PhysicalBusInstance, results: list[L3CheckResult]) -> list[Finding]:
|
def l3_findings(inst: PhysicalBusInstance, results: list[L3CheckResult]) -> list[Finding]:
|
||||||
out: list[Finding] = []
|
out: list[Finding] = []
|
||||||
designator = inst.host_ref or inst.physical_interface_id
|
designator = inst.host_ref or inst.physical_interface_id
|
||||||
@@ -533,12 +552,8 @@ def l3_findings(inst: PhysicalBusInstance, results: list[L3CheckResult]) -> list
|
|||||||
status = "ERROR"
|
status = "ERROR"
|
||||||
finding = f"L3 channel {r.check}: {r.notes}"
|
finding = f"L3 channel {r.check}: {r.notes}"
|
||||||
elif skip_family:
|
elif skip_family:
|
||||||
rule_id = "PE-PRT-L3-001"
|
# No channel FACT, or a field with nothing to certify. Omit the warning.
|
||||||
status = "WARNING"
|
continue
|
||||||
finding = r.notes or (
|
|
||||||
f"Interfaccia {inst.physical_interface_id} non certificata a L3 "
|
|
||||||
f"per {r.check} ({r.result})."
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
rule_id = "PE-PRT-L0-002"
|
rule_id = "PE-PRT-L0-002"
|
||||||
status = "INFO"
|
status = "INFO"
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ def check_to_report_row(
|
|||||||
result = str(dump.get("result") or "")
|
result = str(dump.get("result") or "")
|
||||||
cons = _constraint_for(iface, check) if iface is not None else None
|
cons = _constraint_for(iface, check) if iface is not None else None
|
||||||
doc, section, method = _cite(cons)
|
doc, section, method = _cite(cons)
|
||||||
|
if check == "differential_impedance" and not doc and iface is not None:
|
||||||
|
for alt in iface.constraints:
|
||||||
|
if alt.parameter == "pcb_trace_nominal_zdiff" and alt.source is not None:
|
||||||
|
doc, section, method = _cite(alt)
|
||||||
|
cons = alt
|
||||||
|
break
|
||||||
nets = list(dump.get("nets") or [])
|
nets = list(dump.get("nets") or [])
|
||||||
net = nets[0] if nets else (inst.nets[0] if inst.nets else "")
|
net = nets[0] if nets else (inst.nets[0] if inst.nets else "")
|
||||||
group = _group_for_net(inst, net or None)
|
group = _group_for_net(inst, net or None)
|
||||||
@@ -130,6 +136,7 @@ def check_to_report_row(
|
|||||||
"source": doc,
|
"source": doc,
|
||||||
"method": method,
|
"method": method,
|
||||||
"notes": dump.get("notes") or "",
|
"notes": dump.get("notes") or "",
|
||||||
|
"emit": dump.get("emit") or "",
|
||||||
"skip_visible": skip,
|
"skip_visible": skip,
|
||||||
"rank": result_rank(result),
|
"rank": result_rank(result),
|
||||||
"chain": {
|
"chain": {
|
||||||
@@ -181,6 +188,21 @@ def format_chain(chain: dict[str, Any]) -> str:
|
|||||||
return " → ".join(parts)
|
return " → ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _noise_row(row: dict[str, Any]) -> bool:
|
||||||
|
"""Missing-field skips are not a certification line."""
|
||||||
|
if row.get("emit") == "silent":
|
||||||
|
return True
|
||||||
|
result = str(row.get("result") or "")
|
||||||
|
if result not in _SKIP:
|
||||||
|
return False
|
||||||
|
notes = str(row.get("notes") or "").lower()
|
||||||
|
if "grouping" in notes or "dq/dqs" in notes:
|
||||||
|
return False
|
||||||
|
if row.get("measured") is not None or row.get("limit") is not None:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def attach_instance_report(
|
def attach_instance_report(
|
||||||
inst: PhysicalBusInstance,
|
inst: PhysicalBusInstance,
|
||||||
iface: PhysicalInterface | None,
|
iface: PhysicalInterface | None,
|
||||||
@@ -195,9 +217,7 @@ def attach_instance_report(
|
|||||||
+ [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 l2]
|
||||||
+ [c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in (l3 or [])]
|
+ [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]
|
rows = [r for r in (check_to_report_row(d, inst, iface) for d in dumps) if not _noise_row(r)]
|
||||||
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"])))
|
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"])
|
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
|
# L3 skip/UNKNOWN must not upgrade instance to PASS or hide FAIL
|
||||||
|
|||||||
@@ -42,7 +42,12 @@ _HS_CLASS_RE = (
|
|||||||
("hdmi", re.compile(r"HDMI", re.I)),
|
("hdmi", re.compile(r"HDMI", re.I)),
|
||||||
("pcie", re.compile(r"PCIE|PEX_", re.I)),
|
("pcie", re.compile(r"PCIE|PEX_", re.I)),
|
||||||
("sgmii", re.compile(r"SGMII", re.I)),
|
("sgmii", re.compile(r"SGMII", re.I)),
|
||||||
("rgmii", re.compile(r"RGMII|(?:^|[_/])GMII", re.I)),
|
("rgmii", re.compile(r"RGMII|(?:^|[_/])GMII|TX_CTL|RX_CTL|(?:ETH|MAC).*GTX", re.I)),
|
||||||
|
("rmii", re.compile(
|
||||||
|
r"(?:^|[_/])RMII|"
|
||||||
|
r"(?:ETH|MAC).*(?:TXD|RXD|TXEN|RXDV|CRS_DV|REF_CLK)",
|
||||||
|
re.I,
|
||||||
|
)),
|
||||||
("eth_mdi", re.compile(
|
("eth_mdi", re.compile(
|
||||||
r"(?:^|[_/])(MDI|TRD[0-3]|TCT|RCT)|"
|
r"(?:^|[_/])(MDI|TRD[0-3]|TCT|RCT)|"
|
||||||
r"ETH.?(?:TD|RD|TX|RX|TP)[+\-_PN0-3]|1000BASE|RJ45",
|
r"ETH.?(?:TD|RD|TX|RX|TP)[+\-_PN0-3]|1000BASE|RJ45",
|
||||||
@@ -92,8 +97,10 @@ _BUS_TOKEN_EXPAND: dict[str, frozenset[str]] = {
|
|||||||
"magnetics": frozenset({"eth_mdi"}),
|
"magnetics": frozenset({"eth_mdi"}),
|
||||||
"rgmii": frozenset({"rgmii"}),
|
"rgmii": frozenset({"rgmii"}),
|
||||||
"gmii": frozenset({"rgmii"}),
|
"gmii": frozenset({"rgmii"}),
|
||||||
"mac_phy": frozenset({"rgmii", "sgmii"}),
|
"rmii": frozenset({"rmii"}),
|
||||||
"mac": frozenset({"rgmii", "sgmii"}),
|
"mii": frozenset({"rmii"}),
|
||||||
|
"mac_phy": frozenset({"rmii", "rgmii", "sgmii"}),
|
||||||
|
"mac": frozenset({"rmii", "rgmii", "sgmii"}),
|
||||||
"sgmii": frozenset({"sgmii"}),
|
"sgmii": frozenset({"sgmii"}),
|
||||||
"ddr": frozenset({"ddr3_clk", "ddr3_dqs", "ddr3_dq", "ddr3_addr"}),
|
"ddr": frozenset({"ddr3_clk", "ddr3_dqs", "ddr3_dq", "ddr3_addr"}),
|
||||||
"ddr3": frozenset({"ddr3_clk", "ddr3_dqs", "ddr3_dq", "ddr3_addr"}),
|
"ddr3": frozenset({"ddr3_clk", "ddr3_dqs", "ddr3_dq", "ddr3_addr"}),
|
||||||
@@ -143,6 +150,31 @@ def partner_net(name: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# RMII/MII data and TX enable. Not an MDI pair. Not a 100 Ω differential net.
|
||||||
|
_SINGLE_ENDED_ETH_RE = re.compile(
|
||||||
|
r"(?:^|[^A-Za-z0-9])(?:ETH_|MAC_)?(?:RXD|TXD|TXEN|RXDV|CRS_DV)(?:\d+)?(?:[^A-Za-z0-9]|$)",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def single_ended_eth_mac(net: str) -> bool:
|
||||||
|
"""ETH_RXD / ETH_TXD / ETH_TXEN are single-ended. Not a differential pair."""
|
||||||
|
leaf = _leaf(net)
|
||||||
|
return bool(_SINGLE_ENDED_ETH_RE.search(leaf) or _SINGLE_ENDED_ETH_RE.search(net or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def phy_mac_kind(net: str) -> str | None:
|
||||||
|
"""``rmii``, ``rgmii``, or ``mii`` for a PHY–MAC net. Never ``eth_mdi``."""
|
||||||
|
leaf = _leaf(net).upper()
|
||||||
|
if re.search(r"RGMII|(?:^|[_/])GMII|TX_CTL|RX_CTL|(?:ETH|MAC).*GTX", leaf):
|
||||||
|
return "rgmii"
|
||||||
|
if single_ended_eth_mac(net) or "RMII" in leaf:
|
||||||
|
if re.search(r"(?:^|[_/])MII(?:$|[_/])", leaf) and "RMII" not in leaf:
|
||||||
|
return "mii"
|
||||||
|
return "rmii"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _leaf(net: str) -> str:
|
def _leaf(net: str) -> str:
|
||||||
n = normalize_kicad_hierarchy_net(net)
|
n = normalize_kicad_hierarchy_net(net)
|
||||||
return n.split("/")[-1] if n else ""
|
return n.split("/")[-1] if n else ""
|
||||||
@@ -173,8 +205,10 @@ def bus_class(net: str) -> str | None:
|
|||||||
return "usb3"
|
return "usb3"
|
||||||
if "USB" in u and re.search(r"(D\+|D-|DP|DM)", leaf, re.I):
|
if "USB" in u and re.search(r"(D\+|D-|DP|DM)", leaf, re.I):
|
||||||
return "usb2"
|
return "usb2"
|
||||||
if re.search(r"(?:ETH|MAC).*(TXD|RXD|TXC|RXC|TX_CLK|RX_CLK|TX_CTL|RX_CTL|TXEN|RXDV|GTX)", u):
|
if re.search(r"RGMII|(?:^|[_/])GMII|TX_CTL|RX_CTL|(?:ETH|MAC).*GTX", u):
|
||||||
return "rgmii"
|
return "rgmii"
|
||||||
|
if re.search(r"(?:ETH|MAC).*(TXD|RXD|TXC|RXC|TX_CLK|RX_CLK|TXEN|RXDV|CRS_DV|REF_CLK)|(?:^|[_/])RMII", u):
|
||||||
|
return "rmii"
|
||||||
if re.search(r"(?:^|[_/])ETH(?:$|[_/])", u) and re.search(r"[+\-]|_P$|_N$|_P/|_N/", leaf):
|
if re.search(r"(?:^|[_/])ETH(?:$|[_/])", u) and re.search(r"[+\-]|_P$|_N$|_P/|_N/", leaf):
|
||||||
return "eth_mdi"
|
return "eth_mdi"
|
||||||
for cls, cre in _HS_CLASS_RE:
|
for cls, cre in _HS_CLASS_RE:
|
||||||
@@ -228,6 +262,7 @@ def _rule_target_buses(rule: dict) -> frozenset[str]:
|
|||||||
scans: tuple[tuple[str, str], ...] = (
|
scans: tuple[tuple[str, str], ...] = (
|
||||||
(r"super\s*speed|usb\s*3|sstx|ssrx", "usb3"),
|
(r"super\s*speed|usb\s*3|sstx|ssrx", "usb3"),
|
||||||
(r"rgmii|gtx_clk|tx_ctl|rx_ctl", "rgmii"),
|
(r"rgmii|gtx_clk|tx_ctl|rx_ctl", "rgmii"),
|
||||||
|
(r"\brmii\b|\bmii\b|txen|rxdv|crs_dv", "rmii"),
|
||||||
(r"sgmii", "sgmii"),
|
(r"sgmii", "sgmii"),
|
||||||
(r"mdi|rj-?45|magnetics|trd[0-3]|1000\s*base", "eth_mdi"),
|
(r"mdi|rj-?45|magnetics|trd[0-3]|1000\s*base", "eth_mdi"),
|
||||||
(r"ddr3|\bddr\b", "ddr3"),
|
(r"ddr3|\bddr\b", "ddr3"),
|
||||||
@@ -358,6 +393,8 @@ def _rule_nets(layout: LayoutGraph, rule: dict, graph: DesignGraph) -> list[str]
|
|||||||
if targets:
|
if targets:
|
||||||
if not _bus_in_targets(bc, targets):
|
if not _bus_in_targets(bc, targets):
|
||||||
continue
|
continue
|
||||||
|
if single_ended_eth_mac(net) and "eth_mdi" in targets:
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
# No bus on the quote: pin-scoped only. Never paint USB/DDR/PHY.
|
# No bus on the quote: pin-scoped only. Never paint USB/DDR/PHY.
|
||||||
if not pin:
|
if not pin:
|
||||||
@@ -530,6 +567,8 @@ def check_si(
|
|||||||
want_diff = _num(rule.get("zdiff_ohm")) is not None or kind == "zdiff"
|
want_diff = _num(rule.get("zdiff_ohm")) is not None or kind == "zdiff"
|
||||||
paired: set[tuple[str, str]] = set()
|
paired: set[tuple[str, str]] = set()
|
||||||
for net in nets:
|
for net in nets:
|
||||||
|
if want_diff and single_ended_eth_mac(net):
|
||||||
|
continue
|
||||||
zrow = _z_row(rows, net)
|
zrow = _z_row(rows, net)
|
||||||
partner = _partner_on_board(layout, net, zrow) if want_diff else None
|
partner = _partner_on_board(layout, net, zrow) if want_diff else None
|
||||||
key_net = _pair_key(net, partner) if partner else (net, "")
|
key_net = _pair_key(net, partner) if partner else (net, "")
|
||||||
|
|||||||
@@ -154,16 +154,15 @@ def si_extract_needed_skips(
|
|||||||
}
|
}
|
||||||
if not needs_layout_rules_refresh(payload, min_scan_version=scan_ver):
|
if not needs_layout_rules_refresh(payload, min_scan_version=scan_ver):
|
||||||
continue
|
continue
|
||||||
refs = ",".join(sorted(refs_by_mpn.get(mpn, []))) or mpn
|
refs = sorted(refs_by_mpn.get(mpn, [])) or [mpn]
|
||||||
out.append({
|
reason = (
|
||||||
"designator": refs,
|
|
||||||
"reason": (
|
|
||||||
f"SI layout_rules empty at model_version="
|
f"SI layout_rules empty at model_version="
|
||||||
f"{cons.model_version}; re-run schematic review "
|
f"{cons.model_version}; re-run schematic review "
|
||||||
f"to re-extract pintable {scan_ver} "
|
f"to re-extract pintable {scan_ver} "
|
||||||
"(PCB does not re-read the PDF)."
|
"(PCB does not re-read the PDF)."
|
||||||
),
|
)
|
||||||
})
|
for ref in refs:
|
||||||
|
out.append({"designator": ref, "reason": reason})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -373,6 +372,7 @@ async def run_pcb_pipeline(
|
|||||||
_step(project_id, "ai_review", "running", "layout vs shared library extraction")
|
_step(project_id, "ai_review", "running", "layout vs shared library extraction")
|
||||||
coverage: dict[str, list[str]] = {}
|
coverage: dict[str, list[str]] = {}
|
||||||
skipped: list[dict] = []
|
skipped: list[dict] = []
|
||||||
|
review_errors: dict[str, str] = {}
|
||||||
try:
|
try:
|
||||||
from backend.services.api_logs import ApiLogger
|
from backend.services.api_logs import ApiLogger
|
||||||
from backend.services.pcb_validation import review_pcb_ics
|
from backend.services.pcb_validation import review_pcb_ics
|
||||||
@@ -387,7 +387,7 @@ async def run_pcb_pipeline(
|
|||||||
f"{ref} {tool} {detail}".strip(),
|
f"{ref} {tool} {detail}".strip(),
|
||||||
)
|
)
|
||||||
|
|
||||||
ai_findings, coverage, skipped = await review_pcb_ics(
|
ai_findings, coverage, skipped, review_errors = await review_pcb_ics(
|
||||||
graph, cmap, layout, plan, inventory,
|
graph, cmap, layout, plan, inventory,
|
||||||
pdf_dir, storage=storage, api_logger=logger_api,
|
pdf_dir, storage=storage, api_logger=logger_api,
|
||||||
on_progress=_prog,
|
on_progress=_prog,
|
||||||
@@ -396,6 +396,7 @@ async def run_pcb_pipeline(
|
|||||||
logger_api.flush(storage, user_id, project_id)
|
logger_api.flush(storage, user_id, project_id)
|
||||||
detail = (
|
detail = (
|
||||||
f"{len(ai_findings)} AI findings, {len(skipped)} skipped"
|
f"{len(ai_findings)} AI findings, {len(skipped)} skipped"
|
||||||
|
+ (f", {len(review_errors)} review errors" if review_errors else "")
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("PCB AI review failed — keeping deterministic findings")
|
logger.exception("PCB AI review failed — keeping deterministic findings")
|
||||||
@@ -427,13 +428,20 @@ async def run_pcb_pipeline(
|
|||||||
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
|
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
|
||||||
for f in findings:
|
for f in findings:
|
||||||
summary[f.status] = summary.get(f.status, 0) + 1
|
summary[f.status] = summary.get(f.status, 0) + 1
|
||||||
|
# Drop not_reviewed entries for ICs that were actually reviewed.
|
||||||
|
covered = set(coverage)
|
||||||
|
not_reviewed = [
|
||||||
|
row for row in (skipped + si_skip)
|
||||||
|
if str(row.get("designator") or "") not in covered
|
||||||
|
]
|
||||||
report = ValidationReport(
|
report = ValidationReport(
|
||||||
project=project_id,
|
project=project_id,
|
||||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||||
findings=findings,
|
findings=findings,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
coverage=coverage,
|
coverage=coverage,
|
||||||
not_reviewed=skipped + si_skip,
|
review_errors=review_errors,
|
||||||
|
not_reviewed=not_reviewed,
|
||||||
protocol_certification=proto_section.model_dump(),
|
protocol_certification=proto_section.model_dump(),
|
||||||
)
|
)
|
||||||
report_path = ws.local_path("pcb_report.json")
|
report_path = ws.local_path("pcb_report.json")
|
||||||
|
|||||||
@@ -54,15 +54,20 @@ async def review_pcb_ics(
|
|||||||
storage=None,
|
storage=None,
|
||||||
api_logger: ApiLogger | None = None,
|
api_logger: ApiLogger | None = None,
|
||||||
on_progress=None,
|
on_progress=None,
|
||||||
) -> tuple[list[Finding], dict[str, list[str]], list[dict]]:
|
) -> tuple[list[Finding], dict[str, list[str]], list[dict], dict[str, str]]:
|
||||||
"""Layout-only AI exam using ``library/extracted`` (or project extracted/).
|
"""Layout-only AI exam using ``library/extracted`` (or project extracted/).
|
||||||
|
|
||||||
Does not attach a datasheet PDF. ICs without a library pintable are skipped
|
Does not attach a datasheet PDF. ICs without a library pintable are skipped
|
||||||
with a reason to run schematic review first.
|
with a reason to run schematic review first.
|
||||||
|
|
||||||
|
Returns ``(findings, coverage, not_reviewed, review_errors)``.
|
||||||
|
Real review exceptions go in ``review_errors`` — never as
|
||||||
|
``not_reviewed`` with the blanket label ``pcb_review error``.
|
||||||
"""
|
"""
|
||||||
findings: list[Finding] = []
|
findings: list[Finding] = []
|
||||||
coverage: dict[str, list[str]] = {}
|
coverage: dict[str, list[str]] = {}
|
||||||
skipped: list[dict] = []
|
skipped: list[dict] = []
|
||||||
|
review_errors: dict[str, str] = {}
|
||||||
cache: dict = {}
|
cache: dict = {}
|
||||||
for ref, comp in sorted(graph.components.items()):
|
for ref, comp in sorted(graph.components.items()):
|
||||||
if comp.component_type != ComponentType.IC:
|
if comp.component_type != ComponentType.IC:
|
||||||
@@ -102,15 +107,21 @@ async def review_pcb_ics(
|
|||||||
system_prompt=PCB_SYSTEM_PROMPT,
|
system_prompt=PCB_SYSTEM_PROMPT,
|
||||||
log_stage="pcb_review",
|
log_stage="pcb_review",
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
log.exception("PCB AI review failed for %s — skipping", ref)
|
msg = f"{type(exc).__name__}: {exc}"
|
||||||
skipped.append({"designator": ref, "reason": "pcb_review error"})
|
log.exception("PCB AI review failed for %s — recording review_errors", ref)
|
||||||
|
review_errors[ref] = msg
|
||||||
continue
|
continue
|
||||||
if not isinstance(result, ReviewResult):
|
if not isinstance(result, ReviewResult):
|
||||||
|
# Prefer a visible false positive over a silent miss.
|
||||||
|
skipped.append({
|
||||||
|
"designator": ref,
|
||||||
|
"reason": "PCB review returned no result",
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
_ensure_recs(result.findings)
|
_ensure_recs(result.findings)
|
||||||
annotate_findings_cad(result.findings, cad_index_from_graph(graph))
|
annotate_findings_cad(result.findings, cad_index_from_graph(graph))
|
||||||
findings.extend(result.findings)
|
findings.extend(result.findings)
|
||||||
if result.checked_areas:
|
# Empty checked_areas still means the IC was reviewed (no issues).
|
||||||
coverage[ref] = list(result.checked_areas)
|
coverage[ref] = list(result.checked_areas or ["layout"])
|
||||||
return findings, coverage, skipped
|
return findings, coverage, skipped, review_errors
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
What's new in Periscope.
|
What's new in Periscope.
|
||||||
|
|
||||||
|
## 2.85.0 — 2026-09-23 — USB / RMII cert lines; not_reviewed count
|
||||||
|
|
||||||
|
USB 2.0 D+/D− is one line: calculated pair Z versus the packed 90 Ω board cite. Certified names the measured ohm and the cite. Not certified says why — Z is outside that cite, or Z cannot be calculated from the PCB stackup/geometry. Missing length, stub, via, termination, rise/fall, and channel fields do not each warn. L3 is omitted when no channel fact is on file.
|
||||||
|
|
||||||
|
LAN8720A ETH_RXD / ETH_TXD / ETH_TXEN are RMII (single-ended PHY–MAC), not RJ45/MDI 100 Ω. One line: `RMII certified — …`. No Italian «mancanza di tr, f» on those nets. RJ45 pairs keep `Ethernet certified / not certified`.
|
||||||
|
|
||||||
|
`not_reviewed` count matches the list (one designator per row). Real PCB AI failures go to `review_errors` with the exception message — not a blanket `pcb_review error` on every skip. ICs already in coverage are not listed as not-reviewed.
|
||||||
|
|
||||||
|
- [Changed] `protocol_l1.py`, `protocol_l2.py`, `protocol_l3.py`, `protocol_report.py`, `af_trace_check.py`, `si_check.py`.
|
||||||
|
- [Changed] `pcb_validation.py`, `pcb_pipeline.py`, `pcb_checks.py` merge; report UI copy.
|
||||||
|
- [Changed] Protocol tree shows the certification sentence.
|
||||||
|
- [Changed] pytest `tests/pcb/test_j2_usb_cert_line.py`, `test_rmii_cert_line.py`, `test_not_reviewed_count.py`.
|
||||||
|
|
||||||
## 2.84.0 — 2026-09-22 — Exposed-pad names, ESD cites, CC-via-net, Ethernet pair Z
|
## 2.84.0 — 2026-09-22 — Exposed-pad names, ESD cites, CC-via-net, Ethernet pair Z
|
||||||
|
|
||||||
PE-BOM-011 treats EP, EPAD, THERMAL PAD, exposed pad, and the footprint's exposed land (including pin_count+1) as the same pad when both sides have one. A datasheet exposed pad with no land on the footprint stays ERROR.
|
PE-BOM-011 treats EP, EPAD, THERMAL PAD, exposed pad, and the footprint's exposed land (including pin_count+1) as the same pad when both sides have one. A datasheet exposed pad with no land on the footprint stays ERROR.
|
||||||
|
|||||||
@@ -328,7 +328,10 @@ function ReportContent({ projectId }: { projectId: string }) {
|
|||||||
{report.not_reviewed.length} component{report.not_reviewed.length === 1 ? "" : "s"} not reviewed
|
{report.not_reviewed.length} component{report.not_reviewed.length === 1 ? "" : "s"} not reviewed
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
These components have no datasheet on file, so they were not checked against one. A reversed or mis-wired pin on an unreviewed part (e.g. a DNP footprint with no BOM entry) cannot be caught here — verify these manually.
|
Each line is one designator. The reason is per part — missing
|
||||||
|
datasheet, missing library extraction, or SI re-extract needed.
|
||||||
|
Parts that were reviewed are not listed here. Review failures
|
||||||
|
(with an error message) appear under failed reviews above.
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-1 text-xs">
|
<ul className="space-y-1 text-xs">
|
||||||
{report.not_reviewed.map((nr) => (
|
{report.not_reviewed.map((nr) => (
|
||||||
|
|||||||
@@ -21,8 +21,16 @@ export function ProtocolInstanceBody({ instance }: { instance: ProtocolTreeInsta
|
|||||||
const fail = instance.worst === "FAIL" || instance.status === "ERROR";
|
const fail = instance.worst === "FAIL" || instance.status === "ERROR";
|
||||||
const logical = instance.logicalId;
|
const logical = instance.logicalId;
|
||||||
const physical = instance.physicalId;
|
const physical = instance.physicalId;
|
||||||
|
const certLine = checks.find((c) =>
|
||||||
|
/^(USB|Ethernet|RMII|MII|RGMII) (certified|not certified) —/.test(c.notes || ""),
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 py-2 pr-2">
|
<div className="space-y-2 py-2 pr-2">
|
||||||
|
{certLine?.notes ? (
|
||||||
|
<p className="text-[13px] font-medium tracking-tight text-neutral-900 dark:text-neutral-100">
|
||||||
|
{certLine.notes}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<p className="text-[11px] tabular-nums text-neutral-500">
|
<p className="text-[11px] tabular-nums text-neutral-500">
|
||||||
{logical} → {physical}
|
{logical} → {physical}
|
||||||
{instance.recognition ? ` · ${instance.recognition}` : ""}
|
{instance.recognition ? ` · ${instance.recognition}` : ""}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ test("protocol FAIL finding stays in Error folder with WARNING skips on same ref
|
|||||||
status: "WARNING",
|
status: "WARNING",
|
||||||
rule_id: "PE-PRT-L2-001",
|
rule_id: "PE-PRT-L2-001",
|
||||||
source: "protocol_l2",
|
source: "protocol_l2",
|
||||||
finding: "non certificata a L2 per mancanza di Z",
|
finding: "USB not certified — because Z cannot be calculated from the PCB stackup/geometry. Cited 90 Ω.",
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
assert.equal(groups[0].label, "Error");
|
assert.equal(groups[0].label, "Error");
|
||||||
|
|||||||
@@ -142,12 +142,14 @@ def test_missing_tr_f_is_visible_skip_no_ohm():
|
|||||||
assert all(f.rule_id == "PE-AF-002" for f in out)
|
assert all(f.rule_id == "PE-AF-002" for f in out)
|
||||||
assert len(out) == 1
|
assert len(out) == 1
|
||||||
text = out[0].finding
|
text = out[0].finding
|
||||||
assert "Pista ad alta frequenza non controllata per mancanza di" in text
|
assert text.startswith("HF not certified — because")
|
||||||
assert "tr" in text and "f" in text
|
assert "tr" in text and "f" in text
|
||||||
|
assert "mancanza" not in text
|
||||||
assert out[0].evidence_status == "INSUFFICIENT"
|
assert out[0].evidence_status == "INSUFFICIENT"
|
||||||
assert out[0].finding_class == "REVIEW"
|
assert out[0].finding_class == "REVIEW"
|
||||||
blob = (out[0].finding + out[0].facts).lower()
|
blob = (out[0].finding + out[0].facts).lower()
|
||||||
assert "90" not in blob and "50" not in blob and "ω" not in blob and "ohm" not in blob
|
assert "90 Ω" not in blob and "50 Ω" not in blob
|
||||||
|
assert "invented" in blob
|
||||||
|
|
||||||
|
|
||||||
def test_short_pair_with_tr_is_not_af():
|
def test_short_pair_with_tr_is_not_af():
|
||||||
|
|||||||
@@ -328,11 +328,12 @@ def test_lan8720_pair_without_z_is_error_with_100base_cite(monkeypatch):
|
|||||||
f = findings[0]
|
f = findings[0]
|
||||||
assert f.status == "ERROR"
|
assert f.status == "ERROR"
|
||||||
blob = f"{f.finding} {f.action} {f.requirement}"
|
blob = f"{f.finding} {f.action} {f.requirement}"
|
||||||
assert blob.startswith("ERROR:") or "ERROR:" in f.finding
|
assert f.finding.startswith("Ethernet not certified — because Z cannot be calculated")
|
||||||
assert "100" in blob and "25.4.9" in blob
|
assert "100" in blob and "25.4.9" in blob
|
||||||
assert "stackup" in blob
|
assert "stackup" in blob
|
||||||
assert "Fornire" not in blob
|
assert "Fornire" not in blob
|
||||||
assert "tr" not in blob.lower()
|
assert "mancanza di tr" not in blob.lower()
|
||||||
|
assert "mancanza di f" not in blob.lower()
|
||||||
assert "90" not in blob
|
assert "90" not in blob
|
||||||
assert "Clause 40" not in blob
|
assert "Clause 40" not in blob
|
||||||
assert "1000BASE" not in blob
|
assert "1000BASE" not in blob
|
||||||
@@ -342,16 +343,17 @@ def test_lan8720_pair_without_z_is_error_with_100base_cite(monkeypatch):
|
|||||||
def test_lan8720_pair_z_inside_cite_is_pass(monkeypatch):
|
def test_lan8720_pair_z_inside_cite_is_pass(monkeypatch):
|
||||||
findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, 100.0)
|
findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, 100.0)
|
||||||
assert len(findings) == 1
|
assert len(findings) == 1
|
||||||
assert findings[0].finding.startswith("PASS:")
|
assert findings[0].finding.startswith("Ethernet certified —")
|
||||||
assert findings[0].status == "INFO"
|
assert findings[0].status == "INFO"
|
||||||
assert "RJ45_TXP" in findings[0].finding and "RJ45_TXN" in findings[0].finding
|
assert "RJ45_TXP" in (findings[0].facts or "") or "RJ45_TXP" in findings[0].finding
|
||||||
|
assert "RJ45_TXN" in (findings[0].facts or "") or "RJ45_TXN" in findings[0].finding
|
||||||
assert "25.4.9" in findings[0].finding
|
assert "25.4.9" in findings[0].finding
|
||||||
|
|
||||||
|
|
||||||
def test_lan8720_pair_z_outside_cite_is_fail(monkeypatch):
|
def test_lan8720_pair_z_outside_cite_is_fail(monkeypatch):
|
||||||
findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, 90.0)
|
findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, 90.0)
|
||||||
assert len(findings) == 1
|
assert len(findings) == 1
|
||||||
assert findings[0].finding.startswith("FAIL:")
|
assert findings[0].finding.startswith("Ethernet not certified — because Z")
|
||||||
assert findings[0].status == "ERROR"
|
assert findings[0].status == "ERROR"
|
||||||
assert "PASS" not in findings[0].finding
|
assert "PASS" not in findings[0].finding
|
||||||
assert "25.4.9" in findings[0].finding
|
assert "25.4.9" in findings[0].finding
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""USB Type-C D+/D− one-line certification; J2 has no WARNING wall."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend.periscopex.models import (
|
||||||
|
Component,
|
||||||
|
ComponentType,
|
||||||
|
DesignGraph,
|
||||||
|
LayoutDielectric,
|
||||||
|
LayoutGraph,
|
||||||
|
LayoutSegment,
|
||||||
|
LayoutStackup,
|
||||||
|
Net,
|
||||||
|
NetType,
|
||||||
|
PinConnection,
|
||||||
|
ResistorSpecs,
|
||||||
|
)
|
||||||
|
from backend.periscopex.protocol_l0 import protocol_exam
|
||||||
|
from backend.periscopex.protocol_l2 import certify_l2, usb_pair_z_ohm
|
||||||
|
from backend.periscopex.protocol_recognize import recognize_physical_buses
|
||||||
|
|
||||||
|
|
||||||
|
def _ic(ref: str, pins: dict[str, str], *, mpn: str = "") -> Component:
|
||||||
|
return Component(
|
||||||
|
reference=ref, value=mpn or ref, 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_c_graph() -> DesignGraph:
|
||||||
|
return DesignGraph(
|
||||||
|
components={
|
||||||
|
"U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}, mpn="CH340E"),
|
||||||
|
"J2": Component(
|
||||||
|
reference="J2", value="USB_C",
|
||||||
|
footprint="Connector_USB:USB_C_Receptacle",
|
||||||
|
component_type=ComponentType.CONNECTOR,
|
||||||
|
pins={"A6": "USB_D+", "A7": "USB_D-", "A5": "CC1", "B5": "CC2",
|
||||||
|
"A4": "VBUS", "A1": "GND"},
|
||||||
|
),
|
||||||
|
"R1": Component(
|
||||||
|
reference="R1", value="5.1k", footprint="",
|
||||||
|
component_type=ComponentType.RESISTOR,
|
||||||
|
pins={"1": "CC1", "2": "GND"},
|
||||||
|
specs=ResistorSpecs(value_ohms=5100.0, value_formatted="5.1k"),
|
||||||
|
),
|
||||||
|
"R2": Component(
|
||||||
|
reference="R2", value="5.1k", footprint="",
|
||||||
|
component_type=ComponentType.RESISTOR,
|
||||||
|
pins={"1": "CC2", "2": "GND"},
|
||||||
|
specs=ResistorSpecs(value_ohms=5100.0, value_formatted="5.1k"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={
|
||||||
|
"USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "A6")),
|
||||||
|
"USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "A7")),
|
||||||
|
"CC1": _net("CC1", ("J2", "A5"), ("R1", "1")),
|
||||||
|
"CC2": _net("CC2", ("J2", "B5"), ("R2", "1")),
|
||||||
|
"VBUS": _net("VBUS", ("J2", "A4")),
|
||||||
|
"GND": Net(
|
||||||
|
name="GND", net_type=NetType.GROUND,
|
||||||
|
pins=[
|
||||||
|
PinConnection(component_ref="J2", pin_number="A1"),
|
||||||
|
PinConnection(component_ref="R1", pin_number="2"),
|
||||||
|
PinConnection(component_ref="R2", pin_number="2"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stack() -> LayoutStackup:
|
||||||
|
return LayoutStackup(
|
||||||
|
copper_layers=["F.Cu", "B.Cu"],
|
||||||
|
dielectrics=[LayoutDielectric(name="core", er=4.5, height_mm=0.15)],
|
||||||
|
copper_thickness_mm=0.035,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_usb_missing_z_is_one_not_certified_line():
|
||||||
|
sec, findings = protocol_exam(_usb_c_graph())
|
||||||
|
usb = [i for i in sec.recognized_instances if "usb" in (i.get("logical_protocol_id") or "")]
|
||||||
|
assert usb
|
||||||
|
notes = [
|
||||||
|
c.get("notes") or ""
|
||||||
|
for row in usb
|
||||||
|
for c in (row.get("report_checks") or row.get("l2_checks") or [])
|
||||||
|
]
|
||||||
|
cert = [n for n in notes if n.startswith("USB not certified —") or n.startswith("USB certified —")]
|
||||||
|
assert len(cert) == 1
|
||||||
|
assert cert[0].startswith("USB not certified — because Z cannot be calculated")
|
||||||
|
assert "90" in cert[0]
|
||||||
|
# No Italian warning wall on missing fields.
|
||||||
|
j2 = [f for f in findings if f.designator == "J2" and (f.rule_id or "").startswith("PE-PRT-L")]
|
||||||
|
assert not any("mancanza" in (f.finding or "") for f in j2)
|
||||||
|
assert not any(f.rule_id == "PE-PRT-L1-001" for f in j2)
|
||||||
|
assert not any(f.rule_id == "PE-PRT-L3-001" for f in j2)
|
||||||
|
# At most one L2 cert finding for the pair (optional L0 FAIL allowed separately).
|
||||||
|
l2 = [f for f in j2 if f.rule_id == "PE-PRT-L2-002"]
|
||||||
|
assert len(l2) <= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_usb_calculated_z_inside_cite_is_certified(monkeypatch):
|
||||||
|
graph = _usb_c_graph()
|
||||||
|
layout = LayoutGraph(
|
||||||
|
segments=[
|
||||||
|
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="USB_D+"),
|
||||||
|
LayoutSegment(start=(0, 0.15), end=(20, 0.15), width=0.2, layer="F.Cu", net="USB_D-"),
|
||||||
|
],
|
||||||
|
stackup=_stack(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.periscopex.protocol_l2.usb_pair_z_ohm",
|
||||||
|
lambda layout, nets, impedance_nets: (90.0, ""),
|
||||||
|
)
|
||||||
|
insts = recognize_physical_buses(graph)
|
||||||
|
_by, findings = certify_l2(graph, insts, layout=layout)
|
||||||
|
lines = [f.finding for f in findings if (f.finding or "").startswith("USB certified —")]
|
||||||
|
assert len(lines) == 1
|
||||||
|
assert "90" in lines[0]
|
||||||
|
assert "not invented" not in lines[0].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_usb_pair_z_reports_missing_geometry():
|
||||||
|
z, missing = usb_pair_z_ohm(None, ("USB_D+", "USB_D-"), None)
|
||||||
|
assert z is None
|
||||||
|
assert "stackup" in missing or "geometry" in missing
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""not_reviewed count matches list; pcb_review error is not a blanket label."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from backend.periscopex.models import Component, ComponentConstraints, ComponentType, DesignGraph, Pin
|
||||||
|
from backend.periscopex.pcb_checks import merge_schema_pcb_reports
|
||||||
|
from backend.periscopex.review_parse import ReviewResult
|
||||||
|
from backend.services.pcb_validation import review_pcb_ics
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_expands_joined_designators_and_matches_count():
|
||||||
|
schema = {
|
||||||
|
"findings": [],
|
||||||
|
"coverage": {"U1": ["power"]},
|
||||||
|
"not_reviewed": [
|
||||||
|
{"designator": "U2,U3,U4", "reason": "no datasheet PDF"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
pcb = {
|
||||||
|
"findings": [],
|
||||||
|
"coverage": {"U5": ["layout"]},
|
||||||
|
"not_reviewed": [
|
||||||
|
{"designator": "U6", "reason": "no library extraction — run schematic review first"},
|
||||||
|
{"designator": "U1", "reason": "pcb_review error"}, # reviewed → drop
|
||||||
|
{"designator": "U7", "reason": "pcb_review error"}, # → review_errors
|
||||||
|
],
|
||||||
|
"review_errors": {},
|
||||||
|
}
|
||||||
|
merged = merge_schema_pcb_reports(schema, pcb)
|
||||||
|
nr = merged["not_reviewed"]
|
||||||
|
refs = [r["designator"] for r in nr]
|
||||||
|
assert refs == ["U2", "U3", "U4", "U6"]
|
||||||
|
assert len(nr) == len(refs)
|
||||||
|
assert "U1" not in refs # covered
|
||||||
|
assert "U7" not in refs
|
||||||
|
assert merged["review_errors"]["U7"] == "pcb_review error"
|
||||||
|
assert "pcb_review error" not in {r["reason"] for r in nr}
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_does_not_list_reviewed_as_not_reviewed():
|
||||||
|
schema = {
|
||||||
|
"findings": [],
|
||||||
|
"coverage": {"U10": ["abs_max"], "U11": ["pinout"]},
|
||||||
|
"not_reviewed": [{"designator": "U10", "reason": "no datasheet PDF"}],
|
||||||
|
}
|
||||||
|
pcb = {
|
||||||
|
"findings": [],
|
||||||
|
"coverage": {"U11": ["layout"]},
|
||||||
|
"not_reviewed": [{"designator": "U11", "reason": "pcb_review error"}],
|
||||||
|
}
|
||||||
|
merged = merge_schema_pcb_reports(schema, pcb)
|
||||||
|
assert merged["not_reviewed"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_pcb_review_exception_goes_to_review_errors_not_not_reviewed():
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"U1": Component(
|
||||||
|
reference="U1", value="LAN8720A", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="LAN8720A",
|
||||||
|
pins={"1": "TXEN"},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={},
|
||||||
|
)
|
||||||
|
cons = ComponentConstraints(
|
||||||
|
mpn="LAN8720A",
|
||||||
|
pintable=[Pin(number="1", name="TXEN")],
|
||||||
|
absolute_maximum_ratings=[],
|
||||||
|
rules=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _boom(*_a, **_k):
|
||||||
|
raise RuntimeError("model timeout")
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
with patch(
|
||||||
|
"backend.services.pcb_validation.review_ic_async",
|
||||||
|
new=AsyncMock(side_effect=_boom),
|
||||||
|
):
|
||||||
|
return await review_pcb_ics(
|
||||||
|
graph, {"LAN8720A": cons}, None, None, None, Path("/tmp"),
|
||||||
|
)
|
||||||
|
|
||||||
|
findings, coverage, skipped, errors = asyncio.run(_run())
|
||||||
|
assert findings == []
|
||||||
|
assert coverage == {}
|
||||||
|
assert skipped == []
|
||||||
|
assert "U1" in errors
|
||||||
|
assert "RuntimeError" in errors["U1"]
|
||||||
|
assert "pcb_review error" not in errors["U1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_pcb_review_is_covered_not_skipped():
|
||||||
|
graph = DesignGraph(
|
||||||
|
components={
|
||||||
|
"U1": Component(
|
||||||
|
reference="U1", value="X", footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn="HASPINTABLE",
|
||||||
|
pins={"1": "GND"},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
nets={},
|
||||||
|
)
|
||||||
|
cons = ComponentConstraints(
|
||||||
|
mpn="HASPINTABLE",
|
||||||
|
pintable=[Pin(number="1", name="GND")],
|
||||||
|
absolute_maximum_ratings=[],
|
||||||
|
rules=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _ok(*_a, **_k):
|
||||||
|
return ReviewResult([], []), {}
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
with patch(
|
||||||
|
"backend.services.pcb_validation.review_ic_async",
|
||||||
|
new=AsyncMock(side_effect=_ok),
|
||||||
|
):
|
||||||
|
return await review_pcb_ics(
|
||||||
|
graph, {"HASPINTABLE": cons}, None, None, None, Path("/tmp"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_f, coverage, skipped, errors = asyncio.run(_run())
|
||||||
|
assert coverage == {"U1": ["layout"]}
|
||||||
|
assert skipped == []
|
||||||
|
assert errors == {}
|
||||||
@@ -619,8 +619,9 @@ def test_pcb_ai_skips_without_library_extraction():
|
|||||||
graph, {}, None, None, None, Path("/tmp"),
|
graph, {}, None, None, None, Path("/tmp"),
|
||||||
)
|
)
|
||||||
|
|
||||||
_f, _c, skipped = asyncio.run(_run())
|
_f, _c, skipped, errors = asyncio.run(_run())
|
||||||
assert skipped
|
assert skipped
|
||||||
|
assert errors == {}
|
||||||
assert "library extraction" in skipped[0]["reason"]
|
assert "library extraction" in skipped[0]["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -155,8 +155,7 @@ def test_usb_no_layout_is_visible_skip_not_pass():
|
|||||||
assert rows
|
assert rows
|
||||||
assert all(r.result != "PASS" or r.check == "never" for r in 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(r.result == "UNKNOWN" for r in rows)
|
||||||
assert any(f.rule_id == "PE-PRT-L1-001" for f in findings)
|
assert not 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():
|
def test_axi_internal_l1_not_applicable():
|
||||||
|
|||||||
@@ -109,21 +109,25 @@ def test_usb_z_missing_source_never_invents_90():
|
|||||||
rows, findings, _ = _l2_for(_usb_connected(), "usb2", impedance_nets=zrep)
|
rows, findings, _ = _l2_for(_usb_connected(), "usb2", impedance_nets=zrep)
|
||||||
z = [r for r in rows if r.check == "differential_impedance"]
|
z = [r for r in rows if r.check == "differential_impedance"]
|
||||||
assert z
|
assert z
|
||||||
assert z[0].result in {"MISSING_SOURCE", "UNKNOWN"}
|
assert z[0].result == "FAIL"
|
||||||
assert z[0].result != "PASS"
|
assert z[0].limit_ohm == 90.0
|
||||||
blob = json.dumps(z[0].model_dump())
|
assert z[0].measured_ohm is None
|
||||||
assert "90" not in blob
|
line = z[0].notes
|
||||||
assert any(f.rule_id == "PE-PRT-L2-001" for f in findings)
|
assert line.startswith("USB not certified — because Z cannot be calculated")
|
||||||
assert any("non certificata a L2" in (f.finding or "") for f in findings)
|
assert "90" in line
|
||||||
|
assert "not invented" not in line.lower()
|
||||||
|
assert not any(f.rule_id == "PE-PRT-L2-001" for f in findings)
|
||||||
|
assert any(f.finding == line for f in findings)
|
||||||
|
|
||||||
|
|
||||||
def test_usb_z_skip_when_no_stackup_measurement():
|
def test_usb_z_skip_when_no_stackup_measurement():
|
||||||
rows, findings, _ = _l2_for(_usb_connected(), "usb2")
|
rows, findings, _ = _l2_for(_usb_connected(), "usb2")
|
||||||
z = [r for r in rows if r.check == "differential_impedance"]
|
z = [r for r in rows if r.check == "differential_impedance"]
|
||||||
assert z
|
assert z
|
||||||
assert z[0].result in {"MISSING_SOURCE", "UNKNOWN"}
|
assert z[0].result == "FAIL"
|
||||||
assert z[0].measured_ohm is None
|
assert z[0].measured_ohm is None
|
||||||
assert any("mancanza di Z" in (f.finding or "") for f in findings)
|
assert z[0].limit_ohm == 90.0
|
||||||
|
assert any(f.finding.startswith("USB not certified —") for f in findings)
|
||||||
|
|
||||||
|
|
||||||
def test_cited_pack_z_pass_and_fail():
|
def test_cited_pack_z_pass_and_fail():
|
||||||
@@ -220,7 +224,7 @@ def test_ddr_impedance_phy_dependent_not_invented_ohm():
|
|||||||
blob = json.dumps(z[0].model_dump())
|
blob = json.dumps(z[0].model_dump())
|
||||||
assert "40" not in blob
|
assert "40" not in blob
|
||||||
assert "90" not in blob
|
assert "90" not in blob
|
||||||
assert any(f.rule_id == "PE-PRT-L2-001" for f in findings)
|
assert not any(f.rule_id == "PE-PRT-L2-001" for f in findings)
|
||||||
|
|
||||||
|
|
||||||
def test_length_is_not_delay_timing_unknown():
|
def test_length_is_not_delay_timing_unknown():
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ def test_remaining_margin_is_total_minus_used():
|
|||||||
|
|
||||||
|
|
||||||
def test_usb_hdmi_pcie_skip_without_channel_data():
|
def test_usb_hdmi_pcie_skip_without_channel_data():
|
||||||
|
"""No channel FACT → no L3 warning wall (silence, not Italian mancanza)."""
|
||||||
for graph, token in ((_usb_connected(), "usb"), (_hdmi_graph(), "hdmi"), (_pcie_graph(), "pcie")):
|
for graph, token in ((_usb_connected(), "usb"), (_hdmi_graph(), "hdmi"), (_pcie_graph(), "pcie")):
|
||||||
sec, findings = protocol_exam(graph)
|
sec, findings = protocol_exam(graph)
|
||||||
assert sec.max_level_reached == "L3"
|
assert sec.max_level_reached == "L3"
|
||||||
@@ -183,17 +184,9 @@ def test_usb_hdmi_pcie_skip_without_channel_data():
|
|||||||
or token in str(r.get("physical_interface_id")).lower()
|
or token in str(r.get("physical_interface_id")).lower()
|
||||||
)
|
)
|
||||||
l3 = row["l3_checks"]
|
l3 = row["l3_checks"]
|
||||||
assert l3
|
assert l3 == []
|
||||||
names = {c["check"] for c in l3}
|
assert not any(f.rule_id == "PE-PRT-L3-001" for f in findings)
|
||||||
assert {"timing_budget", "insertion_loss", "return_loss", "crosstalk", "channel"} <= names
|
assert not any("mancanza" in (f.finding or "") for f in findings if f.source == "protocol_l3")
|
||||||
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():
|
def test_axi_l3_not_applicable_not_fail():
|
||||||
@@ -269,13 +262,8 @@ def test_raw_touchstone_without_fact_is_skip_not_invented():
|
|||||||
inst = _inst()
|
inst = _inst()
|
||||||
data = {inst.instance_id: {"sparam_file": "lane.s4p", "touchstone": "lane.s4p"}}
|
data = {inst.instance_id: {"sparam_file": "lane.s4p", "touchstone": "lane.s4p"}}
|
||||||
rows = certify_instance_l3(inst, iface, channel_data=data)
|
rows = certify_instance_l3(inst, iface, channel_data=data)
|
||||||
assert all(r.result != "PASS" for r in rows)
|
# Raw S-param file is not reduced channel FACT — omit L3 (no warning wall).
|
||||||
notes = " ".join(r.notes for r in rows)
|
assert 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():
|
def test_openems_method_is_explicit_skip():
|
||||||
@@ -289,8 +277,8 @@ def test_openems_method_is_explicit_skip():
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
rows = certify_instance_l3(inst, iface, channel_data=data)
|
rows = certify_instance_l3(inst, iface, channel_data=data)
|
||||||
notes = " ".join(r.notes for r in rows)
|
# OpenEMS/FEM is out of product — omit L3 rather than invent S-parameters.
|
||||||
assert "non certificata a L3" in notes
|
assert rows == []
|
||||||
assert all(r.result != "PASS" for r in rows)
|
assert all(r.result != "PASS" for r in rows)
|
||||||
|
|
||||||
|
|
||||||
@@ -298,19 +286,16 @@ def test_no_sparam_invention_on_empty_channel_data():
|
|||||||
insts = recognize_physical_buses(_usb_connected())
|
insts = recognize_physical_buses(_usb_connected())
|
||||||
by, findings = certify_l3(insts, channel_data=None)
|
by, findings = certify_l3(insts, channel_data=None)
|
||||||
rows = [r for lst in by.values() for r in lst]
|
rows = [r for lst in by.values() for r in lst]
|
||||||
blob = json.dumps([r.model_dump() for r in rows])
|
assert rows == []
|
||||||
assert "s21" not in blob.lower()
|
assert not any(f.rule_id == "PE-PRT-L3-001" for f in findings)
|
||||||
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():
|
def test_protocol_exam_attaches_l3_and_does_not_call_l0_electrical():
|
||||||
sec, _ = protocol_exam(_usb_connected())
|
sec, _ = protocol_exam(_usb_connected())
|
||||||
row = sec.recognized_instances[0]
|
row = sec.recognized_instances[0]
|
||||||
assert row.get("l3_checks")
|
# L3 omitted without channel FACT; L0/L1/L2 still attached.
|
||||||
assert all(c.get("level") == "L3" for c in row["l3_checks"])
|
assert row.get("l3_checks") == []
|
||||||
notes = " ".join(c.get("notes") or "" for c in row["l3_checks"])
|
assert row.get("l0_checks")
|
||||||
assert "L0/L1 are not electrical" in notes
|
assert row.get("l2_checks")
|
||||||
l0notes = json.dumps(row.get("l0_checks") or [])
|
l0notes = json.dumps(row.get("l0_checks") or [])
|
||||||
assert "ohm" not in l0notes.lower() or "not" in l0notes.lower()
|
assert "ohm" not in l0notes.lower() or "not" in l0notes.lower()
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ def test_result_rank_fail_before_warning():
|
|||||||
assert instance_worst_result(["UNKNOWN", "FAIL", "PASS"]) == "FAIL"
|
assert instance_worst_result(["UNKNOWN", "FAIL", "PASS"]) == "FAIL"
|
||||||
|
|
||||||
|
|
||||||
def test_usb2_pair_l0_pass_l2_z_unknown_no_invented_ohm():
|
def test_usb2_pair_l0_pass_l2_z_cert_line():
|
||||||
sec, _ = protocol_exam(_usb_connected())
|
sec, _ = protocol_exam(_usb_connected())
|
||||||
assert sec.max_level_reached == "L3"
|
assert sec.max_level_reached == "L3"
|
||||||
assert sec.macrophase == "M9"
|
assert sec.macrophase == "M9"
|
||||||
@@ -106,20 +106,19 @@ def test_usb2_pair_l0_pass_l2_z_unknown_no_invented_ohm():
|
|||||||
assert l0_topo and l0_topo[0]["result"] == "PASS"
|
assert l0_topo and l0_topo[0]["result"] == "PASS"
|
||||||
z = [c for c in checks if c["check"] == "differential_impedance"]
|
z = [c for c in checks if c["check"] == "differential_impedance"]
|
||||||
assert z
|
assert z
|
||||||
assert z[0]["result"] in {"MISSING_SOURCE", "UNKNOWN"}
|
assert z[0]["result"] == "FAIL"
|
||||||
assert z[0]["skip_visible"] is True
|
assert z[0]["limit"] == 90.0 or (z[0].get("notes") or "").find("90") >= 0
|
||||||
assert z[0]["measured"] is None
|
notes = z[0].get("notes") or ""
|
||||||
assert z[0]["limit"] is None
|
assert notes.startswith("USB not certified —")
|
||||||
blob = json.dumps(z[0])
|
assert "90" in notes
|
||||||
assert "90" not in blob
|
assert "not invented" not in notes.lower()
|
||||||
chain = z[0]["chain"]
|
chain = z[0]["chain"]
|
||||||
assert chain["physical_interface_id"]
|
assert chain["physical_interface_id"]
|
||||||
assert chain["logical_protocol_id"]
|
assert chain["logical_protocol_id"]
|
||||||
assert chain["physical_interface_id"] != chain["logical_protocol_id"]
|
assert chain["physical_interface_id"] != chain["logical_protocol_id"]
|
||||||
|
# No L3 warning wall without channel FACT.
|
||||||
l3 = [c for c in checks if c["level"] == "L3"]
|
l3 = [c for c in checks if c["level"] == "L3"]
|
||||||
assert l3 and l3[0]["skip_visible"]
|
assert l3 == []
|
||||||
assert l3[0]["result"] != "PASS"
|
|
||||||
assert "OpenEMS" in l3[0]["notes"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_fail_listed_before_warning_in_report_checks():
|
def test_fail_listed_before_warning_in_report_checks():
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""RMII PHY–MAC is not RJ45 100 Ω; one English certification line."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend.periscopex.af_trace_check import check_af_traces
|
||||||
|
from backend.periscopex.models import (
|
||||||
|
Component,
|
||||||
|
ComponentType,
|
||||||
|
DesignGraph,
|
||||||
|
LayoutDielectric,
|
||||||
|
LayoutGraph,
|
||||||
|
LayoutSegment,
|
||||||
|
LayoutStackup,
|
||||||
|
Net,
|
||||||
|
NetType,
|
||||||
|
PinConnection,
|
||||||
|
)
|
||||||
|
from backend.periscopex.si_check import bus_class, phy_mac_kind, single_ended_eth_mac
|
||||||
|
|
||||||
|
|
||||||
|
def _ic(ref: str, mpn: str, pins: dict[str, str]) -> Component:
|
||||||
|
return Component(
|
||||||
|
reference=ref, value=mpn, footprint="",
|
||||||
|
component_type=ComponentType.IC, mpn=mpn, 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 _rmii_graph() -> DesignGraph:
|
||||||
|
return DesignGraph(
|
||||||
|
components={
|
||||||
|
"U19": _ic("U19", "LAN8720A", {
|
||||||
|
"1": "ETH_RXD0", "2": "ETH_RXD1",
|
||||||
|
"3": "ETH_TXD0", "4": "ETH_TXD1", "5": "ETH_TXEN",
|
||||||
|
}),
|
||||||
|
"U3": _ic("U3", "MCU", {
|
||||||
|
"10": "ETH_RXD0", "11": "ETH_RXD1",
|
||||||
|
"12": "ETH_TXD0", "13": "ETH_TXD1", "14": "ETH_TXEN",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
nets={
|
||||||
|
"ETH_RXD0": _net("ETH_RXD0", ("U19", "1"), ("U3", "10")),
|
||||||
|
"ETH_RXD1": _net("ETH_RXD1", ("U19", "2"), ("U3", "11")),
|
||||||
|
"ETH_TXD0": _net("ETH_TXD0", ("U19", "3"), ("U3", "12")),
|
||||||
|
"ETH_TXD1": _net("ETH_TXD1", ("U19", "4"), ("U3", "13")),
|
||||||
|
"ETH_TXEN": _net("ETH_TXEN", ("U19", "5"), ("U3", "14")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _layout() -> LayoutGraph:
|
||||||
|
segs = []
|
||||||
|
y = 0.0
|
||||||
|
for net in ("ETH_RXD0", "ETH_RXD1", "ETH_TXD0", "ETH_TXD1", "ETH_TXEN"):
|
||||||
|
segs.append(LayoutSegment(
|
||||||
|
start=(0, y), end=(40, y), width=0.15, layer="F.Cu", net=net,
|
||||||
|
))
|
||||||
|
y += 0.5
|
||||||
|
return LayoutGraph(
|
||||||
|
segments=segs,
|
||||||
|
stackup=LayoutStackup(
|
||||||
|
copper_layers=["F.Cu", "B.Cu"],
|
||||||
|
dielectrics=[LayoutDielectric(name="core", er=4.5, height_mm=0.15)],
|
||||||
|
copper_thickness_mm=0.035,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_eth_rxd_is_rmii_not_mdi():
|
||||||
|
for n in ("ETH_RXD0", "ETH_TXD0", "ETH_TXEN"):
|
||||||
|
assert single_ended_eth_mac(n)
|
||||||
|
assert bus_class(n) == "rmii"
|
||||||
|
assert phy_mac_kind(n) == "rmii"
|
||||||
|
assert bus_class(n) != "eth_mdi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rmii_one_certified_line_no_italian_tr_f():
|
||||||
|
findings = check_af_traces(_rmii_graph(), {}, _layout())
|
||||||
|
rmii = [f for f in findings if (f.finding or "").startswith("RMII certified —")]
|
||||||
|
assert len(rmii) == 1
|
||||||
|
blob = rmii[0].finding
|
||||||
|
assert "ETH_RXD" in blob or "ETH_TXD" in blob or "ETH_TXEN" in blob
|
||||||
|
assert "not an RJ45/MDI" in blob or "not an RJ45" in blob
|
||||||
|
assert "mancanza" not in blob
|
||||||
|
assert not any(
|
||||||
|
"mancanza di tr" in (f.finding or "") or "mancanza di f" in (f.finding or "")
|
||||||
|
for f in findings
|
||||||
|
)
|
||||||
|
assert not any(
|
||||||
|
f.rule_id == "PE-AF-002" and any(
|
||||||
|
single_ended_eth_mac(n) for n in ((f.net,) if f.net else ())
|
||||||
|
)
|
||||||
|
for f in findings
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rmii_not_given_100_ohm_mdi_finding():
|
||||||
|
findings = check_af_traces(_rmii_graph(), {}, _layout())
|
||||||
|
assert not any("25.4.9" in (f.finding or "") for f in findings)
|
||||||
|
assert not any(
|
||||||
|
f.finding.startswith("Ethernet certified") or f.finding.startswith("Ethernet not certified")
|
||||||
|
for f in findings
|
||||||
|
if f.net and single_ended_eth_mac(f.net)
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user